@pilotiq/pilotiq 0.11.0 → 0.13.0

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 (66) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +21 -0
  3. package/dist/react/AppShell.d.ts +1 -1
  4. package/dist/react/AppShell.d.ts.map +1 -1
  5. package/dist/react/AppShell.js +7 -1
  6. package/dist/react/AppShell.js.map +1 -1
  7. package/dist/react/CollabTextRendererRegistry.d.ts +75 -0
  8. package/dist/react/CollabTextRendererRegistry.d.ts.map +1 -0
  9. package/dist/react/CollabTextRendererRegistry.js +18 -0
  10. package/dist/react/CollabTextRendererRegistry.js.map +1 -0
  11. package/dist/react/CurrentUserContext.d.ts +39 -0
  12. package/dist/react/CurrentUserContext.d.ts.map +1 -0
  13. package/dist/react/CurrentUserContext.js +27 -0
  14. package/dist/react/CurrentUserContext.js.map +1 -0
  15. package/dist/react/FormCollabBindingRegistry.d.ts +17 -84
  16. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  17. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  18. package/dist/react/FormStateContext.d.ts +1 -35
  19. package/dist/react/FormStateContext.d.ts.map +1 -1
  20. package/dist/react/FormStateContext.js +7 -91
  21. package/dist/react/FormStateContext.js.map +1 -1
  22. package/dist/react/RowCoordsContext.d.ts +19 -0
  23. package/dist/react/RowCoordsContext.d.ts.map +1 -0
  24. package/dist/react/RowCoordsContext.js +6 -0
  25. package/dist/react/RowCoordsContext.js.map +1 -0
  26. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  27. package/dist/react/fields/BuilderInput.js +14 -9
  28. package/dist/react/fields/BuilderInput.js.map +1 -1
  29. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  30. package/dist/react/fields/MarkdownInput.js +70 -101
  31. package/dist/react/fields/MarkdownInput.js.map +1 -1
  32. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  33. package/dist/react/fields/RepeaterInput.js +26 -17
  34. package/dist/react/fields/RepeaterInput.js.map +1 -1
  35. package/dist/react/fields/TextLikeInput.d.ts +11 -9
  36. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  37. package/dist/react/fields/TextLikeInput.js +111 -164
  38. package/dist/react/fields/TextLikeInput.js.map +1 -1
  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 +3 -1
  44. package/dist/react/index.d.ts.map +1 -1
  45. package/dist/react/index.js +2 -0
  46. package/dist/react/index.js.map +1 -1
  47. package/package.json +5 -5
  48. package/src/react/AppShell.tsx +11 -1
  49. package/src/react/CollabTextRendererRegistry.ts +84 -0
  50. package/src/react/CurrentUserContext.tsx +50 -0
  51. package/src/react/FormCollabBindingRegistry.ts +17 -77
  52. package/src/react/FormStateContext.tsx +6 -125
  53. package/src/react/RowCoordsContext.tsx +23 -0
  54. package/src/react/fields/BuilderInput.tsx +22 -10
  55. package/src/react/fields/MarkdownInput.tsx +125 -95
  56. package/src/react/fields/RepeaterInput.tsx +41 -16
  57. package/src/react/fields/TextLikeInput.tsx +147 -181
  58. package/src/react/formStateHelpers.test.ts +0 -99
  59. package/src/react/formStateHelpers.ts +0 -83
  60. package/src/react/index.ts +12 -2
  61. package/dist/react/fields/textDelta.d.ts +0 -44
  62. package/dist/react/fields/textDelta.d.ts.map +0 -1
  63. package/dist/react/fields/textDelta.js +0 -80
  64. package/dist/react/fields/textDelta.js.map +0 -1
  65. package/src/react/fields/textDelta.test.ts +0 -141
  66. package/src/react/fields/textDelta.ts +0 -86
@@ -6,9 +6,12 @@ import {
6
6
  CodeIcon, PaperclipIcon, Loader2Icon,
7
7
  } from 'lucide-react'
8
8
  import { useFieldState } from '../FormStateContext.js'
9
+ import { useCollabRoom } from '../CollabRoomContext.js'
10
+ import { getCollabTextRenderer, type CollabTextRenderer } from '../CollabTextRendererRegistry.js'
11
+ import { useRowCoords } from '../RowCoordsContext.js'
12
+ import { parseRowFieldPath } from '../formStateHelpers.js'
9
13
  import { useToast } from '../Toaster.js'
10
14
  import { Button } from '../ui/button.js'
11
- import { computeDelta, preserveCursor } from './textDelta.js'
12
15
 
13
16
  type ToolbarButton =
14
17
  | 'bold' | 'italic' | 'strike' | 'link'
@@ -46,92 +49,59 @@ export function MarkdownInput({
46
49
  uploadUrl: string | undefined
47
50
  }): React.ReactElement {
48
51
  const fs = useFieldState(name)
52
+ const room = useCollabRoom()
53
+ const collabRenderer = getCollabTextRenderer()
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.
62
+ //
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
67
+ // are write-mode-only on the native path; collab users type markdown
68
+ // syntax directly (`**bold**`, `## heading`). The preview tab keeps
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) {
80
+ return (
81
+ <MarkdownCollabInput
82
+ Renderer={collabRenderer}
83
+ fragmentKey={fragmentKey}
84
+ hiddenInputName={name}
85
+ defaultValue={defaultValue}
86
+ disabled={disabled}
87
+ {...(placeholder !== undefined ? { placeholder } : {})}
88
+ {...(minHeight !== undefined ? { minHeight } : {})}
89
+ {...(maxHeight !== undefined ? { maxHeight } : {})}
90
+ />
91
+ )
92
+ }
93
+
49
94
  const { notify } = useToast()
50
95
  const textareaRef = useRef<HTMLTextAreaElement | null>(null)
51
- // Phase F.6 — IME composition gate. Set between `compositionstart` /
52
- // `compositionend`; the textarea's onChange skips `applyDelta` while
53
- // composing so intermediate chars don't emit ops. Lives at the
54
- // component scope so the onChange and composition handlers share it.
55
- const isComposingRef = useRef<boolean>(false)
56
96
 
57
97
  const initial = useMemo(() => stringValue(defaultValue), [])
58
98
  const [localValue, setLocalValue] = useState<string>(initial)
59
99
  const [tab, setTab] = useState<'write' | 'preview'>('write')
60
100
  const [busy, setBusy] = useState(false)
61
101
 
62
- // Phase F.6 when a `<RecordCollabRoom>` is mounted and the field has
63
- // a `TextBinding`, the textarea is bound to a `Y.Text` and edits emit
64
- // `TextDelta`s. Mirrors the architecture in `TextLikeInput.tsx` but
65
- // wired in-line because MarkdownInput has its own toolbar + Preview
66
- // tab that also need to flow through the binding.
67
- const binding = fs.textBinding
68
- const [boundValue, setBoundValue] = useState<string>(() => binding?.read() ?? initial)
69
- const boundValueRef = useRef<string>(boundValue)
70
- useEffect(() => { boundValueRef.current = boundValue }, [boundValue])
71
-
72
- // On binding swap: read current Y.Text state. If non-empty, lift it
73
- // into local + form-map state. If empty (no peer has typed yet), leave
74
- // the SSR-default-derived `boundValue` showing — first edit will
75
- // emit a replace-from-empty delta that atomically populates Y.Text.
76
- // No client-side seed: Y.Text isn't safe to seed under concurrent
77
- // first-mounters (see @pilotiq-pro/collab `formCollabBinding.ts`).
78
- useEffect(() => {
79
- if (!binding) return
80
- const next = binding.read()
81
- if (next.length > 0) {
82
- setBoundValue(next)
83
- boundValueRef.current = next
84
- fs.setValue(next)
85
- }
86
- // eslint-disable-next-line react-hooks/exhaustive-deps
87
- }, [binding])
88
-
89
- // Subscribe to remote changes. Local-echoes are filtered by the
90
- // `next === prev` guard. Cursor preserved via the same heuristic
91
- // used in `TextLikeInput.BoundTextInput`.
92
- useEffect(() => {
93
- if (!binding) return
94
- return binding.observe((next) => {
95
- const prev = boundValueRef.current
96
- if (next === prev) return
97
- const ta = textareaRef.current
98
- const cursor = ta?.selectionStart ?? next.length
99
- const restored = preserveCursor(prev, next, cursor)
100
- setBoundValue(next)
101
- boundValueRef.current = next
102
- fs.setValue(next)
103
- requestAnimationFrame(() => {
104
- if (!ta) return
105
- if (document.activeElement !== ta) return
106
- try { ta.setSelectionRange(restored, restored) } catch { /* defensive */ }
107
- })
108
- })
109
- // eslint-disable-next-line react-hooks/exhaustive-deps
110
- }, [binding])
111
-
112
- const value = binding
113
- ? boundValue
114
- : (fs.controlled ? stringValue(fs.value) : localValue)
102
+ const value = fs.controlled ? stringValue(fs.value) : localValue
115
103
 
116
104
  const setValue = (next: string): void => {
117
- if (binding) {
118
- // Compute against current Y.Text contents (not the local ref) so:
119
- // - first edit against empty Y.Text → `insert@0 <whole>` atomic
120
- // populate (no separate seed op needed);
121
- // - after a remote-applied update or server-resolve replace, the
122
- // delta reflects the actual current shared state, not stale
123
- // local bookkeeping.
124
- const before = binding.read()
125
- if (next !== before) {
126
- const delta = computeDelta(before, next)
127
- if (delta) binding.applyDelta(delta)
128
- setBoundValue(next)
129
- boundValueRef.current = next
130
- }
131
- fs.setValue(next)
132
- fs.triggerLive(next)
133
- return
134
- }
135
105
  if (fs.controlled) { fs.setValue(next); fs.triggerLive(next) }
136
106
  else { setLocalValue(next); fs.triggerLive(next) }
137
107
  }
@@ -364,24 +334,7 @@ export function MarkdownInput({
364
334
  {...(fs.controlled
365
335
  ? {
366
336
  value,
367
- onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => {
368
- // Phase F.6 — when the binding is active and the user
369
- // is mid-IME, paint locally and hold the delta until
370
- // compositionend so we never emit ops for the
371
- // intermediate composing chars.
372
- if (binding && isComposingRef.current) {
373
- setBoundValue(e.target.value)
374
- return
375
- }
376
- setValue(e.target.value)
377
- },
378
- ...(binding ? {
379
- onCompositionStart: () => { isComposingRef.current = true },
380
- onCompositionEnd: (e: React.CompositionEvent<HTMLTextAreaElement>) => {
381
- isComposingRef.current = false
382
- setValue(e.currentTarget.value)
383
- },
384
- } : {}),
337
+ onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value),
385
338
  }
386
339
  : { defaultValue: initial, onChange: (e) => setLocalValue(e.target.value) })}
387
340
  onPaste={onPaste}
@@ -422,6 +375,83 @@ function TabButton({ active, onClick, children }: {
422
375
  )
423
376
  }
424
377
 
378
+ /**
379
+ * Phase B follow-up — collab-aware markdown editor. Mounts the registered
380
+ * Tiptap-backed plain-text renderer for the Write pane and reuses the
381
+ * existing `marked` pipeline for Preview. No toolbar, no Cmd-shortcuts, no
382
+ * paste-image upload — those features depend on textarea-DOM splicing that
383
+ * doesn't translate to Tiptap's selection model. The cursor-bug fix is the
384
+ * load-bearing change; markdown-syntax authors keep typing as before.
385
+ */
386
+ function MarkdownCollabInput({
387
+ Renderer, fragmentKey, hiddenInputName, defaultValue, disabled, placeholder, minHeight, maxHeight,
388
+ }: {
389
+ Renderer: CollabTextRenderer
390
+ fragmentKey: string
391
+ hiddenInputName: string
392
+ defaultValue: unknown
393
+ disabled: boolean
394
+ placeholder?: string
395
+ minHeight?: string
396
+ maxHeight?: string
397
+ }): React.ReactElement {
398
+ const fs = useFieldState(hiddenInputName)
399
+ const initial = useMemo(() => stringValue(defaultValue), [])
400
+ const [text, setText] = useState<string>(initial)
401
+ const [tab, setTab] = useState<'write' | 'preview'>('write')
402
+ const textRef = useRef(text)
403
+ useEffect(() => { textRef.current = text }, [text])
404
+
405
+ const handleChange = (next: string): void => {
406
+ setText(next)
407
+ if (fs.controlled) fs.setValue(next)
408
+ fs.triggerLive(next)
409
+ }
410
+ const handleBlur = (): void => { /* fire-and-forget — live trigger already ran on change */ }
411
+
412
+ const previewHtml = useMemo(
413
+ () => tab === 'preview' ? marked.parse(text, { gfm: true, breaks: false, async: false }) as string : '',
414
+ [tab, text],
415
+ )
416
+
417
+ const wrapperStyle: React.CSSProperties = {}
418
+ if (minHeight) wrapperStyle.minHeight = minHeight
419
+ if (maxHeight) wrapperStyle.maxHeight = maxHeight
420
+
421
+ return (
422
+ <div className="flex flex-col rounded-md border bg-background">
423
+ <div className="flex items-center border-b px-2 py-1">
424
+ <TabButton active={tab === 'write'} onClick={() => setTab('write')}>Write</TabButton>
425
+ <TabButton active={tab === 'preview'} onClick={() => setTab('preview')}>Preview</TabButton>
426
+ </div>
427
+ {tab === 'write' ? (
428
+ <div style={wrapperStyle} className="overflow-auto">
429
+ <input type="hidden" name={hiddenInputName} value={text} />
430
+ <Renderer
431
+ name={fragmentKey}
432
+ multiline={true}
433
+ defaultValue={initial}
434
+ {...(placeholder !== undefined ? { placeholder } : {})}
435
+ disabled={disabled}
436
+ onChange={handleChange}
437
+ onBlur={handleBlur}
438
+ className="w-full bg-transparent px-3 py-2 text-sm font-mono leading-relaxed outline-none disabled:opacity-50 whitespace-pre-wrap break-words"
439
+ />
440
+ </div>
441
+ ) : (
442
+ <>
443
+ <input type="hidden" name={hiddenInputName} value={text} readOnly />
444
+ <div
445
+ className="prose prose-sm dark:prose-invert max-w-none px-3 py-2"
446
+ style={wrapperStyle}
447
+ dangerouslySetInnerHTML={{ __html: previewHtml || '<p class="text-muted-foreground italic">Nothing to preview</p>' }}
448
+ />
449
+ </>
450
+ )}
451
+ </div>
452
+ )
453
+ }
454
+
425
455
  function stringValue(v: unknown): string {
426
456
  if (v === undefined || v === null) return ''
427
457
  if (typeof v === 'string') return v
@@ -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'
@@ -243,16 +244,14 @@ export function RepeaterInput({
243
244
  // it when present so peers see the same lifecycle events; absent =
244
245
  // today's local-only behaviour, unchanged.
245
246
  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.
247
+ // Mirror row identities into the form's values map so dotted row-leaf
248
+ // consumers can resolve the row's `__id` via `rowIdAtIndex(ctx.values,
249
+ // name, i)`. Setting a `__id` key routes through `routeBindingWrite`
250
+ // `parseRowFieldPath` which filters `__id` no-op on the binding side,
251
+ // so the only effect is a row entry landing in `valuesState`.
252
+ // `formStateForIds` mirrors `formState` below; we read via
253
+ // `useFormState()` here too instead of forward-referencing the later
254
+ // binding.
256
255
  const formStateForIds = useFormState()
257
256
  const ctxSetValue = formStateForIds?.setValue
258
257
  useEffect(() => {
@@ -749,16 +748,25 @@ function RepeaterRow({
749
748
  [row.children, prefix],
750
749
  )
751
750
  const headerLabel = row.itemLabel ?? `Item ${index + 1}`
751
+ // Row coords for dotted-path text leaves — composes fragment-key
752
+ // `${arrayName}.${rowId}.${fieldName}` for the Tiptap-backed collab
753
+ // renderer (see collab-row-text-tiptap-backed.md Phase 1).
754
+ const rowCoords = useMemo(
755
+ () => ({ arrayName: name, rowIndex: index, rowId: row.id }),
756
+ [name, index, row.id],
757
+ )
752
758
 
753
759
  // Hidden rows: render only the inputs (and __id) inside a display:none
754
760
  // wrapper so their values round-trip through FormData on submit. No
755
761
  // chrome, no drag wiring, no labels — `itemHidden` is purely UX.
756
762
  if (row.hidden) {
757
763
  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>
764
+ <RowCoordsContext.Provider value={rowCoords}>
765
+ <div style={{ display: 'none' }} data-pilotiq-repeater-row="hidden">
766
+ <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
767
+ <SchemaRenderer elements={namespaced} />
768
+ </div>
769
+ </RowCoordsContext.Provider>
762
770
  )
763
771
  }
764
772
 
@@ -795,6 +803,7 @@ function RepeaterRow({
795
803
  // wrapping in a class that hides the FieldShell's label slot.
796
804
  if (simple) {
797
805
  return (
806
+ <RowCoordsContext.Provider value={rowCoords}>
798
807
  <div
799
808
  className={`flex items-center gap-2 transition-opacity ${isDragging ? 'opacity-50' : ''}`}
800
809
  data-pilotiq-repeater-row="simple"
@@ -830,10 +839,12 @@ function RepeaterRow({
830
839
  />
831
840
  )}
832
841
  </div>
842
+ </RowCoordsContext.Provider>
833
843
  )
834
844
  }
835
845
 
836
846
  return (
847
+ <RowCoordsContext.Provider value={rowCoords}>
837
848
  <div
838
849
  className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
839
850
  data-pilotiq-repeater-row=""
@@ -910,6 +921,7 @@ function RepeaterRow({
910
921
  : <SchemaRenderer elements={namespaced} />}
911
922
  </div>
912
923
  </div>
924
+ </RowCoordsContext.Provider>
913
925
  )
914
926
  }
915
927
 
@@ -1168,6 +1180,10 @@ function RepeaterTableRow({
1168
1180
  () => row.children.map(c => prefixFieldNames(c, prefix)),
1169
1181
  [row.children, prefix],
1170
1182
  )
1183
+ const rowCoords = useMemo(
1184
+ () => ({ arrayName: name, rowIndex: index, rowId: row.id }),
1185
+ [name, index, row.id],
1186
+ )
1171
1187
 
1172
1188
  if (row.hidden) {
1173
1189
  // Render the hidden envelope as a single full-span cell so column
@@ -1175,11 +1191,18 @@ function RepeaterTableRow({
1175
1191
  // still in the form's submit. Using `<tr style="display:none">`
1176
1192
  // would warn under React strict-mode in Firefox; the wrapping cell
1177
1193
  // keeps the markup HTML-valid.
1194
+ //
1195
+ // The provider wraps the cell rather than the `<tr>` because React's
1196
+ // table-row whitelisting only accepts `<th>/<td>` children, not a
1197
+ // context provider; the provider is a no-DOM wrapper so it sits
1198
+ // inside the cell fine.
1178
1199
  return (
1179
1200
  <tr style={{ display: 'none' }} data-pilotiq-repeater-row="hidden">
1180
1201
  <td colSpan={columns.length + 1}>
1181
- <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
1182
- <SchemaRenderer elements={namespaced} />
1202
+ <RowCoordsContext.Provider value={rowCoords}>
1203
+ <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
1204
+ <SchemaRenderer elements={namespaced} />
1205
+ </RowCoordsContext.Provider>
1183
1206
  </td>
1184
1207
  </tr>
1185
1208
  )
@@ -1209,6 +1232,7 @@ function RepeaterTableRow({
1209
1232
  : {}
1210
1233
 
1211
1234
  return (
1235
+ <RowCoordsContext.Provider value={rowCoords}>
1212
1236
  <tr
1213
1237
  className={`border-t align-top ${isDragging ? 'opacity-50' : ''}`}
1214
1238
  data-pilotiq-repeater-row=""
@@ -1265,6 +1289,7 @@ function RepeaterTableRow({
1265
1289
  </div>
1266
1290
  </td>
1267
1291
  </tr>
1292
+ </RowCoordsContext.Provider>
1268
1293
  )
1269
1294
  }
1270
1295