@pilotiq/pilotiq 0.8.1 → 0.9.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 +209 -0
- package/dist/Resource.d.ts +39 -0
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +30 -0
- package/dist/Resource.js.map +1 -1
- package/dist/pageData/navigation.d.ts +17 -1
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +14 -0
- package/dist/pageData/navigation.js.map +1 -1
- package/dist/react/AppShell.d.ts +5 -0
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +1 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/FieldFocusReporterRegistry.d.ts +29 -0
- package/dist/react/FieldFocusReporterRegistry.d.ts.map +1 -0
- package/dist/react/FieldFocusReporterRegistry.js +14 -0
- package/dist/react/FieldFocusReporterRegistry.js.map +1 -0
- package/dist/react/FieldPresenceRegistry.d.ts +38 -0
- package/dist/react/FieldPresenceRegistry.d.ts.map +1 -0
- package/dist/react/FieldPresenceRegistry.js +14 -0
- package/dist/react/FieldPresenceRegistry.js.map +1 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +71 -1
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +17 -0
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +44 -3
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/RecordWrapperGate.d.ts +19 -6
- package/dist/react/RecordWrapperGate.d.ts.map +1 -1
- package/dist/react/RecordWrapperGate.js +18 -8
- package/dist/react/RecordWrapperGate.js.map +1 -1
- package/dist/react/fields/FieldShell.d.ts.map +1 -1
- package/dist/react/fields/FieldShell.js +27 -3
- package/dist/react/fields/FieldShell.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +105 -3
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts +10 -0
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +179 -0
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/fields/textDelta.d.ts +44 -0
- package/dist/react/fields/textDelta.d.ts.map +1 -0
- package/dist/react/fields/textDelta.js +80 -0
- package/dist/react/fields/textDelta.js.map +1 -0
- 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/dist/react/parseRecordEditUrl.d.ts +33 -9
- package/dist/react/parseRecordEditUrl.d.ts.map +1 -1
- package/dist/react/parseRecordEditUrl.js +40 -2
- package/dist/react/parseRecordEditUrl.js.map +1 -1
- package/package.json +1 -1
- package/src/Resource.test.ts +44 -0
- package/src/Resource.ts +58 -0
- package/src/pageData/navigation.ts +32 -1
- package/src/pageData.test.ts +36 -0
- package/src/react/AppShell.tsx +6 -1
- package/src/react/FieldFocusReporterRegistry.ts +37 -0
- package/src/react/FieldPresenceRegistry.ts +46 -0
- package/src/react/FormCollabBindingRegistry.ts +63 -1
- package/src/react/FormStateContext.tsx +62 -3
- package/src/react/RecordWrapperGate.tsx +26 -8
- package/src/react/fields/FieldShell.tsx +39 -2
- package/src/react/fields/MarkdownInput.tsx +100 -3
- package/src/react/fields/TextLikeInput.tsx +203 -1
- package/src/react/fields/textDelta.test.ts +141 -0
- package/src/react/fields/textDelta.ts +86 -0
- package/src/react/index.ts +20 -1
- package/src/react/parseRecordEditUrl.test.ts +48 -1
- package/src/react/parseRecordEditUrl.ts +52 -13
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
import { runJsHandler } from './fieldJsHandler.js'
|
|
19
19
|
import { useToast } from './Toaster.js'
|
|
20
20
|
import { useCollabRoom } from './CollabRoomContext.js'
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
getFormCollabBinding,
|
|
23
|
+
type FormCollabBinding,
|
|
24
|
+
type TextBinding,
|
|
25
|
+
} from './FormCollabBindingRegistry.js'
|
|
22
26
|
|
|
23
27
|
export type FieldStatus = 'idle' | 'pending'
|
|
24
28
|
|
|
@@ -40,6 +44,13 @@ export interface FormStateApi {
|
|
|
40
44
|
formMeta: ElementMeta
|
|
41
45
|
inFlight: boolean
|
|
42
46
|
fieldStatus: (name: string) => FieldStatus
|
|
47
|
+
/** Phase F.6 — per-field text-CRDT handles stashed at collab-room mount.
|
|
48
|
+
* `null` outside a room or before the binding effect has populated the
|
|
49
|
+
* map. The text/non-text allowlist lives in the binding impl —
|
|
50
|
+
* `FormStateProvider` asks for every top-level field and only stashes
|
|
51
|
+
* non-null answers, so a `Map.get()` hit means the binding has opted
|
|
52
|
+
* this field into the character-level path. */
|
|
53
|
+
textBindings: ReadonlyMap<string, TextBinding> | null
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
const FormStateContext = createContext<FormStateApi | null>(null)
|
|
@@ -93,6 +104,15 @@ export interface UseFieldStateResult {
|
|
|
93
104
|
/** True while a live re-resolve POST is in flight for this field. */
|
|
94
105
|
pending: boolean
|
|
95
106
|
errors: string[]
|
|
107
|
+
/** Phase F.6 — character-level CRDT handle for text-shaped fields when
|
|
108
|
+
* a collab room is mounted up-tree AND the binding strategy applies
|
|
109
|
+
* (allowlist + `.collab() !== false`). Null in every other case —
|
|
110
|
+
* outside a `FormStateProvider`, outside a `<RecordCollabRoom>`, on
|
|
111
|
+
* non-text fields, on dotted-path inner-Repeater rows (deferred to
|
|
112
|
+
* F.5), and on text fields opted out via `.collab(false)`. Text input
|
|
113
|
+
* renderers branch on this: non-null → character-level path with
|
|
114
|
+
* `applyDelta + observe`; null → today's whole-string LWW path. */
|
|
115
|
+
textBinding: TextBinding | null
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
/** Per-field accessor. Inside a `FormStateProvider` it returns the controlled
|
|
@@ -108,6 +128,7 @@ export function useFieldState(name: string): UseFieldStateResult {
|
|
|
108
128
|
triggerLive: () => {},
|
|
109
129
|
pending: false,
|
|
110
130
|
errors: [],
|
|
131
|
+
textBinding: null,
|
|
111
132
|
}
|
|
112
133
|
}
|
|
113
134
|
// Dotted-path fields (inner Repeater rows) always render uncontrolled
|
|
@@ -120,6 +141,11 @@ export function useFieldState(name: string): UseFieldStateResult {
|
|
|
120
141
|
triggerLive: (valueOverride?: unknown) => ctx.triggerLive(name, valueOverride),
|
|
121
142
|
pending: ctx.fieldStatus(name) === 'pending',
|
|
122
143
|
errors: ctx.errors[name] ?? [],
|
|
144
|
+
// Phase F.6 — dotted-path inner-Repeater rows skipped in v1 (deferred
|
|
145
|
+
// to F.5 alongside Y.Array row identity). Outside a collab room or
|
|
146
|
+
// for non-text fields, the stash returns null and the renderer falls
|
|
147
|
+
// back to today's whole-string LWW path.
|
|
148
|
+
textBinding: dotted ? null : (ctx.textBindings?.get(name) ?? null),
|
|
123
149
|
}
|
|
124
150
|
}
|
|
125
151
|
|
|
@@ -170,6 +196,13 @@ export function FormStateProvider({
|
|
|
170
196
|
const [errors, setErrors] = useState<Record<string, string[]>>(initialErrors)
|
|
171
197
|
const [pendingNames, setPendingNames] = useState<Set<string>>(() => new Set())
|
|
172
198
|
const [inFlight, setInFlight] = useState(false)
|
|
199
|
+
// Phase F.6 — per-field text-CRDT stash. `null` until the collab effect
|
|
200
|
+
// populates it; stays `null` outside a collab room. Stored in state (not
|
|
201
|
+
// a ref) so consumers of `useFieldState` re-render once the bindings
|
|
202
|
+
// land. One extra render after collab-mount; acceptable since the
|
|
203
|
+
// existing `setValuesState` overlay below already triggers one when the
|
|
204
|
+
// room has pre-existing state.
|
|
205
|
+
const [textBindings, setTextBindings] = useState<ReadonlyMap<string, TextBinding> | null>(null)
|
|
173
206
|
|
|
174
207
|
const { notify } = useToast()
|
|
175
208
|
|
|
@@ -215,7 +248,12 @@ export function FormStateProvider({
|
|
|
215
248
|
useEffect(() => {
|
|
216
249
|
if (!collabRoom || !bindingFactory || !formId) return
|
|
217
250
|
|
|
218
|
-
const binding = bindingFactory({
|
|
251
|
+
const binding = bindingFactory({
|
|
252
|
+
room: collabRoom,
|
|
253
|
+
formId,
|
|
254
|
+
initial: valuesRef.current,
|
|
255
|
+
formMeta: formMetaRef.current,
|
|
256
|
+
})
|
|
219
257
|
bindingRef.current = binding
|
|
220
258
|
|
|
221
259
|
// Lift any state already in the room (subsequent joiners — first
|
|
@@ -228,6 +266,25 @@ export function FormStateProvider({
|
|
|
228
266
|
setValuesState((prev) => ({ ...prev, ...synced }))
|
|
229
267
|
}
|
|
230
268
|
|
|
269
|
+
// Phase F.6 — ask the binding for a `TextBinding` on every top-level
|
|
270
|
+
// field name. The text/non-text allowlist lives in the binding impl,
|
|
271
|
+
// not in core: the binding returns `null` for non-text fields and
|
|
272
|
+
// text fields opted out via `.collab(false)`. `getTextBinding` is
|
|
273
|
+
// optional on the contract — F1-era plugins that haven't implemented
|
|
274
|
+
// it short-circuit the whole stash and every text field stays on the
|
|
275
|
+
// LWW path. We stash only the non-null answers. Cleanup is owned by
|
|
276
|
+
// `binding.destroy()` (expected to cascade into every issued
|
|
277
|
+
// `TextBinding`).
|
|
278
|
+
if (binding.getTextBinding) {
|
|
279
|
+
const textStash = new Map<string, TextBinding>()
|
|
280
|
+
for (const fieldName of Object.keys(valuesRef.current)) {
|
|
281
|
+
if (fieldName.includes('.')) continue
|
|
282
|
+
const tb = binding.getTextBinding(fieldName)
|
|
283
|
+
if (tb) textStash.set(fieldName, tb)
|
|
284
|
+
}
|
|
285
|
+
if (textStash.size > 0) setTextBindings(textStash)
|
|
286
|
+
}
|
|
287
|
+
|
|
231
288
|
// Subscribe to remote changes. Local writes ALSO trigger this
|
|
232
289
|
// (Yjs observers fire on local transactions too) — the per-key
|
|
233
290
|
// Object.is short-circuit below collapses them into no-op renders.
|
|
@@ -249,6 +306,7 @@ export function FormStateProvider({
|
|
|
249
306
|
unsubscribe()
|
|
250
307
|
binding.destroy()
|
|
251
308
|
bindingRef.current = null
|
|
309
|
+
setTextBindings(null)
|
|
252
310
|
}
|
|
253
311
|
// `valuesRef.current` is intentionally read once at mount — initial
|
|
254
312
|
// values seed the binding; subsequent edits flow through `setValue`
|
|
@@ -479,7 +537,8 @@ export function FormStateProvider({
|
|
|
479
537
|
formMeta,
|
|
480
538
|
inFlight,
|
|
481
539
|
fieldStatus,
|
|
482
|
-
|
|
540
|
+
textBindings,
|
|
541
|
+
}), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings])
|
|
483
542
|
|
|
484
543
|
return (
|
|
485
544
|
<FormStateContext.Provider value={api}>
|
|
@@ -1,37 +1,55 @@
|
|
|
1
1
|
import { type ReactNode } from 'react'
|
|
2
2
|
import { getRecordWrapper } from './RecordWrapperRegistry.js'
|
|
3
|
-
import {
|
|
3
|
+
import { parseRecordPageUrl } from './parseRecordEditUrl.js'
|
|
4
|
+
import type { ResourceCollabConfig } from '../Resource.js'
|
|
5
|
+
|
|
6
|
+
/** Per-resource collab opt-in keyed by URL slug (`R.getSlug()` for
|
|
7
|
+
* non-clustered, `${cluster.slug}/${R.getSlug()}` for clustered). Built
|
|
8
|
+
* server-side by `panelInfo()` as `recordCollab`. */
|
|
9
|
+
export type RecordCollabMap = Record<string, ResourceCollabConfig>
|
|
4
10
|
|
|
5
11
|
export interface RecordWrapperGateProps {
|
|
6
12
|
currentPath?: string
|
|
7
13
|
basePath: string
|
|
14
|
+
/** Resource opt-in map. Absent means no resource opted in (or the
|
|
15
|
+
* panel has no resources) — gate always passes through. */
|
|
16
|
+
recordCollab?: RecordCollabMap
|
|
8
17
|
children: ReactNode
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
/**
|
|
12
21
|
* Conditionally wraps the page tree with the plugin-registered
|
|
13
|
-
* `RecordWrapper` when the current URL resolves to a record-bound
|
|
14
|
-
*
|
|
22
|
+
* `RecordWrapper` when the current URL resolves to a record-bound page
|
|
23
|
+
* AND the underlying resource has opted into collab on that page role.
|
|
24
|
+
* Pass-through in every other case:
|
|
15
25
|
*
|
|
16
26
|
* - no plugin registered a wrapper (`getRecordWrapper() === null`)
|
|
17
|
-
* - the URL isn't a record
|
|
27
|
+
* - the URL isn't a record edit/view page
|
|
18
28
|
* - `currentPath` not yet known on the very first SSR render
|
|
29
|
+
* - the resource has not opted in via `static collab` (or has opted in
|
|
30
|
+
* but excluded the current page role)
|
|
19
31
|
*
|
|
20
32
|
* Mounted once inside `AppShell` around the page content area so
|
|
21
33
|
* record-scoped plugins (collab room, audit trail, …) get one
|
|
22
34
|
* lifetimed mount per record-view-or-edit without each plugin having
|
|
23
35
|
* to thread URL parsing into its own provider.
|
|
24
36
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
37
|
+
* v1 caveat: nested-relation edit URLs (`/articles/:parentId/comments/:childId/edit`)
|
|
38
|
+
* have a dynamic-id segment baked into the URL slug, so they don't match
|
|
39
|
+
* the resource-keyed `recordCollab` map and fall through to no-collab.
|
|
40
|
+
* Collab on nested-relation edits is a follow-up.
|
|
27
41
|
*/
|
|
28
|
-
export function RecordWrapperGate({ currentPath, basePath, children }: RecordWrapperGateProps) {
|
|
42
|
+
export function RecordWrapperGate({ currentPath, basePath, recordCollab, children }: RecordWrapperGateProps) {
|
|
29
43
|
const Wrapper = getRecordWrapper()
|
|
30
44
|
if (!Wrapper || !currentPath) return <>{children}</>
|
|
31
45
|
|
|
32
|
-
const identity =
|
|
46
|
+
const identity = parseRecordPageUrl(currentPath, basePath)
|
|
33
47
|
if (!identity) return <>{children}</>
|
|
34
48
|
|
|
49
|
+
const cfg = recordCollab?.[identity.resourceSlug]
|
|
50
|
+
if (!cfg) return <>{children}</>
|
|
51
|
+
if (!cfg.pages.includes(identity.role)) return <>{children}</>
|
|
52
|
+
|
|
35
53
|
return (
|
|
36
54
|
<Wrapper resourceSlug={identity.resourceSlug} recordId={identity.recordId}>
|
|
37
55
|
{children}
|
|
@@ -5,6 +5,8 @@ import { usePendingSuggestions, usePendingSuggestionsForField, type PendingSugge
|
|
|
5
5
|
import { getPendingSuggestionOverlay } from '../PendingSuggestionOverlayRegistry.js'
|
|
6
6
|
import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
|
|
7
7
|
import { FormIdContext, useFieldState } from '../FormStateContext.js'
|
|
8
|
+
import { getFieldPresenceComponent } from '../FieldPresenceRegistry.js'
|
|
9
|
+
import { getFieldFocusReporter } from '../FieldFocusReporterRegistry.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Field types whose visible state is driven by React (not by a matching
|
|
@@ -140,10 +142,25 @@ export function FieldShell({ el, name, label, required, children, before, after,
|
|
|
140
142
|
const labelClass = hiddenLabel
|
|
141
143
|
? 'sr-only'
|
|
142
144
|
: 'text-sm font-medium leading-none'
|
|
145
|
+
// Phase F4 — presence chip + focus reporter slots. The chip mounts
|
|
146
|
+
// alongside the label so remote-focus indicators don't shift the
|
|
147
|
+
// input geometry; the focus reporter sits on the outer wrapper using
|
|
148
|
+
// capture-phase listeners so any inner-input focus event flows
|
|
149
|
+
// through. Both slots are gated on `meta.collab !== false` (Q3 from
|
|
150
|
+
// the F-plan — opted-out fields are fully invisible to the collab
|
|
151
|
+
// layer) AND on the field having a stable top-level name (dotted-path
|
|
152
|
+
// Repeater rows skip presence in v1 — Phase F.5).
|
|
153
|
+
const collabOptedOut = (el as { collab?: boolean })['collab'] === false
|
|
154
|
+
const dottedName = name.includes('.')
|
|
155
|
+
const presenceSlotEligible = !collabOptedOut && !dottedName
|
|
156
|
+
const PresenceChip = presenceSlotEligible ? getFieldPresenceComponent() : null
|
|
157
|
+
const focusReporter = presenceSlotEligible ? getFieldFocusReporter() : null
|
|
158
|
+
|
|
143
159
|
const labelEl = label !== '' ? (
|
|
144
160
|
<label htmlFor={name} className={labelClass}>
|
|
145
161
|
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
|
146
162
|
{labelSlot}
|
|
163
|
+
{PresenceChip && <PresenceChip fieldName={name} formId={formId ?? ''} />}
|
|
147
164
|
</label>
|
|
148
165
|
) : null
|
|
149
166
|
|
|
@@ -177,9 +194,24 @@ export function FieldShell({ el, name, label, required, children, before, after,
|
|
|
177
194
|
</>
|
|
178
195
|
)
|
|
179
196
|
|
|
197
|
+
// Capture-phase focus / blur dispatch — runs even when the inner
|
|
198
|
+
// input is wrapped in custom NodeViews (Select / Date / Slider). One
|
|
199
|
+
// wrapper-level handler covers every controlled input in the tree.
|
|
200
|
+
const onFocusCapture = focusReporter
|
|
201
|
+
? () => focusReporter.onFocus({ fieldName: name, formId: formId ?? '' })
|
|
202
|
+
: undefined
|
|
203
|
+
const onBlurCapture = focusReporter
|
|
204
|
+
? () => focusReporter.onBlur({ fieldName: name, formId: formId ?? '' })
|
|
205
|
+
: undefined
|
|
206
|
+
|
|
180
207
|
if (inline) {
|
|
181
208
|
return (
|
|
182
|
-
<div
|
|
209
|
+
<div
|
|
210
|
+
className="flex items-baseline gap-3"
|
|
211
|
+
{...wrapperAttrs}
|
|
212
|
+
{...(onFocusCapture ? { onFocusCapture } : {})}
|
|
213
|
+
{...(onBlurCapture ? { onBlurCapture } : {})}
|
|
214
|
+
>
|
|
183
215
|
{labelEl && <div className="min-w-32 pt-2">{labelEl}</div>}
|
|
184
216
|
<div className="min-w-0 flex-1">
|
|
185
217
|
{inputBlock}
|
|
@@ -192,7 +224,12 @@ export function FieldShell({ el, name, label, required, children, before, after,
|
|
|
192
224
|
}
|
|
193
225
|
|
|
194
226
|
return (
|
|
195
|
-
<div
|
|
227
|
+
<div
|
|
228
|
+
className="flex flex-col gap-1.5"
|
|
229
|
+
{...wrapperAttrs}
|
|
230
|
+
{...(onFocusCapture ? { onFocusCapture } : {})}
|
|
231
|
+
{...(onBlurCapture ? { onBlurCapture } : {})}
|
|
232
|
+
>
|
|
196
233
|
{labelEl}
|
|
197
234
|
{inputBlock}
|
|
198
235
|
{helperText && (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useMemo, useRef, useState } from 'react'
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { marked } from 'marked'
|
|
3
3
|
import {
|
|
4
4
|
BoldIcon, ItalicIcon, StrikethroughIcon, LinkIcon,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { useFieldState } from '../FormStateContext.js'
|
|
9
9
|
import { useToast } from '../Toaster.js'
|
|
10
10
|
import { Button } from '../ui/button.js'
|
|
11
|
+
import { computeDelta, preserveCursor } from './textDelta.js'
|
|
11
12
|
|
|
12
13
|
type ToolbarButton =
|
|
13
14
|
| 'bold' | 'italic' | 'strike' | 'link'
|
|
@@ -47,14 +48,90 @@ export function MarkdownInput({
|
|
|
47
48
|
const fs = useFieldState(name)
|
|
48
49
|
const { notify } = useToast()
|
|
49
50
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
|
51
|
+
// Phase F.6 — IME composition gate. Set between `compositionstart` /
|
|
52
|
+
// `compositionend`; the textarea's onChange skips `applyDelta` while
|
|
53
|
+
// composing so intermediate chars don't emit ops. Lives at the
|
|
54
|
+
// component scope so the onChange and composition handlers share it.
|
|
55
|
+
const isComposingRef = useRef<boolean>(false)
|
|
50
56
|
|
|
51
57
|
const initial = useMemo(() => stringValue(defaultValue), [])
|
|
52
58
|
const [localValue, setLocalValue] = useState<string>(initial)
|
|
53
59
|
const [tab, setTab] = useState<'write' | 'preview'>('write')
|
|
54
60
|
const [busy, setBusy] = useState(false)
|
|
55
61
|
|
|
56
|
-
|
|
62
|
+
// Phase F.6 — when a `<RecordCollabRoom>` is mounted and the field has
|
|
63
|
+
// a `TextBinding`, the textarea is bound to a `Y.Text` and edits emit
|
|
64
|
+
// `TextDelta`s. Mirrors the architecture in `TextLikeInput.tsx` but
|
|
65
|
+
// wired in-line because MarkdownInput has its own toolbar + Preview
|
|
66
|
+
// tab that also need to flow through the binding.
|
|
67
|
+
const binding = fs.textBinding
|
|
68
|
+
const [boundValue, setBoundValue] = useState<string>(() => binding?.read() ?? initial)
|
|
69
|
+
const boundValueRef = useRef<string>(boundValue)
|
|
70
|
+
useEffect(() => { boundValueRef.current = boundValue }, [boundValue])
|
|
71
|
+
|
|
72
|
+
// On binding swap: read current Y.Text state. If non-empty, lift it
|
|
73
|
+
// into local + form-map state. If empty (no peer has typed yet), leave
|
|
74
|
+
// the SSR-default-derived `boundValue` showing — first edit will
|
|
75
|
+
// emit a replace-from-empty delta that atomically populates Y.Text.
|
|
76
|
+
// No client-side seed: Y.Text isn't safe to seed under concurrent
|
|
77
|
+
// first-mounters (see @pilotiq-pro/collab `formCollabBinding.ts`).
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!binding) return
|
|
80
|
+
const next = binding.read()
|
|
81
|
+
if (next.length > 0) {
|
|
82
|
+
setBoundValue(next)
|
|
83
|
+
boundValueRef.current = next
|
|
84
|
+
fs.setValue(next)
|
|
85
|
+
}
|
|
86
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
+
}, [binding])
|
|
88
|
+
|
|
89
|
+
// Subscribe to remote changes. Local-echoes are filtered by the
|
|
90
|
+
// `next === prev` guard. Cursor preserved via the same heuristic
|
|
91
|
+
// used in `TextLikeInput.BoundTextInput`.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!binding) return
|
|
94
|
+
return binding.observe((next) => {
|
|
95
|
+
const prev = boundValueRef.current
|
|
96
|
+
if (next === prev) return
|
|
97
|
+
const ta = textareaRef.current
|
|
98
|
+
const cursor = ta?.selectionStart ?? next.length
|
|
99
|
+
const restored = preserveCursor(prev, next, cursor)
|
|
100
|
+
setBoundValue(next)
|
|
101
|
+
boundValueRef.current = next
|
|
102
|
+
fs.setValue(next)
|
|
103
|
+
requestAnimationFrame(() => {
|
|
104
|
+
if (!ta) return
|
|
105
|
+
if (document.activeElement !== ta) return
|
|
106
|
+
try { ta.setSelectionRange(restored, restored) } catch { /* defensive */ }
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
}, [binding])
|
|
111
|
+
|
|
112
|
+
const value = binding
|
|
113
|
+
? boundValue
|
|
114
|
+
: (fs.controlled ? stringValue(fs.value) : localValue)
|
|
115
|
+
|
|
57
116
|
const setValue = (next: string): void => {
|
|
117
|
+
if (binding) {
|
|
118
|
+
// Compute against current Y.Text contents (not the local ref) so:
|
|
119
|
+
// - first edit against empty Y.Text → `insert@0 <whole>` atomic
|
|
120
|
+
// populate (no separate seed op needed);
|
|
121
|
+
// - after a remote-applied update or server-resolve replace, the
|
|
122
|
+
// delta reflects the actual current shared state, not stale
|
|
123
|
+
// local bookkeeping.
|
|
124
|
+
const before = binding.read()
|
|
125
|
+
if (next !== before) {
|
|
126
|
+
const delta = computeDelta(before, next)
|
|
127
|
+
if (delta) binding.applyDelta(delta)
|
|
128
|
+
setBoundValue(next)
|
|
129
|
+
boundValueRef.current = next
|
|
130
|
+
}
|
|
131
|
+
fs.setValue(next)
|
|
132
|
+
fs.triggerLive(next)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
58
135
|
if (fs.controlled) { fs.setValue(next); fs.triggerLive(next) }
|
|
59
136
|
else { setLocalValue(next); fs.triggerLive(next) }
|
|
60
137
|
}
|
|
@@ -285,7 +362,27 @@ export function MarkdownInput({
|
|
|
285
362
|
placeholder={placeholder}
|
|
286
363
|
disabled={disabled}
|
|
287
364
|
{...(fs.controlled
|
|
288
|
-
? {
|
|
365
|
+
? {
|
|
366
|
+
value,
|
|
367
|
+
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
368
|
+
// Phase F.6 — when the binding is active and the user
|
|
369
|
+
// is mid-IME, paint locally and hold the delta until
|
|
370
|
+
// compositionend so we never emit ops for the
|
|
371
|
+
// intermediate composing chars.
|
|
372
|
+
if (binding && isComposingRef.current) {
|
|
373
|
+
setBoundValue(e.target.value)
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
setValue(e.target.value)
|
|
377
|
+
},
|
|
378
|
+
...(binding ? {
|
|
379
|
+
onCompositionStart: () => { isComposingRef.current = true },
|
|
380
|
+
onCompositionEnd: (e: React.CompositionEvent<HTMLTextAreaElement>) => {
|
|
381
|
+
isComposingRef.current = false
|
|
382
|
+
setValue(e.currentTarget.value)
|
|
383
|
+
},
|
|
384
|
+
} : {}),
|
|
385
|
+
}
|
|
289
386
|
: { defaultValue: initial, onChange: (e) => setLocalValue(e.target.value) })}
|
|
290
387
|
onPaste={onPaste}
|
|
291
388
|
onKeyDown={onKeyDown}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import React from 'react'
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import type { ElementMeta } from '../../schema/Element.js'
|
|
3
|
+
import type { TextBinding } from '../FormCollabBindingRegistry.js'
|
|
3
4
|
import { useFieldState } from '../FormStateContext.js'
|
|
4
5
|
import { Input } from '../ui/input.js'
|
|
5
6
|
import { Textarea } from '../ui/textarea.js'
|
|
7
|
+
import { computeDelta, preserveCursor } from './textDelta.js'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Bridge between controlled (FormStateProvider) and uncontrolled
|
|
@@ -10,6 +12,16 @@ import { Textarea } from '../ui/textarea.js'
|
|
|
10
12
|
* `live()` fields, the input is bound to the context's values map and
|
|
11
13
|
* fires the live trigger on change/blur according to the field's `live`
|
|
12
14
|
* config. Outside a controlled form, falls back to plain `defaultValue`.
|
|
15
|
+
*
|
|
16
|
+
* **Phase F.6 — character-level CRDT branch.** When a `<RecordCollabRoom>`
|
|
17
|
+
* is mounted up-tree AND `@pilotiq-pro/collab`'s binding registered a
|
|
18
|
+
* `TextBinding` for this field (text-shaped fieldType + `.collab() !== false`),
|
|
19
|
+
* the input takes the `BoundTextInput` path: edits emit `TextDelta`s to
|
|
20
|
+
* the binding's `Y.Text`, remote changes flow back via `observe`, and
|
|
21
|
+
* cursor position survives both. The legacy whole-string LWW path
|
|
22
|
+
* still runs for non-text fields, non-collab forms, and masked inputs
|
|
23
|
+
* (mask + character-level CRDT is incompatible — peers would see raw
|
|
24
|
+
* keystrokes desynced from the rendered mask).
|
|
13
25
|
*/
|
|
14
26
|
export function TextLikeInput({
|
|
15
27
|
el, name, common, type, extraProps, multiline, applyMask,
|
|
@@ -33,6 +45,28 @@ export function TextLikeInput({
|
|
|
33
45
|
const onBlurMode = liveOpts.onBlur === true
|
|
34
46
|
const mask = applyMask ?? identity
|
|
35
47
|
|
|
48
|
+
// Phase F.6 — character-level CRDT path. Masking is mutually exclusive
|
|
49
|
+
// with character-level CRDT (peers would see raw keystrokes diverged
|
|
50
|
+
// from the local mask render); masked fields fall through to LWW.
|
|
51
|
+
// We read the mask from the field meta directly — `applyMask` is a
|
|
52
|
+
// `useCallback`-wrapped fn that's *always* defined (identity when no
|
|
53
|
+
// mask), so its truthiness can't gate the branch.
|
|
54
|
+
const hasMask = typeof el['mask'] === 'string'
|
|
55
|
+
if (fs.textBinding && !hasMask) {
|
|
56
|
+
return (
|
|
57
|
+
<BoundTextInput
|
|
58
|
+
binding={fs.textBinding}
|
|
59
|
+
name={name}
|
|
60
|
+
triggerLive={fs.triggerLive}
|
|
61
|
+
onBlurMode={onBlurMode}
|
|
62
|
+
common={common}
|
|
63
|
+
extraProps={extraProps}
|
|
64
|
+
type={type}
|
|
65
|
+
multiline={multiline}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
36
70
|
if (fs.controlled) {
|
|
37
71
|
const ctxValue = fs.value !== undefined && fs.value !== null ? String(fs.value) : ''
|
|
38
72
|
const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
|
@@ -71,4 +105,172 @@ export function TextLikeInput({
|
|
|
71
105
|
return <Input {...(common as React.ComponentProps<typeof Input>)} type={type} {...extraProps} />
|
|
72
106
|
}
|
|
73
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Phase F.6 — CRDT-bound text input. Owns its own controlled state
|
|
110
|
+
* because the binding's `Y.Text` is the source of truth (not the
|
|
111
|
+
* form's `values` map). Mirrors every committed value back into the
|
|
112
|
+
* form context via `fs.setValue` so submission / live re-resolve see
|
|
113
|
+
* the latest string.
|
|
114
|
+
*
|
|
115
|
+
* Lifecycle:
|
|
116
|
+
* - Mount: seed local state from `binding.read()`; mirror it into
|
|
117
|
+
* the form's `values` map.
|
|
118
|
+
* - Local edit: compute a `TextDelta` (insert / delete / replace)
|
|
119
|
+
* from the before/after strings and `applyDelta` to the binding.
|
|
120
|
+
* Eagerly update local state in the same React render so the
|
|
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).
|
|
130
|
+
*/
|
|
131
|
+
function BoundTextInput({
|
|
132
|
+
binding, name, triggerLive, onBlurMode, common, extraProps, type, multiline,
|
|
133
|
+
}: {
|
|
134
|
+
binding: TextBinding
|
|
135
|
+
name: string
|
|
136
|
+
triggerLive: (valueOverride?: unknown) => void
|
|
137
|
+
onBlurMode: boolean
|
|
138
|
+
common: Record<string, unknown>
|
|
139
|
+
extraProps: Record<string, unknown>
|
|
140
|
+
type: string
|
|
141
|
+
multiline: boolean
|
|
142
|
+
}): React.ReactElement {
|
|
143
|
+
const fs = useFieldState(name)
|
|
144
|
+
// SSR-rendered default. Captured once at mount; used as display
|
|
145
|
+
// fallback while the room's `Y.Text` is still empty (the seed race
|
|
146
|
+
// for Y.Text isn't safe across concurrent first-mounters, so no peer
|
|
147
|
+
// populates it client-side — see `@pilotiq-pro/collab` for the
|
|
148
|
+
// rationale). First user edit emits a replace-from-empty delta that
|
|
149
|
+
// atomically lifts the displayed value into the CRDT.
|
|
150
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
151
|
+
const fallback = useMemo(() => stringValue(fs.value), [])
|
|
152
|
+
const [value, setValueLocal] = useState<string>(() => binding.read() || fallback)
|
|
153
|
+
const valueRef = useRef<string>(value)
|
|
154
|
+
const isComposing = useRef<boolean>(false)
|
|
155
|
+
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
|
156
|
+
|
|
157
|
+
useEffect(() => { valueRef.current = value }, [value])
|
|
158
|
+
|
|
159
|
+
// Stable ref to the form-mirror writer so the observer effect below
|
|
160
|
+
// doesn't tear down on every render (fs.setValue is a fresh arrow on
|
|
161
|
+
// every useFieldState call).
|
|
162
|
+
const mirrorRef = useRef<(v: string) => void>(() => {})
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
mirrorRef.current = (v: string): void => { fs.setValue(v) }
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// On mount / binding swap: read the binding's current state. If
|
|
168
|
+
// non-empty (i.e. someone else has already typed), display it and
|
|
169
|
+
// mirror into the form values map. If empty, leave the fallback
|
|
170
|
+
// showing — no client-side seed (see file-header comment).
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const initial = binding.read()
|
|
173
|
+
if (initial.length > 0) {
|
|
174
|
+
setValueLocal(initial)
|
|
175
|
+
valueRef.current = initial
|
|
176
|
+
mirrorRef.current(initial)
|
|
177
|
+
}
|
|
178
|
+
}, [binding])
|
|
179
|
+
|
|
180
|
+
// Subscribe to text-CRDT changes. Yjs fires this for BOTH local and
|
|
181
|
+
// remote transactions — local echoes are collapsed by the
|
|
182
|
+
// `next === prev` guard.
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const unsubscribe = binding.observe((next) => {
|
|
185
|
+
const prev = valueRef.current
|
|
186
|
+
if (next === prev) return
|
|
187
|
+
const el = inputRef.current
|
|
188
|
+
const cursor = el?.selectionStart ?? next.length
|
|
189
|
+
const restored = preserveCursor(prev, next, cursor)
|
|
190
|
+
setValueLocal(next)
|
|
191
|
+
valueRef.current = next
|
|
192
|
+
mirrorRef.current(next)
|
|
193
|
+
// Defer cursor restore until after React commits. Only reapply
|
|
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} />
|
|
268
|
+
}
|
|
269
|
+
|
|
74
270
|
function identity(v: string): string { return v }
|
|
271
|
+
|
|
272
|
+
function stringValue(v: unknown): string {
|
|
273
|
+
if (v === undefined || v === null) return ''
|
|
274
|
+
if (typeof v === 'string') return v
|
|
275
|
+
return String(v)
|
|
276
|
+
}
|