@pilotiq/tiptap 3.19.1 → 3.19.2
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 +8 -0
- package/dist/extensions/AiInlineDiffExtension.js +98 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @pilotiq/tiptap
|
|
2
2
|
|
|
3
|
+
## 3.19.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6064202: AI inline diff: the removed/deleted side now keeps its original block formatting (#91).
|
|
8
|
+
|
|
9
|
+
Previously the deleted content was flattened with `textBetween()` / `node.textContent`, so a removed heading, list, FAQ, or other formatted block rendered as plain text next to the (correctly-formatted) inserted side. The deleted widget now serializes the baseline's real nodes via the schema's `DOMSerializer` — a removed `<h2>` stays an `<h2>`, a list keeps its `<ul><li>`, a FAQ keeps its wrappers. A single plain top-level paragraph edit still renders as the inline word-level diff (no regression). Applies to both inline and `lines` display modes.
|
|
10
|
+
|
|
3
11
|
## 3.19.1
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
import { Extension } from '@tiptap/core';
|
|
35
35
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
36
36
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
37
|
+
import { DOMSerializer, Fragment } from '@tiptap/pm/model';
|
|
37
38
|
import { ChangeSet } from 'prosemirror-changeset';
|
|
38
39
|
export const aiInlineDiffPluginKey = new PluginKey('pilotiqAiInlineDiff');
|
|
39
40
|
/** Read the active diff state, if any. Public for hosts that want to
|
|
@@ -73,6 +74,21 @@ export const AiInlineDiffExtension = Extension.create({
|
|
|
73
74
|
color: rgb(153, 27, 27);
|
|
74
75
|
padding: 0 0.125em;
|
|
75
76
|
}
|
|
77
|
+
/* Structure-preserving deleted block (heading / list / faq / …):
|
|
78
|
+
keep the removed node's own formatting, tinted red + struck so it
|
|
79
|
+
still reads as "deleted". Block display so an <h2>/<ul>/faq lays
|
|
80
|
+
out as itself rather than collapsing inline. */
|
|
81
|
+
.${prefix}-deleted-block {
|
|
82
|
+
display: block;
|
|
83
|
+
background-color: rgba(254, 226, 226, 0.55);
|
|
84
|
+
color: rgb(153, 27, 27);
|
|
85
|
+
text-decoration: line-through;
|
|
86
|
+
text-decoration-color: rgba(220, 38, 38, 0.7);
|
|
87
|
+
border-radius: 2px;
|
|
88
|
+
padding: 0 0.25em;
|
|
89
|
+
opacity: 0.9;
|
|
90
|
+
}
|
|
91
|
+
.${prefix}-deleted-block > * { margin-top: 0; margin-bottom: 0; }
|
|
76
92
|
.${prefix}-inserted-line {
|
|
77
93
|
display: block;
|
|
78
94
|
background-color: rgba(187, 247, 208, 0.45);
|
|
@@ -107,6 +123,7 @@ export const AiInlineDiffExtension = Extension.create({
|
|
|
107
123
|
left: 0.25em;
|
|
108
124
|
opacity: 0.7;
|
|
109
125
|
}
|
|
126
|
+
.${prefix}-deleted-line > * { margin-top: 0; margin-bottom: 0; }
|
|
110
127
|
`;
|
|
111
128
|
document.head.appendChild(style);
|
|
112
129
|
},
|
|
@@ -232,32 +249,72 @@ function buildDiffDecorations(state, ds, prefix) {
|
|
|
232
249
|
'data-pilotiq-ai-diff-id': ds.id,
|
|
233
250
|
}));
|
|
234
251
|
}
|
|
235
|
-
// Deleted
|
|
236
|
-
// Render via a widget at the change's insert-point
|
|
237
|
-
//
|
|
238
|
-
//
|
|
252
|
+
// Deleted content — pull from the baseline using the `fromA..toA`
|
|
253
|
+
// range. Render via a widget at the change's insert-point so the
|
|
254
|
+
// deleted content appears immediately before the new run. Empty
|
|
255
|
+
// deletions (pure inserts) skip the widget.
|
|
239
256
|
if (change.toA > change.fromA) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
key: `pilotiq-ai-diff:deleted:${change.fromA}:${change.toA}`,
|
|
246
|
-
}));
|
|
247
|
-
}
|
|
257
|
+
decos.push(Decoration.widget(fromB, () => buildDeletedWidget(ds.baseline, change.fromA, change.toA, prefix, ds.id), {
|
|
258
|
+
side: -1,
|
|
259
|
+
ignoreSelection: true,
|
|
260
|
+
key: `pilotiq-ai-diff:deleted:${change.fromA}:${change.toA}`,
|
|
261
|
+
}));
|
|
248
262
|
}
|
|
249
263
|
}
|
|
250
264
|
return DecorationSet.create(state.doc, decos);
|
|
251
265
|
}
|
|
252
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Render the deleted side of a change preserving its ORIGINAL block
|
|
268
|
+
* formatting (heading / list / faq / alert / blockquote …) — bug #91.
|
|
269
|
+
*
|
|
270
|
+
* Strategy: collect the BASELINE top-level blocks the deleted range
|
|
271
|
+
* `fromA..toA` overlaps, each CUT to the overlapping span, and serialize
|
|
272
|
+
* those real nodes via the schema's `DOMSerializer`. A removed heading
|
|
273
|
+
* stays an `<h2>`, a removed list keeps its `<ul><li>`, a faq keeps its
|
|
274
|
+
* wrappers — because we serialize the node itself, not flattened text.
|
|
275
|
+
*
|
|
276
|
+
* The one exception is a single plain top-level paragraph: there the inline
|
|
277
|
+
* word-diff look (red strike-through text) reads better, and wrapping a
|
|
278
|
+
* one-word change in its `<p>` would stack it as a block and break the
|
|
279
|
+
* inline diff — so paragraphs stay text.
|
|
280
|
+
*/
|
|
281
|
+
function buildDeletedWidget(baseline, fromA, toA, prefix, id) {
|
|
253
282
|
const root = document.createElement('span');
|
|
254
283
|
root.className = `${prefix}-deleted`;
|
|
255
284
|
root.setAttribute('data-pilotiq-ai-diff-id', id);
|
|
256
285
|
root.contentEditable = 'false';
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
286
|
+
// Walk the baseline's top-level blocks; for each one the deleted range
|
|
287
|
+
// touches, keep the whole node when fully covered, else cut it to the
|
|
288
|
+
// overlapping slice (preserving the node's own wrapper either way).
|
|
289
|
+
const pieces = [];
|
|
290
|
+
baseline.forEach((child, offset) => {
|
|
291
|
+
if (Math.min(toA, offset + child.nodeSize) <= Math.max(fromA, offset))
|
|
292
|
+
return;
|
|
293
|
+
if (child.isAtom) {
|
|
294
|
+
pieces.push(child);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const localFrom = Math.max(0, fromA - (offset + 1));
|
|
298
|
+
const localTo = Math.min(child.content.size, toA - (offset + 1));
|
|
299
|
+
if (localFrom <= 0 && localTo >= child.content.size) {
|
|
300
|
+
pieces.push(child);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (localTo <= localFrom)
|
|
304
|
+
return;
|
|
305
|
+
pieces.push(child.cut(localFrom, localTo));
|
|
306
|
+
});
|
|
307
|
+
if (pieces.length === 1 && pieces[0].type.name === 'paragraph') {
|
|
308
|
+
const inner = document.createElement('span');
|
|
309
|
+
inner.className = `${prefix}-deleted-text`;
|
|
310
|
+
inner.textContent = baseline.textBetween(fromA, toA, '\n', ' ');
|
|
311
|
+
root.appendChild(inner);
|
|
312
|
+
return root;
|
|
313
|
+
}
|
|
314
|
+
const block = document.createElement('span');
|
|
315
|
+
block.className = `${prefix}-deleted-block`;
|
|
316
|
+
block.appendChild(DOMSerializer.fromSchema(baseline.type.schema).serializeFragment(Fragment.fromArray(pieces)));
|
|
317
|
+
root.appendChild(block);
|
|
261
318
|
return root;
|
|
262
319
|
}
|
|
263
320
|
/**
|
|
@@ -277,32 +334,36 @@ function buildDeletedWidget(text, prefix, id) {
|
|
|
277
334
|
*/
|
|
278
335
|
function buildLineDiffDecorations(state, ds, prefix) {
|
|
279
336
|
const decos = [];
|
|
280
|
-
const
|
|
337
|
+
const baseBlocks = topLevelBlocks(ds.baseline);
|
|
281
338
|
const current = [];
|
|
282
339
|
state.doc.forEach((node, pos) => {
|
|
283
340
|
current.push({ text: node.textContent, pos, nodeSize: node.nodeSize });
|
|
284
341
|
});
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
342
|
+
const schema = ds.baseline.type.schema;
|
|
343
|
+
// Walk tokens with a pointer into the CURRENT doc's blocks (`j`) and the
|
|
344
|
+
// BASELINE blocks (`bi`). Removed baseline BLOCKS (the real nodes, not
|
|
345
|
+
// their flattened text — bug #91) accumulate and flush as ONE widget
|
|
346
|
+
// anchored before the next current block (or at doc end), so consecutive
|
|
347
|
+
// deletions render as a contiguous red row group with their formatting.
|
|
289
348
|
let j = 0;
|
|
349
|
+
let bi = 0;
|
|
290
350
|
let pendingRemoved = [];
|
|
291
351
|
const flushRemoved = (anchor) => {
|
|
292
352
|
if (pendingRemoved.length === 0)
|
|
293
353
|
return;
|
|
294
|
-
const
|
|
354
|
+
const nodes = pendingRemoved;
|
|
295
355
|
pendingRemoved = [];
|
|
296
|
-
decos.push(Decoration.widget(anchor, () => buildDeletedLinesWidget(
|
|
356
|
+
decos.push(Decoration.widget(anchor, () => buildDeletedLinesWidget(nodes, schema, prefix, ds.id), {
|
|
297
357
|
side: -1,
|
|
298
358
|
ignoreSelection: true,
|
|
299
|
-
key: `pilotiq-ai-diff:deleted-lines:${anchor}:${
|
|
359
|
+
key: `pilotiq-ai-diff:deleted-lines:${anchor}:${nodes.length}`,
|
|
300
360
|
}));
|
|
301
361
|
};
|
|
302
|
-
for (const tok of lcsBlockDiffTokens(
|
|
362
|
+
for (const tok of lcsBlockDiffTokens(baseBlocks.map(b => b.text), current.map(c => c.text))) {
|
|
303
363
|
if (tok.kind === 'kept') {
|
|
304
364
|
flushRemoved(current[j].pos);
|
|
305
365
|
j++;
|
|
366
|
+
bi++;
|
|
306
367
|
continue;
|
|
307
368
|
}
|
|
308
369
|
if (tok.kind === 'added') {
|
|
@@ -314,15 +375,16 @@ function buildLineDiffDecorations(state, ds, prefix) {
|
|
|
314
375
|
j++;
|
|
315
376
|
continue;
|
|
316
377
|
}
|
|
317
|
-
// removed — baseline-only
|
|
318
|
-
pendingRemoved.push(
|
|
378
|
+
// removed — baseline-only block; accumulate the real node for the flush.
|
|
379
|
+
pendingRemoved.push(baseBlocks[bi].node);
|
|
380
|
+
bi++;
|
|
319
381
|
}
|
|
320
382
|
flushRemoved(state.doc.content.size);
|
|
321
383
|
return DecorationSet.create(state.doc, decos);
|
|
322
384
|
}
|
|
323
|
-
function
|
|
385
|
+
function topLevelBlocks(doc) {
|
|
324
386
|
const out = [];
|
|
325
|
-
doc.forEach((node) => { out.push(node.textContent); });
|
|
387
|
+
doc.forEach((node) => { out.push({ text: node.textContent, node }); });
|
|
326
388
|
return out;
|
|
327
389
|
}
|
|
328
390
|
/** Standard LCS walk over two block-text arrays, emitting tokens in
|
|
@@ -370,15 +432,18 @@ function lcsBlockDiffTokens(a, b) {
|
|
|
370
432
|
out.reverse();
|
|
371
433
|
return out;
|
|
372
434
|
}
|
|
373
|
-
function buildDeletedLinesWidget(
|
|
435
|
+
function buildDeletedLinesWidget(nodes, schema, prefix, id) {
|
|
374
436
|
const root = document.createElement('div');
|
|
375
437
|
root.className = `${prefix}-deleted-lines`;
|
|
376
438
|
root.setAttribute('data-pilotiq-ai-diff-id', id);
|
|
377
439
|
root.contentEditable = 'false';
|
|
378
|
-
|
|
440
|
+
const serializer = DOMSerializer.fromSchema(schema);
|
|
441
|
+
for (const node of nodes) {
|
|
379
442
|
const row = document.createElement('div');
|
|
380
443
|
row.className = `${prefix}-deleted-line`;
|
|
381
|
-
|
|
444
|
+
// Serialize the real node so a removed heading / list / faq keeps its
|
|
445
|
+
// structure (bug #91), instead of collapsing to one plain text row.
|
|
446
|
+
row.appendChild(serializer.serializeFragment(Fragment.from(node)));
|
|
382
447
|
root.appendChild(row);
|
|
383
448
|
}
|
|
384
449
|
return root;
|