@pilotiq/tiptap 3.19.0 → 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,21 @@
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
+
11
+ ## 3.19.1
12
+
13
+ ### Patch Changes
14
+
15
+ - ff44b8d: Show the inline mark toolbar on a bare caret inside a formatting mark.
16
+
17
+ The selection-based `FloatingToolbar` only appeared on a non-empty text selection, so a link or bold span couldn't be edited by just clicking into it (#156). Its show/hide decision now lives in a pure, exported `shouldShowFloatingToolbar(state)` predicate: a caret (empty selection) surfaces the toolbar when it sits inside one of the toolbar's marks (`bold` / `italic` / `strike` / `code` / `link`), non-empty ranges behave as before, and the callout/alert suppression from #155 still holds. Pinned by `floatingToolbarVisibility.dom.test.ts` against the real schema.
18
+
3
19
  ## 3.19.0
4
20
 
5
21
  ### Minor 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/dist/index.d.ts CHANGED
@@ -12,4 +12,5 @@ export { AiInlineDiffExtension, aiInlineDiffPluginKey, getAiInlineDiffState, typ
12
12
  export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planWrapBlocks, planUpdateBlockMark, summarizeBlockStructure, type BlockMarkRange, type TransactionModifier, } from './surgicalOps.js';
13
13
  export { renderRichTextToHtml, isRichTextValue, type RenderRichTextOptions, type TiptapNode, type TiptapMark, } from './render.js';
14
14
  export { contentBlockNodes, Intro, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, LabeledBlockExitKeymap, planExitLabeledBlock, isSelectionInAlert, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, type AlertType, } from './extensions/contentBlocks.js';
15
+ export { shouldShowFloatingToolbar, TOOLBAR_MARKS } from './react/floatingToolbarVisibility.js';
15
16
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -17,3 +17,4 @@ export { renderRichTextToHtml, isRichTextValue, } from './render.js';
17
17
  // editor — e.g. to parse the content-block HTML or drive the surgical-op
18
18
  // planners (`planInsertBlockBefore` & co.) in a test, without mounting React.
19
19
  export { contentBlockNodes, Intro, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, LabeledBlockExitKeymap, planExitLabeledBlock, isSelectionInAlert, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, } from './extensions/contentBlocks.js';
20
+ export { shouldShowFloatingToolbar, TOOLBAR_MARKS } from './react/floatingToolbarVisibility.js';
@@ -3,10 +3,12 @@ interface FloatingToolbarProps {
3
3
  editor: Editor;
4
4
  }
5
5
  /**
6
- * Selection-based formatting toolbar. Visible whenever the editor has a
7
- * non-empty range selection inside text content. Inline marks (B/I/S/Code)
8
- * are grouped together; Link sits after a separator since it's a different
9
- * kind of action.
6
+ * Selection-based formatting toolbar. Visible on a non-empty range inside
7
+ * text content, AND on a bare caret sitting inside a formatting mark (so a
8
+ * link / bold span can be edited without selecting it first — #156). Inline
9
+ * marks (B/I/S/Code) are grouped together; Link sits after a separator since
10
+ * it's a different kind of action. Visibility is decided by the pure
11
+ * `shouldShowFloatingToolbar` predicate; this component only positions.
10
12
  */
11
13
  export declare function FloatingToolbar({ editor }: FloatingToolbarProps): import("react").JSX.Element;
12
14
  export {};
@@ -2,12 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Tooltip } from '@base-ui/react/tooltip';
4
4
  import { Dialog } from '@base-ui/react/dialog';
5
- import { isSelectionInAlert } from '../extensions/contentBlocks.js';
5
+ import { shouldShowFloatingToolbar } from './floatingToolbarVisibility.js';
6
6
  /**
7
- * Selection-based formatting toolbar. Visible whenever the editor has a
8
- * non-empty range selection inside text content. Inline marks (B/I/S/Code)
9
- * are grouped together; Link sits after a separator since it's a different
10
- * kind of action.
7
+ * Selection-based formatting toolbar. Visible on a non-empty range inside
8
+ * text content, AND on a bare caret sitting inside a formatting mark (so a
9
+ * link / bold span can be edited without selecting it first — #156). Inline
10
+ * marks (B/I/S/Code) are grouped together; Link sits after a separator since
11
+ * it's a different kind of action. Visibility is decided by the pure
12
+ * `shouldShowFloatingToolbar` predicate; this component only positions.
11
13
  */
12
14
  export function FloatingToolbar({ editor }) {
13
15
  const [pos, setPos] = useState(null);
@@ -15,24 +17,13 @@ export function FloatingToolbar({ editor }) {
15
17
  const [linkUrl, setLinkUrl] = useState('');
16
18
  useEffect(() => {
17
19
  const update = () => {
18
- const { from, to, empty } = editor.state.selection;
19
- if (empty) {
20
- setPos(null);
21
- return;
22
- }
23
- // The callout/alert block owns its content + chrome (the in-block gear
24
- // menu); the inline mark toolbar shouldn't appear inside it — or when the
25
- // whole block is picked via the drag handle (a NodeSelection) (#155).
26
- if (isSelectionInAlert(editor.state.selection)) {
27
- setPos(null);
28
- return;
29
- }
30
- // Don't show on full-block selections (e.g. clicking a custom block).
31
- const slice = editor.state.doc.slice(from, to);
32
- if (slice.content.childCount === 0) {
20
+ if (!shouldShowFloatingToolbar(editor.state)) {
33
21
  setPos(null);
34
22
  return;
35
23
  }
24
+ const { from, to } = editor.state.selection;
25
+ // For a bare caret `from === to`, so both coords resolve to the caret
26
+ // point and the toolbar centers above it.
36
27
  const start = editor.view.coordsAtPos(from);
37
28
  const end = editor.view.coordsAtPos(to);
38
29
  // Viewport-relative — pair with `position: fixed` below. The wrapper
@@ -0,0 +1,20 @@
1
+ import type { EditorState } from '@tiptap/pm/state';
2
+ /**
3
+ * Inline marks the `FloatingToolbar` can toggle — bold / italic / strike /
4
+ * code / link. A caret (empty selection) sitting inside any of these surfaces
5
+ * the toolbar so the formatting can be edited in place, without first
6
+ * selecting the text (#156).
7
+ */
8
+ export declare const TOOLBAR_MARKS: readonly ["bold", "italic", "strike", "code", "link"];
9
+ /**
10
+ * Whether the selection-based `FloatingToolbar` should be visible. A PURE
11
+ * decision (no DOM / coords) so it's unit-testable against the real schema:
12
+ *
13
+ * - never inside a callout/alert block — it owns its own chrome (#155);
14
+ * - a caret (empty selection) shows ONLY when it sits inside a formatting
15
+ * mark (link / bold / …) so the mark can be edited without selecting (#156);
16
+ * - a non-empty range shows whenever it actually spans inline content (the
17
+ * `childCount === 0` guard skips degenerate full-block selections).
18
+ */
19
+ export declare function shouldShowFloatingToolbar(state: EditorState): boolean;
20
+ //# sourceMappingURL=floatingToolbarVisibility.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { isSelectionInAlert } from '../extensions/contentBlocks.js';
2
+ /**
3
+ * Inline marks the `FloatingToolbar` can toggle — bold / italic / strike /
4
+ * code / link. A caret (empty selection) sitting inside any of these surfaces
5
+ * the toolbar so the formatting can be edited in place, without first
6
+ * selecting the text (#156).
7
+ */
8
+ export const TOOLBAR_MARKS = ['bold', 'italic', 'strike', 'code', 'link'];
9
+ const TOOLBAR_MARK_SET = new Set(TOOLBAR_MARKS);
10
+ /**
11
+ * True when the caret (an EMPTY selection) sits inside one of the toolbar's
12
+ * formatting marks. Reads `storedMarks` first (so the active mark at a typed
13
+ * boundary still counts), then the marks resolved at the cursor.
14
+ */
15
+ function caretInToolbarMark(state) {
16
+ const sel = state.selection;
17
+ if (!sel.empty)
18
+ return false;
19
+ const marks = state.storedMarks ?? sel.$from.marks();
20
+ return marks.some((m) => TOOLBAR_MARK_SET.has(m.type.name));
21
+ }
22
+ /**
23
+ * Whether the selection-based `FloatingToolbar` should be visible. A PURE
24
+ * decision (no DOM / coords) so it's unit-testable against the real schema:
25
+ *
26
+ * - never inside a callout/alert block — it owns its own chrome (#155);
27
+ * - a caret (empty selection) shows ONLY when it sits inside a formatting
28
+ * mark (link / bold / …) so the mark can be edited without selecting (#156);
29
+ * - a non-empty range shows whenever it actually spans inline content (the
30
+ * `childCount === 0` guard skips degenerate full-block selections).
31
+ */
32
+ export function shouldShowFloatingToolbar(state) {
33
+ if (isSelectionInAlert(state.selection))
34
+ return false;
35
+ if (state.selection.empty)
36
+ return caretInToolbarMark(state);
37
+ const { from, to } = state.selection;
38
+ return state.doc.slice(from, to).content.childCount > 0;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.19.0",
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": {