@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
@@ -1,10 +1,12 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
2
  import type { ElementMeta } from '../../schema/Element.js'
3
- import type { TextBinding } from '../FormCollabBindingRegistry.js'
4
3
  import { useFieldState } from '../FormStateContext.js'
4
+ import { useCollabRoom } from '../CollabRoomContext.js'
5
+ import { getCollabTextRenderer, type CollabTextRenderer } from '../CollabTextRendererRegistry.js'
6
+ import { useRowCoords } from '../RowCoordsContext.js'
7
+ import { parseRowFieldPath } from '../formStateHelpers.js'
5
8
  import { Input } from '../ui/input.js'
6
9
  import { Textarea } from '../ui/textarea.js'
7
- import { computeDelta, preserveCursor } from './textDelta.js'
8
10
 
9
11
  /**
10
12
  * Bridge between controlled (FormStateProvider) and uncontrolled
@@ -13,15 +15,17 @@ import { computeDelta, preserveCursor } from './textDelta.js'
13
15
  * fires the live trigger on change/blur according to the field's `live`
14
16
  * config. Outside a controlled form, falls back to plain `defaultValue`.
15
17
  *
16
- * **Phase F.6character-level CRDT branch.** When a `<RecordCollabRoom>`
17
- * is mounted up-tree AND `@pilotiq-pro/collab`'s binding registered a
18
- * `TextBinding` for this field (text-shaped fieldType + `.collab() !== false`),
19
- * the input takes the `BoundTextInput` path: edits emit `TextDelta`s to
20
- * the binding's `Y.Text`, remote changes flow back via `observe`, and
21
- * cursor position survives both. The legacy whole-string LWW path
22
- * still runs for non-text fields, non-collab forms, and masked inputs
23
- * (mask + character-level CRDT is incompatible peers would see raw
24
- * keystrokes desynced from the rendered mask).
18
+ * **Collab branchTiptap-backed `Y.XmlFragment`.** When a
19
+ * `<RecordCollabRoom>` is mounted up-tree AND `@pilotiq/tiptap`'s
20
+ * `registerTiptap()` registered a collab text renderer, the input
21
+ * mounts the Tiptap-backed editor against a `Y.XmlFragment` keyed by
22
+ * either the bare field name (top-level) or
23
+ * `${arrayName}.${rowId}.${fieldName}` (Repeater / Builder row leaves
24
+ * via `useRowCoords()`). Selections anchor to `Y.RelativePosition` via
25
+ * y-prosemirror, so cursors survive both mid-word remote edits and
26
+ * concurrent inserts. Masked fields fall through to the legacy
27
+ * whole-string LWW path (mask + character-level CRDT is incompatible
28
+ * — peers would see raw keystrokes desynced from the rendered mask).
25
29
  */
26
30
  export function TextLikeInput({
27
31
  el, name, common, type, extraProps, multiline, applyMask,
@@ -38,6 +42,9 @@ export function TextLikeInput({
38
42
  applyMask?: (value: string) => string
39
43
  }): React.ReactElement {
40
44
  const fs = useFieldState(name)
45
+ const room = useCollabRoom()
46
+ const collabRenderer = getCollabTextRenderer()
47
+ const rowCoords = useRowCoords()
41
48
  const liveCfg = el['live']
42
49
  const liveOpts = (typeof liveCfg === 'object' && liveCfg !== null
43
50
  ? liveCfg as { onBlur?: boolean; debounce?: number }
@@ -45,24 +52,56 @@ export function TextLikeInput({
45
52
  const onBlurMode = liveOpts.onBlur === true
46
53
  const mask = applyMask ?? identity
47
54
 
48
- // Phase F.6 character-level CRDT path. Masking is mutually exclusive
49
- // with character-level CRDT (peers would see raw keystrokes diverged
50
- // from the local mask render); masked fields fall through to LWW.
51
- // We read the mask from the field meta directly — `applyMask` is a
52
- // `useCallback`-wrapped fn that's *always* defined (identity when no
53
- // mask), so its truthiness can't gate the branch.
55
+ // Masking is mutually exclusive with character-level CRDT (peers would
56
+ // see raw keystrokes diverged from the local mask render); masked
57
+ // fields fall through to LWW. We read the mask from the field meta
58
+ // directly — `applyMask` is a `useCallback`-wrapped fn that's *always*
59
+ // defined (identity when no mask), so its truthiness can't gate the
60
+ // branch.
54
61
  const hasMask = typeof el['mask'] === 'string'
55
- if (fs.textBinding && !hasMask) {
62
+
63
+ // Collab branch — Tiptap-backed plain-text editor. Top-level fields
64
+ // use the bare `name` as the fragment-key; Repeater / Builder row
65
+ // leaves compose `${arrayName}.${rowId}.${fieldName}` from
66
+ // `useRowCoords()` so the Y.XmlFragment survives row reorders (keyed
67
+ // by the stable rowId, not the array index). The hidden FormData
68
+ // input keeps the original dotted path so submission lands on the
69
+ // server at the right slot.
70
+ //
71
+ // Dotted paths that don't match a row shape (no rowCoords OR
72
+ // `parseRowFieldPath` returns null — nested row arrays, malformed
73
+ // names) skip the collab path and fall through to the controlled /
74
+ // uncontrolled branches below.
75
+ const fieldCollab = el['collab'] as boolean | undefined
76
+ const fragmentKey: string | null = (() => {
77
+ if (!name.includes('.')) return name
78
+ if (!rowCoords) return null
79
+ const parsed = parseRowFieldPath(name)
80
+ if (!parsed) return null
81
+ if (parsed.arrayName !== rowCoords.arrayName) return null
82
+ if (parsed.index !== rowCoords.rowIndex) return null
83
+ return `${rowCoords.arrayName}.${rowCoords.rowId}.${parsed.fieldName}`
84
+ })()
85
+ if (
86
+ room &&
87
+ collabRenderer &&
88
+ fieldCollab !== false &&
89
+ !hasMask &&
90
+ fragmentKey !== null
91
+ ) {
56
92
  return (
57
- <BoundTextInput
58
- binding={fs.textBinding}
59
- name={name}
93
+ <CollabTextField
94
+ Renderer={collabRenderer}
95
+ fragmentKey={fragmentKey}
96
+ hiddenInputName={name}
97
+ multiline={multiline}
98
+ defaultValue={stringValue(common['defaultValue'])}
99
+ {...(common['placeholder'] !== undefined ? { placeholder: String(common['placeholder']) } : {})}
100
+ disabled={Boolean(common['disabled'])}
60
101
  triggerLive={fs.triggerLive}
102
+ setValue={fs.setValue}
103
+ controlled={fs.controlled}
61
104
  onBlurMode={onBlurMode}
62
- common={common}
63
- extraProps={extraProps}
64
- type={type}
65
- multiline={multiline}
66
105
  />
67
106
  )
68
107
  }
@@ -106,165 +145,92 @@ export function TextLikeInput({
106
145
  }
107
146
 
108
147
  /**
109
- * Phase F.6 CRDT-bound text input. Owns its own controlled state
110
- * because the binding's `Y.Text` is the source of truth (not the
111
- * form's `values` map). Mirrors every committed value back into the
112
- * form context via `fs.setValue` so submission / live re-resolve see
113
- * the latest string.
148
+ * Wrapper around the registered Tiptap-backed collab editor.
149
+ * Owns the local text mirror so the hidden `<input>` always carries the
150
+ * editor's current value for FormData submission. When `FormStateProvider`
151
+ * is mounted up-tree, also mirrors every update into the values map via
152
+ * `fs.setValue` so `$get/$set` computations and any Y.Map LWW path (kept
153
+ * for non-text consumers) stay in sync.
154
+ *
155
+ * No IME / cursor-preservation gymnastics in here — the underlying Tiptap
156
+ * editor handles composition natively and y-prosemirror anchors selections
157
+ * to `Yjs.RelativePosition`, so the cursor survives concurrent + mid-word
158
+ * remote edits without any client-side bookkeeping.
114
159
  *
115
- * Lifecycle:
116
- * - Mount: seed local state from `binding.read()`; mirror it into
117
- * the form's `values` map.
118
- * - Local edit: compute a `TextDelta` (insert / delete / replace)
119
- * from the before/after strings and `applyDelta` to the binding.
120
- * Eagerly update local state in the same React render so the
121
- * controlled input doesn't lag the keystroke.
122
- * - Remote edit: `binding.observe` fires with the post-change
123
- * string; we replace local state and best-effort preserve the
124
- * local cursor via `preserveCursor`. The local-echo of our own
125
- * `applyDelta` is collapsed by the value-equality check.
126
- * - IME composition: `applyDelta` is deferred to `compositionend`
127
- * so the binding never sees intermediate composing chars (which
128
- * would emit one delta per keystroke and confuse downstream
129
- * observers).
160
+ * `fragmentKey` and `hiddenInputName` diverge for row-text leaves (Phase
161
+ * 1 of collab-row-text-tiptap-backed.md): the renderer's Y.XmlFragment is
162
+ * keyed by `${arrayName}.${rowId}.${fieldName}` so it survives row
163
+ * reorders, while the hidden FormData input keeps the dotted path
164
+ * (`items.0.title`) so submission lands at the right server-side slot.
165
+ * For top-level fields the two are identical.
130
166
  */
131
- function BoundTextInput({
132
- binding, name, triggerLive, onBlurMode, common, extraProps, type, multiline,
167
+ function CollabTextField({
168
+ Renderer, fragmentKey, hiddenInputName, multiline, defaultValue, placeholder, disabled,
169
+ triggerLive, setValue, controlled, onBlurMode,
133
170
  }: {
134
- binding: TextBinding
135
- name: string
136
- triggerLive: (valueOverride?: unknown) => void
137
- onBlurMode: boolean
138
- common: Record<string, unknown>
139
- extraProps: Record<string, unknown>
140
- type: string
141
- multiline: boolean
171
+ Renderer: CollabTextRenderer
172
+ fragmentKey: string
173
+ hiddenInputName: string
174
+ multiline: boolean
175
+ defaultValue: string
176
+ placeholder?: string
177
+ disabled: boolean
178
+ triggerLive: (valueOverride?: unknown) => void
179
+ setValue: (v: unknown) => void
180
+ controlled: boolean
181
+ onBlurMode: boolean
142
182
  }): React.ReactElement {
143
- const fs = useFieldState(name)
144
- // SSR-rendered default. Captured once at mount; used as display
145
- // fallback while the room's `Y.Text` is still empty (the seed race
146
- // for Y.Text isn't safe across concurrent first-mounters, so no peer
147
- // populates it client-side see `@pilotiq-pro/collab` for the
148
- // rationale). First user edit emits a replace-from-empty delta that
149
- // atomically lifts the displayed value into the CRDT.
150
- // eslint-disable-next-line react-hooks/exhaustive-deps
151
- const fallback = useMemo(() => stringValue(fs.value), [])
152
- const [value, setValueLocal] = useState<string>(() => binding.read() || fallback)
153
- const valueRef = useRef<string>(value)
154
- const isComposing = useRef<boolean>(false)
155
- const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
156
-
157
- useEffect(() => { valueRef.current = value }, [value])
158
-
159
- // Stable ref to the form-mirror writer so the observer effect below
160
- // doesn't tear down on every render (fs.setValue is a fresh arrow on
161
- // every useFieldState call).
162
- const mirrorRef = useRef<(v: string) => void>(() => {})
163
- useEffect(() => {
164
- mirrorRef.current = (v: string): void => { fs.setValue(v) }
165
- })
166
-
167
- // On mount / binding swap: read the binding's current state. If
168
- // non-empty (i.e. someone else has already typed), display it and
169
- // mirror into the form values map. If empty, leave the fallback
170
- // showing no client-side seed (see file-header comment).
171
- useEffect(() => {
172
- const initial = binding.read()
173
- if (initial.length > 0) {
174
- setValueLocal(initial)
175
- valueRef.current = initial
176
- mirrorRef.current(initial)
177
- }
178
- }, [binding])
179
-
180
- // Subscribe to text-CRDT changes. Yjs fires this for BOTH local and
181
- // remote transactions local echoes are collapsed by the
182
- // `next === prev` guard.
183
- useEffect(() => {
184
- const unsubscribe = binding.observe((next) => {
185
- const prev = valueRef.current
186
- if (next === prev) return
187
- const el = inputRef.current
188
- const cursor = el?.selectionStart ?? next.length
189
- const restored = preserveCursor(prev, next, cursor)
190
- setValueLocal(next)
191
- valueRef.current = next
192
- mirrorRef.current(next)
193
- // Defer cursor restore until after React commits. Only reapply
194
- // when the input is still focused — yanking the selection on a
195
- // blurred field would steal focus across the page.
196
- requestAnimationFrame(() => {
197
- if (!el) return
198
- if (document.activeElement !== el) return
199
- try { el.setSelectionRange(restored, restored) } catch { /* setSelectionRange unsupported on some input types — defensive */ }
200
- })
201
- })
202
- return unsubscribe
203
- }, [binding])
204
-
205
- const commitDelta = useCallback((after: string): void => {
206
- // Compute the delta against the binding's *current* Y.Text contents
207
- // — not the renderer's `before` ref. The two can diverge in three
208
- // cases that all converge correctly under this approach:
209
- // 1. First edit when Y.Text is empty: delta = `insert@0 <whole>`,
210
- // which atomically lifts the displayed fallback into the CRDT
211
- // without a separate seed op.
212
- // 2. After a remote-applied update: Y.Text holds the peer's value;
213
- // computing against it avoids "ghost" deltas that re-emit ops
214
- // against a stale local ref.
215
- // 3. After a server-resolve `triggerLive` replace: same as (2).
216
- const before = binding.read()
217
- if (after === before) return
218
- const delta = computeDelta(before, after)
219
- if (!delta) return
220
- binding.applyDelta(delta)
221
- // Eager local + form-map update so the controlled input doesn't
222
- // wait on the observer echo to render the new keystroke. Observer
223
- // will fire with the same string and short-circuit via the equality
224
- // check above.
225
- setValueLocal(after)
226
- valueRef.current = after
227
- mirrorRef.current(after)
228
- if (!onBlurMode) triggerLive(after)
229
- }, [binding, onBlurMode, triggerLive])
230
-
231
- const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
232
- if (isComposing.current) {
233
- // IME mid-composition — paint locally, hold the delta until commit.
234
- setValueLocal(e.target.value)
235
- return
236
- }
237
- commitDelta(e.target.value)
238
- }
239
-
240
- const onCompositionStart = (): void => { isComposing.current = true }
241
- const onCompositionEnd = (e: React.CompositionEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
242
- isComposing.current = false
243
- commitDelta(e.currentTarget.value)
244
- }
245
-
246
- const onBlur = (): void => {
247
- if (onBlurMode) triggerLive(valueRef.current)
248
- }
249
-
250
- const setRef = (el: HTMLInputElement | HTMLTextAreaElement | null): void => {
251
- inputRef.current = el
252
- }
253
-
254
- const props = {
255
- ...common,
256
- ...extraProps,
257
- defaultValue: undefined,
258
- value,
259
- onChange,
260
- onBlur,
261
- onCompositionStart,
262
- onCompositionEnd,
263
- ref: setRef,
264
- }
265
-
266
- if (multiline) return <Textarea {...(props as React.ComponentProps<typeof Textarea>)} />
267
- return <Input {...(props as React.ComponentProps<typeof Input>)} type={type} />
183
+ const [text, setText] = useState<string>(defaultValue)
184
+ const textRef = useRef(text)
185
+ useEffect(() => { textRef.current = text }, [text])
186
+
187
+ const handleChange = useCallback((next: string): void => {
188
+ setText(next)
189
+ if (controlled) setValue(next)
190
+ if (!onBlurMode) triggerLive(next)
191
+ }, [controlled, onBlurMode, setValue, triggerLive])
192
+
193
+ const handleBlur = useCallback((): void => {
194
+ if (onBlurMode) triggerLive(textRef.current)
195
+ }, [onBlurMode, triggerLive])
196
+
197
+ // Match the visual chrome of `<Input>` / `<Textarea>` so the editor reads
198
+ // as a drop-in replacement. The adapter forwards this class to its
199
+ // contenteditable wrapper; `whitespace-nowrap` on the single-line variant
200
+ // keeps the editor from wrapping into a second line if a stray paragraph
201
+ // split somehow makes it through.
202
+ //
203
+ // `overflow-x-clip` (not `auto`) on the single-line variant matters for
204
+ // `CollaborationCaret` presence labels: per the CSS overflow spec, setting
205
+ // either axis to a non-visible / non-clip value (`auto` / `scroll` /
206
+ // `hidden`) forces the other axis to compute as `auto` too — so
207
+ // `overflow-x-auto` would clip the caret's user-name label, which renders
208
+ // `-1.4em` above the line. `clip` is the one non-visible value that does
209
+ // NOT force the other axis, so `overflow-y` stays `visible` and the label
210
+ // escapes the chrome upward as designed. Trade-off: long text gets clipped
211
+ // on the right rather than horizontally scrollable (native `<input>`
212
+ // semantics) acceptable for plain-text fields, where typing past the
213
+ // visible width is rare and the caret presence label is the higher-value
214
+ // affordance.
215
+ const className = multiline
216
+ ? 'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm whitespace-pre-wrap break-words'
217
+ : 'flex h-9 w-full items-center rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm whitespace-nowrap overflow-x-clip'
218
+
219
+ return (
220
+ <>
221
+ <input type="hidden" name={hiddenInputName} value={text} />
222
+ <Renderer
223
+ name={fragmentKey}
224
+ multiline={multiline}
225
+ defaultValue={defaultValue}
226
+ {...(placeholder !== undefined ? { placeholder } : {})}
227
+ disabled={disabled}
228
+ onChange={handleChange}
229
+ onBlur={handleBlur}
230
+ className={className}
231
+ />
232
+ </>
233
+ )
268
234
  }
269
235
 
270
236
  function identity(v: string): string { return v }
@@ -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
- }
@@ -43,14 +43,18 @@ export {
43
43
  type CollabExtensionFactory,
44
44
  type CollabExtensionFactoryArgs,
45
45
  } from './CollabExtensionFactoryRegistry.js'
46
+ export {
47
+ registerCollabTextRenderer,
48
+ getCollabTextRenderer,
49
+ type CollabTextRenderer,
50
+ type CollabTextRendererProps,
51
+ } from './CollabTextRendererRegistry.js'
46
52
  export {
47
53
  registerFormCollabBinding,
48
54
  getFormCollabBinding,
49
55
  type FormCollabBinding,
50
56
  type FormCollabBindingFactory,
51
57
  type FormCollabBindingFactoryArgs,
52
- type TextBinding,
53
- type TextDelta,
54
58
  type RowsEvent,
55
59
  type RowBindingApi,
56
60
  } from './FormCollabBindingRegistry.js'
@@ -139,6 +143,12 @@ export {
139
143
  type UseResizableWidthApi,
140
144
  } from './useResizableWidth.js'
141
145
 
146
+ export {
147
+ CurrentUserProvider,
148
+ useCurrentUser,
149
+ type CurrentUser,
150
+ } from './CurrentUserContext.js'
151
+
142
152
  export { ThemeProvider, useTheme } from './ThemeProvider.js'
143
153
  export { ThemeToggle } from './ThemeToggle.js'
144
154
  export { ThemeSettingsPage } from './ThemeSettingsPage.js'
@@ -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"}