@pilotiq/tiptap 3.16.0 → 3.18.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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # @pilotiq/tiptap
2
2
 
3
+ ## 3.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 442df8a: Export `planWrapBlocks` from the package entry point.
8
+
9
+ `#148` shipped `planWrapBlocks` but left it internal (only `useAiInlineDiff` could reach it), so the Normalizer agent's wrap path had no way to be contract-tested against the real schema. It now sits alongside the other surgical planners (`planInsertBlockBefore` / `planReplaceBlock` / `planDeleteBlock`) in the public API, and a `surgicalOpsWrap.dom.test.ts` contract test pins its editor-side guarantees (content-preserving wrap, exactly one wrapper node, and the one-trailing-empty-paragraph rule when the wrap produces a terminal landmark) against the live `@pilotiq/tiptap` planners + schema — mirroring the FAQ-placement contract.
10
+
11
+ ## 3.17.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 4930902: Add semantic landmark content blocks and a content-preserving `wrap_blocks` surgical op.
16
+
17
+ - **`intro` block** — a labelled ("Introduction") landmark for the start of an article. Exported as `Intro` and registered in `contentBlockNodes`; rendered read-side via `renderRichTextToHtml`; available from the slash menu.
18
+ - **`summary` block variant** — `summary` gains a `variant: 'section' | 'article'` attr. `section` (default) keeps the "Summary" label for a mid-content paragraph summary; `article` labels it "In summary" for the end-of-article conclusion landmark. The block gear menu offers a Section/Article toggle and the slash menu gains an "Article summary" entry.
19
+ - **`wrap_blocks` surgical op** (`planWrapBlocks` + `useAiInlineDiff`) — wraps a contiguous run of top-level blocks `[fromIndex..toIndex]` into a single container node (`intro` / `summary` / `keyTakeaways`) using ProseMirror's own model, with no HTML round-trip, so marks and attrs are preserved verbatim. Lets agents turn unstructured prose into a landmark block without rewriting its text.
20
+
21
+ These establish document landmarks so block-placement agents can position content deterministically (e.g. key-takeaways after the intro, FAQ after the conclusion).
22
+
3
23
  ## 3.16.0
4
24
 
5
25
  ### Minor Changes
@@ -139,6 +139,11 @@ export function buildSlashItems(blocks, mergeTags, query, insert) {
139
139
  },
140
140
  // Inline content blocks — labelled, editable-in-place regions (nodes in
141
141
  // contentBlocks.ts). Inserted via insertContent; no custom commands.
142
+ {
143
+ key: 'intro', label: 'Intro', icon: '✍️', group: 'Content',
144
+ searchKey: 'intro introduction lead opening preamble',
145
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'intro', content: [{ type: 'paragraph' }] }).run(),
146
+ },
142
147
  {
143
148
  key: 'key-takeaways', label: 'Key takeaways', icon: '🔑', group: 'Content',
144
149
  searchKey: 'key takeaways points highlights tldr',
@@ -152,6 +157,13 @@ export function buildSlashItems(blocks, mergeTags, query, insert) {
152
157
  searchKey: 'summary tldr abstract overview',
153
158
  command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'summary', content: [{ type: 'paragraph' }] }).run(),
154
159
  },
160
+ {
161
+ // Same `summary` node, `article` variant — the end-of-article summary
162
+ // landmark (renders "In summary", sits before the FAQ).
163
+ key: 'article-summary', label: 'Article summary', icon: '📋', group: 'Content',
164
+ searchKey: 'article summary conclusion wrap up in summary closing landmark',
165
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'summary', attrs: { variant: 'article' }, content: [{ type: 'paragraph' }] }).run(),
166
+ },
155
167
  {
156
168
  key: 'faq', label: 'FAQ', icon: '❓', group: 'Content',
157
169
  searchKey: 'faq questions answers frequently asked',
@@ -2,6 +2,7 @@ import { Node, Extension } from '@tiptap/core';
2
2
  export { ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, type AlertType } from './alertVariants.js';
3
3
  export declare const KeyTakeaways: Node<any, any>;
4
4
  export declare const Summary: Node<any, any>;
5
+ export declare const Intro: Node<any, any>;
5
6
  export declare const Faq: Node<any, any>;
6
7
  export declare const FaqItem: Node<any, any>;
7
8
  export declare const FaqQuestion: Node<any, any>;
@@ -42,7 +42,8 @@ function widthAttribute() {
42
42
  * editor renders `LabeledBlockNodeView` (shared across every labelled block —
43
43
  * label + gear menu + width); the `renderHTML` below is the serialized/read
44
44
  * shape and mirrors it: outer anchor + inner `.pilotiq-block-content` wrapper.
45
- * `label`/`cssClass` ride `addOptions()` so the shared NodeView can read them.
45
+ * `label`/`cssClass`/`plain`/`variant` ride `addOptions()` so the shared
46
+ * NodeView can read them.
46
47
  */
47
48
  function labeledBlock(spec) {
48
49
  return Node.create({
@@ -51,20 +52,40 @@ function labeledBlock(spec) {
51
52
  content: 'block+',
52
53
  defining: true,
53
54
  addOptions() {
54
- return { label: spec.label, cssClass: spec.cssClass };
55
+ return { label: spec.label, cssClass: spec.cssClass, plain: spec.plain ?? false, variant: spec.variant ?? null };
55
56
  },
56
57
  addAttributes() {
57
- return { width: widthAttribute() };
58
+ const attrs = { width: widthAttribute() };
59
+ if (spec.variant) {
60
+ const v = spec.variant;
61
+ attrs['variant'] = {
62
+ default: v.default,
63
+ parseHTML: (el) => el.getAttribute('data-variant') ?? v.default,
64
+ renderHTML: (a) => a['variant'] && a['variant'] !== v.default ? { 'data-variant': a['variant'] } : {},
65
+ };
66
+ }
67
+ return attrs;
58
68
  },
59
69
  parseHTML() {
60
70
  return [{ tag: `div[data-type="${spec.name}"]`, contentElement: '.pilotiq-block-body' }];
61
71
  },
62
- renderHTML({ HTMLAttributes }) {
72
+ renderHTML({ node, HTMLAttributes }) {
73
+ // Plain (intro): no label, no content wrapper — body sits in the anchor.
74
+ if (spec.plain) {
75
+ return [
76
+ 'div',
77
+ mergeAttributes(HTMLAttributes, { 'data-type': spec.name, class: spec.cssClass }),
78
+ ['div', { class: 'pilotiq-block-body' }, 0],
79
+ ];
80
+ }
81
+ const label = spec.variant
82
+ ? (spec.variant.labels[node.attrs['variant']] ?? spec.label)
83
+ : spec.label;
63
84
  return [
64
85
  'div',
65
86
  mergeAttributes(HTMLAttributes, { 'data-type': spec.name, class: spec.cssClass }),
66
87
  ['div', { class: 'pilotiq-block-content' },
67
- ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, spec.label],
88
+ ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, label],
68
89
  ['div', { class: 'pilotiq-block-body' }, 0],
69
90
  ],
70
91
  ];
@@ -75,7 +96,20 @@ function labeledBlock(spec) {
75
96
  });
76
97
  }
77
98
  export const KeyTakeaways = labeledBlock({ name: 'keyTakeaways', label: 'Key takeaways', cssClass: 'pilotiq-key-takeaways' });
78
- export const Summary = labeledBlock({ name: 'summary', label: 'Summary', cssClass: 'pilotiq-summary' });
99
+ // `summary` has two purposes, switched by the `variant` attr: `section` (a
100
+ // paragraph/section summary used inside the body) and `article` (the
101
+ // end-of-article summary landmark that sits before the FAQ). One block, two
102
+ // labels — the Normalizer + future block agents read `variant` for placement.
103
+ export const Summary = labeledBlock({
104
+ name: 'summary',
105
+ label: 'Summary',
106
+ cssClass: 'pilotiq-summary',
107
+ variant: { default: 'section', labels: { section: 'Summary', article: 'In summary' } },
108
+ });
109
+ // `intro` is the article's opening landmark — a labelled region ("Introduction"
110
+ // above the body, like Summary / Key takeaways) so the landmark is visible to
111
+ // authors and block agents (e.g. key-takeaways) know to place after it.
112
+ export const Intro = labeledBlock({ name: 'intro', label: 'Introduction', cssClass: 'pilotiq-intro' });
79
113
  // ── FAQ — structured question / answer items ──
80
114
  //
81
115
  // `faq` > `faqItem+`; each item is a `faqQuestion` (inline text, "Q" marker) +
@@ -525,7 +559,7 @@ export const ConsColumn = prosConsColumn('consColumn', 'Cons', 'pilotiq-cons');
525
559
  // start of a content block removes the entire block — the reliable, keyboard
526
560
  // way to delete one (the drag handle's click-to-select is the mouse way).
527
561
  // faq is excluded — it handles Backspace itself (empty-question → remove item).
528
- const DELETABLE_BLOCKS = new Set(['summary', 'keyTakeaways', 'alert', 'prosCons']);
562
+ const DELETABLE_BLOCKS = new Set(['summary', 'keyTakeaways', 'intro', 'alert', 'prosCons']);
529
563
  export const ContentBlockKeymap = Extension.create({
530
564
  name: 'pilotiqContentBlockKeymap',
531
565
  addKeyboardShortcuts() {
@@ -561,6 +595,7 @@ export const ContentBlockKeymap = Extension.create({
561
595
  export const contentBlockNodes = [
562
596
  KeyTakeaways,
563
597
  Summary,
598
+ Intro,
564
599
  Faq,
565
600
  FaqItem,
566
601
  FaqQuestion,
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export { TiptapEditor } from './react/TiptapEditor.js';
9
9
  export { AiSuggestionExtension, aiSuggestionPluginKey, upsertSuggestion, upsertSuggestions, removeSuggestion, remapSuggestions, sortForApproveAll, clampPos, type AiSuggestion, type AiSuggestionExtensionOptions, } from './extensions/AiSuggestionExtension.js';
10
10
  export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js';
11
11
  export { AiInlineDiffExtension, aiInlineDiffPluginKey, getAiInlineDiffState, type AiInlineDiffExtensionOptions, } from './extensions/AiInlineDiffExtension.js';
12
- export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planUpdateBlockMark, summarizeBlockStructure, type BlockMarkRange, type TransactionModifier, } from './surgicalOps.js';
12
+ export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planWrapBlocks, planUpdateBlockMark, summarizeBlockStructure, type BlockMarkRange, type TransactionModifier, } from './surgicalOps.js';
13
13
  export { renderRichTextToHtml, isRichTextValue, type RenderRichTextOptions, type TiptapNode, type TiptapMark, } from './render.js';
14
- export { contentBlockNodes, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, type AlertType, } from './extensions/contentBlocks.js';
14
+ export { contentBlockNodes, Intro, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, type AlertType, } from './extensions/contentBlocks.js';
15
15
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -9,11 +9,11 @@ export { TiptapEditor } from './react/TiptapEditor.js';
9
9
  export { AiSuggestionExtension, aiSuggestionPluginKey, upsertSuggestion, upsertSuggestions, removeSuggestion, remapSuggestions, sortForApproveAll, clampPos, } from './extensions/AiSuggestionExtension.js';
10
10
  export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js';
11
11
  export { AiInlineDiffExtension, aiInlineDiffPluginKey, getAiInlineDiffState, } from './extensions/AiInlineDiffExtension.js';
12
- export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planUpdateBlockMark, summarizeBlockStructure, } from './surgicalOps.js';
12
+ export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planWrapBlocks, planUpdateBlockMark, summarizeBlockStructure, } from './surgicalOps.js';
13
13
  export { renderRichTextToHtml, isRichTextValue, } from './render.js';
14
- // Default content-block node specs (FAQ / Alert / Summary / Key takeaways /
14
+ // Default content-block node specs (Intro / FAQ / Alert / Summary / Key takeaways /
15
15
  // Pros & cons). `contentBlockNodes` is the exact array `TiptapEditor` registers,
16
16
  // so a consumer can build a headless editor whose schema matches the live
17
17
  // editor — e.g. to parse the content-block HTML or drive the surgical-op
18
18
  // planners (`planInsertBlockBefore` & co.) in a test, without mounting React.
19
- export { contentBlockNodes, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, } from './extensions/contentBlocks.js';
19
+ export { contentBlockNodes, Intro, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, } from './extensions/contentBlocks.js';
@@ -3,14 +3,22 @@ import { type NodeViewProps } from '@tiptap/react';
3
3
  /**
4
4
  * Shared React NodeView for the simple **labelled** content blocks — the ones
5
5
  * that are just a non-editable label above a `block+` body (Key takeaways,
6
- * Summary). They rhyme structurally, so they share ONE NodeView instead of a
7
- * bespoke component each; the per-block `label` + `cssClass` ride the node's
8
- * `addOptions()` (see `labeledBlock()` in `extensions/contentBlocks.ts`).
6
+ * Summary, Intro). They rhyme structurally, so they share ONE NodeView instead
7
+ * of a bespoke component each; the per-block `label` / `cssClass` / `plain` /
8
+ * `variant` ride the node's `addOptions()` (see `labeledBlock()` in
9
+ * `extensions/contentBlocks.ts`).
10
+ *
11
+ * Three shapes, one component:
12
+ * - **labelled** (default) — non-editable label above the body.
13
+ * - **variant** (summary) — label resolved from the active `variant` attr,
14
+ * plus a "Type" toggle in the gear menu (e.g. Summary ↔ In summary).
15
+ * - **plain** (intro) — no label, no max-width wrapper: ordinary prose with a
16
+ * faint hover-revealed affordance so authors can still see it's a landmark.
17
+ * The read-side renderer emits no label for plain blocks.
9
18
  *
10
19
  * The two-layer shell (full-width anchor + gear + the inner
11
20
  * `.pilotiq-block-content` that carries the max-width / centering) lives in the
12
- * shared `BlockGearShell`, alongside the Width gear setting. This component
13
- * only supplies the label + body content hole.
21
+ * shared `BlockGearShell`, alongside the Width gear setting.
14
22
  */
15
23
  export declare function LabeledBlockNodeView({ node, updateAttributes, editor, extension }: NodeViewProps): ReactElement;
16
24
  //# sourceMappingURL=LabeledBlockNodeView.d.ts.map
@@ -4,16 +4,44 @@ import { BlockGearShell } from './BlockGearShell.js';
4
4
  /**
5
5
  * Shared React NodeView for the simple **labelled** content blocks — the ones
6
6
  * that are just a non-editable label above a `block+` body (Key takeaways,
7
- * Summary). They rhyme structurally, so they share ONE NodeView instead of a
8
- * bespoke component each; the per-block `label` + `cssClass` ride the node's
9
- * `addOptions()` (see `labeledBlock()` in `extensions/contentBlocks.ts`).
7
+ * Summary, Intro). They rhyme structurally, so they share ONE NodeView instead
8
+ * of a bespoke component each; the per-block `label` / `cssClass` / `plain` /
9
+ * `variant` ride the node's `addOptions()` (see `labeledBlock()` in
10
+ * `extensions/contentBlocks.ts`).
11
+ *
12
+ * Three shapes, one component:
13
+ * - **labelled** (default) — non-editable label above the body.
14
+ * - **variant** (summary) — label resolved from the active `variant` attr,
15
+ * plus a "Type" toggle in the gear menu (e.g. Summary ↔ In summary).
16
+ * - **plain** (intro) — no label, no max-width wrapper: ordinary prose with a
17
+ * faint hover-revealed affordance so authors can still see it's a landmark.
18
+ * The read-side renderer emits no label for plain blocks.
10
19
  *
11
20
  * The two-layer shell (full-width anchor + gear + the inner
12
21
  * `.pilotiq-block-content` that carries the max-width / centering) lives in the
13
- * shared `BlockGearShell`, alongside the Width gear setting. This component
14
- * only supplies the label + body content hole.
22
+ * shared `BlockGearShell`, alongside the Width gear setting.
15
23
  */
16
24
  export function LabeledBlockNodeView({ node, updateAttributes, editor, extension }) {
17
- const { label, cssClass } = extension.options;
18
- return (_jsx(BlockGearShell, { node: node, editor: editor, updateAttributes: updateAttributes, cssClass: cssClass, settingsLabel: label + ' settings', children: _jsxs("div", { className: "pilotiq-block-content", children: [_jsx("div", { className: "pilotiq-block-label", contentEditable: false, children: label }), _jsx(NodeViewContent, { className: "pilotiq-block-body" })] }) }));
25
+ const opts = extension.options;
26
+ // Plain (intro): render as ordinary prose. The faint affordance is editor-
27
+ // only and hover-revealed (mirrors the gear's hover reveal in BlockGearShell).
28
+ if (opts.plain) {
29
+ return (_jsxs(BlockGearShell, { node: node, editor: editor, updateAttributes: updateAttributes, cssClass: opts.cssClass, settingsLabel: opts.label + ' settings', children: [editor.isEditable && (_jsx("div", { contentEditable: false, className: 'pointer-events-none absolute -top-2.5 left-0 text-[0.65rem] font-medium uppercase tracking-wide ' +
30
+ 'text-muted-foreground opacity-0 transition-opacity [.' + opts.cssClass + ':hover_&]:opacity-60', children: opts.label })), _jsx(NodeViewContent, { className: "pilotiq-block-body" })] }));
31
+ }
32
+ const label = opts.variant
33
+ ? (opts.variant.labels[node.attrs['variant']] ?? opts.label)
34
+ : opts.label;
35
+ // Multi-variant blocks (summary) expose a "Type" select in the gear menu.
36
+ const extraSettings = opts.variant
37
+ ? [{
38
+ kind: 'select',
39
+ key: 'variant',
40
+ label: 'Type',
41
+ value: node.attrs['variant'] ?? opts.variant.default,
42
+ options: Object.entries(opts.variant.labels).map(([value, lbl]) => ({ value, label: lbl })),
43
+ onChange: (v) => updateAttributes({ variant: v }),
44
+ }]
45
+ : [];
46
+ return (_jsx(BlockGearShell, { node: node, editor: editor, updateAttributes: updateAttributes, cssClass: opts.cssClass, settingsLabel: label + ' settings', extraSettings: extraSettings, children: _jsxs("div", { className: "pilotiq-block-content", children: [_jsx("div", { className: "pilotiq-block-label", contentEditable: false, children: label }), _jsx(NodeViewContent, { className: "pilotiq-block-body" })] }) }));
19
47
  }
@@ -33,7 +33,7 @@ import { useEffect, useRef } from 'react';
33
33
  import { useEditorState } from '@tiptap/react';
34
34
  import { registerPendingSuggestionApplier, usePendingSuggestionsForField, useFormId, } from '@pilotiq/pilotiq/react';
35
35
  import { aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js';
36
- import { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planUpdateBlockMark, } from '../surgicalOps.js';
36
+ import { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planUpdateBlockMark, planWrapBlocks, } from '../surgicalOps.js';
37
37
  /**
38
38
  * Read the field's `.aiDiffView(...)` choice off the DOM — the
39
39
  * `@pilotiq-pro/ai` field augmentation stamps `data-ai-diff-view` onto
@@ -231,6 +231,22 @@ function parseSurgicalOp(obj) {
231
231
  }
232
232
  case 'delete_block':
233
233
  return { op, blockIndex };
234
+ case 'wrap_blocks': {
235
+ const toIndex = obj['toIndex'];
236
+ const wrapperType = obj['wrapperType'];
237
+ if (typeof toIndex !== 'number')
238
+ return null;
239
+ if (typeof wrapperType !== 'string')
240
+ return null;
241
+ const attrs = obj['attrs'];
242
+ return {
243
+ op,
244
+ blockIndex,
245
+ toIndex,
246
+ wrapperType,
247
+ ...(attrs && typeof attrs === 'object' ? { attrs: attrs } : {}),
248
+ };
249
+ }
234
250
  case 'update_block_mark': {
235
251
  const mark = obj['mark'];
236
252
  const range = obj['range'];
@@ -283,6 +299,7 @@ function planOp(editor, op) {
283
299
  case 'insert_block_before': return planInsertBlockBefore(editor, op.blockIndex, op.content);
284
300
  case 'delete_block': return planDeleteBlock(editor, op.blockIndex);
285
301
  case 'update_block_mark': return planUpdateBlockMark(editor, op.blockIndex, op.mark, op.range, op.apply, op.attrs);
302
+ case 'wrap_blocks': return planWrapBlocks(editor, op.blockIndex, op.toIndex, op.wrapperType, op.attrs);
286
303
  }
287
304
  }
288
305
  /**
package/dist/render.js CHANGED
@@ -146,7 +146,8 @@ function renderNode(node, opts) {
146
146
  case 'grid': return renderGrid(n, opts);
147
147
  case 'gridColumn': return wrap('div', n, opts);
148
148
  case 'keyTakeaways': return labeledBlockHtml('pilotiq-key-takeaways', 'Key takeaways', n, opts, true);
149
- case 'summary': return labeledBlockHtml('pilotiq-summary', 'Summary', n, opts, true);
149
+ case 'summary': return renderSummary(n, opts);
150
+ case 'intro': return labeledBlockHtml('pilotiq-intro', 'Introduction', n, opts, true);
150
151
  case 'faq': return renderFaqNode(n, opts);
151
152
  case 'faqItem': return renderFaqItem(n, opts);
152
153
  case 'faqQuestion': return `<summary class="pilotiq-faq-question">${renderChildren(n, opts)}</summary>`;
@@ -362,7 +363,7 @@ function clampGridColumnsForRender(raw) {
362
363
  // Mirrors the editor's `renderHTML` (extensions/contentBlocks.ts): a small
363
364
  // label above an editable body. Consumer owns the `pilotiq-*` CSS. Covers
364
365
  // keyTakeaways / summary / faq / alert / prosCons (+ pros/cons columns).
365
- function labeledBlockHtml(cssClass, label, n, opts, wrap = false) {
366
+ function labeledBlockHtml(cssClass, label, n, opts, wrap = false, extraAttr = '') {
366
367
  const inner = `<div class="pilotiq-block-label">${escapeHtml(label)}</div>` +
367
368
  `<div class="pilotiq-block-body">${renderChildren(n, opts)}</div>`;
368
369
  // Top-level labelled blocks (summary / keyTakeaways) carry the gear menu, so
@@ -370,9 +371,19 @@ function labeledBlockHtml(cssClass, label, n, opts, wrap = false) {
370
371
  // `.pilotiq-block-content` that holds the max-width / width toggle. Columns
371
372
  // inside Pros & cons render flat (no width of their own).
372
373
  if (!wrap)
373
- return `<div class="${cssClass}">${inner}</div>`;
374
+ return `<div class="${cssClass}"${extraAttr}>${inner}</div>`;
374
375
  const width = n.attrs?.['width'] === 'full' ? ' data-width="full"' : '';
375
- return `<div class="${cssClass}"${width}><div class="pilotiq-block-content">${inner}</div></div>`;
376
+ return `<div class="${cssClass}"${width}${extraAttr}><div class="pilotiq-block-content">${inner}</div></div>`;
377
+ }
378
+ // `summary` carries a `variant` attr (`section` default | `article`). The label
379
+ // + a `data-variant` styling hook differ per variant; otherwise it's the shared
380
+ // labelled-block shape. The Normalizer + block agents use `article` as the
381
+ // end-of-article landmark (sits before the FAQ).
382
+ function renderSummary(n, opts) {
383
+ const isArticle = n.attrs?.['variant'] === 'article';
384
+ const label = isArticle ? 'In summary' : 'Summary';
385
+ const extraAttr = isArticle ? ' data-variant="article"' : '';
386
+ return labeledBlockHtml('pilotiq-summary', label, n, opts, true, extraAttr);
376
387
  }
377
388
  // Pros & cons — two labelled columns. Same two-layer anchor as the labelled
378
389
  // blocks: a full-width outer (`.pilotiq-pros-cons`) + an inner
@@ -42,6 +42,21 @@ export declare function planInsertBlockBefore(editor: Editor, blockIndex: number
42
42
  * delete the last remaining block.
43
43
  */
44
44
  export declare function planDeleteBlock(editor: Editor, blockIndex: number): TransactionModifier | null;
45
+ /**
46
+ * Wrap the contiguous top-level blocks `[fromIndex .. toIndex]` (inclusive)
47
+ * into a single `wrapperType` container node — content-preserving, no HTML
48
+ * round-trip. Used by the Normalizer agent to turn a run of prose into a
49
+ * landmark block (`intro` / `summary` / `keyTakeaways`) WITHOUT rewriting the
50
+ * text (the existing replace/insert ops would need the slice re-serialized to
51
+ * HTML, which has no clean utility here and risks dropping marks/attrs).
52
+ *
53
+ * Returns `null` for an out-of-range or inverted range, an unknown wrapper
54
+ * type, or when the wrapper's content schema can't hold the wrapped blocks
55
+ * (`createAndFill` yields null). Batches sort DESC by `fromIndex` (carried as
56
+ * `blockIndex` by the caller) so disjoint wraps planned against the original
57
+ * doc stay position-valid.
58
+ */
59
+ export declare function planWrapBlocks(editor: Editor, fromIndex: number, toIndex: number, wrapperType: string, attrs?: Record<string, unknown>): TransactionModifier | null;
45
60
  export interface BlockMarkRange {
46
61
  /** 0-based text offset from the start of the block's content. */
47
62
  from: number;
@@ -113,6 +113,51 @@ export function planDeleteBlock(editor, blockIndex) {
113
113
  const end = start + doc.child(blockIndex).nodeSize;
114
114
  return (tr) => { tr.delete(start, end); };
115
115
  }
116
+ /**
117
+ * Wrap the contiguous top-level blocks `[fromIndex .. toIndex]` (inclusive)
118
+ * into a single `wrapperType` container node — content-preserving, no HTML
119
+ * round-trip. Used by the Normalizer agent to turn a run of prose into a
120
+ * landmark block (`intro` / `summary` / `keyTakeaways`) WITHOUT rewriting the
121
+ * text (the existing replace/insert ops would need the slice re-serialized to
122
+ * HTML, which has no clean utility here and risks dropping marks/attrs).
123
+ *
124
+ * Returns `null` for an out-of-range or inverted range, an unknown wrapper
125
+ * type, or when the wrapper's content schema can't hold the wrapped blocks
126
+ * (`createAndFill` yields null). Batches sort DESC by `fromIndex` (carried as
127
+ * `blockIndex` by the caller) so disjoint wraps planned against the original
128
+ * doc stay position-valid.
129
+ */
130
+ export function planWrapBlocks(editor, fromIndex, toIndex, wrapperType, attrs) {
131
+ const doc = editor.state.doc;
132
+ if (!Number.isInteger(fromIndex) || !Number.isInteger(toIndex))
133
+ return null;
134
+ if (fromIndex < 0 || toIndex < fromIndex || toIndex >= doc.childCount)
135
+ return null;
136
+ const nodeType = editor.schema.nodes[wrapperType];
137
+ if (!nodeType)
138
+ return null;
139
+ const start = blockStartPos(doc, fromIndex);
140
+ if (start === null)
141
+ return null;
142
+ const children = [];
143
+ let end = start;
144
+ for (let i = fromIndex; i <= toIndex; i++) {
145
+ const child = doc.child(i);
146
+ children.push(child);
147
+ end += child.nodeSize;
148
+ }
149
+ // Build the wrapper with the existing blocks as its content; bail if the
150
+ // schema rejects them (so a bad request never produces an invalid doc).
151
+ // Pass the raw node array (NOT a separately-imported `Fragment`) — `createAndFill`
152
+ // builds the fragment with the EDITOR's prosemirror-model, so the wrapped nodes
153
+ // and the wrapper share one model instance. Mixing a `Fragment` from this
154
+ // module's own `@tiptap/pm/model` import throws "Can not convert … to a Fragment"
155
+ // whenever two prosemirror-model copies are loaded (e.g. a yalc-linked build).
156
+ const wrapped = nodeType.createAndFill(attrs ?? null, children);
157
+ if (!wrapped)
158
+ return null;
159
+ return (tr) => { tr.replaceWith(start, end, wrapped); };
160
+ }
116
161
  /**
117
162
  * Apply or remove an inline mark on a range *within* the block at
118
163
  * `blockIndex`. `range.from` / `range.to` are text offsets relative to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.16.0",
3
+ "version": "3.18.0",
4
4
  "description": "Tiptap rich-text editor adapter for @pilotiq/pilotiq — slash menu, draggable blocks, custom-block API",
5
5
  "license": "MIT",
6
6
  "repository": {