@pilotiq/pilotiq 0.11.0 → 0.13.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 +21 -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 +17 -84
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +1 -35
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +7 -91
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/RowCoordsContext.d.ts +19 -0
- package/dist/react/RowCoordsContext.d.ts.map +1 -0
- package/dist/react/RowCoordsContext.js +6 -0
- package/dist/react/RowCoordsContext.js.map +1 -0
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +14 -9
- 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 +70 -101
- 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 +26 -17
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts +11 -9
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +111 -164
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/formStateHelpers.d.ts +0 -15
- package/dist/react/formStateHelpers.d.ts.map +1 -1
- package/dist/react/formStateHelpers.js +0 -91
- package/dist/react/formStateHelpers.js.map +1 -1
- package/dist/react/index.d.ts +3 -1
- 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 +17 -77
- package/src/react/FormStateContext.tsx +6 -125
- package/src/react/RowCoordsContext.tsx +23 -0
- package/src/react/fields/BuilderInput.tsx +22 -10
- package/src/react/fields/MarkdownInput.tsx +125 -95
- package/src/react/fields/RepeaterInput.tsx +41 -16
- package/src/react/fields/TextLikeInput.tsx +147 -181
- package/src/react/formStateHelpers.test.ts +0 -99
- package/src/react/formStateHelpers.ts +0 -83
- package/src/react/index.ts +12 -2
- package/dist/react/fields/textDelta.d.ts +0 -44
- package/dist/react/fields/textDelta.d.ts.map +0 -1
- package/dist/react/fields/textDelta.js +0 -80
- package/dist/react/fields/textDelta.js.map +0 -1
- package/src/react/fields/textDelta.test.ts +0 -141
- package/src/react/fields/textDelta.ts +0 -86
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import React, { useCallback, useEffect,
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import type { ElementMeta } from '../../schema/Element.js'
|
|
3
|
-
import type { TextBinding } from '../FormCollabBindingRegistry.js'
|
|
4
3
|
import { useFieldState } from '../FormStateContext.js'
|
|
4
|
+
import { useCollabRoom } from '../CollabRoomContext.js'
|
|
5
|
+
import { getCollabTextRenderer, type CollabTextRenderer } from '../CollabTextRendererRegistry.js'
|
|
6
|
+
import { useRowCoords } from '../RowCoordsContext.js'
|
|
7
|
+
import { parseRowFieldPath } from '../formStateHelpers.js'
|
|
5
8
|
import { Input } from '../ui/input.js'
|
|
6
9
|
import { Textarea } from '../ui/textarea.js'
|
|
7
|
-
import { computeDelta, preserveCursor } from './textDelta.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Bridge between controlled (FormStateProvider) and uncontrolled
|
|
@@ -13,15 +15,17 @@ import { computeDelta, preserveCursor } from './textDelta.js'
|
|
|
13
15
|
* fires the live trigger on change/blur according to the field's `live`
|
|
14
16
|
* config. Outside a controlled form, falls back to plain `defaultValue`.
|
|
15
17
|
*
|
|
16
|
-
* **
|
|
17
|
-
* is mounted up-tree AND `@pilotiq
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
18
|
+
* **Collab branch — Tiptap-backed `Y.XmlFragment`.** When a
|
|
19
|
+
* `<RecordCollabRoom>` is mounted up-tree AND `@pilotiq/tiptap`'s
|
|
20
|
+
* `registerTiptap()` registered a collab text renderer, the input
|
|
21
|
+
* mounts the Tiptap-backed editor against a `Y.XmlFragment` keyed by
|
|
22
|
+
* either the bare field name (top-level) or
|
|
23
|
+
* `${arrayName}.${rowId}.${fieldName}` (Repeater / Builder row leaves
|
|
24
|
+
* via `useRowCoords()`). Selections anchor to `Y.RelativePosition` via
|
|
25
|
+
* y-prosemirror, so cursors survive both mid-word remote edits and
|
|
26
|
+
* concurrent inserts. Masked fields fall through to the legacy
|
|
27
|
+
* whole-string LWW path (mask + character-level CRDT is incompatible
|
|
28
|
+
* — peers would see raw keystrokes desynced from the rendered mask).
|
|
25
29
|
*/
|
|
26
30
|
export function TextLikeInput({
|
|
27
31
|
el, name, common, type, extraProps, multiline, applyMask,
|
|
@@ -38,6 +42,9 @@ export function TextLikeInput({
|
|
|
38
42
|
applyMask?: (value: string) => string
|
|
39
43
|
}): React.ReactElement {
|
|
40
44
|
const fs = useFieldState(name)
|
|
45
|
+
const room = useCollabRoom()
|
|
46
|
+
const collabRenderer = getCollabTextRenderer()
|
|
47
|
+
const rowCoords = useRowCoords()
|
|
41
48
|
const liveCfg = el['live']
|
|
42
49
|
const liveOpts = (typeof liveCfg === 'object' && liveCfg !== null
|
|
43
50
|
? liveCfg as { onBlur?: boolean; debounce?: number }
|
|
@@ -45,24 +52,56 @@ export function TextLikeInput({
|
|
|
45
52
|
const onBlurMode = liveOpts.onBlur === true
|
|
46
53
|
const mask = applyMask ?? identity
|
|
47
54
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
55
|
+
// Masking is mutually exclusive with character-level CRDT (peers would
|
|
56
|
+
// see raw keystrokes diverged from the local mask render); masked
|
|
57
|
+
// fields fall through to LWW. We read the mask from the field meta
|
|
58
|
+
// directly — `applyMask` is a `useCallback`-wrapped fn that's *always*
|
|
59
|
+
// defined (identity when no mask), so its truthiness can't gate the
|
|
60
|
+
// branch.
|
|
54
61
|
const hasMask = typeof el['mask'] === 'string'
|
|
55
|
-
|
|
62
|
+
|
|
63
|
+
// Collab branch — Tiptap-backed plain-text editor. Top-level fields
|
|
64
|
+
// use the bare `name` as the fragment-key; Repeater / Builder row
|
|
65
|
+
// leaves compose `${arrayName}.${rowId}.${fieldName}` from
|
|
66
|
+
// `useRowCoords()` so the Y.XmlFragment survives row reorders (keyed
|
|
67
|
+
// by the stable rowId, not the array index). The hidden FormData
|
|
68
|
+
// input keeps the original dotted path so submission lands on the
|
|
69
|
+
// server at the right slot.
|
|
70
|
+
//
|
|
71
|
+
// Dotted paths that don't match a row shape (no rowCoords OR
|
|
72
|
+
// `parseRowFieldPath` returns null — nested row arrays, malformed
|
|
73
|
+
// names) skip the collab path and fall through to the controlled /
|
|
74
|
+
// uncontrolled branches below.
|
|
75
|
+
const fieldCollab = el['collab'] as boolean | undefined
|
|
76
|
+
const fragmentKey: string | null = (() => {
|
|
77
|
+
if (!name.includes('.')) return name
|
|
78
|
+
if (!rowCoords) return null
|
|
79
|
+
const parsed = parseRowFieldPath(name)
|
|
80
|
+
if (!parsed) return null
|
|
81
|
+
if (parsed.arrayName !== rowCoords.arrayName) return null
|
|
82
|
+
if (parsed.index !== rowCoords.rowIndex) return null
|
|
83
|
+
return `${rowCoords.arrayName}.${rowCoords.rowId}.${parsed.fieldName}`
|
|
84
|
+
})()
|
|
85
|
+
if (
|
|
86
|
+
room &&
|
|
87
|
+
collabRenderer &&
|
|
88
|
+
fieldCollab !== false &&
|
|
89
|
+
!hasMask &&
|
|
90
|
+
fragmentKey !== null
|
|
91
|
+
) {
|
|
56
92
|
return (
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
93
|
+
<CollabTextField
|
|
94
|
+
Renderer={collabRenderer}
|
|
95
|
+
fragmentKey={fragmentKey}
|
|
96
|
+
hiddenInputName={name}
|
|
97
|
+
multiline={multiline}
|
|
98
|
+
defaultValue={stringValue(common['defaultValue'])}
|
|
99
|
+
{...(common['placeholder'] !== undefined ? { placeholder: String(common['placeholder']) } : {})}
|
|
100
|
+
disabled={Boolean(common['disabled'])}
|
|
60
101
|
triggerLive={fs.triggerLive}
|
|
102
|
+
setValue={fs.setValue}
|
|
103
|
+
controlled={fs.controlled}
|
|
61
104
|
onBlurMode={onBlurMode}
|
|
62
|
-
common={common}
|
|
63
|
-
extraProps={extraProps}
|
|
64
|
-
type={type}
|
|
65
|
-
multiline={multiline}
|
|
66
105
|
/>
|
|
67
106
|
)
|
|
68
107
|
}
|
|
@@ -106,165 +145,92 @@ export function TextLikeInput({
|
|
|
106
145
|
}
|
|
107
146
|
|
|
108
147
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
148
|
+
* Wrapper around the registered Tiptap-backed collab editor.
|
|
149
|
+
* Owns the local text mirror so the hidden `<input>` always carries the
|
|
150
|
+
* editor's current value for FormData submission. When `FormStateProvider`
|
|
151
|
+
* is mounted up-tree, also mirrors every update into the values map via
|
|
152
|
+
* `fs.setValue` so `$get/$set` computations and any Y.Map LWW path (kept
|
|
153
|
+
* for non-text consumers) stay in sync.
|
|
154
|
+
*
|
|
155
|
+
* No IME / cursor-preservation gymnastics in here — the underlying Tiptap
|
|
156
|
+
* editor handles composition natively and y-prosemirror anchors selections
|
|
157
|
+
* to `Yjs.RelativePosition`, so the cursor survives concurrent + mid-word
|
|
158
|
+
* remote edits without any client-side bookkeeping.
|
|
114
159
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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).
|
|
160
|
+
* `fragmentKey` and `hiddenInputName` diverge for row-text leaves (Phase
|
|
161
|
+
* 1 of collab-row-text-tiptap-backed.md): the renderer's Y.XmlFragment is
|
|
162
|
+
* keyed by `${arrayName}.${rowId}.${fieldName}` so it survives row
|
|
163
|
+
* reorders, while the hidden FormData input keeps the dotted path
|
|
164
|
+
* (`items.0.title`) so submission lands at the right server-side slot.
|
|
165
|
+
* For top-level fields the two are identical.
|
|
130
166
|
*/
|
|
131
|
-
function
|
|
132
|
-
|
|
167
|
+
function CollabTextField({
|
|
168
|
+
Renderer, fragmentKey, hiddenInputName, multiline, defaultValue, placeholder, disabled,
|
|
169
|
+
triggerLive, setValue, controlled, onBlurMode,
|
|
133
170
|
}: {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
171
|
+
Renderer: CollabTextRenderer
|
|
172
|
+
fragmentKey: string
|
|
173
|
+
hiddenInputName: string
|
|
174
|
+
multiline: boolean
|
|
175
|
+
defaultValue: string
|
|
176
|
+
placeholder?: string
|
|
177
|
+
disabled: boolean
|
|
178
|
+
triggerLive: (valueOverride?: unknown) => void
|
|
179
|
+
setValue: (v: unknown) => void
|
|
180
|
+
controlled: boolean
|
|
181
|
+
onBlurMode: boolean
|
|
142
182
|
}): React.ReactElement {
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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} />
|
|
183
|
+
const [text, setText] = useState<string>(defaultValue)
|
|
184
|
+
const textRef = useRef(text)
|
|
185
|
+
useEffect(() => { textRef.current = text }, [text])
|
|
186
|
+
|
|
187
|
+
const handleChange = useCallback((next: string): void => {
|
|
188
|
+
setText(next)
|
|
189
|
+
if (controlled) setValue(next)
|
|
190
|
+
if (!onBlurMode) triggerLive(next)
|
|
191
|
+
}, [controlled, onBlurMode, setValue, triggerLive])
|
|
192
|
+
|
|
193
|
+
const handleBlur = useCallback((): void => {
|
|
194
|
+
if (onBlurMode) triggerLive(textRef.current)
|
|
195
|
+
}, [onBlurMode, triggerLive])
|
|
196
|
+
|
|
197
|
+
// Match the visual chrome of `<Input>` / `<Textarea>` so the editor reads
|
|
198
|
+
// as a drop-in replacement. The adapter forwards this class to its
|
|
199
|
+
// contenteditable wrapper; `whitespace-nowrap` on the single-line variant
|
|
200
|
+
// keeps the editor from wrapping into a second line if a stray paragraph
|
|
201
|
+
// split somehow makes it through.
|
|
202
|
+
//
|
|
203
|
+
// `overflow-x-clip` (not `auto`) on the single-line variant matters for
|
|
204
|
+
// `CollaborationCaret` presence labels: per the CSS overflow spec, setting
|
|
205
|
+
// either axis to a non-visible / non-clip value (`auto` / `scroll` /
|
|
206
|
+
// `hidden`) forces the other axis to compute as `auto` too — so
|
|
207
|
+
// `overflow-x-auto` would clip the caret's user-name label, which renders
|
|
208
|
+
// `-1.4em` above the line. `clip` is the one non-visible value that does
|
|
209
|
+
// NOT force the other axis, so `overflow-y` stays `visible` and the label
|
|
210
|
+
// escapes the chrome upward as designed. Trade-off: long text gets clipped
|
|
211
|
+
// on the right rather than horizontally scrollable (native `<input>`
|
|
212
|
+
// semantics) — acceptable for plain-text fields, where typing past the
|
|
213
|
+
// visible width is rare and the caret presence label is the higher-value
|
|
214
|
+
// affordance.
|
|
215
|
+
const className = multiline
|
|
216
|
+
? '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'
|
|
217
|
+
: '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'
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<>
|
|
221
|
+
<input type="hidden" name={hiddenInputName} value={text} />
|
|
222
|
+
<Renderer
|
|
223
|
+
name={fragmentKey}
|
|
224
|
+
multiline={multiline}
|
|
225
|
+
defaultValue={defaultValue}
|
|
226
|
+
{...(placeholder !== undefined ? { placeholder } : {})}
|
|
227
|
+
disabled={disabled}
|
|
228
|
+
onChange={handleChange}
|
|
229
|
+
onBlur={handleBlur}
|
|
230
|
+
className={className}
|
|
231
|
+
/>
|
|
232
|
+
</>
|
|
233
|
+
)
|
|
268
234
|
}
|
|
269
235
|
|
|
270
236
|
function identity(v: string): string { return v }
|
|
@@ -4,7 +4,6 @@ import assert from 'node:assert/strict'
|
|
|
4
4
|
import {
|
|
5
5
|
collectFieldDefaults,
|
|
6
6
|
collectRowArrayFieldNames,
|
|
7
|
-
collectRowTextLeavesByArray,
|
|
8
7
|
fieldOptsOutOfCollab,
|
|
9
8
|
findFieldMeta,
|
|
10
9
|
parseFormDataToNested,
|
|
@@ -492,104 +491,6 @@ describe('routeBindingWrite', () => {
|
|
|
492
491
|
})
|
|
493
492
|
})
|
|
494
493
|
|
|
495
|
-
describe('collectRowTextLeavesByArray', () => {
|
|
496
|
-
const textField = (name: string, fieldType: string = 'text', collab?: boolean): ElementMeta => ({
|
|
497
|
-
type: 'field',
|
|
498
|
-
fieldType,
|
|
499
|
-
name,
|
|
500
|
-
label: name,
|
|
501
|
-
required: false,
|
|
502
|
-
disabled: false,
|
|
503
|
-
...(collab === false ? { collab: false } : {}),
|
|
504
|
-
} as ElementMeta)
|
|
505
|
-
|
|
506
|
-
// Repeater meta carries the row schema under `template` (`children` is
|
|
507
|
-
// the per-resolved-row child list, not the field-level template). Tests
|
|
508
|
-
// must mirror what `RepeaterField.toMeta()` actually emits.
|
|
509
|
-
const repeater = (name: string, template: ElementMeta[], collab?: boolean): ElementMeta => ({
|
|
510
|
-
type: 'field',
|
|
511
|
-
fieldType: 'repeater',
|
|
512
|
-
name,
|
|
513
|
-
label: name,
|
|
514
|
-
required: false,
|
|
515
|
-
disabled: false,
|
|
516
|
-
template,
|
|
517
|
-
...(collab === false ? { collab: false } : {}),
|
|
518
|
-
} as ElementMeta)
|
|
519
|
-
|
|
520
|
-
it('collects text-shaped inner-field names per Repeater', () => {
|
|
521
|
-
const meta = formMeta([
|
|
522
|
-
repeater('tags', [
|
|
523
|
-
textField('label', 'text'),
|
|
524
|
-
textField('summary', 'textarea'),
|
|
525
|
-
textField('count', 'number'),
|
|
526
|
-
]),
|
|
527
|
-
])
|
|
528
|
-
const out = collectRowTextLeavesByArray(meta)
|
|
529
|
-
assert.equal(out.size, 1)
|
|
530
|
-
assert.deepEqual([...out.get('tags')!].sort(), ['label', 'summary'])
|
|
531
|
-
})
|
|
532
|
-
|
|
533
|
-
it('walks Builder block templates', () => {
|
|
534
|
-
const meta = formMeta([
|
|
535
|
-
{
|
|
536
|
-
type: 'field',
|
|
537
|
-
fieldType: 'builder',
|
|
538
|
-
name: 'blocks',
|
|
539
|
-
children: [],
|
|
540
|
-
blocks: [
|
|
541
|
-
{ name: 'heading', template: [textField('text', 'text')] },
|
|
542
|
-
{ name: 'paragraph', template: [textField('body', 'markdown')] },
|
|
543
|
-
],
|
|
544
|
-
} as unknown as ElementMeta,
|
|
545
|
-
])
|
|
546
|
-
const out = collectRowTextLeavesByArray(meta)
|
|
547
|
-
assert.deepEqual([...out.get('blocks')!].sort(), ['body', 'text'])
|
|
548
|
-
})
|
|
549
|
-
|
|
550
|
-
it('skips opted-out inner fields', () => {
|
|
551
|
-
const meta = formMeta([
|
|
552
|
-
repeater('tags', [
|
|
553
|
-
textField('label', 'text'),
|
|
554
|
-
textField('private', 'text', false), // .collab(false)
|
|
555
|
-
]),
|
|
556
|
-
])
|
|
557
|
-
const out = collectRowTextLeavesByArray(meta)
|
|
558
|
-
assert.deepEqual([...out.get('tags')!], ['label'])
|
|
559
|
-
})
|
|
560
|
-
|
|
561
|
-
it('omits opted-out top-level arrays', () => {
|
|
562
|
-
const meta = formMeta([
|
|
563
|
-
repeater('public', [textField('a', 'text')]),
|
|
564
|
-
repeater('private', [textField('b', 'text')], false),
|
|
565
|
-
])
|
|
566
|
-
const out = collectRowTextLeavesByArray(meta)
|
|
567
|
-
assert.equal(out.has('public'), true)
|
|
568
|
-
assert.equal(out.has('private'), false)
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
it('stops at nested array boundaries (no 5+ segment dotted paths)', () => {
|
|
572
|
-
const meta = formMeta([
|
|
573
|
-
repeater('outer', [
|
|
574
|
-
textField('outerLabel', 'text'),
|
|
575
|
-
repeater('inner', [textField('innerLabel', 'text')]),
|
|
576
|
-
]),
|
|
577
|
-
])
|
|
578
|
-
const out = collectRowTextLeavesByArray(meta)
|
|
579
|
-
assert.deepEqual([...out.get('outer')!], ['outerLabel'])
|
|
580
|
-
assert.equal(out.has('inner'), false, 'nested Repeater not surfaced as a top-level array')
|
|
581
|
-
})
|
|
582
|
-
|
|
583
|
-
it('returns empty map when no Repeater/Builder has text leaves', () => {
|
|
584
|
-
const meta = formMeta([
|
|
585
|
-
textField('top', 'text'),
|
|
586
|
-
repeater('numbers', [textField('count', 'number')]),
|
|
587
|
-
])
|
|
588
|
-
const out = collectRowTextLeavesByArray(meta)
|
|
589
|
-
assert.equal(out.size, 0)
|
|
590
|
-
})
|
|
591
|
-
})
|
|
592
|
-
|
|
593
494
|
describe('fieldOptsOutOfCollab', () => {
|
|
594
495
|
it('returns true only when the field carries an explicit collab=false', () => {
|
|
595
496
|
const meta = formMeta([
|
|
@@ -379,86 +379,3 @@ export function collectRowArrayFieldNames(formMeta: ElementMeta): string[] {
|
|
|
379
379
|
}
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
-
/**
|
|
383
|
-
* Phase F.5c — text-shaped fieldTypes whose row-leaf values should be
|
|
384
|
-
* routed through `Y.Text` instead of `Y.Map` LWW. Mirrors the same
|
|
385
|
-
* allowlist `@pilotiq-pro/collab`'s top-level binding uses; consumers
|
|
386
|
-
* registering character-level CRDT for additional plain-text-shaped
|
|
387
|
-
* fields update both copies in lockstep until a cross-repo shared
|
|
388
|
-
* constants module exists.
|
|
389
|
-
*/
|
|
390
|
-
const ROW_TEXT_FIELD_TYPES: ReadonlySet<string> = new Set([
|
|
391
|
-
'text', 'textarea', 'email', 'slug', 'markdown',
|
|
392
|
-
])
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Phase F.5c — per-Repeater/Builder set of inner-field names that
|
|
396
|
-
* carry text-shaped leaves eligible for character-level CRDT. Drives
|
|
397
|
-
* `useFieldState(dottedName).textBinding` resolution: only fields in
|
|
398
|
-
* the per-array set go through `binding.getRowTextBinding`; everything
|
|
399
|
-
* else stays on row-level Y.Map LWW.
|
|
400
|
-
*
|
|
401
|
-
* Repeater rows expose their schema directly under `meta.children`;
|
|
402
|
-
* Builder rows nest schemas under `meta.blocks[i].template`. The
|
|
403
|
-
* walker descends through every block's template so a `markdown` leaf
|
|
404
|
-
* inside any block-type lands in the array's allowlist. Nested
|
|
405
|
-
* Repeaters / Builders inside row schemas are out of scope v1 (their
|
|
406
|
-
* dotted paths are 5+ segments and `parseRowFieldPath` rejects them).
|
|
407
|
-
*/
|
|
408
|
-
export function collectRowTextLeavesByArray(formMeta: ElementMeta): Map<string, Set<string>> {
|
|
409
|
-
const out = new Map<string, Set<string>>()
|
|
410
|
-
walkTop(formMeta)
|
|
411
|
-
return out
|
|
412
|
-
|
|
413
|
-
function walkTop(node: ElementMeta): void {
|
|
414
|
-
if (node.type === 'field') {
|
|
415
|
-
const fieldType = String(node['fieldType'] ?? '')
|
|
416
|
-
if (fieldType === 'repeater' || fieldType === 'builder') {
|
|
417
|
-
if ((node as { collab?: boolean }).collab === false) return
|
|
418
|
-
const name = String(node['name'] ?? '')
|
|
419
|
-
if (!name) return
|
|
420
|
-
const set = new Set<string>()
|
|
421
|
-
// Repeater's `toMeta()` emits the row schema under `template` (not
|
|
422
|
-
// `children` — that's per-resolved-row). Builder nests row schemas
|
|
423
|
-
// under `blocks[i].template`. Reading `children` here pre-fix gave
|
|
424
|
-
// every Repeater an empty text-leaf set → row text never CRDT'd.
|
|
425
|
-
if (fieldType === 'repeater') walkRow((node as { template?: unknown }).template, set)
|
|
426
|
-
else walkBlocks((node as { blocks?: unknown }).blocks, set)
|
|
427
|
-
if (set.size > 0) out.set(name, set)
|
|
428
|
-
return
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
const children = node.children
|
|
432
|
-
if (Array.isArray(children)) {
|
|
433
|
-
for (const child of children) walkTop(child as ElementMeta)
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function walkRow(children: unknown, set: Set<string>): void {
|
|
438
|
-
if (!Array.isArray(children)) return
|
|
439
|
-
for (const child of children) walkRowEl(child as ElementMeta, set)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function walkRowEl(node: ElementMeta, set: Set<string>): void {
|
|
443
|
-
if (node.type === 'field') {
|
|
444
|
-
const fieldType = String(node['fieldType'] ?? '')
|
|
445
|
-
if (fieldType === 'repeater' || fieldType === 'builder') return // nested array
|
|
446
|
-
if ((node as { collab?: boolean }).collab === false) return
|
|
447
|
-
const name = String(node['name'] ?? '')
|
|
448
|
-
if (name && ROW_TEXT_FIELD_TYPES.has(fieldType)) set.add(name)
|
|
449
|
-
return
|
|
450
|
-
}
|
|
451
|
-
const children = node.children
|
|
452
|
-
if (Array.isArray(children)) {
|
|
453
|
-
for (const child of children) walkRowEl(child as ElementMeta, set)
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function walkBlocks(blocks: unknown, set: Set<string>): void {
|
|
458
|
-
if (!Array.isArray(blocks)) return
|
|
459
|
-
for (const block of blocks) {
|
|
460
|
-
const tpl = (block as { template?: unknown }).template
|
|
461
|
-
walkRow(tpl, set)
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
package/src/react/index.ts
CHANGED
|
@@ -43,14 +43,18 @@ 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,
|
|
49
55
|
type FormCollabBinding,
|
|
50
56
|
type FormCollabBindingFactory,
|
|
51
57
|
type FormCollabBindingFactoryArgs,
|
|
52
|
-
type TextBinding,
|
|
53
|
-
type TextDelta,
|
|
54
58
|
type RowsEvent,
|
|
55
59
|
type RowBindingApi,
|
|
56
60
|
} from './FormCollabBindingRegistry.js'
|
|
@@ -139,6 +143,12 @@ export {
|
|
|
139
143
|
type UseResizableWidthApi,
|
|
140
144
|
} from './useResizableWidth.js'
|
|
141
145
|
|
|
146
|
+
export {
|
|
147
|
+
CurrentUserProvider,
|
|
148
|
+
useCurrentUser,
|
|
149
|
+
type CurrentUser,
|
|
150
|
+
} from './CurrentUserContext.js'
|
|
151
|
+
|
|
142
152
|
export { ThemeProvider, useTheme } from './ThemeProvider.js'
|
|
143
153
|
export { ThemeToggle } from './ThemeToggle.js'
|
|
144
154
|
export { ThemeSettingsPage } from './ThemeSettingsPage.js'
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { TextDelta } from '../FormCollabBindingRegistry.js';
|
|
2
|
-
/**
|
|
3
|
-
* Phase F.6 — derive a single character-level edit op from two strings.
|
|
4
|
-
*
|
|
5
|
-
* Strategy: find the longest common prefix and suffix between `before`
|
|
6
|
-
* and `after`; whatever's left in the middle is the changed region.
|
|
7
|
-
*
|
|
8
|
-
* - middle-after empty + middle-before non-empty → `delete`
|
|
9
|
-
* - middle-before empty + middle-after non-empty → `insert`
|
|
10
|
-
* - both non-empty → `replace`
|
|
11
|
-
* - both empty (identical strings) → `null`
|
|
12
|
-
*
|
|
13
|
-
* This correctly handles the common edit shapes: single-key insert,
|
|
14
|
-
* single-key backspace, multi-char paste replacing a selection, IME
|
|
15
|
-
* commits, accent-key composition. It does NOT preserve user intent
|
|
16
|
-
* when the same character appears at multiple positions and the edit
|
|
17
|
-
* could be attributed to either occurrence — Yjs's per-character
|
|
18
|
-
* identity makes that distinction lossy at the string-diff layer
|
|
19
|
-
* (the `Y.Text` itself maintains item identity internally). For v1
|
|
20
|
-
* we accept the ambiguity; the CRDT semantics still converge.
|
|
21
|
-
*/
|
|
22
|
-
export declare function computeDelta(before: string, after: string): TextDelta | null;
|
|
23
|
-
/**
|
|
24
|
-
* Phase F.6 — best-effort cursor anchor across a remote-applied edit.
|
|
25
|
-
*
|
|
26
|
-
* - Edit landed AFTER cursor (cursor inside the common prefix) → keep
|
|
27
|
-
* cursor where it is.
|
|
28
|
-
* - Edit landed BEFORE cursor → shift
|
|
29
|
-
* cursor by `after.length − before.length` so its character-offset
|
|
30
|
-
* into the post-edit string matches its pre-edit anchor.
|
|
31
|
-
* - Edit landed OVERLAPPING the cursor → cursor
|
|
32
|
-
* ends up at the boundary between the changed region and the
|
|
33
|
-
* unchanged suffix (which `Math.max(0, cursor + delta)` produces
|
|
34
|
-
* naturally and clamps to the new bounds).
|
|
35
|
-
*
|
|
36
|
-
* This is a heuristic, not a Yjs `RelativePosition`. Two peers typing
|
|
37
|
-
* at the exact same insertion point can still see a one-character cursor
|
|
38
|
-
* twitch on the remote-mirror side; v2 (in-input remote carets) would
|
|
39
|
-
* upgrade to relative positions if/when a consumer asks. Native input
|
|
40
|
-
* cursors are clamped to `[0, after.length]` by every browser, so this
|
|
41
|
-
* function does the same to avoid `setSelectionRange` throwing.
|
|
42
|
-
*/
|
|
43
|
-
export declare function preserveCursor(before: string, after: string, cursor: number): number;
|
|
44
|
-
//# sourceMappingURL=textDelta.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"textDelta.d.ts","sourceRoot":"","sources":["../../../src/react/fields/textDelta.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAA;AAEhE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CA8B5E;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAWpF"}
|