@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
@@ -8,9 +8,10 @@ import {
8
8
  import { useFieldState } from '../FormStateContext.js'
9
9
  import { useCollabRoom } from '../CollabRoomContext.js'
10
10
  import { getCollabTextRenderer, type CollabTextRenderer } from '../CollabTextRendererRegistry.js'
11
+ import { useRowCoords } from '../RowCoordsContext.js'
12
+ import { parseRowFieldPath } from '../formStateHelpers.js'
11
13
  import { useToast } from '../Toaster.js'
12
14
  import { Button } from '../ui/button.js'
13
- import { computeDelta, preserveCursor } from './textDelta.js'
14
15
 
15
16
  type ToolbarButton =
16
17
  | 'bold' | 'italic' | 'strike' | 'link'
@@ -50,24 +51,37 @@ export function MarkdownInput({
50
51
  const fs = useFieldState(name)
51
52
  const room = useCollabRoom()
52
53
  const collabRenderer = getCollabTextRenderer()
53
-
54
- // Phase B follow-up — Tiptap-backed plain-text editor for markdown source
55
- // when collab is on. Same architectural fix as `TextLikeInput`'s
56
- // CollabTextField: y-prosemirror's `RelativePosition` cursor anchoring
57
- // replaces the broken `Y.Text` + `computeDelta` + `preserveCursor` heuristic.
54
+ const rowCoords = useRowCoords()
55
+
56
+ // Tiptap-backed plain-text editor for markdown source when collab is on.
57
+ // Same architectural fix as `TextLikeInput`'s `CollabTextField`:
58
+ // y-prosemirror's `RelativePosition` cursor anchoring against a
59
+ // `Y.XmlFragment` replaces whole-string LWW. Row leaves get the
60
+ // composite-key transform via `useRowCoords()` + `parseRowFieldPath`
61
+ // (same shape as TextLikeInput) so the fragment survives row reorders.
58
62
  //
59
- // Tradeoff: the markdown toolbar + Cmd-shortcuts + paste-image upload all
60
- // operate on a `<textarea>`'s DOM selection — they don't have a way to
61
- // reach into the Tiptap editor's selection without exposing the editor
62
- // instance, which would widen the renderer seam. For now those features
63
+ // Tradeoff: the markdown toolbar + Cmd-shortcuts + paste-image upload
64
+ // all operate on a `<textarea>`'s DOM selection — they don't have a
65
+ // way to reach into the Tiptap editor's selection without exposing the
66
+ // editor instance, which would widen the renderer seam. Those features
63
67
  // are write-mode-only on the native path; collab users type markdown
64
68
  // syntax directly (`**bold**`, `## heading`). The preview tab keeps
65
- // working since it reads `value` from local state.
66
- if (room && collabRenderer) {
69
+ // working since `MarkdownCollabInput` maintains a local mirror.
70
+ const fragmentKey: string | null = (() => {
71
+ if (!name.includes('.')) return name
72
+ if (!rowCoords) return null
73
+ const parsed = parseRowFieldPath(name)
74
+ if (!parsed) return null
75
+ if (parsed.arrayName !== rowCoords.arrayName) return null
76
+ if (parsed.index !== rowCoords.rowIndex) return null
77
+ return `${rowCoords.arrayName}.${rowCoords.rowId}.${parsed.fieldName}`
78
+ })()
79
+ if (room && collabRenderer && fragmentKey !== null) {
67
80
  return (
68
81
  <MarkdownCollabInput
69
82
  Renderer={collabRenderer}
70
- name={name}
83
+ fragmentKey={fragmentKey}
84
+ hiddenInputName={name}
71
85
  defaultValue={defaultValue}
72
86
  disabled={disabled}
73
87
  {...(placeholder !== undefined ? { placeholder } : {})}
@@ -79,100 +93,15 @@ export function MarkdownInput({
79
93
 
80
94
  const { notify } = useToast()
81
95
  const textareaRef = useRef<HTMLTextAreaElement | null>(null)
82
- // Phase F.6 — IME composition gate. Set between `compositionstart` /
83
- // `compositionend`; the textarea's onChange skips `applyDelta` while
84
- // composing so intermediate chars don't emit ops. Lives at the
85
- // component scope so the onChange and composition handlers share it.
86
- const isComposingRef = useRef<boolean>(false)
87
96
 
88
97
  const initial = useMemo(() => stringValue(defaultValue), [])
89
98
  const [localValue, setLocalValue] = useState<string>(initial)
90
99
  const [tab, setTab] = useState<'write' | 'preview'>('write')
91
100
  const [busy, setBusy] = useState(false)
92
101
 
93
- // Phase F.6 when a `<RecordCollabRoom>` is mounted and the field has
94
- // a `TextBinding`, the textarea is bound to a `Y.Text` and edits emit
95
- // `TextDelta`s. Mirrors the architecture in `TextLikeInput.tsx` but
96
- // wired in-line because MarkdownInput has its own toolbar + Preview
97
- // tab that also need to flow through the binding.
98
- const binding = fs.textBinding
99
- const [boundValue, setBoundValue] = useState<string>(() => binding?.read() ?? initial)
100
- const boundValueRef = useRef<string>(boundValue)
101
- useEffect(() => { boundValueRef.current = boundValue }, [boundValue])
102
-
103
- // On binding swap: read current Y.Text state. If non-empty, lift it
104
- // into local + form-map state. If empty (no peer has typed yet), leave
105
- // the SSR-default-derived `boundValue` showing — first edit will
106
- // emit a replace-from-empty delta that atomically populates Y.Text.
107
- // No client-side seed: Y.Text isn't safe to seed under concurrent
108
- // first-mounters (see @pilotiq-pro/collab `formCollabBinding.ts`).
109
- useEffect(() => {
110
- if (!binding) return
111
- const next = binding.read()
112
- if (next.length > 0) {
113
- setBoundValue(next)
114
- boundValueRef.current = next
115
- fs.setValue(next)
116
- }
117
- // eslint-disable-next-line react-hooks/exhaustive-deps
118
- }, [binding])
119
-
120
- // Subscribe to remote changes. Local-echoes are filtered by the
121
- // `next === prev` guard. Cursor preserved via the same heuristic
122
- // used in `TextLikeInput.BoundTextInput`.
123
- useEffect(() => {
124
- if (!binding) return
125
- return binding.observe((next) => {
126
- const prev = boundValueRef.current
127
- if (next === prev) return
128
- const ta = textareaRef.current
129
- const cursor = ta?.selectionStart ?? next.length
130
- const restored = preserveCursor(prev, next, cursor)
131
- setBoundValue(next)
132
- boundValueRef.current = next
133
- fs.setValue(next)
134
- requestAnimationFrame(() => {
135
- if (!ta) return
136
- if (document.activeElement !== ta) return
137
- try { ta.setSelectionRange(restored, restored) } catch { /* defensive */ }
138
- })
139
- })
140
- // eslint-disable-next-line react-hooks/exhaustive-deps
141
- }, [binding])
142
-
143
- const value = binding
144
- ? boundValue
145
- : (fs.controlled ? stringValue(fs.value) : localValue)
102
+ const value = fs.controlled ? stringValue(fs.value) : localValue
146
103
 
147
104
  const setValue = (next: string): void => {
148
- if (binding) {
149
- // Compute against current Y.Text contents (not the local ref) so:
150
- // - first edit against empty Y.Text → `insert@0 <whole>` atomic
151
- // populate (no separate seed op needed);
152
- // - after a remote-applied update or server-resolve replace, the
153
- // delta reflects the actual current shared state, not stale
154
- // local bookkeeping.
155
- const before = binding.read()
156
- if (next !== before) {
157
- const delta = computeDelta(before, next)
158
- // Pre-stamp `boundValueRef.current = next` BEFORE `applyDelta`.
159
- // Y.Text's `observe` fires synchronously inside `applyDelta` for
160
- // our own write; without this the observer would see
161
- // `prev=before, next=after` and run `preserveCursor` — designed
162
- // for *remote* edits — which clobbers the user's caret on local
163
- // typing (scrambled output on mid-string inserts). With
164
- // `boundValueRef` already at `next`, the observer's
165
- // `next === prev` short-circuit fires and the cursor is left
166
- // alone for local echoes. Mirror of the same fix in
167
- // `BoundTextInput.commitDelta`.
168
- boundValueRef.current = next
169
- if (delta) binding.applyDelta(delta)
170
- setBoundValue(next)
171
- }
172
- fs.setValue(next)
173
- fs.triggerLive(next)
174
- return
175
- }
176
105
  if (fs.controlled) { fs.setValue(next); fs.triggerLive(next) }
177
106
  else { setLocalValue(next); fs.triggerLive(next) }
178
107
  }
@@ -405,24 +334,7 @@ export function MarkdownInput({
405
334
  {...(fs.controlled
406
335
  ? {
407
336
  value,
408
- onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => {
409
- // Phase F.6 — when the binding is active and the user
410
- // is mid-IME, paint locally and hold the delta until
411
- // compositionend so we never emit ops for the
412
- // intermediate composing chars.
413
- if (binding && isComposingRef.current) {
414
- setBoundValue(e.target.value)
415
- return
416
- }
417
- setValue(e.target.value)
418
- },
419
- ...(binding ? {
420
- onCompositionStart: () => { isComposingRef.current = true },
421
- onCompositionEnd: (e: React.CompositionEvent<HTMLTextAreaElement>) => {
422
- isComposingRef.current = false
423
- setValue(e.currentTarget.value)
424
- },
425
- } : {}),
337
+ onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value),
426
338
  }
427
339
  : { defaultValue: initial, onChange: (e) => setLocalValue(e.target.value) })}
428
340
  onPaste={onPaste}
@@ -472,17 +384,18 @@ function TabButton({ active, onClick, children }: {
472
384
  * load-bearing change; markdown-syntax authors keep typing as before.
473
385
  */
474
386
  function MarkdownCollabInput({
475
- Renderer, name, defaultValue, disabled, placeholder, minHeight, maxHeight,
387
+ Renderer, fragmentKey, hiddenInputName, defaultValue, disabled, placeholder, minHeight, maxHeight,
476
388
  }: {
477
- Renderer: CollabTextRenderer
478
- name: string
479
- defaultValue: unknown
480
- disabled: boolean
481
- placeholder?: string
482
- minHeight?: string
483
- maxHeight?: string
389
+ Renderer: CollabTextRenderer
390
+ fragmentKey: string
391
+ hiddenInputName: string
392
+ defaultValue: unknown
393
+ disabled: boolean
394
+ placeholder?: string
395
+ minHeight?: string
396
+ maxHeight?: string
484
397
  }): React.ReactElement {
485
- const fs = useFieldState(name)
398
+ const fs = useFieldState(hiddenInputName)
486
399
  const initial = useMemo(() => stringValue(defaultValue), [])
487
400
  const [text, setText] = useState<string>(initial)
488
401
  const [tab, setTab] = useState<'write' | 'preview'>('write')
@@ -513,9 +426,9 @@ function MarkdownCollabInput({
513
426
  </div>
514
427
  {tab === 'write' ? (
515
428
  <div style={wrapperStyle} className="overflow-auto">
516
- <input type="hidden" name={name} value={text} />
429
+ <input type="hidden" name={hiddenInputName} value={text} />
517
430
  <Renderer
518
- name={name}
431
+ name={fragmentKey}
519
432
  multiline={true}
520
433
  defaultValue={initial}
521
434
  {...(placeholder !== undefined ? { placeholder } : {})}
@@ -527,7 +440,7 @@ function MarkdownCollabInput({
527
440
  </div>
528
441
  ) : (
529
442
  <>
530
- <input type="hidden" name={name} value={text} readOnly />
443
+ <input type="hidden" name={hiddenInputName} value={text} readOnly />
531
444
  <div
532
445
  className="prose prose-sm dark:prose-invert max-w-none px-3 py-2"
533
446
  style={wrapperStyle}
@@ -5,6 +5,7 @@ import { Button } from '../ui/button.js'
5
5
  import { SchemaRenderer, dispatchHandlerAction } from '../SchemaRenderer.js'
6
6
  import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
7
7
  import { findFieldMeta } from '../formStateHelpers.js'
8
+ import { RowCoordsContext } from '../RowCoordsContext.js'
8
9
  import { useNavigate } from '../navigate.js'
9
10
  import { useToast } from '../Toaster.js'
10
11
  import type { RowButtonsMeta } from '../../fields/RowButton.js'
@@ -20,6 +21,7 @@ import {
20
21
  DEFAULT_DELETE,
21
22
  } from './rowChromeButton.js'
22
23
  import { syncRowGates } from './syncRowGates.js'
24
+ import { consumeReconcileFlag, computeReconcilePlan } from './repeaterReconcile.js'
23
25
  import {
24
26
  generateRowId, makeAccordionStorage, makeCollapsedStorage,
25
27
  } from './rowState.js'
@@ -243,16 +245,14 @@ export function RepeaterInput({
243
245
  // it when present so peers see the same lifecycle events; absent =
244
246
  // today's local-only behaviour, unchanged.
245
247
  const rowBinding = useRowBinding(name)
246
- // Phase F.5c — mirror row identities into the form's values map so dotted
247
- // row-leaf consumers (`useFieldState('${name}.${i}.heading').textBinding`)
248
- // can resolve the row's `__id` via `rowIdAtIndex(ctx.values, name, i)`.
249
- // The renderer is the only source of truth for `(index → rowId)`; without
250
- // this stamp the F.5c per-row Y.Text path stays null and row text fields
251
- // never sync. Setting a `__id` key routes through `routeBindingWrite` →
252
- // `parseRowFieldPath` which filters `__id` no-op on the binding side
253
- // (no Y.Text writes), so the only effect is a row in `valuesState`.
254
- // `formStateForIds` mirrors `formState` below; we read via `useFormState()`
255
- // here too instead of forward-referencing the later binding.
248
+ // Mirror row identities into the form's values map so dotted row-leaf
249
+ // consumers can resolve the row's `__id` via `rowIdAtIndex(ctx.values,
250
+ // name, i)`. Setting a `__id` key routes through `routeBindingWrite`
251
+ // `parseRowFieldPath` which filters `__id` no-op on the binding side,
252
+ // so the only effect is a row entry landing in `valuesState`.
253
+ // `formStateForIds` mirrors `formState` below; we read via
254
+ // `useFormState()` here too instead of forward-referencing the later
255
+ // binding.
256
256
  const formStateForIds = useFormState()
257
257
  const ctxSetValue = formStateForIds?.setValue
258
258
  useEffect(() => {
@@ -305,6 +305,37 @@ export function RepeaterInput({
305
305
  })
306
306
  })
307
307
  }, [rowBinding, meta.template])
308
+
309
+ // Phase A reconciliation for `Repeater.relationship` PK-switch — when
310
+ // the surrounding form just submitted in this tab AND we're inside a
311
+ // collab room with a row binding, snapshot the CRDT order after a
312
+ // short settle (long enough for WS sync to deliver any persisted
313
+ // state) and reconcile against `initialRows`. Drops orphan UUIDs
314
+ // whose rows just persisted under a fresh DB PK; idempotent + no-op
315
+ // for non-relationship Repeaters where `__id` stays UUID across
316
+ // save+reload. Plan:
317
+ // `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
318
+ useEffect(() => {
319
+ if (!rowBinding) return
320
+ if (!consumeReconcileFlag(formId)) return
321
+ // Give WS sync time to deliver any persisted rows before reading
322
+ // current(). 1500ms is conservative; typical sync settles in <300ms.
323
+ // The reconciler is one-shot per submit, so we accept the brief
324
+ // visual flicker over a tighter timer that might fire pre-sync.
325
+ const timer = setTimeout(() => {
326
+ const plan = computeReconcilePlan({
327
+ current: rowBinding.current(),
328
+ authoritative: initialRows.map(r => r.id),
329
+ })
330
+ for (const id of plan.toRemove) rowBinding.remove(id)
331
+ for (const id of plan.toAdd) rowBinding.add(id, {})
332
+ }, 1500)
333
+ return () => clearTimeout(timer)
334
+ // initialRows is a stable useMemo([]) ref so it's safe to omit. We
335
+ // intentionally key only on rowBinding + formId — the reconciler is
336
+ // tied to the submit lifecycle, not to row-state changes.
337
+ // eslint-disable-next-line react-hooks/exhaustive-deps
338
+ }, [rowBinding, formId])
308
339
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
309
340
  accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
310
341
  )
@@ -395,29 +426,26 @@ export function RepeaterInput({
395
426
  }
396
427
 
397
428
  const moveRow = (id: string, dir: -1 | 1): void => {
398
- let newOrder: string[] | null = null
399
- setRows(prev => {
400
- const idx = prev.findIndex(r => r.id === id)
401
- if (idx < 0) return prev
402
- // Skip past hidden neighbours so reorder operates between visible
403
- // rows. Hidden rows hold their absolute slot — the visible row hops
404
- // over them.
405
- let next: RowState[]
406
- if (dir === -1) {
407
- let target = idx - 1
408
- while (target >= 0 && prev[target]?.hidden) target--
409
- if (target < 0) return prev
410
- next = reorderRows(prev, idx, target)
411
- } else {
412
- let target = idx + 1
413
- while (target < prev.length && prev[target]?.hidden) target++
414
- if (target >= prev.length) return prev
415
- next = reorderRows(prev, idx, target + 1)
416
- }
417
- if (next !== prev) newOrder = next.map(r => r.id)
418
- return next
419
- })
420
- if (newOrder !== null) rowBinding?.reorder(newOrder)
429
+ const idx = rows.findIndex(r => r.id === id)
430
+ if (idx < 0) return
431
+ // Skip past hidden neighbours so reorder operates between visible
432
+ // rows. Hidden rows hold their absolute slot — the visible row hops
433
+ // over them.
434
+ let next: RowState[]
435
+ if (dir === -1) {
436
+ let target = idx - 1
437
+ while (target >= 0 && rows[target]?.hidden) target--
438
+ if (target < 0) return
439
+ next = reorderRows(rows, idx, target)
440
+ } else {
441
+ let target = idx + 1
442
+ while (target < rows.length && rows[target]?.hidden) target++
443
+ if (target >= rows.length) return
444
+ next = reorderRows(rows, idx, target + 1)
445
+ }
446
+ if (next === rows) return
447
+ setRows(next)
448
+ rowBinding?.reorder(next.map(r => r.id))
421
449
  }
422
450
 
423
451
  // ── DnD state ───────────────────────────────────────────
@@ -430,15 +458,20 @@ export function RepeaterInput({
430
458
  } = useRowReorderDnd({
431
459
  enabled: reorderable && !disabled,
432
460
  onDrop: (fromId, at) => {
433
- let newOrder: string[] | null = null
434
- setRows(prev => {
435
- const fromIdx = prev.findIndex(r => r.id === fromId)
436
- if (fromIdx < 0) return prev
437
- const next = reorderRows(prev, fromIdx, at)
438
- if (next !== prev) newOrder = next.map(r => r.id)
439
- return next
440
- })
441
- if (newOrder !== null) rowBinding?.reorder(newOrder)
461
+ // Compute next from the current `rows` directly. The previous
462
+ // setRows(updater) + closure-mutation pattern relied on React
463
+ // running the updater synchronously inside setState — which only
464
+ // happens when no other update is queued. `useRowReorderDnd`'s
465
+ // handleDrop sets dragId/dropAt to null right before calling
466
+ // this callback, so the updater runs in commit phase and the
467
+ // outer `newOrder` stayed null past the `if` check, silently
468
+ // skipping the rowBinding.reorder broadcast.
469
+ const fromIdx = rows.findIndex(r => r.id === fromId)
470
+ if (fromIdx < 0) return
471
+ const next = reorderRows(rows, fromIdx, at)
472
+ if (next === rows) return
473
+ setRows(next)
474
+ rowBinding?.reorder(next.map(r => r.id))
442
475
  },
443
476
  })
444
477
 
@@ -749,16 +782,25 @@ function RepeaterRow({
749
782
  [row.children, prefix],
750
783
  )
751
784
  const headerLabel = row.itemLabel ?? `Item ${index + 1}`
785
+ // Row coords for dotted-path text leaves — composes fragment-key
786
+ // `${arrayName}.${rowId}.${fieldName}` for the Tiptap-backed collab
787
+ // renderer (see collab-row-text-tiptap-backed.md Phase 1).
788
+ const rowCoords = useMemo(
789
+ () => ({ arrayName: name, rowIndex: index, rowId: row.id }),
790
+ [name, index, row.id],
791
+ )
752
792
 
753
793
  // Hidden rows: render only the inputs (and __id) inside a display:none
754
794
  // wrapper so their values round-trip through FormData on submit. No
755
795
  // chrome, no drag wiring, no labels — `itemHidden` is purely UX.
756
796
  if (row.hidden) {
757
797
  return (
758
- <div style={{ display: 'none' }} data-pilotiq-repeater-row="hidden">
759
- <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
760
- <SchemaRenderer elements={namespaced} />
761
- </div>
798
+ <RowCoordsContext.Provider value={rowCoords}>
799
+ <div style={{ display: 'none' }} data-pilotiq-repeater-row="hidden">
800
+ <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
801
+ <SchemaRenderer elements={namespaced} />
802
+ </div>
803
+ </RowCoordsContext.Provider>
762
804
  )
763
805
  }
764
806
 
@@ -795,6 +837,7 @@ function RepeaterRow({
795
837
  // wrapping in a class that hides the FieldShell's label slot.
796
838
  if (simple) {
797
839
  return (
840
+ <RowCoordsContext.Provider value={rowCoords}>
798
841
  <div
799
842
  className={`flex items-center gap-2 transition-opacity ${isDragging ? 'opacity-50' : ''}`}
800
843
  data-pilotiq-repeater-row="simple"
@@ -830,10 +873,12 @@ function RepeaterRow({
830
873
  />
831
874
  )}
832
875
  </div>
876
+ </RowCoordsContext.Provider>
833
877
  )
834
878
  }
835
879
 
836
880
  return (
881
+ <RowCoordsContext.Provider value={rowCoords}>
837
882
  <div
838
883
  className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
839
884
  data-pilotiq-repeater-row=""
@@ -910,6 +955,7 @@ function RepeaterRow({
910
955
  : <SchemaRenderer elements={namespaced} />}
911
956
  </div>
912
957
  </div>
958
+ </RowCoordsContext.Provider>
913
959
  )
914
960
  }
915
961
 
@@ -1168,6 +1214,10 @@ function RepeaterTableRow({
1168
1214
  () => row.children.map(c => prefixFieldNames(c, prefix)),
1169
1215
  [row.children, prefix],
1170
1216
  )
1217
+ const rowCoords = useMemo(
1218
+ () => ({ arrayName: name, rowIndex: index, rowId: row.id }),
1219
+ [name, index, row.id],
1220
+ )
1171
1221
 
1172
1222
  if (row.hidden) {
1173
1223
  // Render the hidden envelope as a single full-span cell so column
@@ -1175,11 +1225,18 @@ function RepeaterTableRow({
1175
1225
  // still in the form's submit. Using `<tr style="display:none">`
1176
1226
  // would warn under React strict-mode in Firefox; the wrapping cell
1177
1227
  // keeps the markup HTML-valid.
1228
+ //
1229
+ // The provider wraps the cell rather than the `<tr>` because React's
1230
+ // table-row whitelisting only accepts `<th>/<td>` children, not a
1231
+ // context provider; the provider is a no-DOM wrapper so it sits
1232
+ // inside the cell fine.
1178
1233
  return (
1179
1234
  <tr style={{ display: 'none' }} data-pilotiq-repeater-row="hidden">
1180
1235
  <td colSpan={columns.length + 1}>
1181
- <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
1182
- <SchemaRenderer elements={namespaced} />
1236
+ <RowCoordsContext.Provider value={rowCoords}>
1237
+ <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
1238
+ <SchemaRenderer elements={namespaced} />
1239
+ </RowCoordsContext.Provider>
1183
1240
  </td>
1184
1241
  </tr>
1185
1242
  )
@@ -1209,6 +1266,7 @@ function RepeaterTableRow({
1209
1266
  : {}
1210
1267
 
1211
1268
  return (
1269
+ <RowCoordsContext.Provider value={rowCoords}>
1212
1270
  <tr
1213
1271
  className={`border-t align-top ${isDragging ? 'opacity-50' : ''}`}
1214
1272
  data-pilotiq-repeater-row=""
@@ -1265,6 +1323,7 @@ function RepeaterTableRow({
1265
1323
  </div>
1266
1324
  </td>
1267
1325
  </tr>
1326
+ </RowCoordsContext.Provider>
1268
1327
  )
1269
1328
  }
1270
1329