@pilotiq/tiptap 3.19.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/extensions/BlockNodeExtension.d.ts +0 -9
  3. package/dist/extensions/BlockNodeExtension.js +0 -20
  4. package/dist/extensions/{AiInlineDiffExtension.d.ts → InlineDiffExtension.d.ts} +23 -23
  5. package/dist/extensions/{AiInlineDiffExtension.js → InlineDiffExtension.js} +33 -33
  6. package/dist/extensions/{AiSuggestionExtension.d.ts → SuggestionChipExtension.d.ts} +29 -29
  7. package/dist/extensions/{AiSuggestionExtension.js → SuggestionChipExtension.js} +52 -52
  8. package/dist/index.d.ts +4 -4
  9. package/dist/index.js +4 -4
  10. package/dist/react/BlockNodeView.d.ts +23 -12
  11. package/dist/react/BlockNodeView.js +55 -21
  12. package/dist/react/CollabTextRenderer.js +16 -16
  13. package/dist/react/MarkdownEditor.js +17 -17
  14. package/dist/react/{AiSuggestionBanner.d.ts → SuggestionBanner.d.ts} +6 -6
  15. package/dist/react/{AiSuggestionBanner.js → SuggestionBanner.js} +5 -5
  16. package/dist/react/TiptapEditor.js +24 -59
  17. package/dist/react/blockValues.d.ts +54 -0
  18. package/dist/react/blockValues.js +161 -0
  19. package/dist/react/floatingToolbarVisibility.d.ts +9 -2
  20. package/dist/react/floatingToolbarVisibility.js +12 -3
  21. package/dist/react/{useAiInlineDiff.d.ts → useInlineDiff.d.ts} +13 -13
  22. package/dist/react/{useAiInlineDiff.js → useInlineDiff.js} +36 -19
  23. package/dist/react/{useAiSuggestionBridge.d.ts → useSuggestionBridge.d.ts} +7 -7
  24. package/dist/react/{useAiSuggestionBridge.js → useSuggestionBridge.js} +10 -10
  25. package/dist/surgicalOps.d.ts +15 -2
  26. package/dist/surgicalOps.js +50 -3
  27. package/package.json +1 -1
  28. package/dist/react/BlockSidePanel.d.ts +0 -105
  29. package/dist/react/BlockSidePanel.js +0 -338
@@ -13,12 +13,12 @@ import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
13
13
  import { Markdown } from '../markdownExtension.js';
14
14
  import { useCollabRoom, getCollabExtensions, useToast, } from '@pilotiq/pilotiq/react';
15
15
  import { useCollabSeed } from '@rudderjs/sync/react';
16
- import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js';
17
- import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js';
16
+ import { SuggestionChipExtension } from '../extensions/SuggestionChipExtension.js';
17
+ import { InlineDiffExtension } from '../extensions/InlineDiffExtension.js';
18
18
  import { Alert, AlertTitle, AlertBody, ContentBlockKeymap } from '../extensions/contentBlocks.js';
19
- import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
20
- import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
21
- import { AiSuggestionBanner } from './AiSuggestionBanner.js';
19
+ import { useSuggestionBridge } from './useSuggestionBridge.js';
20
+ import { useInlineDiff, useIsInlineDiffActive, readDiffViewMarker } from './useInlineDiff.js';
21
+ import { SuggestionBanner } from './SuggestionBanner.js';
22
22
  import { getMarkdownString, parseMarkdownToHtml } from '../markdownStorage.js';
23
23
  // Inline lucide.dev SVGs — same posture as `toolbarButtons.tsx` so this
24
24
  // package doesn't pull `lucide-react` as a peer dep. Keep stroke / size
@@ -135,13 +135,13 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
135
135
  Image.configure({ inline: false, allowBase64: false }),
136
136
  Placeholder.configure({ placeholder: placeholder ?? 'Write in markdown…' }),
137
137
  // AI suggestions — chip widget for surgical (range-anchored) edits.
138
- AiSuggestionExtension,
138
+ SuggestionChipExtension,
139
139
  // AI inline diff — Tiptap-Pro-style visualization for whole-field
140
140
  // suggestions (prosemirror-changeset under the hood). Decorations
141
141
  // show green-background inserts inline + red-strikethrough widgets
142
- // for deleted text. Host's `<AiSuggestionBanner>` drives Accept /
142
+ // for deleted text. Host's `<SuggestionBanner>` drives Accept /
143
143
  // Reject via the extension's commands.
144
- AiInlineDiffExtension,
144
+ InlineDiffExtension,
145
145
  // Alert content block — round-trips to `:::alert{type=…} Title` via the
146
146
  // node's `markdown` storage spec; renders the same shadcn NodeView.
147
147
  Alert,
@@ -167,13 +167,13 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
167
167
  editor.setEditable(!disabled && tab === 'editor');
168
168
  }, [editor, disabled, tab]);
169
169
  // Cross-package suggestion bridge — sync the host's
170
- // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
170
+ // `<PendingSuggestionsContext>` queue with the editor's `InlineSuggestion`
171
171
  // extension. No-op when no provider is mounted (default no-op context).
172
172
  //
173
173
  // Whole-field handling: NO chip widget here. The chip's `textContent`
174
174
  // renderer surfaces raw markdown (`## Heading\n- item`) as literal text
175
175
  // inside the green pill — visually unparseable for multi-paragraph
176
- // rewrites. Instead, `<AiSuggestionBanner>` mounts below the editor
176
+ // rewrites. Instead, `<SuggestionBanner>` mounts below the editor
177
177
  // (see render below). Producer-supplied range suggestions still ride
178
178
  // the inline chip path — those have a precise anchor worth showing
179
179
  // in context.
@@ -182,7 +182,7 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
182
182
  return;
183
183
  editor.commands.setContent(value);
184
184
  };
185
- useAiSuggestionBridge(editor ?? null, name, {
185
+ useSuggestionBridge(editor ?? null, name, {
186
186
  onApplyWholeField: applyWholeField,
187
187
  });
188
188
  // Inline diff for whole-field suggestions — replaces the editor doc with
@@ -194,7 +194,7 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
194
194
  // that HTML into a Slice against THIS editor's schema — same path
195
195
  // the editor's own clipboard-paste uses, so the slice is guaranteed
196
196
  // schema-valid.
197
- useAiInlineDiff(editor ?? null, name, {
197
+ useInlineDiff(editor ?? null, name, {
198
198
  parseSuggestion: (ed, value) => {
199
199
  try {
200
200
  const html = parseMarkdownToHtml(ed, value);
@@ -208,9 +208,9 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
208
208
  return null;
209
209
  }
210
210
  },
211
- resolveDisplayMode: () => readAiDiffViewMarker(name),
211
+ resolveDisplayMode: () => readDiffViewMarker(name),
212
212
  });
213
- const isDiffActive = useIsAiInlineDiffActive(editor ?? null);
213
+ const isDiffActive = useIsInlineDiffActive(editor ?? null);
214
214
  // First-load seed for collab. Collaboration starts the editor empty
215
215
  // regardless of `content`; once the room's first sync resolves,
216
216
  // `useCollabSeed` runs the callback inside `ydoc.transact`. Empty
@@ -439,10 +439,10 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
439
439
  : 'hover:bg-accent hover:text-accent-foreground',
440
440
  'disabled:opacity-50',
441
441
  ].join(' '), onClick: () => exec(b), disabled: disabled || (isAttach && uploading), title: labels[b] ?? b, "aria-label": labels[b] ?? b, "aria-pressed": active, children: isAttach && uploading ? Spinner : icon }, b));
442
- }) }))] }), tab === 'editor' && (_jsx("div", { className: "prose prose-sm dark:prose-invert max-w-none px-3 py-2 [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[6rem]", style: wrapperStyle, children: _jsx(EditorContent, { editor: editor }) })), tab === 'source' && (_jsx("textarea", { className: "w-full resize-y bg-transparent px-3 py-2 text-sm font-mono leading-relaxed outline-none disabled:opacity-50", style: wrapperStyle, value: sourceDraft, onChange: (e) => setSourceDraft(e.target.value), ...(placeholder !== undefined ? { placeholder } : {}), disabled: disabled, "aria-label": `${name} (markdown source)` })), tab === 'preview' && (_jsx("div", { className: "prose prose-sm dark:prose-invert max-w-none px-3 py-2", style: wrapperStyle, dangerouslySetInnerHTML: { __html: previewHtml || '<p class="text-muted-foreground italic">Nothing to preview</p>' } })), _jsx(AiSuggestionBanner, { fieldName: name, onApplyWholeField: applyWholeField, ...(isDiffActive && editor
442
+ }) }))] }), tab === 'editor' && (_jsx("div", { className: "prose prose-sm dark:prose-invert max-w-none px-3 py-2 [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[6rem]", style: wrapperStyle, children: _jsx(EditorContent, { editor: editor }) })), tab === 'source' && (_jsx("textarea", { className: "w-full resize-y bg-transparent px-3 py-2 text-sm font-mono leading-relaxed outline-none disabled:opacity-50", style: wrapperStyle, value: sourceDraft, onChange: (e) => setSourceDraft(e.target.value), ...(placeholder !== undefined ? { placeholder } : {}), disabled: disabled, "aria-label": `${name} (markdown source)` })), tab === 'preview' && (_jsx("div", { className: "prose prose-sm dark:prose-invert max-w-none px-3 py-2", style: wrapperStyle, dangerouslySetInnerHTML: { __html: previewHtml || '<p class="text-muted-foreground italic">Nothing to preview</p>' } })), _jsx(SuggestionBanner, { fieldName: name, onApplyWholeField: applyWholeField, ...(isDiffActive && editor
443
443
  ? {
444
- onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
445
- onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
444
+ onAcceptViaEditor: () => editor.commands.acceptInlineDiff(),
445
+ onRejectViaEditor: () => editor.commands.rejectInlineDiff(),
446
446
  }
447
447
  : {}) })] }));
448
448
  }
@@ -25,7 +25,7 @@ import { type PendingSuggestion } from '@pilotiq/pilotiq/react';
25
25
  * same field stack — Accept all / Reject all collapse the queue in one
26
26
  * pass.
27
27
  */
28
- export interface AiSuggestionBannerProps {
28
+ export interface SuggestionBannerProps {
29
29
  /** Field name, matches the suggestion's `fieldName`. */
30
30
  fieldName: string;
31
31
  /**
@@ -35,8 +35,8 @@ export interface AiSuggestionBannerProps {
35
35
  * MarkdownEditor, HTML / JSON for TiptapEditor).
36
36
  *
37
37
  * Skipped when `onAcceptViaEditor` is supplied — that path means the
38
- * editor already holds the proposed state via `AiInlineDiffExtension`,
39
- * and Accept routes through `acceptAiInlineDiff()` instead. The host
38
+ * editor already holds the proposed state via `InlineDiffExtension`,
39
+ * and Accept routes through `acceptInlineDiff()` instead. The host
40
40
  * still calls `pendingSuggestions.approve(id)` afterwards to dismiss
41
41
  * the queue entry.
42
42
  */
@@ -64,10 +64,10 @@ export interface AiSuggestionBannerProps {
64
64
  * Hook variant — returns banner state without rendering, for renderers
65
65
  * that want to compose their own chrome. Renderer-agnostic.
66
66
  */
67
- export declare function useAiSuggestionBanner(fieldName: string): {
67
+ export declare function useSuggestionBanner(fieldName: string): {
68
68
  pending: readonly PendingSuggestion[];
69
69
  approveAll: (apply: (value: string) => void) => void;
70
70
  rejectAll: () => void;
71
71
  };
72
- export declare function AiSuggestionBanner({ fieldName, onApplyWholeField, onAcceptViaEditor, onRejectViaEditor, className, }: AiSuggestionBannerProps): React.ReactElement | null;
73
- //# sourceMappingURL=AiSuggestionBanner.d.ts.map
72
+ export declare function SuggestionBanner({ fieldName, onApplyWholeField, onAcceptViaEditor, onRejectViaEditor, className, }: SuggestionBannerProps): React.ReactElement | null;
73
+ //# sourceMappingURL=SuggestionBanner.d.ts.map
@@ -5,7 +5,7 @@ import { usePendingSuggestionsForField, usePendingSuggestions, } from '@pilotiq/
5
5
  * Hook variant — returns banner state without rendering, for renderers
6
6
  * that want to compose their own chrome. Renderer-agnostic.
7
7
  */
8
- export function useAiSuggestionBanner(fieldName) {
8
+ export function useSuggestionBanner(fieldName) {
9
9
  const { list, dismiss } = usePendingSuggestionsForField(fieldName);
10
10
  // Only whole-field suggestions land in the banner. Range-anchored ones
11
11
  // ride the editor chip widget.
@@ -28,8 +28,8 @@ function hasEditorRange(s) {
28
28
  const range = meta['editorRange'];
29
29
  return !!(range && typeof range.from === 'number' && typeof range.to === 'number');
30
30
  }
31
- export function AiSuggestionBanner({ fieldName, onApplyWholeField, onAcceptViaEditor, onRejectViaEditor, className, }) {
32
- const { pending, approveAll, rejectAll } = useAiSuggestionBanner(fieldName);
31
+ export function SuggestionBanner({ fieldName, onApplyWholeField, onAcceptViaEditor, onRejectViaEditor, className, }) {
32
+ const { pending, approveAll, rejectAll } = useSuggestionBanner(fieldName);
33
33
  const { dismiss } = usePendingSuggestions();
34
34
  if (pending.length === 0)
35
35
  return null;
@@ -63,9 +63,9 @@ export function AiSuggestionBanner({ fieldName, onApplyWholeField, onAcceptViaEd
63
63
  // Per-suggestion controls when there's more than one — keeps the UX
64
64
  // discoverable. Single suggestion: Accept / Reject only.
65
65
  const single = pending.length === 1;
66
- return (_jsxs("div", { role: "region", "aria-label": "AI suggested changes", "data-pilotiq-ai-banner": "", className: className ?? 'pilotiq-ai-banner', children: [_jsx("span", { className: "pilotiq-ai-banner-icon", "aria-hidden": "true", children: "\uD83D\uDCA1" }), _jsx("span", { className: "pilotiq-ai-banner-label", children: single
66
+ return (_jsxs("div", { role: "region", "aria-label": "AI suggested changes", "data-pilotiq-suggestion-banner": "", className: className ?? 'pilotiq-suggestion-banner', children: [_jsx("span", { className: "pilotiq-suggestion-banner-icon", "aria-hidden": "true", children: "\uD83D\uDCA1" }), _jsx("span", { className: "pilotiq-suggestion-banner-label", children: single
67
67
  ? sourceLabel
68
68
  ? `Changes suggested by ${sourceLabel}`
69
69
  : 'Changes suggested'
70
- : `${pending.length} changes suggested` }), _jsxs("div", { className: "pilotiq-ai-banner-actions", children: [_jsx("button", { type: "button", className: "pilotiq-ai-banner-reject", onClick: handleReject, children: single ? 'Reject' : 'Reject all' }), _jsx("button", { type: "button", className: "pilotiq-ai-banner-accept", onClick: handleAccept, children: single ? 'Accept' : 'Accept all' })] })] }));
70
+ : `${pending.length} changes suggested` }), _jsxs("div", { className: "pilotiq-suggestion-banner-actions", children: [_jsx("button", { type: "button", className: "pilotiq-suggestion-banner-reject", onClick: handleReject, children: single ? 'Reject' : 'Reject all' }), _jsx("button", { type: "button", className: "pilotiq-suggestion-banner-accept", onClick: handleAccept, children: single ? 'Accept' : 'Accept all' })] })] }));
71
71
  }
@@ -17,24 +17,23 @@ import { contentBlockNodes } from '../extensions/contentBlocks.js';
17
17
  import { Popover } from '@base-ui/react/popover';
18
18
  import { useCollabRoom, getCollabExtensions, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react';
19
19
  import { useCollabSeed } from '@rudderjs/sync/react';
20
- import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
21
- import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
22
- import { AiSuggestionBanner } from './AiSuggestionBanner.js';
20
+ import { useSuggestionBridge } from './useSuggestionBridge.js';
21
+ import { useInlineDiff, useIsInlineDiffActive, readDiffViewMarker } from './useInlineDiff.js';
22
+ import { SuggestionBanner } from './SuggestionBanner.js';
23
23
  import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
24
24
  import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js';
25
25
  import { SlashCommandExtension, } from '../extensions/SlashCommandExtension.js';
26
26
  import { DragHandleExtension } from '../extensions/DragHandleExtension.js';
27
27
  import { MergeTagExtension } from '../extensions/MergeTagExtension.js';
28
28
  import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js';
29
- import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js';
30
- import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js';
29
+ import { SuggestionChipExtension } from '../extensions/SuggestionChipExtension.js';
30
+ import { InlineDiffExtension } from '../extensions/InlineDiffExtension.js';
31
31
  import { MentionExtension, } from '../extensions/MentionExtension.js';
32
32
  import { SlashMenu } from './SlashMenu.js';
33
33
  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,
@@ -307,10 +278,10 @@ function ClientEditor(props) {
307
278
  })] : [MentionExtension]),
308
279
  DragHandleExtension,
309
280
  // AI suggestions — chip widget for surgical (range-anchored) edits.
310
- AiSuggestionExtension,
281
+ SuggestionChipExtension,
311
282
  // AI inline diff — Tiptap-Pro-style visualization for whole-field
312
- // suggestions via prosemirror-changeset. See AiInlineDiffExtension.
313
- AiInlineDiffExtension,
283
+ // suggestions via prosemirror-changeset. See InlineDiffExtension.
284
+ InlineDiffExtension,
314
285
  // Realtime-collab extensions (Yjs `Collaboration` + cursor) — empty
315
286
  // when no `<RecordCollabRoom>` is mounted up-tree, or when no plugin
316
287
  // registered a factory via `registerCollabExtensions`.
@@ -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
@@ -461,13 +426,13 @@ function ClientEditor(props) {
461
426
  }
462
427
  });
463
428
  // Cross-package suggestion bridge — sync the host's
464
- // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
429
+ // `<PendingSuggestionsContext>` queue with the editor's `InlineSuggestion`
465
430
  // extension. No-op when no provider is mounted (default no-op context).
466
431
  //
467
432
  // Whole-field handling: NO chip widget here. The chip's `textContent`
468
433
  // renderer would surface raw HTML tags as literal text inside the
469
434
  // green pill — unparseable on multi-paragraph rewrites. Instead,
470
- // `<AiSuggestionBanner>` mounts below the editor (see render below).
435
+ // `<SuggestionBanner>` mounts below the editor (see render below).
471
436
  // Producer-supplied range suggestions still ride the inline chip —
472
437
  // those have a precise anchor worth visualizing in context.
473
438
  const applyWholeField = (value) => {
@@ -475,7 +440,7 @@ function ClientEditor(props) {
475
440
  return;
476
441
  editor.commands.setContent(value);
477
442
  };
478
- useAiSuggestionBridge(editor ?? null, name, {
443
+ useSuggestionBridge(editor ?? null, name, {
479
444
  onApplyWholeField: applyWholeField,
480
445
  });
481
446
  // Inline diff for whole-field suggestions. Pipeline mirrors MarkdownEditor:
@@ -483,7 +448,7 @@ function ClientEditor(props) {
483
448
  // on a RichTextField are typically HTML (or marked-up JSON that the
484
449
  // schema's DOMParser also handles via its serialized round-trip). For
485
450
  // JSON suggestions, the schema may reject — falls back to banner-only.
486
- useAiInlineDiff(editor ?? null, name, {
451
+ useInlineDiff(editor ?? null, name, {
487
452
  parseSuggestion: (ed, value) => {
488
453
  try {
489
454
  const container = document.createElement('div');
@@ -494,18 +459,18 @@ function ClientEditor(props) {
494
459
  return null;
495
460
  }
496
461
  },
497
- resolveDisplayMode: () => readAiDiffViewMarker(name),
462
+ resolveDisplayMode: () => readDiffViewMarker(name),
498
463
  });
499
- const isDiffActive = useIsAiInlineDiffActive(editor ?? null);
464
+ const isDiffActive = useIsInlineDiffActive(editor ?? null);
500
465
  // Re-render the toolbar when the selection / marks change so active-state
501
466
  // booleans stay fresh.
502
467
  const tick = useEditorTick(editor);
503
- return (_jsxs("div", { className: "relative flex flex-col", children: [_jsx("input", { type: "hidden", name: name, value: serialized }), editor && toolbarGroups && toolbarGroups.length > 0 && (_jsx(Toolbar, { editor: editor, groups: toolbarGroups, tick: tick, textColors: textColors, customTextColors: customTextColors, highlightColors: highlightColors, onAttachOpenChange: setAttachOpen })), editor && (_jsx(AttachFilesDialog, { open: attachOpen, onOpenChange: setAttachOpen, editor: editor, fieldName: name, ...(uploadUrl !== undefined ? { uploadUrl } : {}), ...(acceptedFileTypes !== undefined ? { acceptedFileTypes } : {}), ...(maxAttachmentSize !== undefined ? { maxFileSize: maxAttachmentSize } : {}), ...(attachmentDir !== undefined ? { directory: attachmentDir } : {}), ...(attachmentVis !== undefined ? { visibility: attachmentVis } : {}) })), _jsx(EditorContent, { editor: editor }), _jsx(AiSuggestionBanner, { fieldName: name, onApplyWholeField: applyWholeField, ...(isDiffActive && editor
468
+ return (_jsxs("div", { className: "relative flex flex-col", children: [_jsx("input", { type: "hidden", name: name, value: serialized }), editor && toolbarGroups && toolbarGroups.length > 0 && (_jsx(Toolbar, { editor: editor, groups: toolbarGroups, tick: tick, textColors: textColors, customTextColors: customTextColors, highlightColors: highlightColors, onAttachOpenChange: setAttachOpen })), editor && (_jsx(AttachFilesDialog, { open: attachOpen, onOpenChange: setAttachOpen, editor: editor, fieldName: name, ...(uploadUrl !== undefined ? { uploadUrl } : {}), ...(acceptedFileTypes !== undefined ? { acceptedFileTypes } : {}), ...(maxAttachmentSize !== undefined ? { maxFileSize: maxAttachmentSize } : {}), ...(attachmentDir !== undefined ? { directory: attachmentDir } : {}), ...(attachmentVis !== undefined ? { visibility: attachmentVis } : {}) })), _jsx(EditorContent, { editor: editor }), _jsx(SuggestionBanner, { fieldName: name, onApplyWholeField: applyWholeField, ...(isDiffActive && editor
504
469
  ? {
505
- onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
506
- onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
470
+ onAcceptViaEditor: () => editor.commands.acceptInlineDiff(),
471
+ onRejectViaEditor: () => editor.commands.rejectInlineDiff(),
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;