loro-codemirror
Version:
A CodeMirror plugin for loro
303 lines (302 loc) • 10.8 kB
JavaScript
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,
};
};