@pilotiq/pilotiq 0.8.1 → 0.9.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 (74) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +209 -0
  3. package/dist/Resource.d.ts +39 -0
  4. package/dist/Resource.d.ts.map +1 -1
  5. package/dist/Resource.js +30 -0
  6. package/dist/Resource.js.map +1 -1
  7. package/dist/pageData/navigation.d.ts +17 -1
  8. package/dist/pageData/navigation.d.ts.map +1 -1
  9. package/dist/pageData/navigation.js +14 -0
  10. package/dist/pageData/navigation.js.map +1 -1
  11. package/dist/react/AppShell.d.ts +5 -0
  12. package/dist/react/AppShell.d.ts.map +1 -1
  13. package/dist/react/AppShell.js +1 -1
  14. package/dist/react/AppShell.js.map +1 -1
  15. package/dist/react/FieldFocusReporterRegistry.d.ts +29 -0
  16. package/dist/react/FieldFocusReporterRegistry.d.ts.map +1 -0
  17. package/dist/react/FieldFocusReporterRegistry.js +14 -0
  18. package/dist/react/FieldFocusReporterRegistry.js.map +1 -0
  19. package/dist/react/FieldPresenceRegistry.d.ts +38 -0
  20. package/dist/react/FieldPresenceRegistry.d.ts.map +1 -0
  21. package/dist/react/FieldPresenceRegistry.js +14 -0
  22. package/dist/react/FieldPresenceRegistry.js.map +1 -0
  23. package/dist/react/FormCollabBindingRegistry.d.ts +71 -1
  24. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  25. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  26. package/dist/react/FormStateContext.d.ts +17 -0
  27. package/dist/react/FormStateContext.d.ts.map +1 -1
  28. package/dist/react/FormStateContext.js +44 -3
  29. package/dist/react/FormStateContext.js.map +1 -1
  30. package/dist/react/RecordWrapperGate.d.ts +19 -6
  31. package/dist/react/RecordWrapperGate.d.ts.map +1 -1
  32. package/dist/react/RecordWrapperGate.js +18 -8
  33. package/dist/react/RecordWrapperGate.js.map +1 -1
  34. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  35. package/dist/react/fields/FieldShell.js +27 -3
  36. package/dist/react/fields/FieldShell.js.map +1 -1
  37. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  38. package/dist/react/fields/MarkdownInput.js +105 -3
  39. package/dist/react/fields/MarkdownInput.js.map +1 -1
  40. package/dist/react/fields/TextLikeInput.d.ts +10 -0
  41. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  42. package/dist/react/fields/TextLikeInput.js +179 -0
  43. package/dist/react/fields/TextLikeInput.js.map +1 -1
  44. package/dist/react/fields/textDelta.d.ts +44 -0
  45. package/dist/react/fields/textDelta.d.ts.map +1 -0
  46. package/dist/react/fields/textDelta.js +80 -0
  47. package/dist/react/fields/textDelta.js.map +1 -0
  48. package/dist/react/index.d.ts +4 -2
  49. package/dist/react/index.d.ts.map +1 -1
  50. package/dist/react/index.js +3 -1
  51. package/dist/react/index.js.map +1 -1
  52. package/dist/react/parseRecordEditUrl.d.ts +33 -9
  53. package/dist/react/parseRecordEditUrl.d.ts.map +1 -1
  54. package/dist/react/parseRecordEditUrl.js +40 -2
  55. package/dist/react/parseRecordEditUrl.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/Resource.test.ts +44 -0
  58. package/src/Resource.ts +58 -0
  59. package/src/pageData/navigation.ts +32 -1
  60. package/src/pageData.test.ts +36 -0
  61. package/src/react/AppShell.tsx +6 -1
  62. package/src/react/FieldFocusReporterRegistry.ts +37 -0
  63. package/src/react/FieldPresenceRegistry.ts +46 -0
  64. package/src/react/FormCollabBindingRegistry.ts +63 -1
  65. package/src/react/FormStateContext.tsx +62 -3
  66. package/src/react/RecordWrapperGate.tsx +26 -8
  67. package/src/react/fields/FieldShell.tsx +39 -2
  68. package/src/react/fields/MarkdownInput.tsx +100 -3
  69. package/src/react/fields/TextLikeInput.tsx +203 -1
  70. package/src/react/fields/textDelta.test.ts +141 -0
  71. package/src/react/fields/textDelta.ts +86 -0
  72. package/src/react/index.ts +20 -1
  73. package/src/react/parseRecordEditUrl.test.ts +48 -1
  74. package/src/react/parseRecordEditUrl.ts +52 -13
@@ -18,7 +18,11 @@ import {
18
18
  import { runJsHandler } from './fieldJsHandler.js'
19
19
  import { useToast } from './Toaster.js'
20
20
  import { useCollabRoom } from './CollabRoomContext.js'
21
- import { getFormCollabBinding, type FormCollabBinding } from './FormCollabBindingRegistry.js'
21
+ import {
22
+ getFormCollabBinding,
23
+ type FormCollabBinding,
24
+ type TextBinding,
25
+ } from './FormCollabBindingRegistry.js'
22
26
 
23
27
  export type FieldStatus = 'idle' | 'pending'
24
28
 
@@ -40,6 +44,13 @@ export interface FormStateApi {
40
44
  formMeta: ElementMeta
41
45
  inFlight: boolean
42
46
  fieldStatus: (name: string) => FieldStatus
47
+ /** Phase F.6 — per-field text-CRDT handles stashed at collab-room mount.
48
+ * `null` outside a room or before the binding effect has populated the
49
+ * map. The text/non-text allowlist lives in the binding impl —
50
+ * `FormStateProvider` asks for every top-level field and only stashes
51
+ * non-null answers, so a `Map.get()` hit means the binding has opted
52
+ * this field into the character-level path. */
53
+ textBindings: ReadonlyMap<string, TextBinding> | null
43
54
  }
44
55
 
45
56
  const FormStateContext = createContext<FormStateApi | null>(null)
@@ -93,6 +104,15 @@ export interface UseFieldStateResult {
93
104
  /** True while a live re-resolve POST is in flight for this field. */
94
105
  pending: boolean
95
106
  errors: string[]
107
+ /** Phase F.6 — character-level CRDT handle for text-shaped fields when
108
+ * a collab room is mounted up-tree AND the binding strategy applies
109
+ * (allowlist + `.collab() !== false`). Null in every other case —
110
+ * outside a `FormStateProvider`, outside a `<RecordCollabRoom>`, on
111
+ * non-text fields, on dotted-path inner-Repeater rows (deferred to
112
+ * F.5), and on text fields opted out via `.collab(false)`. Text input
113
+ * renderers branch on this: non-null → character-level path with
114
+ * `applyDelta + observe`; null → today's whole-string LWW path. */
115
+ textBinding: TextBinding | null
96
116
  }
97
117
 
98
118
  /** Per-field accessor. Inside a `FormStateProvider` it returns the controlled
@@ -108,6 +128,7 @@ export function useFieldState(name: string): UseFieldStateResult {
108
128
  triggerLive: () => {},
109
129
  pending: false,
110
130
  errors: [],
131
+ textBinding: null,
111
132
  }
112
133
  }
113
134
  // Dotted-path fields (inner Repeater rows) always render uncontrolled
@@ -120,6 +141,11 @@ export function useFieldState(name: string): UseFieldStateResult {
120
141
  triggerLive: (valueOverride?: unknown) => ctx.triggerLive(name, valueOverride),
121
142
  pending: ctx.fieldStatus(name) === 'pending',
122
143
  errors: ctx.errors[name] ?? [],
144
+ // Phase F.6 — dotted-path inner-Repeater rows skipped in v1 (deferred
145
+ // to F.5 alongside Y.Array row identity). Outside a collab room or
146
+ // for non-text fields, the stash returns null and the renderer falls
147
+ // back to today's whole-string LWW path.
148
+ textBinding: dotted ? null : (ctx.textBindings?.get(name) ?? null),
123
149
  }
124
150
  }
125
151
 
@@ -170,6 +196,13 @@ export function FormStateProvider({
170
196
  const [errors, setErrors] = useState<Record<string, string[]>>(initialErrors)
171
197
  const [pendingNames, setPendingNames] = useState<Set<string>>(() => new Set())
172
198
  const [inFlight, setInFlight] = useState(false)
199
+ // Phase F.6 — per-field text-CRDT stash. `null` until the collab effect
200
+ // populates it; stays `null` outside a collab room. Stored in state (not
201
+ // a ref) so consumers of `useFieldState` re-render once the bindings
202
+ // land. One extra render after collab-mount; acceptable since the
203
+ // existing `setValuesState` overlay below already triggers one when the
204
+ // room has pre-existing state.
205
+ const [textBindings, setTextBindings] = useState<ReadonlyMap<string, TextBinding> | null>(null)
173
206
 
174
207
  const { notify } = useToast()
175
208
 
@@ -215,7 +248,12 @@ export function FormStateProvider({
215
248
  useEffect(() => {
216
249
  if (!collabRoom || !bindingFactory || !formId) return
217
250
 
218
- const binding = bindingFactory({ room: collabRoom, formId, initial: valuesRef.current })
251
+ const binding = bindingFactory({
252
+ room: collabRoom,
253
+ formId,
254
+ initial: valuesRef.current,
255
+ formMeta: formMetaRef.current,
256
+ })
219
257
  bindingRef.current = binding
220
258
 
221
259
  // Lift any state already in the room (subsequent joiners — first
@@ -228,6 +266,25 @@ export function FormStateProvider({
228
266
  setValuesState((prev) => ({ ...prev, ...synced }))
229
267
  }
230
268
 
269
+ // Phase F.6 — ask the binding for a `TextBinding` on every top-level
270
+ // field name. The text/non-text allowlist lives in the binding impl,
271
+ // not in core: the binding returns `null` for non-text fields and
272
+ // text fields opted out via `.collab(false)`. `getTextBinding` is
273
+ // optional on the contract — F1-era plugins that haven't implemented
274
+ // it short-circuit the whole stash and every text field stays on the
275
+ // LWW path. We stash only the non-null answers. Cleanup is owned by
276
+ // `binding.destroy()` (expected to cascade into every issued
277
+ // `TextBinding`).
278
+ if (binding.getTextBinding) {
279
+ const textStash = new Map<string, TextBinding>()
280
+ for (const fieldName of Object.keys(valuesRef.current)) {
281
+ if (fieldName.includes('.')) continue
282
+ const tb = binding.getTextBinding(fieldName)
283
+ if (tb) textStash.set(fieldName, tb)
284
+ }
285
+ if (textStash.size > 0) setTextBindings(textStash)
286
+ }
287
+
231
288
  // Subscribe to remote changes. Local writes ALSO trigger this
232
289
  // (Yjs observers fire on local transactions too) — the per-key
233
290
  // Object.is short-circuit below collapses them into no-op renders.
@@ -249,6 +306,7 @@ export function FormStateProvider({
249
306
  unsubscribe()
250
307
  binding.destroy()
251
308
  bindingRef.current = null
309
+ setTextBindings(null)
252
310
  }
253
311
  // `valuesRef.current` is intentionally read once at mount — initial
254
312
  // values seed the binding; subsequent edits flow through `setValue`
@@ -479,7 +537,8 @@ export function FormStateProvider({
479
537
  formMeta,
480
538
  inFlight,
481
539
  fieldStatus,
482
- }), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus])
540
+ textBindings,
541
+ }), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings])
483
542
 
484
543
  return (
485
544
  <FormStateContext.Provider value={api}>
@@ -1,37 +1,55 @@
1
1
  import { type ReactNode } from 'react'
2
2
  import { getRecordWrapper } from './RecordWrapperRegistry.js'
3
- import { parseRecordEditUrl } from './parseRecordEditUrl.js'
3
+ import { parseRecordPageUrl } from './parseRecordEditUrl.js'
4
+ import type { ResourceCollabConfig } from '../Resource.js'
5
+
6
+ /** Per-resource collab opt-in keyed by URL slug (`R.getSlug()` for
7
+ * non-clustered, `${cluster.slug}/${R.getSlug()}` for clustered). Built
8
+ * server-side by `panelInfo()` as `recordCollab`. */
9
+ export type RecordCollabMap = Record<string, ResourceCollabConfig>
4
10
 
5
11
  export interface RecordWrapperGateProps {
6
12
  currentPath?: string
7
13
  basePath: string
14
+ /** Resource opt-in map. Absent means no resource opted in (or the
15
+ * panel has no resources) — gate always passes through. */
16
+ recordCollab?: RecordCollabMap
8
17
  children: ReactNode
9
18
  }
10
19
 
11
20
  /**
12
21
  * Conditionally wraps the page tree with the plugin-registered
13
- * `RecordWrapper` when the current URL resolves to a record-bound edit
14
- * page. Pass-through in every other case:
22
+ * `RecordWrapper` when the current URL resolves to a record-bound page
23
+ * AND the underlying resource has opted into collab on that page role.
24
+ * Pass-through in every other case:
15
25
  *
16
26
  * - no plugin registered a wrapper (`getRecordWrapper() === null`)
17
- * - the URL isn't a record-edit URL (list / create / view / dashboard / …)
27
+ * - the URL isn't a record edit/view page
18
28
  * - `currentPath` not yet known on the very first SSR render
29
+ * - the resource has not opted in via `static collab` (or has opted in
30
+ * but excluded the current page role)
19
31
  *
20
32
  * Mounted once inside `AppShell` around the page content area so
21
33
  * record-scoped plugins (collab room, audit trail, …) get one
22
34
  * lifetimed mount per record-view-or-edit without each plugin having
23
35
  * to thread URL parsing into its own provider.
24
36
  *
25
- * Scope limited to `/edit` URLs in v1; view-page support (read-only
26
- * collab cursors on `ViewPage`) is a follow-up.
37
+ * v1 caveat: nested-relation edit URLs (`/articles/:parentId/comments/:childId/edit`)
38
+ * have a dynamic-id segment baked into the URL slug, so they don't match
39
+ * the resource-keyed `recordCollab` map and fall through to no-collab.
40
+ * Collab on nested-relation edits is a follow-up.
27
41
  */
28
- export function RecordWrapperGate({ currentPath, basePath, children }: RecordWrapperGateProps) {
42
+ export function RecordWrapperGate({ currentPath, basePath, recordCollab, children }: RecordWrapperGateProps) {
29
43
  const Wrapper = getRecordWrapper()
30
44
  if (!Wrapper || !currentPath) return <>{children}</>
31
45
 
32
- const identity = parseRecordEditUrl(currentPath, basePath)
46
+ const identity = parseRecordPageUrl(currentPath, basePath)
33
47
  if (!identity) return <>{children}</>
34
48
 
49
+ const cfg = recordCollab?.[identity.resourceSlug]
50
+ if (!cfg) return <>{children}</>
51
+ if (!cfg.pages.includes(identity.role)) return <>{children}</>
52
+
35
53
  return (
36
54
  <Wrapper resourceSlug={identity.resourceSlug} recordId={identity.recordId}>
37
55
  {children}
@@ -5,6 +5,8 @@ import { usePendingSuggestions, usePendingSuggestionsForField, type PendingSugge
5
5
  import { getPendingSuggestionOverlay } from '../PendingSuggestionOverlayRegistry.js'
6
6
  import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
7
7
  import { FormIdContext, useFieldState } from '../FormStateContext.js'
8
+ import { getFieldPresenceComponent } from '../FieldPresenceRegistry.js'
9
+ import { getFieldFocusReporter } from '../FieldFocusReporterRegistry.js'
8
10
 
9
11
  /**
10
12
  * Field types whose visible state is driven by React (not by a matching
@@ -140,10 +142,25 @@ export function FieldShell({ el, name, label, required, children, before, after,
140
142
  const labelClass = hiddenLabel
141
143
  ? 'sr-only'
142
144
  : 'text-sm font-medium leading-none'
145
+ // Phase F4 — presence chip + focus reporter slots. The chip mounts
146
+ // alongside the label so remote-focus indicators don't shift the
147
+ // input geometry; the focus reporter sits on the outer wrapper using
148
+ // capture-phase listeners so any inner-input focus event flows
149
+ // through. Both slots are gated on `meta.collab !== false` (Q3 from
150
+ // the F-plan — opted-out fields are fully invisible to the collab
151
+ // layer) AND on the field having a stable top-level name (dotted-path
152
+ // Repeater rows skip presence in v1 — Phase F.5).
153
+ const collabOptedOut = (el as { collab?: boolean })['collab'] === false
154
+ const dottedName = name.includes('.')
155
+ const presenceSlotEligible = !collabOptedOut && !dottedName
156
+ const PresenceChip = presenceSlotEligible ? getFieldPresenceComponent() : null
157
+ const focusReporter = presenceSlotEligible ? getFieldFocusReporter() : null
158
+
143
159
  const labelEl = label !== '' ? (
144
160
  <label htmlFor={name} className={labelClass}>
145
161
  {label}{required && <span className="text-destructive ml-0.5">*</span>}
146
162
  {labelSlot}
163
+ {PresenceChip && <PresenceChip fieldName={name} formId={formId ?? ''} />}
147
164
  </label>
148
165
  ) : null
149
166
 
@@ -177,9 +194,24 @@ export function FieldShell({ el, name, label, required, children, before, after,
177
194
  </>
178
195
  )
179
196
 
197
+ // Capture-phase focus / blur dispatch — runs even when the inner
198
+ // input is wrapped in custom NodeViews (Select / Date / Slider). One
199
+ // wrapper-level handler covers every controlled input in the tree.
200
+ const onFocusCapture = focusReporter
201
+ ? () => focusReporter.onFocus({ fieldName: name, formId: formId ?? '' })
202
+ : undefined
203
+ const onBlurCapture = focusReporter
204
+ ? () => focusReporter.onBlur({ fieldName: name, formId: formId ?? '' })
205
+ : undefined
206
+
180
207
  if (inline) {
181
208
  return (
182
- <div className="flex items-baseline gap-3" {...wrapperAttrs}>
209
+ <div
210
+ className="flex items-baseline gap-3"
211
+ {...wrapperAttrs}
212
+ {...(onFocusCapture ? { onFocusCapture } : {})}
213
+ {...(onBlurCapture ? { onBlurCapture } : {})}
214
+ >
183
215
  {labelEl && <div className="min-w-32 pt-2">{labelEl}</div>}
184
216
  <div className="min-w-0 flex-1">
185
217
  {inputBlock}
@@ -192,7 +224,12 @@ export function FieldShell({ el, name, label, required, children, before, after,
192
224
  }
193
225
 
194
226
  return (
195
- <div className="flex flex-col gap-1.5" {...wrapperAttrs}>
227
+ <div
228
+ className="flex flex-col gap-1.5"
229
+ {...wrapperAttrs}
230
+ {...(onFocusCapture ? { onFocusCapture } : {})}
231
+ {...(onBlurCapture ? { onBlurCapture } : {})}
232
+ >
196
233
  {labelEl}
197
234
  {inputBlock}
198
235
  {helperText && (
@@ -1,4 +1,4 @@
1
- import React, { useMemo, useRef, useState } from 'react'
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { marked } from 'marked'
3
3
  import {
4
4
  BoldIcon, ItalicIcon, StrikethroughIcon, LinkIcon,
@@ -8,6 +8,7 @@ import {
8
8
  import { useFieldState } from '../FormStateContext.js'
9
9
  import { useToast } from '../Toaster.js'
10
10
  import { Button } from '../ui/button.js'
11
+ import { computeDelta, preserveCursor } from './textDelta.js'
11
12
 
12
13
  type ToolbarButton =
13
14
  | 'bold' | 'italic' | 'strike' | 'link'
@@ -47,14 +48,90 @@ export function MarkdownInput({
47
48
  const fs = useFieldState(name)
48
49
  const { notify } = useToast()
49
50
  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)
50
56
 
51
57
  const initial = useMemo(() => stringValue(defaultValue), [])
52
58
  const [localValue, setLocalValue] = useState<string>(initial)
53
59
  const [tab, setTab] = useState<'write' | 'preview'>('write')
54
60
  const [busy, setBusy] = useState(false)
55
61
 
56
- const value = fs.controlled ? stringValue(fs.value) : localValue
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)
115
+
57
116
  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
+ }
58
135
  if (fs.controlled) { fs.setValue(next); fs.triggerLive(next) }
59
136
  else { setLocalValue(next); fs.triggerLive(next) }
60
137
  }
@@ -285,7 +362,27 @@ export function MarkdownInput({
285
362
  placeholder={placeholder}
286
363
  disabled={disabled}
287
364
  {...(fs.controlled
288
- ? { value, onChange: (e) => setValue(e.target.value) }
365
+ ? {
366
+ 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
+ } : {}),
385
+ }
289
386
  : { defaultValue: initial, onChange: (e) => setLocalValue(e.target.value) })}
290
387
  onPaste={onPaste}
291
388
  onKeyDown={onKeyDown}
@@ -1,8 +1,10 @@
1
- import React from 'react'
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { ElementMeta } from '../../schema/Element.js'
3
+ import type { TextBinding } from '../FormCollabBindingRegistry.js'
3
4
  import { useFieldState } from '../FormStateContext.js'
4
5
  import { Input } from '../ui/input.js'
5
6
  import { Textarea } from '../ui/textarea.js'
7
+ import { computeDelta, preserveCursor } from './textDelta.js'
6
8
 
7
9
  /**
8
10
  * Bridge between controlled (FormStateProvider) and uncontrolled
@@ -10,6 +12,16 @@ import { Textarea } from '../ui/textarea.js'
10
12
  * `live()` fields, the input is bound to the context's values map and
11
13
  * fires the live trigger on change/blur according to the field's `live`
12
14
  * config. Outside a controlled form, falls back to plain `defaultValue`.
15
+ *
16
+ * **Phase F.6 — character-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).
13
25
  */
14
26
  export function TextLikeInput({
15
27
  el, name, common, type, extraProps, multiline, applyMask,
@@ -33,6 +45,28 @@ export function TextLikeInput({
33
45
  const onBlurMode = liveOpts.onBlur === true
34
46
  const mask = applyMask ?? identity
35
47
 
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.
54
+ const hasMask = typeof el['mask'] === 'string'
55
+ if (fs.textBinding && !hasMask) {
56
+ return (
57
+ <BoundTextInput
58
+ binding={fs.textBinding}
59
+ name={name}
60
+ triggerLive={fs.triggerLive}
61
+ onBlurMode={onBlurMode}
62
+ common={common}
63
+ extraProps={extraProps}
64
+ type={type}
65
+ multiline={multiline}
66
+ />
67
+ )
68
+ }
69
+
36
70
  if (fs.controlled) {
37
71
  const ctxValue = fs.value !== undefined && fs.value !== null ? String(fs.value) : ''
38
72
  const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
@@ -71,4 +105,172 @@ export function TextLikeInput({
71
105
  return <Input {...(common as React.ComponentProps<typeof Input>)} type={type} {...extraProps} />
72
106
  }
73
107
 
108
+ /**
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.
114
+ *
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).
130
+ */
131
+ function BoundTextInput({
132
+ binding, name, triggerLive, onBlurMode, common, extraProps, type, multiline,
133
+ }: {
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
142
+ }): 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} />
268
+ }
269
+
74
270
  function identity(v: string): string { return v }
271
+
272
+ function stringValue(v: unknown): string {
273
+ if (v === undefined || v === null) return ''
274
+ if (typeof v === 'string') return v
275
+ return String(v)
276
+ }