@pilotiq/tiptap 3.12.0 → 3.14.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 +32 -0
- package/dist/Block.d.ts +8 -2
- package/dist/Block.js +9 -3
- package/dist/RichTextField.d.ts +17 -2
- package/dist/RichTextField.js +38 -7
- package/dist/blocks/alert.d.ts +10 -0
- package/dist/blocks/alert.js +23 -0
- package/dist/blocks/faq.d.ts +9 -0
- package/dist/blocks/faq.js +21 -0
- package/dist/blocks/index.d.ts +22 -0
- package/dist/blocks/index.js +20 -0
- package/dist/blocks/keyTakeaways.d.ts +10 -0
- package/dist/blocks/keyTakeaways.js +15 -0
- package/dist/blocks/prosCons.d.ts +9 -0
- package/dist/blocks/prosCons.js +15 -0
- package/dist/blocks/summary.d.ts +9 -0
- package/dist/blocks/summary.js +14 -0
- package/dist/extensions/DragHandleExtension.js +20 -3
- package/dist/extensions/SlashCommandExtension.js +66 -0
- package/dist/extensions/alertVariants.d.ts +44 -0
- package/dist/extensions/alertVariants.js +132 -0
- package/dist/extensions/contentBlocks.d.ts +34 -0
- package/dist/extensions/contentBlocks.js +491 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/react/AlertNodeView.d.ts +4 -0
- package/dist/react/AlertNodeView.js +92 -0
- package/dist/react/MarkdownEditor.js +19 -0
- package/dist/react/TiptapEditor.js +4 -0
- package/dist/render.d.ts +5 -2
- package/dist/render.js +164 -2
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { RichTextField, DEFAULT_TOOLBAR_GROUPS, DEFAULT_TEXT_COLORS, DEFAULT_HIGHLIGHT_COLORS, } from './RichTextField.js';
|
|
2
2
|
export { Block } from './Block.js';
|
|
3
|
+
export { defaultBlocks, faqBlock, alertBlock, summaryBlock, keyTakeawaysBlock, prosConsBlock, } from './blocks/index.js';
|
|
3
4
|
export { MentionProvider, } from './MentionProvider.js';
|
|
4
5
|
export { registerTiptap } from './register.js';
|
|
5
6
|
export { createPlainTextEditor, plainTextOf, plainTextToDoc, } from './PlainTextEditor.js';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
|
4
|
+
import { Popover } from '@base-ui/react/popover';
|
|
5
|
+
import { ALERT_VARIANTS, ALERT_ICON_KEYS, buildAlertIconSvg, sanitizeIconSvg, coerceAlertType, } from '../extensions/alertVariants.js';
|
|
6
|
+
import { Palette } from './Palette.js';
|
|
7
|
+
/**
|
|
8
|
+
* React NodeView for the `alert` content block — a shadcn-style callout on the
|
|
9
|
+
* panel's theme tokens. Icon in column one, editable title + body in column
|
|
10
|
+
* two. Editable mode adds in-block controls (top-right): a variant picker, an
|
|
11
|
+
* icon picker (curated inline-SVG library + a "Custom SVG" paste field — no
|
|
12
|
+
* `lucide-react`), and a color swatch for the `custom` variant.
|
|
13
|
+
*
|
|
14
|
+
* Editable regions are the child nodes (`alertTitle` / `alertBody`) rendered
|
|
15
|
+
* through the single `<NodeViewContent>` hole; the wrapper styles them via
|
|
16
|
+
* child selectors so the nodes' `renderHTML` stays semantic (consumer owns the
|
|
17
|
+
* read-side CSS — see `render.ts`). Custom SVG is sanitized via
|
|
18
|
+
* `sanitizeIconSvg` before it's stored AND when it renders.
|
|
19
|
+
*/
|
|
20
|
+
const VARIANT_BOX = {
|
|
21
|
+
info: 'border-blue-500/30 bg-blue-50/40 dark:bg-blue-950/20',
|
|
22
|
+
warning: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20',
|
|
23
|
+
success: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20',
|
|
24
|
+
tip: 'border-violet-500/30 bg-violet-50/40 dark:bg-violet-950/20',
|
|
25
|
+
custom: 'border-border bg-card',
|
|
26
|
+
};
|
|
27
|
+
const VARIANT_ICON_COLOR = {
|
|
28
|
+
info: 'text-blue-600 dark:text-blue-400',
|
|
29
|
+
warning: 'text-amber-600 dark:text-amber-400',
|
|
30
|
+
success: 'text-emerald-600 dark:text-emerald-400',
|
|
31
|
+
tip: 'text-violet-600 dark:text-violet-400',
|
|
32
|
+
custom: 'text-foreground',
|
|
33
|
+
};
|
|
34
|
+
const VARIANT_LABEL = {
|
|
35
|
+
info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip', custom: 'Custom',
|
|
36
|
+
};
|
|
37
|
+
const COLOR_SWATCHES = [
|
|
38
|
+
{ value: '#ef4444', label: 'Red' },
|
|
39
|
+
{ value: '#f97316', label: 'Orange' },
|
|
40
|
+
{ value: '#eab308', label: 'Yellow' },
|
|
41
|
+
{ value: '#22c55e', label: 'Green' },
|
|
42
|
+
{ value: '#06b6d4', label: 'Cyan' },
|
|
43
|
+
{ value: '#3b82f6', label: 'Blue' },
|
|
44
|
+
{ value: '#8b5cf6', label: 'Violet' },
|
|
45
|
+
{ value: '#ec4899', label: 'Pink' },
|
|
46
|
+
{ value: '#64748b', label: 'Slate' },
|
|
47
|
+
{ value: '#0f172a', label: 'Ink' },
|
|
48
|
+
];
|
|
49
|
+
// Renders a full <svg> string (library or sanitized custom) sized to fill.
|
|
50
|
+
function IconSlot({ svg, className }) {
|
|
51
|
+
return (_jsx("span", { className: 'inline-flex shrink-0 [&>svg]:size-full ' + (className ?? ''), dangerouslySetInnerHTML: { __html: svg } }));
|
|
52
|
+
}
|
|
53
|
+
const chevron = (_jsx("svg", { viewBox: "0 0 24 24", className: "h-3 w-3", fill: "none", stroke: "currentColor", strokeWidth: 2, "aria-hidden": true, children: _jsx("path", { d: "m6 9 6 6 6-6" }) }));
|
|
54
|
+
const ctrlBtn = 'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-opacity ' +
|
|
55
|
+
'hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 opacity-0 [.pilotiq-alert:hover_&]:opacity-100';
|
|
56
|
+
export function AlertNodeView({ node, updateAttributes, editor }) {
|
|
57
|
+
const variant = coerceAlertType(node.attrs['type']);
|
|
58
|
+
const iconKey = String(node.attrs['icon'] ?? '');
|
|
59
|
+
const iconSvg = String(node.attrs['iconSvg'] ?? '');
|
|
60
|
+
const color = String(node.attrs['color'] ?? '');
|
|
61
|
+
const editable = editor.isEditable;
|
|
62
|
+
const iconFull = buildAlertIconSvg(iconKey, iconSvg, variant);
|
|
63
|
+
const tinted = variant === 'custom' && color !== '';
|
|
64
|
+
const [iconOpen, setIconOpen] = useState(false);
|
|
65
|
+
const [svgMode, setSvgMode] = useState(false);
|
|
66
|
+
const [svgDraft, setSvgDraft] = useState('');
|
|
67
|
+
const [svgError, setSvgError] = useState(false);
|
|
68
|
+
const boxStyle = tinted
|
|
69
|
+
? {
|
|
70
|
+
borderColor: `color-mix(in srgb, ${color} 35%, transparent)`,
|
|
71
|
+
backgroundColor: `color-mix(in srgb, ${color} 8%, transparent)`,
|
|
72
|
+
}
|
|
73
|
+
: undefined;
|
|
74
|
+
const closeIconPicker = () => { setIconOpen(false); setSvgMode(false); setSvgDraft(''); setSvgError(false); };
|
|
75
|
+
const pickIcon = (key) => { updateAttributes({ icon: key, iconSvg: '' }); closeIconPicker(); };
|
|
76
|
+
const applyCustomSvg = () => {
|
|
77
|
+
const clean = sanitizeIconSvg(svgDraft);
|
|
78
|
+
if (!clean) {
|
|
79
|
+
setSvgError(true);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
updateAttributes({ iconSvg: clean, icon: '' });
|
|
83
|
+
closeIconPicker();
|
|
84
|
+
};
|
|
85
|
+
return (_jsxs(NodeViewWrapper, { "data-type": "alert", "data-alert-type": variant, style: boxStyle, className: 'pilotiq-alert relative my-3 grid grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-lg border px-4 py-3 text-sm ' +
|
|
86
|
+
'[&_.pilotiq-alert-title]:font-medium [&_.pilotiq-alert-title]:leading-tight ' +
|
|
87
|
+
'[&_.pilotiq-alert-description]:text-muted-foreground [&_.pilotiq-alert-description_p]:my-0 ' +
|
|
88
|
+
VARIANT_BOX[variant], children: [editable ? (_jsxs(Popover.Root, { open: iconOpen, onOpenChange: (o) => (o ? setIconOpen(true) : closeIconPicker()), children: [_jsx(Popover.Trigger, { render: _jsx("button", { type: "button", contentEditable: false, "aria-label": "Alert icon", className: 'mt-0.5 rounded hover:bg-accent ' + (tinted ? '' : VARIANT_ICON_COLOR[variant]), style: tinted ? { color } : undefined, children: _jsx(IconSlot, { svg: iconFull, className: "h-4 w-4" }) }) }), _jsx(Popover.Portal, { children: _jsx(Popover.Positioner, { side: "bottom", align: "start", sideOffset: 6, className: "isolate z-50", children: _jsx(Popover.Popup, { className: "w-64 rounded-md border bg-popover p-2 text-popover-foreground shadow-md outline-hidden", children: !svgMode ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-6 gap-1", children: ALERT_ICON_KEYS.map((key) => (_jsx("button", { type: "button", title: key, "aria-label": key, onClick: () => pickIcon(key), className: 'flex size-8 items-center justify-center rounded hover:bg-accent hover:text-accent-foreground ' +
|
|
89
|
+
(!iconSvg && key === iconKey ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'), children: _jsx(IconSlot, { svg: buildAlertIconSvg(key, '', variant), className: "h-4 w-4" }) }, key))) }), _jsxs("div", { className: "mt-2 flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => { setSvgMode(true); setSvgDraft(iconSvg); setSvgError(false); }, className: "flex-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "Custom SVG\u2026" }), _jsx("button", { type: "button", onClick: () => { updateAttributes({ icon: '', iconSvg: '' }); closeIconPicker(); }, className: "flex-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "Default" })] })] })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("textarea", { value: svgDraft, onChange: (e) => { setSvgDraft(e.target.value); setSvgError(false); }, rows: 5, spellCheck: false, placeholder: "<svg viewBox='0 0 24 24'>\u2026</svg>", className: 'w-full resize-y rounded border bg-background p-2 font-mono text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring ' +
|
|
90
|
+
(svgError ? 'border-destructive' : 'border-input') }), svgError && _jsx("p", { className: "text-xs text-destructive", children: "Not a valid SVG (must start with <svg>)." }), _jsxs("div", { className: "flex items-center justify-end gap-1", children: [_jsx("button", { type: "button", onClick: () => setSvgMode(false), className: "rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "Back" }), _jsx("button", { type: "button", onClick: applyCustomSvg, className: "rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90", children: "Apply" })] })] })) }) }) })] })) : (_jsx("div", { contentEditable: false, className: 'mt-0.5 ' + (tinted ? '' : VARIANT_ICON_COLOR[variant]), style: tinted ? { color } : undefined, children: _jsx(IconSlot, { svg: iconFull, className: "h-4 w-4" }) })), editable && (_jsxs("div", { className: "absolute right-1.5 top-1.5 flex items-center gap-1", contentEditable: false, children: [variant === 'custom' && (_jsx(Palette, { trigger: _jsx("button", { type: "button", "aria-label": "Alert color", className: ctrlBtn, children: _jsx("span", { className: "size-3 rounded-full border border-border/60", style: { background: color || 'var(--color-muted-foreground)' } }) }), swatches: COLOR_SWATCHES, custom: true, activeColor: color || undefined, onPick: (value) => updateAttributes({ color: value }), onClear: () => updateAttributes({ color: '' }), clearLabel: "No color" })), _jsxs(Popover.Root, { children: [_jsx(Popover.Trigger, { render: _jsxs("button", { type: "button", "aria-label": "Alert variant", className: ctrlBtn, children: [VARIANT_LABEL[variant], chevron] }) }), _jsx(Popover.Portal, { children: _jsx(Popover.Positioner, { side: "bottom", align: "end", sideOffset: 4, className: "isolate z-50", children: _jsx(Popover.Popup, { className: "min-w-32 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden", children: ALERT_VARIANTS.map((v) => (_jsx(Popover.Close, { render: _jsxs("button", { type: "button", onClick: () => updateAttributes({ type: v }), className: 'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-accent hover:text-accent-foreground ' +
|
|
91
|
+
(v === variant ? 'bg-accent/50' : ''), children: [_jsx("span", { className: VARIANT_ICON_COLOR[v], children: _jsx(IconSlot, { svg: buildAlertIconSvg('', '', v), className: "h-3.5 w-3.5" }) }), VARIANT_LABEL[v]] }) }, v))) }) }) })] })] })), _jsx(NodeViewContent, { className: "col-start-2 min-w-0" })] }));
|
|
92
|
+
}
|
|
@@ -15,6 +15,7 @@ import { useCollabRoom, getCollabExtensions, useToast, } from '@pilotiq/pilotiq/
|
|
|
15
15
|
import { useCollabSeed } from '@rudderjs/sync/react';
|
|
16
16
|
import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js';
|
|
17
17
|
import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js';
|
|
18
|
+
import { Alert, AlertTitle, AlertBody, ContentBlockKeymap } from '../extensions/contentBlocks.js';
|
|
18
19
|
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
|
|
19
20
|
import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
|
|
20
21
|
import { AiSuggestionBanner } from './AiSuggestionBanner.js';
|
|
@@ -39,6 +40,7 @@ const SvgIcons = {
|
|
|
39
40
|
orderedList: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("line", { x1: "10", y1: "6", x2: "21", y2: "6" }), _jsx("line", { x1: "10", y1: "12", x2: "21", y2: "12" }), _jsx("line", { x1: "10", y1: "18", x2: "21", y2: "18" }), _jsx("path", { d: "M4 6h1v4" }), _jsx("path", { d: "M4 10h2" }), _jsx("path", { d: "M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" })] })),
|
|
40
41
|
blockquote: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("path", { d: "M3 21c3 0 7-1 7-8V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h3" }), _jsx("path", { d: "M15 21c3 0 7-1 7-8V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h3" })] })),
|
|
41
42
|
codeBlock: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("polyline", { points: "16 18 22 12 16 6" }), _jsx("polyline", { points: "8 6 2 12 8 18" })] })),
|
|
43
|
+
alert: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" }), _jsx("path", { d: "M12 9v4" }), _jsx("path", { d: "M12 17h.01" })] })),
|
|
42
44
|
attachFiles: (_jsx("svg", { ...ICON_PROPS, children: _jsx("path", { d: "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" }) })),
|
|
43
45
|
pencil: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("path", { d: "M12 20h9" }), _jsx("path", { d: "M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" })] })),
|
|
44
46
|
source: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }), _jsx("polyline", { points: "14 2 14 8 20 8" }), _jsx("line", { x1: "9", y1: "13", x2: "15", y2: "13" }), _jsx("line", { x1: "9", y1: "17", x2: "15", y2: "17" })] })),
|
|
@@ -140,6 +142,12 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
|
|
|
140
142
|
// for deleted text. Host's `<AiSuggestionBanner>` drives Accept /
|
|
141
143
|
// Reject via the extension's commands.
|
|
142
144
|
AiInlineDiffExtension,
|
|
145
|
+
// Alert content block — round-trips to `:::alert{type=…} Title` via the
|
|
146
|
+
// node's `markdown` storage spec; renders the same shadcn NodeView.
|
|
147
|
+
Alert,
|
|
148
|
+
AlertTitle,
|
|
149
|
+
AlertBody,
|
|
150
|
+
ContentBlockKeymap,
|
|
143
151
|
...collabExtensions,
|
|
144
152
|
],
|
|
145
153
|
// Collab takes ownership of the document — passing `content` would
|
|
@@ -366,6 +374,16 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
|
|
|
366
374
|
case 'codeBlock':
|
|
367
375
|
c.toggleCodeBlock().run();
|
|
368
376
|
break;
|
|
377
|
+
case 'alert':
|
|
378
|
+
c.insertContent({
|
|
379
|
+
type: 'alert',
|
|
380
|
+
attrs: { type: 'info' },
|
|
381
|
+
content: [
|
|
382
|
+
{ type: 'alertTitle', content: [{ type: 'text', text: 'Info' }] },
|
|
383
|
+
{ type: 'alertBody', content: [{ type: 'paragraph' }] },
|
|
384
|
+
],
|
|
385
|
+
}).run();
|
|
386
|
+
break;
|
|
369
387
|
case 'attachFiles':
|
|
370
388
|
onAttachClick();
|
|
371
389
|
break;
|
|
@@ -398,6 +416,7 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
|
|
|
398
416
|
orderedList: 'Numbered list',
|
|
399
417
|
blockquote: 'Quote',
|
|
400
418
|
codeBlock: 'Code block',
|
|
419
|
+
alert: 'Alert',
|
|
401
420
|
attachFiles: 'Attach file',
|
|
402
421
|
};
|
|
403
422
|
const wrapperStyle = {};
|
|
@@ -13,6 +13,7 @@ import Image from '@tiptap/extension-image';
|
|
|
13
13
|
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
|
|
14
14
|
import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-details';
|
|
15
15
|
import { Grid, GridColumn } from '../extensions/GridExtension.js';
|
|
16
|
+
import { contentBlockNodes } from '../extensions/contentBlocks.js';
|
|
16
17
|
import { Popover } from '@base-ui/react/popover';
|
|
17
18
|
import { useCollabRoom, getCollabExtensions, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react';
|
|
18
19
|
import { useCollabSeed } from '@rudderjs/sync/react';
|
|
@@ -276,6 +277,9 @@ function ClientEditor(props) {
|
|
|
276
277
|
// `pilotiq-grid-cols-N`.
|
|
277
278
|
Grid,
|
|
278
279
|
GridColumn,
|
|
280
|
+
// Inline content blocks — labelled, editable-in-place regions:
|
|
281
|
+
// Key takeaways / Summary / FAQ / Alert / Pros & cons.
|
|
282
|
+
...contentBlockNodes,
|
|
279
283
|
Placeholder.configure({ placeholder: placeholder ?? 'Start writing…' }),
|
|
280
284
|
// BlockNodeExtension carries the block registry on its options —
|
|
281
285
|
// NodeViews mount in a separate React tree and can't see context.
|
package/dist/render.d.ts
CHANGED
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
* / image.src + alt + title + width + height
|
|
27
27
|
* / tableCell.colspan + rowspan + colwidth (also tableHeader)
|
|
28
28
|
* / mergeTag.id / mention.id + label + trigger
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* default blocks — the `pilotiqBlock` node's built-in types (faq / alert /
|
|
30
|
+
* summary / key-takeaways / pros-cons) render to semantic
|
|
31
|
+
* `<div class="pilotiq-...">` markup; consumers own the CSS.
|
|
32
|
+
* custom blocks — any other type renders to `<div data-type="..."
|
|
33
|
+
* data-attrs="...">` so consumers can replay or style by data-type.
|
|
31
34
|
*/
|
|
32
35
|
export interface RenderRichTextOptions {
|
|
33
36
|
/**
|
package/dist/render.js
CHANGED
|
@@ -26,9 +26,15 @@
|
|
|
26
26
|
* / image.src + alt + title + width + height
|
|
27
27
|
* / tableCell.colspan + rowspan + colwidth (also tableHeader)
|
|
28
28
|
* / mergeTag.id / mention.id + label + trigger
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* default blocks — the `pilotiqBlock` node's built-in types (faq / alert /
|
|
30
|
+
* summary / key-takeaways / pros-cons) render to semantic
|
|
31
|
+
* `<div class="pilotiq-...">` markup; consumers own the CSS.
|
|
32
|
+
* custom blocks — any other type renders to `<div data-type="..."
|
|
33
|
+
* data-attrs="...">` so consumers can replay or style by data-type.
|
|
31
34
|
*/
|
|
35
|
+
// Pure variant primitives (no Tiptap runtime) — keeps render.ts server-safe
|
|
36
|
+
// while sharing the alert icon/label source of truth with the editor NodeView.
|
|
37
|
+
import { coerceAlertType, ALERT_VARIANT_LABEL, buildAlertIconSvg } from './extensions/alertVariants.js';
|
|
32
38
|
/**
|
|
33
39
|
* Render Tiptap content to HTML.
|
|
34
40
|
*
|
|
@@ -139,9 +145,20 @@ function renderNode(node, opts) {
|
|
|
139
145
|
case 'detailsContent': return renderChildren(n, opts);
|
|
140
146
|
case 'grid': return renderGrid(n, opts);
|
|
141
147
|
case 'gridColumn': return wrap('div', n, opts);
|
|
148
|
+
case 'keyTakeaways': return labeledBlockHtml('pilotiq-key-takeaways', 'Key takeaways', n, opts);
|
|
149
|
+
case 'summary': return labeledBlockHtml('pilotiq-summary', 'Summary', n, opts);
|
|
150
|
+
case 'faq': return labeledBlockHtml('pilotiq-faq', 'FAQ', n, opts);
|
|
151
|
+
case 'faqItem': return `<div class="pilotiq-faq-item">${renderChildren(n, opts)}</div>`;
|
|
152
|
+
case 'faqQuestion': return `<div class="pilotiq-faq-question"><span class="pilotiq-faq-marker">Q</span><span class="pilotiq-faq-text">${renderChildren(n, opts)}</span></div>`;
|
|
153
|
+
case 'faqAnswer': return `<div class="pilotiq-faq-answer"><span class="pilotiq-faq-marker">A</span><div class="pilotiq-faq-body">${renderChildren(n, opts)}</div></div>`;
|
|
154
|
+
case 'alert': return renderAlertNode(n, opts);
|
|
155
|
+
case 'prosCons': return `<div class="pilotiq-pros-cons">${renderChildren(n, opts)}</div>`;
|
|
156
|
+
case 'prosColumn': return labeledBlockHtml('pilotiq-pros', 'Pros', n, opts);
|
|
157
|
+
case 'consColumn': return labeledBlockHtml('pilotiq-cons', 'Cons', n, opts);
|
|
142
158
|
case 'mergeTag': return renderMergeTag(n, opts);
|
|
143
159
|
case 'mention': return renderMention(n, opts);
|
|
144
160
|
case 'text': return renderText(n);
|
|
161
|
+
case 'pilotiqBlock': return renderPilotiqBlock(n, opts);
|
|
145
162
|
default:
|
|
146
163
|
if (opts.renderBlock)
|
|
147
164
|
return opts.renderBlock(n);
|
|
@@ -340,6 +357,53 @@ function clampGridColumnsForRender(raw) {
|
|
|
340
357
|
const trunc = Math.trunc(n);
|
|
341
358
|
return trunc === 3 ? 3 : 2;
|
|
342
359
|
}
|
|
360
|
+
// ─── Inline content blocks (labelled editable nodes) ─────────────────
|
|
361
|
+
//
|
|
362
|
+
// Mirrors the editor's `renderHTML` (extensions/contentBlocks.ts): a small
|
|
363
|
+
// label above an editable body. Consumer owns the `pilotiq-*` CSS. Covers
|
|
364
|
+
// keyTakeaways / summary / faq / alert / prosCons (+ pros/cons columns).
|
|
365
|
+
function labeledBlockHtml(cssClass, label, n, opts) {
|
|
366
|
+
return (`<div class="${cssClass}">` +
|
|
367
|
+
`<div class="pilotiq-block-label">${escapeHtml(label)}</div>` +
|
|
368
|
+
`<div class="pilotiq-block-body">${renderChildren(n, opts)}</div>` +
|
|
369
|
+
`</div>`);
|
|
370
|
+
}
|
|
371
|
+
// shadcn-style callout: icon + editable title + description. Mirrors the
|
|
372
|
+
// editor NodeView (`AlertNodeView`); `coerceAlertType` / `ALERT_ICON_INNER` /
|
|
373
|
+
// labels are shared from `alertVariants` so the two never drift. Consumer owns
|
|
374
|
+
// the `.pilotiq-alert*` CSS.
|
|
375
|
+
function renderAlertNode(n, opts) {
|
|
376
|
+
const type = coerceAlertType(n.attrs?.['type']);
|
|
377
|
+
const icon = typeof n.attrs?.['icon'] === 'string' ? n.attrs['icon'] : '';
|
|
378
|
+
const iconSvg = typeof n.attrs?.['iconSvg'] === 'string' ? n.attrs['iconSvg'] : '';
|
|
379
|
+
const color = typeof n.attrs?.['color'] === 'string' ? n.attrs['color'] : '';
|
|
380
|
+
const kids = Array.isArray(n.content) ? n.content : [];
|
|
381
|
+
const title = kids.find((k) => k?.type === 'alertTitle');
|
|
382
|
+
const body = kids.find((k) => k?.type === 'alertBody');
|
|
383
|
+
const titleHtml = title ? renderChildren(title, opts) : escapeHtml(ALERT_VARIANT_LABEL[type]);
|
|
384
|
+
const bodyHtml = body ? renderChildren(body, opts) : '';
|
|
385
|
+
// Custom variant paints from the chosen color (mirrors the editor's
|
|
386
|
+
// `color-mix` tint); CSS-injection-safe because the value is the parsed
|
|
387
|
+
// attr, only emitted when it matches a strict color literal.
|
|
388
|
+
const tinted = type === 'custom' && isSafeColor(color);
|
|
389
|
+
const style = tinted
|
|
390
|
+
? ` style="border-color:color-mix(in srgb,${color} 35%,transparent);background-color:color-mix(in srgb,${color} 8%,transparent)"`
|
|
391
|
+
: '';
|
|
392
|
+
const iconStyle = tinted ? ` style="color:${color}"` : '';
|
|
393
|
+
return (`<div class="pilotiq-alert pilotiq-alert-${type}" data-alert-type="${type}" role="note"${style}>` +
|
|
394
|
+
`<span class="pilotiq-alert-icon" aria-hidden="true"${iconStyle}>${buildAlertIconSvg(icon, iconSvg, type)}</span>` +
|
|
395
|
+
`<div class="pilotiq-alert-title">${titleHtml}</div>` +
|
|
396
|
+
`<div class="pilotiq-alert-description">${bodyHtml}</div>` +
|
|
397
|
+
`</div>`);
|
|
398
|
+
}
|
|
399
|
+
// Only emit a color into inline CSS when it's a plain literal (hex / rgb(a) /
|
|
400
|
+
// hsl(a) / a CSS keyword) — defends the `style="…"` interpolation against
|
|
401
|
+
// injection via a tampered `color` attr.
|
|
402
|
+
function isSafeColor(value) {
|
|
403
|
+
return /^#[0-9a-fA-F]{3,8}$/.test(value)
|
|
404
|
+
|| /^(rgb|hsl)a?\([0-9.,%\s/]+\)$/.test(value)
|
|
405
|
+
|| /^[a-zA-Z]+$/.test(value);
|
|
406
|
+
}
|
|
343
407
|
// ─── Merge tags + mentions ───────────────────────────────────────────
|
|
344
408
|
/**
|
|
345
409
|
* Render a `mergeTag` atom — either substitute the value from
|
|
@@ -386,6 +450,104 @@ function renderCustomBlock(n) {
|
|
|
386
450
|
const inner = n.content ? renderChildren(n, {}) : '';
|
|
387
451
|
return `<div data-type="${escapeAttr(type)}"${dataAttrs}>${inner}</div>`;
|
|
388
452
|
}
|
|
453
|
+
// ─── Default blocks (pilotiqBlock node, keyed on attrs.blockType) ─────
|
|
454
|
+
//
|
|
455
|
+
// The custom-block node (`pilotiqBlock`) carries `blockType` + `blockData`.
|
|
456
|
+
// The blocks shipped by default in `RichTextField` (FAQ / Alert / Summary /
|
|
457
|
+
// Key takeaways / Pros & cons) render to semantic HTML here; every other
|
|
458
|
+
// (host-defined) block type falls back to `opts.renderBlock` or the generic
|
|
459
|
+
// `data-attrs` div. Consumers own the CSS for the `pilotiq-*` classes.
|
|
460
|
+
function renderPilotiqBlock(n, opts) {
|
|
461
|
+
const attrs = (n.attrs ?? {});
|
|
462
|
+
const blockType = String(attrs['blockType'] ?? '');
|
|
463
|
+
let data = attrs['blockData'];
|
|
464
|
+
if (typeof data === 'string') {
|
|
465
|
+
try {
|
|
466
|
+
data = JSON.parse(data);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
data = {};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const d = (data && typeof data === 'object' ? data : {});
|
|
473
|
+
switch (blockType) {
|
|
474
|
+
case 'faq': return renderFaqBlock(d);
|
|
475
|
+
case 'alert': return renderAlertBlock(d);
|
|
476
|
+
case 'summary': return renderSummaryBlock(d);
|
|
477
|
+
case 'key-takeaways': return renderKeyTakeawaysBlock(d);
|
|
478
|
+
case 'pros-cons': return renderProsConsBlock(d);
|
|
479
|
+
default:
|
|
480
|
+
if (opts.renderBlock)
|
|
481
|
+
return opts.renderBlock(n);
|
|
482
|
+
return renderCustomBlock(n);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/** Escape a plain-text field value and split blank-line-separated paragraphs. */
|
|
486
|
+
function blockParagraphs(raw) {
|
|
487
|
+
const s = String(raw ?? '').trim();
|
|
488
|
+
if (s === '')
|
|
489
|
+
return '';
|
|
490
|
+
return s
|
|
491
|
+
.split(/\n{2,}/)
|
|
492
|
+
.map((p) => `<p>${escapeHtml(p).replace(/\n/g, '<br>')}</p>`)
|
|
493
|
+
.join('');
|
|
494
|
+
}
|
|
495
|
+
/** Coerce a field value to a clean `string[]` (TagsInput stores an array). */
|
|
496
|
+
function blockStringList(raw) {
|
|
497
|
+
if (!Array.isArray(raw))
|
|
498
|
+
return [];
|
|
499
|
+
return raw.map((x) => String(x ?? '').trim()).filter((s) => s !== '');
|
|
500
|
+
}
|
|
501
|
+
function renderFaqBlock(d) {
|
|
502
|
+
const items = Array.isArray(d['items']) ? d['items'] : [];
|
|
503
|
+
const body = items
|
|
504
|
+
.map((it) => {
|
|
505
|
+
const q = escapeHtml(String(it['question'] ?? '').trim());
|
|
506
|
+
if (q === '')
|
|
507
|
+
return '';
|
|
508
|
+
const a = blockParagraphs(it['answer']);
|
|
509
|
+
return `<details class="pilotiq-faq-item"><summary>${q}</summary><div class="pilotiq-faq-answer">${a}</div></details>`;
|
|
510
|
+
})
|
|
511
|
+
.join('');
|
|
512
|
+
if (body === '')
|
|
513
|
+
return '';
|
|
514
|
+
return `<div class="pilotiq-faq">${body}</div>`;
|
|
515
|
+
}
|
|
516
|
+
const ALERT_TYPES = new Set(['info', 'warning', 'success', 'tip']);
|
|
517
|
+
function renderAlertBlock(d) {
|
|
518
|
+
let type = String(d['type'] ?? '').trim().toLowerCase();
|
|
519
|
+
if (!ALERT_TYPES.has(type))
|
|
520
|
+
type = 'info';
|
|
521
|
+
const content = blockParagraphs(d['content']);
|
|
522
|
+
return `<div class="pilotiq-alert pilotiq-alert-${type}" role="note">${content}</div>`;
|
|
523
|
+
}
|
|
524
|
+
function renderSummaryBlock(d) {
|
|
525
|
+
const content = blockParagraphs(d['content']);
|
|
526
|
+
if (content === '')
|
|
527
|
+
return '';
|
|
528
|
+
return (`<div class="pilotiq-summary">` +
|
|
529
|
+
`<div class="pilotiq-summary-label">Summary</div>` +
|
|
530
|
+
`<div class="pilotiq-summary-body">${content}</div>` +
|
|
531
|
+
`</div>`);
|
|
532
|
+
}
|
|
533
|
+
function renderKeyTakeawaysBlock(d) {
|
|
534
|
+
const points = blockStringList(d['points']);
|
|
535
|
+
if (points.length === 0)
|
|
536
|
+
return '';
|
|
537
|
+
const lis = points.map((p) => `<li>${escapeHtml(p)}</li>`).join('');
|
|
538
|
+
return (`<div class="pilotiq-key-takeaways">` +
|
|
539
|
+
`<div class="pilotiq-key-takeaways-label">Key takeaways</div>` +
|
|
540
|
+
`<ul>${lis}</ul>` +
|
|
541
|
+
`</div>`);
|
|
542
|
+
}
|
|
543
|
+
function renderProsConsBlock(d) {
|
|
544
|
+
const pros = blockStringList(d['pros']);
|
|
545
|
+
const cons = blockStringList(d['cons']);
|
|
546
|
+
if (pros.length === 0 && cons.length === 0)
|
|
547
|
+
return '';
|
|
548
|
+
const column = (label, cls, items) => `<div class="pilotiq-${cls}"><h4>${label}</h4><ul>${items.map((i) => `<li>${escapeHtml(i)}</li>`).join('')}</ul></div>`;
|
|
549
|
+
return `<div class="pilotiq-pros-cons">${column('Pros', 'pros', pros)}${column('Cons', 'cons', cons)}</div>`;
|
|
550
|
+
}
|
|
389
551
|
// ─── Escapers + sanitizers ───────────────────────────────────────────
|
|
390
552
|
function escapeHtml(s) {
|
|
391
553
|
return s.replace(/[&<>"']/g, ch => HTML_ESCAPES[ch] ?? ch);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pilotiq/tiptap",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.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": {
|
|
@@ -87,11 +87,13 @@
|
|
|
87
87
|
"@types/react-dom": "^19",
|
|
88
88
|
"esbuild": "^0.27",
|
|
89
89
|
"jsdom": "^29.1.1",
|
|
90
|
+
"markdown-it": "^14",
|
|
91
|
+
"@types/markdown-it": "^14",
|
|
90
92
|
"react": "^19",
|
|
91
93
|
"react-dom": "^19",
|
|
92
94
|
"tiptap-markdown": "^0.9",
|
|
93
95
|
"typescript": "^5",
|
|
94
|
-
"@pilotiq/pilotiq": "^0.
|
|
96
|
+
"@pilotiq/pilotiq": "^0.40.0"
|
|
95
97
|
},
|
|
96
98
|
"author": "Suleiman Shahbari",
|
|
97
99
|
"scripts": {
|