@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.
@@ -1,11 +1,61 @@
1
1
  import { Node, Extension, mergeAttributes } from '@tiptap/core';
2
- /** A labelled region whose body is ordinary editable content (`block+`). */
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-label', contenteditable: 'false' }, spec.label],
17
- ['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
+ ],
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"]', contentElement: '.pilotiq-block-body' }];
99
+ return [{ tag: 'div[data-type="faq"]' }];
40
100
  },
41
101
  renderHTML({ HTMLAttributes }) {
42
- return [
43
- 'div',
44
- mergeAttributes(HTMLAttributes, { 'data-type': 'faq', class: 'pilotiq-faq' }),
45
- ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, 'FAQ'],
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
- return [{ tag: 'div[data-type="faqQuestion"]', contentElement: '.pilotiq-faq-text' }];
135
- },
136
- renderHTML({ HTMLAttributes }) {
206
+ // Back-compat: the pre-accordion question wrapped its text in `.pilotiq-faq-text`.
137
207
  return [
138
- 'div',
139
- mergeAttributes(HTMLAttributes, { 'data-type': 'faqQuestion', class: 'pilotiq-faq-question' }),
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
- return [{ tag: 'div[data-type="faqAnswer"]', contentElement: '.pilotiq-faq-body' }];
151
- },
152
- renderHTML({ HTMLAttributes }) {
221
+ // Back-compat: the pre-accordion answer wrapped its body in `.pilotiq-faq-body`.
153
222
  return [
154
- 'div',
155
- mergeAttributes(HTMLAttributes, { 'data-type': 'faqAnswer', class: 'pilotiq-faq-answer' }),
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
- // ── Alert — a typed notice; the label IS the type (Info/Warning/Success/Tip) ──
162
- export const ALERT_TYPES = ['info', 'warning', 'success', 'tip'];
163
- const ALERT_LABEL = { info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip' };
164
- /** Exported so `render.ts` shares the same coercion at the server boundary. */
165
- export function coerceAlertType(value) {
166
- return ALERT_TYPES.includes(String(value)) ? value : 'info';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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: 'block+',
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"]', contentElement: '.pilotiq-block-body' }];
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
- renderHTML({ node, HTMLAttributes }) {
186
- const type = coerceAlertType(node.attrs['type']);
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
- mergeAttributes(HTMLAttributes, { 'data-type': 'alert', class: `pilotiq-alert pilotiq-alert-${type}` }),
190
- ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, ALERT_LABEL[type]],
191
- ['div', { class: 'pilotiq-block-body' }, 0],
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 [{ 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
+ ];
203
488
  },
204
489
  renderHTML({ HTMLAttributes }) {
205
- 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);
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,4 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type NodeViewProps } from '@tiptap/react';
3
+ export declare function AlertNodeView({ node, updateAttributes, editor }: NodeViewProps): ReactElement;
4
+ //# sourceMappingURL=AlertNodeView.d.ts.map
@@ -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
+ }