@pilotiq/tiptap 3.12.0 → 3.14.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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Alert variant primitives — shared by the node (`contentBlocks.ts`), its
3
+ * React NodeView (`AlertNodeView.tsx`), and the read-side serializer
4
+ * (`render.ts`). Kept dependency-free (no `@tiptap/*`) so the server
5
+ * renderer can import it without pulling a Tiptap runtime.
6
+ *
7
+ * `info` / `warning` / `success` / `tip` are presets (default icon + accent);
8
+ * `custom` is the user-driven variant (own icon + color, Phase 2).
9
+ */
10
+ export const ALERT_VARIANTS = ['info', 'warning', 'success', 'tip', 'custom'];
11
+ export const ALERT_VARIANT_LABEL = {
12
+ info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip', custom: 'Custom',
13
+ };
14
+ /** Coerce an unknown value to a known variant, defaulting to `info`. */
15
+ export function coerceAlertType(value) {
16
+ return ALERT_VARIANTS.includes(String(value)) ? value : 'info';
17
+ }
18
+ /**
19
+ * Curated icon library — inner SVG markup (lucide paths) keyed by name. Shared
20
+ * by the NodeView picker (`AlertNodeView`) and the read-side renderer
21
+ * (`render.ts`) so the editor and published output never drift. Hand-picked
22
+ * (no `lucide-react` dep, no icon font) so the whole set is ~1-2KB gzipped.
23
+ * Wrap in `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
24
+ * stroke-width="2" stroke-linecap="round" stroke-linejoin="round">…</svg>`.
25
+ */
26
+ export const ALERT_ICONS = {
27
+ info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
28
+ warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
29
+ check: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
30
+ lightbulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
31
+ sparkles: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/>',
32
+ bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
33
+ megaphone: '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
34
+ flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
35
+ star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
36
+ heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
37
+ shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
38
+ zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
39
+ flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/>',
40
+ bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
41
+ rocket: '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
42
+ help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
43
+ error: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
44
+ pin: '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
45
+ };
46
+ /** Icon keys, in picker display order. */
47
+ export const ALERT_ICON_KEYS = Object.keys(ALERT_ICONS);
48
+ /** The default icon key per variant (used when the block sets no `icon`). */
49
+ export const VARIANT_DEFAULT_ICON = {
50
+ info: 'info', warning: 'warning', success: 'check', tip: 'lightbulb', custom: 'sparkles',
51
+ };
52
+ /**
53
+ * Resolve the inner SVG for a block: the explicit `icon` override when set +
54
+ * known, else the variant's default. Shared by the editor + read-side render.
55
+ */
56
+ export function resolveAlertIconInner(iconKey, variant) {
57
+ if (iconKey && ALERT_ICONS[iconKey])
58
+ return ALERT_ICONS[iconKey];
59
+ return ALERT_ICONS[VARIANT_DEFAULT_ICON[variant]];
60
+ }
61
+ // ── Custom SVG (user-pasted) — sanitized allowlist ───────────────────────────
62
+ //
63
+ // Custom SVG is raw user markup that renders on PUBLIC pages, so it's an XSS
64
+ // surface. We sanitize with a conservative pure-string allowlist (no DOMPurify
65
+ // — keeps the bundle lean and works server-side in render.ts): only known
66
+ // presentational SVG tags + geometry/paint attributes survive; scripts, event
67
+ // handlers, external refs (`use`/`image`/`a`/`href`), `<style>`, `<animate>`,
68
+ // `<foreignObject>` and unknown attributes are dropped. Applied on input AND on
69
+ // every render (defence in depth — a tampered stored attr stays safe).
70
+ const SVG_ALLOWED_TAGS = new Set([
71
+ 'svg', 'g', 'path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon',
72
+ 'defs', 'lineargradient', 'radialgradient', 'stop', 'title', 'desc', 'clippath',
73
+ ]);
74
+ const SVG_ALLOWED_ATTRS = new Set([
75
+ 'viewbox', 'xmlns', 'width', 'height', 'preserveaspectratio',
76
+ 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
77
+ 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'stroke-miterlimit',
78
+ 'fill-opacity', 'fill-rule', 'clip-rule', 'clip-path', 'opacity', 'transform',
79
+ 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2',
80
+ 'points', 'class', 'gradientunits', 'gradienttransform', 'offset',
81
+ 'stop-color', 'stop-opacity', 'id',
82
+ ]);
83
+ /**
84
+ * Sanitize a user-pasted SVG string to a safe subset. Returns '' for anything
85
+ * that isn't an `<svg>` element or that sanitizes to nothing.
86
+ */
87
+ export function sanitizeIconSvg(raw) {
88
+ if (typeof raw !== 'string')
89
+ return '';
90
+ let s = raw.trim();
91
+ if (!/^<svg[\s>]/i.test(s))
92
+ return '';
93
+ // Drop comments / CDATA / PIs / doctype, then dangerous element blocks whole.
94
+ s = s.replace(/<!--[\s\S]*?-->/g, '').replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '')
95
+ .replace(/<\?[\s\S]*?\?>/g, '').replace(/<!DOCTYPE[^>]*>/gi, '');
96
+ s = s.replace(/<(script|style|foreignObject)\b[\s\S]*?<\/\1\s*>/gi, '');
97
+ // Allowlist every remaining tag + its attributes.
98
+ s = s.replace(/<\/?([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(\/?)>/g, (_m, tag, attrs, slash) => {
99
+ const name = String(tag).toLowerCase();
100
+ if (!SVG_ALLOWED_TAGS.has(name))
101
+ return '';
102
+ if (_m.startsWith('</'))
103
+ return `</${name}>`;
104
+ const kept = [];
105
+ const attrRe = /([a-zA-Z_:][\w:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/g;
106
+ let am;
107
+ while ((am = attrRe.exec(attrs)) !== null) {
108
+ const an = (am[1] ?? '').toLowerCase();
109
+ if (!SVG_ALLOWED_ATTRS.has(an))
110
+ continue;
111
+ const av = (am[2] ?? '').replace(/^["']|["']$/g, '');
112
+ if (/javascript:|expression\(|[<>]/i.test(av))
113
+ continue;
114
+ kept.push(`${an}="${av.replace(/"/g, '&quot;')}"`);
115
+ }
116
+ return `<${name}${kept.length ? ' ' + kept.join(' ') : ''}${slash ? ' /' : ''}>`;
117
+ });
118
+ s = s.trim();
119
+ return /^<svg[\s>]/i.test(s) ? s : '';
120
+ }
121
+ const ICON_SVG_WRAP_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">';
122
+ /**
123
+ * Build the FULL `<svg>` string for a block: a sanitized user `iconSvg` when
124
+ * present, else the (library or variant-default) icon wrapped in the standard
125
+ * stroke svg. Shared by the editor NodeView and the read-side renderer.
126
+ */
127
+ export function buildAlertIconSvg(iconKey, iconSvg, variant) {
128
+ const custom = sanitizeIconSvg(iconSvg);
129
+ if (custom)
130
+ return custom;
131
+ return `${ICON_SVG_WRAP_OPEN}${resolveAlertIconInner(iconKey, variant)}</svg>`;
132
+ }
@@ -0,0 +1,34 @@
1
+ import { Node, Extension } from '@tiptap/core';
2
+ export { ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, type AlertType } from './alertVariants.js';
3
+ export declare const KeyTakeaways: Node<any, any>;
4
+ export declare const Summary: Node<any, any>;
5
+ export declare const Faq: Node<any, any>;
6
+ export declare const FaqItem: Node<any, any>;
7
+ export declare const FaqQuestion: Node<any, any>;
8
+ export declare const FaqAnswer: Node<any, any>;
9
+ type AnyMd = any;
10
+ /** Parse a directive info string (`alert{type=warning}`) into an attrs map. */
11
+ export declare function parseDirectiveAttrs(info: string): Record<string, string>;
12
+ /**
13
+ * Register the `:::alert{…}` block rule + HTML renderers on a markdown-it
14
+ * instance. Idempotent per instance. Wired as `Alert`'s `markdown.parse.setup`.
15
+ */
16
+ export declare function setupAlertDirective(md: AnyMd): void;
17
+ /**
18
+ * `tiptap-markdown` serializer for the Alert node → `:::alert{type=…} Title`.
19
+ * The title rides the opening fence line (admonition style); the body markdown
20
+ * sits between the fences.
21
+ */
22
+ export declare function serializeAlertMarkdown(state: AnyMd, node: AnyMd): void;
23
+ export declare const Alert: Node<any, any>;
24
+ /** The Alert heading — a single editable line, defaults to the variant label. */
25
+ export declare const AlertTitle: Node<any, any>;
26
+ /** The Alert body — the editable description (`block+`). */
27
+ export declare const AlertBody: Node<any, any>;
28
+ export declare const ProsCons: Node<any, any>;
29
+ export declare const ProsColumn: Node<any, any>;
30
+ export declare const ConsColumn: Node<any, any>;
31
+ export declare const ContentBlockKeymap: Extension<any, any>;
32
+ /** All inline content-block extensions — registered in the editor's list. */
33
+ export declare const contentBlockNodes: (Node<any, any> | Extension<any, any>)[];
34
+ //# sourceMappingURL=contentBlocks.d.ts.map
@@ -0,0 +1,491 @@
1
+ import { Node, Extension, mergeAttributes } from '@tiptap/core';
2
+ import { ReactNodeViewRenderer } from '@tiptap/react';
3
+ import { AlertNodeView } from '../react/AlertNodeView.js';
4
+ import { coerceAlertType } from './alertVariants.js';
5
+ // Re-exported for back-compat — the canonical definitions live in
6
+ // `alertVariants.ts` (shared with the React NodeView + the read-side renderer).
7
+ export { ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType } from './alertVariants.js';
8
+ /** A labelled region whose body is ordinary editable content (`block+`). */
9
+ function labeledBlock(spec) {
10
+ return Node.create({
11
+ name: spec.name,
12
+ group: 'block',
13
+ content: 'block+',
14
+ defining: true,
15
+ parseHTML() {
16
+ return [{ tag: `div[data-type="${spec.name}"]`, contentElement: '.pilotiq-block-body' }];
17
+ },
18
+ renderHTML({ HTMLAttributes }) {
19
+ return [
20
+ 'div',
21
+ mergeAttributes(HTMLAttributes, { 'data-type': spec.name, class: spec.cssClass }),
22
+ ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, spec.label],
23
+ ['div', { class: 'pilotiq-block-body' }, 0],
24
+ ];
25
+ },
26
+ });
27
+ }
28
+ export const KeyTakeaways = labeledBlock({ name: 'keyTakeaways', label: 'Key takeaways', cssClass: 'pilotiq-key-takeaways' });
29
+ export const Summary = labeledBlock({ name: 'summary', label: 'Summary', cssClass: 'pilotiq-summary' });
30
+ // ── FAQ — structured question / answer items ──
31
+ //
32
+ // `faq` > `faqItem+`; each item is a `faqQuestion` (inline text, "Q" marker) +
33
+ // a `faqAnswer` (block+, "A" marker). Authoring: Enter in a question jumps to
34
+ // its answer; Cmd/Ctrl-Enter inside an item adds a new Q&A item below.
35
+ const NEW_FAQ_ITEM = {
36
+ type: 'faqItem',
37
+ content: [{ type: 'faqQuestion' }, { type: 'faqAnswer', content: [{ type: 'paragraph' }] }],
38
+ };
39
+ export const Faq = Node.create({
40
+ name: 'faq',
41
+ group: 'block',
42
+ content: 'faqItem+',
43
+ defining: true,
44
+ parseHTML() {
45
+ return [{ tag: 'div[data-type="faq"]', contentElement: '.pilotiq-block-body' }];
46
+ },
47
+ renderHTML({ HTMLAttributes }) {
48
+ return [
49
+ 'div',
50
+ mergeAttributes(HTMLAttributes, { 'data-type': 'faq', class: 'pilotiq-faq' }),
51
+ ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, 'FAQ'],
52
+ ['div', { class: 'pilotiq-block-body' }, 0],
53
+ ];
54
+ },
55
+ addKeyboardShortcuts() {
56
+ return {
57
+ // Enter drives the whole Q&A flow:
58
+ // • in a question → jump to its answer;
59
+ // • in an answer → finish it and start a NEW Q&A item below (focus its
60
+ // question). Repeatable: question → Enter → answer → Enter → next.
61
+ // (Shift-Enter still inserts a line break within an answer.)
62
+ Enter: ({ editor }) => {
63
+ const { $from, empty } = editor.state.selection;
64
+ if (!empty)
65
+ return false;
66
+ for (let d = $from.depth; d > 0; d--) {
67
+ const name = $from.node(d).type.name;
68
+ if (name === 'faqQuestion') {
69
+ return editor.chain().setTextSelection($from.after(d) + 1).focus().run();
70
+ }
71
+ if (name === 'faqAnswer') {
72
+ const after = $from.after(d - 1); // position just after the faqItem
73
+ return editor.chain().insertContentAt(after, NEW_FAQ_ITEM).setTextSelection(after + 2).focus().run();
74
+ }
75
+ }
76
+ return false;
77
+ },
78
+ // Cmd/Ctrl-Enter anywhere in an item also adds a new Q&A item below.
79
+ 'Mod-Enter': ({ editor }) => {
80
+ const { $from } = editor.state.selection;
81
+ for (let d = $from.depth; d > 0; d--) {
82
+ if ($from.node(d).type.name === 'faqItem') {
83
+ const after = $from.after(d);
84
+ return editor.chain().insertContentAt(after, NEW_FAQ_ITEM).setTextSelection(after + 2).focus().run();
85
+ }
86
+ }
87
+ return false;
88
+ },
89
+ // Backspace in an EMPTY question removes that whole Q&A item (and the
90
+ // whole FAQ block if it was the only item) — like emptying a list item.
91
+ Backspace: ({ editor }) => {
92
+ const { $from, empty } = editor.state.selection;
93
+ if (!empty)
94
+ return false;
95
+ let qDepth = -1;
96
+ for (let d = $from.depth; d > 0; d--) {
97
+ if ($from.node(d).type.name === 'faqQuestion') {
98
+ qDepth = d;
99
+ break;
100
+ }
101
+ }
102
+ if (qDepth === -1)
103
+ return false;
104
+ if ($from.node(qDepth).content.size > 0)
105
+ return false; // question not empty → normal backspace
106
+ const faqDepth = qDepth - 2;
107
+ const itemDepth = qDepth - 1;
108
+ const faq = $from.node(faqDepth);
109
+ const faqStart = $from.before(faqDepth);
110
+ if (faq.childCount <= 1) {
111
+ // last item → remove the whole FAQ block
112
+ return editor.chain().deleteRange({ from: faqStart, to: faqStart + faq.nodeSize }).focus().run();
113
+ }
114
+ // remove just this Q&A item; drop the cursor into a neighbouring item
115
+ const itemStart = $from.before(itemDepth);
116
+ const itemEnd = itemStart + $from.node(itemDepth).nodeSize;
117
+ const caret = $from.index(faqDepth) === 0 ? faqStart + 3 : itemStart - 1;
118
+ return editor.chain().deleteRange({ from: itemStart, to: itemEnd }).setTextSelection(caret).focus().run();
119
+ },
120
+ };
121
+ },
122
+ });
123
+ export const FaqItem = Node.create({
124
+ name: 'faqItem',
125
+ group: 'faqItem',
126
+ content: 'faqQuestion faqAnswer',
127
+ defining: true,
128
+ parseHTML() {
129
+ return [{ tag: 'div[data-type="faqItem"]' }];
130
+ },
131
+ renderHTML({ HTMLAttributes }) {
132
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faqItem', class: 'pilotiq-faq-item' }), 0];
133
+ },
134
+ });
135
+ export const FaqQuestion = Node.create({
136
+ name: 'faqQuestion',
137
+ content: 'inline*',
138
+ defining: true,
139
+ parseHTML() {
140
+ return [{ tag: 'div[data-type="faqQuestion"]', contentElement: '.pilotiq-faq-text' }];
141
+ },
142
+ renderHTML({ HTMLAttributes }) {
143
+ return [
144
+ 'div',
145
+ mergeAttributes(HTMLAttributes, { 'data-type': 'faqQuestion', class: 'pilotiq-faq-question' }),
146
+ ['span', { class: 'pilotiq-faq-marker', contenteditable: 'false' }, 'Q'],
147
+ ['span', { class: 'pilotiq-faq-text' }, 0],
148
+ ];
149
+ },
150
+ });
151
+ export const FaqAnswer = Node.create({
152
+ name: 'faqAnswer',
153
+ content: 'block+',
154
+ defining: true,
155
+ parseHTML() {
156
+ return [{ tag: 'div[data-type="faqAnswer"]', contentElement: '.pilotiq-faq-body' }];
157
+ },
158
+ renderHTML({ HTMLAttributes }) {
159
+ return [
160
+ 'div',
161
+ mergeAttributes(HTMLAttributes, { 'data-type': 'faqAnswer', class: 'pilotiq-faq-answer' }),
162
+ ['span', { class: 'pilotiq-faq-marker', contenteditable: 'false' }, 'A'],
163
+ ['div', { class: 'pilotiq-faq-body' }, 0],
164
+ ];
165
+ },
166
+ });
167
+ const DIRECTIVE_MARKER = 0x3a; // ':'
168
+ const DIRECTIVE_MIN_MARKERS = 3;
169
+ /** Parse a directive info string (`alert{type=warning}`) into an attrs map. */
170
+ export function parseDirectiveAttrs(info) {
171
+ const out = {};
172
+ const m = /\{([^}]*)\}/.exec(info);
173
+ if (!m || !m[1])
174
+ return out;
175
+ for (const pair of m[1].split(/[\s,]+/)) {
176
+ const eq = pair.indexOf('=');
177
+ if (eq === -1)
178
+ continue;
179
+ out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
180
+ }
181
+ return out;
182
+ }
183
+ /** The directive name — the token before the first `{` or whitespace. */
184
+ function directiveName(info) {
185
+ return info.trim().split(/[\s{]/, 1)[0] ?? '';
186
+ }
187
+ /**
188
+ * The directive title — the free text after `{attrs}` on the opening fence
189
+ * line (Docusaurus-admonition style: `:::alert{type=warning} Heads up`).
190
+ * Empty when omitted.
191
+ */
192
+ function directiveTitle(info) {
193
+ const close = info.lastIndexOf('}');
194
+ const rest = close === -1 ? info.replace(/^\S+/, '') : info.slice(close + 1);
195
+ return rest.trim();
196
+ }
197
+ /** Minimal HTML escape for text interpolated into the directive's HTML. */
198
+ function escapeDirectiveHtml(s) {
199
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
200
+ }
201
+ /**
202
+ * Register the `:::alert{…}` block rule + HTML renderers on a markdown-it
203
+ * instance. Idempotent per instance. Wired as `Alert`'s `markdown.parse.setup`.
204
+ */
205
+ export function setupAlertDirective(md) {
206
+ if (md.__pilotiqAlertDirective)
207
+ return;
208
+ md.__pilotiqAlertDirective = true;
209
+ md.block.ruler.before('fence', 'pilotiqAlert', (state, startLine, endLine, silent) => {
210
+ let pos = state.bMarks[startLine] + state.tShift[startLine];
211
+ let max = state.eMarks[startLine];
212
+ if (state.src.charCodeAt(pos) !== DIRECTIVE_MARKER)
213
+ return false;
214
+ const memStart = pos;
215
+ while (pos <= max && state.src.charCodeAt(pos) === DIRECTIVE_MARKER)
216
+ pos++;
217
+ const markerCount = pos - memStart;
218
+ if (markerCount < DIRECTIVE_MIN_MARKERS)
219
+ return false;
220
+ const info = state.src.slice(pos, max).trim();
221
+ if (directiveName(info) !== 'alert')
222
+ return false;
223
+ if (silent)
224
+ return true;
225
+ // Scan for the closing fence (>= as many markers, nothing but space after).
226
+ let nextLine = startLine;
227
+ let autoClosed = false;
228
+ for (;;) {
229
+ nextLine++;
230
+ if (nextLine >= endLine)
231
+ break;
232
+ pos = state.bMarks[nextLine] + state.tShift[nextLine];
233
+ max = state.eMarks[nextLine];
234
+ if (state.src.charCodeAt(pos) !== DIRECTIVE_MARKER)
235
+ continue;
236
+ if (state.sCount[nextLine] - state.blkIndent >= 4)
237
+ continue;
238
+ let closePos = pos;
239
+ while (closePos <= max && state.src.charCodeAt(closePos) === DIRECTIVE_MARKER)
240
+ closePos++;
241
+ if (closePos - pos < markerCount)
242
+ continue;
243
+ if (state.src.slice(closePos, max).trim() !== '')
244
+ continue;
245
+ autoClosed = true;
246
+ break;
247
+ }
248
+ const oldParent = state.parentType;
249
+ const oldLineMax = state.lineMax;
250
+ state.parentType = 'pilotiqAlert';
251
+ state.lineMax = nextLine;
252
+ const attrs = parseDirectiveAttrs(info);
253
+ let token = state.push('pilotiq_alert_open', 'div', 1);
254
+ token.markup = ':'.repeat(markerCount);
255
+ token.block = true;
256
+ token.info = info;
257
+ token.meta = { type: attrs['type'], icon: attrs['icon'], color: attrs['color'], title: directiveTitle(info) };
258
+ token.map = [startLine, nextLine];
259
+ state.md.block.tokenize(state, startLine + 1, nextLine);
260
+ token = state.push('pilotiq_alert_close', 'div', -1);
261
+ token.markup = ':'.repeat(markerCount);
262
+ token.block = true;
263
+ state.parentType = oldParent;
264
+ state.lineMax = oldLineMax;
265
+ state.line = nextLine + (autoClosed ? 1 : 0);
266
+ return true;
267
+ }, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
268
+ // Open emits the alert wrapper + the (closed) title div + the OPEN body div;
269
+ // the tokenized content renders into the body; close shuts body + wrapper.
270
+ // The data-types match `Alert`/`AlertTitle`/`AlertBody`'s `parseHTML`, so the
271
+ // HTML-based tiptap-markdown parse path revives the full node structure.
272
+ md.renderer.rules['pilotiq_alert_open'] = (tokens, idx) => {
273
+ const meta = tokens[idx].meta ?? {};
274
+ const type = coerceAlertType(meta.type);
275
+ const title = escapeDirectiveHtml(String(meta.title ?? ''));
276
+ const icon = meta.icon ? ` data-icon="${escapeDirectiveHtml(String(meta.icon))}"` : '';
277
+ const color = meta.color ? ` data-color="${escapeDirectiveHtml(String(meta.color))}"` : '';
278
+ return (`<div data-type="alert" class="pilotiq-alert pilotiq-alert-${type}" data-alert-type="${type}"${icon}${color}>` +
279
+ `<div data-type="alertTitle" class="pilotiq-alert-title">${title}</div>` +
280
+ `<div data-type="alertBody" class="pilotiq-alert-description">\n`);
281
+ };
282
+ md.renderer.rules['pilotiq_alert_close'] = () => '</div></div>\n';
283
+ }
284
+ /**
285
+ * `tiptap-markdown` serializer for the Alert node → `:::alert{type=…} Title`.
286
+ * The title rides the opening fence line (admonition style); the body markdown
287
+ * sits between the fences.
288
+ */
289
+ export function serializeAlertMarkdown(state, node) {
290
+ const type = coerceAlertType(node.attrs?.type);
291
+ const icon = String(node.attrs?.icon ?? '').trim();
292
+ const color = String(node.attrs?.color ?? '').trim();
293
+ const title = String(node.firstChild?.textContent ?? '').replace(/\s+/g, ' ').trim();
294
+ const body = node.childCount > 1 ? node.child(node.childCount - 1) : null;
295
+ // `icon` / `color` are space-free tokens (icon keys + hex), so they ride the
296
+ // whitespace-split directive attrs cleanly.
297
+ const attrs = [`type=${type}`];
298
+ if (icon)
299
+ attrs.push(`icon=${icon}`);
300
+ if (color)
301
+ attrs.push(`color=${color}`);
302
+ state.write(`:::alert{${attrs.join(' ')}}${title ? ' ' + title : ''}\n`);
303
+ if (body)
304
+ state.renderContent(body);
305
+ state.ensureNewLine();
306
+ state.write(':::');
307
+ state.closeBlock(node);
308
+ }
309
+ // ── Alert — shadcn-style callout: icon + editable title + editable body ───────
310
+ //
311
+ // `alert` wraps two editable children: `alertTitle` (the heading, defaults to
312
+ // the variant label) and `alertBody` (the description). The in-editor chrome —
313
+ // icon, variant picker, theme styling — lives in the React NodeView
314
+ // (`AlertNodeView`); `renderHTML` here is the headless / clipboard / getHTML
315
+ // fallback, and `render.ts` owns the published read-side HTML. Variant lives on
316
+ // the `type` attr (info/warning/success/tip/custom); `icon` / `color` back the
317
+ // Phase-2 per-block pickers.
318
+ export const Alert = Node.create({
319
+ name: 'alert',
320
+ group: 'block',
321
+ content: 'alertTitle alertBody',
322
+ defining: true,
323
+ // Markdown round-trip — `:::alert{type=…} Title` (only consulted when the
324
+ // `tiptap-markdown` extension is present, i.e. the Markdown editor).
325
+ addStorage() {
326
+ return {
327
+ markdown: {
328
+ serialize: serializeAlertMarkdown,
329
+ parse: { setup: setupAlertDirective },
330
+ },
331
+ };
332
+ },
333
+ addAttributes() {
334
+ return {
335
+ type: {
336
+ default: 'info',
337
+ parseHTML: (el) => coerceAlertType(el.getAttribute('data-alert-type')),
338
+ renderHTML: (attrs) => ({ 'data-alert-type': coerceAlertType(attrs['type']) }),
339
+ },
340
+ icon: {
341
+ default: '',
342
+ parseHTML: (el) => el.getAttribute('data-icon') ?? '',
343
+ renderHTML: (attrs) => (attrs['icon'] ? { 'data-icon': String(attrs['icon']) } : {}),
344
+ },
345
+ // User-pasted custom SVG (sanitized). Wins over `icon` when set; not
346
+ // carried in markdown (would bloat the source) — rich-text only.
347
+ iconSvg: {
348
+ default: '',
349
+ parseHTML: (el) => el.getAttribute('data-icon-svg') ?? '',
350
+ renderHTML: (attrs) => (attrs['iconSvg'] ? { 'data-icon-svg': String(attrs['iconSvg']) } : {}),
351
+ },
352
+ color: {
353
+ default: '',
354
+ parseHTML: (el) => el.getAttribute('data-color') ?? '',
355
+ renderHTML: (attrs) => (attrs['color'] ? { 'data-color': String(attrs['color']) } : {}),
356
+ },
357
+ };
358
+ },
359
+ parseHTML() {
360
+ return [{ tag: 'div[data-type="alert"]' }];
361
+ },
362
+ renderHTML({ HTMLAttributes }) {
363
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alert', class: 'pilotiq-alert' }), 0];
364
+ },
365
+ addNodeView() {
366
+ return ReactNodeViewRenderer(AlertNodeView);
367
+ },
368
+ });
369
+ /** The Alert heading — a single editable line, defaults to the variant label. */
370
+ export const AlertTitle = Node.create({
371
+ name: 'alertTitle',
372
+ content: 'inline*',
373
+ defining: true,
374
+ parseHTML() {
375
+ return [
376
+ { tag: 'div[data-type="alertTitle"]' },
377
+ { tag: '.pilotiq-alert-title' },
378
+ // Back-compat: the pre-redesign alert's non-editable label.
379
+ { tag: '[data-type="alert"] > .pilotiq-block-label' },
380
+ ];
381
+ },
382
+ renderHTML({ HTMLAttributes }) {
383
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alertTitle', class: 'pilotiq-alert-title' }), 0];
384
+ },
385
+ });
386
+ /** The Alert body — the editable description (`block+`). */
387
+ export const AlertBody = Node.create({
388
+ name: 'alertBody',
389
+ content: 'block+',
390
+ defining: true,
391
+ parseHTML() {
392
+ return [
393
+ { tag: 'div[data-type="alertBody"]' },
394
+ { tag: '.pilotiq-alert-description' },
395
+ // Back-compat: the pre-redesign alert's body wrapper.
396
+ { tag: '[data-type="alert"] > .pilotiq-block-body' },
397
+ ];
398
+ },
399
+ renderHTML({ HTMLAttributes }) {
400
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alertBody', class: 'pilotiq-alert-description' }), 0];
401
+ },
402
+ });
403
+ // ── Pros & cons — two labelled columns (each a `block+` body, list by default) ──
404
+ export const ProsCons = Node.create({
405
+ name: 'prosCons',
406
+ group: 'block',
407
+ content: 'prosColumn consColumn',
408
+ defining: true,
409
+ parseHTML() {
410
+ return [{ tag: 'div[data-type="prosCons"]' }];
411
+ },
412
+ renderHTML({ HTMLAttributes }) {
413
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'prosCons', class: 'pilotiq-pros-cons' }), 0];
414
+ },
415
+ });
416
+ function prosConsColumn(name, label, cssClass) {
417
+ return Node.create({
418
+ name,
419
+ group: 'prosConsColumn',
420
+ content: 'block+',
421
+ defining: true,
422
+ parseHTML() {
423
+ return [{ tag: `div[data-type="${name}"]`, contentElement: '.pilotiq-block-body' }];
424
+ },
425
+ renderHTML({ HTMLAttributes }) {
426
+ return [
427
+ 'div',
428
+ mergeAttributes(HTMLAttributes, { 'data-type': name, class: cssClass }),
429
+ ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, label],
430
+ ['div', { class: 'pilotiq-block-body' }, 0],
431
+ ];
432
+ },
433
+ });
434
+ }
435
+ export const ProsColumn = prosConsColumn('prosColumn', 'Pros', 'pilotiq-pros');
436
+ export const ConsColumn = prosConsColumn('consColumn', 'Cons', 'pilotiq-cons');
437
+ // ── Keyboard: delete a whole content block with Backspace at its start ──
438
+ //
439
+ // Custom blocks have no inline "remove" affordance, and Backspace inside a
440
+ // nested block (FAQ etc.) just deletes characters. So: Backspace at the very
441
+ // start of a content block removes the entire block — the reliable, keyboard
442
+ // way to delete one (the drag handle's click-to-select is the mouse way).
443
+ // faq is excluded — it handles Backspace itself (empty-question → remove item).
444
+ const DELETABLE_BLOCKS = new Set(['summary', 'keyTakeaways', 'alert', 'prosCons']);
445
+ export const ContentBlockKeymap = Extension.create({
446
+ name: 'pilotiqContentBlockKeymap',
447
+ addKeyboardShortcuts() {
448
+ return {
449
+ Backspace: ({ editor }) => {
450
+ const { $from, empty } = editor.state.selection;
451
+ if (!empty || $from.parentOffset !== 0)
452
+ return false;
453
+ for (let d = 1; d <= $from.depth; d++) {
454
+ const node = $from.node(d);
455
+ if (!DELETABLE_BLOCKS.has(node.type.name))
456
+ continue;
457
+ // Only when the cursor is at the block's very start (first child all
458
+ // the way down) — otherwise let Backspace delete normally.
459
+ let atStart = true;
460
+ for (let dd = $from.depth; dd > d; dd--) {
461
+ if ($from.index(dd - 1) !== 0) {
462
+ atStart = false;
463
+ break;
464
+ }
465
+ }
466
+ if (!atStart)
467
+ return false;
468
+ const start = $from.before(d);
469
+ return editor.chain().deleteRange({ from: start, to: start + node.nodeSize }).focus().run();
470
+ }
471
+ return false;
472
+ },
473
+ };
474
+ },
475
+ });
476
+ /** All inline content-block extensions — registered in the editor's list. */
477
+ export const contentBlockNodes = [
478
+ KeyTakeaways,
479
+ Summary,
480
+ Faq,
481
+ FaqItem,
482
+ FaqQuestion,
483
+ FaqAnswer,
484
+ Alert,
485
+ AlertTitle,
486
+ AlertBody,
487
+ ProsCons,
488
+ ProsColumn,
489
+ ConsColumn,
490
+ ContentBlockKeymap,
491
+ ];
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { RichTextField, DEFAULT_TOOLBAR_GROUPS, DEFAULT_TEXT_COLORS, DEFAULT_HIGHLIGHT_COLORS, type ColorSwatch, type RichTextAttachmentVisibility, type RichTextFieldMeta, type RichTextStorage, type ToolbarButtonId, type ToolbarGroups, } from './RichTextField.js';
2
2
  export { Block, type BlockMeta } from './Block.js';
3
+ export { defaultBlocks, faqBlock, alertBlock, summaryBlock, keyTakeawaysBlock, prosConsBlock, } from './blocks/index.js';
3
4
  export { MentionProvider, type MentionItem, type MentionProviderMeta, } from './MentionProvider.js';
4
5
  export { registerTiptap } from './register.js';
5
6
  export { createPlainTextEditor, plainTextOf, plainTextToDoc, type PlainTextEditorOptions, } from './PlainTextEditor.js';