@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.
- package/CHANGELOG.md +37 -0
- package/dist/extensions/{AiInlineDiffExtension.d.ts → InlineDiffExtension.d.ts} +23 -23
- package/dist/extensions/{AiInlineDiffExtension.js → InlineDiffExtension.js} +33 -33
- package/dist/extensions/{AiSuggestionExtension.d.ts → SuggestionChipExtension.d.ts} +29 -29
- package/dist/extensions/{AiSuggestionExtension.js → SuggestionChipExtension.js} +52 -52
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/react/CollabTextRenderer.js +16 -16
- package/dist/react/MarkdownEditor.js +17 -17
- package/dist/react/{AiSuggestionBanner.d.ts → SuggestionBanner.d.ts} +6 -6
- package/dist/react/{AiSuggestionBanner.js → SuggestionBanner.js} +5 -5
- package/dist/react/TiptapEditor.js +17 -17
- package/dist/react/{useAiInlineDiff.d.ts → useInlineDiff.d.ts} +13 -13
- package/dist/react/{useAiInlineDiff.js → useInlineDiff.js} +36 -19
- package/dist/react/{useAiSuggestionBridge.d.ts → useSuggestionBridge.d.ts} +7 -7
- package/dist/react/{useAiSuggestionBridge.js → useSuggestionBridge.js} +10 -10
- package/dist/surgicalOps.d.ts +15 -2
- package/dist/surgicalOps.js +50 -3
- package/package.json +1 -1
|
@@ -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 {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
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 {
|
|
30
|
-
import {
|
|
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
|
-
|
|
281
|
+
SuggestionChipExtension,
|
|
282
282
|
// AI inline diff — Tiptap-Pro-style visualization for whole-field
|
|
283
|
-
// suggestions via prosemirror-changeset. See
|
|
284
|
-
|
|
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 `
|
|
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
|
-
// `<
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
462
|
+
resolveDisplayMode: () => readDiffViewMarker(name),
|
|
463
463
|
});
|
|
464
|
-
const isDiffActive =
|
|
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(
|
|
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.
|
|
471
|
-
onRejectViaEditor: () => editor.commands.
|
|
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 `
|
|
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.
|
|
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 `<
|
|
15
|
+
* registry so the host's `<SuggestionBanner>` Accept button (and
|
|
16
16
|
* any other surface calling `pendingSuggestions.approve(id)`) runs
|
|
17
|
-
* `
|
|
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 `
|
|
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
|
-
* `
|
|
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
|
|
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 (`
|
|
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
|
|
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
|
-
* `
|
|
69
|
+
* `rejectInlineDiff` to revert the doc).
|
|
70
70
|
*/
|
|
71
|
-
export declare function
|
|
72
|
-
export declare function
|
|
73
|
-
//# sourceMappingURL=
|
|
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 `
|
|
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.
|
|
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 `<
|
|
15
|
+
* registry so the host's `<SuggestionBanner>` Accept button (and
|
|
16
16
|
* any other surface calling `pendingSuggestions.approve(id)`) runs
|
|
17
|
-
* `
|
|
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 `
|
|
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
|
-
* `
|
|
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 {
|
|
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
|
|
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
|
-
* `
|
|
59
|
+
* `rejectInlineDiff` to revert the doc).
|
|
60
60
|
*/
|
|
61
|
-
export function
|
|
61
|
+
export function useIsInlineDiffActive(editor) {
|
|
62
62
|
const active = useEditorState({
|
|
63
63
|
editor,
|
|
64
|
-
selector: ({ editor: ed }) => !!ed &&
|
|
64
|
+
selector: ({ editor: ed }) => !!ed && inlineDiffPluginKey.getState(ed.state) !== null,
|
|
65
65
|
});
|
|
66
66
|
return active ?? false;
|
|
67
67
|
}
|
|
68
|
-
export function
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 `
|
|
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 `
|
|
10
|
-
* queue are removed from the editor via `
|
|
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.
|
|
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
|
|
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
|
|
61
|
+
export declare function useSuggestionBridge(editor: Editor | null, fieldName: string, options?: UseSuggestionBridgeOptions): void;
|
|
62
62
|
export type { PendingSuggestion };
|
|
63
|
-
//# sourceMappingURL=
|
|
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 {
|
|
4
|
-
export function
|
|
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 `
|
|
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.
|
|
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 (
|
|
78
|
-
editor.commands.
|
|
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 =
|
|
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 `
|
|
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.
|
|
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().
|
|
130
|
+
editor.chain().focus().approveSuggestion(suggestion.id).run();
|
|
131
131
|
return;
|
|
132
132
|
}
|
|
133
133
|
// Whole-field path WITHOUT visualization — producer skipped the range
|
package/dist/surgicalOps.d.ts
CHANGED
|
@@ -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
|
-
* `
|
|
7
|
-
* `editor.commands.
|
|
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
|
package/dist/surgicalOps.js
CHANGED
|
@@ -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
|
-
* `
|
|
7
|
-
* `editor.commands.
|
|
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 `
|
|
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)
|