@pilotiq/tiptap 3.10.4 → 3.10.6
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/CHANGELOG.md +745 -0
- package/boost/guidelines.md +268 -0
- package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
- package/dist/react/CollabTextRenderer.d.ts.map +1 -1
- package/dist/react/CollabTextRenderer.js +4 -4
- package/dist/react/CollabTextRenderer.js.map +1 -1
- package/dist/react/MarkdownEditor.d.ts.map +1 -1
- package/dist/react/MarkdownEditor.js +4 -5
- package/dist/react/MarkdownEditor.js.map +1 -1
- package/dist/react/TiptapEditor.d.ts.map +1 -1
- package/dist/react/TiptapEditor.js +8 -7
- package/dist/react/TiptapEditor.js.map +1 -1
- package/package.json +6 -3
- package/dist/collabShapes.d.ts +0 -22
- package/dist/collabShapes.d.ts.map +0 -1
- package/dist/collabShapes.js +0 -2
- package/dist/collabShapes.js.map +0 -1
- package/src/Block.ts +0 -75
- package/src/MentionProvider.ts +0 -153
- package/src/PlainTextEditor.dom.test.ts +0 -111
- package/src/PlainTextEditor.test.ts +0 -158
- package/src/PlainTextEditor.ts +0 -229
- package/src/RichTextField.test.ts +0 -447
- package/src/RichTextField.ts +0 -508
- package/src/collabShapes.ts +0 -22
- package/src/extensions/AiInlineDiffExtension.ts +0 -286
- package/src/extensions/AiSuggestionExtension.test.ts +0 -141
- package/src/extensions/AiSuggestionExtension.ts +0 -522
- package/src/extensions/BlockNodeExtension.ts +0 -134
- package/src/extensions/DragHandleExtension.ts +0 -184
- package/src/extensions/GridExtension.test.ts +0 -31
- package/src/extensions/GridExtension.ts +0 -138
- package/src/extensions/MentionExtension.ts +0 -248
- package/src/extensions/MergeTagExtension.ts +0 -75
- package/src/extensions/SlashCommandExtension.test.ts +0 -147
- package/src/extensions/SlashCommandExtension.ts +0 -332
- package/src/extensions/TextSizeMarks.ts +0 -73
- package/src/index.ts +0 -62
- package/src/markdownExtension.ts +0 -19
- package/src/markdownStorage.ts +0 -49
- package/src/plugin.test.ts +0 -19
- package/src/plugin.ts +0 -26
- package/src/react/AiSuggestionBanner.tsx +0 -185
- package/src/react/BlockNodeView.tsx +0 -99
- package/src/react/BlockSidePanel.dom.test.tsx +0 -38
- package/src/react/BlockSidePanel.test.ts +0 -412
- package/src/react/BlockSidePanel.tsx +0 -451
- package/src/react/CollabTextRenderer.tsx +0 -230
- package/src/react/FloatingToolbar.tsx +0 -304
- package/src/react/MarkdownEditor.tsx +0 -606
- package/src/react/MentionMenu.tsx +0 -120
- package/src/react/Palette.tsx +0 -86
- package/src/react/SlashMenu.tsx +0 -129
- package/src/react/TableFloatingToolbar.tsx +0 -154
- package/src/react/TiptapEditor.dom.test.tsx +0 -112
- package/src/react/TiptapEditor.tsx +0 -776
- package/src/react/Toolbar.tsx +0 -438
- package/src/react/toolbarButtons.tsx +0 -579
- package/src/react/useAiInlineDiff.ts +0 -342
- package/src/react/useAiSuggestionBridge.ts +0 -223
- package/src/register.test.ts +0 -14
- package/src/register.ts +0 -42
- package/src/render.test.ts +0 -745
- package/src/render.ts +0 -480
- package/src/surgicalOps.ts +0 -205
- package/src/test/setup.ts +0 -64
|
@@ -1,451 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import type { Editor } from '@tiptap/react'
|
|
3
|
-
import {
|
|
4
|
-
FormFields,
|
|
5
|
-
parseFormDataToNested,
|
|
6
|
-
clampPanelWidth as clampPanelWidthShared,
|
|
7
|
-
useResizableWidth,
|
|
8
|
-
} from '@pilotiq/pilotiq/react'
|
|
9
|
-
import type { BlockMeta } from '../Block.js'
|
|
10
|
-
|
|
11
|
-
const PANEL_WIDTH_STORAGE_KEY = 'pilotiq.tiptap.sidePanel.width'
|
|
12
|
-
const PANEL_WIDTH_DEFAULT = 320
|
|
13
|
-
const PANEL_WIDTH_MIN = 240
|
|
14
|
-
const PANEL_WIDTH_MAX = 600
|
|
15
|
-
|
|
16
|
-
// Keys that point at any tabbable / interactive element inside the panel.
|
|
17
|
-
// Same intent as Filament's focus-trap helper but kept inline — small one-off.
|
|
18
|
-
const FOCUSABLE_SELECTOR = [
|
|
19
|
-
'a[href]',
|
|
20
|
-
'button:not([disabled])',
|
|
21
|
-
'input:not([disabled]):not([type="hidden"])',
|
|
22
|
-
'select:not([disabled])',
|
|
23
|
-
'textarea:not([disabled])',
|
|
24
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
25
|
-
].join(',')
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Floating right-docked side panel for editing a custom block's schema
|
|
29
|
-
* fields. Mounted by `TiptapEditor` once the user clicks the Edit button
|
|
30
|
-
* on a `pilotiqBlock` NodeView; reads/writes flow through ProseMirror
|
|
31
|
-
* directly (no form submit, no roundtrip).
|
|
32
|
-
*
|
|
33
|
-
* Why a sibling of the NodeView and not the NodeView itself:
|
|
34
|
-
* - NodeViews mount in a separate React tree (Tiptap quirk), so they
|
|
35
|
-
* can't reach pilotiq's `FormFields` renderer or any provider on
|
|
36
|
-
* the host page (Theme, Toaster, etc.). Hosting the panel here in
|
|
37
|
-
* the host's tree gives us the full pilotiq field surface for free.
|
|
38
|
-
*
|
|
39
|
-
* Reads: each field's `defaultValue` is overridden from the block's
|
|
40
|
-
* stored `blockData`. Inputs are uncontrolled (outside `FormStateProvider`,
|
|
41
|
-
* pilotiq's renderers fall back to `defaultValue` automatically).
|
|
42
|
-
*
|
|
43
|
-
* Writes: container-level event delegation on the form element. Every
|
|
44
|
-
* change snapshots the entire form via `new FormData(formEl)` →
|
|
45
|
-
* `parseFormDataToNested` (rebuilds nested arrays/objects from
|
|
46
|
-
* dotted-path inputs like `myrep.0.title`) → `coerceBlockValues`
|
|
47
|
-
* (per-fieldType JSON parse / boolean / number coerce so nested-shape
|
|
48
|
-
* fields round-trip in their canonical wire form). The result is
|
|
49
|
-
* dispatched through `state.tr.setNodeMarkup` on the tracked position.
|
|
50
|
-
* The position is kept fresh by mapping it through every editor
|
|
51
|
-
* transaction so live edits elsewhere in the document don't desync.
|
|
52
|
-
*
|
|
53
|
-
* V2 (2026-05-04 cont'd): nested-shape fields now round-trip cleanly:
|
|
54
|
-
* Repeater (array of subschema rows), Builder (heterogeneous block
|
|
55
|
-
* rows), TagsInput / KeyValue / FileUpload (JSON-encoded hidden
|
|
56
|
-
* inputs), Markdown (plain textarea), and the standard primitives
|
|
57
|
-
* (text / textarea / select / toggle / checkbox / radio / date /
|
|
58
|
-
* datetime / number / slider / color / toggleButtons / checkboxList).
|
|
59
|
-
*/
|
|
60
|
-
export interface BlockSidePanelProps {
|
|
61
|
-
editor: Editor
|
|
62
|
-
/** Position at open time. Tracked + remapped on every transaction. */
|
|
63
|
-
initialPos: number
|
|
64
|
-
/** Block type at open time — guards against the user clicking Edit on
|
|
65
|
-
* one block, then someone else's edit replacing it with a different
|
|
66
|
-
* block at the same position. */
|
|
67
|
-
blockType: string
|
|
68
|
-
blocks: BlockMeta[]
|
|
69
|
-
onClose: () => void
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function BlockSidePanel({
|
|
73
|
-
editor,
|
|
74
|
-
initialPos,
|
|
75
|
-
blockType,
|
|
76
|
-
blocks,
|
|
77
|
-
onClose,
|
|
78
|
-
}: BlockSidePanelProps): React.ReactElement | null {
|
|
79
|
-
const meta = blocks.find((b) => b.name === blockType)
|
|
80
|
-
|
|
81
|
-
// Live-tracked position of the block we're editing. Starts at the
|
|
82
|
-
// open-time position; every editor transaction maps it forward so the
|
|
83
|
-
// panel keeps writing to the same node even as the user types text
|
|
84
|
-
// elsewhere in the document.
|
|
85
|
-
const [pos, setPos] = useState<number | null>(initialPos)
|
|
86
|
-
const posRef = useRef<number | null>(initialPos)
|
|
87
|
-
|
|
88
|
-
// Prefilled values seed the form's `defaultValue`s. We re-read once
|
|
89
|
-
// when the panel opens (and on hard re-mount via key prop); ongoing
|
|
90
|
-
// edits don't snapshot the doc — the form's uncontrolled inputs hold
|
|
91
|
-
// their own state until the user closes the panel.
|
|
92
|
-
const initialValuesRef = useRef<Record<string, unknown>>(
|
|
93
|
-
pos !== null ? readBlockData(editor, pos) : {},
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
const asideRef = useRef<HTMLElement | null>(null)
|
|
97
|
-
const formRef = useRef<HTMLFormElement | null>(null)
|
|
98
|
-
|
|
99
|
-
// Width memory — survives panel close/reopen and full reload via
|
|
100
|
-
// localStorage. The shared `useResizableWidth` hook from
|
|
101
|
-
// `@pilotiq/pilotiq/react` handles the localStorage round-trip + drag
|
|
102
|
-
// pipeline; we just bind it to this panel's per-key bounds.
|
|
103
|
-
const { width, onResizeStart } = useResizableWidth({
|
|
104
|
-
storageKey: PANEL_WIDTH_STORAGE_KEY,
|
|
105
|
-
min: PANEL_WIDTH_MIN,
|
|
106
|
-
max: PANEL_WIDTH_MAX,
|
|
107
|
-
defaultWidth: PANEL_WIDTH_DEFAULT,
|
|
108
|
-
edge: 'left',
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
// Save focus on mount, focus the first focusable inside the panel,
|
|
112
|
-
// restore previous focus on unmount. Mount-only effect — re-mounting
|
|
113
|
-
// the panel for a different block (key={pos:blockType}) re-runs this.
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
const previouslyFocused = (typeof document !== 'undefined'
|
|
116
|
-
? document.activeElement
|
|
117
|
-
: null) as HTMLElement | null
|
|
118
|
-
const aside = asideRef.current
|
|
119
|
-
if (aside) {
|
|
120
|
-
const first = aside.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
|
121
|
-
first?.focus()
|
|
122
|
-
}
|
|
123
|
-
return () => {
|
|
124
|
-
// Try/catch — the previously focused element may have been removed
|
|
125
|
-
// (e.g. an editor selection refresh nuked the surrounding NodeView).
|
|
126
|
-
try { previouslyFocused?.focus?.() } catch { /* noop */ }
|
|
127
|
-
}
|
|
128
|
-
}, [])
|
|
129
|
-
|
|
130
|
-
// ESC closes; Tab / Shift+Tab cycles within the panel. Bubble-phase
|
|
131
|
-
// listener — slash and mention menus' capture-phase ESC handlers fire
|
|
132
|
-
// first and stopPropagation, so ESC inside an open slash menu only
|
|
133
|
-
// closes the menu. ESC anywhere else (panel inputs, editor) closes
|
|
134
|
-
// the panel.
|
|
135
|
-
useEffect(() => {
|
|
136
|
-
const onKey = (e: KeyboardEvent): void => {
|
|
137
|
-
if (e.key === 'Escape') {
|
|
138
|
-
onClose()
|
|
139
|
-
e.preventDefault()
|
|
140
|
-
e.stopPropagation()
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
if (e.key !== 'Tab') return
|
|
144
|
-
const aside = asideRef.current
|
|
145
|
-
if (!aside) return
|
|
146
|
-
const active = document.activeElement as Node | null
|
|
147
|
-
if (!active || !aside.contains(active)) return
|
|
148
|
-
const focusables = Array.from(
|
|
149
|
-
aside.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
|
150
|
-
).filter((el) => !el.hasAttribute('disabled'))
|
|
151
|
-
if (focusables.length === 0) return
|
|
152
|
-
const first = focusables[0]!
|
|
153
|
-
const last = focusables[focusables.length - 1]!
|
|
154
|
-
if (e.shiftKey && active === first) {
|
|
155
|
-
e.preventDefault()
|
|
156
|
-
last.focus()
|
|
157
|
-
} else if (!e.shiftKey && active === last) {
|
|
158
|
-
e.preventDefault()
|
|
159
|
-
first.focus()
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
document.addEventListener('keydown', onKey)
|
|
163
|
-
return () => document.removeEventListener('keydown', onKey)
|
|
164
|
-
}, [onClose])
|
|
165
|
-
|
|
166
|
-
useEffect(() => {
|
|
167
|
-
if (pos === null) return
|
|
168
|
-
const handler = ({ transaction }: { transaction: { mapping: { map: (p: number) => number } } }): void => {
|
|
169
|
-
const current = posRef.current
|
|
170
|
-
if (current === null) return
|
|
171
|
-
const mapped = transaction.mapping.map(current)
|
|
172
|
-
// The block was deleted — close the panel.
|
|
173
|
-
const nodeNow = nodeAt(editor, mapped)
|
|
174
|
-
if (!nodeNow || nodeNow.type.name !== 'pilotiqBlock' || String(nodeNow.attrs['blockType'] ?? '') !== blockType) {
|
|
175
|
-
posRef.current = null
|
|
176
|
-
setPos(null)
|
|
177
|
-
onClose()
|
|
178
|
-
return
|
|
179
|
-
}
|
|
180
|
-
posRef.current = mapped
|
|
181
|
-
setPos(mapped)
|
|
182
|
-
}
|
|
183
|
-
editor.on('transaction', handler)
|
|
184
|
-
return () => { editor.off('transaction', handler) }
|
|
185
|
-
}, [editor, blockType, pos, onClose])
|
|
186
|
-
|
|
187
|
-
const writeBack = useCallback((nextValues: Record<string, unknown>): void => {
|
|
188
|
-
const at = posRef.current
|
|
189
|
-
if (at === null) return
|
|
190
|
-
// ProseMirror's `setNodeMarkup` lives on the transaction, not the
|
|
191
|
-
// ChainedCommands surface — go through `tr` directly. Pass `null`
|
|
192
|
-
// for the node-type arg to keep the existing type, just swap attrs.
|
|
193
|
-
const view = (editor as unknown as { view: { dispatch: (tr: unknown) => void } }).view
|
|
194
|
-
const state = (editor as unknown as { state: { tr: { setNodeMarkup: (p: number, t: null, a: Record<string, unknown>) => unknown } } }).state
|
|
195
|
-
const tr = state.tr.setNodeMarkup(at, null, { blockType, blockData: nextValues })
|
|
196
|
-
view.dispatch(tr)
|
|
197
|
-
}, [editor, blockType])
|
|
198
|
-
|
|
199
|
-
const handleChange = useCallback((): void => {
|
|
200
|
-
const formEl = formRef.current
|
|
201
|
-
if (!formEl || !meta) return
|
|
202
|
-
// Snapshot the full form: nested arrays / objects materialize from
|
|
203
|
-
// dotted-path names (`items.0.title`), JSON-encoded hidden inputs
|
|
204
|
-
// (TagsInput / KeyValue / FileUpload-multi) sit as JSON strings,
|
|
205
|
-
// toggle / checkbox hidden inputs sit as `'true' | 'false'`. The
|
|
206
|
-
// coerce pass below normalizes those to canonical shapes.
|
|
207
|
-
const raw = parseFormDataToNested(new FormData(formEl))
|
|
208
|
-
const coerced = coerceBlockValues(raw, meta.schema)
|
|
209
|
-
writeBack(coerced)
|
|
210
|
-
}, [meta, writeBack])
|
|
211
|
-
|
|
212
|
-
if (!meta || pos === null) return null
|
|
213
|
-
|
|
214
|
-
return (
|
|
215
|
-
<aside
|
|
216
|
-
ref={asideRef}
|
|
217
|
-
role="dialog"
|
|
218
|
-
aria-label={`Edit ${meta.label}`}
|
|
219
|
-
style={{ width }}
|
|
220
|
-
className="absolute top-0 left-full ml-4 max-h-[calc(100vh-2rem)] overflow-y-auto rounded-lg border bg-background shadow-lg z-30"
|
|
221
|
-
>
|
|
222
|
-
{/* Left-edge resize handle. Thin strip, hover-highlighted, full
|
|
223
|
-
height of the panel; mousedown starts a drag tracked at
|
|
224
|
-
document level so leaving the strip doesn't end the drag. */}
|
|
225
|
-
<div
|
|
226
|
-
role="separator"
|
|
227
|
-
aria-orientation="vertical"
|
|
228
|
-
aria-label="Resize panel"
|
|
229
|
-
onPointerDown={onResizeStart}
|
|
230
|
-
className="absolute left-0 top-0 h-full w-1 cursor-ew-resize hover:bg-border/80"
|
|
231
|
-
/>
|
|
232
|
-
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b bg-background px-3 py-2">
|
|
233
|
-
<div className="flex items-center gap-2 min-w-0">
|
|
234
|
-
{meta.icon && <span aria-hidden="true">{meta.icon}</span>}
|
|
235
|
-
<span className="text-sm font-medium truncate">{meta.label}</span>
|
|
236
|
-
</div>
|
|
237
|
-
<button
|
|
238
|
-
type="button"
|
|
239
|
-
onClick={onClose}
|
|
240
|
-
aria-label="Close panel"
|
|
241
|
-
className="text-muted-foreground hover:text-foreground text-sm"
|
|
242
|
-
>
|
|
243
|
-
×
|
|
244
|
-
</button>
|
|
245
|
-
</header>
|
|
246
|
-
<form
|
|
247
|
-
ref={formRef}
|
|
248
|
-
onInput={handleChange}
|
|
249
|
-
onChange={handleChange}
|
|
250
|
-
onSubmit={(e) => { e.preventDefault() }}
|
|
251
|
-
className="flex flex-col gap-3 px-3 py-3"
|
|
252
|
-
>
|
|
253
|
-
<FormFields
|
|
254
|
-
elements={meta.schema}
|
|
255
|
-
values={initialValuesRef.current}
|
|
256
|
-
/>
|
|
257
|
-
</form>
|
|
258
|
-
</aside>
|
|
259
|
-
)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Clamp + sanitize a candidate panel width against this panel's bounds
|
|
264
|
-
* (`[PANEL_WIDTH_MIN, PANEL_WIDTH_MAX]`, default `PANEL_WIDTH_DEFAULT`).
|
|
265
|
-
* Thin wrapper around the shared `clampPanelWidth` helper from
|
|
266
|
-
* `@pilotiq/pilotiq/react` — kept exported with the panel-specific
|
|
267
|
-
* defaults baked in so existing tests + downstream callers don't have
|
|
268
|
-
* to plumb the bounds themselves.
|
|
269
|
-
*/
|
|
270
|
-
export function clampPanelWidth(value: unknown): number {
|
|
271
|
-
return clampPanelWidthShared(value, {
|
|
272
|
-
min: PANEL_WIDTH_MIN,
|
|
273
|
-
max: PANEL_WIDTH_MAX,
|
|
274
|
-
defaultWidth: PANEL_WIDTH_DEFAULT,
|
|
275
|
-
})
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Per-fieldType coerce of a nested values map (built by
|
|
280
|
-
* `parseFormDataToNested`) against the block's schema. Mirrors the
|
|
281
|
-
* server-side `coerceFormValues` at a small subset suitable for the
|
|
282
|
-
* side panel — we only run on top-level block fields plus the immediate
|
|
283
|
-
* children of any Repeater rows / Builder rows.data, which is all the
|
|
284
|
-
* V2 surface needs.
|
|
285
|
-
*
|
|
286
|
-
* Non-coerce passthrough for: text, textarea, select, radio, date,
|
|
287
|
-
* dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
|
|
288
|
-
* is already a plain string / array of strings.)
|
|
289
|
-
*
|
|
290
|
-
* Coerce branches:
|
|
291
|
-
* - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
|
|
292
|
-
* - `number` / `slider`: parse to Number, null on empty, raw string
|
|
293
|
-
* passthrough on NaN (so a half-typed value isn't lost).
|
|
294
|
-
* - `tagsInput`: JSON-encoded string → string[].
|
|
295
|
-
* - `checkboxList`: JSON-encoded string OR array → string[].
|
|
296
|
-
* - `keyValue`: JSON-encoded string → Record<string, unknown>.
|
|
297
|
-
* - `fileUpload`: single → URL string passthrough; multiple →
|
|
298
|
-
* JSON-encoded string → string[].
|
|
299
|
-
* - `repeater`: each row in the array gets recursive coerce against
|
|
300
|
-
* the field's `template` (the inner field schema definition).
|
|
301
|
-
* - `builder`: each row's `data` gets recursive coerce against the
|
|
302
|
-
* block matching `row.type` from `field.blocks[]`. Unknown block
|
|
303
|
-
* types pass through verbatim — the renderer shows a placeholder
|
|
304
|
-
* and the data round-trips intact across config rollbacks.
|
|
305
|
-
*
|
|
306
|
-
* Exported for unit tests. Pure — no React, no DOM, no editor.
|
|
307
|
-
*/
|
|
308
|
-
export function coerceBlockValues(
|
|
309
|
-
raw: Record<string, unknown>,
|
|
310
|
-
schema: ReadonlyArray<Record<string, unknown>>,
|
|
311
|
-
): Record<string, unknown> {
|
|
312
|
-
const out: Record<string, unknown> = { ...raw }
|
|
313
|
-
for (const field of schema) {
|
|
314
|
-
const name = String(field['name'] ?? '')
|
|
315
|
-
if (!name) continue
|
|
316
|
-
const ft = String(field['fieldType'] ?? 'text')
|
|
317
|
-
const value = out[name]
|
|
318
|
-
out[name] = coerceField(value, ft, field)
|
|
319
|
-
}
|
|
320
|
-
return out
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function coerceField(
|
|
324
|
-
value: unknown,
|
|
325
|
-
ft: string,
|
|
326
|
-
field: Record<string, unknown>,
|
|
327
|
-
): unknown {
|
|
328
|
-
switch (ft) {
|
|
329
|
-
case 'toggle':
|
|
330
|
-
case 'checkbox':
|
|
331
|
-
return value === 'true' || value === true
|
|
332
|
-
case 'number':
|
|
333
|
-
case 'slider':
|
|
334
|
-
return coerceNumber(value)
|
|
335
|
-
case 'tagsInput':
|
|
336
|
-
return parseJsonArray(value)
|
|
337
|
-
case 'checkboxList':
|
|
338
|
-
return parseJsonArray(value)
|
|
339
|
-
case 'keyValue':
|
|
340
|
-
return parseJsonObject(value)
|
|
341
|
-
case 'fileUpload': {
|
|
342
|
-
const multiple = Boolean(field['multiple'])
|
|
343
|
-
if (multiple) return parseJsonArray(value)
|
|
344
|
-
return typeof value === 'string' ? value : ''
|
|
345
|
-
}
|
|
346
|
-
case 'repeater': {
|
|
347
|
-
if (!Array.isArray(value)) return []
|
|
348
|
-
const template = (field['template'] as ReadonlyArray<Record<string, unknown>> | undefined) ?? []
|
|
349
|
-
return value.map((row) => {
|
|
350
|
-
if (!row || typeof row !== 'object') return {}
|
|
351
|
-
return coerceBlockValues(row as Record<string, unknown>, template)
|
|
352
|
-
})
|
|
353
|
-
}
|
|
354
|
-
case 'builder': {
|
|
355
|
-
if (!Array.isArray(value)) return []
|
|
356
|
-
const blockMetas = (field['blocks'] as ReadonlyArray<Record<string, unknown>> | undefined) ?? []
|
|
357
|
-
return value.map((row) => {
|
|
358
|
-
if (!row || typeof row !== 'object') return { type: '', data: {} }
|
|
359
|
-
const r = row as Record<string, unknown>
|
|
360
|
-
const type = String(r['type'] ?? '')
|
|
361
|
-
const data = (r['data'] as Record<string, unknown> | undefined) ?? {}
|
|
362
|
-
const block = blockMetas.find((b) => String(b['name'] ?? '') === type)
|
|
363
|
-
if (!block) return { type, data }
|
|
364
|
-
const tpl = (block['template'] as ReadonlyArray<Record<string, unknown>> | undefined) ?? []
|
|
365
|
-
return { type, data: coerceBlockValues(data, tpl) }
|
|
366
|
-
})
|
|
367
|
-
}
|
|
368
|
-
default:
|
|
369
|
-
return value === undefined ? '' : value
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function coerceNumber(value: unknown): unknown {
|
|
374
|
-
if (value === '' || value === null || value === undefined) return null
|
|
375
|
-
if (typeof value === 'number') return value
|
|
376
|
-
const raw = String(value)
|
|
377
|
-
if (raw === '') return null
|
|
378
|
-
const n = Number(raw)
|
|
379
|
-
return Number.isNaN(n) ? raw : n
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function parseJsonArray(value: unknown): unknown[] {
|
|
383
|
-
if (Array.isArray(value)) return value
|
|
384
|
-
if (typeof value !== 'string' || value === '') return []
|
|
385
|
-
try {
|
|
386
|
-
const parsed = JSON.parse(value)
|
|
387
|
-
return Array.isArray(parsed) ? parsed : []
|
|
388
|
-
} catch {
|
|
389
|
-
return []
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function parseJsonObject(value: unknown): Record<string, unknown> {
|
|
394
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
395
|
-
return value as Record<string, unknown>
|
|
396
|
-
}
|
|
397
|
-
if (typeof value !== 'string' || value === '') return {}
|
|
398
|
-
try {
|
|
399
|
-
const parsed = JSON.parse(value)
|
|
400
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
401
|
-
return parsed as Record<string, unknown>
|
|
402
|
-
}
|
|
403
|
-
} catch { /* fall through */ }
|
|
404
|
-
return {}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Read the resolved field value for a given input event target. Kept
|
|
409
|
-
* for back-compat — V1 used this in the per-event handler. V2 reads
|
|
410
|
-
* the entire form via `FormData` instead, but this helper still maps
|
|
411
|
-
* cleanly onto a single-input read for testing and is exported.
|
|
412
|
-
*
|
|
413
|
-
* String passthrough for the common case; explicit coercion for
|
|
414
|
-
* booleans and numerics so the round-trip into the node attrs preserves
|
|
415
|
-
* shape.
|
|
416
|
-
*/
|
|
417
|
-
export function readBlockFieldValue(
|
|
418
|
-
target: { type?: string; value: string; checked?: boolean },
|
|
419
|
-
fieldMeta: { fieldType?: unknown },
|
|
420
|
-
): unknown {
|
|
421
|
-
const ft = String(fieldMeta.fieldType ?? 'text')
|
|
422
|
-
if (ft === 'toggle' || ft === 'checkbox') {
|
|
423
|
-
return target.checked === true
|
|
424
|
-
}
|
|
425
|
-
if (ft === 'number' || ft === 'slider') {
|
|
426
|
-
const raw = target.value
|
|
427
|
-
if (raw === '') return null
|
|
428
|
-
const n = Number(raw)
|
|
429
|
-
return Number.isNaN(n) ? raw : n
|
|
430
|
-
}
|
|
431
|
-
return target.value
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
interface PMNode {
|
|
435
|
-
type: { name: string }
|
|
436
|
-
attrs: Record<string, unknown>
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function readBlockData(editor: Editor, pos: number): Record<string, unknown> {
|
|
440
|
-
const node = nodeAt(editor, pos)
|
|
441
|
-
if (!node) return {}
|
|
442
|
-
return (node.attrs['blockData'] as Record<string, unknown> | null) ?? {}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function nodeAt(editor: Editor, pos: number): PMNode | null {
|
|
446
|
-
try {
|
|
447
|
-
return (editor.state.doc as unknown as { nodeAt: (p: number) => PMNode | null }).nodeAt(pos)
|
|
448
|
-
} catch {
|
|
449
|
-
return null
|
|
450
|
-
}
|
|
451
|
-
}
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef } from 'react'
|
|
2
|
-
import { useEditor, EditorContent, type Extension } from '@tiptap/react'
|
|
3
|
-
import type { AnyExtension } from '@tiptap/core'
|
|
4
|
-
import {
|
|
5
|
-
useCollabRoom,
|
|
6
|
-
getCollabExtensions,
|
|
7
|
-
useCollabSeed,
|
|
8
|
-
type CollabTextRendererProps,
|
|
9
|
-
} from '@pilotiq/pilotiq/react'
|
|
10
|
-
import { createPlainTextEditor, plainTextOf, plainTextToDoc } from '../PlainTextEditor.js'
|
|
11
|
-
import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
|
|
12
|
-
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
|
|
13
|
-
import type { YDocShape } from '../collabShapes.js'
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Tiptap-backed plain-text editor for pilotiq's `TextField` / `TextareaField`
|
|
17
|
-
* / similar single-line / multi-line text fields when collab is on.
|
|
18
|
-
*
|
|
19
|
-
* Lifts the cursor-bookkeeping burden off the field renderer: y-prosemirror
|
|
20
|
-
* anchors selections to `Yjs.RelativePosition` items, so concurrent and
|
|
21
|
-
* mid-word remote edits translate the local cursor correctly without any
|
|
22
|
-
* heuristic. Replaces the legacy `Y.Text` + `computeDelta` + `preserveCursor`
|
|
23
|
-
* path documented in `docs/plans/text-fields-tiptap-backed-collab.md`.
|
|
24
|
-
*
|
|
25
|
-
* Mount conditions (enforced upstream by `TextLikeInput`):
|
|
26
|
-
* - A `<RecordCollabRoom>` is mounted up-tree (`useCollabRoom() !== null`).
|
|
27
|
-
* - A collab extension factory was registered (`getCollabExtensions() !== null`).
|
|
28
|
-
* - The field hasn't opted out via `.collab(false)`.
|
|
29
|
-
* - The field is not masked (`.mask(pattern)`).
|
|
30
|
-
* - The field is top-level (not a Repeater / Builder row leaf).
|
|
31
|
-
*
|
|
32
|
-
* If either the room or the factory disappears at runtime (e.g. the plugin
|
|
33
|
-
* was never installed), we still render an editor — it's just a non-collab
|
|
34
|
-
* plain Tiptap. That's a regression vs `<input>` ergonomically but never
|
|
35
|
-
* crashes; in practice the upstream gate prevents this branch from mounting
|
|
36
|
-
* when collab isn't wired.
|
|
37
|
-
*/
|
|
38
|
-
export function CollabTextRenderer({
|
|
39
|
-
name,
|
|
40
|
-
fragmentKey,
|
|
41
|
-
multiline,
|
|
42
|
-
defaultValue,
|
|
43
|
-
placeholder,
|
|
44
|
-
disabled,
|
|
45
|
-
onChange,
|
|
46
|
-
onBlur,
|
|
47
|
-
onSubmit,
|
|
48
|
-
className,
|
|
49
|
-
editorAttributes,
|
|
50
|
-
}: CollabTextRendererProps): React.ReactElement {
|
|
51
|
-
const room = useCollabRoom()
|
|
52
|
-
const factory = getCollabExtensions()
|
|
53
|
-
const collabActive = !!(room && factory)
|
|
54
|
-
|
|
55
|
-
// Capture the initial non-empty `defaultValue` on mount + keep it stable.
|
|
56
|
-
// The seedFn (below) needs to re-seed the empty fragment with the
|
|
57
|
-
// SSR-loaded value even if the host's `defaultValue` prop has been
|
|
58
|
-
// clobbered to '' by a sync-triggered empty `onChange` round-trip
|
|
59
|
-
// (handleChange → setText('') → setValue('') → ctx.values.title='' →
|
|
60
|
-
// FormBody re-renders the field with the new empty value). Closing
|
|
61
|
-
// over the live prop in seedFn means seed-on-resync silently fails;
|
|
62
|
-
// the ref preserves the seed source. Once the user types into the
|
|
63
|
-
// editor the seedFn will no longer fire — the fragment won't be
|
|
64
|
-
// empty — so this ref is read-only after the first non-empty seed.
|
|
65
|
-
const initialDefaultValueRef = useRef<string>(defaultValue)
|
|
66
|
-
if (defaultValue && !initialDefaultValueRef.current) {
|
|
67
|
-
initialDefaultValueRef.current = defaultValue
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Collab-stable identifier — `name` on top-level fields, but the
|
|
71
|
-
// row-id-anchored composite (`items.<rowId>.title`) on Repeater /
|
|
72
|
-
// Builder row leaves so the Y.XmlFragment survives reorders. Routes
|
|
73
|
-
// ONLY into the collab branches (factory + first-load seed); AI
|
|
74
|
-
// suggestion bridge + onChange + hidden FormData input stay on
|
|
75
|
-
// `name` (the positional FormData path) so AI tool calls referencing
|
|
76
|
-
// the field by its FormData name still reach the editor.
|
|
77
|
-
const collabName = fragmentKey ?? name
|
|
78
|
-
|
|
79
|
-
// Built once per editor mount. The factory closes over the room's `ydoc`
|
|
80
|
-
// + `provider` and the field name to produce a `Collaboration` (and
|
|
81
|
-
// optional `CollaborationCursor`) extension targeting the field's
|
|
82
|
-
// `Y.XmlFragment`. Re-running on every render would tear down the editor.
|
|
83
|
-
const collabExtensions = useMemo<AnyExtension[]>(() => {
|
|
84
|
-
if (!collabActive || !room || !factory) return []
|
|
85
|
-
return factory({
|
|
86
|
-
ydoc: room.ydoc,
|
|
87
|
-
provider: room.provider,
|
|
88
|
-
fieldName: collabName,
|
|
89
|
-
...(room.user ? { user: room.user } : {}),
|
|
90
|
-
}) as Extension[]
|
|
91
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
-
}, [collabActive])
|
|
93
|
-
|
|
94
|
-
const editor = useEditor(
|
|
95
|
-
{
|
|
96
|
-
// Tiptap v3 SSR guard. With `immediatelyRender: true` (default)
|
|
97
|
-
// `useEditor` touches the DOM during construction; under Vike's
|
|
98
|
-
// `onRenderHtml` that throws "SSR has been detected, please set
|
|
99
|
-
// `immediatelyRender` explicitly to `false` to avoid hydration
|
|
100
|
-
// mismatches." Deferring until the first React effect lets SSR
|
|
101
|
-
// produce an empty shell + hydration mount the live editor.
|
|
102
|
-
//
|
|
103
|
-
// Load-bearing for the AI-attached auto-upgrade path: with rule
|
|
104
|
-
// #2, AI fields render the Tiptap surface during SSR (where
|
|
105
|
-
// `useCollabRoom()` is null but `aiActions.length > 0` flips the
|
|
106
|
-
// host's gate). Without this flag the dev server would crash on
|
|
107
|
-
// the first SSR pass of any record-edit page touching AI fields.
|
|
108
|
-
immediatelyRender: false,
|
|
109
|
-
...createPlainTextEditor({
|
|
110
|
-
multiline,
|
|
111
|
-
...(placeholder !== undefined ? { placeholder } : {}),
|
|
112
|
-
editable: !disabled,
|
|
113
|
-
// When Collaboration owns the doc, omit `content` so the editor
|
|
114
|
-
// doesn't race the y-prosemirror sync. The post-`synced` effect below
|
|
115
|
-
// seeds the fragment on first connect when it's still empty. When
|
|
116
|
-
// collab is off, seed from defaultValue directly.
|
|
117
|
-
content: collabActive ? '' : defaultValue,
|
|
118
|
-
// AI suggestions — always-on extension that tracks suggested edits as
|
|
119
|
-
// inline strikethrough + Approve/Reject chip widgets. Idle until the
|
|
120
|
-
// host calls `editor.commands.addAiSuggestion(...)` via the bridge below.
|
|
121
|
-
// Matches the `TiptapEditor` wiring so suggestion mode works uniformly
|
|
122
|
-
// across RichTextField / MarkdownField / TextField+TextareaField.
|
|
123
|
-
extensions: [...collabExtensions, AiSuggestionExtension],
|
|
124
|
-
onUpdate: (text) => onChange(text),
|
|
125
|
-
...(onSubmit ? { onSubmit: () => { onSubmit(); return false } } : {}),
|
|
126
|
-
...(className || editorAttributes
|
|
127
|
-
? {
|
|
128
|
-
editorAttributes: {
|
|
129
|
-
...(editorAttributes ?? {}),
|
|
130
|
-
...(className ? { class: className } : {}),
|
|
131
|
-
},
|
|
132
|
-
}
|
|
133
|
-
: {}),
|
|
134
|
-
}),
|
|
135
|
-
},
|
|
136
|
-
// Re-mount when collab toggles. Other props (multiline, name, etc) are
|
|
137
|
-
// stable per mount under the upstream gate.
|
|
138
|
-
[collabActive],
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
// Mirror the editor's editable state with the prop. `useEditor` snapshots
|
|
142
|
-
// `editable` at first call, so we update it imperatively on changes.
|
|
143
|
-
useEffect(() => {
|
|
144
|
-
if (!editor) return
|
|
145
|
-
editor.setEditable(!disabled)
|
|
146
|
-
}, [editor, disabled])
|
|
147
|
-
|
|
148
|
-
// Cross-package suggestion bridge — sync the host's
|
|
149
|
-
// `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
|
|
150
|
-
// extension. No-op when no provider is mounted (default no-op context).
|
|
151
|
-
//
|
|
152
|
-
// Whole-field fallback: chat-driven suggestions (e.g. `update_form_state`)
|
|
153
|
-
// arrive without `meta.editorRange`. Plain-text editors opt into a
|
|
154
|
-
// synthesized full-doc range so the inline-diff chip (red strikethrough on
|
|
155
|
-
// the current value + green chip with the suggested text + ✓/✕ buttons)
|
|
156
|
-
// renders BEFORE the user approves. The extension's `applyApprove` is
|
|
157
|
-
// text-node-based which fits the plain-text schema exactly. The
|
|
158
|
-
// `onApplyWholeField` callback stays as a fallback for cases that don't
|
|
159
|
-
// synthesize (e.g. an empty doc — `from === to` skips the chip but the
|
|
160
|
-
// applier still needs to swap content).
|
|
161
|
-
useAiSuggestionBridge(editor ?? null, name, {
|
|
162
|
-
synthesizeWholeFieldRange: (ed) => ({
|
|
163
|
-
from: 0,
|
|
164
|
-
to: ed.state.doc.content.size,
|
|
165
|
-
}),
|
|
166
|
-
onApplyWholeField: (value) => {
|
|
167
|
-
if (!editor || editor.isDestroyed) return
|
|
168
|
-
editor.commands.setContent(plainTextToDoc(value, !!multiline))
|
|
169
|
-
},
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
// First-load seed when collab is active. Collaboration starts the editor
|
|
173
|
-
// empty regardless of `defaultValue`; once the room's first sync
|
|
174
|
-
// resolves, `useCollabSeed` runs the callback inside `ydoc.transact`.
|
|
175
|
-
// Empty fragment + we have an initial value = first session for this
|
|
176
|
-
// record — push the SSR-rendered default into the editor once.
|
|
177
|
-
//
|
|
178
|
-
// Race caveat: two peers simultaneously mounting against a brand-new
|
|
179
|
-
// record (both seeing `fragment.length === 0`) can both seed and produce
|
|
180
|
-
// duplicated text. Same window as `TiptapEditor`'s rich-text seed path.
|
|
181
|
-
// Acceptable for now; can be tightened later via a deterministic
|
|
182
|
-
// first-writer election or a server-side seed handoff.
|
|
183
|
-
//
|
|
184
|
-
// Subscribe-after-sync mirror: after the seed branch (or no-op when the
|
|
185
|
-
// fragment already has content from a remote peer), replay the editor's
|
|
186
|
-
// current text into `onChange`. The mount-time safety-net effect below
|
|
187
|
-
// fires once when the editor instance materializes, but in the cold-mount
|
|
188
|
-
// case (fresh peer joining a populated doc) y-prosemirror's `ySyncPlugin`
|
|
189
|
-
// view hook may run _forceRerender before the React owner has installed
|
|
190
|
-
// the `update` listener that drives `onUpdate` — leaving the hidden
|
|
191
|
-
// FormData input empty. Mirroring after `room.synced` resolves closes the
|
|
192
|
-
// gap. Idempotent — when `onUpdate` already propagated the value, this is
|
|
193
|
-
// a no-op `setText(sameValue)`. Same shape as the catch-up replay in
|
|
194
|
-
// `@pilotiq-pro/collab`'s `rowArrayBinding.subscribeRows`.
|
|
195
|
-
useCollabSeed(
|
|
196
|
-
editor && collabActive ? room : null,
|
|
197
|
-
collabName,
|
|
198
|
-
(doc) => {
|
|
199
|
-
const fragment = (doc as YDocShape).getXmlFragment(collabName)
|
|
200
|
-
const seedValue = initialDefaultValueRef.current
|
|
201
|
-
if (fragment && fragment.length === 0 && seedValue && editor) {
|
|
202
|
-
editor.commands.setContent(plainTextToDoc(seedValue, multiline))
|
|
203
|
-
}
|
|
204
|
-
if (editor) onChange(plainTextOf(editor))
|
|
205
|
-
},
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
// Bubble the editor's blur event up to the host. Tiptap exposes this via
|
|
209
|
-
// `editor.on('blur', ...)`. The simpler `onBlur` prop on `EditorContent`
|
|
210
|
-
// fires on the DOM node, but selection inside contenteditable can land on
|
|
211
|
-
// child nodes; the Tiptap event is the canonical "editor lost focus".
|
|
212
|
-
useEffect(() => {
|
|
213
|
-
if (!editor) return
|
|
214
|
-
const handler = (): void => onBlur()
|
|
215
|
-
editor.on('blur', handler)
|
|
216
|
-
return () => { editor.off('blur', handler) }
|
|
217
|
-
}, [editor, onBlur])
|
|
218
|
-
|
|
219
|
-
// Best-effort getText safety net — onUpdate should fire on every
|
|
220
|
-
// y-prosemirror sync, but if a remote update somehow doesn't trigger
|
|
221
|
-
// `onUpdate`, the wrapper's hidden input goes stale. Re-emit on every
|
|
222
|
-
// editor render tick. No-op when text matches the last emit.
|
|
223
|
-
useEffect(() => {
|
|
224
|
-
if (!editor) return
|
|
225
|
-
onChange(plainTextOf(editor))
|
|
226
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
227
|
-
}, [editor])
|
|
228
|
-
|
|
229
|
-
return <EditorContent editor={editor} />
|
|
230
|
-
}
|