summaryrefslogtreecommitdiff
path: root/src/ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/ts')
-rw-r--r--src/ts/editor.ts279
-rw-r--r--src/ts/script.ts113
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();
}