@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 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
- * Generic React NodeView for the `pilotiqBlock` ProseMirror node. Reads
4
- * the block type from `node.attrs.blockType`, looks up its `BlockMeta`
5
- * in `BlockNodeExtension.options.blocks`, and renders a compact inline
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 happens in a side panel hosted by `TiptapEditor`, NOT inline.
9
- * The NodeView fires `BlockNodeExtension.options.onEdit(getPos())` when
10
- * the Edit button is clicked; the host opens its panel anchored to the
11
- * editor wrapper. NodeViews live in a separate React tree from the host
12
- * editor, so the bridge has to go through extension options context
13
- * doesn't cross trees.
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
- * If no `onEdit` is wired (e.g. a consumer that uses `BlockNodeExtension`
16
- * standalone without `TiptapEditor`'s panel), the Edit button is hidden.
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
- * Generic React NodeView for the `pilotiqBlock` ProseMirror node. Reads
6
- * the block type from `node.attrs.blockType`, looks up its `BlockMeta`
7
- * in `BlockNodeExtension.options.blocks`, and renders a compact inline
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 happens in a side panel hosted by `TiptapEditor`, NOT inline.
11
- * The NodeView fires `BlockNodeExtension.options.onEdit(getPos())` when
12
- * the Edit button is clicked; the host opens its panel anchored to the
13
- * editor wrapper. NodeViews live in a separate React tree from the host
14
- * editor, so the bridge has to go through extension options context
15
- * doesn't cross trees.
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
- * If no `onEdit` is wired (e.g. a consumer that uses `BlockNodeExtension`
18
- * standalone without `TiptapEditor`'s panel), the Edit button is hidden.
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, getPos, deleteNode } = props;
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 handleEdit = () => {
51
- if (!onEdit)
69
+ const toggleExpanded = () => {
70
+ if (!editable)
52
71
  return;
53
- const pos = getPos();
54
- if (typeof pos !== 'number')
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
- onEdit(pos);
85
+ const raw = parseFormDataToNested(new FormData(formEl));
86
+ const coerced = coerceBlockValues(raw, meta.schema);
87
+ updateAttributes({ blockData: coerced });
57
88
  };
58
- return (_jsx(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: handleEdit, disabled: !onEdit, className: "flex 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: "text-xs text-muted-foreground line-clamp-1", children: summary })] }), _jsxs("div", { className: "flex items-center gap-2", children: [onEdit && (_jsx("button", { type: "button", onClick: handleEdit, className: "text-xs text-muted-foreground hover:text-foreground", children: "Edit" })), _jsx("button", { type: "button", onClick: () => deleteNode(), className: "text-xs text-destructive hover:underline", children: "Remove" })] })] }) }));
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-block side panel opens when a block's NodeView fires its
172
- // Edit button. The NodeView lives in a separate React tree and reaches
173
- // us via `BlockNodeExtension.options.onEdit` (set during configure()
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
- // `onEdit` is the bridge back to the host editor's tree where the
287
- // side panel lives; the NodeView's Edit button calls it with its
288
- // own `getPos()`.
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 }), editor && selectedBlock && (_jsx(BlockSidePanel, { editor: editor, initialPos: selectedBlock.pos, blockType: selectedBlock.blockType, blocks: blocks, onClose: closeBlockPanel }, `${selectedBlock.pos}:${selectedBlock.blockType}`))] }));
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 { EditorState } from '@tiptap/pm/state';
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 inside a callout/alert block it owns its own chrome (#155);
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 { isSelectionInAlert } from '../extensions/contentBlocks.js';
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 inside a callout/alert block it owns its own chrome (#155);
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
- if (isSelectionInAlert(state.selection))
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.19.2",
3
+ "version": "3.20.0",
4
4
  "description": "Tiptap rich-text editor adapter for @pilotiq/pilotiq — slash menu, draggable blocks, custom-block API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- }