web/app/collab/components/code-editor.tsxblame
View source
0b4b5821"use client";
0b4b5822
0b4b5823import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
0b4b5824import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from "@codemirror/view";
0b4b5825import { EditorState } from "@codemirror/state";
0b4b5826import { defaultKeymap, indentWithTab, history, historyKeymap } from "@codemirror/commands";
0b4b5827import { StreamLanguage, HighlightStyle, syntaxHighlighting, bracketMatching } from "@codemirror/language";
0b4b5828import { closeBrackets } from "@codemirror/autocomplete";
0b4b5829import { tags } from "@lezer/highlight";
0b4b58210
0b4b58211// Mermaid stream parser — matches the one from the vanilla SPA
0b4b58212const mermaidParser = {
0b4b58213 startState() { return { inBlock: false, blockType: null as string | null }; },
0b4b58214 token(stream: any, _state: any) {
0b4b58215 if (stream.match(/^%%/)) { stream.skipToEnd(); return "lineComment"; }
0b4b58216 if (stream.match(/^"[^"]*"/)) return "string";
0b4b58217 if (stream.sol() && stream.match(/^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/))
0b4b58218 return "keyword";
0b4b58219 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/))
0b4b58220 return "keyword";
0b4b58221 if (stream.match(/^==>/) || stream.match(/^-\.->/) || stream.match(/^\.->/) || stream.match(/^-->/) || stream.match(/^---/) || stream.match(/^-\.-/) || stream.match(/^===/) || stream.match(/^--|>/))
0b4b58222 return "operator";
0b4b58223 if (stream.match(/^\|/)) return "bracket";
0b4b58224 if (stream.match(/^[\[\](){}]/)) return "bracket";
0b4b58225 if (stream.match(/^(int|text|real|bool|datetime|float|string|void)\b/)) return "typeName";
0b4b58226 if (stream.match(/^(PK|FK|UK)\b/)) return "annotation";
0b4b58227 if (stream.match(/^[+#~-](?=[A-Za-z])/)) return "operator";
0b4b58228 if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) return "variableName";
0b4b58229 if (stream.match(/^[0-9]+(\.[0-9]+)?/)) return "number";
0b4b58230 if (stream.match(/^:/)) return "punctuation";
0b4b58231 stream.next();
0b4b58232 return null;
0b4b58233 },
0b4b58234};
0b4b58235
0b4b58236const mermaidLang = StreamLanguage.define(mermaidParser);
0b4b58237
0b4b58238const groveTheme = EditorView.theme({
0b4b58239 "&": {
0b4b58240 fontSize: "12px",
0b4b58241 fontFamily: "'JetBrains Mono', Menlo, monospace",
0b4b58242 backgroundColor: "var(--bg-inset, #eae6df)",
0b4b58243 color: "var(--text-primary, #2c2824)",
0b4b58244 flex: "1",
0b4b58245 overflow: "hidden",
0b4b58246 },
0b4b58247 "&.cm-focused": { outline: "none" },
0b4b58248 ".cm-scroller": { overflow: "auto", fontFamily: "inherit" },
0b4b58249 ".cm-content": { padding: "12px 8px", caretColor: "var(--accent, #4d8a78)" },
0b4b58250 ".cm-gutters": {
0b4b58251 backgroundColor: "var(--bg-hover, #e2ddd5)",
0b4b58252 color: "var(--text-faint, #a09888)",
0b4b58253 border: "none",
0b4b58254 borderRight: "1px solid var(--border, #d9d3ca)",
0b4b58255 minWidth: "36px",
0b4b58256 },
0b4b58257 ".cm-activeLineGutter": { backgroundColor: "var(--border, #d9d3ca)", color: "var(--text-secondary, #4a4540)" },
0b4b58258 ".cm-activeLine": { backgroundColor: "var(--bg-hover, #e2ddd5)20" },
0b4b58259 ".cm-cursor": { borderLeftColor: "var(--accent, #4d8a78)", borderLeftWidth: "2px" },
0b4b58260 ".cm-selectionBackground": { backgroundColor: "var(--accent, #4d8a78)30 !important" },
0b4b58261}, { dark: false });
0b4b58262
0b4b58263const groveHighlight = HighlightStyle.define([
0b4b58264 { tag: tags.keyword, color: "#7c4dff", fontWeight: "500" },
0b4b58265 { tag: tags.string, color: "#2d6b56" },
0b4b58266 { tag: tags.lineComment, color: "#a09888", fontStyle: "italic" },
0b4b58267 { tag: tags.operator, color: "#c56200" },
0b4b58268 { tag: tags.bracket, color: "#7a746c" },
0b4b58269 { tag: tags.typeName, color: "#0277bd" },
0b4b58270 { tag: tags.annotation, color: "#c62828", fontWeight: "600" },
0b4b58271 { tag: tags.variableName, color: "#2c2824" },
0b4b58272 { tag: tags.number, color: "#0277bd" },
0b4b58273 { tag: tags.punctuation, color: "#7a746c" },
0b4b58274]);
0b4b58275
0b4b58276// ── Mermaid auto-formatter ──
0b4b58277function formatMermaidCode(src: string): string {
0b4b58278 const lines = src.split("\n");
0b4b58279 const out: string[] = [];
0b4b58280 const INDENT = " ";
0b4b58281 let depth = 0;
0b4b58282
0b4b58283 const openers = /^(subgraph|state|loop|alt|par|critical|opt)\b/;
0b4b58284 const closers = /^end\b/;
0b4b58285 const midBlock = /^(else|break)\b/;
0b4b58286 const braceOpen = /\{\s*$/;
0b4b58287 const braceClose = /^\s*\}/;
0b4b58288 const diagramType = /^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/;
0b4b58289
0b4b58290 for (let i = 0; i < lines.length; i++) {
0b4b58291 const trimmed = lines[i].trim();
0b4b58292 if (trimmed === "") { out.push(""); continue; }
0b4b58293 if (trimmed.startsWith("%%")) { out.push(INDENT.repeat(depth) + trimmed); continue; }
0b4b58294 if (diagramType.test(trimmed)) { depth = 0; out.push(trimmed); continue; }
0b4b58295 if (braceClose.test(trimmed)) { depth = Math.max(0, depth - 1); out.push(INDENT.repeat(depth) + trimmed); continue; }
0b4b58296 if (closers.test(trimmed)) { depth = Math.max(0, depth - 1); out.push(INDENT.repeat(depth) + trimmed); continue; }
0b4b58297 if (midBlock.test(trimmed)) { out.push(INDENT.repeat(Math.max(0, depth - 1)) + trimmed); continue; }
0b4b58298 out.push(INDENT.repeat(depth) + trimmed);
0b4b58299 if (openers.test(trimmed) || braceOpen.test(trimmed)) depth++;
0b4b582100 }
0b4b582101
0b4b582102 return out.join("\n");
0b4b582103}
0b4b582104
0b4b582105export interface CodeEditorHandle {
0b4b582106 changeFontSize: (delta: number) => void;
0b4b582107 format: () => void;
0b4b582108 jumpToLine: (line: number, col?: number) => void;
0b4b582109}
0b4b582110
0b4b582111interface CodeEditorProps {
0b4b582112 code: string;
0b4b582113 onChange: (code: string) => void;
0b4b582114 onRun: () => void;
0b4b582115}
0b4b582116
0b4b582117export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
0b4b582118 function CodeEditor({ code, onChange, onRun }, ref) {
0b4b582119 const parentRef = useRef<HTMLDivElement>(null);
0b4b582120 const viewRef = useRef<EditorView | null>(null);
0b4b582121 const fontSizeRef = useRef(12);
0b4b582122 const onChangeRef = useRef(onChange);
0b4b582123 const onRunRef = useRef(onRun);
0b4b582124 onChangeRef.current = onChange;
0b4b582125 onRunRef.current = onRun;
0b4b582126
0b4b582127 useImperativeHandle(ref, () => ({
0b4b582128 changeFontSize(delta: number) {
0b4b582129 fontSizeRef.current = Math.max(8, Math.min(24, fontSizeRef.current + delta));
0b4b582130 const view = viewRef.current;
0b4b582131 if (view) {
0b4b582132 view.dom.style.fontSize = `${fontSizeRef.current}px`;
0b4b582133 view.requestMeasure();
0b4b582134 }
0b4b582135 },
0b4b582136 format() {
0b4b582137 const view = viewRef.current;
0b4b582138 if (!view) return;
0b4b582139 const src = view.state.doc.toString();
0b4b582140 const formatted = formatMermaidCode(src);
0b4b582141 if (formatted !== src) {
0b4b582142 view.dispatch({
0b4b582143 changes: { from: 0, to: src.length, insert: formatted },
0b4b582144 });
0b4b582145 }
0b4b582146 },
0b4b582147 jumpToLine(line: number, col?: number) {
0b4b582148 const view = viewRef.current;
0b4b582149 if (!view) return;
0b4b582150 try {
0b4b582151 const pos = view.state.doc.line(line);
0b4b582152 view.dispatch({
0b4b582153 selection: { anchor: pos.from + (col ? col - 1 : 0) },
0b4b582154 scrollIntoView: true,
0b4b582155 });
0b4b582156 view.focus();
0b4b582157 } catch (_) {}
0b4b582158 },
0b4b582159 }));
0b4b582160
0b4b582161 useEffect(() => {
0b4b582162 if (!parentRef.current) return;
0b4b582163
0b4b582164 const formatKeymap = keymap.of([
0b4b582165 {
0b4b582166 key: "Mod-Enter",
0b4b582167 run: () => { onRunRef.current(); return true; },
0b4b582168 },
0b4b582169 {
0b4b582170 key: "Shift-Alt-f",
0b4b582171 run: (view) => {
0b4b582172 const src = view.state.doc.toString();
0b4b582173 const formatted = formatMermaidCode(src);
0b4b582174 if (formatted !== src) {
0b4b582175 view.dispatch({ changes: { from: 0, to: src.length, insert: formatted } });
0b4b582176 }
0b4b582177 return true;
0b4b582178 },
0b4b582179 },
0b4b582180 ]);
0b4b582181
0b4b582182 const view = new EditorView({
0b4b582183 state: EditorState.create({
0b4b582184 doc: code,
0b4b582185 extensions: [
0b4b582186 lineNumbers(),
0b4b582187 highlightActiveLine(),
0b4b582188 drawSelection(),
0b4b582189 bracketMatching(),
0b4b582190 closeBrackets(),
0b4b582191 history(),
0b4b582192 keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
0b4b582193 formatKeymap,
0b4b582194 mermaidLang,
0b4b582195 groveTheme,
0b4b582196 syntaxHighlighting(groveHighlight),
0b4b582197 EditorView.updateListener.of((update) => {
0b4b582198 if (update.docChanged) {
0b4b582199 onChangeRef.current(update.state.doc.toString());
0b4b582200 }
0b4b582201 }),
0b4b582202 EditorView.lineWrapping,
0b4b582203 ],
0b4b582204 }),
0b4b582205 parent: parentRef.current,
0b4b582206 });
0b4b582207
0b4b582208 viewRef.current = view;
0b4b582209
0b4b582210 return () => {
0b4b582211 view.destroy();
0b4b582212 viewRef.current = null;
0b4b582213 };
0b4b582214 // Only create the editor once per mount
0b4b582215 // eslint-disable-next-line react-hooks/exhaustive-deps
0b4b582216 }, []);
0b4b582217
0b4b582218 // Update editor content when code changes externally
0b4b582219 useEffect(() => {
0b4b582220 const view = viewRef.current;
0b4b582221 if (!view) return;
0b4b582222 const currentDoc = view.state.doc.toString();
0b4b582223 if (currentDoc !== code) {
0b4b582224 view.dispatch({
0b4b582225 changes: { from: 0, to: currentDoc.length, insert: code },
0b4b582226 });
0b4b582227 }
0b4b582228 }, [code]);
0b4b582229
0b4b582230 return (
0b4b582231 <div
0b4b582232 ref={parentRef}
0b4b582233 style={{
0b4b582234 flex: 1,
0b4b582235 display: "flex",
0b4b582236 flexDirection: "column",
0b4b582237 overflow: "hidden",
0b4b582238 }}
0b4b582239 />
0b4b582240 );
0b4b582241 }
0b4b582242);