@pilotiq/tiptap 3.11.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 +20 -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/AiInlineDiffExtension.d.ts +14 -2
- package/dist/extensions/AiInlineDiffExtension.js +175 -5
- 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/CollabTextRenderer.js +2 -1
- package/dist/react/MarkdownEditor.js +2 -1
- package/dist/react/TiptapEditor.js +6 -1
- package/dist/react/useAiInlineDiff.d.ts +16 -0
- package/dist/react/useAiInlineDiff.js +23 -2
- package/dist/render.d.ts +5 -2
- package/dist/render.js +136 -2
- package/package.json +1 -1
|
@@ -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';
|
|
@@ -8,7 +8,7 @@ import { createPlainTextEditor, plainTextOf, plainTextToDoc } from '../PlainText
|
|
|
8
8
|
import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js';
|
|
9
9
|
import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js';
|
|
10
10
|
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
|
|
11
|
-
import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js';
|
|
11
|
+
import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
|
|
12
12
|
import { AiSuggestionBanner } from './AiSuggestionBanner.js';
|
|
13
13
|
/**
|
|
14
14
|
* Tiptap-backed plain-text editor for pilotiq's `TextField` / `TextareaField`
|
|
@@ -158,6 +158,7 @@ export function CollabTextRenderer({ name, fragmentKey, multiline, defaultValue,
|
|
|
158
158
|
return null;
|
|
159
159
|
}
|
|
160
160
|
},
|
|
161
|
+
resolveDisplayMode: () => readAiDiffViewMarker(name),
|
|
161
162
|
});
|
|
162
163
|
const isDiffActive = useIsAiInlineDiffActive(editor ?? null);
|
|
163
164
|
// First-load seed when collab is active. Collaboration starts the editor
|
|
@@ -16,7 +16,7 @@ import { useCollabSeed } from '@rudderjs/sync/react';
|
|
|
16
16
|
import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js';
|
|
17
17
|
import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js';
|
|
18
18
|
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
|
|
19
|
-
import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js';
|
|
19
|
+
import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
|
|
20
20
|
import { AiSuggestionBanner } from './AiSuggestionBanner.js';
|
|
21
21
|
import { getMarkdownString, parseMarkdownToHtml } from '../markdownStorage.js';
|
|
22
22
|
// Inline lucide.dev SVGs — same posture as `toolbarButtons.tsx` so this
|
|
@@ -200,6 +200,7 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
|
|
|
200
200
|
return null;
|
|
201
201
|
}
|
|
202
202
|
},
|
|
203
|
+
resolveDisplayMode: () => readAiDiffViewMarker(name),
|
|
203
204
|
});
|
|
204
205
|
const isDiffActive = useIsAiInlineDiffActive(editor ?? null);
|
|
205
206
|
// First-load seed for collab. Collaboration starts the editor empty
|
|
@@ -13,11 +13,12 @@ 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';
|
|
19
20
|
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
|
|
20
|
-
import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js';
|
|
21
|
+
import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
|
|
21
22
|
import { AiSuggestionBanner } from './AiSuggestionBanner.js';
|
|
22
23
|
import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
|
|
23
24
|
import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js';
|
|
@@ -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.
|
|
@@ -490,6 +494,7 @@ function ClientEditor(props) {
|
|
|
490
494
|
return null;
|
|
491
495
|
}
|
|
492
496
|
},
|
|
497
|
+
resolveDisplayMode: () => readAiDiffViewMarker(name),
|
|
493
498
|
});
|
|
494
499
|
const isDiffActive = useIsAiInlineDiffActive(editor ?? null);
|
|
495
500
|
// Re-render the toolbar when the selection / marks change so active-state
|
|
@@ -45,7 +45,23 @@ export interface UseAiInlineDiffOptions {
|
|
|
45
45
|
* - HTML → DOMParser + ProseMirror's DOMParser.parse
|
|
46
46
|
*/
|
|
47
47
|
parseSuggestion: (editor: Editor, value: string) => Slice | null;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the diff rendering mode at diff-start time. Return `'lines'`
|
|
50
|
+
* for the GitHub-style stacked rows, anything else / omitted keeps the
|
|
51
|
+
* default `'inline'` word-flow. Called lazily per diff so DOM-marker
|
|
52
|
+
* readers (`readAiDiffViewMarker`) see the mounted field wrapper.
|
|
53
|
+
*/
|
|
54
|
+
resolveDisplayMode?: (editor: Editor) => 'inline' | 'lines';
|
|
48
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Read the field's `.aiDiffView(...)` choice off the DOM — the
|
|
58
|
+
* `@pilotiq-pro/ai` field augmentation stamps `data-ai-diff-view` onto
|
|
59
|
+
* the FieldShell wrapper via `extraAttributes` (same channel as
|
|
60
|
+
* `data-ai-suggestions-mode`). Walks up from the field's named input.
|
|
61
|
+
* Defaults to `'inline'` when no marker is present — including in
|
|
62
|
+
* open-core installs where the augmentation never runs.
|
|
63
|
+
*/
|
|
64
|
+
export declare function readAiDiffViewMarker(fieldName: string): 'inline' | 'lines';
|
|
49
65
|
/**
|
|
50
66
|
* Returns whether a diff is currently active in the editor. Hosts use
|
|
51
67
|
* this to gate the banner's UI between the legacy `onApplyWholeField`
|
|
@@ -34,6 +34,24 @@ import { useEditorState } from '@tiptap/react';
|
|
|
34
34
|
import { registerPendingSuggestionApplier, usePendingSuggestionsForField, useFormId, } from '@pilotiq/pilotiq/react';
|
|
35
35
|
import { aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js';
|
|
36
36
|
import { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planUpdateBlockMark, } from '../surgicalOps.js';
|
|
37
|
+
/**
|
|
38
|
+
* Read the field's `.aiDiffView(...)` choice off the DOM — the
|
|
39
|
+
* `@pilotiq-pro/ai` field augmentation stamps `data-ai-diff-view` onto
|
|
40
|
+
* the FieldShell wrapper via `extraAttributes` (same channel as
|
|
41
|
+
* `data-ai-suggestions-mode`). Walks up from the field's named input.
|
|
42
|
+
* Defaults to `'inline'` when no marker is present — including in
|
|
43
|
+
* open-core installs where the augmentation never runs.
|
|
44
|
+
*/
|
|
45
|
+
export function readAiDiffViewMarker(fieldName) {
|
|
46
|
+
if (typeof document === 'undefined')
|
|
47
|
+
return 'inline';
|
|
48
|
+
const els = document.getElementsByName(fieldName);
|
|
49
|
+
const el = els[0];
|
|
50
|
+
if (!(el instanceof Element))
|
|
51
|
+
return 'inline';
|
|
52
|
+
const wrapper = el.closest('[data-ai-diff-view]');
|
|
53
|
+
return wrapper?.getAttribute('data-ai-diff-view') === 'lines' ? 'lines' : 'inline';
|
|
54
|
+
}
|
|
37
55
|
/**
|
|
38
56
|
* Returns whether a diff is currently active in the editor. Hosts use
|
|
39
57
|
* this to gate the banner's UI between the legacy `onApplyWholeField`
|
|
@@ -60,6 +78,8 @@ export function useAiInlineDiff(editor, fieldName, options) {
|
|
|
60
78
|
const formId = useFormId();
|
|
61
79
|
const parseRef = useRef(options.parseSuggestion);
|
|
62
80
|
useEffect(() => { parseRef.current = options.parseSuggestion; }, [options.parseSuggestion]);
|
|
81
|
+
const modeRef = useRef(options.resolveDisplayMode);
|
|
82
|
+
useEffect(() => { modeRef.current = options.resolveDisplayMode; }, [options.resolveDisplayMode]);
|
|
63
83
|
// Track which ids we've handed off to the editor's diff extension
|
|
64
84
|
// so we don't re-start the diff every render or for already-active
|
|
65
85
|
// suggestions.
|
|
@@ -126,11 +146,12 @@ export function useAiInlineDiff(editor, fieldName, options) {
|
|
|
126
146
|
startedRef.current.add(s.id);
|
|
127
147
|
continue;
|
|
128
148
|
}
|
|
149
|
+
const displayMode = modeRef.current?.(editor) ?? 'inline';
|
|
129
150
|
if (surgical) {
|
|
130
151
|
const modifier = planSurgicalModifier(editor, surgical);
|
|
131
152
|
if (!modifier)
|
|
132
153
|
continue;
|
|
133
|
-
editor.commands.applySurgicalAiInlineDiff(s.id, modifier);
|
|
154
|
+
editor.commands.applySurgicalAiInlineDiff(s.id, modifier, displayMode);
|
|
134
155
|
startedRef.current.add(s.id);
|
|
135
156
|
continue;
|
|
136
157
|
}
|
|
@@ -139,7 +160,7 @@ export function useAiInlineDiff(editor, fieldName, options) {
|
|
|
139
160
|
const slice = parseRef.current(editor, s.suggestedValue);
|
|
140
161
|
if (!slice)
|
|
141
162
|
continue;
|
|
142
|
-
editor.commands.startAiInlineDiff(s.id, slice);
|
|
163
|
+
editor.commands.startAiInlineDiff(s.id, slice, displayMode);
|
|
143
164
|
startedRef.current.add(s.id);
|
|
144
165
|
}
|
|
145
166
|
// Cleanup: when a suggestion leaves the context AND we previously
|
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
|
/**
|