@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 +53 -0
- package/dist/extensions/SlashCommandExtension.js +40 -1
- package/dist/extensions/contentBlocks.js +110 -26
- package/dist/react/AlertNodeView.js +70 -25
- package/dist/react/BlockGearShell.d.ts +37 -0
- package/dist/react/BlockGearShell.js +21 -0
- package/dist/react/BlockSettingsMenu.d.ts +47 -0
- package/dist/react/BlockSettingsMenu.js +16 -0
- package/dist/react/FaqItemNodeView.d.ts +17 -0
- package/dist/react/FaqItemNodeView.js +20 -0
- package/dist/react/FaqNodeView.d.ts +11 -0
- package/dist/react/FaqNodeView.js +27 -0
- package/dist/react/LabeledBlockNodeView.d.ts +16 -0
- package/dist/react/LabeledBlockNodeView.js +19 -0
- package/dist/react/ProsConsNodeView.d.ts +13 -0
- package/dist/react/ProsConsNodeView.js +15 -0
- package/dist/render.js +50 -13
- package/package.json +1 -1
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
|
-
|
|
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
|
-
/**
|
|
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-
|
|
23
|
-
|
|
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"]'
|
|
99
|
+
return [{ tag: 'div[data-type="faq"]' }];
|
|
46
100
|
},
|
|
47
101
|
renderHTML({ HTMLAttributes }) {
|
|
48
|
-
return [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
141
|
-
},
|
|
142
|
-
renderHTML({ HTMLAttributes }) {
|
|
206
|
+
// Back-compat: the pre-accordion question wrapped its text in `.pilotiq-faq-text`.
|
|
143
207
|
return [
|
|
144
|
-
'div',
|
|
145
|
-
|
|
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
|
-
|
|
157
|
-
},
|
|
158
|
-
renderHTML({ HTMLAttributes }) {
|
|
221
|
+
// Back-compat: the pre-accordion answer wrapped its body in `.pilotiq-faq-body`.
|
|
159
222
|
return [
|
|
160
|
-
'div',
|
|
161
|
-
|
|
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 [
|
|
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 [
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
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
|
-
*
|
|
17
|
-
* read-side CSS — see `render.ts`). Custom SVG is sanitized via
|
|
18
|
-
*
|
|
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
|
|
75
|
-
const pickIcon = (key) => { updateAttributes({ icon: key, iconSvg: '' });
|
|
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
|
-
|
|
80
|
+
resetSvg();
|
|
84
81
|
};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
151
|
-
case 'faqItem': return
|
|
152
|
-
case 'faqQuestion': return `<
|
|
153
|
-
case 'faqAnswer': return `<div class="pilotiq-faq-answer"
|
|
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
|
|
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
|
-
|
|
367
|
-
`<div class="pilotiq-block-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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) /
|