@pilotiq/tiptap 3.14.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # @pilotiq/tiptap
2
2
 
3
+ ## 3.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c3816ae: In-block content-block controls now live behind a single **gear menu**.
8
+
9
+ Blocks with multiple variations used to scatter their controls (a width chip in one corner, a variant dropdown + color swatch + click-the-icon picker in another). They now share one consistent entry point — a gear button in the block's inline-end gutter that opens a **nested settings menu**, one submenu per setting.
10
+
11
+ - **New reusable `BlockSettingsMenu`** (replaces `BlockWidthControl`): a gear trigger + a Base UI `Menu` with a `SubmenuRoot` per setting. Two setting kinds — `select` (a radio submenu, e.g. Width / Type) and `custom` (caller-supplied submenu body, e.g. the icon grid / color swatches). The active value rides each row as a hint.
12
+ - **Alert** routes Width, Type, Icon (curated SVG library + Custom SVG paste), and Color (custom variant only) through the gear; the icon in column one is now static and changed from the menu.
13
+ - **Alert gains a `width` attr** (`contained` / `full`), mirroring the FAQ block — emitted read-side as `data-width`. To keep the gear from shifting when width changes, Alert now renders in **two layers** (same as FAQ): a full-width `.pilotiq-alert` anchor wrapping an inner `.pilotiq-alert-box` that carries the box chrome + width. Consumer CSS that targeted `.pilotiq-alert` for the box (border/background/padding/`pilotiq-alert-<type>`) should move to `.pilotiq-alert-box`; full-width is `.pilotiq-alert[data-width="full"] .pilotiq-alert-box`.
14
+ - **FAQ** moves its width toggle into the same gear menu.
15
+
16
+ Back-compat: node structures are unchanged; existing Alert/FAQ content loads as-is (alerts default to `contained`).
17
+
18
+ - b6b28bd: The **FAQ** content block is now a collapsible **accordion**.
19
+
20
+ - **Editor:** each Q&A item is a collapsible row (a React NodeView) — the question is the always-visible trigger with a chevron, the answer folds below it. The question stays editable on click; only the chevron toggles. New `open` attr on `faqItem` (defaults open) stores per-item state.
21
+ - **Read-side** (`renderRichTextToHtml`): renders as native **`<details>`/`<summary>`** — a real, accessible, **zero-JS** accordion the browser collapses on its own. Each item's `open` attr drives the platform `open` attribute. Consumer owns the `.pilotiq-faq*` CSS.
22
+ - Dropped the old "Q"/"A" markers in favor of the accordion chrome.
23
+ - **Block width:** an in-block toggle (a reusable `BlockWidthControl`) switches the FAQ between **contained** (max-width, centered) and **full** width — a `width` attr on the `faq` node, emitted read-side as `data-width`. Generic enough to reuse on other blocks.
24
+
25
+ Back-compat: the node structure is unchanged (`faq > faqItem > faqQuestion faqAnswer`), so existing FAQ content loads as-is and gains the default-open state; old HTML question/answer wrappers still parse via fallback rules.
26
+
27
+ - af935e7: The remaining inline content blocks (**Summary**, **Key takeaways**, **Pros & cons**) now use the React NodeView + gear-menu pattern, matching Alert and FAQ.
28
+
29
+ - Each gains an in-block **gear menu** with a **Width** setting (`contained` / `full`), surfaced read-side as `data-width`.
30
+ - **Summary** and **Key takeaways** share one new `LabeledBlockNodeView` (they're structurally identical — a label above a `block+` body), driven by the existing `labeledBlock()` factory, which now attaches the NodeView + `width` attr and carries the per-block `label` / `cssClass` on `addOptions()`.
31
+ - **Pros & cons** gets its own `ProsConsNodeView`; the gear lives on the container, the two columns keep their plain label markup.
32
+ - The `width` attr is consolidated into one shared `widthAttribute()` helper (FAQ, Alert, the labelled blocks, and Pros & cons all reuse it, so they can't drift).
33
+
34
+ **Read-side / consumer CSS:** each block now renders a full-width outer anchor wrapping an inner content layer (mirrors the FAQ outer/`-content` split, so the gear doesn't move on a width toggle):
35
+
36
+ - Summary / Key takeaways: the label + body now sit inside a `.pilotiq-block-content` wrapper; that wrapper carries the max-width / centering (full-width via `[data-width="full"] > .pilotiq-block-content`).
37
+ - Pros & cons: the two-column grid moves from `.pilotiq-pros-cons` onto a new inner `.pilotiq-pros-cons-content`; the outer becomes the full-width anchor.
38
+
39
+ Back-compat: node structures are unchanged and parsing is tolerant of the old (unwrapped) HTML, so existing stored content loads as-is and defaults to `contained`.
40
+
3
41
  ## 3.14.0
4
42
 
5
43
  ### Minor Changes
@@ -1,17 +1,61 @@
1
1
  import { Node, Extension, mergeAttributes } from '@tiptap/core';
2
2
  import { ReactNodeViewRenderer } from '@tiptap/react';
3
3
  import { AlertNodeView } from '../react/AlertNodeView.js';
4
+ import { FaqNodeView } from '../react/FaqNodeView.js';
5
+ import { FaqItemNodeView } from '../react/FaqItemNodeView.js';
6
+ import { LabeledBlockNodeView } from '../react/LabeledBlockNodeView.js';
7
+ import { ProsConsNodeView } from '../react/ProsConsNodeView.js';
4
8
  import { coerceAlertType } from './alertVariants.js';
5
9
  // Re-exported for back-compat — the canonical definitions live in
6
10
  // `alertVariants.ts` (shared with the React NodeView + the read-side renderer).
7
11
  export { ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType } from './alertVariants.js';
8
- /** A labelled region whose body is ordinary editable content (`block+`). */
12
+ /**
13
+ * Inline content blocks — labelled, editable-in-place regions. No card, no
14
+ * popup, no border/background: each renders a small non-editable label above an
15
+ * editable body the author types straight into. This is the approved inline UX
16
+ * (it replaces the earlier card + side-panel schema blocks as the defaults).
17
+ *
18
+ * Mechanics mirror `GridExtension` (pure `renderHTML`, no React NodeView; the
19
+ * consumer owns the `pilotiq-*` CSS). The label is non-editable and parseHTML
20
+ * uses `contentElement` so it never re-parses back into the node's content on
21
+ * an HTML round-trip.
22
+ *
23
+ * Inserted via the slash menu (`SlashCommandExtension`, "Content" group) with
24
+ * `insertContent` — no custom commands needed.
25
+ */
26
+ /**
27
+ * Shared `width` attr (`contained` default | `full` bleed) — surfaced through
28
+ * the in-block gear menu on every content block, mirrored to `data-width` for
29
+ * the consumer CSS (editor NodeView + read-side `render.ts`). One definition so
30
+ * the four blocks that carry it (FAQ / Alert / labelled blocks / Pros & cons)
31
+ * never drift.
32
+ */
33
+ function widthAttribute() {
34
+ return {
35
+ default: 'contained',
36
+ parseHTML: (el) => (el.getAttribute('data-width') === 'full' ? 'full' : 'contained'),
37
+ renderHTML: (attrs) => (attrs['width'] === 'full' ? { 'data-width': 'full' } : {}),
38
+ };
39
+ }
40
+ /**
41
+ * A labelled region whose body is ordinary editable content (`block+`). The
42
+ * editor renders `LabeledBlockNodeView` (shared across every labelled block —
43
+ * label + gear menu + width); the `renderHTML` below is the serialized/read
44
+ * shape and mirrors it: outer anchor + inner `.pilotiq-block-content` wrapper.
45
+ * `label`/`cssClass` ride `addOptions()` so the shared NodeView can read them.
46
+ */
9
47
  function labeledBlock(spec) {
10
48
  return Node.create({
11
49
  name: spec.name,
12
50
  group: 'block',
13
51
  content: 'block+',
14
52
  defining: true,
53
+ addOptions() {
54
+ return { label: spec.label, cssClass: spec.cssClass };
55
+ },
56
+ addAttributes() {
57
+ return { width: widthAttribute() };
58
+ },
15
59
  parseHTML() {
16
60
  return [{ tag: `div[data-type="${spec.name}"]`, contentElement: '.pilotiq-block-body' }];
17
61
  },
@@ -19,10 +63,15 @@ function labeledBlock(spec) {
19
63
  return [
20
64
  'div',
21
65
  mergeAttributes(HTMLAttributes, { 'data-type': spec.name, class: spec.cssClass }),
22
- ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, spec.label],
23
- ['div', { class: 'pilotiq-block-body' }, 0],
66
+ ['div', { class: 'pilotiq-block-content' },
67
+ ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, spec.label],
68
+ ['div', { class: 'pilotiq-block-body' }, 0],
69
+ ],
24
70
  ];
25
71
  },
72
+ addNodeView() {
73
+ return ReactNodeViewRenderer(LabeledBlockNodeView);
74
+ },
26
75
  });
27
76
  }
28
77
  export const KeyTakeaways = labeledBlock({ name: 'keyTakeaways', label: 'Key takeaways', cssClass: 'pilotiq-key-takeaways' });
@@ -41,16 +90,19 @@ export const Faq = Node.create({
41
90
  group: 'block',
42
91
  content: 'faqItem+',
43
92
  defining: true,
93
+ // Block width — `contained` (max-width, centered) or `full` (full bleed).
94
+ // Generic block-layout attr; the in-block toggle lives in `FaqNodeView`.
95
+ addAttributes() {
96
+ return { width: widthAttribute() };
97
+ },
44
98
  parseHTML() {
45
- return [{ tag: 'div[data-type="faq"]', contentElement: '.pilotiq-block-body' }];
99
+ return [{ tag: 'div[data-type="faq"]' }];
46
100
  },
47
101
  renderHTML({ HTMLAttributes }) {
48
- return [
49
- 'div',
50
- mergeAttributes(HTMLAttributes, { 'data-type': 'faq', class: 'pilotiq-faq' }),
51
- ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, 'FAQ'],
52
- ['div', { class: 'pilotiq-block-body' }, 0],
53
- ];
102
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faq', class: 'pilotiq-faq' }), 0];
103
+ },
104
+ addNodeView() {
105
+ return ReactNodeViewRenderer(FaqNodeView);
54
106
  },
55
107
  addKeyboardShortcuts() {
56
108
  return {
@@ -125,44 +177,56 @@ export const FaqItem = Node.create({
125
177
  group: 'faqItem',
126
178
  content: 'faqQuestion faqAnswer',
127
179
  defining: true,
180
+ // Collapsed/expanded state — drives the editor accordion AND the read-side
181
+ // `<details open>`; defaults open so authored content is visible.
182
+ addAttributes() {
183
+ return {
184
+ open: {
185
+ default: true,
186
+ parseHTML: (el) => el.getAttribute('data-open') !== 'false',
187
+ renderHTML: (attrs) => ({ 'data-open': attrs['open'] === false ? 'false' : 'true' }),
188
+ },
189
+ };
190
+ },
128
191
  parseHTML() {
129
192
  return [{ tag: 'div[data-type="faqItem"]' }];
130
193
  },
131
194
  renderHTML({ HTMLAttributes }) {
132
195
  return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faqItem', class: 'pilotiq-faq-item' }), 0];
133
196
  },
197
+ addNodeView() {
198
+ return ReactNodeViewRenderer(FaqItemNodeView);
199
+ },
134
200
  });
135
201
  export const FaqQuestion = Node.create({
136
202
  name: 'faqQuestion',
137
203
  content: 'inline*',
138
204
  defining: true,
139
205
  parseHTML() {
140
- return [{ tag: 'div[data-type="faqQuestion"]', contentElement: '.pilotiq-faq-text' }];
141
- },
142
- renderHTML({ HTMLAttributes }) {
206
+ // Back-compat: the pre-accordion question wrapped its text in `.pilotiq-faq-text`.
143
207
  return [
144
- 'div',
145
- mergeAttributes(HTMLAttributes, { 'data-type': 'faqQuestion', class: 'pilotiq-faq-question' }),
146
- ['span', { class: 'pilotiq-faq-marker', contenteditable: 'false' }, 'Q'],
147
- ['span', { class: 'pilotiq-faq-text' }, 0],
208
+ { tag: 'div[data-type="faqQuestion"]', contentElement: '.pilotiq-faq-text' },
209
+ { tag: 'div[data-type="faqQuestion"]' },
148
210
  ];
149
211
  },
212
+ renderHTML({ HTMLAttributes }) {
213
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faqQuestion', class: 'pilotiq-faq-question' }), 0];
214
+ },
150
215
  });
151
216
  export const FaqAnswer = Node.create({
152
217
  name: 'faqAnswer',
153
218
  content: 'block+',
154
219
  defining: true,
155
220
  parseHTML() {
156
- return [{ tag: 'div[data-type="faqAnswer"]', contentElement: '.pilotiq-faq-body' }];
157
- },
158
- renderHTML({ HTMLAttributes }) {
221
+ // Back-compat: the pre-accordion answer wrapped its body in `.pilotiq-faq-body`.
159
222
  return [
160
- 'div',
161
- mergeAttributes(HTMLAttributes, { 'data-type': 'faqAnswer', class: 'pilotiq-faq-answer' }),
162
- ['span', { class: 'pilotiq-faq-marker', contenteditable: 'false' }, 'A'],
163
- ['div', { class: 'pilotiq-faq-body' }, 0],
223
+ { tag: 'div[data-type="faqAnswer"]', contentElement: '.pilotiq-faq-body' },
224
+ { tag: 'div[data-type="faqAnswer"]' },
164
225
  ];
165
226
  },
227
+ renderHTML({ HTMLAttributes }) {
228
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faqAnswer', class: 'pilotiq-faq-answer' }), 0];
229
+ },
166
230
  });
167
231
  const DIRECTIVE_MARKER = 0x3a; // ':'
168
232
  const DIRECTIVE_MIN_MARKERS = 3;
@@ -337,6 +401,9 @@ export const Alert = Node.create({
337
401
  parseHTML: (el) => coerceAlertType(el.getAttribute('data-alert-type')),
338
402
  renderHTML: (attrs) => ({ 'data-alert-type': coerceAlertType(attrs['type']) }),
339
403
  },
404
+ // Block width — `contained` (default) or `full` (full bleed). Same generic
405
+ // layout attr as the FAQ block; surfaced through the in-block gear menu.
406
+ width: widthAttribute(),
340
407
  icon: {
341
408
  default: '',
342
409
  parseHTML: (el) => el.getAttribute('data-icon') ?? '',
@@ -406,11 +473,28 @@ export const ProsCons = Node.create({
406
473
  group: 'block',
407
474
  content: 'prosColumn consColumn',
408
475
  defining: true,
476
+ addAttributes() {
477
+ return { width: widthAttribute() };
478
+ },
409
479
  parseHTML() {
410
- return [{ tag: 'div[data-type="prosCons"]' }];
480
+ return [
481
+ {
482
+ tag: 'div[data-type="prosCons"]',
483
+ // New shape nests the columns in `.pilotiq-pros-cons-content`; old
484
+ // stored content had them as direct children — fall back to the element.
485
+ contentElement: (el) => el.querySelector('.pilotiq-pros-cons-content') ?? el,
486
+ },
487
+ ];
411
488
  },
412
489
  renderHTML({ HTMLAttributes }) {
413
- return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'prosCons', class: 'pilotiq-pros-cons' }), 0];
490
+ return [
491
+ 'div',
492
+ mergeAttributes(HTMLAttributes, { 'data-type': 'prosCons', class: 'pilotiq-pros-cons' }),
493
+ ['div', { class: 'pilotiq-pros-cons-content' }, 0],
494
+ ];
495
+ },
496
+ addNodeView() {
497
+ return ReactNodeViewRenderer(ProsConsNodeView);
414
498
  },
415
499
  });
416
500
  function prosConsColumn(name, label, cssClass) {
@@ -1,21 +1,21 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
4
- import { Popover } from '@base-ui/react/popover';
5
4
  import { ALERT_VARIANTS, ALERT_ICON_KEYS, buildAlertIconSvg, sanitizeIconSvg, coerceAlertType, } from '../extensions/alertVariants.js';
6
- import { Palette } from './Palette.js';
5
+ import { BlockSettingsMenu } from './BlockSettingsMenu.js';
7
6
  /**
8
7
  * 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.
8
+ * panel's theme tokens. Icon in column one, editable title + body in column two.
9
+ *
10
+ * Every editable control lives behind one in-block **gear menu** (top-end gutter)
11
+ * via `BlockSettingsMenu`: Width, Type, Icon (curated inline-SVG library + a
12
+ * "Custom SVG" paste field — no `lucide-react`), and Color (custom variant only).
13
13
  *
14
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.
15
+ * through the single `<NodeViewContent>` hole; the wrapper styles them via child
16
+ * selectors so the nodes' `renderHTML` stays semantic (consumer owns the
17
+ * read-side CSS — see `render.ts`). Custom SVG is sanitized via `sanitizeIconSvg`
18
+ * before it's stored AND when it renders.
19
19
  */
20
20
  const VARIANT_BOX = {
21
21
  info: 'border-blue-500/30 bg-blue-50/40 dark:bg-blue-950/20',
@@ -50,18 +50,15 @@ const COLOR_SWATCHES = [
50
50
  function IconSlot({ svg, className }) {
51
51
  return (_jsx("span", { className: 'inline-flex shrink-0 [&>svg]:size-full ' + (className ?? ''), dangerouslySetInnerHTML: { __html: svg } }));
52
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
53
  export function AlertNodeView({ node, updateAttributes, editor }) {
57
54
  const variant = coerceAlertType(node.attrs['type']);
58
55
  const iconKey = String(node.attrs['icon'] ?? '');
59
56
  const iconSvg = String(node.attrs['iconSvg'] ?? '');
60
57
  const color = String(node.attrs['color'] ?? '');
58
+ const width = node.attrs['width'] === 'full' ? 'full' : 'contained';
61
59
  const editable = editor.isEditable;
62
60
  const iconFull = buildAlertIconSvg(iconKey, iconSvg, variant);
63
61
  const tinted = variant === 'custom' && color !== '';
64
- const [iconOpen, setIconOpen] = useState(false);
65
62
  const [svgMode, setSvgMode] = useState(false);
66
63
  const [svgDraft, setSvgDraft] = useState('');
67
64
  const [svgError, setSvgError] = useState(false);
@@ -71,8 +68,8 @@ export function AlertNodeView({ node, updateAttributes, editor }) {
71
68
  backgroundColor: `color-mix(in srgb, ${color} 8%, transparent)`,
72
69
  }
73
70
  : undefined;
74
- const closeIconPicker = () => { setIconOpen(false); setSvgMode(false); setSvgDraft(''); setSvgError(false); };
75
- const pickIcon = (key) => { updateAttributes({ icon: key, iconSvg: '' }); closeIconPicker(); };
71
+ const resetSvg = () => { setSvgMode(false); setSvgDraft(''); setSvgError(false); };
72
+ const pickIcon = (key) => { updateAttributes({ icon: key, iconSvg: '' }); resetSvg(); };
76
73
  const applyCustomSvg = () => {
77
74
  const clean = sanitizeIconSvg(svgDraft);
78
75
  if (!clean) {
@@ -80,13 +77,61 @@ export function AlertNodeView({ node, updateAttributes, editor }) {
80
77
  return;
81
78
  }
82
79
  updateAttributes({ iconSvg: clean, icon: '' });
83
- closeIconPicker();
80
+ resetSvg();
84
81
  };
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" })] }));
82
+ // ── Gear-menu settings (editable mode only) ──────────────────────────────
83
+ const iconContent = !svgMode ? (_jsxs("div", { className: "p-1", 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 ' +
84
+ (!iconSvg && key === iconKey ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'), children: _jsx(IconSlot, { svg: buildAlertIconSvg(key, '', variant), className: "size-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: '' }); resetSvg(); }, 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 w-60 flex-col gap-2 p-1", 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 ' +
85
+ (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" })] })] }));
86
+ const colorContent = (_jsxs("div", { className: "flex w-44 flex-col gap-2 p-1", children: [_jsx("div", { className: "grid grid-cols-5 gap-1", children: COLOR_SWATCHES.map((sw) => (_jsx("button", { type: "button", title: sw.label, "aria-label": sw.label, onClick: () => updateAttributes({ color: sw.value }), className: 'size-6 rounded-full border border-border/60 transition-transform hover:scale-110 ' +
87
+ (color === sw.value ? 'ring-2 ring-ring ring-offset-1 ring-offset-popover' : ''), style: { background: sw.value } }, sw.value))) }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs("label", { className: "flex flex-1 cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: [_jsx("input", { type: "color", value: /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#3b82f6', onChange: (e) => updateAttributes({ color: e.target.value }), className: "size-4 cursor-pointer rounded border-0 bg-transparent p-0" }), "Custom\u2026"] }), _jsx("button", { type: "button", onClick: () => updateAttributes({ color: '' }), className: "rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "No color" })] })] }));
88
+ const settings = [
89
+ {
90
+ kind: 'select',
91
+ key: 'width',
92
+ label: 'Width',
93
+ value: width,
94
+ options: [
95
+ { value: 'contained', label: 'Contained' },
96
+ { value: 'full', label: 'Full width' },
97
+ ],
98
+ onChange: (v) => updateAttributes({ width: v }),
99
+ },
100
+ {
101
+ kind: 'select',
102
+ key: 'type',
103
+ label: 'Type',
104
+ value: variant,
105
+ options: ALERT_VARIANTS.map((v) => ({
106
+ value: v,
107
+ label: VARIANT_LABEL[v],
108
+ icon: _jsx("span", { className: VARIANT_ICON_COLOR[v], children: _jsx(IconSlot, { svg: buildAlertIconSvg('', '', v), className: "size-4" }) }),
109
+ })),
110
+ onChange: (v) => updateAttributes({ type: v }),
111
+ },
112
+ {
113
+ kind: 'custom',
114
+ key: 'icon',
115
+ label: 'Icon',
116
+ hint: _jsx("span", { className: tinted ? '' : VARIANT_ICON_COLOR[variant], style: tinted ? { color } : undefined, children: _jsx(IconSlot, { svg: iconFull, className: "size-4" }) }),
117
+ content: iconContent,
118
+ },
119
+ ...(variant === 'custom'
120
+ ? [{
121
+ kind: 'custom',
122
+ key: 'color',
123
+ label: 'Color',
124
+ hint: _jsx("span", { className: "size-3.5 rounded-full border border-border/60", style: { background: color || 'var(--color-muted-foreground)' } }),
125
+ content: colorContent,
126
+ }]
127
+ : []),
128
+ ];
129
+ // Two layers (mirrors the FAQ block): the outer `.pilotiq-alert` stays full
130
+ // width and is the stable anchor for the gear menu; the inner
131
+ // `.pilotiq-alert-box` carries the visual box + the contained/full width, so
132
+ // toggling width never shifts the gear.
133
+ return (_jsxs(NodeViewWrapper, { "data-type": "alert", "data-alert-type": variant, "data-width": width, className: "pilotiq-alert relative my-3", children: [editable && (_jsx(BlockSettingsMenu, { settings: settings, label: "Alert settings", hoverClass: "[.pilotiq-alert:hover_&]:opacity-100" })), _jsxs("div", { role: "note", style: boxStyle, className: 'pilotiq-alert-box grid grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-lg border px-4 py-3 text-sm ' +
134
+ '[&_.pilotiq-alert-title]:font-medium [&_.pilotiq-alert-title]:leading-tight ' +
135
+ '[&_.pilotiq-alert-description]:text-muted-foreground [&_.pilotiq-alert-description_p]:my-0 ' +
136
+ VARIANT_BOX[variant], children: [_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" }) }), _jsx(NodeViewContent, { className: "col-start-2 min-w-0" })] })] }));
92
137
  }
@@ -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
+ }
@@ -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
@@ -145,14 +145,14 @@ function renderNode(node, opts) {
145
145
  case 'detailsContent': return renderChildren(n, opts);
146
146
  case 'grid': return renderGrid(n, opts);
147
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>`;
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>`;
154
154
  case 'alert': return renderAlertNode(n, opts);
155
- case 'prosCons': return `<div class="pilotiq-pros-cons">${renderChildren(n, opts)}</div>`;
155
+ case 'prosCons': return renderProsCons(n, opts);
156
156
  case 'prosColumn': return labeledBlockHtml('pilotiq-pros', 'Pros', n, opts);
157
157
  case 'consColumn': return labeledBlockHtml('pilotiq-cons', 'Cons', n, opts);
158
158
  case 'mergeTag': return renderMergeTag(n, opts);
@@ -362,11 +362,42 @@ function clampGridColumnsForRender(raw) {
362
362
  // Mirrors the editor's `renderHTML` (extensions/contentBlocks.ts): a small
363
363
  // label above an editable body. Consumer owns the `pilotiq-*` CSS. Covers
364
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>`);
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>`);
370
401
  }
371
402
  // shadcn-style callout: icon + editable title + description. Mirrors the
372
403
  // editor NodeView (`AlertNodeView`); `coerceAlertType` / `ALERT_ICON_INNER` /
@@ -377,6 +408,7 @@ function renderAlertNode(n, opts) {
377
408
  const icon = typeof n.attrs?.['icon'] === 'string' ? n.attrs['icon'] : '';
378
409
  const iconSvg = typeof n.attrs?.['iconSvg'] === 'string' ? n.attrs['iconSvg'] : '';
379
410
  const color = typeof n.attrs?.['color'] === 'string' ? n.attrs['color'] : '';
411
+ const width = n.attrs?.['width'] === 'full' ? ' data-width="full"' : '';
380
412
  const kids = Array.isArray(n.content) ? n.content : [];
381
413
  const title = kids.find((k) => k?.type === 'alertTitle');
382
414
  const body = kids.find((k) => k?.type === 'alertBody');
@@ -390,10 +422,15 @@ function renderAlertNode(n, opts) {
390
422
  ? ` style="border-color:color-mix(in srgb,${color} 35%,transparent);background-color:color-mix(in srgb,${color} 8%,transparent)"`
391
423
  : '';
392
424
  const iconStyle = tinted ? ` style="color:${color}"` : '';
393
- return (`<div class="pilotiq-alert pilotiq-alert-${type}" data-alert-type="${type}" role="note"${style}>` +
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}>` +
394
430
  `<span class="pilotiq-alert-icon" aria-hidden="true"${iconStyle}>${buildAlertIconSvg(icon, iconSvg, type)}</span>` +
395
431
  `<div class="pilotiq-alert-title">${titleHtml}</div>` +
396
432
  `<div class="pilotiq-alert-description">${bodyHtml}</div>` +
433
+ `</div>` +
397
434
  `</div>`);
398
435
  }
399
436
  // Only emit a color into inline CSS when it's a plain literal (hex / rgb(a) /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.14.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": {