jazz-tools 0.9.8 → 0.9.9

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.
@@ -26,7 +26,7 @@ import {
26
26
  loadCoValue,
27
27
  randomSessionProvider,
28
28
  subscribeToCoValue
29
- } from "./chunk-YD32FKHW.js";
29
+ } from "./chunk-VQZOWIPU.js";
30
30
 
31
31
  // src/index.native.ts
32
32
  import {
package/dist/index.web.js CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  loadCoValue,
27
27
  randomSessionProvider,
28
28
  subscribeToCoValue
29
- } from "./chunk-YD32FKHW.js";
29
+ } from "./chunk-VQZOWIPU.js";
30
30
 
31
31
  // src/index.web.ts
32
32
  import { cojsonInternals, MAX_RECOMMENDED_TX_SIZE, WasmCrypto } from "cojson";
package/dist/testing.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  Account,
3
3
  activeAccountContext,
4
4
  createAnonymousJazzContext
5
- } from "./chunk-YD32FKHW.js";
5
+ } from "./chunk-VQZOWIPU.js";
6
6
 
7
7
  // src/testing.ts
8
8
  import { cojsonInternals } from "cojson";
package/package.json CHANGED
@@ -23,9 +23,9 @@
23
23
  },
24
24
  "type": "module",
25
25
  "license": "MIT",
26
- "version": "0.9.8",
26
+ "version": "0.9.9",
27
27
  "dependencies": {
28
- "cojson": "0.9.0"
28
+ "cojson": "0.9.9"
29
29
  },
30
30
  "devDependencies": {
31
31
  "tsup": "8.3.5",
@@ -63,6 +63,10 @@ export class CoPlainText extends String implements CoValue {
63
63
  return new this({ text, owner: options.owner });
64
64
  }
65
65
 
66
+ get length() {
67
+ return this._raw.toString().length;
68
+ }
69
+
66
70
  toString() {
67
71
  return this._raw.toString();
68
72
  }
@@ -154,7 +158,7 @@ export class CoPlainText extends String implements CoValue {
154
158
  // }
155
159
 
156
160
  /**
157
- * Load and subscribe to a `CoMap` with a given ID, as a given account.
161
+ * Load and subscribe to a `CoPlainText` with a given ID, as a given account.
158
162
  *
159
163
  * Automatically also subscribes to updates to all referenced/nested CoValues as soon as they are accessed in the listener.
160
164
  *
@@ -226,9 +230,9 @@ export class CoPlainText extends String implements CoValue {
226
230
  // }
227
231
 
228
232
  /**
229
- * Given an already loaded `CoMap`, subscribe to updates to the `CoMap` and ensure that the specified fields are loaded to the specified depth.
233
+ * Given an already loaded `CoPlainText`, subscribe to updates to the `CoPlainText` and ensure that the specified fields are loaded to the specified depth.
230
234
  *
231
- * Works like `CoMap.subscribe()`, but you don't need to pass the ID or the account to load as again.
235
+ * Works like `CoPlainText.subscribe()`, but you don't need to pass the ID or the account to load as again.
232
236
  *
233
237
  * Returns an unsubscribe function that you should call when you no longer need updates.
234
238
  *
@@ -8,6 +8,33 @@ import type { Group } from "./group.js";
8
8
  /**
9
9
  * Base class for text annotations and formatting marks.
10
10
  * Represents a mark with start and end positions in text.
11
+ *
12
+ * Example text: "Hello world! How are you?"
13
+ * If we want to mark "world" with bold:
14
+ *
15
+ * ```
16
+ * uncertainty region
17
+ * ↓
18
+ * Hello [····]world[····]! How are you?
19
+ * ↑ ↑ ↑ ↑
20
+ * | | | |
21
+ * startAfter | | endBefore
22
+ * startBefore endAfter
23
+ * ```
24
+ *
25
+ * - startAfter: Position after "Hello " (exclusive boundary)
26
+ * - startBefore: Position before "world" (inclusive boundary)
27
+ * - endAfter: Position after "world" (inclusive boundary)
28
+ * - endBefore: Position before "!" (exclusive boundary)
29
+ *
30
+ * The regions marked with [····] are "uncertainty regions" where:
31
+ * - Text inserted in the left uncertainty region may or may not be part of the mark
32
+ * - Text inserted in the right uncertainty region may or may not be part of the mark
33
+ * - Text inserted between startBefore and endAfter is definitely part of the mark
34
+ *
35
+ * Positions must satisfy:
36
+ * 0 ≤ startAfter ≤ startBefore < endAfter ≤ endBefore ≤ textLength
37
+ * A mark cannot be zero-length, so endAfter must be greater than startBefore.
11
38
  */
12
39
  export class Mark extends CoMap {
13
40
  startAfter = co.json<TextPos | null>();
@@ -15,6 +42,41 @@ export class Mark extends CoMap {
15
42
  endAfter = co.json<TextPos>();
16
43
  endBefore = co.json<TextPos | null>();
17
44
  tag = co.string;
45
+
46
+ /**
47
+ * Validates and clamps mark positions to ensure they are in the correct order
48
+ * @returns Normalized positions or null if invalid
49
+ */
50
+ validatePositions(
51
+ textLength: number,
52
+ idxAfter: (pos: TextPos) => number | undefined,
53
+ idxBefore: (pos: TextPos) => number | undefined,
54
+ ) {
55
+ if (!textLength) {
56
+ console.error("Cannot validate positions for empty text");
57
+ return null;
58
+ }
59
+
60
+ // Get positions with fallbacks
61
+ const positions = {
62
+ startAfter: this.startAfter ? (idxBefore(this.startAfter) ?? 0) : 0,
63
+ startBefore: this.startBefore ? (idxAfter(this.startBefore) ?? 0) : 0,
64
+ endAfter: this.endAfter
65
+ ? (idxBefore(this.endAfter) ?? textLength)
66
+ : textLength,
67
+ endBefore: this.endBefore
68
+ ? (idxAfter(this.endBefore) ?? textLength)
69
+ : textLength,
70
+ };
71
+
72
+ // Clamp and ensure proper ordering in one step
73
+ return {
74
+ startAfter: Math.max(0, positions.startAfter),
75
+ startBefore: Math.max(positions.startAfter + 1, positions.startBefore),
76
+ endAfter: Math.min(textLength - 1, positions.endAfter),
77
+ endBefore: Math.min(textLength, positions.endBefore),
78
+ };
79
+ }
18
80
  }
19
81
 
20
82
  /**
@@ -194,9 +256,21 @@ export class CoRichText extends CoMap {
194
256
  >,
195
257
  options?: { markOwner?: Account | Group },
196
258
  ) {
197
- if (!this.marks) {
259
+ if (!this.text || !this.marks) {
198
260
  throw new Error("Cannot insert a range without loaded ranges");
199
261
  }
262
+
263
+ const textLength = this.length;
264
+
265
+ // Clamp positions to text bounds
266
+ start = Math.max(start, 0);
267
+ end = Math.min(end, textLength);
268
+
269
+ const owner = options?.markOwner || this._owner;
270
+ if (!owner) {
271
+ throw new Error("No owner specified for mark");
272
+ }
273
+
200
274
  const range = RangeClass.create(
201
275
  {
202
276
  ...extraArgs,
@@ -205,11 +279,109 @@ export class CoRichText extends CoMap {
205
279
  endAfter: this.posBefore(end),
206
280
  endBefore: this.posAfter(end),
207
281
  },
208
- { owner: options?.markOwner || this._owner },
282
+ { owner },
209
283
  );
284
+
210
285
  this.marks.push(range);
211
286
  }
212
287
 
288
+ /**
289
+ * Remove a mark at a specific range.
290
+ */
291
+ removeMark<
292
+ MarkClass extends {
293
+ new (...args: any[]): Mark;
294
+ create(init: any, options: { owner: Account | Group }): Mark;
295
+ },
296
+ >(
297
+ start: number,
298
+ end: number,
299
+ RangeClass: MarkClass,
300
+ options: { tag: string },
301
+ ) {
302
+ if (!this.marks) {
303
+ throw new Error("Cannot remove marks without loaded marks");
304
+ }
305
+
306
+ // Find marks of the given class that overlap with the range
307
+ const resolvedMarks = this.resolveMarks();
308
+
309
+ for (const mark of resolvedMarks) {
310
+ // If mark is outside the range, we'll skip it
311
+ if (mark.endBefore < start || mark.startAfter > end) {
312
+ continue;
313
+ }
314
+
315
+ // If mark is wrong type, we'll skip it
316
+ if (options.tag && mark.sourceMark.tag !== options.tag) {
317
+ continue;
318
+ }
319
+
320
+ const markIndex = this.marks.findIndex((m) => m === mark.sourceMark);
321
+ if (markIndex === -1) {
322
+ continue;
323
+ }
324
+
325
+ // If mark is completely inside the range, we'll remove it
326
+ if (mark.startBefore >= start && mark.endAfter <= end) {
327
+ // Remove the mark
328
+ this.marks.splice(markIndex, 1);
329
+ continue;
330
+ }
331
+
332
+ // If mark starts before and extends after the removal range, update end positions to start of removal
333
+ if (
334
+ mark.startBefore < start &&
335
+ mark.endAfter >= start &&
336
+ mark.endAfter <= end
337
+ ) {
338
+ const endAfterPos = this.posBefore(start);
339
+ const endBeforePos = this.posBefore(start);
340
+ if (endAfterPos && endBeforePos) {
341
+ mark.sourceMark.endAfter = endAfterPos;
342
+ mark.sourceMark.endBefore = endBeforePos;
343
+ }
344
+ continue;
345
+ }
346
+
347
+ // If mark starts in removal range and extends beyond it, update start positions to end of removal
348
+ if (
349
+ mark.startBefore >= start &&
350
+ mark.startBefore <= end &&
351
+ mark.endAfter > end
352
+ ) {
353
+ const startAfterPos = this.posAfter(end);
354
+ const startBeforePos = this.posAfter(end);
355
+ if (startAfterPos && startBeforePos) {
356
+ mark.sourceMark.startAfter = startAfterPos;
357
+ mark.sourceMark.startBefore = startBeforePos;
358
+ }
359
+ continue;
360
+ }
361
+
362
+ // If removal is inside the mark, we'll split the mark
363
+ if (mark.startBefore <= start && mark.endAfter >= end) {
364
+ // Split the mark by shortening it at the start and adding a new mark at the end
365
+ const endAfterPos = this.posBefore(start);
366
+ const endBeforePos = this.posBefore(start);
367
+ if (endAfterPos && endBeforePos) {
368
+ mark.sourceMark.endAfter = endAfterPos;
369
+ mark.sourceMark.endBefore = endBeforePos;
370
+ }
371
+ this.insertMark(
372
+ end + 1,
373
+ mark.endBefore,
374
+ RangeClass,
375
+ {},
376
+ {
377
+ markOwner: mark.sourceMark._owner || this._owner,
378
+ },
379
+ );
380
+ continue;
381
+ }
382
+ }
383
+ }
384
+
213
385
  /**
214
386
  * Resolve the positions of all marks.
215
387
  */
@@ -217,35 +389,27 @@ export class CoRichText extends CoMap {
217
389
  if (!this.text || !this.marks) {
218
390
  throw new Error("Cannot resolve ranges without loaded text and ranges");
219
391
  }
220
- const ranges = this.marks.flatMap((mark) => {
392
+
393
+ const textLength = this.length;
394
+
395
+ return this.marks.flatMap((mark) => {
221
396
  if (!mark) return [];
222
- const startBefore = this.idxAfter(mark.startBefore);
223
- const endAfter = this.idxAfter(mark.endAfter);
224
- if (startBefore === undefined || endAfter === undefined) {
225
- return [];
226
- }
227
- const startAfter = mark.startAfter
228
- ? this.idxAfter(mark.startAfter)
229
- : startBefore - 1;
230
- const endBefore = mark.endBefore
231
- ? this.idxAfter(mark.endBefore)
232
- : endAfter + 1;
233
- if (startAfter === undefined || endBefore === undefined) {
234
- return [];
235
- }
397
+
398
+ const positions = mark.validatePositions(
399
+ textLength,
400
+ (pos) => this.idxAfter(pos),
401
+ (pos) => this.idxBefore(pos),
402
+ );
403
+ if (!positions) return [];
404
+
236
405
  return [
237
406
  {
238
407
  sourceMark: mark,
239
- startAfter,
240
- startBefore,
241
- endAfter,
242
- endBefore,
408
+ ...positions,
243
409
  tag: mark.tag,
244
- from: mark,
245
410
  },
246
411
  ];
247
412
  });
248
- return ranges;
249
413
  }
250
414
 
251
415
  /**
@@ -298,10 +462,10 @@ export class CoRichText extends CoMap {
298
462
  toTree(tagPrecedence: string[]): TreeNode {
299
463
  const ranges = this.resolveAndDiffuseAndFocusMarks();
300
464
 
301
- // convert a bunch of (potentially overlapping) ranges into a tree
465
+ // Convert a bunch of (potentially overlapping) ranges into a tree
302
466
  // - make sure we include all text in leaves, even if it's not covered by a range
303
467
  // - we split overlapping ranges in a way where the higher precedence (tag earlier in tagPrecedence)
304
- // stays intact and the lower precende tag is split into two ranges, one inside and one outside the higher precedence range
468
+ // stays intact and the lower precedence tag is split into two ranges, one inside and one outside the higher precedence range
305
469
 
306
470
  const text = this.text?.toString() || "";
307
471
 
@@ -328,14 +492,6 @@ export class CoRichText extends CoMap {
328
492
  ? splitNode(inOrAfter, range.end)
329
493
  : [undefined, undefined];
330
494
 
331
- // console.log("split", range.start, range.end, {
332
- // before,
333
- // inside,
334
- // after,
335
- // });
336
-
337
- // TODO: also split children
338
-
339
495
  return [
340
496
  ...(before ? [before] : []),
341
497
  ...(inside
@@ -365,6 +521,10 @@ export class CoRichText extends CoMap {
365
521
  };
366
522
  }
367
523
 
524
+ get length() {
525
+ return this.text?.toString().length || 0;
526
+ }
527
+
368
528
  /**
369
529
  * Convert a CoRichText to plain text.
370
530
  */
@@ -400,7 +560,7 @@ export type TreeNode = {
400
560
  /**
401
561
  * Split a node at a specific index. So that the node is split into two parts, one before the index, and one after the index.
402
562
  */
403
- function splitNode(
563
+ export function splitNode(
404
564
  node: TreeNode | TreeLeaf,
405
565
  at: number,
406
566
  ): [TreeNode | TreeLeaf | undefined, TreeNode | TreeLeaf | undefined] {
package/src/exports.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  Marks,
34
34
  type TreeLeaf,
35
35
  type TreeNode,
36
+ type ResolvedMark,
36
37
  } from "./coValues/coRichText.js";
37
38
  export { ImageDefinition } from "./coValues/extensions/imageDef.js";
38
39
  export { Group } from "./coValues/group.js";
package/src/internal.ts CHANGED
@@ -1,13 +1,13 @@
1
- export * from "./implementation/symbols.js";
2
- export * from "./implementation/inspect.js";
3
1
  export * from "./coValues/interfaces.js";
2
+ export * from "./implementation/inspect.js";
3
+ export * from "./implementation/symbols.js";
4
4
 
5
- export * from "./implementation/errors.js";
5
+ export * from "./coValues/deepLoading.js";
6
6
  export * from "./implementation/anonymousJazzAgent.js";
7
+ export * from "./implementation/errors.js";
7
8
  export * from "./implementation/refs.js";
8
9
  export * from "./implementation/schema.js";
9
10
  export * from "./implementation/subscriptionScope.js";
10
- export * from "./coValues/deepLoading.js";
11
11
 
12
12
  export * from "./implementation/createContext.js";
13
13
 
@@ -1,33 +1,161 @@
1
+ import { connectedPeers } from "cojson/src/streamUtils.js";
1
2
  import { describe, expect, test } from "vitest";
2
- import { Account, CoPlainText, WasmCrypto } from "../index.web.js";
3
+ import {
4
+ Account,
5
+ CoPlainText,
6
+ WasmCrypto,
7
+ cojsonInternals,
8
+ createJazzContext,
9
+ fixedCredentialsAuth,
10
+ isControlledAccount,
11
+ } from "../index.web.js";
12
+ import { randomSessionProvider } from "../internal.js";
3
13
 
4
14
  const Crypto = await WasmCrypto.create();
5
15
 
6
- describe("Simple CoPlainText operations", async () => {
7
- const me = await Account.create({
8
- creationProps: { name: "Hermes Puggington" },
9
- crypto: Crypto,
16
+ describe("CoPlainText", () => {
17
+ const initNodeAndText = async () => {
18
+ const me = await Account.create({
19
+ creationProps: { name: "Hermes Puggington" },
20
+ crypto: Crypto,
21
+ });
22
+
23
+ const text = CoPlainText.create("hello world", { owner: me });
24
+
25
+ return { me, text };
26
+ };
27
+
28
+ describe("Simple CoPlainText operations", async () => {
29
+ const { me, text } = await initNodeAndText();
30
+
31
+ test("Construction", () => {
32
+ expect(text + "").toEqual("hello world");
33
+ });
34
+
35
+ describe("Mutation", () => {
36
+ test("insertion", () => {
37
+ const text = CoPlainText.create("hello world", { owner: me });
38
+
39
+ text.insertAfter(5, " cruel");
40
+ expect(text + "").toEqual("hello cruel world");
41
+
42
+ text.insertAfter(0, "Hello, ");
43
+ expect(text + "").toEqual("Hello, hello cruel world");
44
+ });
45
+
46
+ test("deletion", () => {
47
+ const text = CoPlainText.create("hello world", { owner: me });
48
+
49
+ text.deleteRange({ from: 3, to: 8 });
50
+ expect(text + "").toEqual("helrld");
51
+ });
52
+ });
53
+
54
+ describe("Position operations", () => {
55
+ test("idxBefore returns index before a position", () => {
56
+ const text = CoPlainText.create("hello world", { owner: me });
57
+
58
+ // Get position at index 5 (between "hello" and " world")
59
+ const pos = text.posBefore(5);
60
+ expect(pos).toBeDefined();
61
+
62
+ // Verify idxBefore returns the index before the position (4)
63
+ // This makes sense as the position is between characters,
64
+ // and idxBefore returns the index of the last character before that position
65
+ const idx = text.idxBefore(pos!);
66
+ expect(idx).toBe(4); // Index of 'o' in "hello"
67
+ });
68
+
69
+ test("idxAfter returns index after a position", () => {
70
+ const text = CoPlainText.create("hello world", { owner: me });
71
+
72
+ // Get position at index 5 (between "hello" and " world")
73
+ const pos = text.posBefore(5);
74
+ expect(pos).toBeDefined();
75
+
76
+ // Verify idxAfter returns the index after the position (5)
77
+ // This makes sense as the position is between characters,
78
+ // and idxAfter returns the index of the first character after that position
79
+ const idx = text.idxAfter(pos!);
80
+ expect(idx).toBe(5); // Index of ' ' in "hello world"
81
+ });
82
+ });
10
83
  });
11
84
 
12
- const text = CoPlainText.create("hello world", { owner: me });
85
+ describe("Loading and availability", () => {
86
+ test("can load text across peers", async () => {
87
+ const { me, text } = await initNodeAndText();
88
+ const id = text.id;
89
+
90
+ // Set up peer connections
91
+ const [initialAsPeer, secondPeer] = connectedPeers("initial", "second", {
92
+ peer1role: "server",
93
+ peer2role: "client",
94
+ });
95
+
96
+ if (!isControlledAccount(me)) {
97
+ throw "me is not a controlled account";
98
+ }
99
+ me._raw.core.node.syncManager.addPeer(secondPeer);
100
+ const { account: meOnSecondPeer } = await createJazzContext({
101
+ auth: fixedCredentialsAuth({
102
+ accountID: me.id,
103
+ secret: me._raw.agentSecret,
104
+ }),
105
+ sessionProvider: randomSessionProvider,
106
+ peersToLoadFrom: [initialAsPeer],
107
+ crypto: Crypto,
108
+ });
13
109
 
14
- test("Construction", () => {
15
- expect(text + "").toEqual("hello world");
110
+ // Load the text on the second peer
111
+ const loaded = await CoPlainText.load(id, meOnSecondPeer);
112
+ expect(loaded).toBeDefined();
113
+ expect(loaded!.toString()).toBe("hello world");
114
+ });
16
115
  });
17
116
 
18
- describe("Mutation", () => {
19
- test("insertion", () => {
20
- const text = CoPlainText.create("hello world", { owner: me });
117
+ test("Subscription & auto-resolution", async () => {
118
+ const { me, text } = await initNodeAndText();
119
+
120
+ // Set up peer connections
121
+ const [initialAsPeer, secondPeer] = connectedPeers("initial", "second", {
122
+ peer1role: "server",
123
+ peer2role: "client",
124
+ });
21
125
 
22
- text.insertAfter(5, " cruel");
23
- expect(text + "").toEqual("hello cruel world");
126
+ if (!isControlledAccount(me)) {
127
+ throw "me is not a controlled account";
128
+ }
129
+ me._raw.core.node.syncManager.addPeer(secondPeer);
130
+ const { account: meOnSecondPeer } = await createJazzContext({
131
+ auth: fixedCredentialsAuth({
132
+ accountID: me.id,
133
+ secret: me._raw.agentSecret,
134
+ }),
135
+ sessionProvider: randomSessionProvider,
136
+ peersToLoadFrom: [initialAsPeer],
137
+ crypto: Crypto,
24
138
  });
25
139
 
26
- test("deletion", () => {
27
- const text = CoPlainText.create("hello world", { owner: me });
140
+ const queue = new cojsonInternals.Channel();
28
141
 
29
- text.deleteRange({ from: 3, to: 8 });
30
- expect(text + "").toEqual("helrld");
142
+ // Subscribe to text updates
143
+ CoPlainText.subscribe(text.id, meOnSecondPeer, (subscribedText) => {
144
+ void queue.push(subscribedText);
31
145
  });
146
+
147
+ // Initial subscription should give us the text
148
+ const update1 = (await queue.next()).value;
149
+ expect(update1.toString()).toBe("hello world");
150
+
151
+ // When we make a change, we should get an update
152
+ text.insertAfter(5, " beautiful");
153
+ const update2 = (await queue.next()).value;
154
+ expect(update2.toString()).toBe("hello beautiful world");
155
+
156
+ // When we make another change, we should get another update
157
+ update2.deleteRange({ from: 5, to: 15 }); // Delete " beautiful"
158
+ const update3 = (await queue.next()).value;
159
+ expect(update3.toString()).toBe("hello world");
32
160
  });
33
161
  });