UNPKG

loro-codemirror

Version:
205 lines (204 loc) 8.11 kB
import { layer, RectangleMarker } from "@codemirror/view"; import { Cursor, EphemeralStore, LoroDoc, LoroText } from "loro-crdt"; import { getCursorState, remoteAwarenessEffect, RemoteCursorMarker } from "./awareness.js"; import { EditorSelection, StateEffect, StateField } from "@codemirror/state"; export const ephemeralEffect = StateEffect.define(); export const ephemeralStateField = StateField.define({ create() { return { remoteCursors: new Map(), remoteUsers: new Map(), isCheckout: false }; }, update(value, tr) { for (const effect of tr.effects) { if (effect.is(ephemeralEffect)) { switch (effect.value.type) { case "delete": value.remoteCursors.delete(effect.value.peer); break; case "cursor": const { peer, cursor } = effect.value; value.remoteCursors.set(peer, cursor); break; case "user": const { peer: uid, user } = effect.value; value.remoteUsers.set(uid, user); break; case "checkout": value.isCheckout = effect.value.checkout; } } } return value; }, }); const getCursorEffect = (doc, peer, state) => { const anchor = Cursor.decode(state.anchor); const anchorPos = doc.getCursorPos(anchor).offset; let headPos = anchorPos; if (state.head) { // range const head = Cursor.decode(state.head); headPos = doc.getCursorPos(head).offset; } return ephemeralEffect.of({ type: "cursor", peer, cursor: { anchor: anchorPos, head: headPos }, }); }; const isRemoteCursorUpdate = (update) => { const effect = update.transactions .flatMap((transaction) => transaction.effects) .filter((effect) => effect.is(ephemeralEffect)); 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, remoteUsers, isCheckout } = view.state.field(ephemeralStateField); if (isCheckout) { return []; } return Array.from(remoteCursors.entries()).flatMap(([peer, state]) => { const selectionRange = EditorSelection.cursor(state.anchor); const user = remoteUsers.get(peer); return RemoteCursorMarker.createCursor(view, selectionRange, (user === null || user === void 0 ? void 0 : user.name) || "unknown", (user === null || user === void 0 ? void 0 : user.colorClassName) || ""); }); }, }); }; export const createSelectionLayer = () => layer({ above: false, class: "loro-selection-layer", update: isRemoteCursorUpdate, markers: (view) => { const { remoteCursors, remoteUsers, isCheckout } = view.state.field(ephemeralStateField); if (isCheckout) { return []; } return Array.from(remoteCursors.entries()) .filter(([_, state]) => state.head !== undefined && state.anchor !== state.head) .flatMap(([peer, state]) => { const user = remoteUsers.get(peer); const selectionRange = EditorSelection.range(state.anchor, state.head); const markers = RectangleMarker.forRange(view, `loro-selection ${(user === null || user === void 0 ? void 0 : user.colorClassName) || ""}`, selectionRange); return markers; }); }, }); export class EphemeralPlugin { constructor(view, doc, user, ephemeralStore, getTextFromDoc) { this.view = view; this.doc = doc; this.user = user; this.ephemeralStore = ephemeralStore; this.getTextFromDoc = getTextFromDoc; this.initUser = false; this.sub = this.doc.subscribe((e) => { if (e.by === "local") { // update remote cursor position const { remoteCursors: remoteStates, isCheckout } = view.state.field(ephemeralStateField); if (isCheckout) return; const effects = []; for (const peer of remoteStates.keys()) { if (peer === this.doc.peerIdStr) { continue; } const state = this.ephemeralStore.get(`${peer}-cm-cursor`); if (state) { const effect = getCursorEffect(this.doc, peer, state); if (effect) { effects.push(effect); } } else { effects.push(ephemeralEffect.of({ type: "delete", peer, })); } } this.view.dispatch({ effects, }); } else if (e.by === "checkout") { // TODO: better way this.view.dispatch({ effects: [ remoteAwarenessEffect.of({ type: "checkout", checkout: this.doc.isDetached(), }), ], }); } }); this.ephemeralSub = this.ephemeralStore.subscribe((e) => { if (e.by === "local") return; const effects = []; for (const key of e.added.concat(e.updated)) { const peer = key.split("-")[0]; if (key.endsWith(`-cm-cursor`)) { const state = this.ephemeralStore.get(key); const effect = getCursorEffect(this.doc, peer, state); if (effect) { effects.push(effect); } } if (key.endsWith(`-cm-user`)) { const user = this.ephemeralStore.get(key); effects.push(ephemeralEffect.of({ type: "user", peer, user })); } } for (const key of e.removed) { const peer = key.split("-")[0]; if (key.endsWith(`-cm-cursor`)) { effects.push(ephemeralEffect.of({ type: "delete", peer, })); } } this.view.dispatch({ effects }); }); } 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.ephemeralStore.set(`${this.doc.peerIdStr}-cm-cursor`, cursorState); if (!this.initUser) { this.ephemeralStore.set(`${this.doc.peerIdStr}-cm-user`, this.user); this.initUser = true; } } else { // when checkout or blur this.ephemeralStore.delete(`${this.doc.peerIdStr}-cm-cursor`); } } destroy() { var _a, _b; (_a = this.sub) === null || _a === void 0 ? void 0 : _a.call(this); (_b = this.ephemeralSub) === null || _b === void 0 ? void 0 : _b.call(this); this.ephemeralStore.delete(`${this.doc.peerIdStr}-cm-cursor`); this.ephemeralStore.delete(`${this.doc.peerIdStr}-cm-user`); } }