@pilotiq/tiptap 3.19.2 → 3.20.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/CHANGELOG.md +19 -0
- package/dist/extensions/BlockNodeExtension.d.ts +0 -9
- package/dist/extensions/BlockNodeExtension.js +0 -20
- package/dist/react/BlockNodeView.d.ts +23 -12
- package/dist/react/BlockNodeView.js +55 -21
- package/dist/react/TiptapEditor.js +7 -42
- package/dist/react/blockValues.d.ts +54 -0
- package/dist/react/blockValues.js +161 -0
- package/dist/react/floatingToolbarVisibility.d.ts +9 -2
- package/dist/react/floatingToolbarVisibility.js +12 -3
- package/package.json +1 -1
- package/dist/react/BlockSidePanel.d.ts +0 -105
- package/dist/react/BlockSidePanel.js +0 -338
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @pilotiq/tiptap
|
|
2
2
|
|
|
3
|
+
## 3.20.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 47c8187: Custom blocks now edit inline (accordion) instead of in a right-docked side panel.
|
|
8
|
+
|
|
9
|
+
Clicking **Edit** on an inserted custom block (`Block.make().schema([...])`) expands the block in place and renders its schema as a `FormFields` form; edits write straight back onto the node via `updateAttributes({ blockData })` on every change — no popup, no save button.
|
|
10
|
+
|
|
11
|
+
This replaces the `BlockSidePanel` and removes the machinery that existed only to host the form outside the NodeView: the `onEdit` bridge + `Mod-e` shortcut on `BlockNodeExtension`, and the host-side `selectedBlock` state / position-remapping in `TiptapEditor`. The form lives in a `contentEditable=false` region with event guards so ProseMirror never treats the inputs as document content. Pure `coerceBlockValues` / `readBlockFieldValue` helpers moved to `react/blockValues.ts`.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 1a1026d: Inline format toolbar visibility fixed for block nodes (#155).
|
|
16
|
+
|
|
17
|
+
Two adjustments to `shouldShowFloatingToolbar`:
|
|
18
|
+
|
|
19
|
+
- **Hidden on a whole-node block selection.** Clicking a schema-form custom block card (`pilotiqBlock`), an image, an hr, or picking a whole Alert via the drag handle produces a `NodeSelection` with no inline text to format — the bold/italic/link toolbar no longer appears for any of these. Previously only the built-in `alert` block was special-cased, so custom block cards still surfaced the toolbar.
|
|
20
|
+
- **Shown inside the Alert block's editable text.** The Alert block has an editable title and body; the mark toolbar now works there like anywhere else. An earlier fix over-suppressed the entire Alert (including its editable text) — that suppression is reversed; only the whole-node pick is hidden now.
|
|
21
|
+
|
|
3
22
|
## 3.19.2
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
|
@@ -17,15 +17,6 @@ export interface BlockNodeOptions {
|
|
|
17
17
|
* registry data via React context.
|
|
18
18
|
*/
|
|
19
19
|
blocks: BlockMeta[];
|
|
20
|
-
/**
|
|
21
|
-
* Bridge from the NodeView's separate React tree back to the editor's
|
|
22
|
-
* own tree, where the side panel lives. Set by `TiptapEditor` so the
|
|
23
|
-
* "Edit" button on each block can request the panel open against this
|
|
24
|
-
* specific node. `undefined` means no host is listening — the NodeView
|
|
25
|
-
* falls back to a no-op (does not render an Edit affordance, or does
|
|
26
|
-
* so disabled, depending on the consumer's chrome).
|
|
27
|
-
*/
|
|
28
|
-
onEdit?: (pos: number) => void;
|
|
29
20
|
}
|
|
30
21
|
/**
|
|
31
22
|
* Single ProseMirror node type that represents every custom block. The
|
|
@@ -25,9 +25,6 @@ export const BlockNodeExtension = Node.create({
|
|
|
25
25
|
// dispatch — even before any block was inserted.
|
|
26
26
|
draggable: true,
|
|
27
27
|
addOptions() {
|
|
28
|
-
// `onEdit` intentionally omitted — `exactOptionalPropertyTypes` makes
|
|
29
|
-
// an explicit `undefined` non-assignable to the optional field, and
|
|
30
|
-
// the host wires it via `BlockNodeExtension.configure({ onEdit })`.
|
|
31
28
|
return { blocks: [] };
|
|
32
29
|
},
|
|
33
30
|
addAttributes() {
|
|
@@ -82,21 +79,4 @@ export const BlockNodeExtension = Node.create({
|
|
|
82
79
|
}),
|
|
83
80
|
};
|
|
84
81
|
},
|
|
85
|
-
// `Mod-e` opens the side panel for the currently NodeSelected block.
|
|
86
|
-
// Returns false when no block is selected so the browser's default
|
|
87
|
-
// (Safari "Use Selection for Find", etc.) still applies in plain text.
|
|
88
|
-
addKeyboardShortcuts() {
|
|
89
|
-
return {
|
|
90
|
-
'Mod-e': () => {
|
|
91
|
-
const onEdit = this.options.onEdit;
|
|
92
|
-
if (!onEdit)
|
|
93
|
-
return false;
|
|
94
|
-
const sel = this.editor.state.selection;
|
|
95
|
-
if (sel.node?.type.name !== this.name)
|
|
96
|
-
return false;
|
|
97
|
-
onEdit(sel.from);
|
|
98
|
-
return true;
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
},
|
|
102
82
|
});
|
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
import { type NodeViewProps } from '@tiptap/react';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* summary card with an "Edit" button.
|
|
3
|
+
* React NodeView for the `pilotiqBlock` ProseMirror node. Reads the block
|
|
4
|
+
* type from `node.attrs.blockType`, looks up its `BlockMeta` in
|
|
5
|
+
* `BlockNodeExtension.options.blocks`, and renders a compact summary card.
|
|
7
6
|
*
|
|
8
|
-
* Editing
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* Editing is **inline** (accordion): clicking the card (or the Edit chevron)
|
|
8
|
+
* expands a panel below the summary that hosts the block's `Block.schema([…])`
|
|
9
|
+
* as a real pilotiq form via `<FormFields>`. Edits write straight back onto
|
|
10
|
+
* the node with `updateAttributes({ blockData })` on every change — the
|
|
11
|
+
* NodeView already owns the node, so there's no host bridge / side panel /
|
|
12
|
+
* position-remapping to thread through.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* The form is rendered in a `contentEditable={false}` region and every input
|
|
15
|
+
* event is stopped from bubbling into ProseMirror, so the editor never treats
|
|
16
|
+
* the form inputs as document content or hijacks their focus/selection.
|
|
17
|
+
*
|
|
18
|
+
* Reads: each field's `defaultValue` is overridden from the block's stored
|
|
19
|
+
* `blockData`, snapshotted once per expand into `initialValuesRef`. Inputs are
|
|
20
|
+
* uncontrolled (outside a `FormStateProvider`, pilotiq's renderers fall back to
|
|
21
|
+
* `defaultValue`), so write-back transactions re-rendering the NodeView never
|
|
22
|
+
* reset the user's in-progress typing.
|
|
23
|
+
*
|
|
24
|
+
* Writes: container-level `onInput` / `onChange` delegation. Every change
|
|
25
|
+
* snapshots the whole form via `new FormData(formEl)` → `parseFormDataToNested`
|
|
26
|
+
* (rebuilds nested arrays/objects from dotted-path inputs like `items.0.title`)
|
|
27
|
+
* → `coerceBlockValues` (per-fieldType JSON parse / boolean / number coerce).
|
|
17
28
|
*/
|
|
18
29
|
export declare function BlockNodeView(props: NodeViewProps): import("react").JSX.Element | null;
|
|
19
30
|
//# sourceMappingURL=BlockNodeView.d.ts.map
|
|
@@ -1,33 +1,52 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect } from 'react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { NodeViewWrapper } from '@tiptap/react';
|
|
4
|
+
import { FormFields, parseFormDataToNested } from '@pilotiq/pilotiq/react';
|
|
5
|
+
import { coerceBlockValues } from './blockValues.js';
|
|
4
6
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* summary card with an "Edit" button.
|
|
7
|
+
* React NodeView for the `pilotiqBlock` ProseMirror node. Reads the block
|
|
8
|
+
* type from `node.attrs.blockType`, looks up its `BlockMeta` in
|
|
9
|
+
* `BlockNodeExtension.options.blocks`, and renders a compact summary card.
|
|
9
10
|
*
|
|
10
|
-
* Editing
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Editing is **inline** (accordion): clicking the card (or the Edit chevron)
|
|
12
|
+
* expands a panel below the summary that hosts the block's `Block.schema([…])`
|
|
13
|
+
* as a real pilotiq form via `<FormFields>`. Edits write straight back onto
|
|
14
|
+
* the node with `updateAttributes({ blockData })` on every change — the
|
|
15
|
+
* NodeView already owns the node, so there's no host bridge / side panel /
|
|
16
|
+
* position-remapping to thread through.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* The form is rendered in a `contentEditable={false}` region and every input
|
|
19
|
+
* event is stopped from bubbling into ProseMirror, so the editor never treats
|
|
20
|
+
* the form inputs as document content or hijacks their focus/selection.
|
|
21
|
+
*
|
|
22
|
+
* Reads: each field's `defaultValue` is overridden from the block's stored
|
|
23
|
+
* `blockData`, snapshotted once per expand into `initialValuesRef`. Inputs are
|
|
24
|
+
* uncontrolled (outside a `FormStateProvider`, pilotiq's renderers fall back to
|
|
25
|
+
* `defaultValue`), so write-back transactions re-rendering the NodeView never
|
|
26
|
+
* reset the user's in-progress typing.
|
|
27
|
+
*
|
|
28
|
+
* Writes: container-level `onInput` / `onChange` delegation. Every change
|
|
29
|
+
* snapshots the whole form via `new FormData(formEl)` → `parseFormDataToNested`
|
|
30
|
+
* (rebuilds nested arrays/objects from dotted-path inputs like `items.0.title`)
|
|
31
|
+
* → `coerceBlockValues` (per-fieldType JSON parse / boolean / number coerce).
|
|
19
32
|
*/
|
|
20
33
|
export function BlockNodeView(props) {
|
|
21
|
-
const { editor, node,
|
|
34
|
+
const { editor, node, deleteNode, updateAttributes } = props;
|
|
22
35
|
const blockType = String(node.attrs['blockType'] ?? '');
|
|
23
36
|
const blockData = node.attrs['blockData'] ?? {};
|
|
37
|
+
const editable = editor.isEditable;
|
|
24
38
|
// Tiptap mounts NodeViews in a separate React tree, so we can't read the
|
|
25
39
|
// block registry through context. Pull it off the extension's options
|
|
26
40
|
// instead — set by RichTextField via BlockNodeExtension.configure({ blocks }).
|
|
27
41
|
const blockExt = editor.extensionManager.extensions.find((e) => e.name === 'pilotiqBlock');
|
|
28
42
|
const blocks = blockExt?.options['blocks'] ?? [];
|
|
29
|
-
const onEdit = blockExt?.options['onEdit'];
|
|
30
43
|
const meta = blocks.find((b) => b.name === blockType);
|
|
44
|
+
const [expanded, setExpanded] = useState(false);
|
|
45
|
+
// Seeds the form's `defaultValue`s. Re-snapshotted from the live node each
|
|
46
|
+
// time the panel opens; not updated mid-edit (uncontrolled inputs hold their
|
|
47
|
+
// own state while open).
|
|
48
|
+
const initialValuesRef = useRef(blockData);
|
|
49
|
+
const formRef = useRef(null);
|
|
31
50
|
// Self-heal: a block with no `blockType` is malformed — almost always
|
|
32
51
|
// means a stale node from a prior buggy insert. Delete it on mount so
|
|
33
52
|
// the editor doesn't get stuck in an unrecoverable state.
|
|
@@ -47,13 +66,28 @@ export function BlockNodeView(props) {
|
|
|
47
66
|
})
|
|
48
67
|
.filter(Boolean)
|
|
49
68
|
.join(' · ') || meta.label;
|
|
50
|
-
const
|
|
51
|
-
if (!
|
|
69
|
+
const toggleExpanded = () => {
|
|
70
|
+
if (!editable)
|
|
52
71
|
return;
|
|
53
|
-
|
|
54
|
-
|
|
72
|
+
setExpanded((prev) => {
|
|
73
|
+
const next = !prev;
|
|
74
|
+
if (next) {
|
|
75
|
+
initialValuesRef.current =
|
|
76
|
+
node.attrs['blockData'] ?? {};
|
|
77
|
+
}
|
|
78
|
+
return next;
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
const handleChange = () => {
|
|
82
|
+
const formEl = formRef.current;
|
|
83
|
+
if (!formEl)
|
|
55
84
|
return;
|
|
56
|
-
|
|
85
|
+
const raw = parseFormDataToNested(new FormData(formEl));
|
|
86
|
+
const coerced = coerceBlockValues(raw, meta.schema);
|
|
87
|
+
updateAttributes({ blockData: coerced });
|
|
57
88
|
};
|
|
58
|
-
return (
|
|
89
|
+
return (_jsxs(NodeViewWrapper, { className: "pilotiq-block my-3 rounded-lg border bg-muted/30", children: [_jsxs("div", { className: "flex items-start justify-between gap-2 px-3 py-2", children: [_jsxs("button", { type: "button", onClick: toggleExpanded, disabled: !editable, className: "flex min-w-0 items-center gap-2 text-left text-sm disabled:cursor-default", children: [meta.icon && _jsx("span", { "aria-hidden": "true", children: meta.icon }), _jsx("span", { className: "font-medium", children: meta.label }), _jsx("span", { className: "line-clamp-1 text-xs text-muted-foreground", children: summary })] }), editable && (_jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [_jsxs("button", { type: "button", onClick: toggleExpanded, "aria-expanded": expanded, className: "flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground", children: [expanded ? 'Done' : 'Edit', _jsx("svg", { viewBox: "0 0 24 24", className: 'size-3.5 transition-transform ' + (expanded ? 'rotate-180' : ''), fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }), _jsx("button", { type: "button", onClick: () => deleteNode(), className: "text-xs text-destructive hover:underline", children: "Remove" })] }))] }), expanded && editable && (
|
|
90
|
+
// contentEditable=false + event guards keep ProseMirror from treating
|
|
91
|
+
// the form inputs as document content or stealing their focus/caret.
|
|
92
|
+
_jsx("div", { contentEditable: false, className: "border-t px-3 py-3", onMouseDown: (e) => e.stopPropagation(), onPointerDown: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), onKeyUp: (e) => e.stopPropagation(), onPaste: (e) => e.stopPropagation(), onDrop: (e) => e.stopPropagation(), children: _jsx("form", { ref: formRef, onInput: handleChange, onChange: handleChange, onSubmit: (e) => e.preventDefault(), className: "flex flex-col gap-3", children: _jsx(FormFields, { elements: meta.schema, values: initialValuesRef.current }) }) }))] }));
|
|
59
93
|
}
|
|
@@ -34,7 +34,6 @@ import { MentionMenu } from './MentionMenu.js';
|
|
|
34
34
|
import { FloatingToolbar } from './FloatingToolbar.js';
|
|
35
35
|
import { TableFloatingToolbar } from './TableFloatingToolbar.js';
|
|
36
36
|
import { Toolbar, AttachFilesDialog, useEditorTick } from './Toolbar.js';
|
|
37
|
-
import { BlockSidePanel } from './BlockSidePanel.js';
|
|
38
37
|
/**
|
|
39
38
|
* The pilotiq field renderer for `RichTextField`. Registered globally via
|
|
40
39
|
* `registerTiptap()`; pilotiq's `SchemaRenderer` looks it up by `fieldType:
|
|
@@ -168,36 +167,9 @@ function ClientEditor(props) {
|
|
|
168
167
|
setMentionDismissed(false);
|
|
169
168
|
setRawMentionState(s);
|
|
170
169
|
}, []);
|
|
171
|
-
// Custom
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
// below). Stores `pos` + `blockType` at open-time; `BlockSidePanel`
|
|
175
|
-
// tracks the position forward through transactions and writes attrs
|
|
176
|
-
// back via setNodeMarkup. Closing nullifies the slot — re-opening
|
|
177
|
-
// remounts the panel fresh, including a re-snapshot of `blockData`.
|
|
178
|
-
const [selectedBlock, setSelectedBlock] = useState(null);
|
|
179
|
-
const handleEditBlock = useCallback((pos) => {
|
|
180
|
-
// We resolve `blockType` here against the current doc so a stale
|
|
181
|
-
// pos (e.g. the block was just deleted before the click landed)
|
|
182
|
-
// produces a no-op rather than an empty panel.
|
|
183
|
-
setSelectedBlock((prev) => {
|
|
184
|
-
// Read from the editor lazily — the editor ref isn't stable yet
|
|
185
|
-
// on the very first render where this callback is created, so
|
|
186
|
-
// defer the lookup to call time.
|
|
187
|
-
const ed = editorRef.current;
|
|
188
|
-
if (!ed)
|
|
189
|
-
return prev;
|
|
190
|
-
const node = ed.state.doc.nodeAt(pos);
|
|
191
|
-
if (!node || node.type.name !== 'pilotiqBlock')
|
|
192
|
-
return prev;
|
|
193
|
-
return { pos, blockType: String(node.attrs['blockType'] ?? '') };
|
|
194
|
-
});
|
|
195
|
-
}, []);
|
|
196
|
-
const closeBlockPanel = useCallback(() => { setSelectedBlock(null); }, []);
|
|
197
|
-
// editorRef gives the onEdit callback access to the editor instance
|
|
198
|
-
// without re-creating the callback on every render (which would force
|
|
199
|
-
// the extension config to re-evaluate, triggering a full editor reset).
|
|
200
|
-
const editorRef = useRef(null);
|
|
170
|
+
// Custom blocks (`pilotiqBlock`) edit inline now — the NodeView expands an
|
|
171
|
+
// accordion form and writes attrs back via `updateAttributes` itself, so the
|
|
172
|
+
// host needs no side panel, no `onEdit` bridge, and no position tracking.
|
|
201
173
|
// Resolve the collab-attached extensions once per editor build.
|
|
202
174
|
// `Collaboration` is constructed eagerly here (during `useEditor`'s
|
|
203
175
|
// first call); the keyed remount above guarantees we never swap it.
|
|
@@ -283,10 +255,9 @@ function ClientEditor(props) {
|
|
|
283
255
|
Placeholder.configure({ placeholder: placeholder ?? 'Start writing…' }),
|
|
284
256
|
// BlockNodeExtension carries the block registry on its options —
|
|
285
257
|
// NodeViews mount in a separate React tree and can't see context.
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
BlockNodeExtension.configure({ blocks, onEdit: handleEditBlock }),
|
|
258
|
+
// The NodeView edits inline (accordion) and writes back itself, so no
|
|
259
|
+
// host callback is threaded here.
|
|
260
|
+
BlockNodeExtension.configure({ blocks }),
|
|
290
261
|
...(slashEnabled ? [SlashCommandExtension.configure({
|
|
291
262
|
blocks,
|
|
292
263
|
mergeTags,
|
|
@@ -400,12 +371,6 @@ function ClientEditor(props) {
|
|
|
400
371
|
if (debounceRef.current)
|
|
401
372
|
clearTimeout(debounceRef.current);
|
|
402
373
|
}, []);
|
|
403
|
-
// Mirror the editor instance into a ref so callbacks captured during
|
|
404
|
-
// `useEditor`'s extension config (notably the BlockNode `onEdit`
|
|
405
|
-
// bridge) can reach the live editor without re-creating themselves
|
|
406
|
-
// every render. Re-creation would force the editor to rebuild from
|
|
407
|
-
// scratch on every keystroke.
|
|
408
|
-
useEffect(() => { editorRef.current = editor ?? null; }, [editor]);
|
|
409
374
|
// Mirror `disabled` onto the live editor at runtime. `useEditor`'s
|
|
410
375
|
// `editable: !disabled` only fires at construction time, so a parent
|
|
411
376
|
// flipping read-only after mount (e.g. policy denial mid-edit, form
|
|
@@ -505,7 +470,7 @@ function ClientEditor(props) {
|
|
|
505
470
|
onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
|
|
506
471
|
onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
|
|
507
472
|
}
|
|
508
|
-
: {}) }), editor && floatingEnabled && _jsx(FloatingToolbar, { editor: editor }), editor && _jsx(TableFloatingToolbar, { editor: editor }), _jsx(SlashPopover, { state: slashState, keyHandlerRef: slashKeyRef }), _jsx(MentionPopover, { state: mentionState, keyHandlerRef: mentionKeyRef })
|
|
473
|
+
: {}) }), editor && floatingEnabled && _jsx(FloatingToolbar, { editor: editor }), editor && _jsx(TableFloatingToolbar, { editor: editor }), _jsx(SlashPopover, { state: slashState, keyHandlerRef: slashKeyRef }), _jsx(MentionPopover, { state: mentionState, keyHandlerRef: mentionKeyRef })] }));
|
|
509
474
|
}
|
|
510
475
|
/**
|
|
511
476
|
* Cursor-anchored popover for the mention menu. Same Floating-UI / virtual-
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure value-coercion helpers for custom-block (`pilotiqBlock`) form data.
|
|
3
|
+
*
|
|
4
|
+
* The inline accordion editor in `BlockNodeView` snapshots its `<form>` via
|
|
5
|
+
* `new FormData(formEl)` → `parseFormDataToNested` (rebuilds nested arrays /
|
|
6
|
+
* objects from dotted-path inputs like `items.0.title`) → `coerceBlockValues`
|
|
7
|
+
* (per-fieldType JSON parse / boolean / number coerce so nested-shape fields
|
|
8
|
+
* round-trip in their canonical wire form) before writing the result back onto
|
|
9
|
+
* the node via `updateAttributes({ blockData })`.
|
|
10
|
+
*
|
|
11
|
+
* No React, no DOM, no editor — exported for unit tests.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Per-fieldType coerce of a nested values map (built by
|
|
15
|
+
* `parseFormDataToNested`) against the block's schema. Mirrors the
|
|
16
|
+
* server-side `coerceFormValues` at a small subset suitable for the
|
|
17
|
+
* inline block editor — top-level block fields plus the immediate
|
|
18
|
+
* children of any Repeater rows / Builder rows.data.
|
|
19
|
+
*
|
|
20
|
+
* Non-coerce passthrough for: text, textarea, select, radio, date,
|
|
21
|
+
* dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
|
|
22
|
+
* is already a plain string / array of strings.)
|
|
23
|
+
*
|
|
24
|
+
* Coerce branches:
|
|
25
|
+
* - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
|
|
26
|
+
* - `number` / `slider`: parse to Number, null on empty, raw string
|
|
27
|
+
* passthrough on NaN (so a half-typed value isn't lost).
|
|
28
|
+
* - `tagsInput`: JSON-encoded string → string[].
|
|
29
|
+
* - `checkboxList`: JSON-encoded string OR array → string[].
|
|
30
|
+
* - `keyValue`: JSON-encoded string → Record<string, unknown>.
|
|
31
|
+
* - `fileUpload`: single → URL string passthrough; multiple →
|
|
32
|
+
* JSON-encoded string → string[].
|
|
33
|
+
* - `repeater`: each row in the array gets recursive coerce against
|
|
34
|
+
* the field's `template` (the inner field schema definition).
|
|
35
|
+
* - `builder`: each row's `data` gets recursive coerce against the
|
|
36
|
+
* block matching `row.type` from `field.blocks[]`. Unknown block
|
|
37
|
+
* types pass through verbatim — the renderer shows a placeholder
|
|
38
|
+
* and the data round-trips intact across config rollbacks.
|
|
39
|
+
*/
|
|
40
|
+
export declare function coerceBlockValues(raw: Record<string, unknown>, schema: ReadonlyArray<Record<string, unknown>>): Record<string, unknown>;
|
|
41
|
+
/**
|
|
42
|
+
* Read the resolved field value for a given input event target. Maps a
|
|
43
|
+
* single-input change onto its coerced wire shape — string passthrough
|
|
44
|
+
* for the common case; explicit coercion for booleans and numerics so
|
|
45
|
+
* the round-trip into the node attrs preserves shape. Exported for tests.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readBlockFieldValue(target: {
|
|
48
|
+
type?: string;
|
|
49
|
+
value: string;
|
|
50
|
+
checked?: boolean;
|
|
51
|
+
}, fieldMeta: {
|
|
52
|
+
fieldType?: unknown;
|
|
53
|
+
}): unknown;
|
|
54
|
+
//# sourceMappingURL=blockValues.d.ts.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure value-coercion helpers for custom-block (`pilotiqBlock`) form data.
|
|
3
|
+
*
|
|
4
|
+
* The inline accordion editor in `BlockNodeView` snapshots its `<form>` via
|
|
5
|
+
* `new FormData(formEl)` → `parseFormDataToNested` (rebuilds nested arrays /
|
|
6
|
+
* objects from dotted-path inputs like `items.0.title`) → `coerceBlockValues`
|
|
7
|
+
* (per-fieldType JSON parse / boolean / number coerce so nested-shape fields
|
|
8
|
+
* round-trip in their canonical wire form) before writing the result back onto
|
|
9
|
+
* the node via `updateAttributes({ blockData })`.
|
|
10
|
+
*
|
|
11
|
+
* No React, no DOM, no editor — exported for unit tests.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Per-fieldType coerce of a nested values map (built by
|
|
15
|
+
* `parseFormDataToNested`) against the block's schema. Mirrors the
|
|
16
|
+
* server-side `coerceFormValues` at a small subset suitable for the
|
|
17
|
+
* inline block editor — top-level block fields plus the immediate
|
|
18
|
+
* children of any Repeater rows / Builder rows.data.
|
|
19
|
+
*
|
|
20
|
+
* Non-coerce passthrough for: text, textarea, select, radio, date,
|
|
21
|
+
* dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
|
|
22
|
+
* is already a plain string / array of strings.)
|
|
23
|
+
*
|
|
24
|
+
* Coerce branches:
|
|
25
|
+
* - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
|
|
26
|
+
* - `number` / `slider`: parse to Number, null on empty, raw string
|
|
27
|
+
* passthrough on NaN (so a half-typed value isn't lost).
|
|
28
|
+
* - `tagsInput`: JSON-encoded string → string[].
|
|
29
|
+
* - `checkboxList`: JSON-encoded string OR array → string[].
|
|
30
|
+
* - `keyValue`: JSON-encoded string → Record<string, unknown>.
|
|
31
|
+
* - `fileUpload`: single → URL string passthrough; multiple →
|
|
32
|
+
* JSON-encoded string → string[].
|
|
33
|
+
* - `repeater`: each row in the array gets recursive coerce against
|
|
34
|
+
* the field's `template` (the inner field schema definition).
|
|
35
|
+
* - `builder`: each row's `data` gets recursive coerce against the
|
|
36
|
+
* block matching `row.type` from `field.blocks[]`. Unknown block
|
|
37
|
+
* types pass through verbatim — the renderer shows a placeholder
|
|
38
|
+
* and the data round-trips intact across config rollbacks.
|
|
39
|
+
*/
|
|
40
|
+
export function coerceBlockValues(raw, schema) {
|
|
41
|
+
const out = { ...raw };
|
|
42
|
+
for (const field of schema) {
|
|
43
|
+
const name = String(field['name'] ?? '');
|
|
44
|
+
if (!name)
|
|
45
|
+
continue;
|
|
46
|
+
const ft = String(field['fieldType'] ?? 'text');
|
|
47
|
+
const value = out[name];
|
|
48
|
+
out[name] = coerceField(value, ft, field);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function coerceField(value, ft, field) {
|
|
53
|
+
switch (ft) {
|
|
54
|
+
case 'toggle':
|
|
55
|
+
case 'checkbox':
|
|
56
|
+
return value === 'true' || value === true;
|
|
57
|
+
case 'number':
|
|
58
|
+
case 'slider':
|
|
59
|
+
return coerceNumber(value);
|
|
60
|
+
case 'tagsInput':
|
|
61
|
+
return parseJsonArray(value);
|
|
62
|
+
case 'checkboxList':
|
|
63
|
+
return parseJsonArray(value);
|
|
64
|
+
case 'keyValue':
|
|
65
|
+
return parseJsonObject(value);
|
|
66
|
+
case 'fileUpload': {
|
|
67
|
+
const multiple = Boolean(field['multiple']);
|
|
68
|
+
if (multiple)
|
|
69
|
+
return parseJsonArray(value);
|
|
70
|
+
return typeof value === 'string' ? value : '';
|
|
71
|
+
}
|
|
72
|
+
case 'repeater': {
|
|
73
|
+
if (!Array.isArray(value))
|
|
74
|
+
return [];
|
|
75
|
+
const template = field['template'] ?? [];
|
|
76
|
+
return value.map((row) => {
|
|
77
|
+
if (!row || typeof row !== 'object')
|
|
78
|
+
return {};
|
|
79
|
+
return coerceBlockValues(row, template);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
case 'builder': {
|
|
83
|
+
if (!Array.isArray(value))
|
|
84
|
+
return [];
|
|
85
|
+
const blockMetas = field['blocks'] ?? [];
|
|
86
|
+
return value.map((row) => {
|
|
87
|
+
if (!row || typeof row !== 'object')
|
|
88
|
+
return { type: '', data: {} };
|
|
89
|
+
const r = row;
|
|
90
|
+
const type = String(r['type'] ?? '');
|
|
91
|
+
const data = r['data'] ?? {};
|
|
92
|
+
const block = blockMetas.find((b) => String(b['name'] ?? '') === type);
|
|
93
|
+
if (!block)
|
|
94
|
+
return { type, data };
|
|
95
|
+
const tpl = block['template'] ?? [];
|
|
96
|
+
return { type, data: coerceBlockValues(data, tpl) };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
return value === undefined ? '' : value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function coerceNumber(value) {
|
|
104
|
+
if (value === '' || value === null || value === undefined)
|
|
105
|
+
return null;
|
|
106
|
+
if (typeof value === 'number')
|
|
107
|
+
return value;
|
|
108
|
+
const raw = String(value);
|
|
109
|
+
if (raw === '')
|
|
110
|
+
return null;
|
|
111
|
+
const n = Number(raw);
|
|
112
|
+
return Number.isNaN(n) ? raw : n;
|
|
113
|
+
}
|
|
114
|
+
function parseJsonArray(value) {
|
|
115
|
+
if (Array.isArray(value))
|
|
116
|
+
return value;
|
|
117
|
+
if (typeof value !== 'string' || value === '')
|
|
118
|
+
return [];
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(value);
|
|
121
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function parseJsonObject(value) {
|
|
128
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
if (typeof value !== 'string' || value === '')
|
|
132
|
+
return {};
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(value);
|
|
135
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch { /* fall through */ }
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Read the resolved field value for a given input event target. Maps a
|
|
144
|
+
* single-input change onto its coerced wire shape — string passthrough
|
|
145
|
+
* for the common case; explicit coercion for booleans and numerics so
|
|
146
|
+
* the round-trip into the node attrs preserves shape. Exported for tests.
|
|
147
|
+
*/
|
|
148
|
+
export function readBlockFieldValue(target, fieldMeta) {
|
|
149
|
+
const ft = String(fieldMeta.fieldType ?? 'text');
|
|
150
|
+
if (ft === 'toggle' || ft === 'checkbox') {
|
|
151
|
+
return target.checked === true;
|
|
152
|
+
}
|
|
153
|
+
if (ft === 'number' || ft === 'slider') {
|
|
154
|
+
const raw = target.value;
|
|
155
|
+
if (raw === '')
|
|
156
|
+
return null;
|
|
157
|
+
const n = Number(raw);
|
|
158
|
+
return Number.isNaN(n) ? raw : n;
|
|
159
|
+
}
|
|
160
|
+
return target.value;
|
|
161
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EditorState } from '@tiptap/pm/state';
|
|
2
2
|
/**
|
|
3
3
|
* Inline marks the `FloatingToolbar` can toggle — bold / italic / strike /
|
|
4
4
|
* code / link. A caret (empty selection) sitting inside any of these surfaces
|
|
@@ -10,7 +10,14 @@ export declare const TOOLBAR_MARKS: readonly ["bold", "italic", "strike", "code"
|
|
|
10
10
|
* Whether the selection-based `FloatingToolbar` should be visible. A PURE
|
|
11
11
|
* decision (no DOM / coords) so it's unit-testable against the real schema:
|
|
12
12
|
*
|
|
13
|
-
* - never
|
|
13
|
+
* - never on a whole-node selection of a non-text block — a schema-form
|
|
14
|
+
* custom block card (`pilotiqBlock`), an image, an hr, or a whole Alert
|
|
15
|
+
* block picked via the drag handle has no inline text to format, so it
|
|
16
|
+
* must not surface the mark toolbar (#155);
|
|
17
|
+
* - the toolbar DOES show for text inside the Alert's editable title/body —
|
|
18
|
+
* those are real editable text nodes, so the formatting actions should
|
|
19
|
+
* work there like anywhere else (an earlier over-correction suppressed the
|
|
20
|
+
* whole Alert, including its editable text);
|
|
14
21
|
* - a caret (empty selection) shows ONLY when it sits inside a formatting
|
|
15
22
|
* mark (link / bold / …) so the mark can be edited without selecting (#156);
|
|
16
23
|
* - a non-empty range shows whenever it actually spans inline content (the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NodeSelection } from '@tiptap/pm/state';
|
|
2
2
|
/**
|
|
3
3
|
* Inline marks the `FloatingToolbar` can toggle — bold / italic / strike /
|
|
4
4
|
* code / link. A caret (empty selection) sitting inside any of these surfaces
|
|
@@ -23,15 +23,24 @@ function caretInToolbarMark(state) {
|
|
|
23
23
|
* Whether the selection-based `FloatingToolbar` should be visible. A PURE
|
|
24
24
|
* decision (no DOM / coords) so it's unit-testable against the real schema:
|
|
25
25
|
*
|
|
26
|
-
* - never
|
|
26
|
+
* - never on a whole-node selection of a non-text block — a schema-form
|
|
27
|
+
* custom block card (`pilotiqBlock`), an image, an hr, or a whole Alert
|
|
28
|
+
* block picked via the drag handle has no inline text to format, so it
|
|
29
|
+
* must not surface the mark toolbar (#155);
|
|
30
|
+
* - the toolbar DOES show for text inside the Alert's editable title/body —
|
|
31
|
+
* those are real editable text nodes, so the formatting actions should
|
|
32
|
+
* work there like anywhere else (an earlier over-correction suppressed the
|
|
33
|
+
* whole Alert, including its editable text);
|
|
27
34
|
* - a caret (empty selection) shows ONLY when it sits inside a formatting
|
|
28
35
|
* mark (link / bold / …) so the mark can be edited without selecting (#156);
|
|
29
36
|
* - a non-empty range shows whenever it actually spans inline content (the
|
|
30
37
|
* `childCount === 0` guard skips degenerate full-block selections).
|
|
31
38
|
*/
|
|
32
39
|
export function shouldShowFloatingToolbar(state) {
|
|
33
|
-
|
|
40
|
+
const sel = state.selection;
|
|
41
|
+
if (sel instanceof NodeSelection && sel.node.isBlock && !sel.node.isTextblock) {
|
|
34
42
|
return false;
|
|
43
|
+
}
|
|
35
44
|
if (state.selection.empty)
|
|
36
45
|
return caretInToolbarMark(state);
|
|
37
46
|
const { from, to } = state.selection;
|
package/package.json
CHANGED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import type { Editor } from '@tiptap/react';
|
|
2
|
-
import type { BlockMeta } from '../Block.js';
|
|
3
|
-
/**
|
|
4
|
-
* Floating right-docked side panel for editing a custom block's schema
|
|
5
|
-
* fields. Mounted by `TiptapEditor` once the user clicks the Edit button
|
|
6
|
-
* on a `pilotiqBlock` NodeView; reads/writes flow through ProseMirror
|
|
7
|
-
* directly (no form submit, no roundtrip).
|
|
8
|
-
*
|
|
9
|
-
* Why a sibling of the NodeView and not the NodeView itself:
|
|
10
|
-
* - NodeViews mount in a separate React tree (Tiptap quirk), so they
|
|
11
|
-
* can't reach pilotiq's `FormFields` renderer or any provider on
|
|
12
|
-
* the host page (Theme, Toaster, etc.). Hosting the panel here in
|
|
13
|
-
* the host's tree gives us the full pilotiq field surface for free.
|
|
14
|
-
*
|
|
15
|
-
* Reads: each field's `defaultValue` is overridden from the block's
|
|
16
|
-
* stored `blockData`. Inputs are uncontrolled (outside `FormStateProvider`,
|
|
17
|
-
* pilotiq's renderers fall back to `defaultValue` automatically).
|
|
18
|
-
*
|
|
19
|
-
* Writes: container-level event delegation on the form element. Every
|
|
20
|
-
* change snapshots the entire form via `new FormData(formEl)` →
|
|
21
|
-
* `parseFormDataToNested` (rebuilds nested arrays/objects from
|
|
22
|
-
* dotted-path inputs like `myrep.0.title`) → `coerceBlockValues`
|
|
23
|
-
* (per-fieldType JSON parse / boolean / number coerce so nested-shape
|
|
24
|
-
* fields round-trip in their canonical wire form). The result is
|
|
25
|
-
* dispatched through `state.tr.setNodeMarkup` on the tracked position.
|
|
26
|
-
* The position is kept fresh by mapping it through every editor
|
|
27
|
-
* transaction so live edits elsewhere in the document don't desync.
|
|
28
|
-
*
|
|
29
|
-
* V2 (2026-05-04 cont'd): nested-shape fields now round-trip cleanly:
|
|
30
|
-
* Repeater (array of subschema rows), Builder (heterogeneous block
|
|
31
|
-
* rows), TagsInput / KeyValue / FileUpload (JSON-encoded hidden
|
|
32
|
-
* inputs), Markdown (plain textarea), and the standard primitives
|
|
33
|
-
* (text / textarea / select / toggle / checkbox / radio / date /
|
|
34
|
-
* datetime / number / slider / color / toggleButtons / checkboxList).
|
|
35
|
-
*/
|
|
36
|
-
export interface BlockSidePanelProps {
|
|
37
|
-
editor: Editor;
|
|
38
|
-
/** Position at open time. Tracked + remapped on every transaction. */
|
|
39
|
-
initialPos: number;
|
|
40
|
-
/** Block type at open time — guards against the user clicking Edit on
|
|
41
|
-
* one block, then someone else's edit replacing it with a different
|
|
42
|
-
* block at the same position. */
|
|
43
|
-
blockType: string;
|
|
44
|
-
blocks: BlockMeta[];
|
|
45
|
-
onClose: () => void;
|
|
46
|
-
}
|
|
47
|
-
export declare function BlockSidePanel({ editor, initialPos, blockType, blocks, onClose, }: BlockSidePanelProps): React.ReactElement | null;
|
|
48
|
-
/**
|
|
49
|
-
* Clamp + sanitize a candidate panel width against this panel's bounds
|
|
50
|
-
* (`[PANEL_WIDTH_MIN, PANEL_WIDTH_MAX]`, default `PANEL_WIDTH_DEFAULT`).
|
|
51
|
-
* Thin wrapper around the shared `clampPanelWidth` helper from
|
|
52
|
-
* `@pilotiq/pilotiq/react` — kept exported with the panel-specific
|
|
53
|
-
* defaults baked in so existing tests + downstream callers don't have
|
|
54
|
-
* to plumb the bounds themselves.
|
|
55
|
-
*/
|
|
56
|
-
export declare function clampPanelWidth(value: unknown): number;
|
|
57
|
-
/**
|
|
58
|
-
* Per-fieldType coerce of a nested values map (built by
|
|
59
|
-
* `parseFormDataToNested`) against the block's schema. Mirrors the
|
|
60
|
-
* server-side `coerceFormValues` at a small subset suitable for the
|
|
61
|
-
* side panel — we only run on top-level block fields plus the immediate
|
|
62
|
-
* children of any Repeater rows / Builder rows.data, which is all the
|
|
63
|
-
* V2 surface needs.
|
|
64
|
-
*
|
|
65
|
-
* Non-coerce passthrough for: text, textarea, select, radio, date,
|
|
66
|
-
* dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
|
|
67
|
-
* is already a plain string / array of strings.)
|
|
68
|
-
*
|
|
69
|
-
* Coerce branches:
|
|
70
|
-
* - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
|
|
71
|
-
* - `number` / `slider`: parse to Number, null on empty, raw string
|
|
72
|
-
* passthrough on NaN (so a half-typed value isn't lost).
|
|
73
|
-
* - `tagsInput`: JSON-encoded string → string[].
|
|
74
|
-
* - `checkboxList`: JSON-encoded string OR array → string[].
|
|
75
|
-
* - `keyValue`: JSON-encoded string → Record<string, unknown>.
|
|
76
|
-
* - `fileUpload`: single → URL string passthrough; multiple →
|
|
77
|
-
* JSON-encoded string → string[].
|
|
78
|
-
* - `repeater`: each row in the array gets recursive coerce against
|
|
79
|
-
* the field's `template` (the inner field schema definition).
|
|
80
|
-
* - `builder`: each row's `data` gets recursive coerce against the
|
|
81
|
-
* block matching `row.type` from `field.blocks[]`. Unknown block
|
|
82
|
-
* types pass through verbatim — the renderer shows a placeholder
|
|
83
|
-
* and the data round-trips intact across config rollbacks.
|
|
84
|
-
*
|
|
85
|
-
* Exported for unit tests. Pure — no React, no DOM, no editor.
|
|
86
|
-
*/
|
|
87
|
-
export declare function coerceBlockValues(raw: Record<string, unknown>, schema: ReadonlyArray<Record<string, unknown>>): Record<string, unknown>;
|
|
88
|
-
/**
|
|
89
|
-
* Read the resolved field value for a given input event target. Kept
|
|
90
|
-
* for back-compat — V1 used this in the per-event handler. V2 reads
|
|
91
|
-
* the entire form via `FormData` instead, but this helper still maps
|
|
92
|
-
* cleanly onto a single-input read for testing and is exported.
|
|
93
|
-
*
|
|
94
|
-
* String passthrough for the common case; explicit coercion for
|
|
95
|
-
* booleans and numerics so the round-trip into the node attrs preserves
|
|
96
|
-
* shape.
|
|
97
|
-
*/
|
|
98
|
-
export declare function readBlockFieldValue(target: {
|
|
99
|
-
type?: string;
|
|
100
|
-
value: string;
|
|
101
|
-
checked?: boolean;
|
|
102
|
-
}, fieldMeta: {
|
|
103
|
-
fieldType?: unknown;
|
|
104
|
-
}): unknown;
|
|
105
|
-
//# sourceMappingURL=BlockSidePanel.d.ts.map
|
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
-
import { FormFields, parseFormDataToNested, clampPanelWidth as clampPanelWidthShared, useResizableWidth, } from '@pilotiq/pilotiq/react';
|
|
4
|
-
const PANEL_WIDTH_STORAGE_KEY = 'pilotiq.tiptap.sidePanel.width';
|
|
5
|
-
const PANEL_WIDTH_DEFAULT = 320;
|
|
6
|
-
const PANEL_WIDTH_MIN = 240;
|
|
7
|
-
const PANEL_WIDTH_MAX = 600;
|
|
8
|
-
// Keys that point at any tabbable / interactive element inside the panel.
|
|
9
|
-
// Same intent as Filament's focus-trap helper but kept inline — small one-off.
|
|
10
|
-
const FOCUSABLE_SELECTOR = [
|
|
11
|
-
'a[href]',
|
|
12
|
-
'button:not([disabled])',
|
|
13
|
-
'input:not([disabled]):not([type="hidden"])',
|
|
14
|
-
'select:not([disabled])',
|
|
15
|
-
'textarea:not([disabled])',
|
|
16
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
17
|
-
].join(',');
|
|
18
|
-
export function BlockSidePanel({ editor, initialPos, blockType, blocks, onClose, }) {
|
|
19
|
-
const meta = blocks.find((b) => b.name === blockType);
|
|
20
|
-
// Live-tracked position of the block we're editing. Starts at the
|
|
21
|
-
// open-time position; every editor transaction maps it forward so the
|
|
22
|
-
// panel keeps writing to the same node even as the user types text
|
|
23
|
-
// elsewhere in the document.
|
|
24
|
-
const [pos, setPos] = useState(initialPos);
|
|
25
|
-
const posRef = useRef(initialPos);
|
|
26
|
-
// Prefilled values seed the form's `defaultValue`s. We re-read once
|
|
27
|
-
// when the panel opens (and on hard re-mount via key prop); ongoing
|
|
28
|
-
// edits don't snapshot the doc — the form's uncontrolled inputs hold
|
|
29
|
-
// their own state until the user closes the panel.
|
|
30
|
-
const initialValuesRef = useRef(pos !== null ? readBlockData(editor, pos) : {});
|
|
31
|
-
const asideRef = useRef(null);
|
|
32
|
-
const formRef = useRef(null);
|
|
33
|
-
// Width memory — survives panel close/reopen and full reload via
|
|
34
|
-
// localStorage. The shared `useResizableWidth` hook from
|
|
35
|
-
// `@pilotiq/pilotiq/react` handles the localStorage round-trip + drag
|
|
36
|
-
// pipeline; we just bind it to this panel's per-key bounds.
|
|
37
|
-
const { width, onResizeStart } = useResizableWidth({
|
|
38
|
-
storageKey: PANEL_WIDTH_STORAGE_KEY,
|
|
39
|
-
min: PANEL_WIDTH_MIN,
|
|
40
|
-
max: PANEL_WIDTH_MAX,
|
|
41
|
-
defaultWidth: PANEL_WIDTH_DEFAULT,
|
|
42
|
-
edge: 'left',
|
|
43
|
-
});
|
|
44
|
-
// Save focus on mount, focus the first focusable inside the panel,
|
|
45
|
-
// restore previous focus on unmount. Mount-only effect — re-mounting
|
|
46
|
-
// the panel for a different block (key={pos:blockType}) re-runs this.
|
|
47
|
-
useEffect(() => {
|
|
48
|
-
const previouslyFocused = (typeof document !== 'undefined'
|
|
49
|
-
? document.activeElement
|
|
50
|
-
: null);
|
|
51
|
-
const aside = asideRef.current;
|
|
52
|
-
if (aside) {
|
|
53
|
-
const first = aside.querySelector(FOCUSABLE_SELECTOR);
|
|
54
|
-
first?.focus();
|
|
55
|
-
}
|
|
56
|
-
return () => {
|
|
57
|
-
// Try/catch — the previously focused element may have been removed
|
|
58
|
-
// (e.g. an editor selection refresh nuked the surrounding NodeView).
|
|
59
|
-
try {
|
|
60
|
-
previouslyFocused?.focus?.();
|
|
61
|
-
}
|
|
62
|
-
catch { /* noop */ }
|
|
63
|
-
};
|
|
64
|
-
}, []);
|
|
65
|
-
// ESC closes; Tab / Shift+Tab cycles within the panel. Bubble-phase
|
|
66
|
-
// listener — slash and mention menus' capture-phase ESC handlers fire
|
|
67
|
-
// first and stopPropagation, so ESC inside an open slash menu only
|
|
68
|
-
// closes the menu. ESC anywhere else (panel inputs, editor) closes
|
|
69
|
-
// the panel.
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
const onKey = (e) => {
|
|
72
|
-
if (e.key === 'Escape') {
|
|
73
|
-
onClose();
|
|
74
|
-
e.preventDefault();
|
|
75
|
-
e.stopPropagation();
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
if (e.key !== 'Tab')
|
|
79
|
-
return;
|
|
80
|
-
const aside = asideRef.current;
|
|
81
|
-
if (!aside)
|
|
82
|
-
return;
|
|
83
|
-
const active = document.activeElement;
|
|
84
|
-
if (!active || !aside.contains(active))
|
|
85
|
-
return;
|
|
86
|
-
const focusables = Array.from(aside.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => !el.hasAttribute('disabled'));
|
|
87
|
-
if (focusables.length === 0)
|
|
88
|
-
return;
|
|
89
|
-
const first = focusables[0];
|
|
90
|
-
const last = focusables[focusables.length - 1];
|
|
91
|
-
if (e.shiftKey && active === first) {
|
|
92
|
-
e.preventDefault();
|
|
93
|
-
last.focus();
|
|
94
|
-
}
|
|
95
|
-
else if (!e.shiftKey && active === last) {
|
|
96
|
-
e.preventDefault();
|
|
97
|
-
first.focus();
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
document.addEventListener('keydown', onKey);
|
|
101
|
-
return () => document.removeEventListener('keydown', onKey);
|
|
102
|
-
}, [onClose]);
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
if (pos === null)
|
|
105
|
-
return;
|
|
106
|
-
const handler = ({ transaction }) => {
|
|
107
|
-
const current = posRef.current;
|
|
108
|
-
if (current === null)
|
|
109
|
-
return;
|
|
110
|
-
const mapped = transaction.mapping.map(current);
|
|
111
|
-
// The block was deleted — close the panel.
|
|
112
|
-
const nodeNow = nodeAt(editor, mapped);
|
|
113
|
-
if (!nodeNow || nodeNow.type.name !== 'pilotiqBlock' || String(nodeNow.attrs['blockType'] ?? '') !== blockType) {
|
|
114
|
-
posRef.current = null;
|
|
115
|
-
setPos(null);
|
|
116
|
-
onClose();
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
posRef.current = mapped;
|
|
120
|
-
setPos(mapped);
|
|
121
|
-
};
|
|
122
|
-
editor.on('transaction', handler);
|
|
123
|
-
return () => { editor.off('transaction', handler); };
|
|
124
|
-
}, [editor, blockType, pos, onClose]);
|
|
125
|
-
const writeBack = useCallback((nextValues) => {
|
|
126
|
-
const at = posRef.current;
|
|
127
|
-
if (at === null)
|
|
128
|
-
return;
|
|
129
|
-
// ProseMirror's `setNodeMarkup` lives on the transaction, not the
|
|
130
|
-
// ChainedCommands surface — go through `tr` directly. Pass `null`
|
|
131
|
-
// for the node-type arg to keep the existing type, just swap attrs.
|
|
132
|
-
const view = editor.view;
|
|
133
|
-
const state = editor.state;
|
|
134
|
-
const tr = state.tr.setNodeMarkup(at, null, { blockType, blockData: nextValues });
|
|
135
|
-
view.dispatch(tr);
|
|
136
|
-
}, [editor, blockType]);
|
|
137
|
-
const handleChange = useCallback(() => {
|
|
138
|
-
const formEl = formRef.current;
|
|
139
|
-
if (!formEl || !meta)
|
|
140
|
-
return;
|
|
141
|
-
// Snapshot the full form: nested arrays / objects materialize from
|
|
142
|
-
// dotted-path names (`items.0.title`), JSON-encoded hidden inputs
|
|
143
|
-
// (TagsInput / KeyValue / FileUpload-multi) sit as JSON strings,
|
|
144
|
-
// toggle / checkbox hidden inputs sit as `'true' | 'false'`. The
|
|
145
|
-
// coerce pass below normalizes those to canonical shapes.
|
|
146
|
-
const raw = parseFormDataToNested(new FormData(formEl));
|
|
147
|
-
const coerced = coerceBlockValues(raw, meta.schema);
|
|
148
|
-
writeBack(coerced);
|
|
149
|
-
}, [meta, writeBack]);
|
|
150
|
-
if (!meta || pos === null)
|
|
151
|
-
return null;
|
|
152
|
-
return (_jsxs("aside", { ref: asideRef, role: "dialog", "aria-label": `Edit ${meta.label}`, style: { width }, 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", children: [_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize panel", onPointerDown: onResizeStart, className: "absolute left-0 top-0 h-full w-1 cursor-ew-resize hover:bg-border/80" }), _jsxs("header", { className: "sticky top-0 z-10 flex items-center justify-between gap-2 border-b bg-background px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [meta.icon && _jsx("span", { "aria-hidden": "true", children: meta.icon }), _jsx("span", { className: "text-sm font-medium truncate", children: meta.label })] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close panel", className: "text-muted-foreground hover:text-foreground text-sm", children: "\u00D7" })] }), _jsx("form", { ref: formRef, onInput: handleChange, onChange: handleChange, onSubmit: (e) => { e.preventDefault(); }, className: "flex flex-col gap-3 px-3 py-3", children: _jsx(FormFields, { elements: meta.schema, values: initialValuesRef.current }) })] }));
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Clamp + sanitize a candidate panel width against this panel's bounds
|
|
156
|
-
* (`[PANEL_WIDTH_MIN, PANEL_WIDTH_MAX]`, default `PANEL_WIDTH_DEFAULT`).
|
|
157
|
-
* Thin wrapper around the shared `clampPanelWidth` helper from
|
|
158
|
-
* `@pilotiq/pilotiq/react` — kept exported with the panel-specific
|
|
159
|
-
* defaults baked in so existing tests + downstream callers don't have
|
|
160
|
-
* to plumb the bounds themselves.
|
|
161
|
-
*/
|
|
162
|
-
export function clampPanelWidth(value) {
|
|
163
|
-
return clampPanelWidthShared(value, {
|
|
164
|
-
min: PANEL_WIDTH_MIN,
|
|
165
|
-
max: PANEL_WIDTH_MAX,
|
|
166
|
-
defaultWidth: PANEL_WIDTH_DEFAULT,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Per-fieldType coerce of a nested values map (built by
|
|
171
|
-
* `parseFormDataToNested`) against the block's schema. Mirrors the
|
|
172
|
-
* server-side `coerceFormValues` at a small subset suitable for the
|
|
173
|
-
* side panel — we only run on top-level block fields plus the immediate
|
|
174
|
-
* children of any Repeater rows / Builder rows.data, which is all the
|
|
175
|
-
* V2 surface needs.
|
|
176
|
-
*
|
|
177
|
-
* Non-coerce passthrough for: text, textarea, select, radio, date,
|
|
178
|
-
* dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
|
|
179
|
-
* is already a plain string / array of strings.)
|
|
180
|
-
*
|
|
181
|
-
* Coerce branches:
|
|
182
|
-
* - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
|
|
183
|
-
* - `number` / `slider`: parse to Number, null on empty, raw string
|
|
184
|
-
* passthrough on NaN (so a half-typed value isn't lost).
|
|
185
|
-
* - `tagsInput`: JSON-encoded string → string[].
|
|
186
|
-
* - `checkboxList`: JSON-encoded string OR array → string[].
|
|
187
|
-
* - `keyValue`: JSON-encoded string → Record<string, unknown>.
|
|
188
|
-
* - `fileUpload`: single → URL string passthrough; multiple →
|
|
189
|
-
* JSON-encoded string → string[].
|
|
190
|
-
* - `repeater`: each row in the array gets recursive coerce against
|
|
191
|
-
* the field's `template` (the inner field schema definition).
|
|
192
|
-
* - `builder`: each row's `data` gets recursive coerce against the
|
|
193
|
-
* block matching `row.type` from `field.blocks[]`. Unknown block
|
|
194
|
-
* types pass through verbatim — the renderer shows a placeholder
|
|
195
|
-
* and the data round-trips intact across config rollbacks.
|
|
196
|
-
*
|
|
197
|
-
* Exported for unit tests. Pure — no React, no DOM, no editor.
|
|
198
|
-
*/
|
|
199
|
-
export function coerceBlockValues(raw, schema) {
|
|
200
|
-
const out = { ...raw };
|
|
201
|
-
for (const field of schema) {
|
|
202
|
-
const name = String(field['name'] ?? '');
|
|
203
|
-
if (!name)
|
|
204
|
-
continue;
|
|
205
|
-
const ft = String(field['fieldType'] ?? 'text');
|
|
206
|
-
const value = out[name];
|
|
207
|
-
out[name] = coerceField(value, ft, field);
|
|
208
|
-
}
|
|
209
|
-
return out;
|
|
210
|
-
}
|
|
211
|
-
function coerceField(value, ft, field) {
|
|
212
|
-
switch (ft) {
|
|
213
|
-
case 'toggle':
|
|
214
|
-
case 'checkbox':
|
|
215
|
-
return value === 'true' || value === true;
|
|
216
|
-
case 'number':
|
|
217
|
-
case 'slider':
|
|
218
|
-
return coerceNumber(value);
|
|
219
|
-
case 'tagsInput':
|
|
220
|
-
return parseJsonArray(value);
|
|
221
|
-
case 'checkboxList':
|
|
222
|
-
return parseJsonArray(value);
|
|
223
|
-
case 'keyValue':
|
|
224
|
-
return parseJsonObject(value);
|
|
225
|
-
case 'fileUpload': {
|
|
226
|
-
const multiple = Boolean(field['multiple']);
|
|
227
|
-
if (multiple)
|
|
228
|
-
return parseJsonArray(value);
|
|
229
|
-
return typeof value === 'string' ? value : '';
|
|
230
|
-
}
|
|
231
|
-
case 'repeater': {
|
|
232
|
-
if (!Array.isArray(value))
|
|
233
|
-
return [];
|
|
234
|
-
const template = field['template'] ?? [];
|
|
235
|
-
return value.map((row) => {
|
|
236
|
-
if (!row || typeof row !== 'object')
|
|
237
|
-
return {};
|
|
238
|
-
return coerceBlockValues(row, template);
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
case 'builder': {
|
|
242
|
-
if (!Array.isArray(value))
|
|
243
|
-
return [];
|
|
244
|
-
const blockMetas = field['blocks'] ?? [];
|
|
245
|
-
return value.map((row) => {
|
|
246
|
-
if (!row || typeof row !== 'object')
|
|
247
|
-
return { type: '', data: {} };
|
|
248
|
-
const r = row;
|
|
249
|
-
const type = String(r['type'] ?? '');
|
|
250
|
-
const data = r['data'] ?? {};
|
|
251
|
-
const block = blockMetas.find((b) => String(b['name'] ?? '') === type);
|
|
252
|
-
if (!block)
|
|
253
|
-
return { type, data };
|
|
254
|
-
const tpl = block['template'] ?? [];
|
|
255
|
-
return { type, data: coerceBlockValues(data, tpl) };
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
default:
|
|
259
|
-
return value === undefined ? '' : value;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
function coerceNumber(value) {
|
|
263
|
-
if (value === '' || value === null || value === undefined)
|
|
264
|
-
return null;
|
|
265
|
-
if (typeof value === 'number')
|
|
266
|
-
return value;
|
|
267
|
-
const raw = String(value);
|
|
268
|
-
if (raw === '')
|
|
269
|
-
return null;
|
|
270
|
-
const n = Number(raw);
|
|
271
|
-
return Number.isNaN(n) ? raw : n;
|
|
272
|
-
}
|
|
273
|
-
function parseJsonArray(value) {
|
|
274
|
-
if (Array.isArray(value))
|
|
275
|
-
return value;
|
|
276
|
-
if (typeof value !== 'string' || value === '')
|
|
277
|
-
return [];
|
|
278
|
-
try {
|
|
279
|
-
const parsed = JSON.parse(value);
|
|
280
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
return [];
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
function parseJsonObject(value) {
|
|
287
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
288
|
-
return value;
|
|
289
|
-
}
|
|
290
|
-
if (typeof value !== 'string' || value === '')
|
|
291
|
-
return {};
|
|
292
|
-
try {
|
|
293
|
-
const parsed = JSON.parse(value);
|
|
294
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
295
|
-
return parsed;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
catch { /* fall through */ }
|
|
299
|
-
return {};
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Read the resolved field value for a given input event target. Kept
|
|
303
|
-
* for back-compat — V1 used this in the per-event handler. V2 reads
|
|
304
|
-
* the entire form via `FormData` instead, but this helper still maps
|
|
305
|
-
* cleanly onto a single-input read for testing and is exported.
|
|
306
|
-
*
|
|
307
|
-
* String passthrough for the common case; explicit coercion for
|
|
308
|
-
* booleans and numerics so the round-trip into the node attrs preserves
|
|
309
|
-
* shape.
|
|
310
|
-
*/
|
|
311
|
-
export function readBlockFieldValue(target, fieldMeta) {
|
|
312
|
-
const ft = String(fieldMeta.fieldType ?? 'text');
|
|
313
|
-
if (ft === 'toggle' || ft === 'checkbox') {
|
|
314
|
-
return target.checked === true;
|
|
315
|
-
}
|
|
316
|
-
if (ft === 'number' || ft === 'slider') {
|
|
317
|
-
const raw = target.value;
|
|
318
|
-
if (raw === '')
|
|
319
|
-
return null;
|
|
320
|
-
const n = Number(raw);
|
|
321
|
-
return Number.isNaN(n) ? raw : n;
|
|
322
|
-
}
|
|
323
|
-
return target.value;
|
|
324
|
-
}
|
|
325
|
-
function readBlockData(editor, pos) {
|
|
326
|
-
const node = nodeAt(editor, pos);
|
|
327
|
-
if (!node)
|
|
328
|
-
return {};
|
|
329
|
-
return node.attrs['blockData'] ?? {};
|
|
330
|
-
}
|
|
331
|
-
function nodeAt(editor, pos) {
|
|
332
|
-
try {
|
|
333
|
-
return editor.state.doc.nodeAt(pos);
|
|
334
|
-
}
|
|
335
|
-
catch {
|
|
336
|
-
return null;
|
|
337
|
-
}
|
|
338
|
-
}
|