@pylonsync/loro 0.22.39 → 0.22.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -6
- package/src/outbox.ts +18 -7
- package/src/prosemirror/cursors.test.ts +112 -0
- package/src/prosemirror/cursors.ts +172 -0
- package/src/prosemirror/index.ts +37 -2
- package/src/prosemirror/react.ts +1 -1
- package/src/prosemirror/sync.test.ts +323 -0
- package/src/prosemirror/sync.ts +490 -0
- package/src/server.test.ts +138 -0
- package/src/server.ts +99 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProseMirrorSyncPlugin: binds a ProseMirror editor to a ProseMirror field
|
|
3
|
+
* of a row's Loro doc. A drop-in replacement for loro-prosemirror's
|
|
4
|
+
* LoroSyncPlugin (same plugin key and state, so LoroUndoPlugin works with
|
|
5
|
+
* it), with two fixes for people typing at the same time:
|
|
6
|
+
*
|
|
7
|
+
* - A remote change is applied as one replace of the range that changed,
|
|
8
|
+
* not of the whole document. ProseMirror maps the selection, decorations
|
|
9
|
+
* and plugin state through it.
|
|
10
|
+
* - The caret is kept as Loro cursors, taken after every editor update,
|
|
11
|
+
* when the editor and the doc agree. LoroSyncPlugin took them after the
|
|
12
|
+
* import had already changed the doc, replaced the whole document (which
|
|
13
|
+
* moves the caret to its end), and put the caret back in a setTimeout:
|
|
14
|
+
* text typed before that timer ran went to the end of the document, and
|
|
15
|
+
* two people typing at one spot interleaved.
|
|
16
|
+
*
|
|
17
|
+
* The caret binds to the character on its left. A remote insert at the
|
|
18
|
+
* caret's spot then lands after it, and the next character this person
|
|
19
|
+
* types continues their own run, which is how Loro orders concurrent runs.
|
|
20
|
+
*
|
|
21
|
+
* Ported in part from loro-prosemirror 0.4.4 (MIT, Loro contributors):
|
|
22
|
+
* the cursor-to-position walk and the change bookkeeping.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
type ContainerID,
|
|
26
|
+
Cursor,
|
|
27
|
+
type LoroDoc,
|
|
28
|
+
LoroList,
|
|
29
|
+
LoroMap,
|
|
30
|
+
LoroText,
|
|
31
|
+
UndoManager,
|
|
32
|
+
} from "loro-crdt";
|
|
33
|
+
import {
|
|
34
|
+
CHILDREN_KEY,
|
|
35
|
+
createNodeFromLoroObj,
|
|
36
|
+
LoroUndoPlugin,
|
|
37
|
+
loroSyncPluginKey,
|
|
38
|
+
loroUndoPluginKey,
|
|
39
|
+
updateLoroToPmState,
|
|
40
|
+
} from "loro-prosemirror";
|
|
41
|
+
import type { Node } from "prosemirror-model";
|
|
42
|
+
import { Plugin, TextSelection, type EditorState, type Transaction } from "prosemirror-state";
|
|
43
|
+
import type { EditorView } from "prosemirror-view";
|
|
44
|
+
|
|
45
|
+
type Mapping = Map<ContainerID, Node | Node[]>;
|
|
46
|
+
|
|
47
|
+
interface SyncState {
|
|
48
|
+
doc: LoroDoc;
|
|
49
|
+
mapping: Mapping;
|
|
50
|
+
changedBy: "local" | "import" | "checkout";
|
|
51
|
+
containerId?: ContainerID;
|
|
52
|
+
snapshot?: unknown;
|
|
53
|
+
docSubscription?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Cursors {
|
|
57
|
+
anchor: Cursor | null;
|
|
58
|
+
head: Cursor | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ProseMirrorSyncPluginProps {
|
|
62
|
+
doc: LoroDoc;
|
|
63
|
+
/** The field's container, from useProseMirrorDoc or findProseMirrorContainer. */
|
|
64
|
+
containerId: ContainerID;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ProseMirrorSyncPlugin({ doc, containerId }: ProseMirrorSyncPluginProps): Plugin {
|
|
68
|
+
// True while this editor writes to the doc. Loro reports a commit to
|
|
69
|
+
// subscribers synchronously, in the middle of this editor's own dispatch;
|
|
70
|
+
// those events are this editor's own edits. Every other change (another
|
|
71
|
+
// editor on the same doc, app code, an undo, the server) is applied.
|
|
72
|
+
let writing = false;
|
|
73
|
+
const write = (fn: () => void) => {
|
|
74
|
+
writing = true;
|
|
75
|
+
try {
|
|
76
|
+
fn();
|
|
77
|
+
} finally {
|
|
78
|
+
writing = false;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return new Plugin({
|
|
82
|
+
key: loroSyncPluginKey,
|
|
83
|
+
props: {
|
|
84
|
+
editable: (state) => (loroSyncPluginKey.getState(state) as SyncState | undefined)?.snapshot == null,
|
|
85
|
+
},
|
|
86
|
+
state: {
|
|
87
|
+
init: (): SyncState => ({ doc, mapping: new Map(), changedBy: "local", containerId }),
|
|
88
|
+
apply: (tr, state: SyncState, oldEditorState, newEditorState) => {
|
|
89
|
+
const meta = tr.getMeta(loroSyncPluginKey) as { type: string; state?: Partial<SyncState> } | undefined;
|
|
90
|
+
const undoState = loroUndoPluginKey.getState(oldEditorState) as
|
|
91
|
+
| { isUndoing?: { current: boolean } }
|
|
92
|
+
| undefined;
|
|
93
|
+
state.changedBy = meta?.type === "non-local-updates" ? "import" : "local";
|
|
94
|
+
switch (meta?.type) {
|
|
95
|
+
case "doc-changed":
|
|
96
|
+
if (!undoState?.isUndoing?.current) {
|
|
97
|
+
write(() => updateLoroToPmState(doc as never, state.mapping as never, newEditorState, containerId));
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
case "update-state": {
|
|
101
|
+
state = { ...state, ...meta.state };
|
|
102
|
+
const committing = state.doc;
|
|
103
|
+
write(() => committing.commit({ origin: "sys:init", timestamp: Date.now() }));
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return state;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
// Only this editor's own changes are written to the doc. A change that
|
|
111
|
+
// came from the doc is there already, and the empty document an editor
|
|
112
|
+
// shows for an emptied field must not be written back: every editor
|
|
113
|
+
// would add its own.
|
|
114
|
+
appendTransaction: (transactions, _old, newState) =>
|
|
115
|
+
transactions.some(
|
|
116
|
+
(tr) => tr.docChanged && (tr.getMeta(loroSyncPluginKey) as { type?: string } | undefined)?.type !== "non-local-updates",
|
|
117
|
+
)
|
|
118
|
+
? newState.tr.setMeta(loroSyncPluginKey, { type: "doc-changed" })
|
|
119
|
+
: null,
|
|
120
|
+
view: (view) => {
|
|
121
|
+
let cursors: Cursors = { anchor: null, head: null };
|
|
122
|
+
let unsubscribe: (() => void) | null = null;
|
|
123
|
+
const remember = () => {
|
|
124
|
+
cursors = selectionToCursors(view.state, doc, containerId);
|
|
125
|
+
};
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
if (view.isDestroyed) return;
|
|
128
|
+
unsubscribe = doc.getContainerById(containerId)!.subscribe((event) => {
|
|
129
|
+
if (writing) return;
|
|
130
|
+
applyRemote(view, doc, containerId, event, cursors);
|
|
131
|
+
remember();
|
|
132
|
+
});
|
|
133
|
+
// A new document has no top node name yet; the server leaves it to
|
|
134
|
+
// the schema. Write it now, as setup (`sys:init`, which undo skips),
|
|
135
|
+
// so the person's first edit is an edit they can undo.
|
|
136
|
+
const root = doc.getContainerById(containerId) as LoroMap;
|
|
137
|
+
if (root.get("nodeName") == null) {
|
|
138
|
+
write(() => {
|
|
139
|
+
root.set("nodeName", view.state.schema.topNodeType.name);
|
|
140
|
+
doc.commit({ origin: "sys:init" });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
loadInitial(view, doc, containerId);
|
|
144
|
+
remember();
|
|
145
|
+
}, 0);
|
|
146
|
+
return {
|
|
147
|
+
update: () => {
|
|
148
|
+
const state = loroSyncPluginKey.getState(view.state) as SyncState | undefined;
|
|
149
|
+
// Only when the editor and the doc agree: after a local change the
|
|
150
|
+
// apply above wrote it to Loro synchronously.
|
|
151
|
+
if (state) remember();
|
|
152
|
+
},
|
|
153
|
+
destroy: () => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
unsubscribe?.();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Edits closer together than this are one undo step. */
|
|
163
|
+
export const UNDO_MERGE_MS = 500;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Undo for a document bound with ProseMirrorSyncPlugin: LoroUndoPlugin with
|
|
167
|
+
* typing grouped into steps (Loro's default makes every character a step),
|
|
168
|
+
* and without the binding's first load, which is not this person's edit.
|
|
169
|
+
* It undoes only this person's edits.
|
|
170
|
+
*/
|
|
171
|
+
export function ProseMirrorUndoPlugin({ doc }: { doc: LoroDoc }): Plugin {
|
|
172
|
+
const undoManager = new UndoManager(doc, {
|
|
173
|
+
mergeInterval: UNDO_MERGE_MS,
|
|
174
|
+
excludeOriginPrefixes: ["sys:init"],
|
|
175
|
+
});
|
|
176
|
+
return LoroUndoPlugin({ doc: doc as never, undoManager: undoManager as never });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function field(doc: LoroDoc, containerId: ContainerID): LoroMap {
|
|
180
|
+
return doc.getContainerById(containerId) as LoroMap;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The browser's selection in the editor, when it differs from the editor
|
|
185
|
+
* state's and the window has focus. A click or an arrow key moves the DOM
|
|
186
|
+
* selection first, and the editor reads it a little later; a remote change
|
|
187
|
+
* in between would put the caret back where it was.
|
|
188
|
+
*/
|
|
189
|
+
function domSelectionIfMoved(view: EditorView): { anchor: number; head: number } | null {
|
|
190
|
+
// A window without focus keeps a stale DOM selection: the editor does not
|
|
191
|
+
// write its selection to the DOM while it is in the background.
|
|
192
|
+
if (!view.hasFocus() || !view.dom.ownerDocument.hasFocus()) return null;
|
|
193
|
+
const sel = (view.root as Document).getSelection?.();
|
|
194
|
+
if (!sel?.anchorNode || !sel.focusNode || !view.dom.contains(sel.anchorNode)) return null;
|
|
195
|
+
try {
|
|
196
|
+
const anchor = view.posAtDOM(sel.anchorNode, sel.anchorOffset);
|
|
197
|
+
const head = view.posAtDOM(sel.focusNode, sel.focusOffset);
|
|
198
|
+
const { selection } = view.state;
|
|
199
|
+
return anchor === selection.anchor && head === selection.head ? null : { anchor, head };
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** True for a document with no top node or no children yet. */
|
|
206
|
+
function isEmpty(map: LoroMap): boolean {
|
|
207
|
+
if (map.get("nodeName") == null) return true;
|
|
208
|
+
const children = map.get(CHILDREN_KEY);
|
|
209
|
+
return !(children instanceof LoroList) || children.length === 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Fill the editor from the doc on mount (what LoroSyncPlugin's init does). */
|
|
213
|
+
function loadInitial(view: EditorView, doc: LoroDoc, containerId: ContainerID): void {
|
|
214
|
+
const mapping: Mapping = new Map();
|
|
215
|
+
const map = field(doc, containerId);
|
|
216
|
+
let tr = view.state.tr;
|
|
217
|
+
const node = isEmpty(map)
|
|
218
|
+
? null
|
|
219
|
+
: (createNodeFromLoroObj(view.state.schema, map as never, mapping as never) as Node);
|
|
220
|
+
// An empty document (the server's, before anyone typed) keeps the editor's
|
|
221
|
+
// starting content; the first edit writes it into the server's containers.
|
|
222
|
+
if (node && node.childCount > 0) {
|
|
223
|
+
tr = tr.replaceWith(0, view.state.doc.content.size, node.content);
|
|
224
|
+
}
|
|
225
|
+
if (node) tr = withTopAttrs(tr, node);
|
|
226
|
+
tr.setMeta(loroSyncPluginKey, { type: "update-state", state: { mapping, snapshot: null } });
|
|
227
|
+
tr.setMeta("addToHistory", false);
|
|
228
|
+
view.dispatch(tr);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Set the top node's attributes to `node`'s where they differ. */
|
|
232
|
+
function withTopAttrs(tr: Transaction, node: Node): Transaction {
|
|
233
|
+
const current = tr.doc.attrs;
|
|
234
|
+
for (const [name, value] of Object.entries(node.attrs)) {
|
|
235
|
+
if (current[name] !== value) tr = tr.setDocAttribute(name, value);
|
|
236
|
+
}
|
|
237
|
+
return tr;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Drop cached nodes for every container an event touched, and their ancestors. */
|
|
241
|
+
function forgetChanged(doc: LoroDoc, event: { events: Array<{ target: ContainerID }> }, mapping: Mapping): void {
|
|
242
|
+
for (const e of event.events) {
|
|
243
|
+
let obj = doc.getContainerById(e.target) as { id: ContainerID; parent(): unknown } | undefined;
|
|
244
|
+
while (obj) {
|
|
245
|
+
mapping.delete(obj.id);
|
|
246
|
+
obj = obj.parent() as typeof obj;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function applyRemote(
|
|
252
|
+
view: EditorView,
|
|
253
|
+
doc: LoroDoc,
|
|
254
|
+
containerId: ContainerID,
|
|
255
|
+
event: { events: Array<{ target: ContainerID }> },
|
|
256
|
+
cursors: Cursors,
|
|
257
|
+
): void {
|
|
258
|
+
if (view.isDestroyed) return;
|
|
259
|
+
const state = loroSyncPluginKey.getState(view.state) as SyncState;
|
|
260
|
+
// Read before the replace: positions in the document as it is now.
|
|
261
|
+
const moved = domSelectionIfMoved(view);
|
|
262
|
+
forgetChanged(doc, event, state.mapping);
|
|
263
|
+
const root = field(doc, containerId);
|
|
264
|
+
// Someone emptied the document: show the schema's empty document.
|
|
265
|
+
const next = isEmpty(root)
|
|
266
|
+
? view.state.schema.topNodeType.createAndFill()!
|
|
267
|
+
: (createNodeFromLoroObj(view.state.schema, root as never, state.mapping as never) as Node);
|
|
268
|
+
const current = view.state.doc;
|
|
269
|
+
const start = current.content.findDiffStart(next.content);
|
|
270
|
+
let tr = view.state.tr;
|
|
271
|
+
if (start != null) {
|
|
272
|
+
let { a: endA, b: endB } = current.content.findDiffEnd(next.content)!;
|
|
273
|
+
// A run of equal content at both ends can make the two ends cross.
|
|
274
|
+
const overlap = start - Math.min(endA, endB);
|
|
275
|
+
if (overlap > 0) {
|
|
276
|
+
endA += overlap;
|
|
277
|
+
endB += overlap;
|
|
278
|
+
}
|
|
279
|
+
tr = tr.replace(start, endA, next.slice(start, endB));
|
|
280
|
+
}
|
|
281
|
+
tr = withTopAttrs(tr, next);
|
|
282
|
+
// Nothing this editor shows changed (an echo, a change elsewhere in the row).
|
|
283
|
+
if (!tr.docChanged) return;
|
|
284
|
+
// A node selection, a cell selection: ProseMirror maps it through the
|
|
285
|
+
// replace and keeps its kind; a caret rebuilt from cursors would not.
|
|
286
|
+
if (!(view.state.selection instanceof TextSelection)) {
|
|
287
|
+
tr.setMeta(loroSyncPluginKey, { type: "non-local-updates" });
|
|
288
|
+
tr.setMeta("addToHistory", false);
|
|
289
|
+
view.dispatch(tr);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (moved) {
|
|
293
|
+
// The person moved the caret after the cursors were taken: map where it
|
|
294
|
+
// is now through the replace.
|
|
295
|
+
tr = tr.setSelection(
|
|
296
|
+
TextSelection.between(tr.doc.resolve(tr.mapping.map(moved.anchor)), tr.doc.resolve(tr.mapping.map(moved.head))),
|
|
297
|
+
);
|
|
298
|
+
tr.setMeta(loroSyncPluginKey, { type: "non-local-updates" });
|
|
299
|
+
tr.setMeta("addToHistory", false);
|
|
300
|
+
view.dispatch(tr);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const anchor = cursors.anchor && cursorToPosition(cursors.anchor, doc, containerId, state.mapping);
|
|
304
|
+
const head = cursors.head && cursorToPosition(cursors.head, doc, containerId, state.mapping);
|
|
305
|
+
// Otherwise the selection stays where ProseMirror mapped it through the replace.
|
|
306
|
+
if (anchor != null && anchor <= tr.doc.content.size) {
|
|
307
|
+
const headPos = head != null && head <= tr.doc.content.size ? head : anchor;
|
|
308
|
+
tr = tr.setSelection(TextSelection.between(tr.doc.resolve(anchor), tr.doc.resolve(headPos)));
|
|
309
|
+
}
|
|
310
|
+
tr.setMeta(loroSyncPluginKey, { type: "non-local-updates" });
|
|
311
|
+
tr.setMeta("addToHistory", false);
|
|
312
|
+
view.dispatch(tr);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The selection as Loro cursors, bound to the character on the left of each
|
|
317
|
+
* end. Null ends where the doc has no text to bind to.
|
|
318
|
+
*/
|
|
319
|
+
export function selectionToCursors(state: EditorState, doc: LoroDoc, containerId: ContainerID): Cursors {
|
|
320
|
+
const { anchor, head } = state.selection;
|
|
321
|
+
const a = positionToCursor(state.doc, anchor, doc, containerId);
|
|
322
|
+
return { anchor: a, head: head === anchor ? a : positionToCursor(state.doc, head, doc, containerId) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The Loro container for each child of a node, in the binding's layout: a
|
|
327
|
+
* run of text nodes is one LoroText, every other node one LoroMap.
|
|
328
|
+
*/
|
|
329
|
+
function childIndexFor(node: Node, pmIndex: number): number {
|
|
330
|
+
let group = -1;
|
|
331
|
+
let inText = false;
|
|
332
|
+
for (let i = 0; i <= pmIndex && i < node.childCount; i++) {
|
|
333
|
+
const child = node.child(i);
|
|
334
|
+
if (child.isText) {
|
|
335
|
+
if (!inText) group += 1;
|
|
336
|
+
inText = true;
|
|
337
|
+
} else {
|
|
338
|
+
group += 1;
|
|
339
|
+
inText = false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return group;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A cursor for a document position. Walks the position's path from the
|
|
347
|
+
* field's root down, so it does not depend on node identity (a replace
|
|
348
|
+
* rebuilds the nodes along its path).
|
|
349
|
+
*/
|
|
350
|
+
export function positionToCursor(root: Node, pos: number, doc: LoroDoc, containerId: ContainerID): Cursor | null {
|
|
351
|
+
if (pos < 0 || pos > root.content.size) return null;
|
|
352
|
+
const $pos = root.resolve(pos);
|
|
353
|
+
let map = field(doc, containerId);
|
|
354
|
+
for (let depth = 0; depth < $pos.depth; depth++) {
|
|
355
|
+
const children = map.get(CHILDREN_KEY);
|
|
356
|
+
if (!(children instanceof LoroList)) return null;
|
|
357
|
+
const child = children.get(childIndexFor($pos.node(depth), $pos.index(depth)));
|
|
358
|
+
if (!(child instanceof LoroMap)) return null;
|
|
359
|
+
map = child;
|
|
360
|
+
}
|
|
361
|
+
const children = map.get(CHILDREN_KEY);
|
|
362
|
+
if (!(children instanceof LoroList)) return null;
|
|
363
|
+
if (children.length === 0) return children.getCursor(0) ?? null;
|
|
364
|
+
// Between blocks (a node selection, a gap cursor): bind to the next child.
|
|
365
|
+
if (!$pos.parent.inlineContent) {
|
|
366
|
+
return children.getCursor(childIndexFor($pos.parent, $pos.index()) + ($pos.index() >= $pos.parent.childCount ? 1 : 0)) ?? null;
|
|
367
|
+
}
|
|
368
|
+
// Walk the textblock's children alongside the editor's: a run of text
|
|
369
|
+
// nodes is one LoroText, every other inline node one child of its size.
|
|
370
|
+
const parent = $pos.parent;
|
|
371
|
+
const offset = $pos.parentOffset;
|
|
372
|
+
let group = -1;
|
|
373
|
+
let pmOffset = 0;
|
|
374
|
+
let runStart = -1;
|
|
375
|
+
for (let i = 0; i < parent.childCount; i++) {
|
|
376
|
+
const child = parent.child(i);
|
|
377
|
+
if (child.isText) {
|
|
378
|
+
if (runStart < 0) {
|
|
379
|
+
group += 1;
|
|
380
|
+
runStart = pmOffset;
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
// The caret at the end of a text run binds to that run, so typing
|
|
384
|
+
// there continues it.
|
|
385
|
+
if (runStart >= 0 && offset <= pmOffset) return textCursor(children.get(group), offset - runStart);
|
|
386
|
+
runStart = -1;
|
|
387
|
+
group += 1;
|
|
388
|
+
if (offset === pmOffset) return children.getCursor(group) ?? null;
|
|
389
|
+
}
|
|
390
|
+
pmOffset += child.nodeSize;
|
|
391
|
+
}
|
|
392
|
+
if (runStart >= 0 && offset <= pmOffset) return textCursor(children.get(group), offset - runStart);
|
|
393
|
+
return children.getCursor(children.length) ?? null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** True for the second half of a UTF-16 surrogate pair (an emoji's, say). */
|
|
397
|
+
function isLowSurrogate(text: string): boolean {
|
|
398
|
+
const code = text.charCodeAt(0);
|
|
399
|
+
return code >= 0xdc00 && code <= 0xdfff;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A cursor `offset` UTF-16 units into a text run, bound to the character on
|
|
404
|
+
* its left (side 1: just after it), the whole character when it is a
|
|
405
|
+
* surrogate pair. At the start of the run there is none, so it binds to the
|
|
406
|
+
* first character.
|
|
407
|
+
*/
|
|
408
|
+
function textCursor(text: unknown, offset: number): Cursor | null {
|
|
409
|
+
if (!(text instanceof LoroText)) return null;
|
|
410
|
+
if (offset <= 0) return text.getCursor(0, 0) ?? null;
|
|
411
|
+
let start = Math.min(offset, text.length) - 1;
|
|
412
|
+
if (start > 0 && isLowSurrogate(text.slice(start, start + 1))) start -= 1;
|
|
413
|
+
return text.getCursor(start, 1) ?? null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Size in the editor of a container's node, from the binding's mapping. */
|
|
417
|
+
function mappedSize(mapping: Mapping, id: ContainerID): number | null {
|
|
418
|
+
const mapped = mapping.get(id);
|
|
419
|
+
if (mapped == null) return null;
|
|
420
|
+
return Array.isArray(mapped) ? mapped.reduce((n, node) => n + node.nodeSize, 0) : mapped.nodeSize;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The document position a cursor points at now. Uses the mapping, which
|
|
425
|
+
* createNodeFromLoroObj has just filled for the whole tree.
|
|
426
|
+
*/
|
|
427
|
+
export function cursorToPosition(
|
|
428
|
+
cursor: Cursor,
|
|
429
|
+
doc: LoroDoc,
|
|
430
|
+
containerId: ContainerID,
|
|
431
|
+
mapping: Mapping,
|
|
432
|
+
options: { acceptMoved?: boolean } = {},
|
|
433
|
+
): number | null {
|
|
434
|
+
const rootId = field(doc, containerId).id;
|
|
435
|
+
const target = cursor.containerId();
|
|
436
|
+
let index: number;
|
|
437
|
+
let childId: ContainerID;
|
|
438
|
+
let node: LoroMap | undefined;
|
|
439
|
+
if (target.endsWith("List")) {
|
|
440
|
+
const list = doc.getContainerById(target) as LoroList | undefined;
|
|
441
|
+
const pos = doc.getCursorPos(cursor);
|
|
442
|
+
const owner = list?.parent() as LoroMap | undefined;
|
|
443
|
+
if (!list || !pos || (pos.update && !options.acceptMoved) || !owner) return null;
|
|
444
|
+
// Inside a node with no text, or before an inline node: count the
|
|
445
|
+
// children before it.
|
|
446
|
+
index = 0;
|
|
447
|
+
for (let i = 0; i < pos.offset; i++) {
|
|
448
|
+
const child = list.get(i) as { id?: ContainerID } | undefined;
|
|
449
|
+
const size = child?.id ? mappedSize(mapping, child.id) : null;
|
|
450
|
+
if (size == null) return null;
|
|
451
|
+
index += size;
|
|
452
|
+
}
|
|
453
|
+
if (owner.id === rootId) return index;
|
|
454
|
+
childId = owner.id;
|
|
455
|
+
node = (owner.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
|
|
456
|
+
index += 1; // into the owner node
|
|
457
|
+
} else {
|
|
458
|
+
const text = doc.getContainerById(target) as LoroText | undefined;
|
|
459
|
+
const pos = doc.getCursorPos(cursor);
|
|
460
|
+
// `update` means the character was deleted (a split moves text into a
|
|
461
|
+
// new paragraph as a delete and an insert). For this editor's own caret,
|
|
462
|
+
// ProseMirror's mapping through the replace places it better; another
|
|
463
|
+
// person's caret has no mapping, so it takes Loro's nearest position
|
|
464
|
+
// (`acceptMoved`).
|
|
465
|
+
if (!text || !pos || (pos.update && !options.acceptMoved)) return null;
|
|
466
|
+
// The cursor names a character; side 1 means just after it, which is
|
|
467
|
+
// two UTF-16 units for a surrogate pair.
|
|
468
|
+
const width =
|
|
469
|
+
pos.side === 1 ? (pos.offset + 1 < text.length && isLowSurrogate(text.slice(pos.offset + 1, pos.offset + 2)) ? 2 : 1) : 0;
|
|
470
|
+
index = pos.offset + width;
|
|
471
|
+
childId = text.id;
|
|
472
|
+
node = (text.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
|
|
473
|
+
}
|
|
474
|
+
while (node) {
|
|
475
|
+
const children = node.get(CHILDREN_KEY);
|
|
476
|
+
if (!(children instanceof LoroList)) return null;
|
|
477
|
+
for (let i = 0; i < children.length; i++) {
|
|
478
|
+
const child = children.get(i) as { id?: ContainerID } | undefined;
|
|
479
|
+
if (!child?.id || child.id === childId) break;
|
|
480
|
+
const size = mappedSize(mapping, child.id);
|
|
481
|
+
if (size == null) return null;
|
|
482
|
+
index += size;
|
|
483
|
+
}
|
|
484
|
+
if (node.id === rootId) return index;
|
|
485
|
+
childId = node.id;
|
|
486
|
+
node = (node.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
|
|
487
|
+
index += 1; // the opening of the node we came out of
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// LiveDoc and editProseMirror against a fake ctx.crdt whose "server" is a
|
|
2
|
+
// LoroDoc, the way the host's store merges and serves a row's document.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { LoroDoc, LoroList, LoroMap, VersionVector } from "loro-crdt";
|
|
5
|
+
import { Schema } from "prosemirror-model";
|
|
6
|
+
import { schema as basic } from "prosemirror-schema-basic";
|
|
7
|
+
import { editProseMirror, readProseMirror, writeProseMirror, configureProseMirrorMarks } from "./prosemirror/index";
|
|
8
|
+
import { openLiveDoc, type CrdtOps, type LiveDocContext } from "./server";
|
|
9
|
+
|
|
10
|
+
function fakeHost(server: LoroDoc | null) {
|
|
11
|
+
const applied: Uint8Array[] = [];
|
|
12
|
+
const calls: string[] = [];
|
|
13
|
+
const ops = (label: string): CrdtOps => ({
|
|
14
|
+
async snapshot() {
|
|
15
|
+
calls.push(`${label}snapshot`);
|
|
16
|
+
return server ? server.export({ mode: "snapshot" }) : null;
|
|
17
|
+
},
|
|
18
|
+
async since(_e, _i, version) {
|
|
19
|
+
calls.push(`${label}since`);
|
|
20
|
+
return server ? server.export({ mode: "update", from: VersionVector.decode(version) }) : null;
|
|
21
|
+
},
|
|
22
|
+
async apply(_e, _i, update) {
|
|
23
|
+
calls.push(`${label}apply`);
|
|
24
|
+
applied.push(update);
|
|
25
|
+
server!.import(update);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const ctx: LiveDocContext = { crdt: { ...ops(""), unsafe: ops("unsafe:") } };
|
|
29
|
+
return { ctx, applied, calls };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rowDoc(): LoroDoc {
|
|
33
|
+
const d = new LoroDoc();
|
|
34
|
+
d.getMap("row").set("title", "start");
|
|
35
|
+
d.commit();
|
|
36
|
+
return d;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("LiveDoc", () => {
|
|
40
|
+
test("an edit reaches the server", async () => {
|
|
41
|
+
const server = rowDoc();
|
|
42
|
+
const { ctx } = fakeHost(server);
|
|
43
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
44
|
+
await live.edit((doc) => doc.getMap("row").set("status", "done"));
|
|
45
|
+
expect(server.getMap("row").get("status")).toBe("done");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("edit pulls in what others wrote after the doc was opened", async () => {
|
|
49
|
+
const server = rowDoc();
|
|
50
|
+
const text = server.getMap("row").setContainer("body", new (await import("loro-crdt")).LoroText());
|
|
51
|
+
server.commit();
|
|
52
|
+
const { ctx } = fakeHost(server);
|
|
53
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
54
|
+
// Someone types at the server's copy meanwhile.
|
|
55
|
+
text.insert(0, "person ");
|
|
56
|
+
server.commit();
|
|
57
|
+
await live.edit((doc) => {
|
|
58
|
+
const t = doc.getMap("row").get("body") as { insert(i: number, s: string): void; length: number };
|
|
59
|
+
t.insert(t.length, "function");
|
|
60
|
+
});
|
|
61
|
+
expect(text.toString()).toBe("person function");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("commit sends only this function's ops, once", async () => {
|
|
65
|
+
const server = rowDoc();
|
|
66
|
+
const big = new LoroDoc();
|
|
67
|
+
big.import(server.export({ mode: "snapshot" }));
|
|
68
|
+
big.getMap("row").set("blob", "x".repeat(5000));
|
|
69
|
+
big.commit();
|
|
70
|
+
server.import(big.export({ mode: "update" }));
|
|
71
|
+
const { ctx, applied } = fakeHost(server);
|
|
72
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
73
|
+
live.doc.getMap("row").set("n", 1);
|
|
74
|
+
expect(await live.commit()).toBe(true);
|
|
75
|
+
expect(await live.commit()).toBe(false);
|
|
76
|
+
expect(applied.length).toBe(1);
|
|
77
|
+
expect(applied[0].length).toBeLessThan(200);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a row without a document is NOT_FOUND", async () => {
|
|
81
|
+
const { ctx } = fakeHost(null);
|
|
82
|
+
await expect(openLiveDoc(ctx, "Doc", "nope")).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("unsafe goes through ctx.crdt.unsafe", async () => {
|
|
86
|
+
const server = rowDoc();
|
|
87
|
+
const { ctx, calls } = fakeHost(server);
|
|
88
|
+
const live = await openLiveDoc(ctx, "Doc", "d1", { unsafe: true });
|
|
89
|
+
await live.edit((doc) => doc.getMap("row").set("k", 1));
|
|
90
|
+
expect(calls).toEqual(["unsafe:snapshot", "unsafe:since", "unsafe:apply"]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("editProseMirror", () => {
|
|
95
|
+
const schema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
|
|
96
|
+
const para = (s: string) => schema.node("paragraph", null, s ? [schema.text(s)] : []);
|
|
97
|
+
|
|
98
|
+
function serverWithBody(): LoroDoc {
|
|
99
|
+
const server = new LoroDoc();
|
|
100
|
+
const body = server.getMap("row").setContainer("body", new LoroMap());
|
|
101
|
+
body.setContainer("attributes", new LoroMap());
|
|
102
|
+
body.setContainer("children", new LoroList());
|
|
103
|
+
server.commit();
|
|
104
|
+
return server;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test("a function's edit and a person's edit at the same time both land", async () => {
|
|
108
|
+
const server = serverWithBody();
|
|
109
|
+
configureProseMirrorMarks(server, schema);
|
|
110
|
+
writeProseMirror(server, "body", schema, schema.node("doc", null, [para("Hello")]));
|
|
111
|
+
const { ctx } = fakeHost(server);
|
|
112
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
113
|
+
// A person adds a paragraph after the function opened the doc.
|
|
114
|
+
const now = readProseMirror(server, "body", schema)!;
|
|
115
|
+
writeProseMirror(server, "body", schema, now.copy(now.content.append(schema.node("doc", null, [para("from a person")]).content)));
|
|
116
|
+
const written = await editProseMirror(live, "body", schema, (state) => state.tr.insertText("Summary: ", 1));
|
|
117
|
+
expect(written).not.toBeNull();
|
|
118
|
+
const result: string[] = [];
|
|
119
|
+
readProseMirror(server, "body", schema)!.forEach((n) => result.push(n.textContent));
|
|
120
|
+
expect(result).toEqual(["Summary: Hello", "from a person"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("an empty document starts from the schema's empty document", async () => {
|
|
124
|
+
const server = serverWithBody();
|
|
125
|
+
const { ctx } = fakeHost(server);
|
|
126
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
127
|
+
await editProseMirror(live, "body", schema, (state) => state.tr.insertText("first", 1));
|
|
128
|
+
expect(readProseMirror(server, "body", schema)!.textContent).toBe("first");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("returning nothing changes nothing", async () => {
|
|
132
|
+
const server = serverWithBody();
|
|
133
|
+
const { ctx, applied } = fakeHost(server);
|
|
134
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
135
|
+
expect(await editProseMirror(live, "body", schema, () => undefined)).toBeNull();
|
|
136
|
+
expect(applied.length).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|