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) =>
``
)
.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,
});
}
}