@pilotiq/tiptap 3.14.0 → 3.15.1

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,58 @@
1
1
  # @pilotiq/tiptap
2
2
 
3
+ ## 3.15.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 2bc1e54: Slash menu now ranks results by relevance instead of definition order.
8
+
9
+ A query that matches an entry's **label** (exactly, by prefix, or by word) now
10
+ ranks above an entry that only mentions the word in its `searchKey`. Previously
11
+ the menu was a plain substring filter that preserved definition order, so typing
12
+ `/summary` surfaced **Collapsible block** first — its `searchKey` lists
13
+ "summary" and it's defined before the Summary block — and pressing Enter
14
+ inserted the wrong block. The matched set is unchanged (every entry that matched
15
+ before still matches); only the ordering improves. Ties keep their original menu
16
+ order.
17
+
18
+ ## 3.15.0
19
+
20
+ ### Minor Changes
21
+
22
+ - c3816ae: In-block content-block controls now live behind a single **gear menu**.
23
+
24
+ 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.
25
+
26
+ - **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.
27
+ - **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.
28
+ - **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`.
29
+ - **FAQ** moves its width toggle into the same gear menu.
30
+
31
+ Back-compat: node structures are unchanged; existing Alert/FAQ content loads as-is (alerts default to `contained`).
32
+
33
+ - b6b28bd: The **FAQ** content block is now a collapsible **accordion**.
34
+
35
+ - **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.
36
+ - **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.
37
+ - Dropped the old "Q"/"A" markers in favor of the accordion chrome.
38
+ - **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.
39
+
40
+ 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.
41
+
42
+ - af935e7: The remaining inline content blocks (**Summary**, **Key takeaways**, **Pros & cons**) now use the React NodeView + gear-menu pattern, matching Alert and FAQ.
43
+
44
+ - Each gains an in-block **gear menu** with a **Width** setting (`contained` / `full`), surfaced read-side as `data-width`.
45
+ - **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()`.
46
+ - **Pros & cons** gets its own `ProsConsNodeView`; the gear lives on the container, the two columns keep their plain label markup.
47
+ - 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).
48
+
49
+ **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):
50
+
51
+ - 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`).
52
+ - 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.
53
+
54
+ 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`.
55
+
3
56
  ## 3.14.0
4
57
 
5
58
  ### Minor Changes
@@ -298,7 +298,46 @@ export function buildSlashItems(blocks, mergeTags, query, insert) {
298
298
  if (!query)
299
299
  return all;
300
300
  const needle = query.toLowerCase();
301
- return all.filter((item) => `${item.label} ${item.searchKey} ${item.group ?? ''}`.toLowerCase().includes(needle));
301
+ // Membership is unchanged from a plain substring match (an item shows iff the
302
+ // query appears anywhere in its label / searchKey / group). On top of that we
303
+ // RANK by relevance so a query that hits an item's LABEL beats one that only
304
+ // mentions the word in some other item's searchKey — otherwise "/summary"
305
+ // surfaces "Collapsible block" first (its searchKey lists "summary") instead
306
+ // of the Summary block. Stable: equal scores keep their original menu order.
307
+ return all
308
+ .map((item, i) => ({ item, i, score: slashRelevance(item, needle) }))
309
+ .filter((m) => m.score > 0)
310
+ .sort((a, b) => b.score - a.score || a.i - b.i)
311
+ .map((m) => m.item);
312
+ }
313
+ /**
314
+ * Score a slash item against the lowercased query. Higher = more relevant.
315
+ * Returns 0 when nothing matches (the item is filtered out). The tiers mirror
316
+ * how people expect a command palette to rank: an exact/prefix LABEL hit wins,
317
+ * then any label word, then label substring, then searchKey words, then a bare
318
+ * searchKey/group substring. `needle` is assumed already lowercased.
319
+ */
320
+ function slashRelevance(item, needle) {
321
+ const label = item.label.toLowerCase();
322
+ const labelWords = label.split(/\s+/).filter(Boolean);
323
+ if (label === needle)
324
+ return 7;
325
+ if (label.startsWith(needle))
326
+ return 6;
327
+ if (labelWords.some((w) => w.startsWith(needle)))
328
+ return 5;
329
+ if (label.includes(needle))
330
+ return 4;
331
+ const searchWords = item.searchKey.toLowerCase().split(/\s+/).filter(Boolean);
332
+ if (searchWords.some((w) => w === needle))
333
+ return 3;
334
+ if (searchWords.some((w) => w.startsWith(needle)))
335
+ return 2;
336
+ // Matched only via a searchKey / group substring (no word boundary) — still
337
+ // shown, but ranked below every label and word-boundary hit.
338
+ if (`${item.searchKey} ${item.group ?? ''}`.toLowerCase().includes(needle))
339
+ return 1;
340
+ return 0;
302
341
  }
303
342
  function defaultsFromSchema(block) {
304
343
  const out = {};
@@ -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,37 @@
1
+ import { type ReactNode, type ReactElement } from 'react';
2
+ import { type NodeViewProps } from '@tiptap/react';
3
+ import { type BlockSetting } from './BlockSettingsMenu.js';
4
+ /**
5
+ * Shared chrome for the width-aware content blocks (labelled blocks + Pros &
6
+ * cons). Every one of them renders the same two-layer shell — a full-width
7
+ * outer anchor (`cssClass`, stable so the gear doesn't jump on a width toggle)
8
+ * hosting the in-block **gear menu**, with the block's own content as
9
+ * `children`. This component owns the parts that are byte-identical across
10
+ * those blocks so they can't drift:
11
+ *
12
+ * - read the `width` attr → `data-width`,
13
+ * - build the **Width** setting (contained / full) for the gear,
14
+ * - render the `NodeViewWrapper` + the hover-revealed `BlockSettingsMenu`
15
+ * (editor-only), deriving the hover selector from `cssClass`.
16
+ *
17
+ * The caller supplies only what actually varies: the anchor `cssClass`, the
18
+ * gear's accessible `settingsLabel`, the inner content, and (optionally) any
19
+ * block-specific settings to show alongside Width.
20
+ *
21
+ * `data-type` always mirrors the node's own type name — every block that uses
22
+ * this shell wants exactly that.
23
+ */
24
+ export interface BlockGearShellProps {
25
+ node: NodeViewProps['node'];
26
+ editor: NodeViewProps['editor'];
27
+ updateAttributes: NodeViewProps['updateAttributes'];
28
+ /** Outer anchor class — also drives the gear's hover-reveal selector. */
29
+ cssClass: string;
30
+ /** Accessible label for the gear trigger, e.g. `"Summary settings"`. */
31
+ settingsLabel: string;
32
+ /** Block-specific settings shown after Width (none today; future-proofing). */
33
+ extraSettings?: BlockSetting[];
34
+ children: ReactNode;
35
+ }
36
+ export declare function BlockGearShell({ node, editor, updateAttributes, cssClass, settingsLabel, extraSettings, children, }: BlockGearShellProps): ReactElement;
37
+ //# sourceMappingURL=BlockGearShell.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { NodeViewWrapper } from '@tiptap/react';
3
+ import { BlockSettingsMenu } from './BlockSettingsMenu.js';
4
+ export function BlockGearShell({ node, editor, updateAttributes, cssClass, settingsLabel, extraSettings, children, }) {
5
+ const width = node.attrs['width'] === 'full' ? 'full' : 'contained';
6
+ const settings = [
7
+ {
8
+ kind: 'select',
9
+ key: 'width',
10
+ label: 'Width',
11
+ value: width,
12
+ options: [
13
+ { value: 'contained', label: 'Contained' },
14
+ { value: 'full', label: 'Full width' },
15
+ ],
16
+ onChange: (w) => updateAttributes({ width: w }),
17
+ },
18
+ ...(extraSettings ?? []),
19
+ ];
20
+ return (_jsxs(NodeViewWrapper, { "data-type": node.type.name, "data-width": width, className: cssClass + ' relative', children: [editor.isEditable && (_jsx(BlockSettingsMenu, { settings: settings, label: settingsLabel, hoverClass: '[.' + cssClass + ':hover_&]:opacity-100' })), children] }));
21
+ }
@@ -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,16 @@
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
+ * The two-layer shell (full-width anchor + gear + the inner
11
+ * `.pilotiq-block-content` that carries the max-width / centering) lives in the
12
+ * shared `BlockGearShell`, alongside the Width gear setting. This component
13
+ * only supplies the label + body content hole.
14
+ */
15
+ export declare function LabeledBlockNodeView({ node, updateAttributes, editor, extension }: NodeViewProps): ReactElement;
16
+ //# sourceMappingURL=LabeledBlockNodeView.d.ts.map
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { NodeViewContent } from '@tiptap/react';
3
+ import { BlockGearShell } from './BlockGearShell.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
+ * The two-layer shell (full-width anchor + gear + the inner
12
+ * `.pilotiq-block-content` that carries the max-width / centering) lives in the
13
+ * shared `BlockGearShell`, alongside the Width gear setting. This component
14
+ * only supplies the label + body content hole.
15
+ */
16
+ export function LabeledBlockNodeView({ node, updateAttributes, editor, extension }) {
17
+ const { label, cssClass } = extension.options;
18
+ return (_jsx(BlockGearShell, { node: node, editor: editor, updateAttributes: updateAttributes, cssClass: cssClass, settingsLabel: label + ' settings', children: _jsxs("div", { className: "pilotiq-block-content", children: [_jsx("div", { className: "pilotiq-block-label", contentEditable: false, children: label }), _jsx(NodeViewContent, { className: "pilotiq-block-body" })] }) }));
19
+ }
@@ -0,0 +1,13 @@
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. The two-layer shell (full-width anchor + gear) lives in the
8
+ * shared `BlockGearShell`; this component only supplies the inner
9
+ * `.pilotiq-pros-cons-content`, which carries the two-column grid + max-width /
10
+ * centering.
11
+ */
12
+ export declare function ProsConsNodeView({ node, updateAttributes, editor }: NodeViewProps): ReactElement;
13
+ //# sourceMappingURL=ProsConsNodeView.d.ts.map
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { NodeViewContent } from '@tiptap/react';
3
+ import { BlockGearShell } from './BlockGearShell.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. The two-layer shell (full-width anchor + gear) lives in the
9
+ * shared `BlockGearShell`; this component only supplies the inner
10
+ * `.pilotiq-pros-cons-content`, which carries the two-column grid + max-width /
11
+ * centering.
12
+ */
13
+ export function ProsConsNodeView({ node, updateAttributes, editor }) {
14
+ return (_jsx(BlockGearShell, { node: node, editor: editor, updateAttributes: updateAttributes, cssClass: "pilotiq-pros-cons", settingsLabel: "Pros & cons settings", children: _jsx(NodeViewContent, { className: "pilotiq-pros-cons-content" }) }));
15
+ }
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.1",
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": {