@pilotiq/pilotiq 0.11.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 (35) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +8 -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 +31 -17
  16. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  17. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  18. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  19. package/dist/react/fields/MarkdownInput.js +60 -1
  20. package/dist/react/fields/MarkdownInput.js.map +1 -1
  21. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  22. package/dist/react/fields/TextLikeInput.js +83 -6
  23. package/dist/react/fields/TextLikeInput.js.map +1 -1
  24. package/dist/react/index.d.ts +2 -0
  25. package/dist/react/index.d.ts.map +1 -1
  26. package/dist/react/index.js +2 -0
  27. package/dist/react/index.js.map +1 -1
  28. package/package.json +5 -5
  29. package/src/react/AppShell.tsx +11 -1
  30. package/src/react/CollabTextRendererRegistry.ts +84 -0
  31. package/src/react/CurrentUserContext.tsx +50 -0
  32. package/src/react/FormCollabBindingRegistry.ts +31 -17
  33. package/src/react/fields/MarkdownInput.tsx +118 -1
  34. package/src/react/fields/TextLikeInput.tsx +129 -5
  35. package/src/react/index.ts +12 -0
@@ -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 {
@@ -43,6 +43,12 @@ 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,
@@ -139,6 +145,12 @@ export {
139
145
  type UseResizableWidthApi,
140
146
  } from './useResizableWidth.js'
141
147
 
148
+ export {
149
+ CurrentUserProvider,
150
+ useCurrentUser,
151
+ type CurrentUser,
152
+ } from './CurrentUserContext.js'
153
+
142
154
  export { ThemeProvider, useTheme } from './ThemeProvider.js'
143
155
  export { ThemeToggle } from './ThemeToggle.js'
144
156
  export { ThemeSettingsPage } from './ThemeSettingsPage.js'