loro-codemirror
Version:
A CodeMirror plugin for loro
205 lines (204 loc) • 8.11 kB
JavaScript
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`);
}
}