import { html, PropertyValues } from "lit"; import { property } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import { styles } from "./styles.ts"; import { crosshairCursor, drawSelection, dropCursor, EditorView, highlightActiveLine, highlightActiveLineGutter, highlightSpecialChars, keymap, lineNumbers, placeholder, rectangularSelection, } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap, indentWithTab, } from "@codemirror/commands"; import { Annotation, Compartment, EditorState, Extension, Prec, } from "@codemirror/state"; import { bracketMatching, defaultHighlightStyle, foldGutter, foldKeymap, indentOnInput, indentUnit, LanguageSupport, syntaxHighlighting, } from "@codemirror/language"; import { javascript as createJavaScript } from "@codemirror/lang-javascript"; import { markdown as createMarkdown } from "@codemirror/lang-markdown"; import { GFM } from "@lezer/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 { autocompletion, closeBrackets, closeBracketsKeymap, Completion, CompletionContext, completionKeymap, CompletionResult, completionStatus, startCompletion, } from "@codemirror/autocomplete"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; import { lintKeymap } from "@codemirror/lint"; import { type CellHandle, isCellHandle, NAME, type RuntimeClient, } from "@commonfabric/runtime-client"; import { stringSchema } from "@commonfabric/runner/schemas"; import { type InputTimingOptions } from "../../core/input-timing-controller.ts"; import { createStringCellController } from "../../core/cell-controller.ts"; import { consume } from "@lit/context"; import { runtimeContext, spaceContext } from "../../runtime-context.ts"; import type { DID } from "@commonfabric/identity"; import { type StoredFile, uploadFile } from "../../utils/file-cell-storage.ts"; import { Mentionable, MentionableArray, MentionableArraySchema, } from "../../core/mentionable.ts"; import { atomicBacklinkRanges, backlinkEditFilter, backlinkField, createBacklinkDecorationPlugin, } from "./features/backlinks.ts"; import { createProseMarkdownPlugin } from "./features/prose-markdown.ts"; function escapeMarkdownImageAltText(text: string): string { return text.replace(/\\/g, "\\\\") .replace(/\[/g, "\\[") .replace(/\]/g, "\\]") .replace(/\r?\n/g, " "); } /** * 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 }), extensions: GFM, }); 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; }; /** * CFCodeEditor - Code editor component with syntax highlighting and debounced changes * * @element cf-code-editor * * @attr {string|CellHandle} value - Editor content (supports both plain string and CellHandle) * @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 {boolean} autofocus - Auto-focus the editor after initialization (default: false) * @attr {"start"|"end"} cursorPosition - Initial cursor position (default: "start") * @attr {string} timingStrategy - Input timing strategy: "immediate" | "debounce" | "throttle" | "blur" * @attr {number} timingDelay - Delay in milliseconds for debounce/throttle (default: 500) * @attr {CellHandle} mentionable - Cell of mentionable items for @/@[[ completion * @attr {Array} mentioned - Optional Cell of live Pieces mentioned in content * @attr {boolean} wordWrap - Enable soft line wrapping (default: true) * @attr {boolean} lineNumbers - Show line numbers gutter (default: false) * @attr {number|string} maxLineWidth - Optional max line width. Numbers are * treated as ch units (e.g. 80 → "80ch"), strings are used as-is * (e.g. "700px", "50rem"). 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. * @attr {"code"|"prose"} mode - Editor mode; "prose" enables markdown prose editing. * @attr {CellHandle} pattern - Optional pattern piece used for backlink context. * * @fires cf-change - Fired when content changes with detail: { value, oldValue, language } * @fires cf-focus - Fired on focus * @fires cf-blur - Fired on blur * @fires backlink-click - Fired when a backlink is clicked with Cmd/Ctrl+Enter with detail: { text, piece } * @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, pieceId: any, piece: Cell, navigate: boolean } * * @example * */ export class CFCodeEditor 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: { converter: { fromAttribute(value: string | null) { if (value === null) return undefined; const num = Number(value); return Number.isNaN(num) ? value : num; }, toAttribute(value: number | string | undefined) { return value?.toString() ?? null; }, }, }, tabSize: { type: Number }, tabIndent: { type: Boolean }, theme: { type: String, reflect: true }, mode: { type: String, reflect: true }, autofocus: { type: Boolean }, cursorPosition: { type: String }, }; declare value: CellHandle | 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?: CellHandle | null; declare mentioned?: CellHandle; declare pattern: CellHandle; declare wordWrap: boolean; declare lineNumbers: boolean; declare maxLineWidth?: number | string; declare tabSize: number; declare tabIndent: boolean; declare theme: "light" | "dark"; declare mode: "code" | "prose"; declare autofocus: boolean; declare cursorPosition: "start" | "end"; @consume({ context: runtimeContext, subscribe: true }) @property({ attribute: false }) accessor runtime: RuntimeClient | undefined = undefined; @consume({ context: spaceContext, subscribe: true }) @property({ attribute: false }) accessor contextSpace: DID | undefined = undefined; 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 _setupComp = new Compartment(); private _modeComp = new Compartment(); private _proseMarkdownComp = new Compartment(); private _cleanupFns: Array<() => void> = []; private _mentionableUnsub: (() => void) | null = null; private _mentionedUnsub: (() => void) | null = null; private _autofocusPending = false; private _autofocusFrame: number | null = null; private _autofocusIntersectionObserver: IntersectionObserver | null = null; private _autofocusResizeObserver: ResizeObserver | null = null; // Track previous backlink names to detect changes for syncing to piece NAME private _previousBacklinkNames = new Map(); // Track subscriptions to piece NAME cells for bidirectional sync private _pieceNameSubscriptions = new Map void>(); // Cache of resolved piece cell IDs: index in mentionable array → stable piece cell ID. // Populated asynchronously when mentionable changes via resolveAsCell(). private _resolvedPieceIds = new Map(); // 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, }, onChange: (newValue: string, oldValue: string) => { this.emit("cf-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.mode = "code"; this.autofocus = false; this.cursorPosition = "start"; this.mentionable = null; } /** * Create a backlink completion source for [[backlinks]] * The dropdown stays open as long as cursor is inside [[... */ private createBacklinkCompletionSource() { return (context: CompletionContext): CompletionResult | null => { // Look for incomplete backlinks: [[ followed by optional text (not yet closed) const backlink = context.matchBefore(/\[\[([^\]]*)?/); if (!backlink) { return null; } // Check if this is already a complete backlink WITH an ID (not just auto-closed brackets) // Pattern: [[Name (id)]] - if there's an ID, don't show dropdown const afterCursor = context.state.doc.sliceString( context.pos, context.pos + 50, // Look ahead for potential ]] and ID pattern ); const hasIdPattern = afterCursor.match(/^\s*\([^)]+\)\]\]/); if (hasIdPattern) { // This is a complete backlink with ID - don't show dropdown return null; } const query = backlink.text.slice(2); // Remove [[ prefix const mentionable = this.getFilteredMentionable(query); // Check if auto-close added ]] after cursor const hasAutoCloseBrackets = afterCursor.startsWith("]]"); // Build options from existing mentionable items const options: Completion[] = mentionable.map(([piece, index]) => { const pieceId = this._getPieceId(index); const pieceName = piece.key(NAME).get() || ""; const insertText = `${pieceName} (${pieceId})`; return { label: pieceName, // Use apply function to handle auto-closed brackets apply: (view, _completion, from, to) => { // If auto-close added ]], extend replacement to include them const replaceTo = hasAutoCloseBrackets ? to + 2 : to; view.dispatch({ changes: { from, to: replaceTo, insert: insertText + "]]" }, selection: { anchor: from + insertText.length + 2 }, }); }, type: "text", info: "Link to " + pieceName, }; }); // Only show existing pieces - no "Create" option // Enter will complete with exact match or create new piece return { from: backlink.from + 2, // Start after [[ (original behavior) options, }; }; } /** * Get filtered mentionable items based on query. * Returns tuples of [CellHandle, originalIndex] so callers can look up * the stable resolved piece ID via _getPieceId(index). */ private getFilteredMentionable( query: string, ): Array<[CellHandle, number]> { const handle = this.mentionable; if (!handle) { return []; } const mentionableData = (handle.get() ?? []) as MentionableArray; if (mentionableData.length === 0) { return []; } const queryLower = query.toLowerCase(); const matches: Array<[CellHandle, number]> = []; for (let i = 0; i < mentionableData.length; i++) { const mention = mentionableData[i]; if ( mention && mention[NAME] ?.toLowerCase() ?.includes(queryLower) ) { matches.push([handle.key(i) as CellHandle, i]); } } return matches; } /** * Find exact case-insensitive match in mentionable items. * Returns [CellHandle, originalIndex] or null. */ private _findExactMentionable( query: string, ): [CellHandle, number] | null { const handle = this.mentionable; if (!handle) return null; const mentionableData = (handle.get() ?? []) as MentionableArray; const queryLower = query.toLowerCase(); for (let i = 0; i < mentionableData.length; i++) { const mention = mentionableData[i]; const name = mention?.[NAME] ?? ""; if (name.toLowerCase() === queryLower) { return [handle.key(i), i]; } } return null; } /** * Complete a backlink by inserting the full [[Name (id)]] format */ private _completeBacklinkWithId( view: EditorView, _queryText: string, pieceName: string, pieceId: string, ): void { // Find the [[ start position const pos = view.state.selection.main.head; const doc = view.state.doc.toString(); const beforeCursor = doc.slice(0, pos); const bracketPos = beforeCursor.lastIndexOf("[["); if (bracketPos === -1) return; // Check if there are auto-closed brackets after cursor const afterCursor = doc.slice(pos, pos + 2); const hasAutoClose = afterCursor === "]]"; // Build the complete backlink const fullBacklink = `[[${pieceName} (${pieceId})]]`; // Calculate replacement range const replaceFrom = bracketPos; const replaceTo = hasAutoClose ? pos + 2 : pos; view.dispatch({ changes: { from: replaceFrom, to: replaceTo, insert: fullBacklink }, selection: { anchor: replaceFrom + fullBacklink.length }, }); } /** * Complete a backlink as pending (just [[text]] without ID) */ private _completeBacklinkText(view: EditorView): void { const pos = view.state.selection.main.head; const afterCursor = view.state.doc.sliceString(pos, pos + 2); if (afterCursor === "]]") { // Already has closing brackets - just move cursor past them view.dispatch({ selection: { anchor: pos + 2 }, }); } else { // Insert ]] to complete the backlink view.dispatch({ changes: { from: pos, to: pos, insert: "]]" }, selection: { anchor: pos + 2 }, }); } } /** * Handle backlink clicks: * - Click on pill: navigate to linked piece * - Click when expanded (editing mode): places cursor normally */ private createBacklinkClickHandler() { return EditorView.domEventHandlers({ mousedown: (event, view) => { // Check if clicking on a collapsed pill (cm-backlink-pill) const target = event.target as HTMLElement; if (target.closest(".cm-backlink-pill")) { // Navigate to the backlink event.preventDefault(); setTimeout(() => this.handlePillClick(view, event), 0); return true; } return false; }, }); } /** * Handle click on a collapsed backlink pill - navigate to the linked piece */ private async handlePillClick( view: EditorView, event: MouseEvent, ): Promise { // Get the position in the document from the click coordinates const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); if (pos === null) return; const doc = view.state.doc; const line = doc.lineAt(pos); const lineText = line.text; // Find all backlinks on this line const backlinkRegex = /\[\[([^\]]+)\]\]/g; let match; while ((match = backlinkRegex.exec(lineText)) !== null) { const matchStart = line.from + match.index; const _matchEnd = matchStart + match[0].length; const innerText = match[1]; // Check if has ID const idMatch = innerText.match(/^(.+?)\s+\(([^)]+)\)$/); if (!idMatch) continue; // Skip incomplete backlinks const name = idMatch[1]; const id = idMatch[2]; const nameStart = matchStart + 2; // After [[ const nameEnd = nameStart + name.length; // Check if click position is within the name portion (the visible pill) if (pos >= nameStart && pos <= nameEnd) { const runtime = this.pattern.runtime(); const space = this.pattern.space(); const cell = await runtime.getCell(space, id); this.emit("backlink-click", { id, text: innerText, piece: cell, }); return; } } } /** * 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; const piece = backlinkId ? this.findPieceById(backlinkId) : null; if (piece) { this.emit("backlink-click", { id: backlinkId, text: backlinkText, piece, }); 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 async createBacklinkFromPattern( backlinkText: string, navigate: boolean, ): Promise { // The op runs against the pattern's own runtime, not the ambient // `this.runtime` (which RootView clears to undefined on logout). const rt = this.pattern.runtime(); try { // Simple random ID generator for noteId (matches pattern used in note.tsx) const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`; const program = this.pattern.get(); if (!program) return; const pattern = JSON.parse(program); // Provide mentionable list so the pattern can wire backlinks immediately const inputs: Record = { title: backlinkText, content: "", noteId: generateId(), // Ensure notes created via [[mention]] have unique IDs }; // The note is created in the same space as the pattern it backlinks // from — creation, like every page op, names its space. const page = await rt.createPage(pattern, this.pattern.space(), inputs); if (!page) { throw new Error("Could not create piece."); } const pieceId = page.id(); // Insert the ID into the text if we have an editor if (this._editorView && pieceId) { this._insertBacklinkId(backlinkText, pieceId, navigate); } this.emit("backlink-create", { text: backlinkText, pieceId, piece: page.cell(), navigate, }); } catch (error) { // A disposal race (logout, runtime swap) cancels the create; that is // cancellation, not a failure to surface. if (rt.signal.aborted) return; 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 piece by ID in the mentionable list. * Uses pre-resolved stable piece IDs from _resolvedPieceIds cache. */ private findPieceById(id: string): CellHandle | null { const handle = this.mentionable; if (!handle) return null; const mentionableData = (handle.get() ?? []) as MentionableArray; if (mentionableData.length === 0) return null; for (let i = 0; i < mentionableData.length; i++) { const pieceValue = mentionableData[i]; if (!pieceValue) continue; const pieceId = this._getPieceId(i); if (pieceId === id) { return handle.key(i) as CellHandle; } } return null; } /** * Get the stable piece cell ID for a mentionable item at the given index. * Returns the pre-resolved ID if available, otherwise falls back to * the sub-cell ID (which may be unstable across recomputations). */ private _getPieceId(index: number): string { return this._resolvedPieceIds.get(index) ?? (this.mentionable?.key(index)?.id() ?? ""); } /** * Resolve stable piece cell IDs for all items in the mentionable list. * Each mentionable sub-cell (mentionable.key(i)) may be an indirect * reference whose ID changes when the list recomputes. resolveAsCell() * follows the indirection to get the piece's own stable cell ID. */ private async _resolvePieceIds(): Promise { const handle = this.mentionable; if (!handle) return; const mentionableData = (handle.get() ?? []) as MentionableArray; // Keep a reference to the current mentionable to detect staleness const currentMentionable = this.mentionable; const newResolved = new Map(); // Resolve all piece IDs in parallel const promises = mentionableData.map(async (item, i) => { if (!item) return; try { const subCell = handle.key(i); const resolved = await subCell.resolveAsCell(); const resolvedId = resolved.id(); if (resolvedId) { newResolved.set(i, resolvedId); } } catch { // If resolution fails, we'll fall back to the sub-cell ID } }); await Promise.all(promises); // Only apply if mentionable hasn't changed while we were resolving if (this.mentionable === currentMentionable) { this._resolvedPieceIds = newResolved; // Re-resolve mentioned from content now that we have stable IDs this._updateMentionedFromContent(); } } private getValue(): string { return this._cellController.getValue(); } private setValue(newValue: string): void { this._cellController.setValue(newValue); } override connectedCallback() { super.connectedCallback(); if (this.autofocus && this._editorView) { this._queueAutofocus(); } } override disconnectedCallback() { super.disconnectedCallback(); this._cleanup(); } private _updateEditorFromCellValue(): void { if (!this._editorView) return; const newValue = this.getValue(); // Guard against undefined - can happen when cell isn't bound yet if (newValue === undefined || newValue === null) return; 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: CFCodeEditor._cellSyncAnnotation.of(true), }); // Ensure mentioned pieces 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.hasCell()) { const cell = this._cellController.getCell(); if (cell) { this._cellSyncUnsub = cell.subscribe(() => { // First update the editor content this._updateEditorFromCellValue(); // Then trigger component update if originally enabled if (originalTriggerUpdate) { this.requestUpdate(); } }); } } } private _cleanupCellSyncHandler(): void { if (this._cellSyncUnsub) { this._cellSyncUnsub(); this._cellSyncUnsub = null; } } /** * Subscribe to mentionable changes to re-resolve mentioned pieces when * the source list updates. */ private _setupMentionableSyncHandler(): void { if (this._mentionableUnsub) { this._mentionableUnsub(); this._mentionableUnsub = null; } if (!this.mentionable) return; // this.mentionable is already wrapped with asSchema(MentionableArraySchema) // in willUpdate, so the runtime resolves @link indirection before // delivering values to subscribers. const unsubscribe = this.mentionable .subscribe((_value) => { // Clear stale resolved IDs and re-resolve asynchronously this._resolvedPieceIds.clear(); this._resolvePieceIds(); this._updateMentionedFromContent(); }); this._mentionableUnsub = unsubscribe; } /** * Subscribe to mentioned cell changes to handle external updates. * Unsubscribes from previous cell when binding changes. */ private _setupMentionedSyncHandler(): void { if (this._mentionedUnsub) { this._mentionedUnsub(); this._mentionedUnsub = null; } if (!this.mentioned) return; // this.mentioned is already wrapped with asSchema(MentionableArraySchema) // in willUpdate. const unsubscribe = this.mentioned .subscribe((_value) => { // Re-sync piece name subscriptions when mentioned list changes externally this._setupPieceNameSubscriptions(); }); this._mentionedUnsub = unsubscribe; } private _cleanup(): void { this._cancelAutofocus(); this._cleanupCellSyncHandler(); this._cleanupPieceNameSubscriptions(); this._resolvedPieceIds.clear(); if (this._mentionableUnsub) { this._mentionableUnsub(); this._mentionableUnsub = null; } if (this._mentionedUnsub) { this._mentionedUnsub(); this._mentionedUnsub = null; } this._cleanupFns.forEach((fn) => fn()); this._cleanupFns = []; if (this._editorView) { this._editorView.destroy(); this._editorView = undefined; } } override willUpdate(changedProperties: Map) { if (changedProperties.has("mentionable")) { if (this.mentionable) { this.mentionable = this.mentionable.asSchema(MentionableArraySchema); } this._resolvedPieceIds.clear(); this._resolvePieceIds(); this._setupMentionableSyncHandler(); this._updateMentionedFromContent(); } if (changedProperties.has("mentioned")) { if (this.mentioned) { this.mentioned = this.mentioned.asSchema(MentionableArraySchema); } this._setupMentionedSyncHandler(); this._updateMentionedFromContent(); } } 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, stringSchema); 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 maxWidth = typeof n === "number" ? (n > 0 ? `${n}ch` : undefined) : n; const ext = maxWidth ? EditorView.theme({ ".cm-content": { maxWidth }, }) : [] 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 : [], ), }); } // Update mode (setup extensions + prose styling + markdown rendering) if (changedProperties.has("mode") && this._editorView) { this._editorView.dispatch({ effects: [ this._setupComp.reconfigure(this._getSetupExtensions()), this._modeComp.reconfigure(this._getModeExtension()), this._proseMarkdownComp.reconfigure( this.mode === "prose" ? createProseMarkdownPlugin() : [], ), ], }); } if (changedProperties.has("autofocus")) { if (this.autofocus) { this._queueAutofocus(); } else { this._cancelAutofocus(); } } } protected override firstUpdated(_changedProperties: PropertyValues): void { super.firstUpdated(_changedProperties); this._initializeEditor(); // Bind the initial value to the cell controller this._cellController.bind(this.value, stringSchema); // 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._setupMentionedSyncHandler(); this._updateMentionedFromContent(); // Initialize backlink name tracking for sync detection this._initializeBacklinkNameTracking(); // Set up subscriptions for bidirectional NAME sync this._setupPieceNameSubscriptions(); this._queueAutofocus(); } private _queueAutofocus(): void { if (!this.autofocus) return; this._autofocusPending = true; this._observeAutofocusVisibility(); this._scheduleAutofocusAttempt(); } private _scheduleAutofocusAttempt(): void { if (!this._autofocusPending || this._autofocusFrame !== null) return; if (typeof requestAnimationFrame !== "function") { this._attemptAutofocus(); return; } this._autofocusFrame = requestAnimationFrame(() => { this._autofocusFrame = null; this._attemptAutofocus(); }); } private _attemptAutofocus(): void { if (!this._autofocusPending || !this.autofocus) { this._cancelAutofocus(); return; } if (!this._editorView) return; if (typeof document !== "undefined" && !this.isConnected) return; if (!this._isVisibleForAutofocus()) { this._observeAutofocusVisibility(); return; } this._editorView.focus(); this._autofocusPending = false; this._teardownAutofocusObservers(); } private _isVisibleForAutofocus(): boolean { if (typeof document === "undefined") return true; const rect = this.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } private _observeAutofocusVisibility(): void { if (typeof document === "undefined") return; if ( !this._autofocusIntersectionObserver && typeof IntersectionObserver !== "undefined" ) { this._autofocusIntersectionObserver = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { this._scheduleAutofocusAttempt(); } }, ); this._autofocusIntersectionObserver.observe(this); } if ( !this._autofocusResizeObserver && typeof ResizeObserver !== "undefined" ) { this._autofocusResizeObserver = new ResizeObserver(() => { if (this._isVisibleForAutofocus()) { this._scheduleAutofocusAttempt(); } }); this._autofocusResizeObserver.observe(this); } } private _cancelAutofocus(): void { this._autofocusPending = false; if ( this._autofocusFrame !== null && typeof cancelAnimationFrame === "function" ) { cancelAnimationFrame(this._autofocusFrame); } this._autofocusFrame = null; this._teardownAutofocusObservers(); } private _teardownAutofocusObservers(): void { this._autofocusIntersectionObserver?.disconnect(); this._autofocusIntersectionObserver = null; this._autofocusResizeObserver?.disconnect(); this._autofocusResizeObserver = null; } /** * Initialize the backlink name tracking map with current document state. * This establishes a baseline so we can detect subsequent name changes. */ private _initializeBacklinkNameTracking(): void { if (!this._editorView) return; const backlinks = this._editorView.state.field(backlinkField); this._previousBacklinkNames.clear(); for (const bl of backlinks) { if (bl.id) { this._previousBacklinkNames.set(bl.id, bl.name); } } } private _getSetupExtensions(): Extension { // Shared extensions needed in both modes const shared: Extension[] = [ highlightSpecialChars(), history(), drawSelection(), dropCursor(), EditorState.allowMultipleSelections.of(true), indentOnInput(), keymap.of([ ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, ]), ]; if (this.mode === "prose") { // Prose mode: minimal setup — no line numbers, no bracket matching, // no fold gutters, no selection highlights, no rectangular select, // no defaultHighlightStyle (our decoration plugin handles all rendering) return shared; } // Code mode: full setup matching what basicSetup provided return [ ...shared, lineNumbers(), highlightActiveLineGutter(), foldGutter(), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), bracketMatching(), closeBrackets(), rectangularSelection(), crosshairCursor(), highlightActiveLine(), highlightSelectionMatches(), keymap.of([ ...closeBracketsKeymap, ...foldKeymap, ...lintKeymap, ]), ]; } private _getModeExtension(): Extension { if (this.mode !== "prose") return []; const hasCustomWidth = this.maxLineWidth !== undefined; return [ EditorView.theme({ ".cm-content": { fontFamily: "var(--cf-code-editor-font-family-prose, var(--cf-theme-font-family, var(--cf-font-family-sans)))", lineHeight: "1.6", padding: "8px 0", ...(!hasCustomWidth && { maxWidth: "var(--cf-code-editor-prose-max-width, var(--cf-layout-width-prose, 700px))", }), margin: "0 auto", }, ".cm-line": { padding: "1px 0", }, }), ]; } private _initializeEditor(): void { const editorElement = this.shadowRoot?.querySelector( ".code-editor", ) as HTMLElement; if (!editorElement) return; // Create editor extensions const extensions: Extension[] = [ this._setupComp.of(this._getSetupExtensions()), // Backlink protection: StateField + atomic ranges + edit filter backlinkField, atomicBacklinkRanges, backlinkEditFilter, // 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 (number → ch, string → as-is) this._maxLineWidthComp.of( (() => { const n = this.maxLineWidth; const maxWidth = typeof n === "number" ? (n > 0 ? `${n}ch` : undefined) : n; return maxWidth ? EditorView.theme({ ".cm-content": { maxWidth } }) : [] as unknown as Extension; })(), ), // Theme (dark -> oneDark) this._themeComp.of(this.theme === "dark" ? oneDark : []), // Prose/code mode extensions this._modeComp.of(this._getModeExtension()), this._proseMarkdownComp.of( this.mode === "prose" ? createProseMarkdownPlugin() : [], ), 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(CFCodeEditor._cellSyncAnnotation), ); if (update.docChanged && !this.readonly && !isCellSync) { const value = update.state.doc.toString(); this.setValue(value); // Keep $mentioned current as user types this._updateMentionedFromContent(); // Sync name changes to linked pieces this._detectAndSyncNameChanges(); // Refresh subscriptions for any new backlinks this._setupPieceNameSubscriptions(); } }), // Handle focus/blur events EditorView.domEventHandlers({ focus: () => { this._cellController.onFocus(); this.emit("cf-focus"); return false; }, blur: () => { this._cellController.onBlur(); this.emit("cf-blur"); return false; }, paste: (event, view) => { if (this.readonly || this.disabled) return false; const files = Array.from(event.clipboardData?.files ?? []) .filter((file) => file.type.startsWith("image/")); if (files.length === 0) return false; event.preventDefault(); this._handleImagePaste(files, view); return true; }, }), // Add backlink click handler for Cmd/Ctrl+Click this.createBacklinkClickHandler(), // Add backlink decoration plugin to visually style [[backlinks]] createBacklinkDecorationPlugin(), // Add autocompletion with backlink support autocompletion({ override: [this.createBacklinkCompletionSource()], activateOnTyping: true, defaultKeymap: true, // Don't auto-select first option - let user explicitly choose or press Enter selectOnOpen: false, }), // Force completion to stay open when inside [[ context EditorView.updateListener.of((update) => { if (!update.docChanged) return; const query = this._currentBacklinkQuery(update.view); if (query !== null) { const status = completionStatus(update.state); if (status === null) { setTimeout(() => startCompletion(update.view), 0); } } }), // Enter: complete backlink OR exit editing mode (no newline inside backlinks) // Use Prec.highest to ensure this runs before autocompletion handlers Prec.highest(keymap.of([{ key: "Enter", run: (view) => { const pos = view.state.selection.main.head; const backlinks = view.state.field(backlinkField); // Check if cursor is inside a complete backlink (from [[ up to but not after ]]) // Enter inside backlink exits editing; Enter after ]] allows normal newline for (const bl of backlinks) { if (bl.id && pos >= bl.from && pos < bl.to) { // Cursor is inside the backlink - exit editing mode // Move cursor to after ]] without inserting newline view.dispatch({ selection: { anchor: bl.to }, }); return true; // Consume Enter, no newline } } // If typing a new backlink like [[mention, complete it const query = this._currentBacklinkQuery(view); if (query != null) { const text = query.trim(); if (text.length > 0) { // Check for exact match in mentionable const exactMatch = this._findExactMentionable(text); if (exactMatch) { // Found exact match - insert complete backlink with stable piece ID const [matchCell, matchIndex] = exactMatch; const pieceId = this._getPieceId(matchIndex); const pieceName = matchCell.key(NAME).get() || text; this._completeBacklinkWithId(view, text, pieceName, pieceId); } else if (this.pattern) { // No exact match - create new piece without navigating // First complete the backlink text, then create the piece this._completeBacklinkText(view); // createBacklinkFromPattern will insert the ID and emit event this.createBacklinkFromPattern(text, false); } return true; } } return false; }, }])), // Intercept Cmd/Ctrl+S when editor is focused keymap.of([{ key: "Mod-s", run: () => true, // prevent default browser save }]), ]; // Add placeholder extension if specified if (this.placeholder) { extensions.push(placeholder(this.placeholder)); } // Create editor state const doc = this.getValue() ?? ""; const state = EditorState.create({ doc, extensions, selection: { anchor: this.cursorPosition === "end" ? doc.length : 0 }, }); // 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; } private async _handleImagePaste( files: File[], view: EditorView, ): Promise { const runtime = isCellHandle(this.value) ? this.value.runtime() : this.runtime; // The pasted image's blob belongs to the edited cell's space; fall // back to the view's space from context. const space = isCellHandle(this.value) ? this.value.space() : this.contextSpace; if (!runtime || !space) { const message = !runtime ? "Runtime is not available for pasted image storage" : "Space is not available for pasted image storage"; this.emit("cf-error", { error: new Error(message), message }); return; } try { const storedFiles: StoredFile[] = []; for (const file of files) { storedFiles.push(await uploadFile({ file, runtime, space })); } const markdown = storedFiles .map((file) => `![${escapeMarkdownImageAltText(file.name)}](${file.url})` ) .join("\n"); const selection = view.state.selection.main; view.dispatch({ changes: { from: selection.from, to: selection.to, insert: markdown, }, selection: { anchor: selection.from + markdown.length }, }); this.emit("cf-file-paste", { files: storedFiles }); } catch (error) { this.emit("cf-error", { error: error as Error, message: "Failed to store pasted image", }); } } /** * Extract mentioned pieces from current content and write to `$mentioned`. * * Link syntax: [[Name (id)]]. We parse ids and resolve them against * `$mentionable` to produce live Piece instances. */ private _updateMentionedFromContent(): void { if (!this.mentioned) return; const content = this.getValue() || ""; // Extract IDs from content const newIds = this._extractMentionedIds(content); // Get current mentioned IDs by looking them up in mentionable const curIds = this._getCurrentMentionedIds(); // Compare ID sets to avoid unnecessary writes if (newIds.size === curIds.size) { let same = true; for (const id of newIds) { if (!curIds.has(id)) { same = false; break; } } if (same) { return; // No change } } // Resolve IDs to Mentionable values and update the cell const newMentioned = this._extractMentionedPieces(content); this.mentioned.set(newMentioned); this._setupPieceNameSubscriptions(); } /** * Extract unique piece IDs from content backlinks. */ private _extractMentionedIds(content: string): Set { const ids = new Set(); const regex = /\[\[[^\]]*?\(([^)]+)\)\]\]/g; let match: RegExpExecArray | null; while ((match = regex.exec(content)) !== null) { const id = match[1]; if (id) ids.add(id); } return ids; } /** * Get IDs of currently mentioned pieces by looking them up in mentionable. */ private _getCurrentMentionedIds(): Set { const curIds = new Set(); const mentionedHandle = this.mentioned; if (!mentionedHandle) return curIds; const currentSource = (mentionedHandle.get() ?? []) as MentionableArray; const mentionableHandle = this.mentionable; if (!mentionableHandle) return curIds; const mentionableData = (mentionableHandle.get() ?? []) as MentionableArray; // For each current mentioned value, find its ID by matching in mentionable for (const mentionedValue of currentSource) { if (!mentionedValue) continue; for (let i = 0; i < mentionableData.length; i++) { if (mentionableData[i] === mentionedValue) { const pieceId = this._getPieceId(i); if (pieceId) curIds.add(pieceId); break; } } } return curIds; } /** * Set up subscriptions to piece TITLE cells for bidirectional sync. * We subscribe to title (not NAME) because: * - We UPDATE title when user edits backlink in doc * - NAME is computed from title, so subscribing to NAME would cause feedback loops * - By subscribing to title with same changeGroup, our own edits are filtered out */ private _setupPieceNameSubscriptions(): void { if (!this._editorView) return; const backlinks = this._editorView.state.field(backlinkField); const activeIds = new Set(); for (const bl of backlinks) { if (!bl.id) continue; activeIds.add(bl.id); // Skip if already subscribed if (this._pieceNameSubscriptions.has(bl.id)) continue; const pieceCell = this.findPieceById(bl.id); if (!pieceCell) continue; // Subscribe to TITLE cell (not NAME) - this is what we update const titleCell = pieceCell.key("title"); const pieceId = bl.id; // Subscribe with changeGroup so our own edits are filtered out const unsub = titleCell.subscribe(() => { this._handleExternalTitleChange(pieceId, pieceCell); }); this._pieceNameSubscriptions.set(pieceId, unsub); } // Clean up subscriptions for pieces no longer in document for (const [id, unsub] of this._pieceNameSubscriptions) { if (!activeIds.has(id)) { unsub(); this._pieceNameSubscriptions.delete(id); } } } /** * Handle external title change from a piece - update the pill text in the document. * This is called when a piece's title field changes externally (not from our own edit). */ private _handleExternalTitleChange( pieceId: string, pieceCell: CellHandle, ): void { if (!this._editorView) return; // Get the piece's title (without emoji prefix) const title = pieceCell.key("title").get() as string; if (!title) return; // Find backlink in document const backlinks = this._editorView.state.field(backlinkField); const bl = backlinks.find((b) => b.id === pieceId); if (!bl) return; // Strip emoji from document name for comparison const docNameStripped = bl.name.replace(/^(?:📝|📓|📁|🗒️|🗒)\s*/, ""); // Skip if stripped names match (no actual title change) if (docNameStripped === title) return; // Get the full NAME (with emoji) to insert into document const currentName = pieceCell.key(NAME).get() as string; if (!currentName) return; // Update tracking map BEFORE dispatch so _detectAndSyncNameChanges doesn't // try to sync this change back to the piece (it runs synchronously during dispatch) this._previousBacklinkNames.set(pieceId, currentName); // Update document with annotation to prevent updateListener from calling setValue this._editorView.dispatch({ changes: { from: bl.nameFrom, to: bl.nameTo, insert: currentName }, annotations: CFCodeEditor._cellSyncAnnotation.of(true), }); // Update Cell value IMMEDIATELY (bypass debounce) so Cell sync doesn't revert const newDocValue = this._editorView.state.doc.toString(); if (isCellHandle(this.value)) { this.value.set(newDocValue); } } /** * Clean up all piece NAME subscriptions. */ private _cleanupPieceNameSubscriptions(): void { for (const unsub of this._pieceNameSubscriptions.values()) { unsub(); } this._pieceNameSubscriptions.clear(); } /** * Parse content to a list of unique Pieces referenced by [[...]] links. */ private _extractMentionedPieces(content: string): Mentionable[] { if (!content || !this.mentionable) 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 pieces using mentionable list. // Push CellHandles (not plain values) so the pattern system receives // live cell references that backlinks-index can traverse. const seen = new Set(); const result: Mentionable[] = []; for (const id of ids) { if (seen.has(id)) continue; const piece = this.findPieceById(id); if (piece) { // Push the CellHandle itself — it serializes as a link (via toJSON) // so the runtime resolves it to the actual piece cell, preserving // reactive connections for backlinks computation. result.push(piece as unknown as Mentionable); seen.add(id); } } return result; } /** * Detect name changes in backlinks and sync them to linked piece's NAME property. * Called when document changes. */ private _detectAndSyncNameChanges(): void { if (!this._editorView) return; const backlinks = this._editorView.state.field(backlinkField); const currentNames = new Map(); for (const bl of backlinks) { if (!bl.id) continue; currentNames.set(bl.id, bl.name); const previousName = this._previousBacklinkNames.get(bl.id); if (previousName !== undefined && previousName !== bl.name) { // Name changed! Update the piece's NAME property this._updatePieceName(bl.id, bl.name, previousName); } } this._previousBacklinkNames = currentNames; } /** * Update a piece's name when the backlink text changes. * Tries to update 'title' field first (for patterns where NAME is computed), * then falls back to NAME directly. */ private _updatePieceName( pieceId: string, newName: string, oldName: string, ): void { const pieceCell = this.findPieceById(pieceId); if (!pieceCell) { console.warn( `[cf-code-editor] Cannot update name: piece ${pieceId} not found`, ); return; } // Strip common emoji prefixes to get the raw title // Use alternation instead of character class - emoji are multi-codepoint const titleValue = newName.replace(/^(?:📝|📓|📁|🗒️|🗒)\s*/, ""); // Update 'title' field - for note patterns, NAME is computed from title // (NAME = `📝 ${title}`) so setting title will update NAME automatically pieceCell.key("title").set(titleValue); this.emit("backlink-name-changed", { pieceId, oldName, newName, piece: pieceCell, }); } }