@pilotiq/tiptap 3.13.0 → 3.15.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.
@@ -0,0 +1,47 @@
1
+ import { type ReactElement, type ReactNode } from 'react';
2
+ /**
3
+ * Reusable in-block **settings** menu — a gear trigger outset in the inline-end
4
+ * gutter (mirroring the drag handle on the start side), opening a nested menu
5
+ * where each block setting is a submenu. Blocks with multiple variations (Alert
6
+ * = width + type + icon + color; FAQ = width) all surface their controls through
7
+ * one consistent entry point instead of a scattered control cluster.
8
+ *
9
+ * Two setting kinds:
10
+ * - `select` — a radio submenu of mutually-exclusive options (Width, Type). The
11
+ * active option's label rides the parent row as a hint; picking keeps the menu
12
+ * open so the change previews live.
13
+ * - `custom` — a submenu whose body is caller-supplied JSX (icon grid, color
14
+ * swatches). The caller owns the interactions; plain buttons inside don't
15
+ * auto-close the menu.
16
+ *
17
+ * The caller owns every attr write + the matching `data-*` / read-side CSS.
18
+ */
19
+ export type BlockWidth = 'contained' | 'full';
20
+ export type BlockSettingOption = {
21
+ value: string;
22
+ label: string;
23
+ icon?: ReactNode;
24
+ };
25
+ export type BlockSetting = {
26
+ kind: 'select';
27
+ key: string;
28
+ label: string;
29
+ value: string;
30
+ options: BlockSettingOption[];
31
+ onChange: (value: string) => void;
32
+ } | {
33
+ kind: 'custom';
34
+ key: string;
35
+ label: string;
36
+ /** Short hint shown on the parent row (e.g. the current icon / color). */
37
+ hint?: ReactNode;
38
+ /** Submenu body — caller-supplied JSX. */
39
+ content: ReactNode;
40
+ };
41
+ export declare function BlockSettingsMenu({ settings, hoverClass, label, triggerClass, }: {
42
+ settings: BlockSetting[];
43
+ hoverClass?: string;
44
+ label?: string;
45
+ triggerClass?: string;
46
+ }): ReactElement;
47
+ //# sourceMappingURL=BlockSettingsMenu.d.ts.map
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Menu } from '@base-ui/react/menu';
3
+ const gear = (_jsxs("svg", { viewBox: "0 0 24 24", className: "size-3.5", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: [_jsx("circle", { cx: "12", cy: "12", r: "3" }), _jsx("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" })] }));
4
+ const chevronRight = (_jsx("svg", { viewBox: "0 0 24 24", className: "ms-auto size-3.5 text-muted-foreground", fill: "none", stroke: "currentColor", strokeWidth: 2, "aria-hidden": true, children: _jsx("path", { d: "m9 18 6-6-6-6" }) }));
5
+ const check = (_jsx("svg", { viewBox: "0 0 24 24", className: "size-3.5", fill: "none", stroke: "currentColor", strokeWidth: 2.5, "aria-hidden": true, children: _jsx("path", { d: "M20 6 9 17l-5-5" }) }));
6
+ const POPUP = 'min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden';
7
+ const ROW = 'flex w-full cursor-default items-center gap-2 rounded px-2 py-1.5 text-sm outline-hidden ' +
8
+ 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground';
9
+ function activeLabel(s) {
10
+ return s.options.find((o) => o.value === s.value)?.label ?? '';
11
+ }
12
+ export function BlockSettingsMenu({ settings, hoverClass = '[.pilotiq-faq:hover_&]:opacity-100', label = 'Block settings', triggerClass = 'top-0 -end-[30px]', }) {
13
+ return (_jsxs(Menu.Root, { children: [_jsx(Menu.Trigger, { render: _jsx("button", { type: "button", contentEditable: false, "aria-label": label, className: 'absolute z-10 flex items-center justify-center rounded border bg-background p-1 text-muted-foreground shadow-sm ' +
14
+ 'opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 data-[popup-open]:opacity-100 ' +
15
+ triggerClass + ' ' + hoverClass, children: gear }) }), _jsx(Menu.Portal, { children: _jsx(Menu.Positioner, { side: "bottom", align: "end", sideOffset: 4, className: "isolate z-50", children: _jsx(Menu.Popup, { className: POPUP, children: settings.map((s) => (_jsxs(Menu.SubmenuRoot, { children: [_jsxs(Menu.SubmenuTrigger, { className: ROW, children: [_jsx("span", { children: s.label }), _jsx("span", { className: "ms-auto flex items-center gap-1 text-xs text-muted-foreground", children: s.kind === 'select' ? activeLabel(s) : s.hint }), chevronRight] }), _jsx(Menu.Portal, { children: _jsx(Menu.Positioner, { side: "inline-end", align: "start", sideOffset: 2, className: "isolate z-50", children: _jsx(Menu.Popup, { className: POPUP, children: s.kind === 'select' ? (_jsx(Menu.RadioGroup, { value: s.value, onValueChange: (v) => s.onChange(String(v)), children: s.options.map((o) => (_jsxs(Menu.RadioItem, { value: o.value, className: ROW, children: [o.icon && _jsx("span", { className: "flex size-4 items-center justify-center", children: o.icon }), _jsx("span", { children: o.label }), _jsx(Menu.RadioItemIndicator, { className: "ms-auto flex", children: check })] }, o.value))) })) : (s.content) }) }) })] }, s.key))) }) }) })] }));
16
+ }
@@ -0,0 +1,17 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type NodeViewProps } from '@tiptap/react';
3
+ /**
4
+ * React NodeView for a `faqItem` — one row of the FAQ accordion. The question
5
+ * is the always-visible trigger; the answer collapses below it. The chevron (on
6
+ * the right, shadcn-style) toggles the item's `open` attr — the same attr the
7
+ * read-side `<details>` accordion reads, so the editor state is what publishes.
8
+ * The question stays editable on click; only the chevron toggles.
9
+ *
10
+ * One `<NodeViewContent>` holds both child nodes (`faqQuestion` + `faqAnswer`).
11
+ * Layout + the collapse (hiding `.pilotiq-faq-answer` when `data-open="false"`)
12
+ * live in the consumer's `.pilotiq-faq*` CSS — shared with the read-side — so
13
+ * the editable answer stays mounted (round-trips through FormData on save) and
14
+ * the editor matches the published look.
15
+ */
16
+ export declare function FaqItemNodeView({ node, updateAttributes, editor }: NodeViewProps): ReactElement;
17
+ //# sourceMappingURL=FaqItemNodeView.d.ts.map
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
3
+ /**
4
+ * React NodeView for a `faqItem` — one row of the FAQ accordion. The question
5
+ * is the always-visible trigger; the answer collapses below it. The chevron (on
6
+ * the right, shadcn-style) toggles the item's `open` attr — the same attr the
7
+ * read-side `<details>` accordion reads, so the editor state is what publishes.
8
+ * The question stays editable on click; only the chevron toggles.
9
+ *
10
+ * One `<NodeViewContent>` holds both child nodes (`faqQuestion` + `faqAnswer`).
11
+ * Layout + the collapse (hiding `.pilotiq-faq-answer` when `data-open="false"`)
12
+ * live in the consumer's `.pilotiq-faq*` CSS — shared with the read-side — so
13
+ * the editable answer stays mounted (round-trips through FormData on save) and
14
+ * the editor matches the published look.
15
+ */
16
+ export function FaqItemNodeView({ node, updateAttributes, editor }) {
17
+ const open = node.attrs['open'] !== false;
18
+ const editable = editor.isEditable;
19
+ return (_jsxs(NodeViewWrapper, { "data-type": "faqItem", "data-open": open ? 'true' : 'false', className: "pilotiq-faq-item relative", children: [_jsx("button", { type: "button", contentEditable: false, "aria-label": open ? 'Collapse answer' : 'Expand answer', "aria-expanded": open, disabled: !editable, onClick: () => updateAttributes({ open: !open }), className: "absolute start-0 top-2.5 flex size-5 items-center justify-center text-muted-foreground/60 transition-transform hover:text-foreground", style: { transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }, children: _jsx("svg", { viewBox: "0 0 24 24", className: "size-4", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: _jsx("path", { d: "m6 9 6 6 6-6" }) }) }), _jsx(NodeViewContent, {})] }));
20
+ }
@@ -0,0 +1,11 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type NodeViewProps } from '@tiptap/react';
3
+ /**
4
+ * React NodeView for the `faq` container — hosts the in-block **gear menu** at
5
+ * the top-end corner (chevrons live at the start, so the end is free). For now
6
+ * its only setting is **Width** (contained vs full); the `width` attr drives
7
+ * `data-width`, which the consumer's `.pilotiq-faq[data-width="full"]` CSS reads
8
+ * (shared with the read-side). The faqItems render through `<NodeViewContent>`.
9
+ */
10
+ export declare function FaqNodeView({ node, updateAttributes, editor }: NodeViewProps): ReactElement;
11
+ //# sourceMappingURL=FaqNodeView.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
3
+ import { BlockSettingsMenu } from './BlockSettingsMenu.js';
4
+ /**
5
+ * React NodeView for the `faq` container — hosts the in-block **gear menu** at
6
+ * the top-end corner (chevrons live at the start, so the end is free). For now
7
+ * its only setting is **Width** (contained vs full); the `width` attr drives
8
+ * `data-width`, which the consumer's `.pilotiq-faq[data-width="full"]` CSS reads
9
+ * (shared with the read-side). The faqItems render through `<NodeViewContent>`.
10
+ */
11
+ export function FaqNodeView({ node, updateAttributes, editor }) {
12
+ const width = node.attrs['width'] === 'full' ? 'full' : 'contained';
13
+ const settings = [
14
+ {
15
+ kind: 'select',
16
+ key: 'width',
17
+ label: 'Width',
18
+ value: width,
19
+ options: [
20
+ { value: 'contained', label: 'Contained' },
21
+ { value: 'full', label: 'Full width' },
22
+ ],
23
+ onChange: (w) => updateAttributes({ width: w }),
24
+ },
25
+ ];
26
+ return (_jsxs(NodeViewWrapper, { "data-type": "faq", "data-width": width, className: "pilotiq-faq relative", children: [editor.isEditable && _jsx(BlockSettingsMenu, { settings: settings, label: "FAQ settings" }), _jsx(NodeViewContent, { className: "pilotiq-faq-content" })] }));
27
+ }
@@ -0,0 +1,18 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type NodeViewProps } from '@tiptap/react';
3
+ /**
4
+ * Shared React NodeView for the simple **labelled** content blocks — the ones
5
+ * that are just a non-editable label above a `block+` body (Key takeaways,
6
+ * Summary). They rhyme structurally, so they share ONE NodeView instead of a
7
+ * bespoke component each; the per-block `label` + `cssClass` ride the node's
8
+ * `addOptions()` (see `labeledBlock()` in `extensions/contentBlocks.ts`).
9
+ *
10
+ * Mirrors the FAQ/Alert layout: a full-width outer anchor (`cssClass`, stays
11
+ * put so the gear doesn't move on a width toggle) wrapping an inner
12
+ * `.pilotiq-block-content` that carries the max-width / centering. The only
13
+ * setting today is **Width** (contained vs full) via the in-block gear menu;
14
+ * the `width` attr drives `data-width`, which the consumer's CSS reads (shared
15
+ * with the read-side `render.ts`).
16
+ */
17
+ export declare function LabeledBlockNodeView({ node, updateAttributes, editor, extension }: NodeViewProps): ReactElement;
18
+ //# sourceMappingURL=LabeledBlockNodeView.d.ts.map
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
3
+ import { BlockSettingsMenu } from './BlockSettingsMenu.js';
4
+ /**
5
+ * Shared React NodeView for the simple **labelled** content blocks — the ones
6
+ * that are just a non-editable label above a `block+` body (Key takeaways,
7
+ * Summary). They rhyme structurally, so they share ONE NodeView instead of a
8
+ * bespoke component each; the per-block `label` + `cssClass` ride the node's
9
+ * `addOptions()` (see `labeledBlock()` in `extensions/contentBlocks.ts`).
10
+ *
11
+ * Mirrors the FAQ/Alert layout: a full-width outer anchor (`cssClass`, stays
12
+ * put so the gear doesn't move on a width toggle) wrapping an inner
13
+ * `.pilotiq-block-content` that carries the max-width / centering. The only
14
+ * setting today is **Width** (contained vs full) via the in-block gear menu;
15
+ * the `width` attr drives `data-width`, which the consumer's CSS reads (shared
16
+ * with the read-side `render.ts`).
17
+ */
18
+ export function LabeledBlockNodeView({ node, updateAttributes, editor, extension }) {
19
+ const { label, cssClass } = extension.options;
20
+ const width = node.attrs['width'] === 'full' ? 'full' : 'contained';
21
+ const settings = [
22
+ {
23
+ kind: 'select',
24
+ key: 'width',
25
+ label: 'Width',
26
+ value: width,
27
+ options: [
28
+ { value: 'contained', label: 'Contained' },
29
+ { value: 'full', label: 'Full width' },
30
+ ],
31
+ onChange: (w) => updateAttributes({ width: w }),
32
+ },
33
+ ];
34
+ return (_jsxs(NodeViewWrapper, { "data-type": node.type.name, "data-width": width, className: cssClass + ' relative', children: [editor.isEditable && (_jsx(BlockSettingsMenu, { settings: settings, label: label + ' settings', hoverClass: '[.' + cssClass + ':hover_&]:opacity-100' })), _jsxs("div", { className: "pilotiq-block-content", children: [_jsx("div", { className: "pilotiq-block-label", contentEditable: false, children: label }), _jsx(NodeViewContent, { className: "pilotiq-block-body" })] })] }));
35
+ }
@@ -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 = {};
@@ -0,0 +1,14 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type NodeViewProps } from '@tiptap/react';
3
+ /**
4
+ * React NodeView for the `prosCons` container — hosts the in-block **gear menu**
5
+ * (Width) at the top-end corner. The two `prosColumn` / `consColumn` children
6
+ * keep their plain `renderHTML` (label + body); only the container needs React,
7
+ * for the gear. Two layers like FAQ/Alert: a full-width outer anchor
8
+ * (`.pilotiq-pros-cons`, stable so the gear doesn't move on a width toggle)
9
+ * wrapping the inner `.pilotiq-pros-cons-content`, which carries the two-column
10
+ * grid + max-width / centering. The `width` attr drives `data-width` (read by
11
+ * the consumer CSS, shared with the read-side renderer).
12
+ */
13
+ export declare function ProsConsNodeView({ node, updateAttributes, editor }: NodeViewProps): ReactElement;
14
+ //# sourceMappingURL=ProsConsNodeView.d.ts.map
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
3
+ import { BlockSettingsMenu } from './BlockSettingsMenu.js';
4
+ /**
5
+ * React NodeView for the `prosCons` container — hosts the in-block **gear menu**
6
+ * (Width) at the top-end corner. The two `prosColumn` / `consColumn` children
7
+ * keep their plain `renderHTML` (label + body); only the container needs React,
8
+ * for the gear. Two layers like FAQ/Alert: a full-width outer anchor
9
+ * (`.pilotiq-pros-cons`, stable so the gear doesn't move on a width toggle)
10
+ * wrapping the inner `.pilotiq-pros-cons-content`, which carries the two-column
11
+ * grid + max-width / centering. The `width` attr drives `data-width` (read by
12
+ * the consumer CSS, shared with the read-side renderer).
13
+ */
14
+ export function ProsConsNodeView({ node, updateAttributes, editor }) {
15
+ const width = node.attrs['width'] === 'full' ? 'full' : 'contained';
16
+ const settings = [
17
+ {
18
+ kind: 'select',
19
+ key: 'width',
20
+ label: 'Width',
21
+ value: width,
22
+ options: [
23
+ { value: 'contained', label: 'Contained' },
24
+ { value: 'full', label: 'Full width' },
25
+ ],
26
+ onChange: (w) => updateAttributes({ width: w }),
27
+ },
28
+ ];
29
+ return (_jsxs(NodeViewWrapper, { "data-type": "prosCons", "data-width": width, className: "pilotiq-pros-cons relative", children: [editor.isEditable && (_jsx(BlockSettingsMenu, { settings: settings, label: "Pros & cons settings", hoverClass: "[.pilotiq-pros-cons:hover_&]:opacity-100" })), _jsx(NodeViewContent, { className: "pilotiq-pros-cons-content" })] }));
30
+ }
package/dist/render.js CHANGED
@@ -32,6 +32,9 @@
32
32
  * custom blocks — any other type renders to `<div data-type="..."
33
33
  * data-attrs="...">` so consumers can replay or style by data-type.
34
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';
35
38
  /**
36
39
  * Render Tiptap content to HTML.
37
40
  *
@@ -142,14 +145,14 @@ function renderNode(node, opts) {
142
145
  case 'detailsContent': return renderChildren(n, opts);
143
146
  case 'grid': return renderGrid(n, opts);
144
147
  case 'gridColumn': return wrap('div', n, opts);
145
- case 'keyTakeaways': return labeledBlockHtml('pilotiq-key-takeaways', 'Key takeaways', n, opts);
146
- case 'summary': return labeledBlockHtml('pilotiq-summary', 'Summary', n, opts);
147
- case 'faq': return labeledBlockHtml('pilotiq-faq', 'FAQ', n, opts);
148
- case 'faqItem': return `<div class="pilotiq-faq-item">${renderChildren(n, opts)}</div>`;
149
- 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>`;
150
- 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>`;
148
+ case 'keyTakeaways': return labeledBlockHtml('pilotiq-key-takeaways', 'Key takeaways', n, opts, true);
149
+ case 'summary': return labeledBlockHtml('pilotiq-summary', 'Summary', n, opts, true);
150
+ case 'faq': return renderFaqNode(n, opts);
151
+ case 'faqItem': return renderFaqItem(n, opts);
152
+ case 'faqQuestion': return `<summary class="pilotiq-faq-question">${renderChildren(n, opts)}</summary>`;
153
+ case 'faqAnswer': return `<div class="pilotiq-faq-answer">${renderChildren(n, opts)}</div>`;
151
154
  case 'alert': return renderAlertNode(n, opts);
152
- case 'prosCons': return `<div class="pilotiq-pros-cons">${renderChildren(n, opts)}</div>`;
155
+ case 'prosCons': return renderProsCons(n, opts);
153
156
  case 'prosColumn': return labeledBlockHtml('pilotiq-pros', 'Pros', n, opts);
154
157
  case 'consColumn': return labeledBlockHtml('pilotiq-cons', 'Cons', n, opts);
155
158
  case 'mergeTag': return renderMergeTag(n, opts);
@@ -359,23 +362,85 @@ function clampGridColumnsForRender(raw) {
359
362
  // Mirrors the editor's `renderHTML` (extensions/contentBlocks.ts): a small
360
363
  // label above an editable body. Consumer owns the `pilotiq-*` CSS. Covers
361
364
  // keyTakeaways / summary / faq / alert / prosCons (+ pros/cons columns).
362
- function labeledBlockHtml(cssClass, label, n, opts) {
363
- return (`<div class="${cssClass}">` +
364
- `<div class="pilotiq-block-label">${escapeHtml(label)}</div>` +
365
- `<div class="pilotiq-block-body">${renderChildren(n, opts)}</div>` +
366
- `</div>`);
367
- }
368
- const ALERT_NODE_TYPES = new Set(['info', 'warning', 'success', 'tip']);
369
- const ALERT_NODE_LABEL = { info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip' };
365
+ function labeledBlockHtml(cssClass, label, n, opts, wrap = false) {
366
+ const inner = `<div class="pilotiq-block-label">${escapeHtml(label)}</div>` +
367
+ `<div class="pilotiq-block-body">${renderChildren(n, opts)}</div>`;
368
+ // Top-level labelled blocks (summary / keyTakeaways) carry the gear menu, so
369
+ // they get the two-layer anchor: a full-width outer + an inner
370
+ // `.pilotiq-block-content` that holds the max-width / width toggle. Columns
371
+ // inside Pros & cons render flat (no width of their own).
372
+ if (!wrap)
373
+ return `<div class="${cssClass}">${inner}</div>`;
374
+ const width = n.attrs?.['width'] === 'full' ? ' data-width="full"' : '';
375
+ return `<div class="${cssClass}"${width}><div class="pilotiq-block-content">${inner}</div></div>`;
376
+ }
377
+ // Pros & cons — two labelled columns. Same two-layer anchor as the labelled
378
+ // blocks: a full-width outer (`.pilotiq-pros-cons`) + an inner
379
+ // `.pilotiq-pros-cons-content` that carries the grid + width toggle.
380
+ function renderProsCons(n, opts) {
381
+ const width = n.attrs?.['width'] === 'full' ? ' data-width="full"' : '';
382
+ return `<div class="pilotiq-pros-cons"${width}><div class="pilotiq-pros-cons-content">${renderChildren(n, opts)}</div></div>`;
383
+ }
384
+ // FAQ accordion — native, zero-JS `<details>`/`<summary>` per item (mirrors the
385
+ // editor's FaqItem NodeView). Each item's `open` attr drives the platform
386
+ // `open` attribute. Consumer owns the `.pilotiq-faq*` CSS.
387
+ function renderFaqNode(n, opts) {
388
+ const items = (Array.isArray(n.content) ? n.content : []).filter((k) => k?.type === 'faqItem');
389
+ const width = n.attrs?.['width'] === 'full' ? ' data-width="full"' : '';
390
+ return `<div class="pilotiq-faq"${width}><div class="pilotiq-faq-content">${items.map((it) => renderFaqItem(it, opts)).join('')}</div></div>`;
391
+ }
392
+ function renderFaqItem(n, opts) {
393
+ const kids = Array.isArray(n.content) ? n.content : [];
394
+ const q = kids.find((k) => k?.type === 'faqQuestion');
395
+ const a = kids.find((k) => k?.type === 'faqAnswer');
396
+ const open = n.attrs?.['open'] !== false;
397
+ return (`<details class="pilotiq-faq-item"${open ? ' open' : ''}>` +
398
+ `<summary class="pilotiq-faq-question">${q ? renderChildren(q, opts) : ''}</summary>` +
399
+ `<div class="pilotiq-faq-answer">${a ? renderChildren(a, opts) : ''}</div>` +
400
+ `</details>`);
401
+ }
402
+ // shadcn-style callout: icon + editable title + description. Mirrors the
403
+ // editor NodeView (`AlertNodeView`); `coerceAlertType` / `ALERT_ICON_INNER` /
404
+ // labels are shared from `alertVariants` so the two never drift. Consumer owns
405
+ // the `.pilotiq-alert*` CSS.
370
406
  function renderAlertNode(n, opts) {
371
- let type = String(n.attrs?.['type'] ?? '').trim().toLowerCase();
372
- if (!ALERT_NODE_TYPES.has(type))
373
- type = 'info';
374
- return (`<div class="pilotiq-alert pilotiq-alert-${type}" role="note">` +
375
- `<div class="pilotiq-block-label">${ALERT_NODE_LABEL[type]}</div>` +
376
- `<div class="pilotiq-block-body">${renderChildren(n, opts)}</div>` +
407
+ const type = coerceAlertType(n.attrs?.['type']);
408
+ const icon = typeof n.attrs?.['icon'] === 'string' ? n.attrs['icon'] : '';
409
+ const iconSvg = typeof n.attrs?.['iconSvg'] === 'string' ? n.attrs['iconSvg'] : '';
410
+ const color = typeof n.attrs?.['color'] === 'string' ? n.attrs['color'] : '';
411
+ const width = n.attrs?.['width'] === 'full' ? ' data-width="full"' : '';
412
+ const kids = Array.isArray(n.content) ? n.content : [];
413
+ const title = kids.find((k) => k?.type === 'alertTitle');
414
+ const body = kids.find((k) => k?.type === 'alertBody');
415
+ const titleHtml = title ? renderChildren(title, opts) : escapeHtml(ALERT_VARIANT_LABEL[type]);
416
+ const bodyHtml = body ? renderChildren(body, opts) : '';
417
+ // Custom variant paints from the chosen color (mirrors the editor's
418
+ // `color-mix` tint); CSS-injection-safe because the value is the parsed
419
+ // attr, only emitted when it matches a strict color literal.
420
+ const tinted = type === 'custom' && isSafeColor(color);
421
+ const style = tinted
422
+ ? ` style="border-color:color-mix(in srgb,${color} 35%,transparent);background-color:color-mix(in srgb,${color} 8%,transparent)"`
423
+ : '';
424
+ const iconStyle = tinted ? ` style="color:${color}"` : '';
425
+ // Two layers (mirrors the FAQ block): a full-width `.pilotiq-alert` anchor
426
+ // and an inner `.pilotiq-alert-box` that carries the visual box + the
427
+ // contained/full width. Keeps in-block controls anchored to a stable edge.
428
+ return (`<div class="pilotiq-alert" data-alert-type="${type}"${width}>` +
429
+ `<div class="pilotiq-alert-box pilotiq-alert-${type}" role="note"${style}>` +
430
+ `<span class="pilotiq-alert-icon" aria-hidden="true"${iconStyle}>${buildAlertIconSvg(icon, iconSvg, type)}</span>` +
431
+ `<div class="pilotiq-alert-title">${titleHtml}</div>` +
432
+ `<div class="pilotiq-alert-description">${bodyHtml}</div>` +
433
+ `</div>` +
377
434
  `</div>`);
378
435
  }
436
+ // Only emit a color into inline CSS when it's a plain literal (hex / rgb(a) /
437
+ // hsl(a) / a CSS keyword) — defends the `style="…"` interpolation against
438
+ // injection via a tampered `color` attr.
439
+ function isSafeColor(value) {
440
+ return /^#[0-9a-fA-F]{3,8}$/.test(value)
441
+ || /^(rgb|hsl)a?\([0-9.,%\s/]+\)$/.test(value)
442
+ || /^[a-zA-Z]+$/.test(value);
443
+ }
379
444
  // ─── Merge tags + mentions ───────────────────────────────────────────
380
445
  /**
381
446
  * Render a `mergeTag` atom — either substitute the value from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.13.0",
3
+ "version": "3.15.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": {