Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133import 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-<n>"`. */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<typeof setTimeout> | 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) : [], ], }), }); }}