summaryrefslogtreecommitdiff
path: root/src/ts/editor.ts
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2026-01-04 20:58:28 -0800
committerElizabeth Hunt <me@liz.coffee>2026-01-04 20:58:28 -0800
commitacfea7c9e0129168205c374783e7036e5018c9a5 (patch)
treec94ca7170552ef59425203c9150f0523cdd514dd /src/ts/editor.ts
parent4dd5994b27bb32d93efacd6d3a42b130f81425df (diff)
downloadadelie-acfea7c9e0129168205c374783e7036e5018c9a5.tar.gz
adelie-acfea7c9e0129168205c374783e7036e5018c9a5.zip
Claude: first attempt at code editor
Diffstat (limited to 'src/ts/editor.ts')
-rw-r--r--src/ts/editor.ts279
1 files changed, 279 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)]
+ ),
+ });
+}