@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.
- package/CHANGELOG.md +56 -0
- package/dist/extensions/BlockNodeExtension.d.ts +0 -9
- package/dist/extensions/BlockNodeExtension.js +0 -20
- package/dist/extensions/{AiInlineDiffExtension.d.ts → InlineDiffExtension.d.ts} +23 -23
- package/dist/extensions/{AiInlineDiffExtension.js → InlineDiffExtension.js} +33 -33
- package/dist/extensions/{AiSuggestionExtension.d.ts → SuggestionChipExtension.d.ts} +29 -29
- package/dist/extensions/{AiSuggestionExtension.js → SuggestionChipExtension.js} +52 -52
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/react/BlockNodeView.d.ts +23 -12
- package/dist/react/BlockNodeView.js +55 -21
- package/dist/react/CollabTextRenderer.js +16 -16
- package/dist/react/MarkdownEditor.js +17 -17
- package/dist/react/{AiSuggestionBanner.d.ts → SuggestionBanner.d.ts} +6 -6
- package/dist/react/{AiSuggestionBanner.js → SuggestionBanner.js} +5 -5
- package/dist/react/TiptapEditor.js +24 -59
- package/dist/react/blockValues.d.ts +54 -0
- package/dist/react/blockValues.js +161 -0
- package/dist/react/floatingToolbarVisibility.d.ts +9 -2
- package/dist/react/floatingToolbarVisibility.js +12 -3
- package/dist/react/{useAiInlineDiff.d.ts → useInlineDiff.d.ts} +13 -13
- package/dist/react/{useAiInlineDiff.js → useInlineDiff.js} +36 -19
- package/dist/react/{useAiSuggestionBridge.d.ts → useSuggestionBridge.d.ts} +7 -7
- package/dist/react/{useAiSuggestionBridge.js → useSuggestionBridge.js} +10 -10
- package/dist/surgicalOps.d.ts +15 -2
- package/dist/surgicalOps.js +50 -3
- package/package.json +1 -1
- package/dist/react/BlockSidePanel.d.ts +0 -105
- 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 {
|
|
17
|
-
import {
|
|
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 {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
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
|
-
|
|
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 `<
|
|
142
|
+
// for deleted text. Host's `<SuggestionBanner>` drives Accept /
|
|
143
143
|
// Reject via the extension's commands.
|
|
144
|
-
|
|
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 `
|
|
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, `<
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
211
|
+
resolveDisplayMode: () => readDiffViewMarker(name),
|
|
212
212
|
});
|
|
213
|
-
const isDiffActive =
|
|
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(
|
|
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.
|
|
445
|
-
onRejectViaEditor: () => editor.commands.
|
|
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
|
|
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 `
|
|
39
|
-
* and Accept routes through `
|
|
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
|
|
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
|
|
73
|
-
//# sourceMappingURL=
|
|
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
|
|
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
|
|
32
|
-
const { pending, approveAll, rejectAll } =
|
|
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-
|
|
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-
|
|
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 {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
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 {
|
|
30
|
-
import {
|
|
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
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
// below). Stores `pos` + `blockType` at open-time; `BlockSidePanel`
|
|
175
|
-
// tracks the position forward through transactions and writes attrs
|
|
176
|
-
// back via setNodeMarkup. Closing nullifies the slot — re-opening
|
|
177
|
-
// remounts the panel fresh, including a re-snapshot of `blockData`.
|
|
178
|
-
const [selectedBlock, setSelectedBlock] = useState(null);
|
|
179
|
-
const handleEditBlock = useCallback((pos) => {
|
|
180
|
-
// We resolve `blockType` here against the current doc so a stale
|
|
181
|
-
// pos (e.g. the block was just deleted before the click landed)
|
|
182
|
-
// produces a no-op rather than an empty panel.
|
|
183
|
-
setSelectedBlock((prev) => {
|
|
184
|
-
// Read from the editor lazily — the editor ref isn't stable yet
|
|
185
|
-
// on the very first render where this callback is created, so
|
|
186
|
-
// defer the lookup to call time.
|
|
187
|
-
const ed = editorRef.current;
|
|
188
|
-
if (!ed)
|
|
189
|
-
return prev;
|
|
190
|
-
const node = ed.state.doc.nodeAt(pos);
|
|
191
|
-
if (!node || node.type.name !== 'pilotiqBlock')
|
|
192
|
-
return prev;
|
|
193
|
-
return { pos, blockType: String(node.attrs['blockType'] ?? '') };
|
|
194
|
-
});
|
|
195
|
-
}, []);
|
|
196
|
-
const closeBlockPanel = useCallback(() => { setSelectedBlock(null); }, []);
|
|
197
|
-
// editorRef gives the onEdit callback access to the editor instance
|
|
198
|
-
// without re-creating the callback on every render (which would force
|
|
199
|
-
// the extension config to re-evaluate, triggering a full editor reset).
|
|
200
|
-
const editorRef = useRef(null);
|
|
170
|
+
// Custom blocks (`pilotiqBlock`) edit inline now — the NodeView expands an
|
|
171
|
+
// accordion form and writes attrs back via `updateAttributes` itself, so the
|
|
172
|
+
// host needs no side panel, no `onEdit` bridge, and no position tracking.
|
|
201
173
|
// Resolve the collab-attached extensions once per editor build.
|
|
202
174
|
// `Collaboration` is constructed eagerly here (during `useEditor`'s
|
|
203
175
|
// first call); the keyed remount above guarantees we never swap it.
|
|
@@ -283,10 +255,9 @@ function ClientEditor(props) {
|
|
|
283
255
|
Placeholder.configure({ placeholder: placeholder ?? 'Start writing…' }),
|
|
284
256
|
// BlockNodeExtension carries the block registry on its options —
|
|
285
257
|
// NodeViews mount in a separate React tree and can't see context.
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
BlockNodeExtension.configure({ blocks, onEdit: handleEditBlock }),
|
|
258
|
+
// The NodeView edits inline (accordion) and writes back itself, so no
|
|
259
|
+
// host callback is threaded here.
|
|
260
|
+
BlockNodeExtension.configure({ blocks }),
|
|
290
261
|
...(slashEnabled ? [SlashCommandExtension.configure({
|
|
291
262
|
blocks,
|
|
292
263
|
mergeTags,
|
|
@@ -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
|
-
|
|
281
|
+
SuggestionChipExtension,
|
|
311
282
|
// AI inline diff — Tiptap-Pro-style visualization for whole-field
|
|
312
|
-
// suggestions via prosemirror-changeset. See
|
|
313
|
-
|
|
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 `
|
|
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
|
-
// `<
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
462
|
+
resolveDisplayMode: () => readDiffViewMarker(name),
|
|
498
463
|
});
|
|
499
|
-
const isDiffActive =
|
|
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(
|
|
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.
|
|
506
|
-
onRejectViaEditor: () => editor.commands.
|
|
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 })
|
|
473
|
+
: {}) }), editor && floatingEnabled && _jsx(FloatingToolbar, { editor: editor }), editor && _jsx(TableFloatingToolbar, { editor: editor }), _jsx(SlashPopover, { state: slashState, keyHandlerRef: slashKeyRef }), _jsx(MentionPopover, { state: mentionState, keyHandlerRef: mentionKeyRef })] }));
|
|
509
474
|
}
|
|
510
475
|
/**
|
|
511
476
|
* Cursor-anchored popover for the mention menu. Same Floating-UI / virtual-
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure value-coercion helpers for custom-block (`pilotiqBlock`) form data.
|
|
3
|
+
*
|
|
4
|
+
* The inline accordion editor in `BlockNodeView` snapshots its `<form>` via
|
|
5
|
+
* `new FormData(formEl)` → `parseFormDataToNested` (rebuilds nested arrays /
|
|
6
|
+
* objects from dotted-path inputs like `items.0.title`) → `coerceBlockValues`
|
|
7
|
+
* (per-fieldType JSON parse / boolean / number coerce so nested-shape fields
|
|
8
|
+
* round-trip in their canonical wire form) before writing the result back onto
|
|
9
|
+
* the node via `updateAttributes({ blockData })`.
|
|
10
|
+
*
|
|
11
|
+
* No React, no DOM, no editor — exported for unit tests.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Per-fieldType coerce of a nested values map (built by
|
|
15
|
+
* `parseFormDataToNested`) against the block's schema. Mirrors the
|
|
16
|
+
* server-side `coerceFormValues` at a small subset suitable for the
|
|
17
|
+
* inline block editor — top-level block fields plus the immediate
|
|
18
|
+
* children of any Repeater rows / Builder rows.data.
|
|
19
|
+
*
|
|
20
|
+
* Non-coerce passthrough for: text, textarea, select, radio, date,
|
|
21
|
+
* dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
|
|
22
|
+
* is already a plain string / array of strings.)
|
|
23
|
+
*
|
|
24
|
+
* Coerce branches:
|
|
25
|
+
* - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
|
|
26
|
+
* - `number` / `slider`: parse to Number, null on empty, raw string
|
|
27
|
+
* passthrough on NaN (so a half-typed value isn't lost).
|
|
28
|
+
* - `tagsInput`: JSON-encoded string → string[].
|
|
29
|
+
* - `checkboxList`: JSON-encoded string OR array → string[].
|
|
30
|
+
* - `keyValue`: JSON-encoded string → Record<string, unknown>.
|
|
31
|
+
* - `fileUpload`: single → URL string passthrough; multiple →
|
|
32
|
+
* JSON-encoded string → string[].
|
|
33
|
+
* - `repeater`: each row in the array gets recursive coerce against
|
|
34
|
+
* the field's `template` (the inner field schema definition).
|
|
35
|
+
* - `builder`: each row's `data` gets recursive coerce against the
|
|
36
|
+
* block matching `row.type` from `field.blocks[]`. Unknown block
|
|
37
|
+
* types pass through verbatim — the renderer shows a placeholder
|
|
38
|
+
* and the data round-trips intact across config rollbacks.
|
|
39
|
+
*/
|
|
40
|
+
export declare function coerceBlockValues(raw: Record<string, unknown>, schema: ReadonlyArray<Record<string, unknown>>): Record<string, unknown>;
|
|
41
|
+
/**
|
|
42
|
+
* Read the resolved field value for a given input event target. Maps a
|
|
43
|
+
* single-input change onto its coerced wire shape — string passthrough
|
|
44
|
+
* for the common case; explicit coercion for booleans and numerics so
|
|
45
|
+
* the round-trip into the node attrs preserves shape. Exported for tests.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readBlockFieldValue(target: {
|
|
48
|
+
type?: string;
|
|
49
|
+
value: string;
|
|
50
|
+
checked?: boolean;
|
|
51
|
+
}, fieldMeta: {
|
|
52
|
+
fieldType?: unknown;
|
|
53
|
+
}): unknown;
|
|
54
|
+
//# sourceMappingURL=blockValues.d.ts.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure value-coercion helpers for custom-block (`pilotiqBlock`) form data.
|
|
3
|
+
*
|
|
4
|
+
* The inline accordion editor in `BlockNodeView` snapshots its `<form>` via
|
|
5
|
+
* `new FormData(formEl)` → `parseFormDataToNested` (rebuilds nested arrays /
|
|
6
|
+
* objects from dotted-path inputs like `items.0.title`) → `coerceBlockValues`
|
|
7
|
+
* (per-fieldType JSON parse / boolean / number coerce so nested-shape fields
|
|
8
|
+
* round-trip in their canonical wire form) before writing the result back onto
|
|
9
|
+
* the node via `updateAttributes({ blockData })`.
|
|
10
|
+
*
|
|
11
|
+
* No React, no DOM, no editor — exported for unit tests.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Per-fieldType coerce of a nested values map (built by
|
|
15
|
+
* `parseFormDataToNested`) against the block's schema. Mirrors the
|
|
16
|
+
* server-side `coerceFormValues` at a small subset suitable for the
|
|
17
|
+
* inline block editor — top-level block fields plus the immediate
|
|
18
|
+
* children of any Repeater rows / Builder rows.data.
|
|
19
|
+
*
|
|
20
|
+
* Non-coerce passthrough for: text, textarea, select, radio, date,
|
|
21
|
+
* dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
|
|
22
|
+
* is already a plain string / array of strings.)
|
|
23
|
+
*
|
|
24
|
+
* Coerce branches:
|
|
25
|
+
* - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
|
|
26
|
+
* - `number` / `slider`: parse to Number, null on empty, raw string
|
|
27
|
+
* passthrough on NaN (so a half-typed value isn't lost).
|
|
28
|
+
* - `tagsInput`: JSON-encoded string → string[].
|
|
29
|
+
* - `checkboxList`: JSON-encoded string OR array → string[].
|
|
30
|
+
* - `keyValue`: JSON-encoded string → Record<string, unknown>.
|
|
31
|
+
* - `fileUpload`: single → URL string passthrough; multiple →
|
|
32
|
+
* JSON-encoded string → string[].
|
|
33
|
+
* - `repeater`: each row in the array gets recursive coerce against
|
|
34
|
+
* the field's `template` (the inner field schema definition).
|
|
35
|
+
* - `builder`: each row's `data` gets recursive coerce against the
|
|
36
|
+
* block matching `row.type` from `field.blocks[]`. Unknown block
|
|
37
|
+
* types pass through verbatim — the renderer shows a placeholder
|
|
38
|
+
* and the data round-trips intact across config rollbacks.
|
|
39
|
+
*/
|
|
40
|
+
export function coerceBlockValues(raw, schema) {
|
|
41
|
+
const out = { ...raw };
|
|
42
|
+
for (const field of schema) {
|
|
43
|
+
const name = String(field['name'] ?? '');
|
|
44
|
+
if (!name)
|
|
45
|
+
continue;
|
|
46
|
+
const ft = String(field['fieldType'] ?? 'text');
|
|
47
|
+
const value = out[name];
|
|
48
|
+
out[name] = coerceField(value, ft, field);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function coerceField(value, ft, field) {
|
|
53
|
+
switch (ft) {
|
|
54
|
+
case 'toggle':
|
|
55
|
+
case 'checkbox':
|
|
56
|
+
return value === 'true' || value === true;
|
|
57
|
+
case 'number':
|
|
58
|
+
case 'slider':
|
|
59
|
+
return coerceNumber(value);
|
|
60
|
+
case 'tagsInput':
|
|
61
|
+
return parseJsonArray(value);
|
|
62
|
+
case 'checkboxList':
|
|
63
|
+
return parseJsonArray(value);
|
|
64
|
+
case 'keyValue':
|
|
65
|
+
return parseJsonObject(value);
|
|
66
|
+
case 'fileUpload': {
|
|
67
|
+
const multiple = Boolean(field['multiple']);
|
|
68
|
+
if (multiple)
|
|
69
|
+
return parseJsonArray(value);
|
|
70
|
+
return typeof value === 'string' ? value : '';
|
|
71
|
+
}
|
|
72
|
+
case 'repeater': {
|
|
73
|
+
if (!Array.isArray(value))
|
|
74
|
+
return [];
|
|
75
|
+
const template = field['template'] ?? [];
|
|
76
|
+
return value.map((row) => {
|
|
77
|
+
if (!row || typeof row !== 'object')
|
|
78
|
+
return {};
|
|
79
|
+
return coerceBlockValues(row, template);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
case 'builder': {
|
|
83
|
+
if (!Array.isArray(value))
|
|
84
|
+
return [];
|
|
85
|
+
const blockMetas = field['blocks'] ?? [];
|
|
86
|
+
return value.map((row) => {
|
|
87
|
+
if (!row || typeof row !== 'object')
|
|
88
|
+
return { type: '', data: {} };
|
|
89
|
+
const r = row;
|
|
90
|
+
const type = String(r['type'] ?? '');
|
|
91
|
+
const data = r['data'] ?? {};
|
|
92
|
+
const block = blockMetas.find((b) => String(b['name'] ?? '') === type);
|
|
93
|
+
if (!block)
|
|
94
|
+
return { type, data };
|
|
95
|
+
const tpl = block['template'] ?? [];
|
|
96
|
+
return { type, data: coerceBlockValues(data, tpl) };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
return value === undefined ? '' : value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function coerceNumber(value) {
|
|
104
|
+
if (value === '' || value === null || value === undefined)
|
|
105
|
+
return null;
|
|
106
|
+
if (typeof value === 'number')
|
|
107
|
+
return value;
|
|
108
|
+
const raw = String(value);
|
|
109
|
+
if (raw === '')
|
|
110
|
+
return null;
|
|
111
|
+
const n = Number(raw);
|
|
112
|
+
return Number.isNaN(n) ? raw : n;
|
|
113
|
+
}
|
|
114
|
+
function parseJsonArray(value) {
|
|
115
|
+
if (Array.isArray(value))
|
|
116
|
+
return value;
|
|
117
|
+
if (typeof value !== 'string' || value === '')
|
|
118
|
+
return [];
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(value);
|
|
121
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function parseJsonObject(value) {
|
|
128
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
if (typeof value !== 'string' || value === '')
|
|
132
|
+
return {};
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(value);
|
|
135
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch { /* fall through */ }
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Read the resolved field value for a given input event target. Maps a
|
|
144
|
+
* single-input change onto its coerced wire shape — string passthrough
|
|
145
|
+
* for the common case; explicit coercion for booleans and numerics so
|
|
146
|
+
* the round-trip into the node attrs preserves shape. Exported for tests.
|
|
147
|
+
*/
|
|
148
|
+
export function readBlockFieldValue(target, fieldMeta) {
|
|
149
|
+
const ft = String(fieldMeta.fieldType ?? 'text');
|
|
150
|
+
if (ft === 'toggle' || ft === 'checkbox') {
|
|
151
|
+
return target.checked === true;
|
|
152
|
+
}
|
|
153
|
+
if (ft === 'number' || ft === 'slider') {
|
|
154
|
+
const raw = target.value;
|
|
155
|
+
if (raw === '')
|
|
156
|
+
return null;
|
|
157
|
+
const n = Number(raw);
|
|
158
|
+
return Number.isNaN(n) ? raw : n;
|
|
159
|
+
}
|
|
160
|
+
return target.value;
|
|
161
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EditorState } from '@tiptap/pm/state';
|
|
2
2
|
/**
|
|
3
3
|
* Inline marks the `FloatingToolbar` can toggle — bold / italic / strike /
|
|
4
4
|
* code / link. A caret (empty selection) sitting inside any of these surfaces
|
|
@@ -10,7 +10,14 @@ export declare const TOOLBAR_MARKS: readonly ["bold", "italic", "strike", "code"
|
|
|
10
10
|
* Whether the selection-based `FloatingToolbar` should be visible. A PURE
|
|
11
11
|
* decision (no DOM / coords) so it's unit-testable against the real schema:
|
|
12
12
|
*
|
|
13
|
-
* - never
|
|
13
|
+
* - never on a whole-node selection of a non-text block — a schema-form
|
|
14
|
+
* custom block card (`pilotiqBlock`), an image, an hr, or a whole Alert
|
|
15
|
+
* block picked via the drag handle has no inline text to format, so it
|
|
16
|
+
* must not surface the mark toolbar (#155);
|
|
17
|
+
* - the toolbar DOES show for text inside the Alert's editable title/body —
|
|
18
|
+
* those are real editable text nodes, so the formatting actions should
|
|
19
|
+
* work there like anywhere else (an earlier over-correction suppressed the
|
|
20
|
+
* whole Alert, including its editable text);
|
|
14
21
|
* - a caret (empty selection) shows ONLY when it sits inside a formatting
|
|
15
22
|
* mark (link / bold / …) so the mark can be edited without selecting (#156);
|
|
16
23
|
* - a non-empty range shows whenever it actually spans inline content (the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NodeSelection } from '@tiptap/pm/state';
|
|
2
2
|
/**
|
|
3
3
|
* Inline marks the `FloatingToolbar` can toggle — bold / italic / strike /
|
|
4
4
|
* code / link. A caret (empty selection) sitting inside any of these surfaces
|
|
@@ -23,15 +23,24 @@ function caretInToolbarMark(state) {
|
|
|
23
23
|
* Whether the selection-based `FloatingToolbar` should be visible. A PURE
|
|
24
24
|
* decision (no DOM / coords) so it's unit-testable against the real schema:
|
|
25
25
|
*
|
|
26
|
-
* - never
|
|
26
|
+
* - never on a whole-node selection of a non-text block — a schema-form
|
|
27
|
+
* custom block card (`pilotiqBlock`), an image, an hr, or a whole Alert
|
|
28
|
+
* block picked via the drag handle has no inline text to format, so it
|
|
29
|
+
* must not surface the mark toolbar (#155);
|
|
30
|
+
* - the toolbar DOES show for text inside the Alert's editable title/body —
|
|
31
|
+
* those are real editable text nodes, so the formatting actions should
|
|
32
|
+
* work there like anywhere else (an earlier over-correction suppressed the
|
|
33
|
+
* whole Alert, including its editable text);
|
|
27
34
|
* - a caret (empty selection) shows ONLY when it sits inside a formatting
|
|
28
35
|
* mark (link / bold / …) so the mark can be edited without selecting (#156);
|
|
29
36
|
* - a non-empty range shows whenever it actually spans inline content (the
|
|
30
37
|
* `childCount === 0` guard skips degenerate full-block selections).
|
|
31
38
|
*/
|
|
32
39
|
export function shouldShowFloatingToolbar(state) {
|
|
33
|
-
|
|
40
|
+
const sel = state.selection;
|
|
41
|
+
if (sel instanceof NodeSelection && sel.node.isBlock && !sel.node.isTextblock) {
|
|
34
42
|
return false;
|
|
43
|
+
}
|
|
35
44
|
if (state.selection.empty)
|
|
36
45
|
return caretInToolbarMark(state);
|
|
37
46
|
const { from, to } = state.selection;
|