@pylonsync/loro 0.22.44 → 0.22.47

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.
@@ -7,11 +7,11 @@ if (!globalThis.document) GlobalRegistrator.register();
7
7
  import { afterEach, describe, expect, test } from "bun:test";
8
8
  import { LoroDoc, LoroList, LoroMap, type ContainerID } from "loro-crdt";
9
9
  import { undo } from "loro-prosemirror";
10
- import { Schema } from "prosemirror-model";
10
+ import { type Node, Schema } from "prosemirror-model";
11
11
  import { schema as basic } from "prosemirror-schema-basic";
12
- import { EditorState, TextSelection } from "prosemirror-state";
12
+ import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
13
13
  import { EditorView } from "prosemirror-view";
14
- import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
14
+ import { configureProseMirrorMarks, findProseMirrorContainer, writeProseMirror } from "./index";
15
15
  import { ProseMirrorSyncPlugin, ProseMirrorUndoPlugin } from "./sync";
16
16
 
17
17
  const defaultSchema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
@@ -312,7 +312,6 @@ describe("ProseMirrorSyncPlugin review fixes", () => {
312
312
  type(a, "pic ");
313
313
  a.dispatch(a.state.tr.insert(5, defaultSchema.node("image", { src: "x.png" })));
314
314
  sync();
315
- const { NodeSelection } = await import("prosemirror-state");
316
315
  b.dispatch(b.state.tr.setSelection(NodeSelection.create(b.state.doc, 5)));
317
316
  caretAt(a, 1);
318
317
  type(a, "A ");
@@ -321,3 +320,110 @@ describe("ProseMirrorSyncPlugin review fixes", () => {
321
320
  expect((b.state.selection as InstanceType<typeof NodeSelection>).node.type.name).toBe("image");
322
321
  });
323
322
  });
323
+
324
+ describe("ProseMirrorSyncPlugin with nested nodes", () => {
325
+ // A table's rows and cells, with prosemirror-tables' content rules.
326
+ const tableSchema = new Schema({
327
+ nodes: {
328
+ doc: { content: "block+" },
329
+ paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
330
+ table: { group: "block", content: "row+", toDOM: () => ["table", ["tbody", 0]] },
331
+ row: { content: "cell*", toDOM: () => ["tr", 0] },
332
+ cell: { content: "block+", isolating: true, toDOM: () => ["td", 0] },
333
+ text: { group: "inline" },
334
+ },
335
+ });
336
+ const p = (t: string) => tableSchema.node("paragraph", null, t ? [tableSchema.text(t)] : []);
337
+ const table = (rows: string[][]) =>
338
+ tableSchema.node(
339
+ "table",
340
+ null,
341
+ rows.map((r) => tableSchema.node("row", null, r.map((c) => tableSchema.node("cell", null, [p(c)])))),
342
+ );
343
+ const doc = (rows: string[][]) => tableSchema.node("doc", null, [p("Owners"), table(rows)]);
344
+
345
+ // A model streaming a table: each write is the whole document again. Here
346
+ // the next write fills the first cell and the second. The smallest
347
+ // replace runs from inside one cell into the next, and ProseMirror does
348
+ // not join across isolating cells: the row got a fourth cell.
349
+ test("a document rewritten through a table reaches the editor exactly", async () => {
350
+ const { a, docs } = setup({ schema: tableSchema });
351
+ await tick();
352
+ const steps = [
353
+ doc([["Area", "Owner", "Due"], ["Rele", "", ""]]),
354
+ doc([["Area", "Owner", "Due"], ["Release notes", "Ben", ""]]),
355
+ doc([["Area", "Owner", "Due"], ["Release notes", "Ben", "Oct 25"], ["Sup", "", ""]]),
356
+ doc([["Area", "Owner", "Due"], ["Release notes", "Ben", "Oct 25"], ["Support inbox", "TBD", "Oct 26"]]),
357
+ ];
358
+ for (const next of steps) {
359
+ writeProseMirror(docs[1]!, "body", tableSchema, next);
360
+ docs[1]!.commit();
361
+ docs[0]!.import(docs[1]!.export({ mode: "update", from: docs[0]!.oplogVersion() }));
362
+ await tick();
363
+ expect(a.state.doc.toJSON()).toEqual(next.toJSON());
364
+ }
365
+ });
366
+
367
+ test("a selected cell the change does not touch stays selected", async () => {
368
+ const { a, docs } = setup({ schema: tableSchema });
369
+ await tick();
370
+ const write = async (next: Node) => {
371
+ writeProseMirror(docs[1]!, "body", tableSchema, next);
372
+ docs[1]!.commit();
373
+ docs[0]!.import(docs[1]!.export({ mode: "update", from: docs[0]!.oplogVersion() }));
374
+ await tick();
375
+ };
376
+ await write(doc([["Area", "Owner", "Due"], ["Rele", "", ""]]));
377
+ let areaCell = -1;
378
+ a.state.doc.descendants((node, pos) => {
379
+ if (areaCell < 0 && node.type.name === "cell") areaCell = pos;
380
+ });
381
+ a.dispatch(a.state.tr.setSelection(NodeSelection.create(a.state.doc, areaCell)));
382
+ await write(doc([["Area", "Owner", "Due"], ["Release notes", "Ben", ""]]));
383
+ expect(a.state.selection).toBeInstanceOf(NodeSelection);
384
+ expect(a.state.selection.from).toBe(areaCell);
385
+ expect((a.state.selection as InstanceType<typeof NodeSelection>).node.textContent).toBe("Area");
386
+ });
387
+
388
+ test("a selected paragraph keeps its selection when the cells around it change", async () => {
389
+ const { a, docs } = setup({ schema: tableSchema });
390
+ await tick();
391
+ const cells = (first: string, second: string) =>
392
+ tableSchema.node("doc", null, [
393
+ tableSchema.node("table", null, [
394
+ tableSchema.node("row", null, [
395
+ tableSchema.node("cell", null, [p("Keep"), p(first)]),
396
+ tableSchema.node("cell", null, [p(second)]),
397
+ ]),
398
+ ]),
399
+ ]);
400
+ const write = async (next: Node) => {
401
+ writeProseMirror(docs[1]!, "body", tableSchema, next);
402
+ docs[1]!.commit();
403
+ docs[0]!.import(docs[1]!.export({ mode: "update", from: docs[0]!.oplogVersion() }));
404
+ await tick();
405
+ };
406
+ await write(cells("Rele", ""));
407
+ // doc > table > row > cell > the "Keep" paragraph.
408
+ a.dispatch(a.state.tr.setSelection(NodeSelection.create(a.state.doc, 3)));
409
+ await write(cells("Release notes", "Ben"));
410
+ expect(a.state.doc.toJSON()).toEqual(cells("Release notes", "Ben").toJSON());
411
+ expect(a.state.selection).toBeInstanceOf(NodeSelection);
412
+ expect((a.state.selection as InstanceType<typeof NodeSelection>).node.textContent).toBe("Keep");
413
+ });
414
+
415
+ test("typing in a paragraph still maps the selection, not the whole block", async () => {
416
+ const { a, b, sync } = setup({ schema: tableSchema });
417
+ await tick();
418
+ b.dispatch(b.state.tr.replaceWith(0, b.state.doc.content.size, doc([["x", "y", "z"]]).content));
419
+ sync();
420
+ await tick();
421
+ // Ada's caret after "Own".
422
+ a.dispatch(a.state.tr.setSelection(TextSelection.create(a.state.doc, 4)));
423
+ b.dispatch(b.state.tr.insertText("The ", 1));
424
+ sync();
425
+ await tick();
426
+ expect(a.state.doc.child(0).textContent).toBe("The Owners");
427
+ expect(a.state.selection.from).toBe(8);
428
+ });
429
+ });
@@ -38,7 +38,7 @@ import {
38
38
  loroUndoPluginKey,
39
39
  updateLoroToPmState,
40
40
  } from "loro-prosemirror";
41
- import type { Node } from "prosemirror-model";
41
+ import type { Fragment, Node } from "prosemirror-model";
42
42
  import { Plugin, TextSelection, type EditorState, type Transaction } from "prosemirror-state";
43
43
  import type { EditorView } from "prosemirror-view";
44
44
 
@@ -265,20 +265,7 @@ function applyRemote(
265
265
  const next = isEmpty(root)
266
266
  ? view.state.schema.topNodeType.createAndFill()!
267
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);
268
+ let tr = withTopAttrs(replaceToMatch(view.state, next), next);
282
269
  // Nothing this editor shows changed (an echo, a change elsewhere in the row).
283
270
  if (!tr.docChanged) return;
284
271
  // A node selection, a cell selection: ProseMirror maps it through the
@@ -312,6 +299,81 @@ function applyRemote(
312
299
  view.dispatch(tr);
313
300
  }
314
301
 
302
+ /**
303
+ * A transaction that turns the editor's document into `next`'s content,
304
+ * changing as little as it can so the selection maps through.
305
+ */
306
+ function replaceToMatch(state: EditorState, next: Node): Transaction {
307
+ const current = state.doc;
308
+ const start = current.content.findDiffStart(next.content);
309
+ if (start == null) return state.tr;
310
+ let { a: endA, b: endB } = current.content.findDiffEnd(next.content)!;
311
+ // A run of equal content at both ends can make the two ends cross.
312
+ const overlap = start - Math.min(endA, endB);
313
+ if (overlap > 0) {
314
+ endA += overlap;
315
+ endB += overlap;
316
+ }
317
+ const fine = state.tr.replace(start, endA, next.slice(start, endB));
318
+ if (fine.doc.content.eq(next.content)) return fine;
319
+ // ProseMirror fits a slice into the place it goes, and it does not join
320
+ // across isolating nodes (a table's cells): the fitted result can differ
321
+ // from `next`. Replace whole nodes instead, as deep as the change allows,
322
+ // so positions in the nodes around it still map.
323
+ const exact = replaceChangedNodes(state, next);
324
+ if (exact.doc.content.eq(next.content)) return exact;
325
+ return state.tr.replaceWith(0, current.content.size, next.content);
326
+ }
327
+
328
+ /**
329
+ * Replace the nodes that differ between the editor's document and `next`,
330
+ * looking inside each one that keeps its type and attributes, so nodes
331
+ * that did not change keep their positions (and a selection on them).
332
+ */
333
+ function replaceChangedNodes(state: EditorState, next: Node): Transaction {
334
+ const tr = state.tr;
335
+ replaceChangedChildren(tr, state.doc.content, next.content, 0);
336
+ return tr;
337
+ }
338
+
339
+ /**
340
+ * Turn fragment `a`, which starts at `offset` in `tr.doc`, into `b`. Works
341
+ * from the end, so positions before each change still hold.
342
+ */
343
+ function replaceChangedChildren(tr: Transaction, a: Fragment, b: Fragment, offset: number): void {
344
+ let first = 0;
345
+ while (first < a.childCount && first < b.childCount && a.child(first).eq(b.child(first))) first++;
346
+ let endA = a.childCount;
347
+ let endB = b.childCount;
348
+ while (endA > first && endB > first && a.child(endA - 1).eq(b.child(endB - 1))) {
349
+ endA--;
350
+ endB--;
351
+ }
352
+ let from = offset;
353
+ for (let i = 0; i < first; i++) from += a.child(i).nodeSize;
354
+ let to = from;
355
+ for (let i = first; i < endA; i++) to += a.child(i).nodeSize;
356
+ // Children that pair up one to one: each changed one on its own. (The
357
+ // caller checks the result; a step ProseMirror has to fit falls back.)
358
+ if (endA - first === endB - first) {
359
+ let end = to;
360
+ for (let i = endA - 1; i >= first; i--) {
361
+ const x = a.child(i);
362
+ const y = b.child(i);
363
+ const start = end - x.nodeSize;
364
+ if (!x.eq(y)) {
365
+ if (!x.isLeaf && x.sameMarkup(y)) replaceChangedChildren(tr, x.content, y.content, start + 1);
366
+ else tr.replaceWith(start, end, y);
367
+ }
368
+ end = start;
369
+ }
370
+ return;
371
+ }
372
+ const nodes: Node[] = [];
373
+ for (let i = first; i < endB; i++) nodes.push(b.child(i));
374
+ tr.replaceWith(from, to, nodes);
375
+ }
376
+
315
377
  /**
316
378
  * The selection as Loro cursors, bound to the character on the left of each
317
379
  * end. Null ends where the doc has no text to bind to.
@@ -346,8 +408,21 @@ function childIndexFor(node: Node, pmIndex: number): number {
346
408
  * A cursor for a document position. Walks the position's path from the
347
409
  * field's root down, so it does not depend on node identity (a replace
348
410
  * rebuilds the nodes along its path).
411
+ *
412
+ * Inside text the cursor binds to a character: by default the one on the
413
+ * left, so text typed at a caret goes before it. `bind: "right"` binds to the
414
+ * character on the right instead, for the start of a range, so text typed
415
+ * just before the range stays out of it. Where there is no character on that
416
+ * side, it binds to the other one.
349
417
  */
350
- export function positionToCursor(root: Node, pos: number, doc: LoroDoc, containerId: ContainerID): Cursor | null {
418
+ export function positionToCursor(
419
+ root: Node,
420
+ pos: number,
421
+ doc: LoroDoc,
422
+ containerId: ContainerID,
423
+ options: { bind?: "left" | "right" } = {},
424
+ ): Cursor | null {
425
+ const bind = options.bind ?? "left";
351
426
  if (pos < 0 || pos > root.content.size) return null;
352
427
  const $pos = root.resolve(pos);
353
428
  let map = field(doc, containerId);
@@ -382,14 +457,14 @@ export function positionToCursor(root: Node, pos: number, doc: LoroDoc, containe
382
457
  } else {
383
458
  // The caret at the end of a text run binds to that run, so typing
384
459
  // there continues it.
385
- if (runStart >= 0 && offset <= pmOffset) return textCursor(children.get(group), offset - runStart);
460
+ if (runStart >= 0 && offset <= pmOffset) return textCursor(children.get(group), offset - runStart, bind);
386
461
  runStart = -1;
387
462
  group += 1;
388
463
  if (offset === pmOffset) return children.getCursor(group) ?? null;
389
464
  }
390
465
  pmOffset += child.nodeSize;
391
466
  }
392
- if (runStart >= 0 && offset <= pmOffset) return textCursor(children.get(group), offset - runStart);
467
+ if (runStart >= 0 && offset <= pmOffset) return textCursor(children.get(group), offset - runStart, bind);
393
468
  return children.getCursor(children.length) ?? null;
394
469
  }
395
470
 
@@ -403,11 +478,16 @@ function isLowSurrogate(text: string): boolean {
403
478
  * A cursor `offset` UTF-16 units into a text run, bound to the character on
404
479
  * its left (side 1: just after it), the whole character when it is a
405
480
  * surrogate pair. At the start of the run there is none, so it binds to the
406
- * first character.
481
+ * first character. With `bind` "right" it binds to the character on its
482
+ * right (side -1: just before it) while there is one.
407
483
  */
408
- function textCursor(text: unknown, offset: number): Cursor | null {
484
+ function textCursor(text: unknown, offset: number, bind: "left" | "right"): Cursor | null {
409
485
  if (!(text instanceof LoroText)) return null;
410
486
  if (offset <= 0) return text.getCursor(0, 0) ?? null;
487
+ if (bind === "right" && offset < text.length) {
488
+ const at = isLowSurrogate(text.slice(offset, offset + 1)) ? offset - 1 : offset;
489
+ return text.getCursor(at, -1) ?? null;
490
+ }
411
491
  let start = Math.min(offset, text.length) - 1;
412
492
  if (start > 0 && isLowSurrogate(text.slice(start, start + 1))) start -= 1;
413
493
  return text.getCursor(start, 1) ?? null;