@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +128 -0
- package/dist/react/AppShell.d.ts +1 -1
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +7 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/CollabTextRendererRegistry.d.ts +75 -0
- package/dist/react/CollabTextRendererRegistry.d.ts.map +1 -0
- package/dist/react/CollabTextRendererRegistry.js +18 -0
- package/dist/react/CollabTextRendererRegistry.js.map +1 -0
- package/dist/react/CurrentUserContext.d.ts +39 -0
- package/dist/react/CurrentUserContext.d.ts.map +1 -0
- package/dist/react/CurrentUserContext.js +27 -0
- package/dist/react/CurrentUserContext.js.map +1 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +161 -17
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +39 -1
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +126 -33
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +112 -10
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +60 -1
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +113 -10
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +83 -6
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/formStateHelpers.d.ts +102 -0
- package/dist/react/formStateHelpers.d.ts.map +1 -1
- package/dist/react/formStateHelpers.js +234 -0
- package/dist/react/formStateHelpers.js.map +1 -1
- package/dist/react/index.d.ts +4 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -1
- package/dist/react/index.js.map +1 -1
- package/package.json +5 -5
- package/src/react/AppShell.tsx +11 -1
- package/src/react/CollabTextRendererRegistry.ts +84 -0
- package/src/react/CurrentUserContext.tsx +50 -0
- package/src/react/FormCollabBindingRegistry.ts +160 -17
- package/src/react/FormStateContext.tsx +157 -34
- package/src/react/fields/BuilderInput.tsx +97 -8
- package/src/react/fields/MarkdownInput.tsx +118 -1
- package/src/react/fields/RepeaterInput.tsx +97 -8
- package/src/react/fields/TextLikeInput.tsx +129 -5
- package/src/react/formStateHelpers.test.ts +312 -0
- package/src/react/formStateHelpers.ts +246 -0
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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 {
|