import type { Extension } from "@codemirror/state"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { LoroEphemeralPlugin, LoroSyncPlugin, LoroUndoPlugin, type UserState } from "loro-codemirror"; import { EphemeralStore, UndoManager } from "loro-crdt"; import { TEXT_KEY, type Doc } from "./doc.ts"; export type { UserState }; /** Remote cursor colors, selected with `colorClassName: "nib-user-"`. */ export const PRESENCE_COLORS = ["#e5484d", "#0090ff", "#30a46c", "#f76b15", "#8e4ec6", "#d6409f"]; const presenceTheme = EditorView.theme({ // Labels sit above the cursor so they don't cover the next line. ".loro-cursor::before": { top: "-1.5em", fontSize: "11px", padding: "1px 5px", opacity: "1" }, ...Object.fromEntries( PRESENCE_COLORS.flatMap((color, i) => [ [`.loro-cursor.nib-user-${i}, .loro-cursor.nib-user-${i}::before`, { backgroundColor: color, color: "white" }], [`.loro-selection.nib-user-${i}`, { backgroundColor: `color-mix(in srgb, ${color} 30%, transparent)` }], ]), ), }); /** * A CodeMirror editor custom element bound to a `Doc`, with Loro undo and remote cursors. * Set `doc` (and optionally `presence` and `user`) as properties; the editor mounts once `doc` is set * and the element is connected. Subclasses supply the editing extensions. */ export abstract class CollabEditorElement extends HTMLElement { #doc: Doc | undefined; #presence: EphemeralStore | undefined; #user: UserState = { name: "anonymous", colorClassName: "nib-user-0" }; #view: EditorView | undefined; #disconnectTimer: ReturnType | undefined; /** The document the shadow root's base styles were last mounted for; see `remount`'s check below. */ #styledFor: Document | undefined; readonly #root = this.attachShadow({ mode: "open" }); protected abstract extensions(): Extension; get doc(): Doc | undefined { return this.#doc; } set doc(doc: Doc | undefined) { this.#doc = doc; this.remount(); } get presence(): EphemeralStore | undefined { return this.#presence; } set presence(presence: EphemeralStore | undefined) { this.#presence = presence; this.remount(); } get user(): UserState { return this.#user; } set user(user: UserState) { this.#user = user; this.remount(); } /** The CodeMirror view, for tests and hosts that add their own extensions. */ get view(): EditorView | undefined { return this.#view; } connectedCallback(): void { if (this.#disconnectTimer !== undefined) { // Reconnected in the same tick a disconnect scheduled below: a reparent (floating a panel // into another window moves its DOM across documents), not a real removal. The view, and the // DOM CodeMirror rendered, moved with it; keep it instead of tearing it down and rebuilding. // A fresh EditorView here would fail: CodeMirror mounts its base styles through a stylesheet // constructed once at module load and reused across instances, and a constructed stylesheet // can only be adopted into shadow roots that live in the document that created it. clearTimeout(this.#disconnectTimer); this.#disconnectTimer = undefined; return; } if (!this.#view) this.remount(); } disconnectedCallback(): void { this.#disconnectTimer = setTimeout(() => { this.#disconnectTimer = undefined; this.#view?.destroy(); this.#view = undefined; }, 0); } protected remount(): void { if (this.#disconnectTimer !== undefined) { clearTimeout(this.#disconnectTimer); this.#disconnectTimer = undefined; } this.#view?.destroy(); this.#view = undefined; const doc = this.#doc; if (!doc || !this.isConnected) return; // CodeMirror's base styles mount into the shadow root through `style-mod`, which caches its // constructed stylesheet on the root itself the first time and reuses it on every later mount. // If this root's owning document changed since that first mount (a panel floated into another // window, which moves its DOM, including this host and its shadow root, across documents), that // cached sheet belongs to the wrong document and adopting it here throws. Dropping the cache // lets `style-mod` build a fresh one for the document the root is in now. if (this.#styledFor !== this.ownerDocument) { this.#styledFor = this.ownerDocument; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- style-mod's cache key is a private Symbol. for (const symbol of Object.getOwnPropertySymbols(this.#root)) delete (this.#root as any)[symbol]; } const getText = () => doc.loro.getText(TEXT_KEY); this.#view = new EditorView({ root: this.#root, parent: this.#root, // loro-codemirror owns the initial sync, so the editor mounts empty. state: EditorState.create({ doc: "", extensions: [ this.extensions(), presenceTheme, LoroSyncPlugin(doc.loro, getText), LoroUndoPlugin(doc.loro, new UndoManager(doc.loro, {}), getText), this.#presence ? LoroEphemeralPlugin(doc.loro, this.#presence, this.#user, getText) : [], ], }), }); } }