/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * CodeEditor — CodeMirror 6 wrapper for the script panel. * * Provides JavaScript/TypeScript editing with autocomplete for the bim.* API * surface. Completions are auto-generated from the bridge-schema so they * stay in sync with the sandbox API automatically. */ import { useRef, useEffect } from 'react'; import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection } from '@codemirror/view'; import { EditorState, Compartment, Transaction } from '@codemirror/state'; import { javascript } from '@codemirror/lang-javascript'; import { autocompletion, type CompletionContext, type CompletionResult, type Completion } from '@codemirror/autocomplete'; import { defaultKeymap, history, historyKeymap, indentWithTab, undo, redo, undoDepth, redoDepth } from '@codemirror/commands'; import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, indentOnInput } from '@codemirror/language'; import { highlightSelectionMatches } from '@codemirror/search'; import { NAMESPACE_SCHEMAS } from '@ifc-lite/sandbox/schema'; import type { ScriptEditorSelection, ScriptEditorTextChange } from '@/lib/llm/types'; /** Shared structural styles (mode-agnostic) */ const baseTheme = EditorView.theme({ '&': { backgroundColor: 'transparent', fontSize: '13px', height: '100%', }, '.cm-scroller': { fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', lineHeight: '1.6', overflow: 'auto', }, '.cm-content': { padding: '8px 0', }, '.cm-gutters': { backgroundColor: 'transparent', border: 'none', paddingRight: '4px', }, '.cm-activeLineGutter': { backgroundColor: 'transparent', }, '.cm-completionIcon': { display: 'none', }, }); /** Dark mode colors */ const darkTheme = EditorView.theme({ '&': { color: '#e4e4e7' }, '.cm-content': { caretColor: '#e4e4e7' }, '.cm-gutters': { color: '#71717a' }, '.cm-activeLineGutter': { color: '#e4e4e7' }, '.cm-activeLine': { backgroundColor: 'rgba(255,255,255,0.04)' }, '.cm-selectionMatch': { backgroundColor: 'rgba(255,255,255,0.1)' }, '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { backgroundColor: 'rgba(99,102,241,0.3)', }, '.cm-cursor': { borderLeftColor: '#e4e4e7' }, '.cm-tooltip': { backgroundColor: '#1c1c22', color: '#e4e4e7', border: '1px solid #27272a', borderRadius: '6px', }, '.cm-tooltip-autocomplete': { '& > ul > li[aria-selected]': { backgroundColor: 'rgba(99,102,241,0.2)' }, }, }, { dark: true }); /** Light mode colors */ const lightTheme = EditorView.theme({ '&': { color: '#18181b' }, '.cm-content': { caretColor: '#18181b' }, '.cm-gutters': { color: '#a1a1aa' }, '.cm-activeLineGutter': { color: '#52525b' }, '.cm-activeLine': { backgroundColor: 'rgba(0,0,0,0.03)' }, '.cm-selectionMatch': { backgroundColor: 'rgba(99,102,241,0.15)' }, '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { backgroundColor: 'rgba(99,102,241,0.2)', }, '.cm-cursor': { borderLeftColor: '#18181b' }, '.cm-tooltip': { backgroundColor: '#ffffff', color: '#18181b', border: '1px solid #e4e4e7', borderRadius: '6px', }, '.cm-tooltip-autocomplete': { '& > ul > li[aria-selected]': { backgroundColor: 'rgba(99,102,241,0.12)' }, }, }, { dark: false }); /** Compartment for swapping light/dark theme at runtime */ const themeCompartment = new Compartment(); function isDarkMode(): boolean { return document.documentElement.classList.contains('dark'); } function getColorTheme() { return isDarkMode() ? darkTheme : lightTheme; } // ============================================================================ // Completion Generation (auto-generated from bridge-schema) // ============================================================================ /** Namespace → method completions derived from NAMESPACE_SCHEMAS */ interface NamespaceCompletions { namespace: string; detail: string; methods: Completion[]; } /** * Build completions for bim.* SDK methods from the bridge schema. * Lazily initialized once, then cached for all CodeEditor instances. */ let cachedCompletionMap: Map | null = null; function getCompletionMap(): Map { if (cachedCompletionMap) return cachedCompletionMap; const map = new Map(); for (const ns of NAMESPACE_SCHEMAS) { const methods: Completion[] = ns.methods.map(m => ({ label: m.args.length === 0 ? `bim.${ns.name}.${m.name}()` // no-arg methods get () : `bim.${ns.name}.${m.name}(`, // methods with args get ( type: 'function', detail: m.doc, })); map.set(ns.name, { namespace: ns.name, detail: ns.doc, methods }); } cachedCompletionMap = map; return map; } /** bim.* API completions — generated from node registry */ function bimCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/[\w.]*$/); if (!word || (word.from === word.to && !context.explicit)) return null; const text = word.text; const completionMap = getCompletionMap(); // Top-level bim completions if (text === 'bim' || text === 'bim.') { return { from: word.from, options: Array.from(completionMap.values()).map(ns => ({ label: `bim.${ns.namespace}`, type: 'variable', detail: ns.detail, })), }; } // Namespace method completions for (const [ns, data] of completionMap) { if (text.startsWith(`bim.${ns}`)) { return { from: word.from, options: data.methods, }; } } // Entity data field completions — IFC attribute names (both PascalCase and camelCase work) if (text.endsWith('.Name') || text.endsWith('.Type') || text.endsWith('.GlobalId') || text.endsWith('.name') || text.endsWith('.type') || text.endsWith('.ref')) { return { from: word.from + text.lastIndexOf('.') + 1, options: [ { label: 'Name', type: 'property', detail: 'IFC Name attribute (IfcLabel)' }, { label: 'Type', type: 'property', detail: 'IFC entity type (e.g. IfcWall)' }, { label: 'GlobalId', type: 'property', detail: 'IFC GlobalId (IfcGloballyUniqueId)' }, { label: 'Description', type: 'property', detail: 'IFC Description attribute (IfcText)' }, { label: 'ObjectType', type: 'property', detail: 'IFC ObjectType attribute (IfcLabel)' }, { label: 'ref', type: 'property', detail: 'Entity reference { modelId, expressId }' }, { label: 'name', type: 'property', detail: 'IFC Name (camelCase alias)' }, { label: 'type', type: 'property', detail: 'IFC type (camelCase alias)' }, { label: 'globalId', type: 'property', detail: 'IFC GlobalId (camelCase alias)' }, ], }; } return null; } // ============================================================================ // Component // ============================================================================ interface CodeEditorProps { value: string; onChange: (value: string) => void; onSelectionChange?: (selection: ScriptEditorSelection) => void; onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void; registerApplyAdapter?: ((adapter: { apply: ( nextContent: string, selection: ScriptEditorSelection, options?: { userEvent?: string; changes?: ScriptEditorTextChange[] }, ) => void; undo: () => void; redo: () => void; } | null) => void); onRun?: () => void; onSave?: () => void; className?: string; } export function CodeEditor({ value, onChange, onSelectionChange, onHistoryChange, registerApplyAdapter, onRun, onSave, className, }: CodeEditorProps) { const containerRef = useRef(null); const viewRef = useRef(null); const onChangeRef = useRef(onChange); const onSelectionChangeRef = useRef(onSelectionChange); const onHistoryChangeRef = useRef(onHistoryChange); const onRunRef = useRef(onRun); const onSaveRef = useRef(onSave); // Keep callback refs up to date without recreating the editor onChangeRef.current = onChange; onSelectionChangeRef.current = onSelectionChange; onHistoryChangeRef.current = onHistoryChange; onRunRef.current = onRun; onSaveRef.current = onSave; // Create editor once useEffect(() => { if (!containerRef.current) return; const runKeymap = keymap.of([ { key: 'Ctrl-Enter', mac: 'Cmd-Enter', run: () => { onRunRef.current?.(); return true; }, }, { key: 'Ctrl-s', mac: 'Cmd-s', run: () => { onSaveRef.current?.(); return true; }, }, ]); const updateListener = EditorView.updateListener.of((update) => { if (update.docChanged) { onChangeRef.current(update.state.doc.toString()); } if (update.selectionSet || update.docChanged) { const main = update.state.selection.main; onSelectionChangeRef.current?.({ from: main.from, to: main.to }); } if (update.docChanged) { onHistoryChangeRef.current?.( undoDepth(update.state) > 0, redoDepth(update.state) > 0, ); } }); const state = EditorState.create({ doc: value, extensions: [ lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), drawSelection(), bracketMatching(), indentOnInput(), history(), highlightSelectionMatches(), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), javascript({ typescript: true }), autocompletion({ override: [bimCompletions], activateOnTyping: true, maxRenderedOptions: 15, }), runKeymap, keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]), updateListener, baseTheme, themeCompartment.of(getColorTheme()), EditorView.lineWrapping, ], }); const view = new EditorView({ state, parent: containerRef.current, }); viewRef.current = view; const initialSelection = view.state.selection.main; onSelectionChangeRef.current?.({ from: initialSelection.from, to: initialSelection.to }); onHistoryChangeRef.current?.(undoDepth(view.state) > 0, redoDepth(view.state) > 0); registerApplyAdapter?.({ apply: (nextContent, selection, options) => { const active = viewRef.current; if (!active) return; const safeFrom = Math.max(0, Math.min(selection.from, nextContent.length)); const safeTo = Math.max(safeFrom, Math.min(selection.to, nextContent.length)); const changes = options?.changes && options.changes.length > 0 ? options.changes : [{ from: 0, to: active.state.doc.length, insert: nextContent }]; active.dispatch({ changes, selection: { anchor: safeFrom, head: safeTo }, annotations: options?.userEvent ? [Transaction.userEvent.of(options.userEvent)] : undefined, }); }, undo: () => { const active = viewRef.current; if (!active) return; undo(active); }, redo: () => { const active = viewRef.current; if (!active) return; redo(active); }, }); // Watch for light/dark mode changes on class const observer = new MutationObserver(() => { view.dispatch({ effects: themeCompartment.reconfigure(getColorTheme()) }); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); return () => { onHistoryChangeRef.current?.(false, false); registerApplyAdapter?.(null); observer.disconnect(); view.destroy(); viewRef.current = null; }; // Only create once — value is set via initial doc // eslint-disable-next-line react-hooks/exhaustive-deps }, [registerApplyAdapter]); // Sync external value changes into the editor (e.g., loading a different script) const lastExternalValue = useRef(value); useEffect(() => { const view = viewRef.current; if (!view) return; // Only update if value changed externally (not from our own onChange) if (value !== lastExternalValue.current && value !== view.state.doc.toString()) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value }, }); } lastExternalValue.current = value; }, [value]); return
; }