@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +19 -0
- package/dist/pageData/helpers.d.ts +16 -0
- package/dist/pageData/helpers.d.ts.map +1 -1
- package/dist/pageData/helpers.js +61 -1
- package/dist/pageData/helpers.js.map +1 -1
- package/dist/pageData.d.ts +1 -1
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +1 -1
- package/dist/pageData.js.map +1 -1
- package/dist/react/FormCollabBindingRegistry.d.ts +33 -98
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +1 -35
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +15 -92
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/RowCoordsContext.d.ts +19 -0
- package/dist/react/RowCoordsContext.d.ts.map +1 -0
- package/dist/react/RowCoordsContext.js +6 -0
- package/dist/react/RowCoordsContext.js.map +1 -0
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +78 -49
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +35 -125
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +104 -60
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts +11 -9
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +59 -189
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/fields/repeaterReconcile.d.ts +66 -0
- package/dist/react/fields/repeaterReconcile.d.ts.map +1 -0
- package/dist/react/fields/repeaterReconcile.js +96 -0
- package/dist/react/fields/repeaterReconcile.js.map +1 -0
- package/dist/react/formStateHelpers.d.ts +0 -15
- package/dist/react/formStateHelpers.d.ts.map +1 -1
- package/dist/react/formStateHelpers.js +0 -91
- package/dist/react/formStateHelpers.js.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
- package/dist/react/schemaRenderer/form/FormRenderer.js +10 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
- package/package.json +1 -1
- package/src/pageData/helpers.ts +55 -1
- package/src/pageData.test.ts +67 -0
- package/src/pageData.ts +1 -0
- package/src/react/FormCollabBindingRegistry.ts +34 -91
- package/src/react/FormStateContext.tsx +14 -126
- package/src/react/RowCoordsContext.tsx +23 -0
- package/src/react/fields/BuilderInput.tsx +75 -39
- package/src/react/fields/MarkdownInput.tsx +42 -129
- package/src/react/fields/RepeaterInput.tsx +107 -48
- package/src/react/fields/TextLikeInput.tsx +67 -225
- package/src/react/fields/repeaterReconcile.test.ts +114 -0
- package/src/react/fields/repeaterReconcile.ts +104 -0
- package/src/react/formStateHelpers.test.ts +0 -99
- package/src/react/formStateHelpers.ts +0 -83
- package/src/react/index.ts +0 -2
- package/src/react/schemaRenderer/form/FormRenderer.tsx +10 -0
- package/dist/react/fields/textDelta.d.ts +0 -44
- package/dist/react/fields/textDelta.d.ts.map +0 -1
- package/dist/react/fields/textDelta.js +0 -80
- package/dist/react/fields/textDelta.js.map +0 -1
- package/src/react/fields/textDelta.test.ts +0 -141
- 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
|
-
|
|
55
|
-
// when collab is on.
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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
|
|
60
|
-
// operate on a `<textarea>`'s DOM selection — they don't have a
|
|
61
|
-
// reach into the Tiptap editor's selection without exposing the
|
|
62
|
-
// instance, which would widen the renderer seam.
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
387
|
+
Renderer, fragmentKey, hiddenInputName, defaultValue, disabled, placeholder, minHeight, maxHeight,
|
|
476
388
|
}: {
|
|
477
|
-
Renderer:
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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(
|
|
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={
|
|
429
|
+
<input type="hidden" name={hiddenInputName} value={text} />
|
|
517
430
|
<Renderer
|
|
518
|
-
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={
|
|
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
|
-
//
|
|
247
|
-
// row
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
// `
|
|
253
|
-
//
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
let
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
434
|
-
setRows(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
<
|
|
759
|
-
<
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
<
|
|
1182
|
-
|
|
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
|
|