@pilotiq/tiptap 3.10.8 → 3.12.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/AiInlineDiffExtension.d.ts +14 -2
- package/dist/extensions/AiInlineDiffExtension.js +175 -5
- package/dist/markdownExtension.js +2 -2
- package/dist/react/CollabTextRenderer.js +48 -24
- package/dist/react/MarkdownEditor.js +2 -1
- package/dist/react/TiptapEditor.js +2 -1
- package/dist/react/useAiInlineDiff.d.ts +16 -0
- package/dist/react/useAiInlineDiff.js +23 -2
- package/dist/test/setup.js +12 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @pilotiq/tiptap
|
|
2
2
|
|
|
3
|
+
## 3.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7252aaa: GitHub-style line-mode rendering for the AI inline diff. `startAiInlineDiff` / `applySurgicalAiInlineDiff` accept an optional `displayMode: 'inline' | 'lines'` — in `'lines'` mode every block touched by an insert renders as a full-width green row (`+` gutter) and deleted content renders as stacked red rows (`−` gutter) above the change, instead of the inline word-flow. `useAiInlineDiff` gained `resolveDisplayMode`, and all three editor surfaces (rich text, markdown, collab text) resolve it from a `data-ai-diff-view` wrapper marker — stamped by `@pilotiq-pro/ai`'s `Field.aiDiffView('lines')` setter. Default stays `'inline'`.
|
|
8
|
+
|
|
9
|
+
## 3.11.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 30a802e: `CollabTextRenderer` (the Tiptap surface behind collab/AI `TextField` / `TextareaField`) now renders whole-field AI suggestions through `AiInlineDiffExtension` + `AiSuggestionBanner` — the same red/green inline diff and amber Accept/Reject banner `RichTextField` uses — instead of the legacy green-pill chip with ✓/✕. One review surface across every text shape. The chip bridge stays mounted for producer-supplied `editorRange` suggestions; `onApplyWholeField` remains the fallback when a suggestion can't parse.
|
|
14
|
+
|
|
3
15
|
## 3.10.8
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -48,7 +48,7 @@ declare module '@tiptap/core' {
|
|
|
48
48
|
* banner / approve handlers can correlate the editor state with
|
|
49
49
|
* the queue entry.
|
|
50
50
|
*/
|
|
51
|
-
startAiInlineDiff: (id: string, newDocSlice: Slice) => ReturnType;
|
|
51
|
+
startAiInlineDiff: (id: string, newDocSlice: Slice, displayMode?: AiDiffDisplayMode) => ReturnType;
|
|
52
52
|
/**
|
|
53
53
|
* Start the inline-diff review session for a surgical edit.
|
|
54
54
|
* Snapshots the current doc as the baseline, then runs
|
|
@@ -62,7 +62,7 @@ declare module '@tiptap/core' {
|
|
|
62
62
|
* `delete_block` / `update_block_mark` AI ops. Returns false (no
|
|
63
63
|
* dispatch) when `applyFn` produced no doc change.
|
|
64
64
|
*/
|
|
65
|
-
applySurgicalAiInlineDiff: (id: string, applyFn: (tr: Transaction) => void) => ReturnType;
|
|
65
|
+
applySurgicalAiInlineDiff: (id: string, applyFn: (tr: Transaction) => void, displayMode?: AiDiffDisplayMode) => ReturnType;
|
|
66
66
|
/** Clear diff state. Current doc IS the accepted state. */
|
|
67
67
|
acceptAiInlineDiff: () => ReturnType;
|
|
68
68
|
/** Revert doc to the captured baseline and clear diff state. */
|
|
@@ -70,12 +70,24 @@ declare module '@tiptap/core' {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* How the pending diff renders:
|
|
75
|
+
* - `'inline'` (default) — word-flow: green inline decorations on
|
|
76
|
+
* inserted ranges, deleted text struck through in place.
|
|
77
|
+
* - `'lines'` — GitHub-style: every block touched by an insert gets a
|
|
78
|
+
* full-width green row (`+` gutter), deleted content renders as a
|
|
79
|
+
* full-width red row (`−` gutter) above the change. Suits markdown
|
|
80
|
+
* sources / structured text where lines are the meaningful unit.
|
|
81
|
+
*/
|
|
82
|
+
export type AiDiffDisplayMode = 'inline' | 'lines';
|
|
73
83
|
interface DiffState {
|
|
74
84
|
id: string;
|
|
75
85
|
/** Original doc captured at `startAiInlineDiff` time — used for revert. */
|
|
76
86
|
baseline: ProseMirrorNode;
|
|
77
87
|
/** ChangeSet accumulating diffs since baseline. */
|
|
78
88
|
changeset: ChangeSet;
|
|
89
|
+
/** Rendering mode for the decorations — see `AiDiffDisplayMode`. */
|
|
90
|
+
displayMode: AiDiffDisplayMode;
|
|
79
91
|
}
|
|
80
92
|
export declare const aiInlineDiffPluginKey: PluginKey<DiffState | null>;
|
|
81
93
|
/** Read the active diff state, if any. Public for hosts that want to
|
|
@@ -73,30 +73,64 @@ export const AiInlineDiffExtension = Extension.create({
|
|
|
73
73
|
color: rgb(153, 27, 27);
|
|
74
74
|
padding: 0 0.125em;
|
|
75
75
|
}
|
|
76
|
+
.${prefix}-inserted-line {
|
|
77
|
+
display: block;
|
|
78
|
+
background-color: rgba(187, 247, 208, 0.45);
|
|
79
|
+
border-radius: 2px;
|
|
80
|
+
padding-left: 1.25em;
|
|
81
|
+
position: relative;
|
|
82
|
+
/* Some text surfaces style the editor root as a flex row (input
|
|
83
|
+
mimic); full-basis keeps each diff row on its own line there. */
|
|
84
|
+
flex: 0 0 100%;
|
|
85
|
+
width: 100%;
|
|
86
|
+
}
|
|
87
|
+
.${prefix}-inserted-line::before {
|
|
88
|
+
content: '+';
|
|
89
|
+
position: absolute;
|
|
90
|
+
left: 0.25em;
|
|
91
|
+
color: rgb(20, 83, 45);
|
|
92
|
+
opacity: 0.7;
|
|
93
|
+
}
|
|
94
|
+
.${prefix}-deleted-lines { display: block; flex: 0 0 100%; width: 100%; }
|
|
95
|
+
.${prefix}-lines-active { display: block !important; }
|
|
96
|
+
.${prefix}-deleted-line {
|
|
97
|
+
background-color: rgba(254, 226, 226, 0.55);
|
|
98
|
+
color: rgb(153, 27, 27);
|
|
99
|
+
border-radius: 2px;
|
|
100
|
+
padding-left: 1.25em;
|
|
101
|
+
position: relative;
|
|
102
|
+
white-space: pre-wrap;
|
|
103
|
+
}
|
|
104
|
+
.${prefix}-deleted-line::before {
|
|
105
|
+
content: '−';
|
|
106
|
+
position: absolute;
|
|
107
|
+
left: 0.25em;
|
|
108
|
+
opacity: 0.7;
|
|
109
|
+
}
|
|
76
110
|
`;
|
|
77
111
|
document.head.appendChild(style);
|
|
78
112
|
},
|
|
79
113
|
addCommands() {
|
|
80
114
|
return {
|
|
81
|
-
startAiInlineDiff: (id, newDocSlice) => ({ tr, state, dispatch }) => {
|
|
115
|
+
startAiInlineDiff: (id, newDocSlice, displayMode) => ({ tr, state, dispatch }) => {
|
|
82
116
|
const baseline = state.doc;
|
|
83
117
|
const docEnd = state.doc.content.size;
|
|
84
118
|
// Replace the whole doc body with the proposed content. The
|
|
85
119
|
// schema enforces validity — if the slice doesn't fit, ProseMirror
|
|
86
120
|
// throws (callers should pre-validate via `editor.schema`).
|
|
87
121
|
tr.replaceRange(0, docEnd, newDocSlice);
|
|
88
|
-
const meta = { type: 'start', id, baseline };
|
|
122
|
+
const meta = { type: 'start', id, baseline, ...(displayMode ? { displayMode } : {}) };
|
|
89
123
|
tr.setMeta(aiInlineDiffPluginKey, meta);
|
|
90
124
|
if (dispatch)
|
|
91
125
|
dispatch(tr);
|
|
92
126
|
return true;
|
|
93
127
|
},
|
|
94
|
-
applySurgicalAiInlineDiff: (id, applyFn) => ({ tr, state, dispatch }) => {
|
|
128
|
+
applySurgicalAiInlineDiff: (id, applyFn, displayMode) => ({ tr, state, dispatch }) => {
|
|
95
129
|
const baseline = state.doc;
|
|
96
130
|
applyFn(tr);
|
|
97
131
|
if (!tr.docChanged)
|
|
98
132
|
return false;
|
|
99
|
-
const meta = { type: 'start', id, baseline };
|
|
133
|
+
const meta = { type: 'start', id, baseline, ...(displayMode ? { displayMode } : {}) };
|
|
100
134
|
tr.setMeta(aiInlineDiffPluginKey, meta);
|
|
101
135
|
if (dispatch)
|
|
102
136
|
dispatch(tr);
|
|
@@ -141,7 +175,7 @@ export const AiInlineDiffExtension = Extension.create({
|
|
|
141
175
|
// the transaction's step list to compute the diff between
|
|
142
176
|
// the baseline doc and the post-transaction doc.
|
|
143
177
|
const cs = ChangeSet.create(meta.baseline).addSteps(tr.doc, tr.mapping.maps, null);
|
|
144
|
-
return { id: meta.id, baseline: meta.baseline, changeset: cs };
|
|
178
|
+
return { id: meta.id, baseline: meta.baseline, changeset: cs, displayMode: meta.displayMode ?? 'inline' };
|
|
145
179
|
}
|
|
146
180
|
if (meta?.type === 'clear')
|
|
147
181
|
return null;
|
|
@@ -164,12 +198,25 @@ export const AiInlineDiffExtension = Extension.create({
|
|
|
164
198
|
return DecorationSet.empty;
|
|
165
199
|
return buildDiffDecorations(state, ds, ext.options.classPrefix ?? 'pilotiq-ai-diff');
|
|
166
200
|
},
|
|
201
|
+
// While a LINES-mode diff is active, force the editor root to
|
|
202
|
+
// block layout. Some text surfaces style the root as a flex row
|
|
203
|
+
// (single-line input mimic) — without this the stacked diff
|
|
204
|
+
// rows lay out as overflowing columns. Drops automatically on
|
|
205
|
+
// accept / reject.
|
|
206
|
+
attributes(state) {
|
|
207
|
+
const ds = aiInlineDiffPluginKey.getState(state);
|
|
208
|
+
return ds?.displayMode === 'lines'
|
|
209
|
+
? { class: `${ext.options.classPrefix ?? 'pilotiq-ai-diff'}-lines-active` }
|
|
210
|
+
: {};
|
|
211
|
+
},
|
|
167
212
|
},
|
|
168
213
|
}),
|
|
169
214
|
];
|
|
170
215
|
},
|
|
171
216
|
});
|
|
172
217
|
function buildDiffDecorations(state, ds, prefix) {
|
|
218
|
+
if (ds.displayMode === 'lines')
|
|
219
|
+
return buildLineDiffDecorations(state, ds, prefix);
|
|
173
220
|
const decos = [];
|
|
174
221
|
const docSize = state.doc.content.size;
|
|
175
222
|
for (const change of ds.changeset.changes) {
|
|
@@ -213,3 +260,126 @@ function buildDeletedWidget(text, prefix, id) {
|
|
|
213
260
|
root.appendChild(inner);
|
|
214
261
|
return root;
|
|
215
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* GitHub-style line rendering. Unlike the inline mode (which walks the
|
|
265
|
+
* changeset's MINIMAL change ranges), lines mode treats whole top-level
|
|
266
|
+
* blocks as the diff unit — an LCS over the baseline's block texts vs
|
|
267
|
+
* the current doc's block texts. A partially-edited line therefore
|
|
268
|
+
* renders as one full red row (the old line) above one full green row
|
|
269
|
+
* (the new line), instead of fragmented word-level shards. Mark-only
|
|
270
|
+
* changes (same text, different formatting) read as "kept" here — the
|
|
271
|
+
* block unit is text; use inline mode when formatting deltas matter.
|
|
272
|
+
*
|
|
273
|
+
* The changeset state still drives baseline capture / accept / reject;
|
|
274
|
+
* lines mode just re-derives its presentation from baseline-vs-current
|
|
275
|
+
* on every decoration pass, so remote collab edits during review stay
|
|
276
|
+
* correct for free.
|
|
277
|
+
*/
|
|
278
|
+
function buildLineDiffDecorations(state, ds, prefix) {
|
|
279
|
+
const decos = [];
|
|
280
|
+
const baseTexts = topLevelBlockTexts(ds.baseline);
|
|
281
|
+
const current = [];
|
|
282
|
+
state.doc.forEach((node, pos) => {
|
|
283
|
+
current.push({ text: node.textContent, pos, nodeSize: node.nodeSize });
|
|
284
|
+
});
|
|
285
|
+
// Walk tokens with a pointer into the CURRENT doc's blocks. Removed
|
|
286
|
+
// baseline lines accumulate and flush as ONE widget anchored before
|
|
287
|
+
// the next current block (or at doc end), so consecutive deletions
|
|
288
|
+
// render as a contiguous red row group.
|
|
289
|
+
let j = 0;
|
|
290
|
+
let pendingRemoved = [];
|
|
291
|
+
const flushRemoved = (anchor) => {
|
|
292
|
+
if (pendingRemoved.length === 0)
|
|
293
|
+
return;
|
|
294
|
+
const lines = pendingRemoved;
|
|
295
|
+
pendingRemoved = [];
|
|
296
|
+
decos.push(Decoration.widget(anchor, () => buildDeletedLinesWidget(lines.join('\n'), prefix, ds.id), {
|
|
297
|
+
side: -1,
|
|
298
|
+
ignoreSelection: true,
|
|
299
|
+
key: `pilotiq-ai-diff:deleted-lines:${anchor}:${lines.length}`,
|
|
300
|
+
}));
|
|
301
|
+
};
|
|
302
|
+
for (const tok of lcsBlockDiffTokens(baseTexts, current.map(c => c.text))) {
|
|
303
|
+
if (tok.kind === 'kept') {
|
|
304
|
+
flushRemoved(current[j].pos);
|
|
305
|
+
j++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (tok.kind === 'added') {
|
|
309
|
+
flushRemoved(current[j].pos);
|
|
310
|
+
decos.push(Decoration.node(current[j].pos, current[j].pos + current[j].nodeSize, {
|
|
311
|
+
class: `${prefix}-inserted-line`,
|
|
312
|
+
'data-pilotiq-ai-diff-id': ds.id,
|
|
313
|
+
}));
|
|
314
|
+
j++;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
// removed — baseline-only line; accumulate for the next flush.
|
|
318
|
+
pendingRemoved.push(tok.text);
|
|
319
|
+
}
|
|
320
|
+
flushRemoved(state.doc.content.size);
|
|
321
|
+
return DecorationSet.create(state.doc, decos);
|
|
322
|
+
}
|
|
323
|
+
function topLevelBlockTexts(doc) {
|
|
324
|
+
const out = [];
|
|
325
|
+
doc.forEach((node) => { out.push(node.textContent); });
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
/** Standard LCS walk over two block-text arrays, emitting tokens in
|
|
329
|
+
* presentation order with removed-before-added on replacements. */
|
|
330
|
+
function lcsBlockDiffTokens(a, b) {
|
|
331
|
+
const n = a.length;
|
|
332
|
+
const m = b.length;
|
|
333
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
334
|
+
for (let i = 1; i <= n; i++) {
|
|
335
|
+
for (let j = 1; j <= m; j++) {
|
|
336
|
+
if (a[i - 1] === b[j - 1])
|
|
337
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
338
|
+
else
|
|
339
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const out = [];
|
|
343
|
+
let i = n;
|
|
344
|
+
let j = m;
|
|
345
|
+
while (i > 0 && j > 0) {
|
|
346
|
+
if (a[i - 1] === b[j - 1]) {
|
|
347
|
+
out.push({ kind: 'kept', text: a[i - 1] });
|
|
348
|
+
i--;
|
|
349
|
+
j--;
|
|
350
|
+
}
|
|
351
|
+
// Strict `>` ties toward pushing `added` first in this backward walk,
|
|
352
|
+
// which renders removed-before-added after the final reverse.
|
|
353
|
+
else if (dp[i - 1][j] > dp[i][j - 1]) {
|
|
354
|
+
out.push({ kind: 'removed', text: a[i - 1] });
|
|
355
|
+
i--;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
out.push({ kind: 'added', text: b[j - 1] });
|
|
359
|
+
j--;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
while (i > 0) {
|
|
363
|
+
out.push({ kind: 'removed', text: a[i - 1] });
|
|
364
|
+
i--;
|
|
365
|
+
}
|
|
366
|
+
while (j > 0) {
|
|
367
|
+
out.push({ kind: 'added', text: b[j - 1] });
|
|
368
|
+
j--;
|
|
369
|
+
}
|
|
370
|
+
out.reverse();
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
function buildDeletedLinesWidget(text, prefix, id) {
|
|
374
|
+
const root = document.createElement('div');
|
|
375
|
+
root.className = `${prefix}-deleted-lines`;
|
|
376
|
+
root.setAttribute('data-pilotiq-ai-diff-id', id);
|
|
377
|
+
root.contentEditable = 'false';
|
|
378
|
+
for (const line of text.split('\n')) {
|
|
379
|
+
const row = document.createElement('div');
|
|
380
|
+
row.className = `${prefix}-deleted-line`;
|
|
381
|
+
row.textContent = line || ' ';
|
|
382
|
+
root.appendChild(row);
|
|
383
|
+
}
|
|
384
|
+
return root;
|
|
385
|
+
}
|
|
@@ -129,7 +129,7 @@ var require_markdown_it_task_lists = __commonJS({
|
|
|
129
129
|
}
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
// ../../node_modules/.pnpm/tiptap-markdown@0.9.0_@tiptap+core@3.
|
|
132
|
+
// ../../node_modules/.pnpm/tiptap-markdown@0.9.0_@tiptap+core@3.26.0_@tiptap+pm@3.26.0_/node_modules/tiptap-markdown/dist/tiptap-markdown.es.js
|
|
133
133
|
import { Extension, Mark as Mark2, getHTMLFromFragment, Node as Node$1, extensions } from "@tiptap/core";
|
|
134
134
|
|
|
135
135
|
// ../../node_modules/.pnpm/orderedmap@2.1.1/node_modules/orderedmap/dist/index.js
|
|
@@ -8859,7 +8859,7 @@ var MarkdownSerializerState = class {
|
|
|
8859
8859
|
}
|
|
8860
8860
|
};
|
|
8861
8861
|
|
|
8862
|
-
// ../../node_modules/.pnpm/tiptap-markdown@0.9.0_@tiptap+core@3.
|
|
8862
|
+
// ../../node_modules/.pnpm/tiptap-markdown@0.9.0_@tiptap+core@3.26.0_@tiptap+pm@3.26.0_/node_modules/tiptap-markdown/dist/tiptap-markdown.es.js
|
|
8863
8863
|
var import_markdown_it_task_lists = __toESM(require_markdown_it_task_lists(), 1);
|
|
8864
8864
|
import { Fragment as Fragment2, DOMParser } from "@tiptap/pm/model";
|
|
8865
8865
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { useEditor, EditorContent } from '@tiptap/react';
|
|
4
|
+
import { Slice } from '@tiptap/pm/model';
|
|
4
5
|
import { useCollabRoom, getCollabExtensions, } from '@pilotiq/pilotiq/react';
|
|
5
6
|
import { useCollabSeed } from '@rudderjs/sync/react';
|
|
6
7
|
import { createPlainTextEditor, plainTextOf, plainTextToDoc } from '../PlainTextEditor.js';
|
|
7
8
|
import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js';
|
|
9
|
+
import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js';
|
|
8
10
|
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
|
|
11
|
+
import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
|
|
12
|
+
import { AiSuggestionBanner } from './AiSuggestionBanner.js';
|
|
9
13
|
/**
|
|
10
14
|
* Tiptap-backed plain-text editor for pilotiq's `TextField` / `TextareaField`
|
|
11
15
|
* / similar single-line / multi-line text fields when collab is on.
|
|
@@ -93,12 +97,14 @@ export function CollabTextRenderer({ name, fragmentKey, multiline, defaultValue,
|
|
|
93
97
|
// seeds the fragment on first connect when it's still empty. When
|
|
94
98
|
// collab is off, seed from defaultValue directly.
|
|
95
99
|
content: collabActive ? '' : defaultValue,
|
|
96
|
-
// AI suggestions —
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
+
// AI suggestions — chip extension (producer-supplied range
|
|
101
|
+
// suggestions) + inline-diff extension (whole-field suggestions:
|
|
102
|
+
// red strikethrough on removed runs, green on inserted, with the
|
|
103
|
+
// `<AiSuggestionBanner>` Accept / Reject below). Both idle until
|
|
104
|
+
// a suggestion arrives via the bridges below. Matches the
|
|
105
|
+
// `TiptapEditor` wiring so the review surface reads identically
|
|
100
106
|
// across RichTextField / MarkdownField / TextField+TextareaField.
|
|
101
|
-
extensions: [...collabExtensions, AiSuggestionExtension],
|
|
107
|
+
extensions: [...collabExtensions, AiSuggestionExtension, AiInlineDiffExtension],
|
|
102
108
|
onUpdate: (text) => onChange(text),
|
|
103
109
|
...(onSubmit ? { onSubmit: () => { onSubmit(); return false; } } : {}),
|
|
104
110
|
...(className || editorAttributes
|
|
@@ -125,26 +131,36 @@ export function CollabTextRenderer({ name, fragmentKey, multiline, defaultValue,
|
|
|
125
131
|
// `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
|
|
126
132
|
// extension. No-op when no provider is mounted (default no-op context).
|
|
127
133
|
//
|
|
128
|
-
// Whole-field
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
// Whole-field suggestions do NOT synthesize a chip range anymore —
|
|
135
|
+
// they render through `useAiInlineDiff` below (same red/green inline
|
|
136
|
+
// diff + banner as `TiptapEditor`), replacing the old green-pill chip
|
|
137
|
+
// that read differently from the rich-text surface. The bridge stays
|
|
138
|
+
// mounted for producer-supplied `meta.editorRange` suggestions (precise
|
|
139
|
+
// anchors worth visualizing in place) and as the `onApplyWholeField`
|
|
140
|
+
// fallback when the diff path can't parse a suggestion.
|
|
141
|
+
const applyWholeField = (value) => {
|
|
142
|
+
if (!editor || editor.isDestroyed)
|
|
143
|
+
return;
|
|
144
|
+
editor.commands.setContent(plainTextToDoc(value, !!multiline));
|
|
145
|
+
};
|
|
137
146
|
useAiSuggestionBridge(editor ?? null, name, {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
147
|
+
onApplyWholeField: applyWholeField,
|
|
148
|
+
});
|
|
149
|
+
// Inline diff for whole-field suggestions — plain-text shape: each
|
|
150
|
+
// line wraps in a `paragraph` node, mirroring `plainTextToDoc`.
|
|
151
|
+
useAiInlineDiff(editor ?? null, name, {
|
|
152
|
+
parseSuggestion: (ed, value) => {
|
|
153
|
+
try {
|
|
154
|
+
const node = ed.schema.nodeFromJSON(plainTextToDoc(value, !!multiline));
|
|
155
|
+
return new Slice(node.content, 0, 0);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
146
160
|
},
|
|
161
|
+
resolveDisplayMode: () => readAiDiffViewMarker(name),
|
|
147
162
|
});
|
|
163
|
+
const isDiffActive = useIsAiInlineDiffActive(editor ?? null);
|
|
148
164
|
// First-load seed when collab is active. Collaboration starts the editor
|
|
149
165
|
// empty regardless of `defaultValue`; once the room's first sync
|
|
150
166
|
// resolves, `useCollabSeed` runs the callback inside `ydoc.transact`.
|
|
@@ -197,5 +213,13 @@ export function CollabTextRenderer({ name, fragmentKey, multiline, defaultValue,
|
|
|
197
213
|
onChange(plainTextOf(editor));
|
|
198
214
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
199
215
|
}, [editor]);
|
|
200
|
-
|
|
216
|
+
// Banner mounts below the editor exactly like `TiptapEditor`'s — it
|
|
217
|
+
// renders nothing while no suggestion is pending for this field, so
|
|
218
|
+
// the single-line text surface keeps its normal footprint.
|
|
219
|
+
return (_jsxs(_Fragment, { children: [_jsx(EditorContent, { editor: editor }), _jsx(AiSuggestionBanner, { fieldName: name, onApplyWholeField: applyWholeField, ...(isDiffActive && editor
|
|
220
|
+
? {
|
|
221
|
+
onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
|
|
222
|
+
onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
|
|
223
|
+
}
|
|
224
|
+
: {}) })] }));
|
|
201
225
|
}
|
|
@@ -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
|
|
@@ -17,7 +17,7 @@ import { Popover } from '@base-ui/react/popover';
|
|
|
17
17
|
import { useCollabRoom, getCollabExtensions, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react';
|
|
18
18
|
import { useCollabSeed } from '@rudderjs/sync/react';
|
|
19
19
|
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
|
|
20
|
-
import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js';
|
|
20
|
+
import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
|
|
21
21
|
import { AiSuggestionBanner } from './AiSuggestionBanner.js';
|
|
22
22
|
import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
|
|
23
23
|
import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js';
|
|
@@ -490,6 +490,7 @@ function ClientEditor(props) {
|
|
|
490
490
|
return null;
|
|
491
491
|
}
|
|
492
492
|
},
|
|
493
|
+
resolveDisplayMode: () => readAiDiffViewMarker(name),
|
|
493
494
|
});
|
|
494
495
|
const isDiffActive = useIsAiInlineDiffActive(editor ?? null);
|
|
495
496
|
// 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/test/setup.js
CHANGED
|
@@ -40,6 +40,9 @@ const globals = {
|
|
|
40
40
|
KeyboardEvent: window.KeyboardEvent,
|
|
41
41
|
CustomEvent: window.CustomEvent,
|
|
42
42
|
DocumentFragment: window.DocumentFragment,
|
|
43
|
+
// prosemirror-view ≥1.41 probes `root instanceof ShadowRoot` while
|
|
44
|
+
// resolving the editor's event root.
|
|
45
|
+
ShadowRoot: window.ShadowRoot,
|
|
43
46
|
Range: window.Range,
|
|
44
47
|
Selection: window.Selection,
|
|
45
48
|
MutationObserver: window.MutationObserver,
|
|
@@ -57,6 +60,15 @@ const globals = {
|
|
|
57
60
|
for (const [k, v] of Object.entries(globals)) {
|
|
58
61
|
Object.defineProperty(globalThis, k, { value: v, writable: true, configurable: true });
|
|
59
62
|
}
|
|
63
|
+
// jsdom has no layout engine and never implements elementFromPoint;
|
|
64
|
+
// prosemirror-view ≥1.41 calls it during view initialization.
|
|
65
|
+
if (typeof window.document.elementFromPoint !== 'function') {
|
|
66
|
+
Object.defineProperty(window.document, 'elementFromPoint', {
|
|
67
|
+
value: () => null,
|
|
68
|
+
writable: true,
|
|
69
|
+
configurable: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
60
72
|
// React 19 + RTL require `IS_REACT_ACT_ENVIRONMENT` so `act()` warnings
|
|
61
73
|
// don't fire on every render. Without it, Tiptap's mount cascade
|
|
62
74
|
// produces dozens of warnings that swamp real test failures.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pilotiq/tiptap",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
4
4
|
"description": "Tiptap rich-text editor adapter for @pilotiq/pilotiq — slash menu, draggable blocks, custom-block API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"react-dom": "^19",
|
|
92
92
|
"tiptap-markdown": "^0.9",
|
|
93
93
|
"typescript": "^5",
|
|
94
|
-
"@pilotiq/pilotiq": "^0.
|
|
94
|
+
"@pilotiq/pilotiq": "^0.37.0"
|
|
95
95
|
},
|
|
96
96
|
"author": "Suleiman Shahbari",
|
|
97
97
|
"scripts": {
|