@pilotiq/pilotiq 0.12.0 → 0.13.1

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.
Files changed (71) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/dist/pageData/helpers.d.ts +16 -0
  4. package/dist/pageData/helpers.d.ts.map +1 -1
  5. package/dist/pageData/helpers.js +61 -1
  6. package/dist/pageData/helpers.js.map +1 -1
  7. package/dist/pageData.d.ts +1 -1
  8. package/dist/pageData.d.ts.map +1 -1
  9. package/dist/pageData.js +1 -1
  10. package/dist/pageData.js.map +1 -1
  11. package/dist/react/FormCollabBindingRegistry.d.ts +33 -98
  12. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  13. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  14. package/dist/react/FormStateContext.d.ts +1 -35
  15. package/dist/react/FormStateContext.d.ts.map +1 -1
  16. package/dist/react/FormStateContext.js +15 -92
  17. package/dist/react/FormStateContext.js.map +1 -1
  18. package/dist/react/RowCoordsContext.d.ts +19 -0
  19. package/dist/react/RowCoordsContext.d.ts.map +1 -0
  20. package/dist/react/RowCoordsContext.js +6 -0
  21. package/dist/react/RowCoordsContext.js.map +1 -0
  22. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  23. package/dist/react/fields/BuilderInput.js +78 -49
  24. package/dist/react/fields/BuilderInput.js.map +1 -1
  25. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  26. package/dist/react/fields/MarkdownInput.js +35 -125
  27. package/dist/react/fields/MarkdownInput.js.map +1 -1
  28. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  29. package/dist/react/fields/RepeaterInput.js +104 -60
  30. package/dist/react/fields/RepeaterInput.js.map +1 -1
  31. package/dist/react/fields/TextLikeInput.d.ts +11 -9
  32. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  33. package/dist/react/fields/TextLikeInput.js +59 -189
  34. package/dist/react/fields/TextLikeInput.js.map +1 -1
  35. package/dist/react/fields/repeaterReconcile.d.ts +66 -0
  36. package/dist/react/fields/repeaterReconcile.d.ts.map +1 -0
  37. package/dist/react/fields/repeaterReconcile.js +96 -0
  38. package/dist/react/fields/repeaterReconcile.js.map +1 -0
  39. package/dist/react/formStateHelpers.d.ts +0 -15
  40. package/dist/react/formStateHelpers.d.ts.map +1 -1
  41. package/dist/react/formStateHelpers.js +0 -91
  42. package/dist/react/formStateHelpers.js.map +1 -1
  43. package/dist/react/index.d.ts +1 -1
  44. package/dist/react/index.d.ts.map +1 -1
  45. package/dist/react/index.js.map +1 -1
  46. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
  47. package/dist/react/schemaRenderer/form/FormRenderer.js +10 -0
  48. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/pageData/helpers.ts +55 -1
  51. package/src/pageData.test.ts +67 -0
  52. package/src/pageData.ts +1 -0
  53. package/src/react/FormCollabBindingRegistry.ts +34 -91
  54. package/src/react/FormStateContext.tsx +14 -126
  55. package/src/react/RowCoordsContext.tsx +23 -0
  56. package/src/react/fields/BuilderInput.tsx +75 -39
  57. package/src/react/fields/MarkdownInput.tsx +42 -129
  58. package/src/react/fields/RepeaterInput.tsx +107 -48
  59. package/src/react/fields/TextLikeInput.tsx +67 -225
  60. package/src/react/fields/repeaterReconcile.test.ts +114 -0
  61. package/src/react/fields/repeaterReconcile.ts +104 -0
  62. package/src/react/formStateHelpers.test.ts +0 -99
  63. package/src/react/formStateHelpers.ts +0 -83
  64. package/src/react/index.ts +0 -2
  65. package/src/react/schemaRenderer/form/FormRenderer.tsx +10 -0
  66. package/dist/react/fields/textDelta.d.ts +0 -44
  67. package/dist/react/fields/textDelta.d.ts.map +0 -1
  68. package/dist/react/fields/textDelta.js +0 -80
  69. package/dist/react/fields/textDelta.js.map +0 -1
  70. package/src/react/fields/textDelta.test.ts +0 -141
  71. package/src/react/fields/textDelta.ts +0 -86
@@ -4,7 +4,6 @@ import assert from 'node:assert/strict'
4
4
  import {
5
5
  collectFieldDefaults,
6
6
  collectRowArrayFieldNames,
7
- collectRowTextLeavesByArray,
8
7
  fieldOptsOutOfCollab,
9
8
  findFieldMeta,
10
9
  parseFormDataToNested,
@@ -492,104 +491,6 @@ describe('routeBindingWrite', () => {
492
491
  })
493
492
  })
494
493
 
495
- describe('collectRowTextLeavesByArray', () => {
496
- const textField = (name: string, fieldType: string = 'text', collab?: boolean): ElementMeta => ({
497
- type: 'field',
498
- fieldType,
499
- name,
500
- label: name,
501
- required: false,
502
- disabled: false,
503
- ...(collab === false ? { collab: false } : {}),
504
- } as ElementMeta)
505
-
506
- // Repeater meta carries the row schema under `template` (`children` is
507
- // the per-resolved-row child list, not the field-level template). Tests
508
- // must mirror what `RepeaterField.toMeta()` actually emits.
509
- const repeater = (name: string, template: ElementMeta[], collab?: boolean): ElementMeta => ({
510
- type: 'field',
511
- fieldType: 'repeater',
512
- name,
513
- label: name,
514
- required: false,
515
- disabled: false,
516
- template,
517
- ...(collab === false ? { collab: false } : {}),
518
- } as ElementMeta)
519
-
520
- it('collects text-shaped inner-field names per Repeater', () => {
521
- const meta = formMeta([
522
- repeater('tags', [
523
- textField('label', 'text'),
524
- textField('summary', 'textarea'),
525
- textField('count', 'number'),
526
- ]),
527
- ])
528
- const out = collectRowTextLeavesByArray(meta)
529
- assert.equal(out.size, 1)
530
- assert.deepEqual([...out.get('tags')!].sort(), ['label', 'summary'])
531
- })
532
-
533
- it('walks Builder block templates', () => {
534
- const meta = formMeta([
535
- {
536
- type: 'field',
537
- fieldType: 'builder',
538
- name: 'blocks',
539
- children: [],
540
- blocks: [
541
- { name: 'heading', template: [textField('text', 'text')] },
542
- { name: 'paragraph', template: [textField('body', 'markdown')] },
543
- ],
544
- } as unknown as ElementMeta,
545
- ])
546
- const out = collectRowTextLeavesByArray(meta)
547
- assert.deepEqual([...out.get('blocks')!].sort(), ['body', 'text'])
548
- })
549
-
550
- it('skips opted-out inner fields', () => {
551
- const meta = formMeta([
552
- repeater('tags', [
553
- textField('label', 'text'),
554
- textField('private', 'text', false), // .collab(false)
555
- ]),
556
- ])
557
- const out = collectRowTextLeavesByArray(meta)
558
- assert.deepEqual([...out.get('tags')!], ['label'])
559
- })
560
-
561
- it('omits opted-out top-level arrays', () => {
562
- const meta = formMeta([
563
- repeater('public', [textField('a', 'text')]),
564
- repeater('private', [textField('b', 'text')], false),
565
- ])
566
- const out = collectRowTextLeavesByArray(meta)
567
- assert.equal(out.has('public'), true)
568
- assert.equal(out.has('private'), false)
569
- })
570
-
571
- it('stops at nested array boundaries (no 5+ segment dotted paths)', () => {
572
- const meta = formMeta([
573
- repeater('outer', [
574
- textField('outerLabel', 'text'),
575
- repeater('inner', [textField('innerLabel', 'text')]),
576
- ]),
577
- ])
578
- const out = collectRowTextLeavesByArray(meta)
579
- assert.deepEqual([...out.get('outer')!], ['outerLabel'])
580
- assert.equal(out.has('inner'), false, 'nested Repeater not surfaced as a top-level array')
581
- })
582
-
583
- it('returns empty map when no Repeater/Builder has text leaves', () => {
584
- const meta = formMeta([
585
- textField('top', 'text'),
586
- repeater('numbers', [textField('count', 'number')]),
587
- ])
588
- const out = collectRowTextLeavesByArray(meta)
589
- assert.equal(out.size, 0)
590
- })
591
- })
592
-
593
494
  describe('fieldOptsOutOfCollab', () => {
594
495
  it('returns true only when the field carries an explicit collab=false', () => {
595
496
  const meta = formMeta([
@@ -379,86 +379,3 @@ export function collectRowArrayFieldNames(formMeta: ElementMeta): string[] {
379
379
  }
380
380
  }
381
381
 
382
- /**
383
- * Phase F.5c — text-shaped fieldTypes whose row-leaf values should be
384
- * routed through `Y.Text` instead of `Y.Map` LWW. Mirrors the same
385
- * allowlist `@pilotiq-pro/collab`'s top-level binding uses; consumers
386
- * registering character-level CRDT for additional plain-text-shaped
387
- * fields update both copies in lockstep until a cross-repo shared
388
- * constants module exists.
389
- */
390
- const ROW_TEXT_FIELD_TYPES: ReadonlySet<string> = new Set([
391
- 'text', 'textarea', 'email', 'slug', 'markdown',
392
- ])
393
-
394
- /**
395
- * Phase F.5c — per-Repeater/Builder set of inner-field names that
396
- * carry text-shaped leaves eligible for character-level CRDT. Drives
397
- * `useFieldState(dottedName).textBinding` resolution: only fields in
398
- * the per-array set go through `binding.getRowTextBinding`; everything
399
- * else stays on row-level Y.Map LWW.
400
- *
401
- * Repeater rows expose their schema directly under `meta.children`;
402
- * Builder rows nest schemas under `meta.blocks[i].template`. The
403
- * walker descends through every block's template so a `markdown` leaf
404
- * inside any block-type lands in the array's allowlist. Nested
405
- * Repeaters / Builders inside row schemas are out of scope v1 (their
406
- * dotted paths are 5+ segments and `parseRowFieldPath` rejects them).
407
- */
408
- export function collectRowTextLeavesByArray(formMeta: ElementMeta): Map<string, Set<string>> {
409
- const out = new Map<string, Set<string>>()
410
- walkTop(formMeta)
411
- return out
412
-
413
- function walkTop(node: ElementMeta): void {
414
- if (node.type === 'field') {
415
- const fieldType = String(node['fieldType'] ?? '')
416
- if (fieldType === 'repeater' || fieldType === 'builder') {
417
- if ((node as { collab?: boolean }).collab === false) return
418
- const name = String(node['name'] ?? '')
419
- if (!name) return
420
- const set = new Set<string>()
421
- // Repeater's `toMeta()` emits the row schema under `template` (not
422
- // `children` — that's per-resolved-row). Builder nests row schemas
423
- // under `blocks[i].template`. Reading `children` here pre-fix gave
424
- // every Repeater an empty text-leaf set → row text never CRDT'd.
425
- if (fieldType === 'repeater') walkRow((node as { template?: unknown }).template, set)
426
- else walkBlocks((node as { blocks?: unknown }).blocks, set)
427
- if (set.size > 0) out.set(name, set)
428
- return
429
- }
430
- }
431
- const children = node.children
432
- if (Array.isArray(children)) {
433
- for (const child of children) walkTop(child as ElementMeta)
434
- }
435
- }
436
-
437
- function walkRow(children: unknown, set: Set<string>): void {
438
- if (!Array.isArray(children)) return
439
- for (const child of children) walkRowEl(child as ElementMeta, set)
440
- }
441
-
442
- function walkRowEl(node: ElementMeta, set: Set<string>): void {
443
- if (node.type === 'field') {
444
- const fieldType = String(node['fieldType'] ?? '')
445
- if (fieldType === 'repeater' || fieldType === 'builder') return // nested array
446
- if ((node as { collab?: boolean }).collab === false) return
447
- const name = String(node['name'] ?? '')
448
- if (name && ROW_TEXT_FIELD_TYPES.has(fieldType)) set.add(name)
449
- return
450
- }
451
- const children = node.children
452
- if (Array.isArray(children)) {
453
- for (const child of children) walkRowEl(child as ElementMeta, set)
454
- }
455
- }
456
-
457
- function walkBlocks(blocks: unknown, set: Set<string>): void {
458
- if (!Array.isArray(blocks)) return
459
- for (const block of blocks) {
460
- const tpl = (block as { template?: unknown }).template
461
- walkRow(tpl, set)
462
- }
463
- }
464
- }
@@ -55,8 +55,6 @@ export {
55
55
  type FormCollabBinding,
56
56
  type FormCollabBindingFactory,
57
57
  type FormCollabBindingFactoryArgs,
58
- type TextBinding,
59
- type TextDelta,
60
58
  type RowsEvent,
61
59
  type RowBindingApi,
62
60
  } from './FormCollabBindingRegistry.js'
@@ -6,6 +6,7 @@ import { useCollabRoom } from '../../CollabRoomContext.js'
6
6
  import { getFormCollabBinding } from '../../FormCollabBindingRegistry.js'
7
7
  import { useNavigate } from '../../navigate.js'
8
8
  import { useToast } from '../../Toaster.js'
9
+ import { markSubmitForReconcile } from '../../fields/repeaterReconcile.js'
9
10
  import { renderField } from './renderField.js'
10
11
 
11
12
  // ─── Form ───────────────────────────────────────────────────
@@ -111,6 +112,15 @@ export function FormRenderer({
111
112
  }
112
113
 
113
114
  // Success — drain notifications and SPA-navigate to the redirect.
115
+ //
116
+ // Before navigating, mark this tab for the relationship-backed
117
+ // Repeater/Builder PK-switch reconciler. The next mount of any
118
+ // child Repeater/Builder under this formId will run a one-shot
119
+ // CRDT reconcile to drop orphan UUIDs whose rows just persisted
120
+ // under a fresh DB PK. See
121
+ // `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`
122
+ // (Phase A).
123
+ markSubmitForReconcile(formId)
114
124
  const notifs = (data as { notifications?: NotificationMeta[] }).notifications
115
125
  if (notifs && notifs.length > 0) for (const n of notifs) notify(n)
116
126
  const redirect = String((data as { redirect?: string }).redirect ?? '')
@@ -1,44 +0,0 @@
1
- import type { TextDelta } from '../FormCollabBindingRegistry.js';
2
- /**
3
- * Phase F.6 — derive a single character-level edit op from two strings.
4
- *
5
- * Strategy: find the longest common prefix and suffix between `before`
6
- * and `after`; whatever's left in the middle is the changed region.
7
- *
8
- * - middle-after empty + middle-before non-empty → `delete`
9
- * - middle-before empty + middle-after non-empty → `insert`
10
- * - both non-empty → `replace`
11
- * - both empty (identical strings) → `null`
12
- *
13
- * This correctly handles the common edit shapes: single-key insert,
14
- * single-key backspace, multi-char paste replacing a selection, IME
15
- * commits, accent-key composition. It does NOT preserve user intent
16
- * when the same character appears at multiple positions and the edit
17
- * could be attributed to either occurrence — Yjs's per-character
18
- * identity makes that distinction lossy at the string-diff layer
19
- * (the `Y.Text` itself maintains item identity internally). For v1
20
- * we accept the ambiguity; the CRDT semantics still converge.
21
- */
22
- export declare function computeDelta(before: string, after: string): TextDelta | null;
23
- /**
24
- * Phase F.6 — best-effort cursor anchor across a remote-applied edit.
25
- *
26
- * - Edit landed AFTER cursor (cursor inside the common prefix) → keep
27
- * cursor where it is.
28
- * - Edit landed BEFORE cursor → shift
29
- * cursor by `after.length − before.length` so its character-offset
30
- * into the post-edit string matches its pre-edit anchor.
31
- * - Edit landed OVERLAPPING the cursor → cursor
32
- * ends up at the boundary between the changed region and the
33
- * unchanged suffix (which `Math.max(0, cursor + delta)` produces
34
- * naturally and clamps to the new bounds).
35
- *
36
- * This is a heuristic, not a Yjs `RelativePosition`. Two peers typing
37
- * at the exact same insertion point can still see a one-character cursor
38
- * twitch on the remote-mirror side; v2 (in-input remote carets) would
39
- * upgrade to relative positions if/when a consumer asks. Native input
40
- * cursors are clamped to `[0, after.length]` by every browser, so this
41
- * function does the same to avoid `setSelectionRange` throwing.
42
- */
43
- export declare function preserveCursor(before: string, after: string, cursor: number): number;
44
- //# sourceMappingURL=textDelta.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"textDelta.d.ts","sourceRoot":"","sources":["../../../src/react/fields/textDelta.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAA;AAEhE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CA8B5E;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAWpF"}
@@ -1,80 +0,0 @@
1
- /**
2
- * Phase F.6 — derive a single character-level edit op from two strings.
3
- *
4
- * Strategy: find the longest common prefix and suffix between `before`
5
- * and `after`; whatever's left in the middle is the changed region.
6
- *
7
- * - middle-after empty + middle-before non-empty → `delete`
8
- * - middle-before empty + middle-after non-empty → `insert`
9
- * - both non-empty → `replace`
10
- * - both empty (identical strings) → `null`
11
- *
12
- * This correctly handles the common edit shapes: single-key insert,
13
- * single-key backspace, multi-char paste replacing a selection, IME
14
- * commits, accent-key composition. It does NOT preserve user intent
15
- * when the same character appears at multiple positions and the edit
16
- * could be attributed to either occurrence — Yjs's per-character
17
- * identity makes that distinction lossy at the string-diff layer
18
- * (the `Y.Text` itself maintains item identity internally). For v1
19
- * we accept the ambiguity; the CRDT semantics still converge.
20
- */
21
- export function computeDelta(before, after) {
22
- if (before === after)
23
- return null;
24
- let prefix = 0;
25
- const minLen = Math.min(before.length, after.length);
26
- while (prefix < minLen && before[prefix] === after[prefix])
27
- prefix++;
28
- // Walk back from each end, capped so suffix can't overlap the prefix
29
- // on either side. Without the cap, identical strings of repeated
30
- // chars (e.g. 'aaa' → 'aa') would consume the same byte from both
31
- // directions and produce an empty middle on both sides.
32
- let suffix = 0;
33
- const maxSuffix = Math.min(before.length - prefix, after.length - prefix);
34
- while (suffix < maxSuffix &&
35
- before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) {
36
- suffix++;
37
- }
38
- const beforeMid = before.slice(prefix, before.length - suffix);
39
- const afterMid = after.slice(prefix, after.length - suffix);
40
- if (beforeMid.length === 0 && afterMid.length > 0) {
41
- return { kind: 'insert', index: prefix, text: afterMid };
42
- }
43
- if (afterMid.length === 0 && beforeMid.length > 0) {
44
- return { kind: 'delete', index: prefix, length: beforeMid.length };
45
- }
46
- return { kind: 'replace', from: prefix, to: prefix + beforeMid.length, text: afterMid };
47
- }
48
- /**
49
- * Phase F.6 — best-effort cursor anchor across a remote-applied edit.
50
- *
51
- * - Edit landed AFTER cursor (cursor inside the common prefix) → keep
52
- * cursor where it is.
53
- * - Edit landed BEFORE cursor → shift
54
- * cursor by `after.length − before.length` so its character-offset
55
- * into the post-edit string matches its pre-edit anchor.
56
- * - Edit landed OVERLAPPING the cursor → cursor
57
- * ends up at the boundary between the changed region and the
58
- * unchanged suffix (which `Math.max(0, cursor + delta)` produces
59
- * naturally and clamps to the new bounds).
60
- *
61
- * This is a heuristic, not a Yjs `RelativePosition`. Two peers typing
62
- * at the exact same insertion point can still see a one-character cursor
63
- * twitch on the remote-mirror side; v2 (in-input remote carets) would
64
- * upgrade to relative positions if/when a consumer asks. Native input
65
- * cursors are clamped to `[0, after.length]` by every browser, so this
66
- * function does the same to avoid `setSelectionRange` throwing.
67
- */
68
- export function preserveCursor(before, after, cursor) {
69
- if (before === after)
70
- return cursor;
71
- let prefix = 0;
72
- const minLen = Math.min(before.length, after.length);
73
- while (prefix < minLen && before[prefix] === after[prefix])
74
- prefix++;
75
- if (cursor <= prefix)
76
- return Math.min(cursor, after.length);
77
- const delta = after.length - before.length;
78
- return Math.max(0, Math.min(after.length, cursor + delta));
79
- }
80
- //# sourceMappingURL=textDelta.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"textDelta.js","sourceRoot":"","sources":["../../../src/react/fields/textDelta.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc,EAAE,KAAa;IACxD,IAAI,MAAM,KAAK,KAAK;QAAE,OAAO,IAAI,CAAA;IAEjC,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAA;IACpD,OAAO,MAAM,GAAG,MAAM,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,KAAK,CAAC,MAAM,CAAC;QAAE,MAAM,EAAE,CAAA;IAEpE,qEAAqE;IACrE,iEAAiE;IACjE,kEAAkE;IAClE,wDAAwD;IACxD,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,MAAM,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;IACzE,OACE,MAAM,GAAG,SAAS;QAClB,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,EACvE,CAAC;QACD,MAAM,EAAE,CAAA;IACV,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;IAC9D,MAAM,QAAQ,GAAI,KAAK,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;IAE5D,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAClD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;IAC1D,CAAC;IACD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAClD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,MAAM,EAAE,CAAA;IACpE,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;AACzF,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,cAAc,CAAC,MAAc,EAAE,KAAa,EAAE,MAAc;IAC1E,IAAI,MAAM,KAAK,KAAK;QAAE,OAAO,MAAM,CAAA;IAEnC,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAA;IACpD,OAAO,MAAM,GAAG,MAAM,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,KAAK,CAAC,MAAM,CAAC;QAAE,MAAM,EAAE,CAAA;IAEpE,IAAI,MAAM,IAAI,MAAM;QAAE,OAAO,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAA;IAE3D,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAA;IAC1C,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,KAAK,CAAC,CAAC,CAAA;AAC5D,CAAC"}
@@ -1,141 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import { computeDelta, preserveCursor } from './textDelta.js'
5
-
6
- describe('computeDelta — string-diff to TextDelta', () => {
7
- it('returns null for identical strings', () => {
8
- assert.equal(computeDelta('hello', 'hello'), null)
9
- assert.equal(computeDelta('', ''), null)
10
- })
11
-
12
- it('emits insert when text is appended', () => {
13
- assert.deepEqual(
14
- computeDelta('hello', 'hello!'),
15
- { kind: 'insert', index: 5, text: '!' },
16
- )
17
- })
18
-
19
- it('emits insert when text is prepended', () => {
20
- assert.deepEqual(
21
- computeDelta('world', 'hello world'),
22
- { kind: 'insert', index: 0, text: 'hello ' },
23
- )
24
- })
25
-
26
- it('emits insert when text is spliced mid-string', () => {
27
- // Inserting an 'l' to make 'helo' → 'hello'. The longest common
28
- // prefix is 'hel' (3 chars — before[2]='l' and after[2]='l' both
29
- // match), so the insertion lands at index 3. Either interpretation
30
- // (index 2 or index 3) produces the same CRDT result; the diff
31
- // picks the rightmost feasible point deterministically.
32
- assert.deepEqual(
33
- computeDelta('helo', 'hello'),
34
- { kind: 'insert', index: 3, text: 'l' },
35
- )
36
- })
37
-
38
- it('emits delete when a trailing run is removed', () => {
39
- assert.deepEqual(
40
- computeDelta('hello!', 'hello'),
41
- { kind: 'delete', index: 5, length: 1 },
42
- )
43
- })
44
-
45
- it('emits delete when a leading run is removed', () => {
46
- assert.deepEqual(
47
- computeDelta('hello world', 'world'),
48
- { kind: 'delete', index: 0, length: 6 },
49
- )
50
- })
51
-
52
- it('emits delete when a mid-string run is removed', () => {
53
- assert.deepEqual(
54
- computeDelta('hello', 'hlo'),
55
- { kind: 'delete', index: 1, length: 2 },
56
- )
57
- })
58
-
59
- it('emits replace when a mid-string selection is swapped', () => {
60
- assert.deepEqual(
61
- computeDelta('hello world', 'hello pilot'),
62
- { kind: 'replace', from: 6, to: 11, text: 'pilot' },
63
- )
64
- })
65
-
66
- it('emits replace when the whole string is swapped', () => {
67
- assert.deepEqual(
68
- computeDelta('foo', 'bar'),
69
- { kind: 'replace', from: 0, to: 3, text: 'bar' },
70
- )
71
- })
72
-
73
- it('emits insert when growing from empty', () => {
74
- assert.deepEqual(
75
- computeDelta('', 'a'),
76
- { kind: 'insert', index: 0, text: 'a' },
77
- )
78
- })
79
-
80
- it('emits delete when shrinking to empty', () => {
81
- assert.deepEqual(
82
- computeDelta('abc', ''),
83
- { kind: 'delete', index: 0, length: 3 },
84
- )
85
- })
86
-
87
- it('handles repeated-char shrink without prefix/suffix overlap', () => {
88
- // 'aaa' → 'aa' — the prefix walk could greedily eat all 2 chars from
89
- // the after side; the suffix cap must stop suffix at 2 so beforeMid
90
- // is 'a' (length 1) instead of '' (length 0, identity).
91
- assert.deepEqual(
92
- computeDelta('aaa', 'aa'),
93
- { kind: 'delete', index: 2, length: 1 },
94
- )
95
- })
96
- })
97
-
98
- describe('preserveCursor — anchor across remote edits', () => {
99
- it('returns input cursor when strings are identical', () => {
100
- assert.equal(preserveCursor('hello', 'hello', 3), 3)
101
- })
102
-
103
- it('leaves cursor untouched when edit lands AFTER cursor', () => {
104
- // Cursor at index 2 ('he|llo'); remote appends ' world'. Edit prefix
105
- // length is 5, cursor 2 ≤ prefix → no shift.
106
- assert.equal(preserveCursor('hello', 'hello world', 2), 2)
107
- })
108
-
109
- it('shifts cursor when edit lands BEFORE cursor', () => {
110
- // Cursor at 5 ('hello|'); remote prepends 'XX '. The common prefix
111
- // is empty, so cursor > prefix → shift by (8 − 5) = 3, landing at
112
- // 8 (the end of the new string, same logical position as before).
113
- assert.equal(preserveCursor('hello', 'XX hello', 5), 8)
114
- })
115
-
116
- it('lands at end-of-string for non-contiguous edits (heuristic limit)', () => {
117
- // Both-sides insertion ('hello' → 'X hello world') flattens into a
118
- // single full-string `replace` at the diff layer because the prefix
119
- // and suffix walks find no common ground. Cursor lands at the end
120
- // of the new string — imperfect for this case but harmless. A
121
- // future v2 using Yjs `RelativePosition` would land it at 7
122
- // (just after the original 'hello' substring).
123
- assert.equal(preserveCursor('hello', 'X hello world', 5), 13)
124
- })
125
-
126
- it('clamps cursor when remote deletes around the cursor', () => {
127
- // Cursor at 5 ('hello|world'); remote deletes 'hello'. Prefix is 0,
128
- // delta is -5 → shifted to 0.
129
- assert.equal(preserveCursor('helloworld', 'world', 5), 0)
130
- })
131
-
132
- it('never returns a negative cursor', () => {
133
- assert.equal(preserveCursor('abcdef', '', 3), 0)
134
- })
135
-
136
- it('never returns a cursor past the new length', () => {
137
- // Defensive — caller might pass a stale cursor longer than the new
138
- // string. Clamp to new bounds.
139
- assert.equal(preserveCursor('hello', 'hi', 10), 2)
140
- })
141
- })
@@ -1,86 +0,0 @@
1
- import type { TextDelta } from '../FormCollabBindingRegistry.js'
2
-
3
- /**
4
- * Phase F.6 — derive a single character-level edit op from two strings.
5
- *
6
- * Strategy: find the longest common prefix and suffix between `before`
7
- * and `after`; whatever's left in the middle is the changed region.
8
- *
9
- * - middle-after empty + middle-before non-empty → `delete`
10
- * - middle-before empty + middle-after non-empty → `insert`
11
- * - both non-empty → `replace`
12
- * - both empty (identical strings) → `null`
13
- *
14
- * This correctly handles the common edit shapes: single-key insert,
15
- * single-key backspace, multi-char paste replacing a selection, IME
16
- * commits, accent-key composition. It does NOT preserve user intent
17
- * when the same character appears at multiple positions and the edit
18
- * could be attributed to either occurrence — Yjs's per-character
19
- * identity makes that distinction lossy at the string-diff layer
20
- * (the `Y.Text` itself maintains item identity internally). For v1
21
- * we accept the ambiguity; the CRDT semantics still converge.
22
- */
23
- export function computeDelta(before: string, after: string): TextDelta | null {
24
- if (before === after) return null
25
-
26
- let prefix = 0
27
- const minLen = Math.min(before.length, after.length)
28
- while (prefix < minLen && before[prefix] === after[prefix]) prefix++
29
-
30
- // Walk back from each end, capped so suffix can't overlap the prefix
31
- // on either side. Without the cap, identical strings of repeated
32
- // chars (e.g. 'aaa' → 'aa') would consume the same byte from both
33
- // directions and produce an empty middle on both sides.
34
- let suffix = 0
35
- const maxSuffix = Math.min(before.length - prefix, after.length - prefix)
36
- while (
37
- suffix < maxSuffix &&
38
- before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
39
- ) {
40
- suffix++
41
- }
42
-
43
- const beforeMid = before.slice(prefix, before.length - suffix)
44
- const afterMid = after.slice(prefix, after.length - suffix)
45
-
46
- if (beforeMid.length === 0 && afterMid.length > 0) {
47
- return { kind: 'insert', index: prefix, text: afterMid }
48
- }
49
- if (afterMid.length === 0 && beforeMid.length > 0) {
50
- return { kind: 'delete', index: prefix, length: beforeMid.length }
51
- }
52
- return { kind: 'replace', from: prefix, to: prefix + beforeMid.length, text: afterMid }
53
- }
54
-
55
- /**
56
- * Phase F.6 — best-effort cursor anchor across a remote-applied edit.
57
- *
58
- * - Edit landed AFTER cursor (cursor inside the common prefix) → keep
59
- * cursor where it is.
60
- * - Edit landed BEFORE cursor → shift
61
- * cursor by `after.length − before.length` so its character-offset
62
- * into the post-edit string matches its pre-edit anchor.
63
- * - Edit landed OVERLAPPING the cursor → cursor
64
- * ends up at the boundary between the changed region and the
65
- * unchanged suffix (which `Math.max(0, cursor + delta)` produces
66
- * naturally and clamps to the new bounds).
67
- *
68
- * This is a heuristic, not a Yjs `RelativePosition`. Two peers typing
69
- * at the exact same insertion point can still see a one-character cursor
70
- * twitch on the remote-mirror side; v2 (in-input remote carets) would
71
- * upgrade to relative positions if/when a consumer asks. Native input
72
- * cursors are clamped to `[0, after.length]` by every browser, so this
73
- * function does the same to avoid `setSelectionRange` throwing.
74
- */
75
- export function preserveCursor(before: string, after: string, cursor: number): number {
76
- if (before === after) return cursor
77
-
78
- let prefix = 0
79
- const minLen = Math.min(before.length, after.length)
80
- while (prefix < minLen && before[prefix] === after[prefix]) prefix++
81
-
82
- if (cursor <= prefix) return Math.min(cursor, after.length)
83
-
84
- const delta = after.length - before.length
85
- return Math.max(0, Math.min(after.length, cursor + delta))
86
- }