import { html, PropertyValues } from "lit"; import { BaseElement } from "../../core/base-element.ts"; import { styles } from "./styles.ts"; import { basicSetup } from "codemirror"; import { EditorView, keymap, placeholder } from "@codemirror/view"; import { indentWithTab } from "@codemirror/commands"; import { Annotation, Compartment, EditorState, Extension, } from "@codemirror/state"; import { indentUnit, LanguageSupport } from "@codemirror/language"; import { javascript as createJavaScript } from "@codemirror/lang-javascript"; import { markdown as createMarkdown } from "@codemirror/lang-markdown"; import { css as createCss } from "@codemirror/lang-css"; import { html as createHtml } from "@codemirror/lang-html"; import { json as createJson } from "@codemirror/lang-json"; import { oneDark } from "@codemirror/theme-one-dark"; import { acceptCompletion, autocompletion, Completion, CompletionContext, CompletionResult, } from "@codemirror/autocomplete"; import { Decoration, DecorationSet, ViewPlugin, ViewUpdate, } from "@codemirror/view"; import { type Cell, getEntityId, isCell, NAME } from "@commontools/runner"; import { type InputTimingOptions } from "../../core/input-timing-controller.ts"; import { createStringCellController } from "../../core/cell-controller.ts"; import { Mentionable, MentionableArray } from "../../core/mentionable.ts"; /** * Supported MIME types for syntax highlighting */ export const MimeType = Object.freeze( { css: "text/css", html: "text/html", javascript: "text/javascript", jsx: "text/x.jsx", typescript: "text/x.typescript", json: "application/json", markdown: "text/markdown", } as const, ); export type MimeType = (typeof MimeType)[keyof typeof MimeType]; // Language registry const langRegistry = new Map(); const markdownLang = createMarkdown({ defaultCodeLanguage: createJavaScript({ jsx: true }), }); const defaultLang = markdownLang; langRegistry.set(MimeType.javascript, createJavaScript()); langRegistry.set(MimeType.jsx, createJavaScript({ jsx: true })); langRegistry.set( MimeType.typescript, createJavaScript({ jsx: true, typescript: true }), ); langRegistry.set(MimeType.css, createCss()); langRegistry.set(MimeType.html, createHtml()); langRegistry.set(MimeType.markdown, markdownLang); langRegistry.set(MimeType.json, createJson()); const getLangExtFromMimeType = (mime: MimeType) => { return langRegistry.get(mime) ?? defaultLang; }; /** * CTCodeEditor - Code editor component with syntax highlighting and debounced changes * * @element ct-code-editor * * @attr {string|Cell} value - Editor content (supports both plain string and Cell) * @attr {string} language - MIME type for syntax highlighting * @attr {boolean} disabled - Whether the editor is disabled * @attr {boolean} readonly - Whether the editor is read-only * @attr {string} placeholder - Placeholder text when empty * @attr {string} timingStrategy - Input timing strategy: "immediate" | "debounce" | "throttle" | "blur" * @attr {number} timingDelay - Delay in milliseconds for debounce/throttle (default: 500) * @attr {Cell} mentionable - Cell of mentionable items for @/@[[ completion * @attr {Array} mentioned - Optional Cell of live Charms mentioned in content * @attr {boolean} wordWrap - Enable soft line wrapping (default: true) * @attr {boolean} lineNumbers - Show line numbers gutter (default: false) * @attr {number} maxLineWidth - Optional max line width in ch units * (default: undefined) * @attr {number} tabSize - Tab size (spaces shown for a tab, default: 2) * @attr {boolean} tabIndent - Indent on Tab key (default: true) * @attr {"light"|"dark"} theme - Editor theme mode; "dark" enables oneDark. * * @fires ct-change - Fired when content changes with detail: { value, oldValue, language } * @fires ct-focus - Fired on focus * @fires ct-blur - Fired on blur * @fires backlink-click - Fired when a backlink is clicked with Cmd/Ctrl+Enter with detail: { text, charm } * @fires backlink-create - Fired when a novel backlink is activated (Cmd/Ctrl+Click) * or confirmed with Enter during autocomplete with no matches. Detail: * { text: string, charmId: any, charm: Cell, navigate: boolean } * * @example * */ export class CTCodeEditor extends BaseElement { static override styles = [BaseElement.baseStyles, styles]; static override properties = { value: { type: String }, language: { type: String }, disabled: { type: Boolean }, readonly: { type: Boolean }, placeholder: { type: String }, timingStrategy: { type: String }, timingDelay: { type: Number }, mentionable: { type: Object }, mentioned: { type: Array }, pattern: { type: Object }, // New editor configuration props wordWrap: { type: Boolean }, lineNumbers: { type: Boolean }, maxLineWidth: { type: Number }, tabSize: { type: Number }, tabIndent: { type: Boolean }, theme: { type: String, reflect: true }, }; declare value: Cell | string; declare language: MimeType; declare disabled: boolean; declare readonly: boolean; declare placeholder: string; declare timingStrategy: InputTimingOptions["strategy"]; declare timingDelay: number; /** * Mentionable items for @ completion. */ declare mentionable?: Cell | null; declare mentioned?: Cell; declare pattern: Cell; declare wordWrap: boolean; declare lineNumbers: boolean; declare maxLineWidth?: number; declare tabSize: number; declare tabIndent: boolean; declare theme: "light" | "dark"; private _editorView: EditorView | undefined; private _lang = new Compartment(); private _readonly = new Compartment(); private _wrap = new Compartment(); private _gutters = new Compartment(); private _tabSizeComp = new Compartment(); private _tabIndentComp = new Compartment(); private _maxLineWidthComp = new Compartment(); private _indentUnitComp = new Compartment(); private _themeComp = new Compartment(); private _cleanupFns: Array<() => void> = []; private _mentionableUnsub: (() => void) | null = null; private _changeGroup = crypto.randomUUID(); // Transaction annotation to mark Cell-originated updates. // This is the idiomatic CodeMirror 6 way to distinguish programmatic // changes from user input. The updateListener checks this annotation // and skips setValue for Cell-originated changes, preventing the // feedback loop: Cell → Editor → updateListener → setValue → Cell... private static _cellSyncAnnotation = Annotation.define(); private _cellController = createStringCellController(this, { timing: { strategy: "debounce", delay: 500, }, setValue: (value, newValue) => { if (isCell(value)) { const tx = value.runtime.edit({ changeGroup: this._changeGroup }); value.withTx(tx).set(newValue); tx.commit(); } }, onChange: (newValue: string, oldValue: string) => { this.emit("ct-change", { value: newValue, oldValue, language: this.language, }); // Keep $mentioned in sync with content changes this._updateMentionedFromContent(); }, }); constructor() { super(); this.value = ""; this.language = MimeType.markdown; this.disabled = false; this.readonly = false; this.placeholder = ""; this.timingStrategy = "debounce"; this.timingDelay = 500; // Defaults for new props this.wordWrap = true; this.lineNumbers = false; this.maxLineWidth = undefined; this.tabSize = 2; this.tabIndent = true; this.theme = "light"; this.mentionable = null; } /** * Create a backlink completion source for [[backlinks]] */ private createBacklinkCompletionSource() { return (context: CompletionContext): CompletionResult | null => { // Look for incomplete backlinks: [[ followed by optional text const backlink = context.matchBefore(/\[\[([^\]]*)?/); if (!backlink) { return null; } // Check what comes after the cursor const afterCursor = context.state.doc.sliceString( context.pos, context.pos + 2, ); // Allow completion inside existing backlinks - we'll replace the content between [[ and ]] const query = backlink.text.slice(2); // Remove [[ prefix const mentionable = this.getFilteredMentionable(query); // Build options from existing mentionable items const options: Completion[] = mentionable.map((charm) => { const charmIdObj = getEntityId(charm.resolveAsCell()); const charmId = charmIdObj?.["/"] || ""; const charmName = charm.key(NAME).get() || ""; const insertText = `${charmName} (${charmId})`; return { label: charmName, apply: afterCursor === "]]" ? insertText : insertText + "]]", type: "text", info: "Backlink to " + charmName, }; }); // Inject a "create new" option when the typed text doesn't exactly match // any existing charm. This ensures there's a selectable option for // keyboard users when creating a novel backlink. const raw = query.trim(); if (raw.length > 0) { const lower = raw.toLowerCase(); const hasExact = options.some((o) => o.label.toLowerCase() === lower); if (!hasExact) { options.push({ label: raw, detail: "Create", type: "text", info: "Create new backlink", apply: () => { // Instantiate the pattern if available if (this.pattern) { this.createBacklinkFromPattern(raw, false); } else { this.emit("backlink-create", { text: raw, navigate: false }); } }, }); } } if (options.length === 0) return null; return { from: backlink.from + 2, // Start after [[ to: afterCursor === "]]" ? context.pos : undefined, options, validFor: /^[^\]]*$/, }; }; } /** * Get filtered mentionable items based on query */ private getFilteredMentionable(query: string): Cell[] { const mentionableCell = this._getMentionableCell(); if (!mentionableCell) { return []; } const rawMentionable = mentionableCell.get(); const mentionableData = Array.isArray(rawMentionable) ? rawMentionable as MentionableArray : isCell(rawMentionable) ? ((rawMentionable.get() ?? []) as MentionableArray) : []; if (mentionableData.length === 0) { return []; } const queryLower = query.toLowerCase(); const matches: Cell[] = []; for (let i = 0; i < mentionableData.length; i++) { const mention = mentionableData[i]; if ( mention && mention[NAME] ?.toLowerCase() ?.includes(queryLower) ) { matches.push(mentionableCell.key(i) as Cell); } } return matches; } /** * Handle backlink clicks with Cmd/Ctrl+Click */ private createBacklinkClickHandler() { return EditorView.domEventHandlers({ click: (event, view) => { if (event.ctrlKey || event.metaKey) { return this.handleBacklinkActivation(view, event); } return false; }, }); } /** * Handle backlink activation (Cmd/Ctrl+Click on a backlink) */ private handleBacklinkActivation( view: EditorView, _event?: MouseEvent, ): boolean { const state = view.state; const pos = state.selection.main.head; const doc = state.doc; // Find backlinks around cursor position const lineStart = doc.lineAt(pos).from; const lineEnd = doc.lineAt(pos).to; const lineText = doc.sliceString(lineStart, lineEnd); // Find all [[...]] patterns in the line const backlinkRegex = /\[\[([^\]]+)\]\]/g; let match; while ((match = backlinkRegex.exec(lineText)) !== null) { const matchStart = lineStart + match.index; const matchEnd = matchStart + match[0].length; // Check if cursor is within this backlink if (pos >= matchStart && pos <= matchEnd) { const backlinkText = match[1]; // Extract ID from "Name (id)" format const idMatch = backlinkText.match(/\(([^)]+)\)$/); const backlinkId = idMatch ? idMatch[1] : undefined; // If we have a valid ID, navigate directly using getCellFromEntityId. // This bypasses the mentionable array and eliminates a race condition // where findCharmById returns null because the array hasn't synced yet // (common when clicking a backlink immediately after creating it). if (backlinkId) { const runtime = this.pattern?.runtime ?? this.mentionable?.runtime ?? this.mentioned?.runtime; const space = this.pattern?.space ?? this.mentionable?.space ?? this.mentioned?.space; if (runtime && space) { // Get cell directly by entity ID - no array search needed const charmCell = runtime.getCellFromEntityId(space, { "/": backlinkId, }); // Use navigateCallback (same pattern as ct-cell-link) if (runtime.navigateCallback) { runtime.navigateCallback(charmCell); } this.emit("backlink-click", { id: backlinkId, text: backlinkText, charm: charmCell, }); return true; } else { // Log warning instead of silent failure console.warn( "[ct-code-editor] Cannot navigate to backlink: runtime or space unavailable", ); this.emit("backlink-click", { id: backlinkId, text: backlinkText, charm: null, }); return true; } } // Only create new backlink if there's NO ID (text-only like [[Name]]) if (!backlinkId && this.pattern) { this.createBacklinkFromPattern(backlinkText, true); } return true; } } return false; } /** * Create a backlink from pattern */ private createBacklinkFromPattern( backlinkText: string, navigate: boolean, ): void { try { const rt = this.pattern.runtime; const tx = rt.edit(); const spaceName = this.pattern.space; // ensure the cause is unique const result = rt.getCell( spaceName, { note: this.value, title: backlinkText }, ); // parse + start the recipe + link the inputs const pattern = JSON.parse(this.pattern.get()); // Provide mentionable list so the pattern can wire backlinks immediately const inputs: Record = { title: backlinkText, content: "", }; rt.run(tx, pattern, inputs, result); // let the pattern know about the new backlink tx.commit(); const charmId = getEntityId(result.resolveAsCell()); // Insert the ID into the text if we have an editor if (this._editorView && charmId) { this._insertBacklinkId(backlinkText, charmId["/"], navigate); } this.emit("backlink-create", { text: backlinkText, charmId, charm: result, navigate, }); } catch (error) { console.error("Error creating backlink:", error); } } /** * Insert the ID into an incomplete backlink and position cursor appropriately. * Replaces [[text]] with [[text (id)]] and positions cursor after ]]. */ private _insertBacklinkId( backlinkText: string, id: string, navigate: boolean, ): void { if (!this._editorView) return; const view = this._editorView; const state = view.state; const doc = state.doc; const content = doc.toString(); // Find the incomplete backlink: [[backlinkText]] const searchPattern = `[[${backlinkText}]]`; const index = content.indexOf(searchPattern); if (index === -1) return; // Replace with complete backlink including ID const replacement = `[[${backlinkText} (${id})]]`; const from = index; const to = index + searchPattern.length; view.dispatch({ changes: { from, to, insert: replacement }, selection: navigate ? undefined // Keep current selection if navigating away : { anchor: from + replacement.length }, // Position after ]] if staying }); } /** * If the cursor is after an unclosed [[... token on the same line, * return the current query text. Otherwise return null. */ private _currentBacklinkQuery(view: EditorView): string | null { const pos = view.state.selection.main.head; const line = view.state.doc.lineAt(pos); const textBefore = view.state.doc.sliceString(line.from, pos); const m = textBefore.match(/\[\[([^\]]*)$/); if (!m) return null; return m[1] ?? ""; } /** * Find a charm by ID in the mentionable list */ private findCharmById(id: string): Cell | null { const mentionableCell = this._getMentionableCell(); if (!mentionableCell) return null; const rawMentionable = mentionableCell.get(); const mentionableData = Array.isArray(rawMentionable) ? rawMentionable as MentionableArray : isCell(rawMentionable) ? ((rawMentionable.get() ?? []) as MentionableArray) : []; if (mentionableData.length === 0) return null; for (let i = 0; i < mentionableData.length; i++) { const charmValue = mentionableData[i]; if (!charmValue) continue; const charmCell = mentionableCell.key(i) as Cell; // getEntityId now properly dereferences cells with paths, so we get // the charm's intrinsic ID whether we call it on the cell or the value const charmIdObj = getEntityId(charmCell.resolveAsCell()); const charmId = charmIdObj?.["/"] || ""; if (charmId === id) { return charmCell; } } return null; } /** * Create a plugin to decorate backlinks with special styling */ private createBacklinkDecorationPlugin() { const backlinkMark = Decoration.mark({ class: "cm-backlink" }); return ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = this.getBacklinkDecorations(view); } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { this.decorations = this.getBacklinkDecorations(update.view); } } getBacklinkDecorations(view: EditorView) { const decorations = []; const doc = view.state.doc; const backlinkRegex = /\[\[([^\]]+)\]\]/g; for (const { from, to } of view.visibleRanges) { for (let pos = from; pos <= to;) { const line = doc.lineAt(pos); const text = line.text; let match; backlinkRegex.lastIndex = 0; // Reset regex while ((match = backlinkRegex.exec(text)) !== null) { const start = line.from + match.index; const end = start + match[0].length; // Only decorate if within visible range if (start >= from && end <= to) { decorations.push(backlinkMark.range(start, end)); } } pos = line.to + 1; } } return Decoration.set(decorations); } }, { decorations: (v) => v.decorations, }, ); } private getValue(): string { return this._cellController.getValue(); } private setValue(newValue: string): void { this._cellController.setValue(newValue); } override connectedCallback() { super.connectedCallback(); } override disconnectedCallback() { super.disconnectedCallback(); this._cleanup(); } private _updateEditorFromCellValue(): void { if (!this._editorView) return; const newValue = this.getValue(); const currentValue = this._editorView.state.doc.toString(); // Skip if content already matches - handles Cell echoes. // This is the key check that prevents cursor jumping: if the editor // already has the content the Cell is trying to set, do nothing. if (newValue === currentValue) { return; } // External updates override local edits, so drop any pending debounced write. this._cellController.cancel(); // Apply external update to editor, preserving cursor position. // Clamp cursor to new document length in case content is shorter. const currentSelection = this._editorView.state.selection.main; const newLength = newValue.length; const anchorPos = Math.min(currentSelection.anchor, newLength); const headPos = Math.min(currentSelection.head, newLength); this._editorView.dispatch({ changes: { from: 0, to: this._editorView.state.doc.length, insert: newValue, }, selection: { anchor: anchorPos, head: headPos }, annotations: CTCodeEditor._cellSyncAnnotation.of(true), }); // Ensure mentioned charms reflect external value changes this._updateMentionedFromContent(); } private _cellSyncUnsub: (() => void) | null = null; private _setupCellSyncHandler(): void { // Create a custom Cell sync handler that integrates with the CellController // but provides the special CodeMirror synchronization logic const originalTriggerUpdate = this._cellController["options"].triggerUpdate; // Override the CellController's update mechanism to include CodeMirror sync this._cellController["options"].triggerUpdate = false; // Disable default updates // Set up our own Cell subscription that calls both update methods if (this._cellController.isCell()) { const cell = this._cellController.getCell(); if (cell) { this._cellSyncUnsub = cell.sink(() => { // First update the editor content this._updateEditorFromCellValue(); // Then trigger component update if originally enabled if (originalTriggerUpdate) { this.requestUpdate(); } }, { changeGroup: this._changeGroup }); } } } private _cleanupCellSyncHandler(): void { if (this._cellSyncUnsub) { this._cellSyncUnsub(); this._cellSyncUnsub = null; } } /** * Subscribe to mentionable changes to re-resolve mentioned charms when * the source list updates. */ private _setupMentionableSyncHandler(): void { if (this._mentionableUnsub) { this._mentionableUnsub(); this._mentionableUnsub = null; } const mentionableCell = this._getMentionableCell(); if (!mentionableCell) return; const unsubscribe = mentionableCell.sink(() => { this._updateMentionedFromContent(); }); this._mentionableUnsub = unsubscribe; } private _cleanup(): void { this._cleanupCellSyncHandler(); if (this._mentionableUnsub) { this._mentionableUnsub(); this._mentionableUnsub = null; } this._cleanupFns.forEach((fn) => fn()); this._cleanupFns = []; if (this._editorView) { this._editorView.destroy(); this._editorView = undefined; } } override updated(changedProperties: Map) { super.updated(changedProperties); // If the value property itself changed (e.g., switched to a different cell) if (changedProperties.has("value")) { // Cancel pending debounced updates from old Cell to prevent race condition this._cellController.cancel(); // Clean up old Cell subscription and set up new one this._cleanupCellSyncHandler(); this._cellController.bind(this.value); this._setupCellSyncHandler(); this._updateEditorFromCellValue(); } // Update language if (changedProperties.has("language") && this._editorView) { const lang = getLangExtFromMimeType(this.language); this._editorView.dispatch({ effects: this._lang.reconfigure(lang), }); } // Update readonly state if (changedProperties.has("readonly") && this._editorView) { this._editorView.dispatch({ effects: this._readonly.reconfigure( EditorState.readOnly.of(this.readonly), ), }); } // Update word wrap if (changedProperties.has("wordWrap") && this._editorView) { this._editorView.dispatch({ effects: this._wrap.reconfigure( this.wordWrap ? EditorView.lineWrapping : [], ), }); } // Update line numbers visibility (hide gutters when false) if (changedProperties.has("lineNumbers") && this._editorView) { const hideGutters = !this.lineNumbers; const ext = hideGutters ? EditorView.theme({ ".cm-gutters": { display: "none" }, ".cm-content": { paddingLeft: "0px" }, }) : [] as unknown as Extension; this._editorView.dispatch({ effects: this._gutters.reconfigure(ext), }); } // Update tab size if (changedProperties.has("tabSize") && this._editorView) { const size = this.tabSize ?? 2; this._editorView.dispatch({ effects: [ this._tabSizeComp.reconfigure(EditorState.tabSize.of(size)), this._indentUnitComp.reconfigure(indentUnit.of(" ".repeat(size))), ], }); } // Update tab indent keymap if (changedProperties.has("tabIndent") && this._editorView) { const ext = this.tabIndent ? keymap.of([indentWithTab]) : []; this._editorView.dispatch({ effects: this._tabIndentComp.reconfigure(ext), }); } // Update max line width theme if (changedProperties.has("maxLineWidth") && this._editorView) { const n = this.maxLineWidth; const ext = typeof n === "number" && n > 0 ? EditorView.theme({ ".cm-content": { maxWidth: `${n}ch` }, }) : [] as unknown as Extension; this._editorView.dispatch({ effects: this._maxLineWidthComp.reconfigure(ext), }); } // Update timing controller if timing options changed if ( changedProperties.has("timingStrategy") || changedProperties.has("timingDelay") ) { this._cellController.updateTimingOptions({ strategy: this.timingStrategy, delay: this.timingDelay, }); } // Update theme plugin if (changedProperties.has("theme") && this._editorView) { this._editorView.dispatch({ effects: this._themeComp.reconfigure( this.theme === "dark" ? oneDark : [], ), }); } // Re-subscribe if mention source reference changes if (changedProperties.has("mentionable")) { this._setupMentionableSyncHandler(); this._updateMentionedFromContent(); } // If `$mentioned` binding changes, push current state immediately if (changedProperties.has("mentioned")) { this._updateMentionedFromContent(); } } protected override firstUpdated(_changedProperties: PropertyValues): void { super.firstUpdated(_changedProperties); this._initializeEditor(); // Bind the initial value to the cell controller this._cellController.bind(this.value); // Update timing options to match current properties this._cellController.updateTimingOptions({ strategy: this.timingStrategy, delay: this.timingDelay, }); // Set up custom cell sync handler for CodeMirror this._setupCellSyncHandler(); // Set up mentionable sync handler and initialize mentioned list this._setupMentionableSyncHandler(); this._updateMentionedFromContent(); } private _initializeEditor(): void { const editorElement = this.shadowRoot?.querySelector( ".code-editor", ) as HTMLElement; if (!editorElement) return; // Create editor extensions const extensions: Extension[] = [ basicSetup, // Tab indentation keymap (toggleable) this._tabIndentComp.of(this.tabIndent ? keymap.of([indentWithTab]) : []), this._lang.of(getLangExtFromMimeType(this.language)), this._readonly.of(EditorState.readOnly.of(this.readonly)), // Word wrapping this._wrap.of(this.wordWrap ? EditorView.lineWrapping : []), // Hide gutters when line numbers are disabled this._gutters.of( !this.lineNumbers ? EditorView.theme({ ".cm-gutters": { display: "none" }, ".cm-content": { paddingLeft: "0px" }, }) : [] as unknown as Extension, ), // Tab size this._tabSizeComp.of(EditorState.tabSize.of(this.tabSize ?? 2)), this._indentUnitComp.of( indentUnit.of(" ".repeat(this.tabSize ?? 2)), ), // Optional max line width (in ch) this._maxLineWidthComp.of( typeof this.maxLineWidth === "number" && this.maxLineWidth > 0 ? EditorView.theme({ ".cm-content": { maxWidth: `${this.maxLineWidth}ch` }, }) : [] as unknown as Extension, ), // Theme (dark -> oneDark) this._themeComp.of(this.theme === "dark" ? oneDark : []), EditorView.updateListener.of((update) => { // Only process user-initiated changes, not Cell-originated updates. // Check if any transaction has the Cell sync annotation - if so, skip. // This prevents the feedback loop: Cell → Editor → setValue → Cell... const isCellSync = update.transactions.some( (tr) => tr.annotation(CTCodeEditor._cellSyncAnnotation), ); if (update.docChanged && !this.readonly && !isCellSync) { const value = update.state.doc.toString(); this.setValue(value); // Keep $mentioned current as user types this._updateMentionedFromContent(); } }), // Handle focus/blur events EditorView.domEventHandlers({ focus: () => { this._cellController.onFocus(); this.emit("ct-focus"); return false; }, blur: () => { this._cellController.onBlur(); this.emit("ct-blur"); return false; }, }), // Add backlink click handler for Cmd/Ctrl+Click this.createBacklinkClickHandler(), // Add backlink decoration plugin to visually style [[backlinks]] this.createBacklinkDecorationPlugin(), // Add autocompletion with backlink support autocompletion({ override: [this.createBacklinkCompletionSource()], activateOnTyping: true, closeOnBlur: true, }), // Enter: accept selected completion, or create novel backlink keymap.of([{ key: "Enter", run: (view) => { // Try accepting an active completion first if (acceptCompletion(view)) return true; // If typing a backlink with no matches, create new backlink const query = this._currentBacklinkQuery(view); if (query != null) { const matches = this.getFilteredMentionable(query); if (matches.length === 0) { const text = query.trim(); if (text.length > 0) { // Instantiate the pattern if available if (this.pattern) { this.createBacklinkFromPattern(text, false); } else { this.emit("backlink-create", { text, navigate: false }); } return true; } } } return false; }, }]), // Intercept Cmd/Ctrl+S when editor is focused keymap.of([{ key: "Mod-s", run: () => { console.log("[ct-code-editor] Intercepted save (Cmd/Ctrl+S)."); return true; // prevent default browser save }, }]), ]; // Add placeholder extension if specified if (this.placeholder) { extensions.push(placeholder(this.placeholder)); } // Create editor state const state = EditorState.create({ doc: this.getValue(), extensions, }); // Create editor view this._editorView = new EditorView({ state, parent: editorElement, }); } override render() { return html`
`; } /** * Focus the editor programmatically */ override focus(): void { this._editorView?.focus(); } /** * Get the current editor state */ get editorState(): EditorState | undefined { return this._editorView?.state; } /** * Get the editor view instance */ get editorView(): EditorView | undefined { return this._editorView; } /** * Extract mentioned charms from current content and write to `$mentioned`. * * Link syntax: [[Name (id)]]. We parse ids and resolve them against * `$mentionable` to produce live Charm instances. */ private _updateMentionedFromContent(): void { if (!this.mentioned) return; const content = this.getValue() || ""; const newMentioned = this._extractMentionedCharms(content); // Compare by id set to avoid unnecessary writes const rawMentioned = this.mentioned.get(); const currentSource = Array.isArray(rawMentioned) ? rawMentioned : isCell(rawMentioned) ? ((rawMentioned.get() ?? []) as MentionableArray) : []; const current: Mentionable[] = currentSource.filter(( value, ): value is Mentionable => Boolean(value)); const curIds = new Set( current .map((c) => getEntityId(c)?.["/"]) .filter((id): id is string => typeof id === "string"), ); const newIds = new Set( newMentioned .map((c) => getEntityId(c)?.["/"]) .filter((id): id is string => typeof id === "string"), ); if (curIds.size === newIds.size) { let same = true; for (const id of newIds) { if (!curIds.has(id)) { same = false; break; } } if (same) return; // No change } const tx = this.mentioned.runtime.edit(); this.mentioned.withTx(tx).set(newMentioned); tx.commit(); } /** * Parse content to a list of unique Charms referenced by [[...]] links. */ private _extractMentionedCharms(content: string): Mentionable[] { const mentionableCell = this._getMentionableCell(); if (!content || !mentionableCell) return []; const ids: string[] = []; const regex = /\[\[[^\]]*?\(([^)]+)\)\]\]/g; let match: RegExpExecArray | null; while ((match = regex.exec(content)) !== null) { const id = match[1]; if (id) ids.push(id); } // Resolve unique ids to charms using mentionable list const seen = new Set(); const result: Mentionable[] = []; for (const id of ids) { if (seen.has(id)) continue; const charm = this.findCharmById(id); if (charm) { result.push(charm.get()); seen.add(id); } } return result; } /** * Resolve the active mentionable cell. */ private _getMentionableCell(): Cell | null { return this.mentionable ?? null; } } globalThis.customElements.define("ct-code-editor", CTCodeEditor);