jazz-tools 0.9.1 → 0.9.8

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,475 @@
1
+ import { co } from "../internal.js";
2
+ import type { Account } from "./account.js";
3
+ import { CoList } from "./coList.js";
4
+ import { CoMap, type CoMapInit } from "./coMap.js";
5
+ import { CoPlainText, type TextPos } from "./coPlainText.js";
6
+ import type { Group } from "./group.js";
7
+
8
+ /**
9
+ * Base class for text annotations and formatting marks.
10
+ * Represents a mark with start and end positions in text.
11
+ */
12
+ export class Mark extends CoMap {
13
+ startAfter = co.json<TextPos | null>();
14
+ startBefore = co.json<TextPos>();
15
+ endAfter = co.json<TextPos>();
16
+ endBefore = co.json<TextPos | null>();
17
+ tag = co.string;
18
+ }
19
+
20
+ /**
21
+ * A mark with resolved numeric positions in text.
22
+ * Contains both position information and reference to the source mark.
23
+ * @template R Type extending Mark, defaults to Mark
24
+ */
25
+ export type ResolvedMark<R extends Mark = Mark> = {
26
+ startAfter: number;
27
+ startBefore: number;
28
+ endAfter: number;
29
+ endBefore: number;
30
+ sourceMark: R;
31
+ };
32
+
33
+ /**
34
+ * A mark that has been resolved and diffused with certainty information.
35
+ * Includes start/end positions and indication of boundary certainty.
36
+ * @template R Type extending Mark, defaults to Mark
37
+ */
38
+ export type ResolvedAndDiffusedMark<R extends Mark = Mark> = {
39
+ start: number;
40
+ end: number;
41
+ side: "uncertainStart" | "certainMiddle" | "uncertainEnd";
42
+ sourceMark: R;
43
+ };
44
+
45
+ /**
46
+ * Defines how marks should be focused when resolving positions.
47
+ * - 'far': Positions marks at furthest valid positions
48
+ * - 'close': Positions marks at nearest valid positions
49
+ * - 'closestWhitespace': Positions marks at nearest whitespace
50
+ */
51
+ export type FocusBias = "far" | "close" | "closestWhitespace";
52
+
53
+ /**
54
+ * A mark that has been resolved and focused to specific positions.
55
+ * Contains simplified position information and reference to source mark.
56
+ * @template R Type extending Mark, defaults to Mark
57
+ */
58
+ export type ResolvedAndFocusedMark<R extends Mark = Mark> = {
59
+ start: number;
60
+ end: number;
61
+ sourceMark: R;
62
+ };
63
+
64
+ /**
65
+ * Main class for handling rich text content with marks.
66
+ * Combines plain text with a list of marks for formatting and annotations.
67
+ * Provides methods for text manipulation, mark insertion, and tree conversion.
68
+ */
69
+ export class CoRichText extends CoMap {
70
+ text = co.ref(CoPlainText);
71
+ marks = co.ref(CoList.Of(co.ref(Mark)));
72
+
73
+ /**
74
+ * Create a CoRichText from plain text.
75
+ */
76
+ static createFromPlainText(
77
+ text: string,
78
+ options: { owner: Account | Group },
79
+ ) {
80
+ return this.create(
81
+ {
82
+ text: CoPlainText.create(text, { owner: options.owner }),
83
+ marks: CoList.Of(co.ref(Mark)).create([], {
84
+ owner: options.owner,
85
+ }),
86
+ },
87
+ { owner: options.owner },
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Create a CoRichText from plain text and a mark.
93
+ */
94
+ static createFromPlainTextAndMark<
95
+ MarkClass extends {
96
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
+ new (...args: any[]): Mark;
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ create(init: any, options: { owner: Account | Group }): Mark;
100
+ },
101
+ >(
102
+ text: string,
103
+ WrapIn: MarkClass,
104
+ extraArgs: Omit<
105
+ CoMapInit<InstanceType<MarkClass>>,
106
+ "startAfter" | "startBefore" | "endAfter" | "endBefore"
107
+ >,
108
+ options: { owner: Account | Group },
109
+ ) {
110
+ const richtext = this.createFromPlainText(text, options);
111
+
112
+ richtext.insertMark(0, text.length, WrapIn, extraArgs);
113
+
114
+ return richtext;
115
+ }
116
+
117
+ /**
118
+ * Insert text at a specific index.
119
+ */
120
+ insertAfter(idx: number, text: string) {
121
+ if (!this.text)
122
+ throw new Error("Cannot insert into a CoRichText without loaded text");
123
+ this.text.insertAfter(idx, text);
124
+ }
125
+
126
+ /**
127
+ * Delete a range of text.
128
+ */
129
+ deleteRange(range: { from: number; to: number }) {
130
+ if (!this.text)
131
+ throw new Error("Cannot delete from a CoRichText without loaded text");
132
+ this.text.deleteRange(range);
133
+ }
134
+
135
+ /**
136
+ * Get the position of a specific index.
137
+ */
138
+ posBefore(idx: number): TextPos | undefined {
139
+ if (!this.text)
140
+ throw new Error(
141
+ "Cannot get posBefore in a CoRichText without loaded text",
142
+ );
143
+ return this.text.posBefore(idx);
144
+ }
145
+
146
+ /**
147
+ * Get the position of a specific index.
148
+ */
149
+ posAfter(idx: number): TextPos | undefined {
150
+ if (!this.text)
151
+ throw new Error(
152
+ "Cannot get posAfter in a CoRichText without loaded text",
153
+ );
154
+ return this.text.posAfter(idx);
155
+ }
156
+
157
+ /**
158
+ * Get the index of a specific position.
159
+ */
160
+ idxBefore(pos: TextPos): number | undefined {
161
+ if (!this.text)
162
+ throw new Error(
163
+ "Cannot get idxBefore in a CoRichText without loaded text",
164
+ );
165
+ return this.text.idxBefore(pos);
166
+ }
167
+
168
+ /**
169
+ * Get the index of a specific position.
170
+ */
171
+ idxAfter(pos: TextPos): number | undefined {
172
+ if (!this.text)
173
+ throw new Error(
174
+ "Cannot get idxAfter in a CoRichText without loaded text",
175
+ );
176
+ return this.text.idxAfter(pos);
177
+ }
178
+
179
+ /**
180
+ * Insert a mark at a specific range.
181
+ */
182
+ insertMark<
183
+ MarkClass extends {
184
+ new (...args: any[]): Mark;
185
+ create(init: any, options: { owner: Account | Group }): Mark;
186
+ },
187
+ >(
188
+ start: number,
189
+ end: number,
190
+ RangeClass: MarkClass,
191
+ extraArgs: Omit<
192
+ CoMapInit<InstanceType<MarkClass>>,
193
+ "startAfter" | "startBefore" | "endAfter" | "endBefore"
194
+ >,
195
+ options?: { markOwner?: Account | Group },
196
+ ) {
197
+ if (!this.marks) {
198
+ throw new Error("Cannot insert a range without loaded ranges");
199
+ }
200
+ const range = RangeClass.create(
201
+ {
202
+ ...extraArgs,
203
+ startAfter: this.posBefore(start),
204
+ startBefore: this.posAfter(start),
205
+ endAfter: this.posBefore(end),
206
+ endBefore: this.posAfter(end),
207
+ },
208
+ { owner: options?.markOwner || this._owner },
209
+ );
210
+ this.marks.push(range);
211
+ }
212
+
213
+ /**
214
+ * Resolve the positions of all marks.
215
+ */
216
+ resolveMarks(): ResolvedMark[] {
217
+ if (!this.text || !this.marks) {
218
+ throw new Error("Cannot resolve ranges without loaded text and ranges");
219
+ }
220
+ const ranges = this.marks.flatMap((mark) => {
221
+ 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
+ }
236
+ return [
237
+ {
238
+ sourceMark: mark,
239
+ startAfter,
240
+ startBefore,
241
+ endAfter,
242
+ endBefore,
243
+ tag: mark.tag,
244
+ from: mark,
245
+ },
246
+ ];
247
+ });
248
+ return ranges;
249
+ }
250
+
251
+ /**
252
+ * Resolve and diffuse the positions of all marks.
253
+ */
254
+ resolveAndDiffuseMarks(): ResolvedAndDiffusedMark[] {
255
+ return this.resolveMarks().flatMap((range) => [
256
+ ...(range.startAfter < range.startBefore - 1
257
+ ? [
258
+ {
259
+ start: range.startAfter,
260
+ end: range.startBefore - 1,
261
+ side: "uncertainStart" as const,
262
+ sourceMark: range.sourceMark,
263
+ },
264
+ ]
265
+ : []),
266
+ {
267
+ start: range.startBefore - 1,
268
+ end: range.endAfter + 1,
269
+ side: "certainMiddle" as const,
270
+ sourceMark: range.sourceMark,
271
+ },
272
+ ...(range.endAfter + 1 < range.endBefore
273
+ ? [
274
+ {
275
+ start: range.endAfter + 1,
276
+ end: range.endBefore,
277
+ side: "uncertainEnd" as const,
278
+ sourceMark: range.sourceMark,
279
+ },
280
+ ]
281
+ : []),
282
+ ]);
283
+ }
284
+
285
+ /**
286
+ * Resolve, diffuse, and focus the positions of all marks.
287
+ */
288
+ resolveAndDiffuseAndFocusMarks(): ResolvedAndFocusedMark[] {
289
+ // for now we only keep the certainMiddle ranges
290
+ return this.resolveAndDiffuseMarks().filter(
291
+ (range) => range.side === "certainMiddle",
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Convert a CoRichText to a tree structure useful for client libraries.
297
+ */
298
+ toTree(tagPrecedence: string[]): TreeNode {
299
+ const ranges = this.resolveAndDiffuseAndFocusMarks();
300
+
301
+ // convert a bunch of (potentially overlapping) ranges into a tree
302
+ // - make sure we include all text in leaves, even if it's not covered by a range
303
+ // - 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
305
+
306
+ const text = this.text?.toString() || "";
307
+
308
+ let currentNodes: (TreeLeaf | TreeNode)[] = [
309
+ {
310
+ type: "leaf",
311
+ start: 0,
312
+ end: text.length,
313
+ },
314
+ ];
315
+
316
+ const rangesSortedLowToHighPrecedence = ranges.sort((a, b) => {
317
+ const aPrecedence = tagPrecedence.indexOf(a.sourceMark.tag);
318
+ const bPrecedence = tagPrecedence.indexOf(b.sourceMark.tag);
319
+ return bPrecedence - aPrecedence;
320
+ });
321
+
322
+ // for each range, split the current nodes where necessary (no matter if leaf or already a node), wrapping the resulting "inside" parts in a node with the range's tag
323
+ for (const range of rangesSortedLowToHighPrecedence) {
324
+ // console.log("currentNodes", currentNodes);
325
+ const newNodes = currentNodes.flatMap((node) => {
326
+ const [before, inOrAfter] = splitNode(node, range.start);
327
+ const [inside, after] = inOrAfter
328
+ ? splitNode(inOrAfter, range.end)
329
+ : [undefined, undefined];
330
+
331
+ // console.log("split", range.start, range.end, {
332
+ // before,
333
+ // inside,
334
+ // after,
335
+ // });
336
+
337
+ // TODO: also split children
338
+
339
+ return [
340
+ ...(before ? [before] : []),
341
+ ...(inside
342
+ ? [
343
+ {
344
+ type: "node" as const,
345
+ tag: range.sourceMark.tag,
346
+ start: inside.start,
347
+ end: inside.end,
348
+ children: [inside],
349
+ },
350
+ ]
351
+ : []),
352
+ ...(after ? [after] : []),
353
+ ];
354
+ });
355
+
356
+ currentNodes = newNodes;
357
+ }
358
+
359
+ return {
360
+ type: "node",
361
+ tag: "root",
362
+ start: 0,
363
+ end: text.length,
364
+ children: currentNodes,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Convert a CoRichText to plain text.
370
+ */
371
+ toString() {
372
+ if (!this.text) return "";
373
+ return this.text.toString();
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Represents a leaf node in the rich text tree structure.
379
+ * Contains plain text without any marks.
380
+ */
381
+ export type TreeLeaf = {
382
+ type: "leaf";
383
+ start: number;
384
+ end: number;
385
+ };
386
+
387
+ /**
388
+ * Represents a node in the rich text tree structure.
389
+ * Can contain other nodes or leaves, and includes formatting information.
390
+ */
391
+ export type TreeNode = {
392
+ type: "node";
393
+ tag: string;
394
+ start: number;
395
+ end: number;
396
+ range?: ResolvedAndFocusedMark;
397
+ children: (TreeNode | TreeLeaf)[];
398
+ };
399
+
400
+ /**
401
+ * 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
+ */
403
+ function splitNode(
404
+ node: TreeNode | TreeLeaf,
405
+ at: number,
406
+ ): [TreeNode | TreeLeaf | undefined, TreeNode | TreeLeaf | undefined] {
407
+ if (node.type === "leaf") {
408
+ return [
409
+ at > node.start
410
+ ? {
411
+ type: "leaf",
412
+ start: node.start,
413
+ end: Math.min(at, node.end),
414
+ }
415
+ : undefined,
416
+ at < node.end
417
+ ? {
418
+ type: "leaf",
419
+ start: Math.max(at, node.start),
420
+ end: node.end,
421
+ }
422
+ : undefined,
423
+ ];
424
+ } else {
425
+ const children = node.children;
426
+ return [
427
+ at > node.start
428
+ ? {
429
+ type: "node",
430
+ tag: node.tag,
431
+ start: node.start,
432
+ end: Math.min(at, node.end),
433
+ children: children
434
+ .map((child) => splitNode(child, at)[0])
435
+ .filter((c): c is Exclude<typeof c, undefined> => !!c),
436
+ }
437
+ : undefined,
438
+ at < node.end
439
+ ? {
440
+ type: "node",
441
+ tag: node.tag,
442
+ start: Math.max(at, node.start),
443
+ end: node.end,
444
+ children: children
445
+ .map((child) => splitNode(child, at)[1])
446
+ .filter((c): c is Exclude<typeof c, undefined> => !!c),
447
+ }
448
+ : undefined,
449
+ ];
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Collection of predefined mark types for common text formatting.
455
+ * Includes marks for headings, paragraphs, links, and text styling.
456
+ */
457
+ export const Marks = {
458
+ Heading: class Heading extends Mark {
459
+ tag = co.literal("heading");
460
+ level = co.number;
461
+ },
462
+ Paragraph: class Paragraph extends Mark {
463
+ tag = co.literal("paragraph");
464
+ },
465
+ Link: class Link extends Mark {
466
+ tag = co.literal("link");
467
+ url = co.string;
468
+ },
469
+ Strong: class Strong extends Mark {
470
+ tag = co.literal("strong");
471
+ },
472
+ Em: class Italic extends Mark {
473
+ tag = co.literal("em");
474
+ },
475
+ };
@@ -67,7 +67,10 @@ export function fulfillsDepth(depth: any, value: CoValue): boolean {
67
67
  .optional,
68
68
  );
69
69
  }
70
- } else if (value._type === "BinaryCoStream") {
70
+ } else if (
71
+ value._type === "BinaryCoStream" ||
72
+ value._type === "CoPlainText"
73
+ ) {
71
74
  return true;
72
75
  } else {
73
76
  console.error(value);
@@ -204,4 +207,10 @@ export type DeeplyLoaded<
204
207
  },
205
208
  ]
206
209
  ? V
207
- : never;
210
+ : [V] extends [
211
+ {
212
+ _type: "CoPlainText";
213
+ },
214
+ ]
215
+ ? V
216
+ : never;
@@ -13,9 +13,9 @@ import {
13
13
  MembersSym,
14
14
  Ref,
15
15
  ensureCoValueLoaded,
16
- loadCoValue,
17
- parseCoValueCreateOptions,
18
- subscribeToCoValue,
16
+ loadCoValueWithoutMe,
17
+ parseGroupCreateOptions,
18
+ subscribeToCoValueWithoutMe,
19
19
  subscribeToExistingCoValue,
20
20
  } from "../internal.js";
21
21
  import { AccountAndGroupProxyHandler, isControlledAccount } from "./account.js";
@@ -124,9 +124,9 @@ export class Group extends CoValueBase implements CoValue {
124
124
 
125
125
  static create<G extends Group>(
126
126
  this: CoValueClass<G>,
127
- options: { owner: Account } | Account,
127
+ options?: { owner: Account } | Account,
128
128
  ) {
129
- return new this(parseCoValueCreateOptions(options));
129
+ return new this(parseGroupCreateOptions(options));
130
130
  }
131
131
 
132
132
  myRole(): Role | undefined {
@@ -179,24 +179,56 @@ export class Group extends CoValueBase implements CoValue {
179
179
  }
180
180
 
181
181
  /** @category Subscription & Loading */
182
- static load<G extends Group, Depth>(
183
- this: CoValueClass<G>,
184
- id: ID<G>,
182
+ static load<C extends Group, Depth>(
183
+ this: CoValueClass<C>,
184
+ id: ID<C>,
185
+ depth: Depth & DepthsIn<C>,
186
+ ): Promise<DeeplyLoaded<C, Depth> | undefined>;
187
+ static load<C extends Group, Depth>(
188
+ this: CoValueClass<C>,
189
+ id: ID<C>,
185
190
  as: Account,
186
- depth: Depth & DepthsIn<G>,
187
- ): Promise<DeeplyLoaded<G, Depth> | undefined> {
188
- return loadCoValue(this, id, as, depth);
191
+ depth: Depth & DepthsIn<C>,
192
+ ): Promise<DeeplyLoaded<C, Depth> | undefined>;
193
+ static load<C extends Group, Depth>(
194
+ this: CoValueClass<C>,
195
+ id: ID<C>,
196
+ asOrDepth: Account | (Depth & DepthsIn<C>),
197
+ depth?: Depth & DepthsIn<C>,
198
+ ): Promise<DeeplyLoaded<C, Depth> | undefined> {
199
+ return loadCoValueWithoutMe(this, id, asOrDepth, depth);
189
200
  }
190
201
 
191
202
  /** @category Subscription & Loading */
192
- static subscribe<G extends Group, Depth>(
193
- this: CoValueClass<G>,
194
- id: ID<G>,
203
+ static subscribe<C extends Group, Depth>(
204
+ this: CoValueClass<C>,
205
+ id: ID<C>,
206
+ depth: Depth & DepthsIn<C>,
207
+ listener: (value: DeeplyLoaded<C, Depth>) => void,
208
+ ): () => void;
209
+ static subscribe<C extends Group, Depth>(
210
+ this: CoValueClass<C>,
211
+ id: ID<C>,
195
212
  as: Account,
196
- depth: Depth & DepthsIn<G>,
197
- listener: (value: DeeplyLoaded<G, Depth>) => void,
213
+ depth: Depth & DepthsIn<C>,
214
+ listener: (value: DeeplyLoaded<C, Depth>) => void,
215
+ ): () => void;
216
+ static subscribe<C extends Group, Depth>(
217
+ this: CoValueClass<C>,
218
+ id: ID<C>,
219
+ asOrDepth: Account | (Depth & DepthsIn<C>),
220
+ depthOrListener:
221
+ | (Depth & DepthsIn<C>)
222
+ | ((value: DeeplyLoaded<C, Depth>) => void),
223
+ listener?: (value: DeeplyLoaded<C, Depth>) => void,
198
224
  ): () => void {
199
- return subscribeToCoValue<G, Depth>(this, id, as, depth, listener);
225
+ return subscribeToCoValueWithoutMe<C, Depth>(
226
+ this,
227
+ id,
228
+ asOrDepth,
229
+ depthOrListener,
230
+ listener,
231
+ );
200
232
  }
201
233
 
202
234
  /** @category Subscription & Loading */
@@ -7,6 +7,7 @@ import {
7
7
  SessionID,
8
8
  } from "cojson";
9
9
  import { CoStreamItem, RawCoStream } from "cojson";
10
+ import { activeAccountContext } from "../implementation/activeAccountContext.js";
10
11
  import { type Account } from "./account.js";
11
12
  import { CoValue, CoValueClass, ID, loadCoValue } from "./interfaces.js";
12
13
 
@@ -312,7 +313,9 @@ export class InboxSender<I extends CoValue, O extends CoValue | undefined> {
312
313
  static async load<
313
314
  I extends CoValue,
314
315
  O extends CoValue | undefined = undefined,
315
- >(inboxOwnerID: ID<Account>, currentAccount: Account) {
316
+ >(inboxOwnerID: ID<Account>, currentAccount?: Account) {
317
+ currentAccount ||= activeAccountContext.get();
318
+
316
319
  const node = currentAccount._raw.core.node;
317
320
 
318
321
  const inboxOwnerRaw = await node.load(
@@ -347,7 +350,9 @@ export class InboxSender<I extends CoValue, O extends CoValue | undefined> {
347
350
  }
348
351
  }
349
352
 
350
- async function acceptInvite(invite: string, account: Account) {
353
+ async function acceptInvite(invite: string, account?: Account) {
354
+ account ||= activeAccountContext.get();
355
+
351
356
  const id = invite.slice(0, invite.indexOf("/")) as CoID<MessagesStream>;
352
357
 
353
358
  const inviteSecret = invite.slice(invite.indexOf("/") + 1) as InviteSecret;