@pilotiq/tiptap 3.11.0 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 3.11.0
4
10
 
5
11
  ### Minor 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
+ }
@@ -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
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.11.0",
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": {