| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useRef, useImperativeHandle, forwardRef } from "react"; |
| 4 | import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from "@codemirror/view"; |
| 5 | import { EditorState } from "@codemirror/state"; |
| 6 | import { defaultKeymap, indentWithTab, history, historyKeymap } from "@codemirror/commands"; |
| 7 | import { StreamLanguage, HighlightStyle, syntaxHighlighting, bracketMatching } from "@codemirror/language"; |
| 8 | import { closeBrackets } from "@codemirror/autocomplete"; |
| 9 | import { tags } from "@lezer/highlight"; |
| 10 | |
| 11 | // Mermaid stream parser — matches the one from the vanilla SPA |
| 12 | const mermaidParser = { |
| 13 | startState() { return { inBlock: false, blockType: null as string | null }; }, |
| 14 | token(stream: any, _state: any) { |
| 15 | if (stream.match(/^%%/)) { stream.skipToEnd(); return "lineComment"; } |
| 16 | if (stream.match(/^"[^"]*"/)) return "string"; |
| 17 | if (stream.sol() && stream.match(/^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/)) |
| 18 | return "keyword"; |
| 19 | if (stream.match(/^(subgraph|end|direction|class|state|section|participant|actor|loop|alt|else|opt|par|critical|break|note|over|of|left|right|title)\b/)) |
| 20 | return "keyword"; |
| 21 | if (stream.match(/^==>/) || stream.match(/^-\.->/) || stream.match(/^\.->/) || stream.match(/^-->/) || stream.match(/^---/) || stream.match(/^-\.-/) || stream.match(/^===/) || stream.match(/^--|>/)) |
| 22 | return "operator"; |
| 23 | if (stream.match(/^\|/)) return "bracket"; |
| 24 | if (stream.match(/^[\[\](){}]/)) return "bracket"; |
| 25 | if (stream.match(/^(int|text|real|bool|datetime|float|string|void)\b/)) return "typeName"; |
| 26 | if (stream.match(/^(PK|FK|UK)\b/)) return "annotation"; |
| 27 | if (stream.match(/^[+#~-](?=[A-Za-z])/)) return "operator"; |
| 28 | if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) return "variableName"; |
| 29 | if (stream.match(/^[0-9]+(\.[0-9]+)?/)) return "number"; |
| 30 | if (stream.match(/^:/)) return "punctuation"; |
| 31 | stream.next(); |
| 32 | return null; |
| 33 | }, |
| 34 | }; |
| 35 | |
| 36 | const mermaidLang = StreamLanguage.define(mermaidParser); |
| 37 | |
| 38 | const groveTheme = EditorView.theme({ |
| 39 | "&": { |
| 40 | fontSize: "12px", |
| 41 | fontFamily: "'JetBrains Mono', Menlo, monospace", |
| 42 | backgroundColor: "var(--bg-inset, #eae6df)", |
| 43 | color: "var(--text-primary, #2c2824)", |
| 44 | flex: "1", |
| 45 | overflow: "hidden", |
| 46 | }, |
| 47 | "&.cm-focused": { outline: "none" }, |
| 48 | ".cm-scroller": { overflow: "auto", fontFamily: "inherit" }, |
| 49 | ".cm-content": { padding: "12px 8px", caretColor: "var(--accent, #4d8a78)" }, |
| 50 | ".cm-gutters": { |
| 51 | backgroundColor: "var(--bg-hover, #e2ddd5)", |
| 52 | color: "var(--text-faint, #a09888)", |
| 53 | border: "none", |
| 54 | borderRight: "1px solid var(--border, #d9d3ca)", |
| 55 | minWidth: "36px", |
| 56 | }, |
| 57 | ".cm-activeLineGutter": { backgroundColor: "var(--border, #d9d3ca)", color: "var(--text-secondary, #4a4540)" }, |
| 58 | ".cm-activeLine": { backgroundColor: "var(--bg-hover, #e2ddd5)20" }, |
| 59 | ".cm-cursor": { borderLeftColor: "var(--accent, #4d8a78)", borderLeftWidth: "2px" }, |
| 60 | ".cm-selectionBackground": { backgroundColor: "var(--accent, #4d8a78)30 !important" }, |
| 61 | }, { dark: false }); |
| 62 | |
| 63 | const groveHighlight = HighlightStyle.define([ |
| 64 | { tag: tags.keyword, color: "#7c4dff", fontWeight: "500" }, |
| 65 | { tag: tags.string, color: "#2d6b56" }, |
| 66 | { tag: tags.lineComment, color: "#a09888", fontStyle: "italic" }, |
| 67 | { tag: tags.operator, color: "#c56200" }, |
| 68 | { tag: tags.bracket, color: "#7a746c" }, |
| 69 | { tag: tags.typeName, color: "#0277bd" }, |
| 70 | { tag: tags.annotation, color: "#c62828", fontWeight: "600" }, |
| 71 | { tag: tags.variableName, color: "#2c2824" }, |
| 72 | { tag: tags.number, color: "#0277bd" }, |
| 73 | { tag: tags.punctuation, color: "#7a746c" }, |
| 74 | ]); |
| 75 | |
| 76 | // ── Mermaid auto-formatter ── |
| 77 | function formatMermaidCode(src: string): string { |
| 78 | const lines = src.split("\n"); |
| 79 | const out: string[] = []; |
| 80 | const INDENT = " "; |
| 81 | let depth = 0; |
| 82 | |
| 83 | const openers = /^(subgraph|state|loop|alt|par|critical|opt)\b/; |
| 84 | const closers = /^end\b/; |
| 85 | const midBlock = /^(else|break)\b/; |
| 86 | const braceOpen = /\{\s*$/; |
| 87 | const braceClose = /^\s*\}/; |
| 88 | const diagramType = /^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/; |
| 89 | |
| 90 | for (let i = 0; i < lines.length; i++) { |
| 91 | const trimmed = lines[i].trim(); |
| 92 | if (trimmed === "") { out.push(""); continue; } |
| 93 | if (trimmed.startsWith("%%")) { out.push(INDENT.repeat(depth) + trimmed); continue; } |
| 94 | if (diagramType.test(trimmed)) { depth = 0; out.push(trimmed); continue; } |
| 95 | if (braceClose.test(trimmed)) { depth = Math.max(0, depth - 1); out.push(INDENT.repeat(depth) + trimmed); continue; } |
| 96 | if (closers.test(trimmed)) { depth = Math.max(0, depth - 1); out.push(INDENT.repeat(depth) + trimmed); continue; } |
| 97 | if (midBlock.test(trimmed)) { out.push(INDENT.repeat(Math.max(0, depth - 1)) + trimmed); continue; } |
| 98 | out.push(INDENT.repeat(depth) + trimmed); |
| 99 | if (openers.test(trimmed) || braceOpen.test(trimmed)) depth++; |
| 100 | } |
| 101 | |
| 102 | return out.join("\n"); |
| 103 | } |
| 104 | |
| 105 | export interface CodeEditorHandle { |
| 106 | changeFontSize: (delta: number) => void; |
| 107 | format: () => void; |
| 108 | jumpToLine: (line: number, col?: number) => void; |
| 109 | } |
| 110 | |
| 111 | interface CodeEditorProps { |
| 112 | code: string; |
| 113 | onChange: (code: string) => void; |
| 114 | onRun: () => void; |
| 115 | } |
| 116 | |
| 117 | export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>( |
| 118 | function CodeEditor({ code, onChange, onRun }, ref) { |
| 119 | const parentRef = useRef<HTMLDivElement>(null); |
| 120 | const viewRef = useRef<EditorView | null>(null); |
| 121 | const fontSizeRef = useRef(12); |
| 122 | const onChangeRef = useRef(onChange); |
| 123 | const onRunRef = useRef(onRun); |
| 124 | onChangeRef.current = onChange; |
| 125 | onRunRef.current = onRun; |
| 126 | |
| 127 | useImperativeHandle(ref, () => ({ |
| 128 | changeFontSize(delta: number) { |
| 129 | fontSizeRef.current = Math.max(8, Math.min(24, fontSizeRef.current + delta)); |
| 130 | const view = viewRef.current; |
| 131 | if (view) { |
| 132 | view.dom.style.fontSize = `${fontSizeRef.current}px`; |
| 133 | view.requestMeasure(); |
| 134 | } |
| 135 | }, |
| 136 | format() { |
| 137 | const view = viewRef.current; |
| 138 | if (!view) return; |
| 139 | const src = view.state.doc.toString(); |
| 140 | const formatted = formatMermaidCode(src); |
| 141 | if (formatted !== src) { |
| 142 | view.dispatch({ |
| 143 | changes: { from: 0, to: src.length, insert: formatted }, |
| 144 | }); |
| 145 | } |
| 146 | }, |
| 147 | jumpToLine(line: number, col?: number) { |
| 148 | const view = viewRef.current; |
| 149 | if (!view) return; |
| 150 | try { |
| 151 | const pos = view.state.doc.line(line); |
| 152 | view.dispatch({ |
| 153 | selection: { anchor: pos.from + (col ? col - 1 : 0) }, |
| 154 | scrollIntoView: true, |
| 155 | }); |
| 156 | view.focus(); |
| 157 | } catch (_) {} |
| 158 | }, |
| 159 | })); |
| 160 | |
| 161 | useEffect(() => { |
| 162 | if (!parentRef.current) return; |
| 163 | |
| 164 | const formatKeymap = keymap.of([ |
| 165 | { |
| 166 | key: "Mod-Enter", |
| 167 | run: () => { onRunRef.current(); return true; }, |
| 168 | }, |
| 169 | { |
| 170 | key: "Shift-Alt-f", |
| 171 | run: (view) => { |
| 172 | const src = view.state.doc.toString(); |
| 173 | const formatted = formatMermaidCode(src); |
| 174 | if (formatted !== src) { |
| 175 | view.dispatch({ changes: { from: 0, to: src.length, insert: formatted } }); |
| 176 | } |
| 177 | return true; |
| 178 | }, |
| 179 | }, |
| 180 | ]); |
| 181 | |
| 182 | const view = new EditorView({ |
| 183 | state: EditorState.create({ |
| 184 | doc: code, |
| 185 | extensions: [ |
| 186 | lineNumbers(), |
| 187 | highlightActiveLine(), |
| 188 | drawSelection(), |
| 189 | bracketMatching(), |
| 190 | closeBrackets(), |
| 191 | history(), |
| 192 | keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), |
| 193 | formatKeymap, |
| 194 | mermaidLang, |
| 195 | groveTheme, |
| 196 | syntaxHighlighting(groveHighlight), |
| 197 | EditorView.updateListener.of((update) => { |
| 198 | if (update.docChanged) { |
| 199 | onChangeRef.current(update.state.doc.toString()); |
| 200 | } |
| 201 | }), |
| 202 | EditorView.lineWrapping, |
| 203 | ], |
| 204 | }), |
| 205 | parent: parentRef.current, |
| 206 | }); |
| 207 | |
| 208 | viewRef.current = view; |
| 209 | |
| 210 | return () => { |
| 211 | view.destroy(); |
| 212 | viewRef.current = null; |
| 213 | }; |
| 214 | // Only create the editor once per mount |
| 215 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 216 | }, []); |
| 217 | |
| 218 | // Update editor content when code changes externally |
| 219 | useEffect(() => { |
| 220 | const view = viewRef.current; |
| 221 | if (!view) return; |
| 222 | const currentDoc = view.state.doc.toString(); |
| 223 | if (currentDoc !== code) { |
| 224 | view.dispatch({ |
| 225 | changes: { from: 0, to: currentDoc.length, insert: code }, |
| 226 | }); |
| 227 | } |
| 228 | }, [code]); |
| 229 | |
| 230 | return ( |
| 231 | <div |
| 232 | ref={parentRef} |
| 233 | style={{ |
| 234 | flex: 1, |
| 235 | display: "flex", |
| 236 | flexDirection: "column", |
| 237 | overflow: "hidden", |
| 238 | }} |
| 239 | /> |
| 240 | ); |
| 241 | } |
| 242 | ); |
| 243 | |