@pylonsync/loro 0.22.38 → 0.22.40

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.
@@ -0,0 +1,402 @@
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 } 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
+ return new Plugin({
69
+ key: loroSyncPluginKey,
70
+ props: {
71
+ editable: (state) => (loroSyncPluginKey.getState(state) as SyncState | undefined)?.snapshot == null,
72
+ },
73
+ state: {
74
+ init: (): SyncState => ({ doc, mapping: new Map(), changedBy: "local", containerId }),
75
+ apply: (tr, state: SyncState, oldEditorState, newEditorState) => {
76
+ const meta = tr.getMeta(loroSyncPluginKey) as { type: string; state?: Partial<SyncState> } | undefined;
77
+ const undoState = loroUndoPluginKey.getState(oldEditorState) as
78
+ | { isUndoing?: { current: boolean } }
79
+ | undefined;
80
+ state.changedBy = meta?.type === "non-local-updates" ? "import" : "local";
81
+ switch (meta?.type) {
82
+ case "doc-changed":
83
+ if (!undoState?.isUndoing?.current) {
84
+ updateLoroToPmState(doc as never, state.mapping as never, newEditorState, containerId);
85
+ }
86
+ break;
87
+ case "update-state":
88
+ state = { ...state, ...meta.state };
89
+ state.doc.commit({ origin: "sys:init", timestamp: Date.now() });
90
+ break;
91
+ }
92
+ return state;
93
+ },
94
+ },
95
+ // After a remote change too, as LoroSyncPlugin does: the write is a no-op
96
+ // when the editor and the doc agree, and it points the mapping at the
97
+ // editor's own nodes again.
98
+ appendTransaction: (transactions, _old, newState) =>
99
+ transactions.some((tr) => tr.docChanged)
100
+ ? newState.tr.setMeta(loroSyncPluginKey, { type: "doc-changed" })
101
+ : null,
102
+ view: (view) => {
103
+ let cursors: Cursors = { anchor: null, head: null };
104
+ let unsubscribe: (() => void) | null = null;
105
+ const remember = () => {
106
+ cursors = selectionToCursors(view.state, doc, containerId);
107
+ };
108
+ const timer = setTimeout(() => {
109
+ if (view.isDestroyed) return;
110
+ unsubscribe = doc.getContainerById(containerId)!.subscribe((event) => {
111
+ // Local edits came from this editor, so it already shows them.
112
+ // An undo is local to Loro but new to the editor.
113
+ if (event.by === "local" && event.origin !== "undo") return;
114
+ applyRemote(view, doc, containerId, event, cursors);
115
+ remember();
116
+ });
117
+ loadInitial(view, doc, containerId);
118
+ remember();
119
+ }, 0);
120
+ return {
121
+ update: () => {
122
+ const state = loroSyncPluginKey.getState(view.state) as SyncState | undefined;
123
+ // Only when the editor and the doc agree: after a local change the
124
+ // apply above wrote it to Loro synchronously.
125
+ if (state) remember();
126
+ },
127
+ destroy: () => {
128
+ clearTimeout(timer);
129
+ unsubscribe?.();
130
+ },
131
+ };
132
+ },
133
+ });
134
+ }
135
+
136
+ /** Edits closer together than this are one undo step. */
137
+ export const UNDO_MERGE_MS = 500;
138
+
139
+ /**
140
+ * Undo for a document bound with ProseMirrorSyncPlugin: LoroUndoPlugin with
141
+ * typing grouped into steps (Loro's default makes every character a step),
142
+ * and without the binding's first load, which is not this person's edit.
143
+ * It undoes only this person's edits.
144
+ */
145
+ export function ProseMirrorUndoPlugin({ doc }: { doc: LoroDoc }): Plugin {
146
+ const undoManager = new UndoManager(doc, {
147
+ mergeInterval: UNDO_MERGE_MS,
148
+ excludeOriginPrefixes: ["sys:init"],
149
+ });
150
+ return LoroUndoPlugin({ doc: doc as never, undoManager: undoManager as never });
151
+ }
152
+
153
+ function field(doc: LoroDoc, containerId: ContainerID): LoroMap {
154
+ return doc.getContainerById(containerId) as LoroMap;
155
+ }
156
+
157
+ /**
158
+ * The browser's selection in the editor, when it differs from the editor
159
+ * state's. A click or an arrow key moves the DOM selection first, and the
160
+ * editor reads it a little later; a remote change in between would put the
161
+ * caret back where it was.
162
+ */
163
+ function domSelectionIfMoved(view: EditorView): { anchor: number; head: number } | null {
164
+ if (!view.hasFocus()) return null;
165
+ const sel = (view.root as Document).getSelection?.();
166
+ if (!sel?.anchorNode || !sel.focusNode || !view.dom.contains(sel.anchorNode)) return null;
167
+ try {
168
+ const anchor = view.posAtDOM(sel.anchorNode, sel.anchorOffset);
169
+ const head = view.posAtDOM(sel.focusNode, sel.focusOffset);
170
+ const { selection } = view.state;
171
+ return anchor === selection.anchor && head === selection.head ? null : { anchor, head };
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /** True for a document with no top node or no children yet. */
178
+ function isEmpty(map: LoroMap): boolean {
179
+ if (map.get("nodeName") == null) return true;
180
+ const children = map.get(CHILDREN_KEY);
181
+ return !(children instanceof LoroList) || children.length === 0;
182
+ }
183
+
184
+ /** Fill the editor from the doc on mount (what LoroSyncPlugin's init does). */
185
+ function loadInitial(view: EditorView, doc: LoroDoc, containerId: ContainerID): void {
186
+ const mapping: Mapping = new Map();
187
+ const map = field(doc, containerId);
188
+ let tr = view.state.tr;
189
+ const node = isEmpty(map)
190
+ ? null
191
+ : (createNodeFromLoroObj(view.state.schema, map as never, mapping as never) as Node);
192
+ // An empty document (the server's, before anyone typed) keeps the editor's
193
+ // starting content; the first edit writes it into the server's containers.
194
+ if (node && node.childCount > 0) {
195
+ tr = tr.replaceWith(0, view.state.doc.content.size, node.content);
196
+ }
197
+ tr.setMeta(loroSyncPluginKey, { type: "update-state", state: { mapping, snapshot: null } });
198
+ tr.setMeta("addToHistory", false);
199
+ view.dispatch(tr);
200
+ }
201
+
202
+ /** Drop cached nodes for every container an event touched, and their ancestors. */
203
+ function forgetChanged(doc: LoroDoc, event: { events: Array<{ target: ContainerID }> }, mapping: Mapping): void {
204
+ for (const e of event.events) {
205
+ let obj = doc.getContainerById(e.target) as { id: ContainerID; parent(): unknown } | undefined;
206
+ while (obj) {
207
+ mapping.delete(obj.id);
208
+ obj = obj.parent() as typeof obj;
209
+ }
210
+ }
211
+ }
212
+
213
+ function applyRemote(
214
+ view: EditorView,
215
+ doc: LoroDoc,
216
+ containerId: ContainerID,
217
+ event: { events: Array<{ target: ContainerID }> },
218
+ cursors: Cursors,
219
+ ): void {
220
+ if (view.isDestroyed) return;
221
+ const state = loroSyncPluginKey.getState(view.state) as SyncState;
222
+ // Read before the replace: positions in the document as it is now.
223
+ const moved = domSelectionIfMoved(view);
224
+ forgetChanged(doc, event, state.mapping);
225
+ const root = field(doc, containerId);
226
+ // Someone emptied the document: show the schema's empty document.
227
+ const next = isEmpty(root)
228
+ ? view.state.schema.topNodeType.createAndFill()!
229
+ : (createNodeFromLoroObj(view.state.schema, root as never, state.mapping as never) as Node);
230
+ const current = view.state.doc;
231
+ const start = current.content.findDiffStart(next.content);
232
+ let tr = view.state.tr;
233
+ if (start != null) {
234
+ let { a: endA, b: endB } = current.content.findDiffEnd(next.content)!;
235
+ // A run of equal content at both ends can make the two ends cross.
236
+ const overlap = start - Math.min(endA, endB);
237
+ if (overlap > 0) {
238
+ endA += overlap;
239
+ endB += overlap;
240
+ }
241
+ tr = tr.replace(start, endA, next.slice(start, endB));
242
+ }
243
+ if (moved) {
244
+ // The person moved the caret after the cursors were taken: map where it
245
+ // is now through the replace.
246
+ tr = tr.setSelection(
247
+ TextSelection.between(tr.doc.resolve(tr.mapping.map(moved.anchor)), tr.doc.resolve(tr.mapping.map(moved.head))),
248
+ );
249
+ tr.setMeta(loroSyncPluginKey, { type: "non-local-updates" });
250
+ tr.setMeta("addToHistory", false);
251
+ view.dispatch(tr);
252
+ return;
253
+ }
254
+ const anchor = cursors.anchor && cursorToPosition(cursors.anchor, doc, containerId, state.mapping);
255
+ const head = cursors.head && cursorToPosition(cursors.head, doc, containerId, state.mapping);
256
+ // Otherwise the selection stays where ProseMirror mapped it through the replace.
257
+ if (anchor != null && anchor <= tr.doc.content.size) {
258
+ const headPos = head != null && head <= tr.doc.content.size ? head : anchor;
259
+ tr = tr.setSelection(TextSelection.between(tr.doc.resolve(anchor), tr.doc.resolve(headPos)));
260
+ }
261
+ tr.setMeta(loroSyncPluginKey, { type: "non-local-updates" });
262
+ tr.setMeta("addToHistory", false);
263
+ view.dispatch(tr);
264
+ }
265
+
266
+ /**
267
+ * The selection as Loro cursors, bound to the character on the left of each
268
+ * end. Null ends where the doc has no text to bind to.
269
+ */
270
+ export function selectionToCursors(state: EditorState, doc: LoroDoc, containerId: ContainerID): Cursors {
271
+ const { anchor, head } = state.selection;
272
+ const a = positionToCursor(state.doc, anchor, doc, containerId);
273
+ return { anchor: a, head: head === anchor ? a : positionToCursor(state.doc, head, doc, containerId) };
274
+ }
275
+
276
+ /**
277
+ * The Loro container for each child of a node, in the binding's layout: a
278
+ * run of text nodes is one LoroText, every other node one LoroMap.
279
+ */
280
+ function childIndexFor(node: Node, pmIndex: number): number {
281
+ let group = -1;
282
+ let inText = false;
283
+ for (let i = 0; i <= pmIndex && i < node.childCount; i++) {
284
+ const child = node.child(i);
285
+ if (child.isText) {
286
+ if (!inText) group += 1;
287
+ inText = true;
288
+ } else {
289
+ group += 1;
290
+ inText = false;
291
+ }
292
+ }
293
+ return group;
294
+ }
295
+
296
+ /**
297
+ * A cursor for a document position. Walks the position's path from the
298
+ * field's root down, so it does not depend on node identity (a replace
299
+ * rebuilds the nodes along its path).
300
+ */
301
+ export function positionToCursor(root: Node, pos: number, doc: LoroDoc, containerId: ContainerID): Cursor | null {
302
+ if (pos < 0 || pos > root.content.size) return null;
303
+ const $pos = root.resolve(pos);
304
+ let map = field(doc, containerId);
305
+ for (let depth = 0; depth < $pos.depth; depth++) {
306
+ const children = map.get(CHILDREN_KEY);
307
+ if (!(children instanceof LoroList)) return null;
308
+ const child = children.get(childIndexFor($pos.node(depth), $pos.index(depth)));
309
+ if (!(child instanceof LoroMap)) return null;
310
+ map = child;
311
+ }
312
+ const children = map.get(CHILDREN_KEY);
313
+ if (!(children instanceof LoroList)) return null;
314
+ if (children.length === 0) return children.getCursor(0) ?? null;
315
+ // Between blocks (a node selection, a gap cursor): bind to the next child.
316
+ if (!$pos.parent.inlineContent) {
317
+ return children.getCursor(childIndexFor($pos.parent, $pos.index()) + ($pos.index() >= $pos.parent.childCount ? 1 : 0)) ?? null;
318
+ }
319
+ // Walk the textblock's children: text runs count their characters, every
320
+ // other inline node counts one.
321
+ let offset = $pos.parentOffset;
322
+ for (let i = 0; i < children.length; i++) {
323
+ const child = children.get(i);
324
+ if (child instanceof LoroText) {
325
+ if (offset <= child.length) {
326
+ // Bind to the character on the left (side 1: just after it). At the
327
+ // start of a run there is none, so bind to the first character.
328
+ return (offset > 0 ? child.getCursor(offset - 1, 1) : child.getCursor(0, 0)) ?? null;
329
+ }
330
+ offset -= child.length;
331
+ } else {
332
+ if (offset === 0) return children.getCursor(i) ?? null;
333
+ offset -= 1;
334
+ }
335
+ }
336
+ return children.getCursor(children.length) ?? null;
337
+ }
338
+
339
+ /** Size in the editor of a container's node, from the binding's mapping. */
340
+ function mappedSize(mapping: Mapping, id: ContainerID): number | null {
341
+ const mapped = mapping.get(id);
342
+ if (mapped == null) return null;
343
+ return Array.isArray(mapped) ? mapped.reduce((n, node) => n + node.nodeSize, 0) : mapped.nodeSize;
344
+ }
345
+
346
+ /**
347
+ * The document position a cursor points at now. Uses the mapping, which
348
+ * createNodeFromLoroObj has just filled for the whole tree.
349
+ */
350
+ export function cursorToPosition(cursor: Cursor, doc: LoroDoc, containerId: ContainerID, mapping: Mapping): number | null {
351
+ const rootId = field(doc, containerId).id;
352
+ const target = cursor.containerId();
353
+ let index: number;
354
+ let childId: ContainerID;
355
+ let node: LoroMap | undefined;
356
+ if (target.endsWith("List")) {
357
+ const list = doc.getContainerById(target) as LoroList | undefined;
358
+ const pos = doc.getCursorPos(cursor);
359
+ const owner = list?.parent() as LoroMap | undefined;
360
+ if (!list || !pos || pos.update || !owner) return null;
361
+ // Inside a node with no text, or before an inline node: count the
362
+ // children before it.
363
+ index = 0;
364
+ for (let i = 0; i < pos.offset; i++) {
365
+ const child = list.get(i) as { id?: ContainerID } | undefined;
366
+ const size = child?.id ? mappedSize(mapping, child.id) : null;
367
+ if (size == null) return null;
368
+ index += size;
369
+ }
370
+ if (owner.id === rootId) return index;
371
+ childId = owner.id;
372
+ node = (owner.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
373
+ index += 1; // into the owner node
374
+ } else {
375
+ const text = doc.getContainerById(target) as LoroText | undefined;
376
+ const pos = doc.getCursorPos(cursor);
377
+ // `update` means the character was deleted (a split moves text into a
378
+ // new paragraph as a delete and an insert). ProseMirror's own mapping
379
+ // through the replace places the caret better then.
380
+ if (!text || !pos || pos.update) return null;
381
+ // The cursor names a character; side 1 means just after it.
382
+ index = pos.offset + (pos.side === 1 ? 1 : 0);
383
+ childId = text.id;
384
+ node = (text.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
385
+ }
386
+ while (node) {
387
+ const children = node.get(CHILDREN_KEY);
388
+ if (!(children instanceof LoroList)) return null;
389
+ for (let i = 0; i < children.length; i++) {
390
+ const child = children.get(i) as { id?: ContainerID } | undefined;
391
+ if (!child?.id || child.id === childId) break;
392
+ const size = mappedSize(mapping, child.id);
393
+ if (size == null) return null;
394
+ index += size;
395
+ }
396
+ if (node.id === rootId) return index;
397
+ childId = node.id;
398
+ node = (node.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
399
+ index += 1; // the opening of the node we came out of
400
+ }
401
+ return null;
402
+ }