@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.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +21 -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 +17 -84
  16. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  17. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  18. package/dist/react/FormStateContext.d.ts +1 -35
  19. package/dist/react/FormStateContext.d.ts.map +1 -1
  20. package/dist/react/FormStateContext.js +7 -91
  21. package/dist/react/FormStateContext.js.map +1 -1
  22. package/dist/react/RowCoordsContext.d.ts +19 -0
  23. package/dist/react/RowCoordsContext.d.ts.map +1 -0
  24. package/dist/react/RowCoordsContext.js +6 -0
  25. package/dist/react/RowCoordsContext.js.map +1 -0
  26. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  27. package/dist/react/fields/BuilderInput.js +14 -9
  28. package/dist/react/fields/BuilderInput.js.map +1 -1
  29. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  30. package/dist/react/fields/MarkdownInput.js +70 -101
  31. package/dist/react/fields/MarkdownInput.js.map +1 -1
  32. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  33. package/dist/react/fields/RepeaterInput.js +26 -17
  34. package/dist/react/fields/RepeaterInput.js.map +1 -1
  35. package/dist/react/fields/TextLikeInput.d.ts +11 -9
  36. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  37. package/dist/react/fields/TextLikeInput.js +111 -164
  38. package/dist/react/fields/TextLikeInput.js.map +1 -1
  39. package/dist/react/formStateHelpers.d.ts +0 -15
  40. package/dist/react/formStateHelpers.d.ts.map +1 -1
  41. package/dist/react/formStateHelpers.js +0 -91
  42. package/dist/react/formStateHelpers.js.map +1 -1
  43. package/dist/react/index.d.ts +3 -1
  44. package/dist/react/index.d.ts.map +1 -1
  45. package/dist/react/index.js +2 -0
  46. package/dist/react/index.js.map +1 -1
  47. package/package.json +5 -5
  48. package/src/react/AppShell.tsx +11 -1
  49. package/src/react/CollabTextRendererRegistry.ts +84 -0
  50. package/src/react/CurrentUserContext.tsx +50 -0
  51. package/src/react/FormCollabBindingRegistry.ts +17 -77
  52. package/src/react/FormStateContext.tsx +6 -125
  53. package/src/react/RowCoordsContext.tsx +23 -0
  54. package/src/react/fields/BuilderInput.tsx +22 -10
  55. package/src/react/fields/MarkdownInput.tsx +125 -95
  56. package/src/react/fields/RepeaterInput.tsx +41 -16
  57. package/src/react/fields/TextLikeInput.tsx +147 -181
  58. package/src/react/formStateHelpers.test.ts +0 -99
  59. package/src/react/formStateHelpers.ts +0 -83
  60. package/src/react/index.ts +12 -2
  61. package/dist/react/fields/textDelta.d.ts +0 -44
  62. package/dist/react/fields/textDelta.d.ts.map +0 -1
  63. package/dist/react/fields/textDelta.js +0 -80
  64. package/dist/react/fields/textDelta.js.map +0 -1
  65. package/src/react/fields/textDelta.test.ts +0 -141
  66. package/src/react/fields/textDelta.ts +0 -86
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/pilotiq",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "View-based admin panel for RudderJS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -81,10 +81,10 @@
81
81
  },
82
82
  "devDependencies": {
83
83
  "@base-ui/react": "^1",
84
- "@rudderjs/contracts": "^1.3.0",
85
- "@rudderjs/core": "^1.1.2",
86
- "@rudderjs/router": "^1.1.2",
87
- "@rudderjs/view": "^1.0.1",
84
+ "@rudderjs/contracts": "^1.6.0",
85
+ "@rudderjs/core": "^1.1.4",
86
+ "@rudderjs/router": "^1.2.0",
87
+ "@rudderjs/view": "^1.1.0",
88
88
  "@types/node": "^20",
89
89
  "@types/react": "^19",
90
90
  "@types/sanitize-html": "^2.16.1",
@@ -16,6 +16,7 @@ import type { NavItem, UserMenuMeta, DatabaseNotificationsMeta, RightSidebarMeta
16
16
  import type { RenderHookMap } from '../RenderHook.js'
17
17
  import { RenderHookSlot } from './RenderHookSlot.js'
18
18
  import type { ComponentSlotRegistry } from './component-slots.js'
19
+ import { CurrentUserProvider } from './CurrentUserContext.js'
19
20
 
20
21
  export interface AppShellProps {
21
22
  panel: {
@@ -180,7 +181,16 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
180
181
  props.basePath,
181
182
  )
182
183
 
183
- return wrapped
184
+ // `CurrentUserProvider` sits OUTSIDE the layout-provider chain so
185
+ // plugin-registered providers (e.g. `@pilotiq-pro/collab`'s
186
+ // CollabProvider, which threads the user into CollaborationCaret
187
+ // presence labels) can read the active user via `useCurrentUser()`.
188
+ // Value source mirrors what the top-right user dropdown renders.
189
+ return (
190
+ <CurrentUserProvider value={props.panel.userMenu?.user ?? null}>
191
+ {wrapped}
192
+ </CurrentUserProvider>
193
+ )
184
194
  }
185
195
 
186
196
  /**
@@ -0,0 +1,84 @@
1
+ import type { ComponentType } from 'react'
2
+
3
+ /**
4
+ * Module-level registry slot for the collab-aware plain-text editor renderer.
5
+ *
6
+ * Wiring posture (mirrors `CollabExtensionFactoryRegistry` /
7
+ * `FormCollabBindingRegistry`):
8
+ * - `@pilotiq/tiptap`'s `registerTiptap(...)` calls `registerCollabTextRenderer(...)`
9
+ * once at boot. The registered component closes over `@tiptap/*` imports so
10
+ * pilotiq core stays free of any tiptap peer dep — same posture as the
11
+ * existing rich-text renderer registry.
12
+ * - `TextLikeInput` checks for the registered component when a `<RecordCollabRoom>`
13
+ * is mounted up-tree AND the field hasn't opted out via `.collab(false)` AND
14
+ * the field has no `.mask()`. If present, the legacy `BoundTextInput`
15
+ * (Y.Text + `computeDelta` + heuristic `preserveCursor`) is bypassed in
16
+ * favour of a y-prosemirror-backed Tiptap editor that anchors selections to
17
+ * `Yjs.RelativePosition` — the architectural fix for the cursor-jump and
18
+ * two-peer concurrent-insert races documented in
19
+ * `docs/plans/text-fields-tiptap-backed-collab.md`.
20
+ *
21
+ * Wire props are deliberately framework-agnostic — the renderer doesn't take a
22
+ * `binding` since it consumes the room's `ydoc` directly via the existing
23
+ * `useCollabRoom()` + `getCollabExtensions()` plumbing on its own side. Core
24
+ * keeps the seam narrow: handler callbacks + DOM chrome only.
25
+ */
26
+ export interface CollabTextRendererProps {
27
+ /** Field name — drives the `Y.XmlFragment` selector inside the collab adapter. */
28
+ name: string
29
+ /** `true` for textarea-like (multiple paragraphs); `false` for input-like. */
30
+ multiline: boolean
31
+ /**
32
+ * Server-rendered default value. The renderer is expected to seed the
33
+ * `Y.XmlFragment` from this on first connect when the room has no
34
+ * persisted state for this field (i.e. brand-new record).
35
+ */
36
+ defaultValue: string
37
+ /** Optional placeholder hint. */
38
+ placeholder?: string
39
+ /** Disabled / read-only state. */
40
+ disabled?: boolean
41
+ /** Fired on every editor `update` with the editor's current plain text. */
42
+ onChange: (text: string) => void
43
+ /** Fired on editor blur — host wires this to live-onBlur trigger semantics. */
44
+ onBlur: () => void
45
+ /**
46
+ * Single-line submit — fired when `multiline: false` AND the user presses
47
+ * Enter. The renderer is expected to blur the editor after invoking this.
48
+ * Multiline mode ignores it (Enter inserts a paragraph instead).
49
+ */
50
+ onSubmit?: () => void
51
+ /**
52
+ * Tailwind className applied to the editor's contenteditable wrapper so the
53
+ * rendered editor matches the native `<input>` / `<textarea>` chrome it
54
+ * replaces. The host owns the styling — the adapter just forwards.
55
+ */
56
+ className?: string
57
+ /**
58
+ * Additional DOM attributes for the editor's contenteditable wrapper —
59
+ * typically `id`, `aria-*`, `autocomplete`, etc.
60
+ */
61
+ editorAttributes?: Record<string, string>
62
+ }
63
+
64
+ export type CollabTextRenderer = ComponentType<CollabTextRendererProps>
65
+
66
+ let _renderer: CollabTextRenderer | null = null
67
+
68
+ /**
69
+ * Register the collab plain-text editor component. Called once at boot by
70
+ * `@pilotiq/tiptap`'s `registerTiptap()` (or directly by an app that imports
71
+ * the renderer). Calling with `null` clears the registry — useful for tests.
72
+ *
73
+ * No-op behaviour when no renderer is registered: `TextLikeInput` falls back
74
+ * to the legacy `BoundTextInput` path (or the plain controlled / uncontrolled
75
+ * input when no collab room is mounted).
76
+ */
77
+ export function registerCollabTextRenderer(component: CollabTextRenderer | null): void {
78
+ _renderer = component
79
+ }
80
+
81
+ /** Returns the registered component, or `null` when no adapter is installed. */
82
+ export function getCollabTextRenderer(): CollabTextRenderer | null {
83
+ return _renderer
84
+ }
@@ -0,0 +1,50 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+
3
+ /**
4
+ * Resolved identity of the user driving the current page. Mirrors the
5
+ * `UserMenuMeta.user` shape that `panelInfo()` ships to the renderer —
6
+ * whichever fields the `Pilotiq.user(req => …)` resolver populated.
7
+ *
8
+ * `null` is the no-user state: either the panel never wired a resolver,
9
+ * or the resolver returned `null` for this request. Consumers should
10
+ * gracefully fall back (no avatar, no presence label, etc.) rather than
11
+ * treating absence as an error.
12
+ */
13
+ export interface CurrentUser {
14
+ name?: string
15
+ email?: string
16
+ avatar?: string
17
+ }
18
+
19
+ const CurrentUserContext = createContext<CurrentUser | null>(null)
20
+
21
+ /**
22
+ * Mounted by `AppShell` around the layout-provider chain so plugins
23
+ * (collab user presence, audit-trail attribution, analytics
24
+ * client-side opt-outs, …) can read the active user via
25
+ * `useCurrentUser()` without prop-drilling through `panel`.
26
+ *
27
+ * Value source is `viewProps.panel.userMenu?.user` — the same shape the
28
+ * top-right dropdown renders. The provider sits OUTSIDE
29
+ * `layoutProviderRegistry` so plugin-registered layout providers can
30
+ * subscribe.
31
+ */
32
+ export function CurrentUserProvider({
33
+ value,
34
+ children,
35
+ }: {
36
+ value: CurrentUser | null
37
+ children: ReactNode
38
+ }): ReactNode {
39
+ return <CurrentUserContext.Provider value={value}>{children}</CurrentUserContext.Provider>
40
+ }
41
+
42
+ /**
43
+ * Read the active user inside any descendant of `<AppShell>`. Returns
44
+ * `null` outside an `AppShell` mount (defensive — keeps storybook /
45
+ * isolated-render tests from throwing) and when no user resolved for
46
+ * the request.
47
+ */
48
+ export function useCurrentUser(): CurrentUser | null {
49
+ return useContext(CurrentUserContext)
50
+ }
@@ -1,44 +1,6 @@
1
1
  import type { ElementMeta } from '../schema/Element.js'
2
2
  import type { CollabRoom } from './CollabRoomContext.js'
3
3
 
4
- /**
5
- * Phase F.6 — character-level edit op emitted by `TextLikeInput` and
6
- * applied through `TextBinding.applyDelta`. `replace` covers IME / paste
7
- * / multi-char selections; `insert` and `delete` cover the single-key
8
- * common path. Pilotiq core stays Yjs-free — the binding impl in
9
- * `@pilotiq-pro/collab` translates these into `Y.Text.insert / delete`
10
- * inside a transaction.
11
- */
12
- export type TextDelta =
13
- | { kind: 'insert', index: number, text: string }
14
- | { kind: 'delete', index: number, length: number }
15
- | { kind: 'replace', from: number, to: number, text: string }
16
-
17
- /**
18
- * Phase F.6 — per-field character-level CRDT handle. Issued by
19
- * `FormCollabBinding.getTextBinding(name)` for text-shaped fields
20
- * (`TextField / TextareaField / EmailField / SlugField / MarkdownField`);
21
- * returns `null` for non-text fields or text fields opted out via
22
- * `.collab(false)`. The surface stays intentionally narrow so pilotiq
23
- * core never touches Yjs directly — same posture as `FormCollabBinding`.
24
- *
25
- * - `read()` returns the current full string. `TextLikeInput` calls
26
- * this once on mount to seed its controlled value.
27
- * - `applyDelta(delta)` is called from `onInput` events with a single
28
- * `insert / delete / replace` op derived from the input's selection.
29
- * - `observe(fn)` registers a remote-change listener; `fn(next)`
30
- * receives the post-change string. Returns an unsubscribe function.
31
- * - `destroy()` cleans up everything the handle holds. The owning
32
- * `FormCollabBinding.destroy()` is expected to cascade — consumers
33
- * don't need to call this directly.
34
- */
35
- export interface TextBinding {
36
- read(): string
37
- applyDelta(delta: TextDelta): void
38
- observe(fn: (next: string) => void): () => void
39
- destroy(): void
40
- }
41
-
42
4
  /**
43
5
  * Binding contract that a collab plugin returns from
44
6
  * `registerFormCollabBinding` — wraps a single form's value map in a
@@ -55,14 +17,14 @@ export interface TextBinding {
55
17
  * - `subscribe(fn)` registers a listener that fires when REMOTE
56
18
  * changes land; `fn(snapshot)` receives the full updated map.
57
19
  * The provider re-applies this snapshot onto its React state.
58
- * - `getTextBinding(name)` (Phase F.6) returns a `Y.Text`-backed
59
- * handle for text-shaped fields, or `null` for non-text fields and
60
- * text fields opted out via `.collab(false)`. The text/non-text
61
- * allowlist lives in the binding impl — `FormStateProvider` asks
62
- * for every field and routes per-field on the answer.
63
20
  * - `destroy()` is called on unmount — gives the plugin a chance to
64
- * remove its CRDT observer. Implementations are expected to cascade
65
- * into every `TextBinding` they issued.
21
+ * remove its CRDT observer.
22
+ *
23
+ * Text-field character-level CRDT lives in `@pilotiq/tiptap`'s
24
+ * `CollabTextRenderer` (`Y.XmlFragment` per field, keyed by bare name
25
+ * for top-level + `${arrayName}.${rowId}.${fieldName}` composite for
26
+ * row leaves) — pilotiq core no longer mediates per-field text CRDT,
27
+ * so this binding only handles non-text values + row lifecycle.
66
28
  *
67
29
  * `unknown` payloads keep pilotiq core Yjs-free; the binding owns its
68
30
  * own type knowledge. Same posture as `CollabExtensionFactory`.
@@ -74,12 +36,6 @@ export interface FormCollabBinding {
74
36
  set(name: string, value: unknown): void
75
37
  /** Subscribe to remote changes. Returns an unsubscribe function. */
76
38
  subscribe(fn: (snapshot: Record<string, unknown>) => void): () => void
77
- /** Phase F.6 — per-field text-CRDT handle. Returns `null` for non-text
78
- * fields or text fields opted out via `.collab(false)`. Optional so
79
- * existing F1-era plugins keep type-checking without a no-op stub;
80
- * when absent, every text field stays on today's whole-string LWW
81
- * path (i.e. F.6 character-level CRDT is opt-in by impl). */
82
- getTextBinding?(name: string): TextBinding | null
83
39
  /** Cleanup hook called when the form unmounts. */
84
40
  destroy(): void
85
41
 
@@ -125,11 +81,10 @@ export interface FormCollabBinding {
125
81
  /**
126
82
  * Write a single field on a row. Replaces the dotted-path `set` for
127
83
  * row leaves (`tags.0.label` → `setRow('tags', rowId, 'label', value)`).
128
- *
129
- * Binding routes by allowlist same as top-level `set`: text-shaped
130
- * fields go through the row's `TextBinding` when a `Y.Text` exists
131
- * (see `getRowTextBinding`); non-text fields land on the row's
132
- * scalar field-map under LWW.
84
+ * Non-text row fields land on the row's scalar field-map under LWW;
85
+ * text row fields don't flow through here the Tiptap renderer
86
+ * writes them directly to a `Y.XmlFragment` at the doc root keyed by
87
+ * `${arrayName}.${rowId}.${fieldName}`.
133
88
  *
134
89
  * `FormStateProvider` calls this on every local edit when both a
135
90
  * dotted-path name is being written AND `setRow` is implemented;
@@ -137,19 +92,6 @@ export interface FormCollabBinding {
137
92
  */
138
93
  setRow?(arrayName: string, rowId: string, fieldName: string, value: unknown): void
139
94
 
140
- /**
141
- * Per-row text-CRDT handle. Composes with F.6's `BoundTextInput`:
142
- * the renderer asks for one of these when a row leaf is text-shaped
143
- * AND not opted out via `.collab(false)`, then threads it through
144
- * the existing `TextBinding` plumbing inside the row.
145
- *
146
- * Returns `null` for non-text fields, fields opted out via collab,
147
- * rows not yet present in the binding's index (e.g. before
148
- * `addRow` has propagated), or when the binding doesn't yet
149
- * implement per-row text CRDT (deferred to F.5c).
150
- */
151
- getRowTextBinding?(arrayName: string, rowId: string, fieldName: string): TextBinding | null
152
-
153
95
  /**
154
96
  * Subscribe to row-lifecycle events for a Repeater/Builder array.
155
97
  * Fires on remote add / remove / move; the renderer reconciles its
@@ -232,14 +174,12 @@ export interface FormCollabBindingFactoryArgs {
232
174
  */
233
175
  initial: Record<string, unknown>
234
176
  /**
235
- * Phase F.6 — initial form meta from the server. The binding walks
236
- * this once at construction to decide which fields are text-shaped
237
- * (`fieldType { text, textarea, email, slug, markdown }`) and
238
- * which have opted out via `.collab(false)`. Text fields get a
239
- * dedicated `Y.Text` and route through `getTextBinding`; non-text
240
- * fields stay on the `Y.Map`. The meta is captured at mount; later
241
- * structural changes from `live()` re-resolves aren't re-walked
242
- * (rare in practice — dynamic field add/remove is an F-followup).
177
+ * Initial form meta from the server. The binding walks this once at
178
+ * construction to index Repeater / Builder array names for row-level
179
+ * CRDT. Text fields (top-level and row leaves alike) don't need to be
180
+ * partitioned here the Tiptap renderer owns their `Y.XmlFragment`s
181
+ * at the doc root. The meta is captured at mount; later structural
182
+ * changes from `live()` re-resolves aren't re-walked.
243
183
  */
244
184
  formMeta: ElementMeta
245
185
  }
@@ -11,13 +11,10 @@ import type { ElementMeta } from '../schema/Element.js'
11
11
  import {
12
12
  collectFieldDefaults,
13
13
  collectRowArrayFieldNames,
14
- collectRowTextLeavesByArray,
15
14
  findFieldMeta,
16
15
  parseFormDataToNested,
17
- parseRowFieldPath,
18
16
  readNestedValue,
19
17
  routeBindingWrite,
20
- rowIdAtIndex,
21
18
  writeNestedValue,
22
19
  } from './formStateHelpers.js'
23
20
  import { runJsHandler } from './fieldJsHandler.js'
@@ -27,7 +24,6 @@ import {
27
24
  getFormCollabBinding,
28
25
  type FormCollabBinding,
29
26
  type RowBindingApi,
30
- type TextBinding,
31
27
  } from './FormCollabBindingRegistry.js'
32
28
 
33
29
  export type FieldStatus = 'idle' | 'pending'
@@ -50,36 +46,11 @@ export interface FormStateApi {
50
46
  formMeta: ElementMeta
51
47
  inFlight: boolean
52
48
  fieldStatus: (name: string) => FieldStatus
53
- /** Phase F.6 — per-field text-CRDT handles stashed at collab-room mount.
54
- * `null` outside a room or before the binding effect has populated the
55
- * map. The text/non-text allowlist lives in the binding impl —
56
- * `FormStateProvider` asks for every top-level field and only stashes
57
- * non-null answers, so a `Map.get()` hit means the binding has opted
58
- * this field into the character-level path. */
59
- textBindings: ReadonlyMap<string, TextBinding> | null
60
49
  /** Phase F.5 — per-Repeater/Builder row-array bindings. `null` outside a
61
50
  * collab room or when the binding doesn't implement F.5 row methods.
62
51
  * Each entry's API methods are pre-bound to the array name so renderers
63
52
  * call `.add(rowId, initial)` rather than `binding.addRow(name, …)`. */
64
53
  rowBindings: ReadonlyMap<string, RowBindingApi> | null
65
- /**
66
- * Phase F.5c — per-array set of inner-field names that should route
67
- * through `Y.Text` (character-level CRDT) instead of row-level Y.Map
68
- * LWW. Built from a single meta walk at binding mount. Read by
69
- * `useFieldState(dottedName).textBinding` to decide whether to call
70
- * `getRowTextBinding`. Sparse — only arrays with at least one
71
- * text-shaped row leaf appear; absence on a key means "no text leaves
72
- * in this Repeater/Builder".
73
- */
74
- rowTextLeaves: ReadonlyMap<string, ReadonlySet<string>> | null
75
- /**
76
- * Phase F.5c — resolve a per-row `TextBinding`. Pre-bound to the
77
- * active F.5 binding so consumers don't reach for `bindingRef`
78
- * directly. Returns `null` when no binding implements F.5c OR the
79
- * row+field doesn't qualify (renderer caller should fall back to
80
- * `defaultValue` like a non-collab form).
81
- */
82
- getRowTextBinding: ((arrayName: string, rowId: string, fieldName: string) => TextBinding | null) | null
83
54
  }
84
55
 
85
56
  const FormStateContext = createContext<FormStateApi | null>(null)
@@ -120,15 +91,6 @@ export interface UseFieldStateResult {
120
91
  /** True while a live re-resolve POST is in flight for this field. */
121
92
  pending: boolean
122
93
  errors: string[]
123
- /** Phase F.6 — character-level CRDT handle for text-shaped fields when
124
- * a collab room is mounted up-tree AND the binding strategy applies
125
- * (allowlist + `.collab() !== false`). Null in every other case —
126
- * outside a `FormStateProvider`, outside a `<RecordCollabRoom>`, on
127
- * non-text fields, on dotted-path inner-Repeater rows (deferred to
128
- * F.5), and on text fields opted out via `.collab(false)`. Text input
129
- * renderers branch on this: non-null → character-level path with
130
- * `applyDelta + observe`; null → today's whole-string LWW path. */
131
- textBinding: TextBinding | null
132
94
  }
133
95
 
134
96
  /**
@@ -164,7 +126,6 @@ export function useFieldState(name: string): UseFieldStateResult {
164
126
  triggerLive: () => {},
165
127
  pending: false,
166
128
  errors: [],
167
- textBinding: null,
168
129
  }
169
130
  }
170
131
  // Dotted-path fields (inner Repeater rows) always render uncontrolled
@@ -177,35 +138,9 @@ export function useFieldState(name: string): UseFieldStateResult {
177
138
  triggerLive: (valueOverride?: unknown) => ctx.triggerLive(name, valueOverride),
178
139
  pending: ctx.fieldStatus(name) === 'pending',
179
140
  errors: ctx.errors[name] ?? [],
180
- // Phase F.6 — top-level text fields resolve from the binding-mount
181
- // text stash. Phase F.5c — dotted-path row leaves resolve through
182
- // `getRowTextBinding` when the field is text-shaped AND the row's
183
- // `__id` is already stamped in the values map. Outside a collab
184
- // room, for non-text fields, or before `addRow` has settled the
185
- // row's id, the lookup returns null and `BoundTextInput` falls
186
- // back to today's uncontrolled-input path.
187
- textBinding: dotted
188
- ? resolveRowTextBinding(ctx, name)
189
- : (ctx.textBindings?.get(name) ?? null),
190
141
  }
191
142
  }
192
143
 
193
- /**
194
- * Phase F.5c — dotted-name `TextBinding` resolver. Returns `null`
195
- * whenever any precondition fails so the caller's renderer can take a
196
- * single branch on null vs non-null.
197
- */
198
- function resolveRowTextBinding(ctx: FormStateApi, dottedName: string): TextBinding | null {
199
- if (!ctx.rowTextLeaves || !ctx.getRowTextBinding) return null
200
- const parsed = parseRowFieldPath(dottedName)
201
- if (!parsed) return null
202
- const set = ctx.rowTextLeaves.get(parsed.arrayName)
203
- if (!set?.has(parsed.fieldName)) return null
204
- const rowId = rowIdAtIndex(ctx.values, parsed.arrayName, parsed.index)
205
- if (!rowId) return null
206
- return ctx.getRowTextBinding(parsed.arrayName, rowId, parsed.fieldName)
207
- }
208
-
209
144
  /** Response shape from `POST {base}/.../_form/:formId/state`. */
210
145
  interface FormStateResponse {
211
146
  ok: boolean
@@ -253,22 +188,11 @@ export function FormStateProvider({
253
188
  const [errors, setErrors] = useState<Record<string, string[]>>(initialErrors)
254
189
  const [pendingNames, setPendingNames] = useState<Set<string>>(() => new Set())
255
190
  const [inFlight, setInFlight] = useState(false)
256
- // Phase F.6 — per-field text-CRDT stash. `null` until the collab effect
257
- // populates it; stays `null` outside a collab room. Stored in state (not
258
- // a ref) so consumers of `useFieldState` re-render once the bindings
259
- // land. One extra render after collab-mount; acceptable since the
260
- // existing `setValuesState` overlay below already triggers one when the
261
- // room has pre-existing state.
262
- const [textBindings, setTextBindings] = useState<ReadonlyMap<string, TextBinding> | null>(null)
263
- // Phase F.5 — per-Repeater/Builder row-array stash. Same lifecycle as
264
- // `textBindings`: populated on collab mount when the binding implements
265
- // F.5 row methods, cleared on unmount.
191
+ // Phase F.5 — per-Repeater/Builder row-array stash. Populated on
192
+ // collab mount when the binding implements F.5 row methods, cleared
193
+ // on unmount. Stored in state (not a ref) so consumers of
194
+ // `useRowBinding` re-render once the bindings land.
266
195
  const [rowBindings, setRowBindings] = useState<ReadonlyMap<string, RowBindingApi> | null>(null)
267
- // Phase F.5c — per-array set of text-shaped row leaves + a pre-bound
268
- // resolver. Both populated at binding mount when the active binding
269
- // implements `getRowTextBinding`; cleared on unmount.
270
- const [rowTextLeaves, setRowTextLeaves] = useState<ReadonlyMap<string, ReadonlySet<string>> | null>(null)
271
- const [getRowTextBinding, setGetRowTextBinding] = useState<((arrayName: string, rowId: string, fieldName: string) => TextBinding | null) | null>(null)
272
196
 
273
197
  const { notify } = useToast()
274
198
 
@@ -332,25 +256,6 @@ export function FormStateProvider({
332
256
  setValuesState((prev) => ({ ...prev, ...synced }))
333
257
  }
334
258
 
335
- // Phase F.6 — ask the binding for a `TextBinding` on every top-level
336
- // field name. The text/non-text allowlist lives in the binding impl,
337
- // not in core: the binding returns `null` for non-text fields and
338
- // text fields opted out via `.collab(false)`. `getTextBinding` is
339
- // optional on the contract — F1-era plugins that haven't implemented
340
- // it short-circuit the whole stash and every text field stays on the
341
- // LWW path. We stash only the non-null answers. Cleanup is owned by
342
- // `binding.destroy()` (expected to cascade into every issued
343
- // `TextBinding`).
344
- if (binding.getTextBinding) {
345
- const textStash = new Map<string, TextBinding>()
346
- for (const fieldName of Object.keys(valuesRef.current)) {
347
- if (fieldName.includes('.')) continue
348
- const tb = binding.getTextBinding(fieldName)
349
- if (tb) textStash.set(fieldName, tb)
350
- }
351
- if (textStash.size > 0) setTextBindings(textStash)
352
- }
353
-
354
259
  // Phase F.5 — build a `RowBindingApi` per top-level Repeater/Builder
355
260
  // field when the binding implements all three lifecycle methods. The
356
261
  // walk reads from formMeta (structural — fields exist regardless of
@@ -358,8 +263,7 @@ export function FormStateProvider({
358
263
  // the array name so `RepeaterInput` calls `rb.add(rowId, …)` rather
359
264
  // than `binding.addRow(name, rowId, …)`. Partial F.5 impls (e.g. a
360
265
  // binding that has addRow but not reorderRows) skip the stash — the
361
- // contract says all three or nothing. F.5c's per-row text path is
362
- // exposed separately via `getRowTextBinding` and stays optional.
266
+ // contract says all three or nothing.
363
267
  if (binding.addRow && binding.removeRow && binding.reorderRows) {
364
268
  const { addRow, removeRow, reorderRows, subscribeRows } = binding
365
269
  const arrayNames = collectRowArrayFieldNames(formMetaRef.current)
@@ -383,23 +287,6 @@ export function FormStateProvider({
383
287
  }
384
288
  }
385
289
 
386
- // Phase F.5c — capture the per-array text-leaf allowlist + bind the
387
- // row-text resolver to the active binding. `getRowTextBinding` may
388
- // be absent on partial F.5 impls; we expose the resolver as null in
389
- // that case so `useFieldState` short-circuits cleanly.
390
- if (binding.getRowTextBinding) {
391
- const leaves = collectRowTextLeavesByArray(formMetaRef.current)
392
- if (leaves.size > 0) {
393
- setRowTextLeaves(leaves)
394
- const bound = binding.getRowTextBinding
395
- // useState's functional-updater overload would invoke the
396
- // stored function during set; wrapping in a fresh closure keeps
397
- // React's setState path from confusing it for an updater fn.
398
- setGetRowTextBinding(() => (arrayName: string, rowId: string, fieldName: string) =>
399
- bound.call(binding, arrayName, rowId, fieldName))
400
- }
401
- }
402
-
403
290
  // Subscribe to remote changes. Local writes ALSO trigger this
404
291
  // (Yjs observers fire on local transactions too) — the per-key
405
292
  // Object.is short-circuit below collapses them into no-op renders.
@@ -421,10 +308,7 @@ export function FormStateProvider({
421
308
  unsubscribe()
422
309
  binding.destroy()
423
310
  bindingRef.current = null
424
- setTextBindings(null)
425
311
  setRowBindings(null)
426
- setRowTextLeaves(null)
427
- setGetRowTextBinding(null)
428
312
  }
429
313
  // `valuesRef.current` is intentionally read once at mount — initial
430
314
  // values seed the binding; subsequent edits flow through `setValue`
@@ -657,11 +541,8 @@ export function FormStateProvider({
657
541
  formMeta,
658
542
  inFlight,
659
543
  fieldStatus,
660
- textBindings,
661
544
  rowBindings,
662
- rowTextLeaves,
663
- getRowTextBinding,
664
- }), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings, rowBindings, rowTextLeaves, getRowTextBinding])
545
+ }), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, rowBindings])
665
546
 
666
547
  return (
667
548
  <FormStateContext.Provider value={api}>
@@ -0,0 +1,23 @@
1
+ import React, { createContext, useContext } from 'react'
2
+
3
+ /**
4
+ * Phase 1 — row-text Tiptap-backed collab plan
5
+ * (`pilotiq-pro/docs/plans/collab-row-text-tiptap-backed.md`).
6
+ *
7
+ * Each Repeater / Builder row mounts a `<RowCoordsContext.Provider>`
8
+ * around its children so dotted-path text leaves can compose a
9
+ * fragment-key that includes the stable `rowId` (survives reorders).
10
+ * Top-level fields see `null` and fall through to bare-name fragment
11
+ * routing.
12
+ */
13
+ export interface RowCoords {
14
+ arrayName: string
15
+ rowIndex: number
16
+ rowId: string
17
+ }
18
+
19
+ export const RowCoordsContext = createContext<RowCoords | null>(null)
20
+
21
+ export function useRowCoords(): RowCoords | null {
22
+ return useContext(RowCoordsContext)
23
+ }
@@ -5,6 +5,7 @@ import { Button } from '../ui/button.js'
5
5
  import { SchemaRenderer } from '../SchemaRenderer.js'
6
6
  import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
7
7
  import { findFieldMeta } from '../formStateHelpers.js'
8
+ import { RowCoordsContext } from '../RowCoordsContext.js'
8
9
  import { useIconFor } from '../icon-context.js'
9
10
  import { reorderRows, ExtraActionStrip, buildGridContainer } from './RepeaterInput.js'
10
11
  import { syncRowGates } from './syncRowGates.js'
@@ -191,11 +192,9 @@ export function BuilderInput({
191
192
  // the first event — without it, the picker dropdown choice doesn't
192
193
  // propagate until the user makes their first inner-field edit.
193
194
  const rowBinding = useRowBinding(name)
194
- // Phase F.5c — mirror row identities into the form's values map so dotted
195
- // row-leaf consumers (`useFieldState('${name}.${i}.data.text').textBinding`)
196
- // can resolve the row's `__id` via `rowIdAtIndex(ctx.values, name, i)`.
197
- // Without this stamp the F.5c per-row Y.Text path stays null on Builder
198
- // and inner text fields never sync. Mirrors the same fix in RepeaterInput.
195
+ // Mirror row identities into the form's values map so dotted row-leaf
196
+ // consumers can resolve the row's `__id` via `rowIdAtIndex(ctx.values,
197
+ // name, i)`. Mirrors the same plumbing in RepeaterInput.
199
198
  const formStateForIds = useFormState()
200
199
  const ctxSetValue = formStateForIds?.setValue
201
200
  useEffect(() => {
@@ -870,6 +869,15 @@ function BuilderRow({
870
869
  () => row.children.map(c => prefixFieldNames(c, dataPrefix)),
871
870
  [row.children, dataPrefix],
872
871
  )
872
+ // Row coords for dotted-path text leaves under this row — composes
873
+ // fragment-key `${arrayName}.${rowId}.${fieldName}` (Phase 1 of
874
+ // collab-row-text-tiptap-backed.md). `parseRowFieldPath` strips the
875
+ // Builder-specific `data` segment, so the coords use the array name +
876
+ // the row's stable id without referencing the dialect.
877
+ const rowCoords = useMemo(
878
+ () => ({ arrayName: name, rowIndex: index, rowId: row.id }),
879
+ [name, index, row.id],
880
+ )
873
881
 
874
882
  const RowIcon = useIconFor(showIcons ? block?.icon : undefined)
875
883
  const blockLabel = block?.label ?? row.type ?? 'Block'
@@ -878,11 +886,13 @@ function BuilderRow({
878
886
 
879
887
  if (row.hidden) {
880
888
  return (
881
- <div style={{ display: 'none' }} data-pilotiq-builder-row="hidden">
882
- <input type="hidden" name={`${name}.${index}.__id`} value={row.id} readOnly />
883
- <input type="hidden" name={`${name}.${index}.type`} value={row.type} readOnly />
884
- <SchemaRenderer elements={namespaced} />
885
- </div>
889
+ <RowCoordsContext.Provider value={rowCoords}>
890
+ <div style={{ display: 'none' }} data-pilotiq-builder-row="hidden">
891
+ <input type="hidden" name={`${name}.${index}.__id`} value={row.id} readOnly />
892
+ <input type="hidden" name={`${name}.${index}.type`} value={row.type} readOnly />
893
+ <SchemaRenderer elements={namespaced} />
894
+ </div>
895
+ </RowCoordsContext.Provider>
886
896
  )
887
897
  }
888
898
 
@@ -921,6 +931,7 @@ function BuilderRow({
921
931
  const innerColumns = block.columns && block.columns > 1 ? block.columns : 1
922
932
 
923
933
  return (
934
+ <RowCoordsContext.Provider value={rowCoords}>
924
935
  <div
925
936
  className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
926
937
  data-pilotiq-builder-row=""
@@ -999,6 +1010,7 @@ function BuilderRow({
999
1010
  : <SchemaRenderer elements={namespaced} />}
1000
1011
  </div>
1001
1012
  </div>
1013
+ </RowCoordsContext.Provider>
1002
1014
  )
1003
1015
  }
1004
1016