diff options
Diffstat (limited to 'src/ts')
| -rw-r--r-- | src/ts/editor.ts | 279 | ||||
| -rw-r--r-- | src/ts/script.ts | 113 |
2 files changed, 392 insertions, 0 deletions
diff --git a/src/ts/editor.ts b/src/ts/editor.ts new file mode 100644 index 0000000..2d2a3da --- /dev/null +++ b/src/ts/editor.ts @@ -0,0 +1,279 @@ +import { EditorView, minimalSetup } from 'codemirror'; +import { javascript } from '@codemirror/lang-javascript'; +import { css } from '@codemirror/lang-css'; +import { html } from '@codemirror/lang-html'; +import { EditorState, Compartment } from '@codemirror/state'; +import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; +import { vim } from '@replit/codemirror-vim'; +import { ViewPlugin, Decoration, DecorationSet, gutter, GutterMarker } from '@codemirror/view'; +import { RangeSetBuilder } from '@codemirror/state'; +import { closeBrackets } from '@codemirror/autocomplete'; + +const themeConfig = new Compartment(); + +// Whitespace visualization - only show in selected text +function highlightWhitespace() { + const whitespaceDecoration = Decoration.mark({ + class: 'cm-whitespace', + attributes: { 'aria-hidden': 'true' }, + }); + + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: any) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView) { + const builder = new RangeSetBuilder<Decoration>(); + const selection = view.state.selection.main; + + // Only show whitespace in selected ranges + if (!selection.empty) { + const text = view.state.doc.sliceString(selection.from, selection.to); + let pos = selection.from; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === ' ' || char === '\t') { + builder.add(pos + i, pos + i + 1, whitespaceDecoration); + } + } + } + + return builder.finish(); + } + }, + { + decorations: (v) => v.decorations, + } + ); +} + +// Relative line numbers that update on cursor movement +class RelativeLineNumberMarker extends GutterMarker { + constructor(private lineNo: number, private currentLine: number) { + super(); + } + + eq(other: RelativeLineNumberMarker) { + return this.lineNo === other.lineNo && this.currentLine === other.currentLine; + } + + toDOM() { + const relative = Math.abs(this.lineNo - this.currentLine); + const display = this.lineNo === this.currentLine ? String(this.lineNo) : String(relative); + return document.createTextNode(display); + } +} + +const relativeLineNumberGutter = ViewPlugin.fromClass( + class { + constructor(private view: EditorView) {} + + update(update: any) { + // Force update on selection change + if (update.selectionSet || update.docChanged || update.viewportChanged) { + this.view.requestMeasure(); + } + } + } +); + +function relativeLineNumbers() { + return [ + relativeLineNumberGutter, + gutter({ + class: 'cm-lineNumbers', + lineMarker(view, line) { + const currentLine = view.state.doc.lineAt(view.state.selection.main.head).number; + const lineNo = view.state.doc.lineAt(line.from).number; + return new RelativeLineNumberMarker(lineNo, currentLine); + }, + lineMarkerChange: (update) => update.selectionSet, + initialSpacer: () => new RelativeLineNumberMarker(1, 1), + }), + ]; +} + +// Match Prism.js colors exactly +const lightHighlighting = HighlightStyle.define([ + { tag: tags.comment, color: '#6a5550', fontStyle: 'italic' }, + { tag: tags.punctuation, color: '#2a1f1d' }, + { tag: [tags.propertyName, tags.attributeName], color: '#8f78d6' }, + { tag: tags.tagName, color: '#8f78d6' }, + { tag: tags.bool, color: '#8f78d6' }, + { tag: tags.number, color: '#8f78d6' }, + { tag: tags.literal, color: '#8f78d6' }, + { tag: [tags.string, tags.character, tags.special(tags.string)], color: '#7a5245' }, + { tag: tags.operator, color: '#c84d86' }, + { tag: tags.keyword, color: '#c84d86', fontWeight: '700' }, + { tag: tags.function(tags.variableName), color: '#e56aa6', fontWeight: '700' }, + { tag: tags.className, color: '#e56aa6', fontWeight: '700' }, + { tag: tags.variableName, color: '#b88a68' }, +]); + +// Match Prism.js dark theme colors +const darkHighlighting = HighlightStyle.define([ + { tag: tags.comment, color: '#cbb8b1', fontStyle: 'italic' }, + { tag: tags.punctuation, color: '#f9efea' }, + { tag: [tags.propertyName, tags.attributeName], color: '#d7c8ff' }, + { tag: tags.tagName, color: '#d7c8ff' }, + { tag: tags.bool, color: '#d7c8ff' }, + { tag: tags.number, color: '#d7c8ff' }, + { tag: tags.literal, color: '#d7c8ff' }, + { tag: [tags.string, tags.character, tags.special(tags.string)], color: '#c9936a' }, + { tag: tags.operator, color: '#ff97c8' }, + { tag: tags.keyword, color: '#ff97c8', fontWeight: '700' }, + { tag: tags.function(tags.variableName), color: '#f06aa6', fontWeight: '700' }, + { tag: tags.className, color: '#f06aa6', fontWeight: '700' }, + { tag: tags.variableName, color: '#a57353' }, +]); + +const lightTheme = EditorView.theme({ + '&': { + backgroundColor: 'var(--surface)', + color: 'var(--text)', + }, + '.cm-content': { + caretColor: 'var(--primary)', + fontFamily: 'var(--font-mono)', + fontSize: '0.875rem', + paddingLeft: '0', + }, + '.cm-gutters': { + backgroundColor: 'var(--bg)', + color: 'var(--muted)', + border: 'none', + borderRight: '1px solid var(--border)', + }, + '.cm-activeLineGutter': { + backgroundColor: 'var(--primary-light)', + color: 'var(--text)', + }, + '.cm-activeLine': { + backgroundColor: 'rgba(229, 106, 166, 0.08)', + }, + '.cm-selectionBackground': { + backgroundColor: 'rgba(229, 106, 166, 0.5) !important', + }, + '&.cm-focused .cm-selectionBackground': { + backgroundColor: 'rgba(229, 106, 166, 0.5) !important', + }, + '&.cm-focused .cm-selectionMatch': { + backgroundColor: 'rgba(142, 120, 214, 0.3) !important', + }, + '.cm-cursor': { + borderLeftColor: 'var(--primary)', + }, + '.cm-whitespace::before': { + content: '"·"', + position: 'absolute', + opacity: '0.3', + color: 'var(--muted)', + pointerEvents: 'none', + }, +}); + +const darkTheme = EditorView.theme({ + '&': { + backgroundColor: 'var(--surface)', + color: 'var(--text)', + }, + '.cm-content': { + caretColor: 'var(--primary)', + fontFamily: 'var(--font-mono)', + fontSize: '0.875rem', + paddingLeft: '0', + }, + '.cm-gutters': { + backgroundColor: 'var(--bg)', + color: 'var(--muted)', + border: 'none', + borderRight: '1px solid var(--border)', + }, + '.cm-activeLineGutter': { + backgroundColor: 'rgba(229, 106, 166, 0.2)', + color: 'var(--text)', + }, + '.cm-activeLine': { + backgroundColor: 'rgba(240, 106, 166, 0.08)', + }, + '.cm-selectionBackground': { + backgroundColor: 'rgba(240, 106, 166, 0.45) !important', + }, + '&.cm-focused .cm-selectionBackground': { + backgroundColor: 'rgba(240, 106, 166, 0.45) !important', + }, + '&.cm-focused .cm-selectionMatch': { + backgroundColor: 'rgba(182, 156, 255, 0.35) !important', + }, + '.cm-cursor': { + borderLeftColor: 'var(--primary)', + }, + '.cm-whitespace::before': { + content: '"·"', + position: 'absolute', + opacity: '0.25', + color: 'var(--muted)', + pointerEvents: 'none', + }, +}, { dark: true }); + +interface EditorOptions { + parent: HTMLElement; + language?: 'javascript' | 'css' | 'html'; + initialCode?: string; + theme?: 'light' | 'dark'; +} + +export function createEditor(options: EditorOptions): EditorView { + const { parent, language = 'javascript', initialCode = '', theme = 'light' } = options; + + const languageExtension = { + javascript, + css, + html, + }[language]; + + const state = EditorState.create({ + doc: initialCode, + extensions: [ + minimalSetup, + closeBrackets(), + relativeLineNumbers(), + highlightWhitespace(), + vim(), + languageExtension(), + themeConfig.of(theme === 'dark' + ? [darkTheme, syntaxHighlighting(darkHighlighting)] + : [lightTheme, syntaxHighlighting(lightHighlighting)] + ), + ], + }); + + const view = new EditorView({ + state, + parent, + }); + + return view; +} + +export function setEditorTheme(view: EditorView, theme: 'light' | 'dark'): void { + view.dispatch({ + effects: themeConfig.reconfigure(theme === 'dark' + ? [darkTheme, syntaxHighlighting(darkHighlighting)] + : [lightTheme, syntaxHighlighting(lightHighlighting)] + ), + }); +} diff --git a/src/ts/script.ts b/src/ts/script.ts index 79a9a75..e8502c8 100644 --- a/src/ts/script.ts +++ b/src/ts/script.ts @@ -5,10 +5,14 @@ import 'prismjs/components/prism-markup'; import { initOneko } from './oneko'; type Theme = 'light' | 'dark'; +type EditorView = any; const THEME_STORAGE_KEY = 'theme'; const DARK_THEME_ATTRIBUTE = 'data-theme'; +let editorInstance: EditorView | null = null; +let editorModule: typeof import('./editor') | null = null; + function detectAssetBase(): string { const currentScript = document.currentScript as HTMLScriptElement | null; const scriptSrc = @@ -58,6 +62,10 @@ function initThemeToggle(): void { const nextTheme: Theme = toggleButton.checked ? 'dark' : 'light'; applyTheme(nextTheme); sessionStorage.setItem(THEME_STORAGE_KEY, nextTheme); + + if (editorInstance && editorModule) { + editorModule.setEditorTheme(editorInstance, nextTheme); + } }); } @@ -142,11 +150,116 @@ function initFileInputs(): void { }); } +async function loadEditor() { + if (!editorModule) { + editorModule = await import('./editor'); + } + return editorModule; +} + +function initCodeEditor(): void { + const editorContainer = document.getElementById('code-editor'); + const languageSelect = document.getElementById('language-select'); + + if (!editorContainer || !(languageSelect instanceof HTMLSelectElement)) return; + + const initialCode = { + javascript: `// Welcome to the live code editor! +function greet(name) { + return \`Hello, \${name}! 👋\`; +} + +console.log(greet('World'));`, + css: `/* Try editing this CSS! */ +.retro-box { + background: var(--primary); + color: var(--text); + padding: var(--space-md); + border-radius: var(--border-radius); + box-shadow: var(--shadow-box); +}`, + html: `<!-- Try editing this HTML! --> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Retro Page</title> +</head> +<body> + <h1>Hello, World!</h1> + <p>Welcome to the retro web.</p> +</body> +</html>`, + }; + + let editorLoaded = false; + + const currentTheme = getStoredTheme() ?? getSystemTheme(); + + async function createEditorInstance() { + if (editorLoaded) return; + editorLoaded = true; + + const { createEditor } = await loadEditor(); + const language = languageSelect.value as 'javascript' | 'css' | 'html'; + + editorInstance = createEditor({ + parent: editorContainer, + language, + initialCode: initialCode[language], + theme: currentTheme, + }); + } + + // Lazy load on intersection (when editor scrolls into view) + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + createEditorInstance(); + observer.disconnect(); + } + }); + }, + { rootMargin: '100px' } + ); + + observer.observe(editorContainer); + + // Also load on language change + languageSelect.addEventListener('change', async () => { + const language = languageSelect.value as 'javascript' | 'css' | 'html'; + + if (!editorModule) { + await createEditorInstance(); + return; + } + + if (editorInstance && editorContainer) { + editorContainer.innerHTML = ''; + + const { createEditor } = editorModule; + editorInstance = createEditor({ + parent: editorContainer, + language, + initialCode: initialCode[language], + theme: currentTheme, + }); + } + }); +} + function init(): void { setAssetBase(); initThemeToggle(); initFairyDust(); initFileInputs(); + + // Only initialize code editor if the container exists + if (document.getElementById('code-editor')) { + initCodeEditor(); + } + Prism.highlightAll(); initOneko(); } |
