@pilotiq/tiptap 3.13.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/dist/extensions/SlashCommandExtension.js +13 -18
- package/dist/extensions/alertVariants.d.ts +44 -0
- package/dist/extensions/alertVariants.js +132 -0
- package/dist/extensions/contentBlocks.d.ts +19 -4
- package/dist/extensions/contentBlocks.js +334 -40
- package/dist/react/AlertNodeView.d.ts +4 -0
- package/dist/react/AlertNodeView.js +137 -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 +18 -0
- package/dist/react/LabeledBlockNodeView.js +35 -0
- package/dist/react/MarkdownEditor.js +19 -0
- package/dist/react/ProsConsNodeView.d.ts +14 -0
- package/dist/react/ProsConsNodeView.js +30 -0
- package/dist/render.js +86 -21
- package/package.json +4 -2
|
@@ -1,11 +1,61 @@
|
|
|
1
1
|
import { Node, Extension, mergeAttributes } from '@tiptap/core';
|
|
2
|
-
|
|
2
|
+
import { ReactNodeViewRenderer } from '@tiptap/react';
|
|
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';
|
|
8
|
+
import { coerceAlertType } from './alertVariants.js';
|
|
9
|
+
// Re-exported for back-compat — the canonical definitions live in
|
|
10
|
+
// `alertVariants.ts` (shared with the React NodeView + the read-side renderer).
|
|
11
|
+
export { ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType } from './alertVariants.js';
|
|
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
|
+
*/
|
|
3
47
|
function labeledBlock(spec) {
|
|
4
48
|
return Node.create({
|
|
5
49
|
name: spec.name,
|
|
6
50
|
group: 'block',
|
|
7
51
|
content: 'block+',
|
|
8
52
|
defining: true,
|
|
53
|
+
addOptions() {
|
|
54
|
+
return { label: spec.label, cssClass: spec.cssClass };
|
|
55
|
+
},
|
|
56
|
+
addAttributes() {
|
|
57
|
+
return { width: widthAttribute() };
|
|
58
|
+
},
|
|
9
59
|
parseHTML() {
|
|
10
60
|
return [{ tag: `div[data-type="${spec.name}"]`, contentElement: '.pilotiq-block-body' }];
|
|
11
61
|
},
|
|
@@ -13,10 +63,15 @@ function labeledBlock(spec) {
|
|
|
13
63
|
return [
|
|
14
64
|
'div',
|
|
15
65
|
mergeAttributes(HTMLAttributes, { 'data-type': spec.name, class: spec.cssClass }),
|
|
16
|
-
['div', { class: 'pilotiq-block-
|
|
17
|
-
|
|
66
|
+
['div', { class: 'pilotiq-block-content' },
|
|
67
|
+
['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, spec.label],
|
|
68
|
+
['div', { class: 'pilotiq-block-body' }, 0],
|
|
69
|
+
],
|
|
18
70
|
];
|
|
19
71
|
},
|
|
72
|
+
addNodeView() {
|
|
73
|
+
return ReactNodeViewRenderer(LabeledBlockNodeView);
|
|
74
|
+
},
|
|
20
75
|
});
|
|
21
76
|
}
|
|
22
77
|
export const KeyTakeaways = labeledBlock({ name: 'keyTakeaways', label: 'Key takeaways', cssClass: 'pilotiq-key-takeaways' });
|
|
@@ -35,16 +90,19 @@ export const Faq = Node.create({
|
|
|
35
90
|
group: 'block',
|
|
36
91
|
content: 'faqItem+',
|
|
37
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
|
+
},
|
|
38
98
|
parseHTML() {
|
|
39
|
-
return [{ tag: 'div[data-type="faq"]'
|
|
99
|
+
return [{ tag: 'div[data-type="faq"]' }];
|
|
40
100
|
},
|
|
41
101
|
renderHTML({ HTMLAttributes }) {
|
|
42
|
-
return [
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
['div', { class: 'pilotiq-block-body' }, 0],
|
|
47
|
-
];
|
|
102
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faq', class: 'pilotiq-faq' }), 0];
|
|
103
|
+
},
|
|
104
|
+
addNodeView() {
|
|
105
|
+
return ReactNodeViewRenderer(FaqNodeView);
|
|
48
106
|
},
|
|
49
107
|
addKeyboardShortcuts() {
|
|
50
108
|
return {
|
|
@@ -119,57 +177,223 @@ export const FaqItem = Node.create({
|
|
|
119
177
|
group: 'faqItem',
|
|
120
178
|
content: 'faqQuestion faqAnswer',
|
|
121
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
|
+
},
|
|
122
191
|
parseHTML() {
|
|
123
192
|
return [{ tag: 'div[data-type="faqItem"]' }];
|
|
124
193
|
},
|
|
125
194
|
renderHTML({ HTMLAttributes }) {
|
|
126
195
|
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faqItem', class: 'pilotiq-faq-item' }), 0];
|
|
127
196
|
},
|
|
197
|
+
addNodeView() {
|
|
198
|
+
return ReactNodeViewRenderer(FaqItemNodeView);
|
|
199
|
+
},
|
|
128
200
|
});
|
|
129
201
|
export const FaqQuestion = Node.create({
|
|
130
202
|
name: 'faqQuestion',
|
|
131
203
|
content: 'inline*',
|
|
132
204
|
defining: true,
|
|
133
205
|
parseHTML() {
|
|
134
|
-
|
|
135
|
-
},
|
|
136
|
-
renderHTML({ HTMLAttributes }) {
|
|
206
|
+
// Back-compat: the pre-accordion question wrapped its text in `.pilotiq-faq-text`.
|
|
137
207
|
return [
|
|
138
|
-
'div',
|
|
139
|
-
|
|
140
|
-
['span', { class: 'pilotiq-faq-marker', contenteditable: 'false' }, 'Q'],
|
|
141
|
-
['span', { class: 'pilotiq-faq-text' }, 0],
|
|
208
|
+
{ tag: 'div[data-type="faqQuestion"]', contentElement: '.pilotiq-faq-text' },
|
|
209
|
+
{ tag: 'div[data-type="faqQuestion"]' },
|
|
142
210
|
];
|
|
143
211
|
},
|
|
212
|
+
renderHTML({ HTMLAttributes }) {
|
|
213
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faqQuestion', class: 'pilotiq-faq-question' }), 0];
|
|
214
|
+
},
|
|
144
215
|
});
|
|
145
216
|
export const FaqAnswer = Node.create({
|
|
146
217
|
name: 'faqAnswer',
|
|
147
218
|
content: 'block+',
|
|
148
219
|
defining: true,
|
|
149
220
|
parseHTML() {
|
|
150
|
-
|
|
151
|
-
},
|
|
152
|
-
renderHTML({ HTMLAttributes }) {
|
|
221
|
+
// Back-compat: the pre-accordion answer wrapped its body in `.pilotiq-faq-body`.
|
|
153
222
|
return [
|
|
154
|
-
'div',
|
|
155
|
-
|
|
156
|
-
['span', { class: 'pilotiq-faq-marker', contenteditable: 'false' }, 'A'],
|
|
157
|
-
['div', { class: 'pilotiq-faq-body' }, 0],
|
|
223
|
+
{ tag: 'div[data-type="faqAnswer"]', contentElement: '.pilotiq-faq-body' },
|
|
224
|
+
{ tag: 'div[data-type="faqAnswer"]' },
|
|
158
225
|
];
|
|
159
226
|
},
|
|
227
|
+
renderHTML({ HTMLAttributes }) {
|
|
228
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faqAnswer', class: 'pilotiq-faq-answer' }), 0];
|
|
229
|
+
},
|
|
160
230
|
});
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
231
|
+
const DIRECTIVE_MARKER = 0x3a; // ':'
|
|
232
|
+
const DIRECTIVE_MIN_MARKERS = 3;
|
|
233
|
+
/** Parse a directive info string (`alert{type=warning}`) into an attrs map. */
|
|
234
|
+
export function parseDirectiveAttrs(info) {
|
|
235
|
+
const out = {};
|
|
236
|
+
const m = /\{([^}]*)\}/.exec(info);
|
|
237
|
+
if (!m || !m[1])
|
|
238
|
+
return out;
|
|
239
|
+
for (const pair of m[1].split(/[\s,]+/)) {
|
|
240
|
+
const eq = pair.indexOf('=');
|
|
241
|
+
if (eq === -1)
|
|
242
|
+
continue;
|
|
243
|
+
out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
167
246
|
}
|
|
247
|
+
/** The directive name — the token before the first `{` or whitespace. */
|
|
248
|
+
function directiveName(info) {
|
|
249
|
+
return info.trim().split(/[\s{]/, 1)[0] ?? '';
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The directive title — the free text after `{attrs}` on the opening fence
|
|
253
|
+
* line (Docusaurus-admonition style: `:::alert{type=warning} Heads up`).
|
|
254
|
+
* Empty when omitted.
|
|
255
|
+
*/
|
|
256
|
+
function directiveTitle(info) {
|
|
257
|
+
const close = info.lastIndexOf('}');
|
|
258
|
+
const rest = close === -1 ? info.replace(/^\S+/, '') : info.slice(close + 1);
|
|
259
|
+
return rest.trim();
|
|
260
|
+
}
|
|
261
|
+
/** Minimal HTML escape for text interpolated into the directive's HTML. */
|
|
262
|
+
function escapeDirectiveHtml(s) {
|
|
263
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Register the `:::alert{…}` block rule + HTML renderers on a markdown-it
|
|
267
|
+
* instance. Idempotent per instance. Wired as `Alert`'s `markdown.parse.setup`.
|
|
268
|
+
*/
|
|
269
|
+
export function setupAlertDirective(md) {
|
|
270
|
+
if (md.__pilotiqAlertDirective)
|
|
271
|
+
return;
|
|
272
|
+
md.__pilotiqAlertDirective = true;
|
|
273
|
+
md.block.ruler.before('fence', 'pilotiqAlert', (state, startLine, endLine, silent) => {
|
|
274
|
+
let pos = state.bMarks[startLine] + state.tShift[startLine];
|
|
275
|
+
let max = state.eMarks[startLine];
|
|
276
|
+
if (state.src.charCodeAt(pos) !== DIRECTIVE_MARKER)
|
|
277
|
+
return false;
|
|
278
|
+
const memStart = pos;
|
|
279
|
+
while (pos <= max && state.src.charCodeAt(pos) === DIRECTIVE_MARKER)
|
|
280
|
+
pos++;
|
|
281
|
+
const markerCount = pos - memStart;
|
|
282
|
+
if (markerCount < DIRECTIVE_MIN_MARKERS)
|
|
283
|
+
return false;
|
|
284
|
+
const info = state.src.slice(pos, max).trim();
|
|
285
|
+
if (directiveName(info) !== 'alert')
|
|
286
|
+
return false;
|
|
287
|
+
if (silent)
|
|
288
|
+
return true;
|
|
289
|
+
// Scan for the closing fence (>= as many markers, nothing but space after).
|
|
290
|
+
let nextLine = startLine;
|
|
291
|
+
let autoClosed = false;
|
|
292
|
+
for (;;) {
|
|
293
|
+
nextLine++;
|
|
294
|
+
if (nextLine >= endLine)
|
|
295
|
+
break;
|
|
296
|
+
pos = state.bMarks[nextLine] + state.tShift[nextLine];
|
|
297
|
+
max = state.eMarks[nextLine];
|
|
298
|
+
if (state.src.charCodeAt(pos) !== DIRECTIVE_MARKER)
|
|
299
|
+
continue;
|
|
300
|
+
if (state.sCount[nextLine] - state.blkIndent >= 4)
|
|
301
|
+
continue;
|
|
302
|
+
let closePos = pos;
|
|
303
|
+
while (closePos <= max && state.src.charCodeAt(closePos) === DIRECTIVE_MARKER)
|
|
304
|
+
closePos++;
|
|
305
|
+
if (closePos - pos < markerCount)
|
|
306
|
+
continue;
|
|
307
|
+
if (state.src.slice(closePos, max).trim() !== '')
|
|
308
|
+
continue;
|
|
309
|
+
autoClosed = true;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
const oldParent = state.parentType;
|
|
313
|
+
const oldLineMax = state.lineMax;
|
|
314
|
+
state.parentType = 'pilotiqAlert';
|
|
315
|
+
state.lineMax = nextLine;
|
|
316
|
+
const attrs = parseDirectiveAttrs(info);
|
|
317
|
+
let token = state.push('pilotiq_alert_open', 'div', 1);
|
|
318
|
+
token.markup = ':'.repeat(markerCount);
|
|
319
|
+
token.block = true;
|
|
320
|
+
token.info = info;
|
|
321
|
+
token.meta = { type: attrs['type'], icon: attrs['icon'], color: attrs['color'], title: directiveTitle(info) };
|
|
322
|
+
token.map = [startLine, nextLine];
|
|
323
|
+
state.md.block.tokenize(state, startLine + 1, nextLine);
|
|
324
|
+
token = state.push('pilotiq_alert_close', 'div', -1);
|
|
325
|
+
token.markup = ':'.repeat(markerCount);
|
|
326
|
+
token.block = true;
|
|
327
|
+
state.parentType = oldParent;
|
|
328
|
+
state.lineMax = oldLineMax;
|
|
329
|
+
state.line = nextLine + (autoClosed ? 1 : 0);
|
|
330
|
+
return true;
|
|
331
|
+
}, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
|
|
332
|
+
// Open emits the alert wrapper + the (closed) title div + the OPEN body div;
|
|
333
|
+
// the tokenized content renders into the body; close shuts body + wrapper.
|
|
334
|
+
// The data-types match `Alert`/`AlertTitle`/`AlertBody`'s `parseHTML`, so the
|
|
335
|
+
// HTML-based tiptap-markdown parse path revives the full node structure.
|
|
336
|
+
md.renderer.rules['pilotiq_alert_open'] = (tokens, idx) => {
|
|
337
|
+
const meta = tokens[idx].meta ?? {};
|
|
338
|
+
const type = coerceAlertType(meta.type);
|
|
339
|
+
const title = escapeDirectiveHtml(String(meta.title ?? ''));
|
|
340
|
+
const icon = meta.icon ? ` data-icon="${escapeDirectiveHtml(String(meta.icon))}"` : '';
|
|
341
|
+
const color = meta.color ? ` data-color="${escapeDirectiveHtml(String(meta.color))}"` : '';
|
|
342
|
+
return (`<div data-type="alert" class="pilotiq-alert pilotiq-alert-${type}" data-alert-type="${type}"${icon}${color}>` +
|
|
343
|
+
`<div data-type="alertTitle" class="pilotiq-alert-title">${title}</div>` +
|
|
344
|
+
`<div data-type="alertBody" class="pilotiq-alert-description">\n`);
|
|
345
|
+
};
|
|
346
|
+
md.renderer.rules['pilotiq_alert_close'] = () => '</div></div>\n';
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* `tiptap-markdown` serializer for the Alert node → `:::alert{type=…} Title`.
|
|
350
|
+
* The title rides the opening fence line (admonition style); the body markdown
|
|
351
|
+
* sits between the fences.
|
|
352
|
+
*/
|
|
353
|
+
export function serializeAlertMarkdown(state, node) {
|
|
354
|
+
const type = coerceAlertType(node.attrs?.type);
|
|
355
|
+
const icon = String(node.attrs?.icon ?? '').trim();
|
|
356
|
+
const color = String(node.attrs?.color ?? '').trim();
|
|
357
|
+
const title = String(node.firstChild?.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
358
|
+
const body = node.childCount > 1 ? node.child(node.childCount - 1) : null;
|
|
359
|
+
// `icon` / `color` are space-free tokens (icon keys + hex), so they ride the
|
|
360
|
+
// whitespace-split directive attrs cleanly.
|
|
361
|
+
const attrs = [`type=${type}`];
|
|
362
|
+
if (icon)
|
|
363
|
+
attrs.push(`icon=${icon}`);
|
|
364
|
+
if (color)
|
|
365
|
+
attrs.push(`color=${color}`);
|
|
366
|
+
state.write(`:::alert{${attrs.join(' ')}}${title ? ' ' + title : ''}\n`);
|
|
367
|
+
if (body)
|
|
368
|
+
state.renderContent(body);
|
|
369
|
+
state.ensureNewLine();
|
|
370
|
+
state.write(':::');
|
|
371
|
+
state.closeBlock(node);
|
|
372
|
+
}
|
|
373
|
+
// ── Alert — shadcn-style callout: icon + editable title + editable body ───────
|
|
374
|
+
//
|
|
375
|
+
// `alert` wraps two editable children: `alertTitle` (the heading, defaults to
|
|
376
|
+
// the variant label) and `alertBody` (the description). The in-editor chrome —
|
|
377
|
+
// icon, variant picker, theme styling — lives in the React NodeView
|
|
378
|
+
// (`AlertNodeView`); `renderHTML` here is the headless / clipboard / getHTML
|
|
379
|
+
// fallback, and `render.ts` owns the published read-side HTML. Variant lives on
|
|
380
|
+
// the `type` attr (info/warning/success/tip/custom); `icon` / `color` back the
|
|
381
|
+
// Phase-2 per-block pickers.
|
|
168
382
|
export const Alert = Node.create({
|
|
169
383
|
name: 'alert',
|
|
170
384
|
group: 'block',
|
|
171
|
-
content: '
|
|
385
|
+
content: 'alertTitle alertBody',
|
|
172
386
|
defining: true,
|
|
387
|
+
// Markdown round-trip — `:::alert{type=…} Title` (only consulted when the
|
|
388
|
+
// `tiptap-markdown` extension is present, i.e. the Markdown editor).
|
|
389
|
+
addStorage() {
|
|
390
|
+
return {
|
|
391
|
+
markdown: {
|
|
392
|
+
serialize: serializeAlertMarkdown,
|
|
393
|
+
parse: { setup: setupAlertDirective },
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
},
|
|
173
397
|
addAttributes() {
|
|
174
398
|
return {
|
|
175
399
|
type: {
|
|
@@ -177,20 +401,71 @@ export const Alert = Node.create({
|
|
|
177
401
|
parseHTML: (el) => coerceAlertType(el.getAttribute('data-alert-type')),
|
|
178
402
|
renderHTML: (attrs) => ({ 'data-alert-type': coerceAlertType(attrs['type']) }),
|
|
179
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(),
|
|
407
|
+
icon: {
|
|
408
|
+
default: '',
|
|
409
|
+
parseHTML: (el) => el.getAttribute('data-icon') ?? '',
|
|
410
|
+
renderHTML: (attrs) => (attrs['icon'] ? { 'data-icon': String(attrs['icon']) } : {}),
|
|
411
|
+
},
|
|
412
|
+
// User-pasted custom SVG (sanitized). Wins over `icon` when set; not
|
|
413
|
+
// carried in markdown (would bloat the source) — rich-text only.
|
|
414
|
+
iconSvg: {
|
|
415
|
+
default: '',
|
|
416
|
+
parseHTML: (el) => el.getAttribute('data-icon-svg') ?? '',
|
|
417
|
+
renderHTML: (attrs) => (attrs['iconSvg'] ? { 'data-icon-svg': String(attrs['iconSvg']) } : {}),
|
|
418
|
+
},
|
|
419
|
+
color: {
|
|
420
|
+
default: '',
|
|
421
|
+
parseHTML: (el) => el.getAttribute('data-color') ?? '',
|
|
422
|
+
renderHTML: (attrs) => (attrs['color'] ? { 'data-color': String(attrs['color']) } : {}),
|
|
423
|
+
},
|
|
180
424
|
};
|
|
181
425
|
},
|
|
182
426
|
parseHTML() {
|
|
183
|
-
return [{ tag: 'div[data-type="alert"]'
|
|
427
|
+
return [{ tag: 'div[data-type="alert"]' }];
|
|
428
|
+
},
|
|
429
|
+
renderHTML({ HTMLAttributes }) {
|
|
430
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alert', class: 'pilotiq-alert' }), 0];
|
|
431
|
+
},
|
|
432
|
+
addNodeView() {
|
|
433
|
+
return ReactNodeViewRenderer(AlertNodeView);
|
|
184
434
|
},
|
|
185
|
-
|
|
186
|
-
|
|
435
|
+
});
|
|
436
|
+
/** The Alert heading — a single editable line, defaults to the variant label. */
|
|
437
|
+
export const AlertTitle = Node.create({
|
|
438
|
+
name: 'alertTitle',
|
|
439
|
+
content: 'inline*',
|
|
440
|
+
defining: true,
|
|
441
|
+
parseHTML() {
|
|
187
442
|
return [
|
|
188
|
-
'div',
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
443
|
+
{ tag: 'div[data-type="alertTitle"]' },
|
|
444
|
+
{ tag: '.pilotiq-alert-title' },
|
|
445
|
+
// Back-compat: the pre-redesign alert's non-editable label.
|
|
446
|
+
{ tag: '[data-type="alert"] > .pilotiq-block-label' },
|
|
447
|
+
];
|
|
448
|
+
},
|
|
449
|
+
renderHTML({ HTMLAttributes }) {
|
|
450
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alertTitle', class: 'pilotiq-alert-title' }), 0];
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
/** The Alert body — the editable description (`block+`). */
|
|
454
|
+
export const AlertBody = Node.create({
|
|
455
|
+
name: 'alertBody',
|
|
456
|
+
content: 'block+',
|
|
457
|
+
defining: true,
|
|
458
|
+
parseHTML() {
|
|
459
|
+
return [
|
|
460
|
+
{ tag: 'div[data-type="alertBody"]' },
|
|
461
|
+
{ tag: '.pilotiq-alert-description' },
|
|
462
|
+
// Back-compat: the pre-redesign alert's body wrapper.
|
|
463
|
+
{ tag: '[data-type="alert"] > .pilotiq-block-body' },
|
|
192
464
|
];
|
|
193
465
|
},
|
|
466
|
+
renderHTML({ HTMLAttributes }) {
|
|
467
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alertBody', class: 'pilotiq-alert-description' }), 0];
|
|
468
|
+
},
|
|
194
469
|
});
|
|
195
470
|
// ── Pros & cons — two labelled columns (each a `block+` body, list by default) ──
|
|
196
471
|
export const ProsCons = Node.create({
|
|
@@ -198,11 +473,28 @@ export const ProsCons = Node.create({
|
|
|
198
473
|
group: 'block',
|
|
199
474
|
content: 'prosColumn consColumn',
|
|
200
475
|
defining: true,
|
|
476
|
+
addAttributes() {
|
|
477
|
+
return { width: widthAttribute() };
|
|
478
|
+
},
|
|
201
479
|
parseHTML() {
|
|
202
|
-
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
|
+
];
|
|
203
488
|
},
|
|
204
489
|
renderHTML({ HTMLAttributes }) {
|
|
205
|
-
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);
|
|
206
498
|
},
|
|
207
499
|
});
|
|
208
500
|
function prosConsColumn(name, label, cssClass) {
|
|
@@ -274,6 +566,8 @@ export const contentBlockNodes = [
|
|
|
274
566
|
FaqQuestion,
|
|
275
567
|
FaqAnswer,
|
|
276
568
|
Alert,
|
|
569
|
+
AlertTitle,
|
|
570
|
+
AlertBody,
|
|
277
571
|
ProsCons,
|
|
278
572
|
ProsColumn,
|
|
279
573
|
ConsColumn,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
|
|
4
|
+
import { ALERT_VARIANTS, ALERT_ICON_KEYS, buildAlertIconSvg, sanitizeIconSvg, coerceAlertType, } from '../extensions/alertVariants.js';
|
|
5
|
+
import { BlockSettingsMenu } from './BlockSettingsMenu.js';
|
|
6
|
+
/**
|
|
7
|
+
* React NodeView for the `alert` content block — a shadcn-style callout on the
|
|
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
|
+
*
|
|
14
|
+
* Editable regions are the child nodes (`alertTitle` / `alertBody`) rendered
|
|
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
|
+
*/
|
|
20
|
+
const VARIANT_BOX = {
|
|
21
|
+
info: 'border-blue-500/30 bg-blue-50/40 dark:bg-blue-950/20',
|
|
22
|
+
warning: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20',
|
|
23
|
+
success: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20',
|
|
24
|
+
tip: 'border-violet-500/30 bg-violet-50/40 dark:bg-violet-950/20',
|
|
25
|
+
custom: 'border-border bg-card',
|
|
26
|
+
};
|
|
27
|
+
const VARIANT_ICON_COLOR = {
|
|
28
|
+
info: 'text-blue-600 dark:text-blue-400',
|
|
29
|
+
warning: 'text-amber-600 dark:text-amber-400',
|
|
30
|
+
success: 'text-emerald-600 dark:text-emerald-400',
|
|
31
|
+
tip: 'text-violet-600 dark:text-violet-400',
|
|
32
|
+
custom: 'text-foreground',
|
|
33
|
+
};
|
|
34
|
+
const VARIANT_LABEL = {
|
|
35
|
+
info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip', custom: 'Custom',
|
|
36
|
+
};
|
|
37
|
+
const COLOR_SWATCHES = [
|
|
38
|
+
{ value: '#ef4444', label: 'Red' },
|
|
39
|
+
{ value: '#f97316', label: 'Orange' },
|
|
40
|
+
{ value: '#eab308', label: 'Yellow' },
|
|
41
|
+
{ value: '#22c55e', label: 'Green' },
|
|
42
|
+
{ value: '#06b6d4', label: 'Cyan' },
|
|
43
|
+
{ value: '#3b82f6', label: 'Blue' },
|
|
44
|
+
{ value: '#8b5cf6', label: 'Violet' },
|
|
45
|
+
{ value: '#ec4899', label: 'Pink' },
|
|
46
|
+
{ value: '#64748b', label: 'Slate' },
|
|
47
|
+
{ value: '#0f172a', label: 'Ink' },
|
|
48
|
+
];
|
|
49
|
+
// Renders a full <svg> string (library or sanitized custom) sized to fill.
|
|
50
|
+
function IconSlot({ svg, className }) {
|
|
51
|
+
return (_jsx("span", { className: 'inline-flex shrink-0 [&>svg]:size-full ' + (className ?? ''), dangerouslySetInnerHTML: { __html: svg } }));
|
|
52
|
+
}
|
|
53
|
+
export function AlertNodeView({ node, updateAttributes, editor }) {
|
|
54
|
+
const variant = coerceAlertType(node.attrs['type']);
|
|
55
|
+
const iconKey = String(node.attrs['icon'] ?? '');
|
|
56
|
+
const iconSvg = String(node.attrs['iconSvg'] ?? '');
|
|
57
|
+
const color = String(node.attrs['color'] ?? '');
|
|
58
|
+
const width = node.attrs['width'] === 'full' ? 'full' : 'contained';
|
|
59
|
+
const editable = editor.isEditable;
|
|
60
|
+
const iconFull = buildAlertIconSvg(iconKey, iconSvg, variant);
|
|
61
|
+
const tinted = variant === 'custom' && color !== '';
|
|
62
|
+
const [svgMode, setSvgMode] = useState(false);
|
|
63
|
+
const [svgDraft, setSvgDraft] = useState('');
|
|
64
|
+
const [svgError, setSvgError] = useState(false);
|
|
65
|
+
const boxStyle = tinted
|
|
66
|
+
? {
|
|
67
|
+
borderColor: `color-mix(in srgb, ${color} 35%, transparent)`,
|
|
68
|
+
backgroundColor: `color-mix(in srgb, ${color} 8%, transparent)`,
|
|
69
|
+
}
|
|
70
|
+
: undefined;
|
|
71
|
+
const resetSvg = () => { setSvgMode(false); setSvgDraft(''); setSvgError(false); };
|
|
72
|
+
const pickIcon = (key) => { updateAttributes({ icon: key, iconSvg: '' }); resetSvg(); };
|
|
73
|
+
const applyCustomSvg = () => {
|
|
74
|
+
const clean = sanitizeIconSvg(svgDraft);
|
|
75
|
+
if (!clean) {
|
|
76
|
+
setSvgError(true);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
updateAttributes({ iconSvg: clean, icon: '' });
|
|
80
|
+
resetSvg();
|
|
81
|
+
};
|
|
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" })] })] }));
|
|
137
|
+
}
|