@pilotiq/tiptap 3.12.0 → 3.13.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 +14 -0
- package/dist/Block.d.ts +8 -2
- package/dist/Block.js +9 -3
- package/dist/RichTextField.d.ts +17 -2
- package/dist/RichTextField.js +38 -7
- package/dist/blocks/alert.d.ts +10 -0
- package/dist/blocks/alert.js +23 -0
- package/dist/blocks/faq.d.ts +9 -0
- package/dist/blocks/faq.js +21 -0
- package/dist/blocks/index.d.ts +22 -0
- package/dist/blocks/index.js +20 -0
- package/dist/blocks/keyTakeaways.d.ts +10 -0
- package/dist/blocks/keyTakeaways.js +15 -0
- package/dist/blocks/prosCons.d.ts +9 -0
- package/dist/blocks/prosCons.js +15 -0
- package/dist/blocks/summary.d.ts +9 -0
- package/dist/blocks/summary.js +14 -0
- package/dist/extensions/DragHandleExtension.js +20 -3
- package/dist/extensions/SlashCommandExtension.js +71 -0
- package/dist/extensions/contentBlocks.d.ts +19 -0
- package/dist/extensions/contentBlocks.js +281 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/react/TiptapEditor.js +4 -0
- package/dist/render.d.ts +5 -2
- package/dist/render.js +136 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @pilotiq/tiptap
|
|
2
2
|
|
|
3
|
+
## 3.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ded54a8: Ship five built-in **inline content blocks** in every `RichTextField` (free + pro): **FAQ**, **Alert** (info/warning/success/tip), **Summary**, **Key takeaways**, and **Pros & cons**.
|
|
8
|
+
|
|
9
|
+
They are **inline editable nodes** — a small label on top, content typed straight into the block in place (no card, no popup, no border/background). Inserted from the slash menu's **Content** group. Each renders read-side to semantic `pilotiq-*` HTML via `renderRichTextToHtml` (consumer owns the CSS). Quote and Table remain the native `blockquote` / table extensions.
|
|
10
|
+
|
|
11
|
+
- Nodes live in `extensions/contentBlocks.ts`; registered by default in the editor.
|
|
12
|
+
- Alert's type is the label (Info/Warning/Success/Tip), chosen from the slash menu.
|
|
13
|
+
- Pros & cons is two labelled list columns.
|
|
14
|
+
|
|
15
|
+
The `Block.make().schema([...])` API (card + side-panel form) stays for **custom** blocks via `RichTextField.blocks([...])`, but no schema block ships as a default. `Block.toMeta()` / `RichTextField.toMeta()` are now `async` so option-fields (Select/Radio/ToggleButtons) resolve correctly inside custom schema blocks.
|
|
16
|
+
|
|
3
17
|
## 3.12.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/dist/Block.d.ts
CHANGED
|
@@ -41,7 +41,13 @@ export declare class Block {
|
|
|
41
41
|
getLabel(): string;
|
|
42
42
|
getIcon(): string | undefined;
|
|
43
43
|
getSchema(): readonly Field[];
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* @internal
|
|
46
|
+
* Async because schema fields can be async — `SelectField` / `RadioField` /
|
|
47
|
+
* `ToggleButtonsField` resolve their options at meta-build time and return a
|
|
48
|
+
* `Promise<FieldMeta>`. We await every field so the serialized schema never
|
|
49
|
+
* contains an unresolved Promise (which would JSON-stringify to `{}`).
|
|
50
|
+
*/
|
|
51
|
+
toMeta(): Promise<BlockMeta>;
|
|
46
52
|
}
|
|
47
53
|
//# sourceMappingURL=Block.d.ts.map
|
package/dist/Block.js
CHANGED
|
@@ -43,13 +43,19 @@ export class Block {
|
|
|
43
43
|
getLabel() { return this._label ?? this._name; }
|
|
44
44
|
getIcon() { return this._icon; }
|
|
45
45
|
getSchema() { return this._schema; }
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* @internal
|
|
48
|
+
* Async because schema fields can be async — `SelectField` / `RadioField` /
|
|
49
|
+
* `ToggleButtonsField` resolve their options at meta-build time and return a
|
|
50
|
+
* `Promise<FieldMeta>`. We await every field so the serialized schema never
|
|
51
|
+
* contains an unresolved Promise (which would JSON-stringify to `{}`).
|
|
52
|
+
*/
|
|
53
|
+
async toMeta() {
|
|
48
54
|
return {
|
|
49
55
|
name: this._name,
|
|
50
56
|
label: this._label ?? this._name,
|
|
51
57
|
icon: this._icon,
|
|
52
|
-
schema: this._schema.map((f) => f.toMeta()),
|
|
58
|
+
schema: (await Promise.all(this._schema.map((f) => f.toMeta()))),
|
|
53
59
|
};
|
|
54
60
|
}
|
|
55
61
|
}
|
package/dist/RichTextField.d.ts
CHANGED
|
@@ -107,6 +107,7 @@ export interface RichTextFieldMeta extends FieldMeta {
|
|
|
107
107
|
*/
|
|
108
108
|
export declare class RichTextField extends Field {
|
|
109
109
|
private _blocks;
|
|
110
|
+
private _includeDefaultBlocks;
|
|
110
111
|
private _slashCommand;
|
|
111
112
|
/** `undefined` = use default, `null` = hidden, array = explicit override. */
|
|
112
113
|
private _toolbarOverride;
|
|
@@ -127,8 +128,22 @@ export declare class RichTextField extends Field {
|
|
|
127
128
|
private _mentionsUrl?;
|
|
128
129
|
private constructor();
|
|
129
130
|
static make(name: string): RichTextField;
|
|
130
|
-
/** Custom blocks available via the slash menu. */
|
|
131
|
+
/** Custom blocks available via the slash menu (merged on top of the defaults). */
|
|
131
132
|
blocks(blocks: Block[]): this;
|
|
133
|
+
/**
|
|
134
|
+
* Toggle the built-in default blocks (FAQ, Alert, Summary, Key takeaways,
|
|
135
|
+
* Pros & cons) for this field. On by default — every field gets them in the
|
|
136
|
+
* slash menu and the agent block catalog. Pass `false` to ship only the
|
|
137
|
+
* blocks declared via `.blocks([...])`.
|
|
138
|
+
*/
|
|
139
|
+
withDefaultBlocks(enabled?: boolean): this;
|
|
140
|
+
/**
|
|
141
|
+
* The blocks this field actually exposes — the default set (unless opted
|
|
142
|
+
* out) merged with the field's own `.blocks([...])`. A field block with the
|
|
143
|
+
* same name overrides the default. This is what the editor slash menu, the
|
|
144
|
+
* side panel, and the agent block catalog all consume.
|
|
145
|
+
*/
|
|
146
|
+
private resolveBlocks;
|
|
132
147
|
/** Toggle the slash menu (`/`) on/off. Defaults to `true`. */
|
|
133
148
|
slashCommand(enabled: boolean): this;
|
|
134
149
|
/**
|
|
@@ -281,6 +296,6 @@ export declare class RichTextField extends Field {
|
|
|
281
296
|
*/
|
|
282
297
|
getToolbarGroups(): ToolbarGroups | null;
|
|
283
298
|
isFloatingToolbarEnabled(): boolean;
|
|
284
|
-
toMeta(ctx?: RenderContext): RichTextFieldMeta
|
|
299
|
+
toMeta(ctx?: RenderContext): Promise<RichTextFieldMeta>;
|
|
285
300
|
}
|
|
286
301
|
//# sourceMappingURL=RichTextField.d.ts.map
|
package/dist/RichTextField.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Field } from '@pilotiq/pilotiq';
|
|
2
|
+
import { defaultBlocks } from './blocks/index.js';
|
|
2
3
|
/** Default top-level toolbar groups — mirrors the reference admin's layout. */
|
|
3
4
|
export const DEFAULT_TOOLBAR_GROUPS = [
|
|
4
5
|
['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link'],
|
|
@@ -59,6 +60,7 @@ export const DEFAULT_HIGHLIGHT_COLORS = [
|
|
|
59
60
|
*/
|
|
60
61
|
export class RichTextField extends Field {
|
|
61
62
|
_blocks = [];
|
|
63
|
+
_includeDefaultBlocks = true;
|
|
62
64
|
_slashCommand = true;
|
|
63
65
|
/** `undefined` = use default, `null` = hidden, array = explicit override. */
|
|
64
66
|
_toolbarOverride = undefined;
|
|
@@ -83,11 +85,37 @@ export class RichTextField extends Field {
|
|
|
83
85
|
static make(name) {
|
|
84
86
|
return new RichTextField(name);
|
|
85
87
|
}
|
|
86
|
-
/** Custom blocks available via the slash menu. */
|
|
88
|
+
/** Custom blocks available via the slash menu (merged on top of the defaults). */
|
|
87
89
|
blocks(blocks) {
|
|
88
90
|
this._blocks = blocks;
|
|
89
91
|
return this;
|
|
90
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Toggle the built-in default blocks (FAQ, Alert, Summary, Key takeaways,
|
|
95
|
+
* Pros & cons) for this field. On by default — every field gets them in the
|
|
96
|
+
* slash menu and the agent block catalog. Pass `false` to ship only the
|
|
97
|
+
* blocks declared via `.blocks([...])`.
|
|
98
|
+
*/
|
|
99
|
+
withDefaultBlocks(enabled = true) {
|
|
100
|
+
this._includeDefaultBlocks = enabled;
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The blocks this field actually exposes — the default set (unless opted
|
|
105
|
+
* out) merged with the field's own `.blocks([...])`. A field block with the
|
|
106
|
+
* same name overrides the default. This is what the editor slash menu, the
|
|
107
|
+
* side panel, and the agent block catalog all consume.
|
|
108
|
+
*/
|
|
109
|
+
resolveBlocks() {
|
|
110
|
+
const byName = new Map();
|
|
111
|
+
if (this._includeDefaultBlocks) {
|
|
112
|
+
for (const b of defaultBlocks)
|
|
113
|
+
byName.set(b.getName(), b);
|
|
114
|
+
}
|
|
115
|
+
for (const b of this._blocks)
|
|
116
|
+
byName.set(b.getName(), b);
|
|
117
|
+
return [...byName.values()];
|
|
118
|
+
}
|
|
91
119
|
/** Toggle the slash menu (`/`) on/off. Defaults to `true`. */
|
|
92
120
|
slashCommand(enabled) {
|
|
93
121
|
this._slashCommand = enabled;
|
|
@@ -256,7 +284,7 @@ export class RichTextField extends Field {
|
|
|
256
284
|
this._mentions = providers;
|
|
257
285
|
return this;
|
|
258
286
|
}
|
|
259
|
-
getBlocks() { return this.
|
|
287
|
+
getBlocks() { return this.resolveBlocks(); }
|
|
260
288
|
getMergeTags() { return this._mergeTags; }
|
|
261
289
|
getMentionProviders() { return this._mentions; }
|
|
262
290
|
/**
|
|
@@ -326,10 +354,13 @@ export class RichTextField extends Field {
|
|
|
326
354
|
isFloatingToolbarEnabled() {
|
|
327
355
|
return this._floatingToolbar;
|
|
328
356
|
}
|
|
329
|
-
toMeta(ctx) {
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
357
|
+
async toMeta(ctx) {
|
|
358
|
+
// Block fields can be async — Select / Radio / ToggleButtons resolve their
|
|
359
|
+
// options at meta-build time and return `Promise<FieldMeta>`. So block
|
|
360
|
+
// metas, and therefore this method, are async. The core resolves every
|
|
361
|
+
// field via `await field.toMeta(ctx)` (`resolveField`), so returning a
|
|
362
|
+
// Promise is the supported path.
|
|
363
|
+
const base = (await Promise.resolve(super.toMeta(ctx)));
|
|
333
364
|
// Strip `attachFiles` server-side when the panel hasn't registered an
|
|
334
365
|
// upload adapter — same posture as `MarkdownField` and the editor
|
|
335
366
|
// chrome stays clean. `uploadUrl` is the wire-side URL for the picker
|
|
@@ -342,7 +373,7 @@ export class RichTextField extends Field {
|
|
|
342
373
|
.filter(g => g.length > 0) ?? null;
|
|
343
374
|
return {
|
|
344
375
|
...base,
|
|
345
|
-
blocks: this.
|
|
376
|
+
blocks: await Promise.all(this.resolveBlocks().map((b) => b.toMeta())),
|
|
346
377
|
slashCommand: this._slashCommand,
|
|
347
378
|
toolbarGroups: filteredGroups,
|
|
348
379
|
floatingToolbar: this._floatingToolbar,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Block } from '../Block.js';
|
|
2
|
+
/**
|
|
3
|
+
* Default **Alert** (callout) block — a typed notice.
|
|
4
|
+
*
|
|
5
|
+
* Wire shape: `{ type: 'info' | 'warning' | 'success' | 'tip'; content: string }`.
|
|
6
|
+
* Renders read-side as `<div class="pilotiq-alert pilotiq-alert-<type>">`. An
|
|
7
|
+
* empty / unknown `type` falls back to `info` at render time.
|
|
8
|
+
*/
|
|
9
|
+
export declare const alertBlock: Block;
|
|
10
|
+
//# sourceMappingURL=alert.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SelectField, TextareaField } from '@pilotiq/pilotiq';
|
|
2
|
+
import { Block } from '../Block.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default **Alert** (callout) block — a typed notice.
|
|
5
|
+
*
|
|
6
|
+
* Wire shape: `{ type: 'info' | 'warning' | 'success' | 'tip'; content: string }`.
|
|
7
|
+
* Renders read-side as `<div class="pilotiq-alert pilotiq-alert-<type>">`. An
|
|
8
|
+
* empty / unknown `type` falls back to `info` at render time.
|
|
9
|
+
*/
|
|
10
|
+
export const alertBlock = Block.make('alert')
|
|
11
|
+
.label('Alert')
|
|
12
|
+
.icon('⚠️')
|
|
13
|
+
.schema([
|
|
14
|
+
SelectField.make('type')
|
|
15
|
+
.label('Type')
|
|
16
|
+
.options([
|
|
17
|
+
{ value: 'info', label: 'Info' },
|
|
18
|
+
{ value: 'warning', label: 'Warning' },
|
|
19
|
+
{ value: 'success', label: 'Success' },
|
|
20
|
+
{ value: 'tip', label: 'Tip' },
|
|
21
|
+
]),
|
|
22
|
+
TextareaField.make('content').label('Content').placeholder('Alert message…'),
|
|
23
|
+
]);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Block } from '../Block.js';
|
|
2
|
+
/**
|
|
3
|
+
* Default **FAQ** block — a list of question / answer pairs.
|
|
4
|
+
*
|
|
5
|
+
* Wire shape: `{ items: Array<{ question: string; answer: string }> }`.
|
|
6
|
+
* Renders read-side as a `<div class="pilotiq-faq">` of `<details>` items.
|
|
7
|
+
*/
|
|
8
|
+
export declare const faqBlock: Block;
|
|
9
|
+
//# sourceMappingURL=faq.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Repeater, TextField, TextareaField } from '@pilotiq/pilotiq';
|
|
2
|
+
import { Block } from '../Block.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default **FAQ** block — a list of question / answer pairs.
|
|
5
|
+
*
|
|
6
|
+
* Wire shape: `{ items: Array<{ question: string; answer: string }> }`.
|
|
7
|
+
* Renders read-side as a `<div class="pilotiq-faq">` of `<details>` items.
|
|
8
|
+
*/
|
|
9
|
+
export const faqBlock = Block.make('faq')
|
|
10
|
+
.label('FAQ')
|
|
11
|
+
.icon('❓')
|
|
12
|
+
.schema([
|
|
13
|
+
Repeater.make('items')
|
|
14
|
+
.label('Questions')
|
|
15
|
+
.schema([
|
|
16
|
+
TextField.make('question').label('Question').placeholder('What is …?'),
|
|
17
|
+
TextareaField.make('answer').label('Answer').placeholder('A short, direct answer.'),
|
|
18
|
+
])
|
|
19
|
+
.reorderable()
|
|
20
|
+
.addActionLabel('Add question'),
|
|
21
|
+
]);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Block } from '../Block.js';
|
|
2
|
+
/**
|
|
3
|
+
* Schema-form block factories — `Block.make().schema([...])` style, edited via
|
|
4
|
+
* the right-docked side panel. Kept for **custom / legacy** use via
|
|
5
|
+
* `RichTextField.blocks([...])`. They are NO LONGER defaults.
|
|
6
|
+
*
|
|
7
|
+
* The built-in content blocks — FAQ, Alert, Summary, Key takeaways, Pros & cons
|
|
8
|
+
* — are now **inline editable nodes** (`extensions/contentBlocks.ts`):
|
|
9
|
+
* labelled, edited in place, registered directly in the editor + slash menu.
|
|
10
|
+
*/
|
|
11
|
+
export { faqBlock } from './faq.js';
|
|
12
|
+
export { alertBlock } from './alert.js';
|
|
13
|
+
export { summaryBlock } from './summary.js';
|
|
14
|
+
export { keyTakeawaysBlock } from './keyTakeaways.js';
|
|
15
|
+
export { prosConsBlock } from './prosCons.js';
|
|
16
|
+
/**
|
|
17
|
+
* Schema blocks shipped by default in every `RichTextField`. Empty — the
|
|
18
|
+
* default content blocks are inline nodes now, not schema-form blocks. A
|
|
19
|
+
* field's own `.blocks([...])` still merge in via `RichTextField`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const defaultBlocks: readonly Block[];
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-form block factories — `Block.make().schema([...])` style, edited via
|
|
3
|
+
* the right-docked side panel. Kept for **custom / legacy** use via
|
|
4
|
+
* `RichTextField.blocks([...])`. They are NO LONGER defaults.
|
|
5
|
+
*
|
|
6
|
+
* The built-in content blocks — FAQ, Alert, Summary, Key takeaways, Pros & cons
|
|
7
|
+
* — are now **inline editable nodes** (`extensions/contentBlocks.ts`):
|
|
8
|
+
* labelled, edited in place, registered directly in the editor + slash menu.
|
|
9
|
+
*/
|
|
10
|
+
export { faqBlock } from './faq.js';
|
|
11
|
+
export { alertBlock } from './alert.js';
|
|
12
|
+
export { summaryBlock } from './summary.js';
|
|
13
|
+
export { keyTakeawaysBlock } from './keyTakeaways.js';
|
|
14
|
+
export { prosConsBlock } from './prosCons.js';
|
|
15
|
+
/**
|
|
16
|
+
* Schema blocks shipped by default in every `RichTextField`. Empty — the
|
|
17
|
+
* default content blocks are inline nodes now, not schema-form blocks. A
|
|
18
|
+
* field's own `.blocks([...])` still merge in via `RichTextField`.
|
|
19
|
+
*/
|
|
20
|
+
export const defaultBlocks = [];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Block } from '../Block.js';
|
|
2
|
+
/**
|
|
3
|
+
* Default **Key takeaways** block — a short bulleted list of the article's
|
|
4
|
+
* main points.
|
|
5
|
+
*
|
|
6
|
+
* Wire shape: `{ points: string[] }`. Renders read-side as
|
|
7
|
+
* `<div class="pilotiq-key-takeaways">` with a label and a `<ul>`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const keyTakeawaysBlock: Block;
|
|
10
|
+
//# sourceMappingURL=keyTakeaways.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TagsInput } from '@pilotiq/pilotiq';
|
|
2
|
+
import { Block } from '../Block.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default **Key takeaways** block — a short bulleted list of the article's
|
|
5
|
+
* main points.
|
|
6
|
+
*
|
|
7
|
+
* Wire shape: `{ points: string[] }`. Renders read-side as
|
|
8
|
+
* `<div class="pilotiq-key-takeaways">` with a label and a `<ul>`.
|
|
9
|
+
*/
|
|
10
|
+
export const keyTakeawaysBlock = Block.make('key-takeaways')
|
|
11
|
+
.label('Key takeaways')
|
|
12
|
+
.icon('🔑')
|
|
13
|
+
.schema([
|
|
14
|
+
TagsInput.make('points').label('Key takeaways'),
|
|
15
|
+
]);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Block } from '../Block.js';
|
|
2
|
+
/**
|
|
3
|
+
* Default **Pros & cons** block — two lists, side by side.
|
|
4
|
+
*
|
|
5
|
+
* Wire shape: `{ pros: string[]; cons: string[] }`. Renders read-side as
|
|
6
|
+
* `<div class="pilotiq-pros-cons">` with a `pros` and a `cons` column.
|
|
7
|
+
*/
|
|
8
|
+
export declare const prosConsBlock: Block;
|
|
9
|
+
//# sourceMappingURL=prosCons.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TagsInput } from '@pilotiq/pilotiq';
|
|
2
|
+
import { Block } from '../Block.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default **Pros & cons** block — two lists, side by side.
|
|
5
|
+
*
|
|
6
|
+
* Wire shape: `{ pros: string[]; cons: string[] }`. Renders read-side as
|
|
7
|
+
* `<div class="pilotiq-pros-cons">` with a `pros` and a `cons` column.
|
|
8
|
+
*/
|
|
9
|
+
export const prosConsBlock = Block.make('pros-cons')
|
|
10
|
+
.label('Pros & cons')
|
|
11
|
+
.icon('⚖️')
|
|
12
|
+
.schema([
|
|
13
|
+
TagsInput.make('pros').label('Pros'),
|
|
14
|
+
TagsInput.make('cons').label('Cons'),
|
|
15
|
+
]);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Block } from '../Block.js';
|
|
2
|
+
/**
|
|
3
|
+
* Default **Summary** block — a short, labelled summary of the article.
|
|
4
|
+
*
|
|
5
|
+
* Wire shape: `{ content: string }`. Renders read-side as
|
|
6
|
+
* `<div class="pilotiq-summary">` with a label and the body.
|
|
7
|
+
*/
|
|
8
|
+
export declare const summaryBlock: Block;
|
|
9
|
+
//# sourceMappingURL=summary.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { TextareaField } from '@pilotiq/pilotiq';
|
|
2
|
+
import { Block } from '../Block.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default **Summary** block — a short, labelled summary of the article.
|
|
5
|
+
*
|
|
6
|
+
* Wire shape: `{ content: string }`. Renders read-side as
|
|
7
|
+
* `<div class="pilotiq-summary">` with a label and the body.
|
|
8
|
+
*/
|
|
9
|
+
export const summaryBlock = Block.make('summary')
|
|
10
|
+
.label('Summary')
|
|
11
|
+
.icon('📝')
|
|
12
|
+
.schema([
|
|
13
|
+
TextareaField.make('content').label('Summary').placeholder('A short summary of the article…'),
|
|
14
|
+
]);
|
|
@@ -34,11 +34,14 @@ export const DragHandleExtension = Extension.create({
|
|
|
34
34
|
},
|
|
35
35
|
});
|
|
36
36
|
function createDragHandleView(view) {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// A <div> (not <button>): a button grabs DOM focus on click, which yanks
|
|
38
|
+
// focus out of the editor so a follow-up Backspace/Delete on the selected
|
|
39
|
+
// block goes nowhere. A div doesn't steal focus, so click-to-select works.
|
|
40
|
+
const handle = document.createElement('div');
|
|
41
|
+
handle.setAttribute('role', 'button');
|
|
39
42
|
handle.setAttribute('data-pilotiq-drag-handle', '');
|
|
40
43
|
handle.setAttribute('contenteditable', 'false');
|
|
41
|
-
handle.setAttribute('aria-label', 'Drag block');
|
|
44
|
+
handle.setAttribute('aria-label', 'Drag or select block');
|
|
42
45
|
handle.setAttribute('draggable', 'true');
|
|
43
46
|
handle.style.cssText = [
|
|
44
47
|
'position: absolute',
|
|
@@ -149,16 +152,30 @@ function createDragHandleView(view) {
|
|
|
149
152
|
const onDragEnd = () => {
|
|
150
153
|
handle.style.cursor = 'grab';
|
|
151
154
|
};
|
|
155
|
+
// A plain click (no drag) selects the hovered block as a NodeSelection, so
|
|
156
|
+
// the whole block can be deleted (Backspace / Delete) or replaced — the only
|
|
157
|
+
// affordance for removing atom-ish custom blocks (FAQ, callout, grid, …).
|
|
158
|
+
const onClick = (event) => {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
if (activePos === null)
|
|
161
|
+
return;
|
|
162
|
+
if (!view.state.doc.nodeAt(activePos))
|
|
163
|
+
return;
|
|
164
|
+
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, activePos)));
|
|
165
|
+
view.focus();
|
|
166
|
+
};
|
|
152
167
|
view.dom.addEventListener('mousemove', onMouseMove);
|
|
153
168
|
view.dom.addEventListener('mouseleave', onMouseLeave);
|
|
154
169
|
handle.addEventListener('dragstart', onDragStart);
|
|
155
170
|
handle.addEventListener('dragend', onDragEnd);
|
|
171
|
+
handle.addEventListener('click', onClick);
|
|
156
172
|
return {
|
|
157
173
|
destroy: () => {
|
|
158
174
|
view.dom.removeEventListener('mousemove', onMouseMove);
|
|
159
175
|
view.dom.removeEventListener('mouseleave', onMouseLeave);
|
|
160
176
|
handle.removeEventListener('dragstart', onDragStart);
|
|
161
177
|
handle.removeEventListener('dragend', onDragEnd);
|
|
178
|
+
handle.removeEventListener('click', onClick);
|
|
162
179
|
handle.remove();
|
|
163
180
|
},
|
|
164
181
|
};
|
|
@@ -137,6 +137,77 @@ export function buildSlashItems(blocks, mergeTags, query, insert) {
|
|
|
137
137
|
searchKey: 'grid columns layout 3 three split',
|
|
138
138
|
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setGrid({ columns: 3 }).run(),
|
|
139
139
|
},
|
|
140
|
+
// Inline content blocks — labelled, editable-in-place regions (nodes in
|
|
141
|
+
// contentBlocks.ts). Inserted via insertContent; no custom commands.
|
|
142
|
+
{
|
|
143
|
+
key: 'key-takeaways', label: 'Key takeaways', icon: '🔑', group: 'Content',
|
|
144
|
+
searchKey: 'key takeaways points highlights tldr',
|
|
145
|
+
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({
|
|
146
|
+
type: 'keyTakeaways',
|
|
147
|
+
content: [{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] }],
|
|
148
|
+
}).run(),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
key: 'summary', label: 'Summary', icon: '📝', group: 'Content',
|
|
152
|
+
searchKey: 'summary tldr abstract overview',
|
|
153
|
+
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'summary', content: [{ type: 'paragraph' }] }).run(),
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
key: 'faq', label: 'FAQ', icon: '❓', group: 'Content',
|
|
157
|
+
searchKey: 'faq questions answers frequently asked',
|
|
158
|
+
command: ({ editor, range }) => {
|
|
159
|
+
editor.chain().focus().deleteRange(range).insertContent({
|
|
160
|
+
type: 'faq',
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: 'faqItem',
|
|
164
|
+
content: [{ type: 'faqQuestion' }, { type: 'faqAnswer', content: [{ type: 'paragraph' }] }],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
}).run();
|
|
168
|
+
// insertContent leaves the cursor in the answer — move it into the first
|
|
169
|
+
// question. Resolve the enclosing faqItem and select its question start
|
|
170
|
+
// (before(faqItem) +1 into the item +1 into the question = +2).
|
|
171
|
+
const { $from } = editor.state.selection;
|
|
172
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
173
|
+
if ($from.node(d).type.name === 'faqItem') {
|
|
174
|
+
editor.chain().setTextSelection($from.before(d) + 2).focus().run();
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
key: 'pros-cons', label: 'Pros & cons', icon: '⚖️', group: 'Content',
|
|
182
|
+
searchKey: 'pros cons advantages disadvantages comparison',
|
|
183
|
+
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({
|
|
184
|
+
type: 'prosCons',
|
|
185
|
+
content: [
|
|
186
|
+
{ type: 'prosColumn', content: [{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] }] },
|
|
187
|
+
{ type: 'consColumn', content: [{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] }] },
|
|
188
|
+
],
|
|
189
|
+
}).run(),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: 'alert-info', label: 'Alert: Info', icon: 'ⓘ', group: 'Content',
|
|
193
|
+
searchKey: 'alert callout notice info note',
|
|
194
|
+
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'alert', attrs: { type: 'info' }, content: [{ type: 'paragraph' }] }).run(),
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
key: 'alert-warning', label: 'Alert: Warning', icon: '⚠️', group: 'Content',
|
|
198
|
+
searchKey: 'alert callout notice warning caution danger',
|
|
199
|
+
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'alert', attrs: { type: 'warning' }, content: [{ type: 'paragraph' }] }).run(),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
key: 'alert-success', label: 'Alert: Success', icon: '✅', group: 'Content',
|
|
203
|
+
searchKey: 'alert callout notice success ok done',
|
|
204
|
+
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'alert', attrs: { type: 'success' }, content: [{ type: 'paragraph' }] }).run(),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
key: 'alert-tip', label: 'Alert: Tip', icon: '💡', group: 'Content',
|
|
208
|
+
searchKey: 'alert callout notice tip hint',
|
|
209
|
+
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'alert', attrs: { type: 'tip' }, content: [{ type: 'paragraph' }] }).run(),
|
|
210
|
+
},
|
|
140
211
|
// Image entry shares the toolbar's attach-files dialog; only surfaced
|
|
141
212
|
// when the panel has wired an `UploadAdapter`. Without one, the dialog
|
|
142
213
|
// would post to a missing endpoint — the slash item degrades the same
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Node, Extension } from '@tiptap/core';
|
|
2
|
+
export declare const KeyTakeaways: Node<any, any>;
|
|
3
|
+
export declare const Summary: Node<any, any>;
|
|
4
|
+
export declare const Faq: Node<any, any>;
|
|
5
|
+
export declare const FaqItem: Node<any, any>;
|
|
6
|
+
export declare const FaqQuestion: Node<any, any>;
|
|
7
|
+
export declare const FaqAnswer: Node<any, any>;
|
|
8
|
+
export declare const ALERT_TYPES: readonly ["info", "warning", "success", "tip"];
|
|
9
|
+
export type AlertType = (typeof ALERT_TYPES)[number];
|
|
10
|
+
/** Exported so `render.ts` shares the same coercion at the server boundary. */
|
|
11
|
+
export declare function coerceAlertType(value: unknown): AlertType;
|
|
12
|
+
export declare const Alert: Node<any, any>;
|
|
13
|
+
export declare const ProsCons: Node<any, any>;
|
|
14
|
+
export declare const ProsColumn: Node<any, any>;
|
|
15
|
+
export declare const ConsColumn: Node<any, any>;
|
|
16
|
+
export declare const ContentBlockKeymap: Extension<any, any>;
|
|
17
|
+
/** All inline content-block extensions — registered in the editor's list. */
|
|
18
|
+
export declare const contentBlockNodes: (Node<any, any> | Extension<any, any>)[];
|
|
19
|
+
//# sourceMappingURL=contentBlocks.d.ts.map
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Node, Extension, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
/** A labelled region whose body is ordinary editable content (`block+`). */
|
|
3
|
+
function labeledBlock(spec) {
|
|
4
|
+
return Node.create({
|
|
5
|
+
name: spec.name,
|
|
6
|
+
group: 'block',
|
|
7
|
+
content: 'block+',
|
|
8
|
+
defining: true,
|
|
9
|
+
parseHTML() {
|
|
10
|
+
return [{ tag: `div[data-type="${spec.name}"]`, contentElement: '.pilotiq-block-body' }];
|
|
11
|
+
},
|
|
12
|
+
renderHTML({ HTMLAttributes }) {
|
|
13
|
+
return [
|
|
14
|
+
'div',
|
|
15
|
+
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],
|
|
18
|
+
];
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export const KeyTakeaways = labeledBlock({ name: 'keyTakeaways', label: 'Key takeaways', cssClass: 'pilotiq-key-takeaways' });
|
|
23
|
+
export const Summary = labeledBlock({ name: 'summary', label: 'Summary', cssClass: 'pilotiq-summary' });
|
|
24
|
+
// ── FAQ — structured question / answer items ──
|
|
25
|
+
//
|
|
26
|
+
// `faq` > `faqItem+`; each item is a `faqQuestion` (inline text, "Q" marker) +
|
|
27
|
+
// a `faqAnswer` (block+, "A" marker). Authoring: Enter in a question jumps to
|
|
28
|
+
// its answer; Cmd/Ctrl-Enter inside an item adds a new Q&A item below.
|
|
29
|
+
const NEW_FAQ_ITEM = {
|
|
30
|
+
type: 'faqItem',
|
|
31
|
+
content: [{ type: 'faqQuestion' }, { type: 'faqAnswer', content: [{ type: 'paragraph' }] }],
|
|
32
|
+
};
|
|
33
|
+
export const Faq = Node.create({
|
|
34
|
+
name: 'faq',
|
|
35
|
+
group: 'block',
|
|
36
|
+
content: 'faqItem+',
|
|
37
|
+
defining: true,
|
|
38
|
+
parseHTML() {
|
|
39
|
+
return [{ tag: 'div[data-type="faq"]', contentElement: '.pilotiq-block-body' }];
|
|
40
|
+
},
|
|
41
|
+
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
|
+
];
|
|
48
|
+
},
|
|
49
|
+
addKeyboardShortcuts() {
|
|
50
|
+
return {
|
|
51
|
+
// Enter drives the whole Q&A flow:
|
|
52
|
+
// • in a question → jump to its answer;
|
|
53
|
+
// • in an answer → finish it and start a NEW Q&A item below (focus its
|
|
54
|
+
// question). Repeatable: question → Enter → answer → Enter → next.
|
|
55
|
+
// (Shift-Enter still inserts a line break within an answer.)
|
|
56
|
+
Enter: ({ editor }) => {
|
|
57
|
+
const { $from, empty } = editor.state.selection;
|
|
58
|
+
if (!empty)
|
|
59
|
+
return false;
|
|
60
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
61
|
+
const name = $from.node(d).type.name;
|
|
62
|
+
if (name === 'faqQuestion') {
|
|
63
|
+
return editor.chain().setTextSelection($from.after(d) + 1).focus().run();
|
|
64
|
+
}
|
|
65
|
+
if (name === 'faqAnswer') {
|
|
66
|
+
const after = $from.after(d - 1); // position just after the faqItem
|
|
67
|
+
return editor.chain().insertContentAt(after, NEW_FAQ_ITEM).setTextSelection(after + 2).focus().run();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
},
|
|
72
|
+
// Cmd/Ctrl-Enter anywhere in an item also adds a new Q&A item below.
|
|
73
|
+
'Mod-Enter': ({ editor }) => {
|
|
74
|
+
const { $from } = editor.state.selection;
|
|
75
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
76
|
+
if ($from.node(d).type.name === 'faqItem') {
|
|
77
|
+
const after = $from.after(d);
|
|
78
|
+
return editor.chain().insertContentAt(after, NEW_FAQ_ITEM).setTextSelection(after + 2).focus().run();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
},
|
|
83
|
+
// Backspace in an EMPTY question removes that whole Q&A item (and the
|
|
84
|
+
// whole FAQ block if it was the only item) — like emptying a list item.
|
|
85
|
+
Backspace: ({ editor }) => {
|
|
86
|
+
const { $from, empty } = editor.state.selection;
|
|
87
|
+
if (!empty)
|
|
88
|
+
return false;
|
|
89
|
+
let qDepth = -1;
|
|
90
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
91
|
+
if ($from.node(d).type.name === 'faqQuestion') {
|
|
92
|
+
qDepth = d;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (qDepth === -1)
|
|
97
|
+
return false;
|
|
98
|
+
if ($from.node(qDepth).content.size > 0)
|
|
99
|
+
return false; // question not empty → normal backspace
|
|
100
|
+
const faqDepth = qDepth - 2;
|
|
101
|
+
const itemDepth = qDepth - 1;
|
|
102
|
+
const faq = $from.node(faqDepth);
|
|
103
|
+
const faqStart = $from.before(faqDepth);
|
|
104
|
+
if (faq.childCount <= 1) {
|
|
105
|
+
// last item → remove the whole FAQ block
|
|
106
|
+
return editor.chain().deleteRange({ from: faqStart, to: faqStart + faq.nodeSize }).focus().run();
|
|
107
|
+
}
|
|
108
|
+
// remove just this Q&A item; drop the cursor into a neighbouring item
|
|
109
|
+
const itemStart = $from.before(itemDepth);
|
|
110
|
+
const itemEnd = itemStart + $from.node(itemDepth).nodeSize;
|
|
111
|
+
const caret = $from.index(faqDepth) === 0 ? faqStart + 3 : itemStart - 1;
|
|
112
|
+
return editor.chain().deleteRange({ from: itemStart, to: itemEnd }).setTextSelection(caret).focus().run();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
export const FaqItem = Node.create({
|
|
118
|
+
name: 'faqItem',
|
|
119
|
+
group: 'faqItem',
|
|
120
|
+
content: 'faqQuestion faqAnswer',
|
|
121
|
+
defining: true,
|
|
122
|
+
parseHTML() {
|
|
123
|
+
return [{ tag: 'div[data-type="faqItem"]' }];
|
|
124
|
+
},
|
|
125
|
+
renderHTML({ HTMLAttributes }) {
|
|
126
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'faqItem', class: 'pilotiq-faq-item' }), 0];
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
export const FaqQuestion = Node.create({
|
|
130
|
+
name: 'faqQuestion',
|
|
131
|
+
content: 'inline*',
|
|
132
|
+
defining: true,
|
|
133
|
+
parseHTML() {
|
|
134
|
+
return [{ tag: 'div[data-type="faqQuestion"]', contentElement: '.pilotiq-faq-text' }];
|
|
135
|
+
},
|
|
136
|
+
renderHTML({ HTMLAttributes }) {
|
|
137
|
+
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],
|
|
142
|
+
];
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
export const FaqAnswer = Node.create({
|
|
146
|
+
name: 'faqAnswer',
|
|
147
|
+
content: 'block+',
|
|
148
|
+
defining: true,
|
|
149
|
+
parseHTML() {
|
|
150
|
+
return [{ tag: 'div[data-type="faqAnswer"]', contentElement: '.pilotiq-faq-body' }];
|
|
151
|
+
},
|
|
152
|
+
renderHTML({ HTMLAttributes }) {
|
|
153
|
+
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],
|
|
158
|
+
];
|
|
159
|
+
},
|
|
160
|
+
});
|
|
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';
|
|
167
|
+
}
|
|
168
|
+
export const Alert = Node.create({
|
|
169
|
+
name: 'alert',
|
|
170
|
+
group: 'block',
|
|
171
|
+
content: 'block+',
|
|
172
|
+
defining: true,
|
|
173
|
+
addAttributes() {
|
|
174
|
+
return {
|
|
175
|
+
type: {
|
|
176
|
+
default: 'info',
|
|
177
|
+
parseHTML: (el) => coerceAlertType(el.getAttribute('data-alert-type')),
|
|
178
|
+
renderHTML: (attrs) => ({ 'data-alert-type': coerceAlertType(attrs['type']) }),
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
parseHTML() {
|
|
183
|
+
return [{ tag: 'div[data-type="alert"]', contentElement: '.pilotiq-block-body' }];
|
|
184
|
+
},
|
|
185
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
186
|
+
const type = coerceAlertType(node.attrs['type']);
|
|
187
|
+
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],
|
|
192
|
+
];
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
// ── Pros & cons — two labelled columns (each a `block+` body, list by default) ──
|
|
196
|
+
export const ProsCons = Node.create({
|
|
197
|
+
name: 'prosCons',
|
|
198
|
+
group: 'block',
|
|
199
|
+
content: 'prosColumn consColumn',
|
|
200
|
+
defining: true,
|
|
201
|
+
parseHTML() {
|
|
202
|
+
return [{ tag: 'div[data-type="prosCons"]' }];
|
|
203
|
+
},
|
|
204
|
+
renderHTML({ HTMLAttributes }) {
|
|
205
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'prosCons', class: 'pilotiq-pros-cons' }), 0];
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
function prosConsColumn(name, label, cssClass) {
|
|
209
|
+
return Node.create({
|
|
210
|
+
name,
|
|
211
|
+
group: 'prosConsColumn',
|
|
212
|
+
content: 'block+',
|
|
213
|
+
defining: true,
|
|
214
|
+
parseHTML() {
|
|
215
|
+
return [{ tag: `div[data-type="${name}"]`, contentElement: '.pilotiq-block-body' }];
|
|
216
|
+
},
|
|
217
|
+
renderHTML({ HTMLAttributes }) {
|
|
218
|
+
return [
|
|
219
|
+
'div',
|
|
220
|
+
mergeAttributes(HTMLAttributes, { 'data-type': name, class: cssClass }),
|
|
221
|
+
['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, label],
|
|
222
|
+
['div', { class: 'pilotiq-block-body' }, 0],
|
|
223
|
+
];
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
export const ProsColumn = prosConsColumn('prosColumn', 'Pros', 'pilotiq-pros');
|
|
228
|
+
export const ConsColumn = prosConsColumn('consColumn', 'Cons', 'pilotiq-cons');
|
|
229
|
+
// ── Keyboard: delete a whole content block with Backspace at its start ──
|
|
230
|
+
//
|
|
231
|
+
// Custom blocks have no inline "remove" affordance, and Backspace inside a
|
|
232
|
+
// nested block (FAQ etc.) just deletes characters. So: Backspace at the very
|
|
233
|
+
// start of a content block removes the entire block — the reliable, keyboard
|
|
234
|
+
// way to delete one (the drag handle's click-to-select is the mouse way).
|
|
235
|
+
// faq is excluded — it handles Backspace itself (empty-question → remove item).
|
|
236
|
+
const DELETABLE_BLOCKS = new Set(['summary', 'keyTakeaways', 'alert', 'prosCons']);
|
|
237
|
+
export const ContentBlockKeymap = Extension.create({
|
|
238
|
+
name: 'pilotiqContentBlockKeymap',
|
|
239
|
+
addKeyboardShortcuts() {
|
|
240
|
+
return {
|
|
241
|
+
Backspace: ({ editor }) => {
|
|
242
|
+
const { $from, empty } = editor.state.selection;
|
|
243
|
+
if (!empty || $from.parentOffset !== 0)
|
|
244
|
+
return false;
|
|
245
|
+
for (let d = 1; d <= $from.depth; d++) {
|
|
246
|
+
const node = $from.node(d);
|
|
247
|
+
if (!DELETABLE_BLOCKS.has(node.type.name))
|
|
248
|
+
continue;
|
|
249
|
+
// Only when the cursor is at the block's very start (first child all
|
|
250
|
+
// the way down) — otherwise let Backspace delete normally.
|
|
251
|
+
let atStart = true;
|
|
252
|
+
for (let dd = $from.depth; dd > d; dd--) {
|
|
253
|
+
if ($from.index(dd - 1) !== 0) {
|
|
254
|
+
atStart = false;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!atStart)
|
|
259
|
+
return false;
|
|
260
|
+
const start = $from.before(d);
|
|
261
|
+
return editor.chain().deleteRange({ from: start, to: start + node.nodeSize }).focus().run();
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
/** All inline content-block extensions — registered in the editor's list. */
|
|
269
|
+
export const contentBlockNodes = [
|
|
270
|
+
KeyTakeaways,
|
|
271
|
+
Summary,
|
|
272
|
+
Faq,
|
|
273
|
+
FaqItem,
|
|
274
|
+
FaqQuestion,
|
|
275
|
+
FaqAnswer,
|
|
276
|
+
Alert,
|
|
277
|
+
ProsCons,
|
|
278
|
+
ProsColumn,
|
|
279
|
+
ConsColumn,
|
|
280
|
+
ContentBlockKeymap,
|
|
281
|
+
];
|
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';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { RichTextField, DEFAULT_TOOLBAR_GROUPS, DEFAULT_TEXT_COLORS, DEFAULT_HIGHLIGHT_COLORS, } from './RichTextField.js';
|
|
2
2
|
export { Block } from './Block.js';
|
|
3
|
+
export { defaultBlocks, faqBlock, alertBlock, summaryBlock, keyTakeawaysBlock, prosConsBlock, } from './blocks/index.js';
|
|
3
4
|
export { MentionProvider, } from './MentionProvider.js';
|
|
4
5
|
export { registerTiptap } from './register.js';
|
|
5
6
|
export { createPlainTextEditor, plainTextOf, plainTextToDoc, } from './PlainTextEditor.js';
|
|
@@ -13,6 +13,7 @@ import Image from '@tiptap/extension-image';
|
|
|
13
13
|
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
|
|
14
14
|
import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-details';
|
|
15
15
|
import { Grid, GridColumn } from '../extensions/GridExtension.js';
|
|
16
|
+
import { contentBlockNodes } from '../extensions/contentBlocks.js';
|
|
16
17
|
import { Popover } from '@base-ui/react/popover';
|
|
17
18
|
import { useCollabRoom, getCollabExtensions, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react';
|
|
18
19
|
import { useCollabSeed } from '@rudderjs/sync/react';
|
|
@@ -276,6 +277,9 @@ function ClientEditor(props) {
|
|
|
276
277
|
// `pilotiq-grid-cols-N`.
|
|
277
278
|
Grid,
|
|
278
279
|
GridColumn,
|
|
280
|
+
// Inline content blocks — labelled, editable-in-place regions:
|
|
281
|
+
// Key takeaways / Summary / FAQ / Alert / Pros & cons.
|
|
282
|
+
...contentBlockNodes,
|
|
279
283
|
Placeholder.configure({ placeholder: placeholder ?? 'Start writing…' }),
|
|
280
284
|
// BlockNodeExtension carries the block registry on its options —
|
|
281
285
|
// NodeViews mount in a separate React tree and can't see context.
|
package/dist/render.d.ts
CHANGED
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
* / image.src + alt + title + width + height
|
|
27
27
|
* / tableCell.colspan + rowspan + colwidth (also tableHeader)
|
|
28
28
|
* / mergeTag.id / mention.id + label + trigger
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* default blocks — the `pilotiqBlock` node's built-in types (faq / alert /
|
|
30
|
+
* summary / key-takeaways / pros-cons) render to semantic
|
|
31
|
+
* `<div class="pilotiq-...">` markup; consumers own the CSS.
|
|
32
|
+
* custom blocks — any other type renders to `<div data-type="..."
|
|
33
|
+
* data-attrs="...">` so consumers can replay or style by data-type.
|
|
31
34
|
*/
|
|
32
35
|
export interface RenderRichTextOptions {
|
|
33
36
|
/**
|
package/dist/render.js
CHANGED
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
* / image.src + alt + title + width + height
|
|
27
27
|
* / tableCell.colspan + rowspan + colwidth (also tableHeader)
|
|
28
28
|
* / mergeTag.id / mention.id + label + trigger
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* default blocks — the `pilotiqBlock` node's built-in types (faq / alert /
|
|
30
|
+
* summary / key-takeaways / pros-cons) render to semantic
|
|
31
|
+
* `<div class="pilotiq-...">` markup; consumers own the CSS.
|
|
32
|
+
* custom blocks — any other type renders to `<div data-type="..."
|
|
33
|
+
* data-attrs="...">` so consumers can replay or style by data-type.
|
|
31
34
|
*/
|
|
32
35
|
/**
|
|
33
36
|
* Render Tiptap content to HTML.
|
|
@@ -139,9 +142,20 @@ function renderNode(node, opts) {
|
|
|
139
142
|
case 'detailsContent': return renderChildren(n, opts);
|
|
140
143
|
case 'grid': return renderGrid(n, opts);
|
|
141
144
|
case 'gridColumn': return wrap('div', n, opts);
|
|
145
|
+
case 'keyTakeaways': return labeledBlockHtml('pilotiq-key-takeaways', 'Key takeaways', n, opts);
|
|
146
|
+
case 'summary': return labeledBlockHtml('pilotiq-summary', 'Summary', n, opts);
|
|
147
|
+
case 'faq': return labeledBlockHtml('pilotiq-faq', 'FAQ', n, opts);
|
|
148
|
+
case 'faqItem': return `<div class="pilotiq-faq-item">${renderChildren(n, opts)}</div>`;
|
|
149
|
+
case 'faqQuestion': return `<div class="pilotiq-faq-question"><span class="pilotiq-faq-marker">Q</span><span class="pilotiq-faq-text">${renderChildren(n, opts)}</span></div>`;
|
|
150
|
+
case 'faqAnswer': return `<div class="pilotiq-faq-answer"><span class="pilotiq-faq-marker">A</span><div class="pilotiq-faq-body">${renderChildren(n, opts)}</div></div>`;
|
|
151
|
+
case 'alert': return renderAlertNode(n, opts);
|
|
152
|
+
case 'prosCons': return `<div class="pilotiq-pros-cons">${renderChildren(n, opts)}</div>`;
|
|
153
|
+
case 'prosColumn': return labeledBlockHtml('pilotiq-pros', 'Pros', n, opts);
|
|
154
|
+
case 'consColumn': return labeledBlockHtml('pilotiq-cons', 'Cons', n, opts);
|
|
142
155
|
case 'mergeTag': return renderMergeTag(n, opts);
|
|
143
156
|
case 'mention': return renderMention(n, opts);
|
|
144
157
|
case 'text': return renderText(n);
|
|
158
|
+
case 'pilotiqBlock': return renderPilotiqBlock(n, opts);
|
|
145
159
|
default:
|
|
146
160
|
if (opts.renderBlock)
|
|
147
161
|
return opts.renderBlock(n);
|
|
@@ -340,6 +354,28 @@ function clampGridColumnsForRender(raw) {
|
|
|
340
354
|
const trunc = Math.trunc(n);
|
|
341
355
|
return trunc === 3 ? 3 : 2;
|
|
342
356
|
}
|
|
357
|
+
// ─── Inline content blocks (labelled editable nodes) ─────────────────
|
|
358
|
+
//
|
|
359
|
+
// Mirrors the editor's `renderHTML` (extensions/contentBlocks.ts): a small
|
|
360
|
+
// label above an editable body. Consumer owns the `pilotiq-*` CSS. Covers
|
|
361
|
+
// keyTakeaways / summary / faq / alert / prosCons (+ pros/cons columns).
|
|
362
|
+
function labeledBlockHtml(cssClass, label, n, opts) {
|
|
363
|
+
return (`<div class="${cssClass}">` +
|
|
364
|
+
`<div class="pilotiq-block-label">${escapeHtml(label)}</div>` +
|
|
365
|
+
`<div class="pilotiq-block-body">${renderChildren(n, opts)}</div>` +
|
|
366
|
+
`</div>`);
|
|
367
|
+
}
|
|
368
|
+
const ALERT_NODE_TYPES = new Set(['info', 'warning', 'success', 'tip']);
|
|
369
|
+
const ALERT_NODE_LABEL = { info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip' };
|
|
370
|
+
function renderAlertNode(n, opts) {
|
|
371
|
+
let type = String(n.attrs?.['type'] ?? '').trim().toLowerCase();
|
|
372
|
+
if (!ALERT_NODE_TYPES.has(type))
|
|
373
|
+
type = 'info';
|
|
374
|
+
return (`<div class="pilotiq-alert pilotiq-alert-${type}" role="note">` +
|
|
375
|
+
`<div class="pilotiq-block-label">${ALERT_NODE_LABEL[type]}</div>` +
|
|
376
|
+
`<div class="pilotiq-block-body">${renderChildren(n, opts)}</div>` +
|
|
377
|
+
`</div>`);
|
|
378
|
+
}
|
|
343
379
|
// ─── Merge tags + mentions ───────────────────────────────────────────
|
|
344
380
|
/**
|
|
345
381
|
* Render a `mergeTag` atom — either substitute the value from
|
|
@@ -386,6 +422,104 @@ function renderCustomBlock(n) {
|
|
|
386
422
|
const inner = n.content ? renderChildren(n, {}) : '';
|
|
387
423
|
return `<div data-type="${escapeAttr(type)}"${dataAttrs}>${inner}</div>`;
|
|
388
424
|
}
|
|
425
|
+
// ─── Default blocks (pilotiqBlock node, keyed on attrs.blockType) ─────
|
|
426
|
+
//
|
|
427
|
+
// The custom-block node (`pilotiqBlock`) carries `blockType` + `blockData`.
|
|
428
|
+
// The blocks shipped by default in `RichTextField` (FAQ / Alert / Summary /
|
|
429
|
+
// Key takeaways / Pros & cons) render to semantic HTML here; every other
|
|
430
|
+
// (host-defined) block type falls back to `opts.renderBlock` or the generic
|
|
431
|
+
// `data-attrs` div. Consumers own the CSS for the `pilotiq-*` classes.
|
|
432
|
+
function renderPilotiqBlock(n, opts) {
|
|
433
|
+
const attrs = (n.attrs ?? {});
|
|
434
|
+
const blockType = String(attrs['blockType'] ?? '');
|
|
435
|
+
let data = attrs['blockData'];
|
|
436
|
+
if (typeof data === 'string') {
|
|
437
|
+
try {
|
|
438
|
+
data = JSON.parse(data);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
data = {};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const d = (data && typeof data === 'object' ? data : {});
|
|
445
|
+
switch (blockType) {
|
|
446
|
+
case 'faq': return renderFaqBlock(d);
|
|
447
|
+
case 'alert': return renderAlertBlock(d);
|
|
448
|
+
case 'summary': return renderSummaryBlock(d);
|
|
449
|
+
case 'key-takeaways': return renderKeyTakeawaysBlock(d);
|
|
450
|
+
case 'pros-cons': return renderProsConsBlock(d);
|
|
451
|
+
default:
|
|
452
|
+
if (opts.renderBlock)
|
|
453
|
+
return opts.renderBlock(n);
|
|
454
|
+
return renderCustomBlock(n);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/** Escape a plain-text field value and split blank-line-separated paragraphs. */
|
|
458
|
+
function blockParagraphs(raw) {
|
|
459
|
+
const s = String(raw ?? '').trim();
|
|
460
|
+
if (s === '')
|
|
461
|
+
return '';
|
|
462
|
+
return s
|
|
463
|
+
.split(/\n{2,}/)
|
|
464
|
+
.map((p) => `<p>${escapeHtml(p).replace(/\n/g, '<br>')}</p>`)
|
|
465
|
+
.join('');
|
|
466
|
+
}
|
|
467
|
+
/** Coerce a field value to a clean `string[]` (TagsInput stores an array). */
|
|
468
|
+
function blockStringList(raw) {
|
|
469
|
+
if (!Array.isArray(raw))
|
|
470
|
+
return [];
|
|
471
|
+
return raw.map((x) => String(x ?? '').trim()).filter((s) => s !== '');
|
|
472
|
+
}
|
|
473
|
+
function renderFaqBlock(d) {
|
|
474
|
+
const items = Array.isArray(d['items']) ? d['items'] : [];
|
|
475
|
+
const body = items
|
|
476
|
+
.map((it) => {
|
|
477
|
+
const q = escapeHtml(String(it['question'] ?? '').trim());
|
|
478
|
+
if (q === '')
|
|
479
|
+
return '';
|
|
480
|
+
const a = blockParagraphs(it['answer']);
|
|
481
|
+
return `<details class="pilotiq-faq-item"><summary>${q}</summary><div class="pilotiq-faq-answer">${a}</div></details>`;
|
|
482
|
+
})
|
|
483
|
+
.join('');
|
|
484
|
+
if (body === '')
|
|
485
|
+
return '';
|
|
486
|
+
return `<div class="pilotiq-faq">${body}</div>`;
|
|
487
|
+
}
|
|
488
|
+
const ALERT_TYPES = new Set(['info', 'warning', 'success', 'tip']);
|
|
489
|
+
function renderAlertBlock(d) {
|
|
490
|
+
let type = String(d['type'] ?? '').trim().toLowerCase();
|
|
491
|
+
if (!ALERT_TYPES.has(type))
|
|
492
|
+
type = 'info';
|
|
493
|
+
const content = blockParagraphs(d['content']);
|
|
494
|
+
return `<div class="pilotiq-alert pilotiq-alert-${type}" role="note">${content}</div>`;
|
|
495
|
+
}
|
|
496
|
+
function renderSummaryBlock(d) {
|
|
497
|
+
const content = blockParagraphs(d['content']);
|
|
498
|
+
if (content === '')
|
|
499
|
+
return '';
|
|
500
|
+
return (`<div class="pilotiq-summary">` +
|
|
501
|
+
`<div class="pilotiq-summary-label">Summary</div>` +
|
|
502
|
+
`<div class="pilotiq-summary-body">${content}</div>` +
|
|
503
|
+
`</div>`);
|
|
504
|
+
}
|
|
505
|
+
function renderKeyTakeawaysBlock(d) {
|
|
506
|
+
const points = blockStringList(d['points']);
|
|
507
|
+
if (points.length === 0)
|
|
508
|
+
return '';
|
|
509
|
+
const lis = points.map((p) => `<li>${escapeHtml(p)}</li>`).join('');
|
|
510
|
+
return (`<div class="pilotiq-key-takeaways">` +
|
|
511
|
+
`<div class="pilotiq-key-takeaways-label">Key takeaways</div>` +
|
|
512
|
+
`<ul>${lis}</ul>` +
|
|
513
|
+
`</div>`);
|
|
514
|
+
}
|
|
515
|
+
function renderProsConsBlock(d) {
|
|
516
|
+
const pros = blockStringList(d['pros']);
|
|
517
|
+
const cons = blockStringList(d['cons']);
|
|
518
|
+
if (pros.length === 0 && cons.length === 0)
|
|
519
|
+
return '';
|
|
520
|
+
const column = (label, cls, items) => `<div class="pilotiq-${cls}"><h4>${label}</h4><ul>${items.map((i) => `<li>${escapeHtml(i)}</li>`).join('')}</ul></div>`;
|
|
521
|
+
return `<div class="pilotiq-pros-cons">${column('Pros', 'pros', pros)}${column('Cons', 'cons', cons)}</div>`;
|
|
522
|
+
}
|
|
389
523
|
// ─── Escapers + sanitizers ───────────────────────────────────────────
|
|
390
524
|
function escapeHtml(s) {
|
|
391
525
|
return s.replace(/[&<>"']/g, ch => HTML_ESCAPES[ch] ?? ch);
|