@pilotiq/tiptap 3.20.0 → 4.0.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.
@@ -17,17 +17,17 @@ import { contentBlockNodes } from '../extensions/contentBlocks.js';
17
17
  import { Popover } from '@base-ui/react/popover';
18
18
  import { useCollabRoom, getCollabExtensions, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react';
19
19
  import { useCollabSeed } from '@rudderjs/sync/react';
20
- import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
21
- import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
22
- import { AiSuggestionBanner } from './AiSuggestionBanner.js';
20
+ import { useSuggestionBridge } from './useSuggestionBridge.js';
21
+ import { useInlineDiff, useIsInlineDiffActive, readDiffViewMarker } from './useInlineDiff.js';
22
+ import { SuggestionBanner } from './SuggestionBanner.js';
23
23
  import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
24
24
  import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js';
25
25
  import { SlashCommandExtension, } from '../extensions/SlashCommandExtension.js';
26
26
  import { DragHandleExtension } from '../extensions/DragHandleExtension.js';
27
27
  import { MergeTagExtension } from '../extensions/MergeTagExtension.js';
28
28
  import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js';
29
- import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js';
30
- import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js';
29
+ import { SuggestionChipExtension } from '../extensions/SuggestionChipExtension.js';
30
+ import { InlineDiffExtension } from '../extensions/InlineDiffExtension.js';
31
31
  import { MentionExtension, } from '../extensions/MentionExtension.js';
32
32
  import { SlashMenu } from './SlashMenu.js';
33
33
  import { MentionMenu } from './MentionMenu.js';
@@ -278,10 +278,10 @@ function ClientEditor(props) {
278
278
  })] : [MentionExtension]),
279
279
  DragHandleExtension,
280
280
  // AI suggestions — chip widget for surgical (range-anchored) edits.
281
- AiSuggestionExtension,
281
+ SuggestionChipExtension,
282
282
  // AI inline diff — Tiptap-Pro-style visualization for whole-field
283
- // suggestions via prosemirror-changeset. See AiInlineDiffExtension.
284
- AiInlineDiffExtension,
283
+ // suggestions via prosemirror-changeset. See InlineDiffExtension.
284
+ InlineDiffExtension,
285
285
  // Realtime-collab extensions (Yjs `Collaboration` + cursor) — empty
286
286
  // when no `<RecordCollabRoom>` is mounted up-tree, or when no plugin
287
287
  // registered a factory via `registerCollabExtensions`.
@@ -426,13 +426,13 @@ function ClientEditor(props) {
426
426
  }
427
427
  });
428
428
  // Cross-package suggestion bridge — sync the host's
429
- // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
429
+ // `<PendingSuggestionsContext>` queue with the editor's `InlineSuggestion`
430
430
  // extension. No-op when no provider is mounted (default no-op context).
431
431
  //
432
432
  // Whole-field handling: NO chip widget here. The chip's `textContent`
433
433
  // renderer would surface raw HTML tags as literal text inside the
434
434
  // green pill — unparseable on multi-paragraph rewrites. Instead,
435
- // `<AiSuggestionBanner>` mounts below the editor (see render below).
435
+ // `<SuggestionBanner>` mounts below the editor (see render below).
436
436
  // Producer-supplied range suggestions still ride the inline chip —
437
437
  // those have a precise anchor worth visualizing in context.
438
438
  const applyWholeField = (value) => {
@@ -440,7 +440,7 @@ function ClientEditor(props) {
440
440
  return;
441
441
  editor.commands.setContent(value);
442
442
  };
443
- useAiSuggestionBridge(editor ?? null, name, {
443
+ useSuggestionBridge(editor ?? null, name, {
444
444
  onApplyWholeField: applyWholeField,
445
445
  });
446
446
  // Inline diff for whole-field suggestions. Pipeline mirrors MarkdownEditor:
@@ -448,7 +448,7 @@ function ClientEditor(props) {
448
448
  // on a RichTextField are typically HTML (or marked-up JSON that the
449
449
  // schema's DOMParser also handles via its serialized round-trip). For
450
450
  // JSON suggestions, the schema may reject — falls back to banner-only.
451
- useAiInlineDiff(editor ?? null, name, {
451
+ useInlineDiff(editor ?? null, name, {
452
452
  parseSuggestion: (ed, value) => {
453
453
  try {
454
454
  const container = document.createElement('div');
@@ -459,16 +459,16 @@ function ClientEditor(props) {
459
459
  return null;
460
460
  }
461
461
  },
462
- resolveDisplayMode: () => readAiDiffViewMarker(name),
462
+ resolveDisplayMode: () => readDiffViewMarker(name),
463
463
  });
464
- const isDiffActive = useIsAiInlineDiffActive(editor ?? null);
464
+ const isDiffActive = useIsInlineDiffActive(editor ?? null);
465
465
  // Re-render the toolbar when the selection / marks change so active-state
466
466
  // booleans stay fresh.
467
467
  const tick = useEditorTick(editor);
468
- return (_jsxs("div", { className: "relative flex flex-col", children: [_jsx("input", { type: "hidden", name: name, value: serialized }), editor && toolbarGroups && toolbarGroups.length > 0 && (_jsx(Toolbar, { editor: editor, groups: toolbarGroups, tick: tick, textColors: textColors, customTextColors: customTextColors, highlightColors: highlightColors, onAttachOpenChange: setAttachOpen })), editor && (_jsx(AttachFilesDialog, { open: attachOpen, onOpenChange: setAttachOpen, editor: editor, fieldName: name, ...(uploadUrl !== undefined ? { uploadUrl } : {}), ...(acceptedFileTypes !== undefined ? { acceptedFileTypes } : {}), ...(maxAttachmentSize !== undefined ? { maxFileSize: maxAttachmentSize } : {}), ...(attachmentDir !== undefined ? { directory: attachmentDir } : {}), ...(attachmentVis !== undefined ? { visibility: attachmentVis } : {}) })), _jsx(EditorContent, { editor: editor }), _jsx(AiSuggestionBanner, { fieldName: name, onApplyWholeField: applyWholeField, ...(isDiffActive && editor
468
+ return (_jsxs("div", { className: "relative flex flex-col", children: [_jsx("input", { type: "hidden", name: name, value: serialized }), editor && toolbarGroups && toolbarGroups.length > 0 && (_jsx(Toolbar, { editor: editor, groups: toolbarGroups, tick: tick, textColors: textColors, customTextColors: customTextColors, highlightColors: highlightColors, onAttachOpenChange: setAttachOpen })), editor && (_jsx(AttachFilesDialog, { open: attachOpen, onOpenChange: setAttachOpen, editor: editor, fieldName: name, ...(uploadUrl !== undefined ? { uploadUrl } : {}), ...(acceptedFileTypes !== undefined ? { acceptedFileTypes } : {}), ...(maxAttachmentSize !== undefined ? { maxFileSize: maxAttachmentSize } : {}), ...(attachmentDir !== undefined ? { directory: attachmentDir } : {}), ...(attachmentVis !== undefined ? { visibility: attachmentVis } : {}) })), _jsx(EditorContent, { editor: editor }), _jsx(SuggestionBanner, { fieldName: name, onApplyWholeField: applyWholeField, ...(isDiffActive && editor
469
469
  ? {
470
- onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
471
- onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
470
+ onAcceptViaEditor: () => editor.commands.acceptInlineDiff(),
471
+ onRejectViaEditor: () => editor.commands.rejectInlineDiff(),
472
472
  }
473
473
  : {}) }), editor && floatingEnabled && _jsx(FloatingToolbar, { editor: editor }), editor && _jsx(TableFloatingToolbar, { editor: editor }), _jsx(SlashPopover, { state: slashState, keyHandlerRef: slashKeyRef }), _jsx(MentionPopover, { state: mentionState, keyHandlerRef: mentionKeyRef })] }));
474
474
  }
@@ -1,37 +1,37 @@
1
1
  /**
2
2
  * Bridge between the host's `<PendingSuggestionsContext>` queue and the
3
- * editor's `AiInlineDiffExtension`. When a whole-field suggestion arrives
3
+ * editor's `InlineDiffExtension`. When a whole-field suggestion arrives
4
4
  * for the field, the hook:
5
5
  *
6
6
  * 1. Parses the suggested value into a ProseMirror `Slice` via the
7
7
  * renderer-supplied parser. Each Tiptap surface owns its own
8
8
  * content shape — markdown source for `MarkdownEditor`, HTML / JSON
9
9
  * for `TiptapEditor`, plain text for `CollabTextRenderer`.
10
- * 2. Calls `editor.commands.startAiInlineDiff(id, slice)` — the
10
+ * 2. Calls `editor.commands.startInlineDiff(id, slice)` — the
11
11
  * extension snapshots the current doc as the baseline, replaces
12
12
  * the doc content with the proposed slice, and starts a
13
13
  * `prosemirror-changeset` tracking the diff.
14
14
  * 3. Registers an applier on the cross-tree pending-suggestion
15
- * registry so the host's `<AiSuggestionBanner>` Accept button (and
15
+ * registry so the host's `<SuggestionBanner>` Accept button (and
16
16
  * any other surface calling `pendingSuggestions.approve(id)`) runs
17
- * `acceptAiInlineDiff()` instead of the legacy `onApplyWholeField`
17
+ * `acceptInlineDiff()` instead of the legacy `onApplyWholeField`
18
18
  * callback. The current doc IS the accepted state — no extra
19
19
  * content swap needed.
20
20
  *
21
21
  * Reject handling: not registered on the applier (the registry only
22
22
  * tracks Approve). Renderers wire Reject through the banner's
23
- * `onRejectWithEditor` prop, which calls `rejectAiInlineDiff()` to revert
23
+ * `onRejectWithEditor` prop, which calls `rejectInlineDiff()` to revert
24
24
  * the doc to the baseline before dismissing the suggestion.
25
25
  *
26
26
  * Defensive: only one inline diff active at a time per editor. If a new
27
27
  * synthesized suggestion arrives while one is still pending review, the
28
28
  * hook drops it (the producer should have waited). This matches
29
- * `AiSuggestionExtension`'s chip path which also allows only one
29
+ * `SuggestionChipExtension`'s chip path which also allows only one
30
30
  * suggestion at a time per id.
31
31
  */
32
32
  import type { Editor } from '@tiptap/core';
33
33
  import type { Slice } from '@tiptap/pm/model';
34
- export interface UseAiInlineDiffOptions {
34
+ export interface UseInlineDiffOptions {
35
35
  /**
36
36
  * Parse the suggested string value into a ProseMirror Slice that's
37
37
  * compatible with this editor's schema. Returns `null` to skip (e.g.
@@ -49,7 +49,7 @@ export interface UseAiInlineDiffOptions {
49
49
  * Resolve the diff rendering mode at diff-start time. Return `'lines'`
50
50
  * for the GitHub-style stacked rows, anything else / omitted keeps the
51
51
  * default `'inline'` word-flow. Called lazily per diff so DOM-marker
52
- * readers (`readAiDiffViewMarker`) see the mounted field wrapper.
52
+ * readers (`readDiffViewMarker`) see the mounted field wrapper.
53
53
  */
54
54
  resolveDisplayMode?: (editor: Editor) => 'inline' | 'lines';
55
55
  }
@@ -61,13 +61,13 @@ export interface UseAiInlineDiffOptions {
61
61
  * Defaults to `'inline'` when no marker is present — including in
62
62
  * open-core installs where the augmentation never runs.
63
63
  */
64
- export declare function readAiDiffViewMarker(fieldName: string): 'inline' | 'lines';
64
+ export declare function readDiffViewMarker(fieldName: string): 'inline' | 'lines';
65
65
  /**
66
66
  * Returns whether a diff is currently active in the editor. Hosts use
67
67
  * this to gate the banner's UI between the legacy `onApplyWholeField`
68
68
  * mode and the diff-aware mode (Reject routes through
69
- * `rejectAiInlineDiff` to revert the doc).
69
+ * `rejectInlineDiff` to revert the doc).
70
70
  */
71
- export declare function useIsAiInlineDiffActive(editor: Editor | null): boolean;
72
- export declare function useAiInlineDiff(editor: Editor | null, fieldName: string, options: UseAiInlineDiffOptions): void;
73
- //# sourceMappingURL=useAiInlineDiff.d.ts.map
71
+ export declare function useIsInlineDiffActive(editor: Editor | null): boolean;
72
+ export declare function useInlineDiff(editor: Editor | null, fieldName: string, options: UseInlineDiffOptions): void;
73
+ //# sourceMappingURL=useInlineDiff.d.ts.map
@@ -1,39 +1,39 @@
1
1
  /**
2
2
  * Bridge between the host's `<PendingSuggestionsContext>` queue and the
3
- * editor's `AiInlineDiffExtension`. When a whole-field suggestion arrives
3
+ * editor's `InlineDiffExtension`. When a whole-field suggestion arrives
4
4
  * for the field, the hook:
5
5
  *
6
6
  * 1. Parses the suggested value into a ProseMirror `Slice` via the
7
7
  * renderer-supplied parser. Each Tiptap surface owns its own
8
8
  * content shape — markdown source for `MarkdownEditor`, HTML / JSON
9
9
  * for `TiptapEditor`, plain text for `CollabTextRenderer`.
10
- * 2. Calls `editor.commands.startAiInlineDiff(id, slice)` — the
10
+ * 2. Calls `editor.commands.startInlineDiff(id, slice)` — the
11
11
  * extension snapshots the current doc as the baseline, replaces
12
12
  * the doc content with the proposed slice, and starts a
13
13
  * `prosemirror-changeset` tracking the diff.
14
14
  * 3. Registers an applier on the cross-tree pending-suggestion
15
- * registry so the host's `<AiSuggestionBanner>` Accept button (and
15
+ * registry so the host's `<SuggestionBanner>` Accept button (and
16
16
  * any other surface calling `pendingSuggestions.approve(id)`) runs
17
- * `acceptAiInlineDiff()` instead of the legacy `onApplyWholeField`
17
+ * `acceptInlineDiff()` instead of the legacy `onApplyWholeField`
18
18
  * callback. The current doc IS the accepted state — no extra
19
19
  * content swap needed.
20
20
  *
21
21
  * Reject handling: not registered on the applier (the registry only
22
22
  * tracks Approve). Renderers wire Reject through the banner's
23
- * `onRejectWithEditor` prop, which calls `rejectAiInlineDiff()` to revert
23
+ * `onRejectWithEditor` prop, which calls `rejectInlineDiff()` to revert
24
24
  * the doc to the baseline before dismissing the suggestion.
25
25
  *
26
26
  * Defensive: only one inline diff active at a time per editor. If a new
27
27
  * synthesized suggestion arrives while one is still pending review, the
28
28
  * hook drops it (the producer should have waited). This matches
29
- * `AiSuggestionExtension`'s chip path which also allows only one
29
+ * `SuggestionChipExtension`'s chip path which also allows only one
30
30
  * suggestion at a time per id.
31
31
  */
32
32
  import { useEffect, useRef } from 'react';
33
33
  import { useEditorState } from '@tiptap/react';
34
34
  import { registerPendingSuggestionApplier, usePendingSuggestionsForField, useFormId, } from '@pilotiq/pilotiq/react';
35
- import { aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js';
36
- import { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planUpdateBlockMark, planWrapBlocks, } from '../surgicalOps.js';
35
+ import { inlineDiffPluginKey } from '../extensions/InlineDiffExtension.js';
36
+ import { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planUpdateBlockMark, planWrapBlocks, planReplaceText, } from '../surgicalOps.js';
37
37
  /**
38
38
  * Read the field's `.aiDiffView(...)` choice off the DOM — the
39
39
  * `@pilotiq-pro/ai` field augmentation stamps `data-ai-diff-view` onto
@@ -42,7 +42,7 @@ import { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planUpdateBlo
42
42
  * Defaults to `'inline'` when no marker is present — including in
43
43
  * open-core installs where the augmentation never runs.
44
44
  */
45
- export function readAiDiffViewMarker(fieldName) {
45
+ export function readDiffViewMarker(fieldName) {
46
46
  if (typeof document === 'undefined')
47
47
  return 'inline';
48
48
  const els = document.getElementsByName(fieldName);
@@ -56,16 +56,16 @@ export function readAiDiffViewMarker(fieldName) {
56
56
  * Returns whether a diff is currently active in the editor. Hosts use
57
57
  * this to gate the banner's UI between the legacy `onApplyWholeField`
58
58
  * mode and the diff-aware mode (Reject routes through
59
- * `rejectAiInlineDiff` to revert the doc).
59
+ * `rejectInlineDiff` to revert the doc).
60
60
  */
61
- export function useIsAiInlineDiffActive(editor) {
61
+ export function useIsInlineDiffActive(editor) {
62
62
  const active = useEditorState({
63
63
  editor,
64
- selector: ({ editor: ed }) => !!ed && aiInlineDiffPluginKey.getState(ed.state) !== null,
64
+ selector: ({ editor: ed }) => !!ed && inlineDiffPluginKey.getState(ed.state) !== null,
65
65
  });
66
66
  return active ?? false;
67
67
  }
68
- export function useAiInlineDiff(editor, fieldName, options) {
68
+ export function useInlineDiff(editor, fieldName, options) {
69
69
  const { list } = usePendingSuggestionsForField(fieldName);
70
70
  // Scope the applier registration by the surrounding form's id so
71
71
  // multi-form pages route suggestions to the editor instance inside the
@@ -103,7 +103,7 @@ export function useAiInlineDiff(editor, fieldName, options) {
103
103
  // surgical-block suggestion. `meta.surgical` (if present) routes to a
104
104
  // precise PM transaction; otherwise we treat the suggested value as a
105
105
  // whole-field replacement. `meta.editorRange` (chip path) is filtered
106
- // out — handled by AiSuggestionExtension elsewhere.
106
+ // out — handled by SuggestionChipExtension elsewhere.
107
107
  useEffect(() => {
108
108
  if (!editor)
109
109
  return;
@@ -111,7 +111,7 @@ export function useAiInlineDiff(editor, fieldName, options) {
111
111
  for (const s of diffable) {
112
112
  if (startedRef.current.has(s.id))
113
113
  continue;
114
- const diffActive = aiInlineDiffPluginKey.getState(editor.state) !== null;
114
+ const diffActive = inlineDiffPluginKey.getState(editor.state) !== null;
115
115
  const surgical = readSurgicalMeta(s);
116
116
  // Cross-tool-call surgical stacking. When a diff is already active
117
117
  // and a fresh surgical suggestion arrives (typically the model
@@ -151,7 +151,7 @@ export function useAiInlineDiff(editor, fieldName, options) {
151
151
  const modifier = planSurgicalModifier(editor, surgical);
152
152
  if (!modifier)
153
153
  continue;
154
- editor.commands.applySurgicalAiInlineDiff(s.id, modifier, displayMode);
154
+ editor.commands.applySurgicalInlineDiff(s.id, modifier, displayMode);
155
155
  startedRef.current.add(s.id);
156
156
  continue;
157
157
  }
@@ -160,7 +160,7 @@ export function useAiInlineDiff(editor, fieldName, options) {
160
160
  const slice = parseRef.current(editor, s.suggestedValue);
161
161
  if (!slice)
162
162
  continue;
163
- editor.commands.startAiInlineDiff(s.id, slice, displayMode);
163
+ editor.commands.startInlineDiff(s.id, slice, displayMode);
164
164
  startedRef.current.add(s.id);
165
165
  }
166
166
  // Cleanup: when a suggestion leaves the context AND we previously
@@ -190,7 +190,7 @@ export function useAiInlineDiff(editor, fieldName, options) {
190
190
  return;
191
191
  const applier = (suggestion) => {
192
192
  if (startedRef.current.has(suggestion.id)) {
193
- editor.commands.acceptAiInlineDiff();
193
+ editor.commands.acceptInlineDiff();
194
194
  return;
195
195
  }
196
196
  const surgical = readSurgicalMeta(suggestion);
@@ -218,6 +218,16 @@ function hasEditorRange(s) {
218
218
  }
219
219
  function parseSurgicalOp(obj) {
220
220
  const op = obj['op'];
221
+ // `replace_text` carries no `blockIndex` — parse it before the index guard.
222
+ if (op === 'replace_text') {
223
+ const search = obj['search'];
224
+ const replace = obj['replace'];
225
+ if (typeof search !== 'string' || search.length === 0)
226
+ return null;
227
+ if (typeof replace !== 'string')
228
+ return null;
229
+ return { op, search, replace };
230
+ }
221
231
  const blockIndex = obj['blockIndex'];
222
232
  if (typeof blockIndex !== 'number')
223
233
  return null;
@@ -300,6 +310,7 @@ function planOp(editor, op) {
300
310
  case 'delete_block': return planDeleteBlock(editor, op.blockIndex);
301
311
  case 'update_block_mark': return planUpdateBlockMark(editor, op.blockIndex, op.mark, op.range, op.apply, op.attrs);
302
312
  case 'wrap_blocks': return planWrapBlocks(editor, op.blockIndex, op.toIndex, op.wrapperType, op.attrs);
313
+ case 'replace_text': return planReplaceText(editor, op.search, op.replace);
303
314
  }
304
315
  }
305
316
  /**
@@ -315,9 +326,15 @@ function planOp(editor, op) {
315
326
  * still runs whatever did plan, so a single bad op doesn't kill the
316
327
  * whole batch.
317
328
  */
329
+ /** Sort key for batch ordering. Index-free ops (`replace_text`) sort last so the
330
+ * index-based ops apply first at their original positions; the text swap then
331
+ * resolves against the live tr doc. */
332
+ function opBlockIndex(op) {
333
+ return 'blockIndex' in op ? op.blockIndex : Number.NEGATIVE_INFINITY;
334
+ }
318
335
  function planSurgicalModifier(editor, surgical) {
319
336
  if ('ops' in surgical) {
320
- const sorted = [...surgical.ops].sort((a, b) => b.blockIndex - a.blockIndex);
337
+ const sorted = [...surgical.ops].sort((a, b) => opBlockIndex(b) - opBlockIndex(a));
321
338
  const modifiers = [];
322
339
  for (const op of sorted) {
323
340
  const mod = planOp(editor, op);
@@ -2,12 +2,12 @@ import type { Editor } from '@tiptap/core';
2
2
  import { type PendingSuggestion } from '@pilotiq/pilotiq/react';
3
3
  /**
4
4
  * Two-way sync between the cross-package `<PendingSuggestionsContext>`
5
- * queue and this editor's `AiSuggestionExtension` state.
5
+ * queue and this editor's `SuggestionChipExtension` state.
6
6
  *
7
7
  * - **Context → editor**: every entry whose `meta.editorRange = { from, to }`
8
8
  * is present and whose `suggestedValue` is a string gets pushed into the
9
- * editor as an inline-diff hunk via `addAiSuggestion`. Entries leaving the
10
- * queue are removed from the editor via `rejectAiSuggestion` (no doc edit).
9
+ * editor as an inline-diff hunk via `addSuggestion`. Entries leaving the
10
+ * queue are removed from the editor via `rejectSuggestion` (no doc edit).
11
11
  *
12
12
  * - **Editor → context**: when a chip's Approve / Reject button removes a
13
13
  * hunk from the editor's plugin state, the matching id is dismissed from
@@ -19,7 +19,7 @@ import { type PendingSuggestion } from '@pilotiq/pilotiq/react';
19
19
  * the editor (`pushed`). The Context→editor pass never re-pushes an id that's
20
20
  * already there, and the Editor→context pass only dismisses ids that this
21
21
  * hook had previously pushed (so an id added directly by host code via
22
- * `editor.commands.addAiSuggestion(...)` doesn't get reflected back through
22
+ * `editor.commands.addSuggestion(...)` doesn't get reflected back through
23
23
  * a context that never knew about it).
24
24
  *
25
25
  * **Whole-field fallback** (chat-driven suggestions). Producers like
@@ -35,7 +35,7 @@ import { type PendingSuggestion } from '@pilotiq/pilotiq/react';
35
35
  * responsible for the Approve UI — FieldShell hides its legacy overlay
36
36
  * whenever a Tiptap renderer is mounted (richtext / markdown / collab text).
37
37
  */
38
- export interface UseAiSuggestionBridgeOptions {
38
+ export interface UseSuggestionBridgeOptions {
39
39
  /**
40
40
  * Apply a whole-field suggestion that lacks `meta.editorRange`. Each
41
41
  * Tiptap renderer passes its own implementation (different content
@@ -58,6 +58,6 @@ export interface UseAiSuggestionBridgeOptions {
58
58
  to: number;
59
59
  } | undefined;
60
60
  }
61
- export declare function useAiSuggestionBridge(editor: Editor | null, fieldName: string, options?: UseAiSuggestionBridgeOptions): void;
61
+ export declare function useSuggestionBridge(editor: Editor | null, fieldName: string, options?: UseSuggestionBridgeOptions): void;
62
62
  export type { PendingSuggestion };
63
- //# sourceMappingURL=useAiSuggestionBridge.d.ts.map
63
+ //# sourceMappingURL=useSuggestionBridge.d.ts.map
@@ -1,10 +1,10 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { registerPendingSuggestionApplier, usePendingSuggestionsForField, useFormId, } from '@pilotiq/pilotiq/react';
3
- import { aiSuggestionPluginKey } from '../extensions/AiSuggestionExtension.js';
4
- export function useAiSuggestionBridge(editor, fieldName, options = {}) {
3
+ import { suggestionChipPluginKey } from '../extensions/SuggestionChipExtension.js';
4
+ export function useSuggestionBridge(editor, fieldName, options = {}) {
5
5
  const { list, dismiss } = usePendingSuggestionsForField(fieldName);
6
6
  // Scope the applier under the surrounding form's id — same reasoning
7
- // as `useAiInlineDiff`: two editors with the same field name across
7
+ // as `useInlineDiff`: two editors with the same field name across
8
8
  // different forms (main edit form vs. a Replicate modal, say) would
9
9
  // otherwise race on `registerPendingSuggestionApplier(undefined, …)`
10
10
  // and the last-mounted editor would steal every approval.
@@ -59,7 +59,7 @@ export function useAiSuggestionBridge(editor, fieldName, options = {}) {
59
59
  isSynthesized = true;
60
60
  }
61
61
  const replacement = typeof s.suggestedValue === 'string' ? s.suggestedValue : '';
62
- editor.commands.addAiSuggestion({
62
+ editor.commands.addSuggestion({
63
63
  id: s.id,
64
64
  from: range.from,
65
65
  to: range.to,
@@ -74,8 +74,8 @@ export function useAiSuggestionBridge(editor, fieldName, options = {}) {
74
74
  if (contextIds.has(id))
75
75
  continue;
76
76
  // Context dropped the suggestion — remove from editor without
77
- // mutating the doc (rejectAiSuggestion drops state only).
78
- editor.commands.rejectAiSuggestion(id);
77
+ // mutating the doc (rejectSuggestion drops state only).
78
+ editor.commands.rejectSuggestion(id);
79
79
  pushedRef.current.delete(id);
80
80
  synthesizedRef.current.delete(id);
81
81
  }
@@ -85,7 +85,7 @@ export function useAiSuggestionBridge(editor, fieldName, options = {}) {
85
85
  if (!editor)
86
86
  return;
87
87
  const handler = () => {
88
- const ps = aiSuggestionPluginKey.getState(editor.state);
88
+ const ps = suggestionChipPluginKey.getState(editor.state);
89
89
  if (!ps)
90
90
  return;
91
91
  const editorIds = new Set(ps.suggestions.map((s) => s.id));
@@ -114,20 +114,20 @@ export function useAiSuggestionBridge(editor, fieldName, options = {}) {
114
114
  const hasSynthesized = synthesizedRef.current.has(suggestion.id);
115
115
  const hasPushed = pushedRef.current.has(suggestion.id);
116
116
  // Synthesized whole-field range — the chip rendered for visualization,
117
- // but routing Approve through the editor's `approveAiSuggestion` would
117
+ // but routing Approve through the editor's `approveSuggestion` would
118
118
  // do a plain-text replace and clobber HTML / markdown formatting.
119
119
  // Delegate to the renderer-supplied applier (content-shape-aware)
120
120
  // and clear the chip state without a doc edit.
121
121
  if (hasSynthesized && apply && typeof suggestion.suggestedValue === 'string') {
122
122
  apply(suggestion.suggestedValue);
123
- editor.commands.rejectAiSuggestion(suggestion.id);
123
+ editor.commands.rejectSuggestion(suggestion.id);
124
124
  return;
125
125
  }
126
126
  // Producer-supplied editor range — surgical edit. Forward Approve to
127
127
  // the editor command; the transaction listener above mirrors the
128
128
  // dismiss back into context.
129
129
  if (hasPushed) {
130
- editor.chain().focus().approveAiSuggestion(suggestion.id).run();
130
+ editor.chain().focus().approveSuggestion(suggestion.id).run();
131
131
  return;
132
132
  }
133
133
  // Whole-field path WITHOUT visualization — producer skipped the range
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Each planner takes the editor + a logical block index + a payload and
5
5
  * returns a `TransactionModifier` — a function the caller (typically
6
- * `useAiInlineDiff`) feeds into
7
- * `editor.commands.applySurgicalAiInlineDiff(id, modifier)`. The diff
6
+ * `useInlineDiff`) feeds into
7
+ * `editor.commands.applySurgicalInlineDiff(id, modifier)`. The diff
8
8
  * extension wraps the modifier in a snapshot-then-apply step so the
9
9
  * inline-diff overlay renders against the precise changed range.
10
10
  *
@@ -87,4 +87,17 @@ export declare function planUpdateBlockMark(editor: Editor, blockIndex: number,
87
87
  * `[2] bulletList: 3 items`
88
88
  */
89
89
  export declare function summarizeBlockStructure(doc: ProseMirrorNode, maxChars?: number): string;
90
+ /**
91
+ * In-block text find→replace. Swaps the FIRST occurrence of `search` with
92
+ * `replace`, preserving the surrounding node structure — so it can fix a word,
93
+ * number, or typo INSIDE a custom block (alert / prosCons / faq / keyTakeaways)
94
+ * or a table cell without rebuilding (and flattening) the block, which is what
95
+ * `replace_block` would force. Index-free: the match position is resolved at
96
+ * apply time against the live transaction doc, so it composes safely after the
97
+ * index-based block ops in a batch.
98
+ *
99
+ * Returns `null` when `search` isn't present (the caller surfaces "no change")
100
+ * so a stale/guessed search string can never silently corrupt the doc.
101
+ */
102
+ export declare function planReplaceText(editor: Editor, search: string, replace: string): TransactionModifier | null;
90
103
  //# sourceMappingURL=surgicalOps.d.ts.map
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Each planner takes the editor + a logical block index + a payload and
5
5
  * returns a `TransactionModifier` — a function the caller (typically
6
- * `useAiInlineDiff`) feeds into
7
- * `editor.commands.applySurgicalAiInlineDiff(id, modifier)`. The diff
6
+ * `useInlineDiff`) feeds into
7
+ * `editor.commands.applySurgicalInlineDiff(id, modifier)`. The diff
8
8
  * extension wraps the modifier in a snapshot-then-apply step so the
9
9
  * inline-diff overlay renders against the precise changed range.
10
10
  *
@@ -38,7 +38,7 @@ function blockStartPos(doc, blockIndex) {
38
38
  * `TiptapEditor` path).
39
39
  *
40
40
  * Mirrors the same auto-detect strategy `MarkdownEditor.tsx` uses for
41
- * its `parseSuggestion` whole-field callback (see `useAiInlineDiff`),
41
+ * its `parseSuggestion` whole-field callback (see `useInlineDiff`),
42
42
  * so surgical ops on markdown fields stay consistent with the
43
43
  * existing whole-field replacement path.
44
44
  *
@@ -217,6 +217,53 @@ export function summarizeBlockStructure(doc, maxChars = 80) {
217
217
  }
218
218
  return lines.join('\n');
219
219
  }
220
+ /**
221
+ * In-block text find→replace. Swaps the FIRST occurrence of `search` with
222
+ * `replace`, preserving the surrounding node structure — so it can fix a word,
223
+ * number, or typo INSIDE a custom block (alert / prosCons / faq / keyTakeaways)
224
+ * or a table cell without rebuilding (and flattening) the block, which is what
225
+ * `replace_block` would force. Index-free: the match position is resolved at
226
+ * apply time against the live transaction doc, so it composes safely after the
227
+ * index-based block ops in a batch.
228
+ *
229
+ * Returns `null` when `search` isn't present (the caller surfaces "no change")
230
+ * so a stale/guessed search string can never silently corrupt the doc.
231
+ */
232
+ export function planReplaceText(editor, search, replace) {
233
+ if (typeof search !== 'string' || search.length === 0)
234
+ return null;
235
+ if (typeof replace !== 'string')
236
+ return null;
237
+ let present = false;
238
+ editor.state.doc.descendants((node) => {
239
+ if (present)
240
+ return false;
241
+ if (node.isText && node.text && node.text.includes(search)) {
242
+ present = true;
243
+ return false;
244
+ }
245
+ return true;
246
+ });
247
+ if (!present)
248
+ return null;
249
+ return (tr) => {
250
+ let foundFrom = -1;
251
+ tr.doc.descendants((node, pos) => {
252
+ if (foundFrom >= 0)
253
+ return false;
254
+ if (node.isText && node.text) {
255
+ const i = node.text.indexOf(search);
256
+ if (i !== -1) {
257
+ foundFrom = pos + i;
258
+ return false;
259
+ }
260
+ }
261
+ return true;
262
+ });
263
+ if (foundFrom >= 0)
264
+ tr.insertText(replace, foundFrom, foundFrom + search.length);
265
+ };
266
+ }
220
267
  function describeStructuralNode(node) {
221
268
  const kids = node.childCount;
222
269
  if (kids === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.20.0",
3
+ "version": "4.0.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": {