8.8 KB243 lines
Blame
1"use client";
2
3import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
4import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from "@codemirror/view";
5import { EditorState } from "@codemirror/state";
6import { defaultKeymap, indentWithTab, history, historyKeymap } from "@codemirror/commands";
7import { StreamLanguage, HighlightStyle, syntaxHighlighting, bracketMatching } from "@codemirror/language";
8import { closeBrackets } from "@codemirror/autocomplete";
9import { tags } from "@lezer/highlight";
10
11// Mermaid stream parser — matches the one from the vanilla SPA
12const 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
36const mermaidLang = StreamLanguage.define(mermaidParser);
37
38const 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
63const 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 ──
77function 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
105export interface CodeEditorHandle {
106 changeFontSize: (delta: number) => void;
107 format: () => void;
108 jumpToLine: (line: number, col?: number) => void;
109}
110
111interface CodeEditorProps {
112 code: string;
113 onChange: (code: string) => void;
114 onRun: () => void;
115}
116
117export 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