@pylonsync/loro 0.22.47 → 0.22.50

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/loro",
3
- "version": "0.22.47",
3
+ "version": "0.22.50",
4
4
  "description": "Pylon's local-first React layer — wraps loro-crdt with a registry, useLoroDoc hook, and the binary WS wire-format decoder that mirrors crates/router/encode_crdt_frame.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -23,8 +23,8 @@
23
23
  "check": "tsc -p tsconfig.json --noEmit"
24
24
  },
25
25
  "dependencies": {
26
- "@pylonsync/react": "0.22.47",
27
- "@pylonsync/sync": "0.22.47",
26
+ "@pylonsync/react": "0.22.50",
27
+ "@pylonsync/sync": "0.22.50",
28
28
  "loro-crdt": "^1.10.6"
29
29
  },
30
30
  "peerDependencies": {
@@ -8,8 +8,9 @@ import { Schema } from "prosemirror-model";
8
8
  import { schema as basic } from "prosemirror-schema-basic";
9
9
  import { EditorState, TextSelection } from "prosemirror-state";
10
10
  import { EditorView } from "prosemirror-view";
11
- import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
11
+ import { configureProseMirrorMarks, findProseMirrorContainer, readProseMirror, writeProseMirror } from "./index";
12
12
  import {
13
+ resolveRangesInDoc,
13
14
  diffMatches,
14
15
  encodeRange,
15
16
  mapSpans,
@@ -884,3 +885,33 @@ test("an in-place undo after an outside retype does not keep the retyped range",
884
885
  expect(reanchored).toEqual([]);
885
886
  void a;
886
887
  });
888
+
889
+ // A server function has no editor: it reads the document from the doc.
890
+ test("resolveRangesInDoc finds a range in the doc as it is now, or says it is gone", () => {
891
+ const doc = new LoroDoc();
892
+ const body = doc.getMap("row").setContainer("body", new LoroMap());
893
+ body.setContainer("attributes", new LoroMap());
894
+ body.setContainer("children", new LoroList());
895
+ doc.commit();
896
+ configureProseMirrorMarks(doc, schema);
897
+ const p = (t: string) => schema.node("paragraph", null, [schema.text(t)]);
898
+ writeProseMirror(doc, "body", schema, schema.node("doc", null, [p("Hello world"), p("Second")]));
899
+ doc.commit();
900
+ const containerId = findProseMirrorContainer(doc, "body")!;
901
+ const state = EditorState.create({ schema, doc: readProseMirror(doc, "body", schema)! });
902
+ // "world": after "Hello " in the first paragraph (position 1 is its start).
903
+ const anchor = encodeRange(state, doc, containerId, 7, 12);
904
+ const text = (r: { from: number; to: number }) => readProseMirror(doc, "body", schema)!.textBetween(r.from, r.to);
905
+
906
+ writeProseMirror(doc, "body", schema, schema.node("doc", null, [p("Oh, hello world"), p("Second")]));
907
+ doc.commit();
908
+ const [moved] = resolveRangesInDoc(doc, "body", schema, [{ id: "a", ...anchor }]);
909
+ expect(moved!.gone).toBe(false);
910
+ expect(text(moved!)).toBe("world");
911
+
912
+ writeProseMirror(doc, "body", schema, schema.node("doc", null, [p("Oh, hello"), p("Second")]));
913
+ doc.commit();
914
+ expect(resolveRangesInDoc(doc, "body", schema, [{ id: "a", ...anchor }])[0]!.gone).toBe(true);
915
+ // A doc with no document in the field.
916
+ expect(resolveRangesInDoc(new LoroDoc(), "body", schema, [{ id: "a", ...anchor }])[0]!.gone).toBe(true);
917
+ });
@@ -17,6 +17,10 @@
17
17
  * // For a margin: where each range is now, or `gone`.
18
18
  * resolveRanges(view.state, doc, containerId, ranges)
19
19
  *
20
+ * // In a server function, with no editor: positions in the document
21
+ * // readProseMirror (or editProseMirror's state) gives for the doc now.
22
+ * resolveRangesInDoc(doc, "body", schema, ranges)
23
+ *
20
24
  * The binding writes some edits as a delete and an insert: splitting or
21
25
  * joining paragraphs moves text into new characters, and a cursor on a moved
22
26
  * character no longer names it. The editor that makes such an edit still
@@ -26,14 +30,18 @@
26
30
  * again. Until then, other editors draw a range only where its cursors find
27
31
  * it, so never on characters that replaced its own.
28
32
  */
29
- import type { ContainerID, LoroDoc } from "loro-crdt";
30
- import { loroSyncPluginKey } from "loro-prosemirror";
31
- import type { Node } from "prosemirror-model";
33
+ import type { ContainerID, LoroDoc, LoroMap } from "loro-crdt";
34
+ import { createNodeFromLoroObj, loroSyncPluginKey } from "loro-prosemirror";
35
+ import type { Node, Schema } from "prosemirror-model";
32
36
  import { type EditorState, Plugin, PluginKey, type Transaction } from "prosemirror-state";
33
37
  import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
34
38
  import { decodeCursor, encodeCursor } from "./cursors";
39
+ import { findProseMirrorContainer } from "./index";
35
40
  import { cursorToPosition, positionToCursor } from "./sync";
36
41
 
42
+ /** Loro containers to the ProseMirror nodes made from them (loro-prosemirror's mapping). */
43
+ type Mapping = Map<ContainerID, Node | Node[]>;
44
+
37
45
  /** A range as encoded cursors (see {@link encodeRange}). */
38
46
  export interface EncodedRange {
39
47
  from: string | null;
@@ -116,22 +124,38 @@ export function encodeRange(
116
124
  return { from: start ? encodeCursor(start) : null, to: end ? encodeCursor(end) : null };
117
125
  }
118
126
 
127
+ /** The mapping of an editor bound with ProseMirrorSyncPlugin, or null without one. */
128
+ function syncMapping(state: EditorState): Mapping | null {
129
+ const sync = loroSyncPluginKey.getState(state) as { mapping?: Mapping } | undefined;
130
+ return sync?.mapping ?? null;
131
+ }
132
+
119
133
  /** Where a stored cursor is now, and whether its character was deleted. */
120
134
  function locate(
121
135
  encoded: string | null | undefined,
122
136
  state: EditorState,
123
137
  doc: LoroDoc,
124
138
  containerId: ContainerID,
139
+ ): { pos: number; moved: boolean } | null {
140
+ const mapping = syncMapping(state);
141
+ return mapping ? locateIn(encoded, state.doc, mapping, doc, containerId) : null;
142
+ }
143
+
144
+ function locateIn(
145
+ encoded: string | null | undefined,
146
+ root: Node,
147
+ mapping: Mapping,
148
+ doc: LoroDoc,
149
+ containerId: ContainerID,
125
150
  ): { pos: number; moved: boolean } | null {
126
151
  if (!encoded) return null;
127
- const sync = loroSyncPluginKey.getState(state) as { mapping?: Map<ContainerID, Node | Node[]> } | undefined;
128
152
  const cursor = decodeCursor(encoded);
129
- if (!sync?.mapping || !cursor) return null;
130
- const inDoc = (pos: number | null) => pos != null && pos >= 0 && pos <= state.doc.content.size;
153
+ if (!cursor) return null;
154
+ const inDoc = (pos: number | null) => pos != null && pos >= 0 && pos <= root.content.size;
131
155
  try {
132
- const exact = cursorToPosition(cursor, doc, containerId, sync.mapping);
156
+ const exact = cursorToPosition(cursor, doc, containerId, mapping);
133
157
  if (inDoc(exact)) return { pos: exact!, moved: false };
134
- const near = cursorToPosition(cursor, doc, containerId, sync.mapping, { acceptMoved: true });
158
+ const near = cursorToPosition(cursor, doc, containerId, mapping, { acceptMoved: true });
135
159
  return inDoc(near) ? { pos: near!, moved: true } : null;
136
160
  } catch {
137
161
  return null;
@@ -159,10 +183,43 @@ export function resolveRanges(
159
183
  doc: LoroDoc,
160
184
  containerId: ContainerID,
161
185
  ranges: Array<Pick<AnchoredRange, "id" | "from" | "to">>,
186
+ ): ResolvedRange[] {
187
+ const mapping = syncMapping(state);
188
+ return resolveWith(state.doc, mapping, doc, containerId, ranges);
189
+ }
190
+
191
+ /**
192
+ * {@link resolveRanges} for code with no editor, such as a server function:
193
+ * the positions are in `field`'s document as the doc holds it now, the
194
+ * document `readProseMirror` returns and `editProseMirror` hands its
195
+ * `change`. Every range is `gone` when the doc has no document in `field`.
196
+ */
197
+ export function resolveRangesInDoc(
198
+ doc: LoroDoc,
199
+ field: string,
200
+ schema: Schema,
201
+ ranges: Array<Pick<AnchoredRange, "id" | "from" | "to">>,
202
+ ): ResolvedRange[] {
203
+ const containerId = findProseMirrorContainer(doc, field);
204
+ const map = containerId ? (doc.getContainerById(containerId) as LoroMap | undefined) : undefined;
205
+ if (!containerId || !map || map.get("nodeName") == null) {
206
+ return ranges.map((r) => ({ id: r.id, from: 0, to: 0, gone: true }));
207
+ }
208
+ const mapping: Mapping = new Map();
209
+ const root = createNodeFromLoroObj(schema, map as never, mapping as never) as Node;
210
+ return resolveWith(root, mapping, doc, containerId, ranges);
211
+ }
212
+
213
+ function resolveWith(
214
+ root: Node,
215
+ mapping: Mapping | null,
216
+ doc: LoroDoc,
217
+ containerId: ContainerID,
218
+ ranges: Array<Pick<AnchoredRange, "id" | "from" | "to">>,
162
219
  ): ResolvedRange[] {
163
220
  return ranges.map((r) => {
164
- const from = locate(r.from, state, doc, containerId);
165
- const to = locate(r.to, state, doc, containerId);
221
+ const from = mapping ? locateIn(r.from, root, mapping, doc, containerId) : null;
222
+ const to = mapping ? locateIn(r.to, root, mapping, doc, containerId) : null;
166
223
  const at = from?.pos ?? to?.pos ?? 0;
167
224
  if (!from || !to || (from.moved && to.moved) || to.pos <= from.pos) return { id: r.id, from: at, to: at, gone: true };
168
225
  return { id: r.id, from: from.pos, to: to.pos, gone: false };