@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/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,4 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type NodeViewProps } from '@tiptap/react';
3
+ export declare function AlertNodeView({ node, updateAttributes, editor }: NodeViewProps): ReactElement;
4
+ //# sourceMappingURL=AlertNodeView.d.ts.map
@@ -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
- * custom blocks — render to `<div data-type="..." data-attrs="...">` so
30
- * consumers can replay or style by data-type.
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
- * custom blocks — render to `<div data-type="..." data-attrs="...">` so
30
- * consumers can replay or style by data-type.
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.12.0",
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.37.0"
96
+ "@pilotiq/pilotiq": "^0.40.0"
95
97
  },
96
98
  "author": "Suleiman Shahbari",
97
99
  "scripts": {