@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +8 -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 +31 -17
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.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/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/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- 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 +31 -17
- package/src/react/fields/MarkdownInput.tsx +118 -1
- package/src/react/fields/TextLikeInput.tsx +129 -5
- 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 {
|
package/src/react/index.ts
CHANGED
|
@@ -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'
|