@pilotiq/tiptap 3.16.0 → 3.17.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 +12 -0
- package/dist/extensions/SlashCommandExtension.js +12 -0
- package/dist/extensions/contentBlocks.d.ts +1 -0
- package/dist/extensions/contentBlocks.js +42 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/react/LabeledBlockNodeView.d.ts +13 -5
- package/dist/react/LabeledBlockNodeView.js +35 -7
- package/dist/react/useAiInlineDiff.js +18 -1
- package/dist/render.js +15 -4
- package/dist/surgicalOps.d.ts +15 -0
- package/dist/surgicalOps.js +45 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @pilotiq/tiptap
|
|
2
2
|
|
|
3
|
+
## 3.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4930902: Add semantic landmark content blocks and a content-preserving `wrap_blocks` surgical op.
|
|
8
|
+
|
|
9
|
+
- **`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.
|
|
10
|
+
- **`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.
|
|
11
|
+
- **`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.
|
|
12
|
+
|
|
13
|
+
These establish document landmarks so block-placement agents can position content deterministically (e.g. key-takeaways after the intro, FAQ after the conclusion).
|
|
14
|
+
|
|
3
15
|
## 3.16.0
|
|
4
16
|
|
|
5
17
|
### 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
|
|
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
|
-
|
|
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' },
|
|
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
|
-
|
|
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
|
@@ -11,5 +11,5 @@ export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js';
|
|
|
11
11
|
export { AiInlineDiffExtension, aiInlineDiffPluginKey, getAiInlineDiffState, type AiInlineDiffExtensionOptions, } from './extensions/AiInlineDiffExtension.js';
|
|
12
12
|
export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, 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
|
@@ -11,9 +11,9 @@ export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js';
|
|
|
11
11
|
export { AiInlineDiffExtension, aiInlineDiffPluginKey, getAiInlineDiffState, } from './extensions/AiInlineDiffExtension.js';
|
|
12
12
|
export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, 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
|
|
7
|
-
* bespoke component each; the per-block `label`
|
|
8
|
-
* `addOptions()` (see `labeledBlock()` in
|
|
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.
|
|
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
|
|
8
|
-
* bespoke component each; the per-block `label`
|
|
9
|
-
* `addOptions()` (see `labeledBlock()` in
|
|
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.
|
|
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
|
|
18
|
-
|
|
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
|
|
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
|
package/dist/surgicalOps.d.ts
CHANGED
|
@@ -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;
|
package/dist/surgicalOps.js
CHANGED
|
@@ -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
|