@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 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 text — pull from the baseline using the `fromA..toA` range.
236
- // Render via a widget at the change's insert-point (or end of insert)
237
- // so the deleted text appears immediately before / after the new run.
238
- // Empty deletions (pure inserts) skip the widget.
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
- const deletedText = ds.baseline.textBetween(change.fromA, change.toA, '\n', ' ');
241
- if (deletedText.length > 0) {
242
- decos.push(Decoration.widget(fromB, () => buildDeletedWidget(deletedText, prefix, ds.id), {
243
- side: -1,
244
- ignoreSelection: true,
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
- function buildDeletedWidget(text, prefix, id) {
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
- const inner = document.createElement('span');
258
- inner.className = `${prefix}-deleted-text`;
259
- inner.textContent = text;
260
- root.appendChild(inner);
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 baseTexts = topLevelBlockTexts(ds.baseline);
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
- // 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.
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 lines = pendingRemoved;
354
+ const nodes = pendingRemoved;
295
355
  pendingRemoved = [];
296
- decos.push(Decoration.widget(anchor, () => buildDeletedLinesWidget(lines.join('\n'), prefix, ds.id), {
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}:${lines.length}`,
359
+ key: `pilotiq-ai-diff:deleted-lines:${anchor}:${nodes.length}`,
300
360
  }));
301
361
  };
302
- for (const tok of lcsBlockDiffTokens(baseTexts, current.map(c => c.text))) {
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 line; accumulate for the next flush.
318
- pendingRemoved.push(tok.text);
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 topLevelBlockTexts(doc) {
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(text, prefix, id) {
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
- for (const line of text.split('\n')) {
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
- row.textContent = line || ' ';
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.19.1",
3
+ "version": "3.19.2",
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": {