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