UNPKG

loro-codemirror

Version:
303 lines (302 loc) 10.8 kB
import { EditorView, ViewUpdate, layer, Direction, RectangleMarker, } from "@codemirror/view"; import { Awareness, Cursor, LoroDoc, LoroText, } from "loro-crdt"; import { Annotation, EditorSelection, SelectionRange, StateEffect, StateField, } from "@codemirror/state"; export const loroCursorTheme = EditorView.baseTheme({ ".loro-cursor": { position: "absolute", width: "2px", display: "inline-block", height: "1.2em", }, ".loro-cursor::before": { position: "absolute", top: "1.3em", left: "0", content: "var(--name)", padding: "2px 6px", fontSize: "12px", borderRadius: "3px", whiteSpace: "nowrap", userSelect: "none", opacity: "0.7", }, ".loro-selection": { opacity: "0.5", }, }); // We should use layer https://github.com/codemirror/dev/issues/989 export const remoteAwarenessAnnotation = Annotation.define(); export const remoteAwarenessEffect = StateEffect.define(); export const remoteAwarenessStateField = StateField.define({ create() { return { remoteCursors: new Map(), isCheckout: false }; }, update(value, tr) { for (const effect of tr.effects) { if (effect.is(remoteAwarenessEffect)) { switch (effect.value.type) { case "update": const { peer: uid, user, cursor } = effect.value; value.remoteCursors.set(uid, { cursor, user, }); break; case "delete": value.remoteCursors.delete(effect.value.peer); break; case "checkout": value.isCheckout = effect.value.checkout; } } } return value; }, }); const isRemoteCursorUpdate = (update) => { const effect = update.transactions .flatMap((transaction) => transaction.effects) .filter((effect) => effect.is(remoteAwarenessEffect)); return update.docChanged || update.viewportChanged || effect.length > 0; }; export const createCursorLayer = () => { return layer({ above: true, class: "loro-cursor-layer", update: isRemoteCursorUpdate, markers: (view) => { const { remoteCursors: remoteStates, isCheckout } = view.state.field(remoteAwarenessStateField); if (isCheckout) { return []; } return Array.from(remoteStates.values()).flatMap((state) => { var _a, _b; const selectionRange = EditorSelection.cursor(state.cursor.anchor); return RemoteCursorMarker.createCursor(view, selectionRange, ((_a = state.user) === null || _a === void 0 ? void 0 : _a.name) || "unknown", ((_b = state.user) === null || _b === void 0 ? void 0 : _b.colorClassName) || ""); }); }, }); }; export const createSelectionLayer = () => layer({ above: false, class: "loro-selection-layer", update: isRemoteCursorUpdate, markers: (view) => { const { remoteCursors: remoteStates, isCheckout } = view.state.field(remoteAwarenessStateField); if (isCheckout) { return []; } return Array.from(remoteStates.entries()) .filter(([_, state]) => state.cursor.head !== undefined && state.cursor.anchor !== state.cursor.head) .flatMap(([_, state]) => { var _a; const selectionRange = EditorSelection.range(state.cursor.anchor, state.cursor.head); const markers = RectangleMarker.forRange(view, `loro-selection ${((_a = state.user) === null || _a === void 0 ? void 0 : _a.colorClassName) || ""}`, selectionRange); return markers; }); }, }); /** * Renders a blinking cursor to indicate the cursor of another user. */ export class RemoteCursorMarker { constructor(left, top, height, name, colorClassName) { this.left = left; this.top = top; this.height = height; this.name = name; this.colorClassName = colorClassName; } draw() { const elt = document.createElement("div"); this.adjust(elt); return elt; } update(elt) { this.adjust(elt); return true; } adjust(element) { element.style.left = `${this.left}px`; element.style.top = `${this.top}px`; element.style.height = `${this.height}px`; element.className = `loro-cursor ${this.colorClassName}`; element.style.setProperty("--name", `"${this.name}"`); } eq(other) { return (this.left === other.left && this.top === other.top && this.height === other.height && this.name === other.name); } static createCursor(view, position, displayName, colorClassName) { const absolutePosition = this.calculateAbsoluteCursorPosition(position, view); if (!absolutePosition) { return []; } const rect = view.scrollDOM.getBoundingClientRect(); const left = view.textDirection == Direction.LTR ? rect.left : rect.right - view.scrollDOM.clientWidth; const baseLeft = left - view.scrollDOM.scrollLeft; const baseTop = rect.top - view.scrollDOM.scrollTop; return [ new RemoteCursorMarker(absolutePosition.left - baseLeft, absolutePosition.top - baseTop, absolutePosition.bottom - absolutePosition.top, displayName, colorClassName), ]; } static calculateAbsoluteCursorPosition(position, view) { const cappedPositionHead = Math.max(0, Math.min(view.state.doc.length, position.anchor)); return view.coordsAtPos(cappedPositionHead, position.assoc || 1); } } const parseAwarenessUpdate = (doc, awareness, arg) => { const effects = []; const { updated, added, removed } = arg; for (const update of updated.concat(added)) { const effect = getEffects(doc, awareness, update); if (effect) { effects.push(effect); } } return effects; }; const getEffects = (doc, awareness, peer) => { const states = awareness.getAllStates(); const state = states[peer]; if (!state) { return; } if (peer === doc.peerIdStr) { return; } if (state.type === "delete") { return remoteAwarenessEffect.of({ type: "delete", peer: state.uid, }); } const anchor = Cursor.decode(state.cursor.anchor); const anchorPos = doc.getCursorPos(anchor).offset; let headPos = anchorPos; if (state.cursor.head) { // range const head = Cursor.decode(state.cursor.head); headPos = doc.getCursorPos(head).offset; } return remoteAwarenessEffect.of({ type: "update", peer: state.uid, cursor: { anchor: anchorPos, head: headPos }, user: state.user, }); }; /** * @deprecated Use EphemeralPlugin instead */ export class AwarenessPlugin { constructor(view, doc, user, awareness, getUserId, getTextFromDoc) { this.view = view; this.doc = doc; this.user = user; this.awareness = awareness; this.getUserId = getUserId; this.getTextFromDoc = getTextFromDoc; this.sub = this.doc.subscribe((e) => { if (e.by === "local") { // update remote cursor position const effects = []; for (const peer of this.awareness.peers()) { const effect = getEffects(this.doc, this.awareness, peer); if (effect) { effects.push(effect); } } this.view.dispatch({ effects, }); } else if (e.by === "checkout") { // TODO: better way this.view.dispatch({ effects: [ remoteAwarenessEffect.of({ type: "checkout", checkout: this.doc.isDetached(), }), ], }); } }); } update(update) { if (!update.selectionSet && !update.focusChanged && !update.docChanged) { return; } const selection = update.state.selection.main; if (this.view.hasFocus && !this.doc.isDetached()) { const cursorState = getCursorState(this.doc, selection.anchor, selection.head, this.getTextFromDoc); this.awareness.setLocalState({ type: "update", uid: this.getUserId ? this.getUserId() : this.doc.peerIdStr, cursor: cursorState, user: this.user, }); } else { // when checkout or blur this.awareness.setLocalState({ type: "delete", uid: this.getUserId ? this.getUserId() : this.doc.peerIdStr, }); } } destroy() { var _a; (_a = this.sub) === null || _a === void 0 ? void 0 : _a.call(this); this.awareness.setLocalState({ type: "delete", uid: this.getUserId ? this.getUserId() : this.doc.peerIdStr, }); } } export class RemoteAwarenessPlugin { constructor(view, doc, awareness) { this.view = view; this.doc = doc; this.awareness = awareness; const listener = async (arg, origin) => { if (origin === "local") return; this.view.dispatch({ effects: parseAwarenessUpdate(this.doc, this.awareness, arg), }); }; this._awarenessListener = listener; this.awareness.addListener(listener); } destroy() { if (this._awarenessListener) this.awareness.removeListener(this._awarenessListener); } } export const getCursorState = (doc, anchor, head, getTextFromDoc) => { var _a, _b; if (anchor === head) { head = undefined; } const anchorCursor = (_a = getTextFromDoc(doc).getCursor(anchor)) === null || _a === void 0 ? void 0 : _a.encode(); if (!anchorCursor) { throw new Error("cursor head not found"); } let headCursor = undefined; if (head !== undefined) { headCursor = (_b = getTextFromDoc(doc).getCursor(head)) === null || _b === void 0 ? void 0 : _b.encode(); } return { anchor: anchorCursor, head: headCursor, }; };