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(); 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)] ), }); }