@pilotiq/pilotiq 0.10.0 → 0.12.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 (54) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +128 -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 +161 -17
  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 +39 -1
  19. package/dist/react/FormStateContext.d.ts.map +1 -1
  20. package/dist/react/FormStateContext.js +126 -33
  21. package/dist/react/FormStateContext.js.map +1 -1
  22. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  23. package/dist/react/fields/BuilderInput.js +112 -10
  24. package/dist/react/fields/BuilderInput.js.map +1 -1
  25. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  26. package/dist/react/fields/MarkdownInput.js +60 -1
  27. package/dist/react/fields/MarkdownInput.js.map +1 -1
  28. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  29. package/dist/react/fields/RepeaterInput.js +113 -10
  30. package/dist/react/fields/RepeaterInput.js.map +1 -1
  31. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  32. package/dist/react/fields/TextLikeInput.js +83 -6
  33. package/dist/react/fields/TextLikeInput.js.map +1 -1
  34. package/dist/react/formStateHelpers.d.ts +102 -0
  35. package/dist/react/formStateHelpers.d.ts.map +1 -1
  36. package/dist/react/formStateHelpers.js +234 -0
  37. package/dist/react/formStateHelpers.js.map +1 -1
  38. package/dist/react/index.d.ts +4 -2
  39. package/dist/react/index.d.ts.map +1 -1
  40. package/dist/react/index.js +3 -1
  41. package/dist/react/index.js.map +1 -1
  42. package/package.json +5 -5
  43. package/src/react/AppShell.tsx +11 -1
  44. package/src/react/CollabTextRendererRegistry.ts +84 -0
  45. package/src/react/CurrentUserContext.tsx +50 -0
  46. package/src/react/FormCollabBindingRegistry.ts +160 -17
  47. package/src/react/FormStateContext.tsx +157 -34
  48. package/src/react/fields/BuilderInput.tsx +97 -8
  49. package/src/react/fields/MarkdownInput.tsx +118 -1
  50. package/src/react/fields/RepeaterInput.tsx +97 -8
  51. package/src/react/fields/TextLikeInput.tsx +129 -5
  52. package/src/react/formStateHelpers.test.ts +312 -0
  53. package/src/react/formStateHelpers.ts +246 -0
  54. package/src/react/index.ts +15 -0
@@ -6,6 +6,8 @@ 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'
9
11
  import { useToast } from '../Toaster.js'
10
12
  import { Button } from '../ui/button.js'
11
13
  import { computeDelta, preserveCursor } from './textDelta.js'
@@ -46,6 +48,35 @@ export function MarkdownInput({
46
48
  uploadUrl: string | undefined
47
49
  }): React.ReactElement {
48
50
  const fs = useFieldState(name)
51
+ const room = useCollabRoom()
52
+ const collabRenderer = getCollabTextRenderer()
53
+
54
+ // Phase B follow-up — Tiptap-backed plain-text editor for markdown source
55
+ // when collab is on. Same architectural fix as `TextLikeInput`'s
56
+ // CollabTextField: y-prosemirror's `RelativePosition` cursor anchoring
57
+ // replaces the broken `Y.Text` + `computeDelta` + `preserveCursor` heuristic.
58
+ //
59
+ // Tradeoff: the markdown toolbar + Cmd-shortcuts + paste-image upload all
60
+ // operate on a `<textarea>`'s DOM selection — they don't have a way to
61
+ // reach into the Tiptap editor's selection without exposing the editor
62
+ // instance, which would widen the renderer seam. For now those features
63
+ // are write-mode-only on the native path; collab users type markdown
64
+ // syntax directly (`**bold**`, `## heading`). The preview tab keeps
65
+ // working since it reads `value` from local state.
66
+ if (room && collabRenderer) {
67
+ return (
68
+ <MarkdownCollabInput
69
+ Renderer={collabRenderer}
70
+ name={name}
71
+ defaultValue={defaultValue}
72
+ disabled={disabled}
73
+ {...(placeholder !== undefined ? { placeholder } : {})}
74
+ {...(minHeight !== undefined ? { minHeight } : {})}
75
+ {...(maxHeight !== undefined ? { maxHeight } : {})}
76
+ />
77
+ )
78
+ }
79
+
49
80
  const { notify } = useToast()
50
81
  const textareaRef = useRef<HTMLTextAreaElement | null>(null)
51
82
  // Phase F.6 — IME composition gate. Set between `compositionstart` /
@@ -124,9 +155,19 @@ export function MarkdownInput({
124
155
  const before = binding.read()
125
156
  if (next !== before) {
126
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
127
169
  if (delta) binding.applyDelta(delta)
128
170
  setBoundValue(next)
129
- boundValueRef.current = next
130
171
  }
131
172
  fs.setValue(next)
132
173
  fs.triggerLive(next)
@@ -422,6 +463,82 @@ function TabButton({ active, onClick, children }: {
422
463
  )
423
464
  }
424
465
 
466
+ /**
467
+ * Phase B follow-up — collab-aware markdown editor. Mounts the registered
468
+ * Tiptap-backed plain-text renderer for the Write pane and reuses the
469
+ * existing `marked` pipeline for Preview. No toolbar, no Cmd-shortcuts, no
470
+ * paste-image upload — those features depend on textarea-DOM splicing that
471
+ * doesn't translate to Tiptap's selection model. The cursor-bug fix is the
472
+ * load-bearing change; markdown-syntax authors keep typing as before.
473
+ */
474
+ function MarkdownCollabInput({
475
+ Renderer, name, defaultValue, disabled, placeholder, minHeight, maxHeight,
476
+ }: {
477
+ Renderer: CollabTextRenderer
478
+ name: string
479
+ defaultValue: unknown
480
+ disabled: boolean
481
+ placeholder?: string
482
+ minHeight?: string
483
+ maxHeight?: string
484
+ }): React.ReactElement {
485
+ const fs = useFieldState(name)
486
+ const initial = useMemo(() => stringValue(defaultValue), [])
487
+ const [text, setText] = useState<string>(initial)
488
+ const [tab, setTab] = useState<'write' | 'preview'>('write')
489
+ const textRef = useRef(text)
490
+ useEffect(() => { textRef.current = text }, [text])
491
+
492
+ const handleChange = (next: string): void => {
493
+ setText(next)
494
+ if (fs.controlled) fs.setValue(next)
495
+ fs.triggerLive(next)
496
+ }
497
+ const handleBlur = (): void => { /* fire-and-forget — live trigger already ran on change */ }
498
+
499
+ const previewHtml = useMemo(
500
+ () => tab === 'preview' ? marked.parse(text, { gfm: true, breaks: false, async: false }) as string : '',
501
+ [tab, text],
502
+ )
503
+
504
+ const wrapperStyle: React.CSSProperties = {}
505
+ if (minHeight) wrapperStyle.minHeight = minHeight
506
+ if (maxHeight) wrapperStyle.maxHeight = maxHeight
507
+
508
+ return (
509
+ <div className="flex flex-col rounded-md border bg-background">
510
+ <div className="flex items-center border-b px-2 py-1">
511
+ <TabButton active={tab === 'write'} onClick={() => setTab('write')}>Write</TabButton>
512
+ <TabButton active={tab === 'preview'} onClick={() => setTab('preview')}>Preview</TabButton>
513
+ </div>
514
+ {tab === 'write' ? (
515
+ <div style={wrapperStyle} className="overflow-auto">
516
+ <input type="hidden" name={name} value={text} />
517
+ <Renderer
518
+ name={name}
519
+ multiline={true}
520
+ defaultValue={initial}
521
+ {...(placeholder !== undefined ? { placeholder } : {})}
522
+ disabled={disabled}
523
+ onChange={handleChange}
524
+ onBlur={handleBlur}
525
+ 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"
526
+ />
527
+ </div>
528
+ ) : (
529
+ <>
530
+ <input type="hidden" name={name} value={text} readOnly />
531
+ <div
532
+ className="prose prose-sm dark:prose-invert max-w-none px-3 py-2"
533
+ style={wrapperStyle}
534
+ dangerouslySetInnerHTML={{ __html: previewHtml || '<p class="text-muted-foreground italic">Nothing to preview</p>' }}
535
+ />
536
+ </>
537
+ )}
538
+ </div>
539
+ )
540
+ }
541
+
425
542
  function stringValue(v: unknown): string {
426
543
  if (v === undefined || v === null) return ''
427
544
  if (typeof v === 'string') return v
@@ -3,7 +3,7 @@ import { PlusIcon } from 'lucide-react'
3
3
  import type { ElementMeta } from '../../schema/Element.js'
4
4
  import { Button } from '../ui/button.js'
5
5
  import { SchemaRenderer, dispatchHandlerAction } from '../SchemaRenderer.js'
6
- import { FormIdContext, useFormState } from '../FormStateContext.js'
6
+ import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
7
7
  import { findFieldMeta } from '../formStateHelpers.js'
8
8
  import { useNavigate } from '../navigate.js'
9
9
  import { useToast } from '../Toaster.js'
@@ -236,6 +236,75 @@ export function RepeaterInput({
236
236
  if (!metaRows) return
237
237
  setRows(prev => syncRowGates(prev, metaRows))
238
238
  }, [metaRows])
239
+ // Phase F.5 — row-array CRDT binding. `null` outside a collab room
240
+ // OR when the active binding doesn't implement F.5 row methods OR when
241
+ // this Repeater opted out via `.collab(false)`. The four row mutations
242
+ // (`addRow / cloneRow / removeRow / moveRow + DnD drop`) below call into
243
+ // it when present so peers see the same lifecycle events; absent =
244
+ // today's local-only behaviour, unchanged.
245
+ 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.
256
+ const formStateForIds = useFormState()
257
+ const ctxSetValue = formStateForIds?.setValue
258
+ useEffect(() => {
259
+ if (!ctxSetValue) return
260
+ for (let i = 0; i < rows.length; i++) {
261
+ const row = rows[i]
262
+ if (!row) continue
263
+ ctxSetValue(`${name}.${i}.__id`, row.id)
264
+ }
265
+ }, [rows, name, ctxSetValue])
266
+ // Phase F.5 — reconcile remote row events into the local `rows` state
267
+ // by `__id`. Local mutations also surface here (Yjs observers fire on
268
+ // local transactions); we dedupe by checking whether the rowId is
269
+ // already present in the current state. `template` seeds new rows so
270
+ // remote-added rows render with the same inner schema as locally-added
271
+ // ones.
272
+ useEffect(() => {
273
+ if (!rowBinding) return
274
+ const tpl = meta.template ?? []
275
+ return rowBinding.subscribe((event) => {
276
+ if (event.kind === 'add') {
277
+ setRows((prev) => {
278
+ if (prev.some(r => r.id === event.rowId)) return prev
279
+ const incoming: RowState = { id: event.rowId, children: tpl }
280
+ const next = prev.slice()
281
+ const at = Math.max(0, Math.min(event.index, next.length))
282
+ next.splice(at, 0, incoming)
283
+ return next
284
+ })
285
+ return
286
+ }
287
+ if (event.kind === 'remove') {
288
+ setRows((prev) => {
289
+ if (!prev.some(r => r.id === event.rowId)) return prev
290
+ return prev.filter(r => r.id !== event.rowId)
291
+ })
292
+ return
293
+ }
294
+ // move — recompute the local row order by lifting the row at `from`
295
+ // and re-inserting at `to`. No-op when local already matches.
296
+ setRows((prev) => {
297
+ const fromIdx = prev.findIndex(r => r.id === event.rowId)
298
+ if (fromIdx < 0) return prev
299
+ if (fromIdx === event.to) return prev
300
+ const next = prev.slice()
301
+ const [moved] = next.splice(fromIdx, 1)
302
+ if (!moved) return prev
303
+ next.splice(event.to, 0, moved)
304
+ return next
305
+ })
306
+ })
307
+ }, [rowBinding, meta.template])
239
308
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
240
309
  accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
241
310
  )
@@ -269,6 +338,7 @@ export function RepeaterInput({
269
338
  children: meta.template ?? [],
270
339
  }
271
340
  setRows(prev => [...prev, newRow])
341
+ rowBinding?.add(newRow.id, {})
272
342
  if (accordion) {
273
343
  // New row should be the only one open — the user just asked for it.
274
344
  setAccordionOpenId(newRow.id)
@@ -284,6 +354,7 @@ export function RepeaterInput({
284
354
  const removeRow = (id: string): void => {
285
355
  if (atMin) return
286
356
  setRows(prev => prev.filter(r => r.id !== id))
357
+ rowBinding?.remove(id)
287
358
  if (accordion) {
288
359
  if (accordionOpenId === id) {
289
360
  setAccordionOpenId(null)
@@ -300,12 +371,14 @@ export function RepeaterInput({
300
371
 
301
372
  const cloneRow = (id: string): void => {
302
373
  if (atMax) return
374
+ let cloneId: string | null = null
303
375
  setRows(prev => {
304
376
  const idx = prev.findIndex(r => r.id === id)
305
377
  if (idx < 0) return prev
306
378
  const source = prev[idx]!
379
+ cloneId = generateRowId()
307
380
  const clone: RowState = {
308
- id: generateRowId(),
381
+ id: cloneId,
309
382
  children: source.children,
310
383
  ...(source.itemLabel !== undefined ? { itemLabel: source.itemLabel } : {}),
311
384
  }
@@ -313,26 +386,38 @@ export function RepeaterInput({
313
386
  next.splice(idx + 1, 0, clone)
314
387
  return next
315
388
  })
389
+ // F.5 — register the clone's stable id on the binding. Per-field
390
+ // clone-of-source values flow through `setRow` on the user's next
391
+ // edit; v1 doesn't lift the source row's values onto the clone (the
392
+ // binding's empty seed combined with the DOM's defaultValue-copied
393
+ // inputs gives the local user the right visual state).
394
+ if (cloneId !== null) rowBinding?.add(cloneId, {})
316
395
  }
317
396
 
318
397
  const moveRow = (id: string, dir: -1 | 1): void => {
398
+ let newOrder: string[] | null = null
319
399
  setRows(prev => {
320
400
  const idx = prev.findIndex(r => r.id === id)
321
401
  if (idx < 0) return prev
322
402
  // Skip past hidden neighbours so reorder operates between visible
323
403
  // rows. Hidden rows hold their absolute slot — the visible row hops
324
404
  // over them.
405
+ let next: RowState[]
325
406
  if (dir === -1) {
326
407
  let target = idx - 1
327
408
  while (target >= 0 && prev[target]?.hidden) target--
328
409
  if (target < 0) return prev
329
- return reorderRows(prev, idx, target)
410
+ next = reorderRows(prev, idx, target)
411
+ } else {
412
+ let target = idx + 1
413
+ while (target < prev.length && prev[target]?.hidden) target++
414
+ if (target >= prev.length) return prev
415
+ next = reorderRows(prev, idx, target + 1)
330
416
  }
331
- let target = idx + 1
332
- while (target < prev.length && prev[target]?.hidden) target++
333
- if (target >= prev.length) return prev
334
- return reorderRows(prev, idx, target + 1)
417
+ if (next !== prev) newOrder = next.map(r => r.id)
418
+ return next
335
419
  })
420
+ if (newOrder !== null) rowBinding?.reorder(newOrder)
336
421
  }
337
422
 
338
423
  // ── DnD state ───────────────────────────────────────────
@@ -345,11 +430,15 @@ export function RepeaterInput({
345
430
  } = useRowReorderDnd({
346
431
  enabled: reorderable && !disabled,
347
432
  onDrop: (fromId, at) => {
433
+ let newOrder: string[] | null = null
348
434
  setRows(prev => {
349
435
  const fromIdx = prev.findIndex(r => r.id === fromId)
350
436
  if (fromIdx < 0) return prev
351
- return reorderRows(prev, fromIdx, at)
437
+ const next = reorderRows(prev, fromIdx, at)
438
+ if (next !== prev) newOrder = next.map(r => r.id)
439
+ return next
352
440
  })
441
+ if (newOrder !== null) rowBinding?.reorder(newOrder)
353
442
  },
354
443
  })
355
444
 
@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { ElementMeta } from '../../schema/Element.js'
3
3
  import type { TextBinding } from '../FormCollabBindingRegistry.js'
4
4
  import { useFieldState } from '../FormStateContext.js'
5
+ import { useCollabRoom } from '../CollabRoomContext.js'
6
+ import { getCollabTextRenderer, type CollabTextRenderer } from '../CollabTextRendererRegistry.js'
5
7
  import { Input } from '../ui/input.js'
6
8
  import { Textarea } from '../ui/textarea.js'
7
9
  import { computeDelta, preserveCursor } from './textDelta.js'
@@ -38,6 +40,8 @@ export function TextLikeInput({
38
40
  applyMask?: (value: string) => string
39
41
  }): React.ReactElement {
40
42
  const fs = useFieldState(name)
43
+ const room = useCollabRoom()
44
+ const collabRenderer = getCollabTextRenderer()
41
45
  const liveCfg = el['live']
42
46
  const liveOpts = (typeof liveCfg === 'object' && liveCfg !== null
43
47
  ? liveCfg as { onBlur?: boolean; debounce?: number }
@@ -52,6 +56,40 @@ export function TextLikeInput({
52
56
  // `useCallback`-wrapped fn that's *always* defined (identity when no
53
57
  // mask), so its truthiness can't gate the branch.
54
58
  const hasMask = typeof el['mask'] === 'string'
59
+
60
+ // Phase B — Tiptap-backed plain-text editor for collab text fields.
61
+ // When a `<RecordCollabRoom>` is mounted up-tree AND `@pilotiq/tiptap`'s
62
+ // `registerTiptap()` registered a collab text renderer, take the new path:
63
+ // the editor anchors selections to Yjs `RelativePosition` (via y-prosemirror)
64
+ // instead of integer string offsets, fixing the cursor-jump + two-peer
65
+ // concurrent-insert races that the legacy `Y.Text` + `computeDelta` path
66
+ // can't resolve. Dotted-path row leaves (Repeater / Builder) stay on the
67
+ // legacy `fs.textBinding` path — per-row collab editor support is a
68
+ // separate follow-up.
69
+ const fieldCollab = el['collab'] as boolean | undefined
70
+ if (
71
+ room &&
72
+ collabRenderer &&
73
+ fieldCollab !== false &&
74
+ !hasMask &&
75
+ !name.includes('.')
76
+ ) {
77
+ return (
78
+ <CollabTextField
79
+ Renderer={collabRenderer}
80
+ name={name}
81
+ multiline={multiline}
82
+ defaultValue={stringValue(common['defaultValue'])}
83
+ {...(common['placeholder'] !== undefined ? { placeholder: String(common['placeholder']) } : {})}
84
+ disabled={Boolean(common['disabled'])}
85
+ triggerLive={fs.triggerLive}
86
+ setValue={fs.setValue}
87
+ controlled={fs.controlled}
88
+ onBlurMode={onBlurMode}
89
+ />
90
+ )
91
+ }
92
+
55
93
  if (fs.textBinding && !hasMask) {
56
94
  return (
57
95
  <BoundTextInput
@@ -217,13 +255,18 @@ function BoundTextInput({
217
255
  if (after === before) return
218
256
  const delta = computeDelta(before, after)
219
257
  if (!delta) return
258
+ // Pre-stamp `valueRef.current = after` BEFORE `applyDelta`. Y.Text's
259
+ // observe fires synchronously inside `applyDelta` for our own write,
260
+ // so without this the observer would see `prev=before, next=after`
261
+ // and run `preserveCursor` — which is designed for *remote* edits
262
+ // and clobbers the user's caret on local typing (typed '1' at pos 0
263
+ // would jump cursor forward by delta-length and the next keystroke
264
+ // would insert at the wrong index, producing scrambled output).
265
+ // With `valueRef` already at `after`, the observer's `next === prev`
266
+ // short-circuit fires and the cursor is left alone for local echoes.
267
+ valueRef.current = after
220
268
  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
269
  setValueLocal(after)
226
- valueRef.current = after
227
270
  mirrorRef.current(after)
228
271
  if (!onBlurMode) triggerLive(after)
229
272
  }, [binding, onBlurMode, triggerLive])
@@ -267,6 +310,87 @@ function BoundTextInput({
267
310
  return <Input {...(props as React.ComponentProps<typeof Input>)} type={type} />
268
311
  }
269
312
 
313
+ /**
314
+ * Phase B — wrapper around the registered Tiptap-backed collab editor.
315
+ * Owns the local text mirror so the hidden `<input>` always carries the
316
+ * editor's current value for FormData submission. When `FormStateProvider`
317
+ * is mounted up-tree, also mirrors every update into the values map via
318
+ * `fs.setValue` so `$get/$set` computations and any Y.Map LWW path (kept
319
+ * for non-text consumers) stay in sync.
320
+ *
321
+ * No IME / cursor-preservation gymnastics in here — the underlying Tiptap
322
+ * editor handles composition natively and y-prosemirror anchors selections
323
+ * to `Yjs.RelativePosition`, so the cursor survives concurrent + mid-word
324
+ * remote edits without any client-side bookkeeping.
325
+ */
326
+ function CollabTextField({
327
+ Renderer, name, multiline, defaultValue, placeholder, disabled,
328
+ triggerLive, setValue, controlled, onBlurMode,
329
+ }: {
330
+ Renderer: CollabTextRenderer
331
+ name: string
332
+ multiline: boolean
333
+ defaultValue: string
334
+ placeholder?: string
335
+ disabled: boolean
336
+ triggerLive: (valueOverride?: unknown) => void
337
+ setValue: (v: unknown) => void
338
+ controlled: boolean
339
+ onBlurMode: boolean
340
+ }): React.ReactElement {
341
+ const [text, setText] = useState<string>(defaultValue)
342
+ const textRef = useRef(text)
343
+ useEffect(() => { textRef.current = text }, [text])
344
+
345
+ const handleChange = useCallback((next: string): void => {
346
+ setText(next)
347
+ if (controlled) setValue(next)
348
+ if (!onBlurMode) triggerLive(next)
349
+ }, [controlled, onBlurMode, setValue, triggerLive])
350
+
351
+ const handleBlur = useCallback((): void => {
352
+ if (onBlurMode) triggerLive(textRef.current)
353
+ }, [onBlurMode, triggerLive])
354
+
355
+ // Match the visual chrome of `<Input>` / `<Textarea>` so the editor reads
356
+ // as a drop-in replacement. The adapter forwards this class to its
357
+ // contenteditable wrapper; `whitespace-nowrap` on the single-line variant
358
+ // keeps the editor from wrapping into a second line if a stray paragraph
359
+ // split somehow makes it through.
360
+ //
361
+ // `overflow-x-clip` (not `auto`) on the single-line variant matters for
362
+ // `CollaborationCaret` presence labels: per the CSS overflow spec, setting
363
+ // either axis to a non-visible / non-clip value (`auto` / `scroll` /
364
+ // `hidden`) forces the other axis to compute as `auto` too — so
365
+ // `overflow-x-auto` would clip the caret's user-name label, which renders
366
+ // `-1.4em` above the line. `clip` is the one non-visible value that does
367
+ // NOT force the other axis, so `overflow-y` stays `visible` and the label
368
+ // escapes the chrome upward as designed. Trade-off: long text gets clipped
369
+ // on the right rather than horizontally scrollable (native `<input>`
370
+ // semantics) — acceptable for plain-text fields, where typing past the
371
+ // visible width is rare and the caret presence label is the higher-value
372
+ // affordance.
373
+ const className = multiline
374
+ ? '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'
375
+ : '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'
376
+
377
+ return (
378
+ <>
379
+ <input type="hidden" name={name} value={text} />
380
+ <Renderer
381
+ name={name}
382
+ multiline={multiline}
383
+ defaultValue={defaultValue}
384
+ {...(placeholder !== undefined ? { placeholder } : {})}
385
+ disabled={disabled}
386
+ onChange={handleChange}
387
+ onBlur={handleBlur}
388
+ className={className}
389
+ />
390
+ </>
391
+ )
392
+ }
393
+
270
394
  function identity(v: string): string { return v }
271
395
 
272
396
  function stringValue(v: unknown): string {