@pilotiq/tiptap 3.19.2 → 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 +56 -0
- package/dist/extensions/BlockNodeExtension.d.ts +0 -9
- package/dist/extensions/BlockNodeExtension.js +0 -20
- 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/BlockNodeView.d.ts +23 -12
- package/dist/react/BlockNodeView.js +55 -21
- 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 +24 -59
- package/dist/react/blockValues.d.ts +54 -0
- package/dist/react/blockValues.js +161 -0
- package/dist/react/floatingToolbarVisibility.d.ts +9 -2
- package/dist/react/floatingToolbarVisibility.js +12 -3
- 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
- package/dist/react/BlockSidePanel.d.ts +0 -105
- package/dist/react/BlockSidePanel.js +0 -338
|
@@ -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)
|
package/package.json
CHANGED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import type { Editor } from '@tiptap/react';
|
|
2
|
-
import type { BlockMeta } from '../Block.js';
|
|
3
|
-
/**
|
|
4
|
-
* Floating right-docked side panel for editing a custom block's schema
|
|
5
|
-
* fields. Mounted by `TiptapEditor` once the user clicks the Edit button
|
|
6
|
-
* on a `pilotiqBlock` NodeView; reads/writes flow through ProseMirror
|
|
7
|
-
* directly (no form submit, no roundtrip).
|
|
8
|
-
*
|
|
9
|
-
* Why a sibling of the NodeView and not the NodeView itself:
|
|
10
|
-
* - NodeViews mount in a separate React tree (Tiptap quirk), so they
|
|
11
|
-
* can't reach pilotiq's `FormFields` renderer or any provider on
|
|
12
|
-
* the host page (Theme, Toaster, etc.). Hosting the panel here in
|
|
13
|
-
* the host's tree gives us the full pilotiq field surface for free.
|
|
14
|
-
*
|
|
15
|
-
* Reads: each field's `defaultValue` is overridden from the block's
|
|
16
|
-
* stored `blockData`. Inputs are uncontrolled (outside `FormStateProvider`,
|
|
17
|
-
* pilotiq's renderers fall back to `defaultValue` automatically).
|
|
18
|
-
*
|
|
19
|
-
* Writes: container-level event delegation on the form element. Every
|
|
20
|
-
* change snapshots the entire form via `new FormData(formEl)` →
|
|
21
|
-
* `parseFormDataToNested` (rebuilds nested arrays/objects from
|
|
22
|
-
* dotted-path inputs like `myrep.0.title`) → `coerceBlockValues`
|
|
23
|
-
* (per-fieldType JSON parse / boolean / number coerce so nested-shape
|
|
24
|
-
* fields round-trip in their canonical wire form). The result is
|
|
25
|
-
* dispatched through `state.tr.setNodeMarkup` on the tracked position.
|
|
26
|
-
* The position is kept fresh by mapping it through every editor
|
|
27
|
-
* transaction so live edits elsewhere in the document don't desync.
|
|
28
|
-
*
|
|
29
|
-
* V2 (2026-05-04 cont'd): nested-shape fields now round-trip cleanly:
|
|
30
|
-
* Repeater (array of subschema rows), Builder (heterogeneous block
|
|
31
|
-
* rows), TagsInput / KeyValue / FileUpload (JSON-encoded hidden
|
|
32
|
-
* inputs), Markdown (plain textarea), and the standard primitives
|
|
33
|
-
* (text / textarea / select / toggle / checkbox / radio / date /
|
|
34
|
-
* datetime / number / slider / color / toggleButtons / checkboxList).
|
|
35
|
-
*/
|
|
36
|
-
export interface BlockSidePanelProps {
|
|
37
|
-
editor: Editor;
|
|
38
|
-
/** Position at open time. Tracked + remapped on every transaction. */
|
|
39
|
-
initialPos: number;
|
|
40
|
-
/** Block type at open time — guards against the user clicking Edit on
|
|
41
|
-
* one block, then someone else's edit replacing it with a different
|
|
42
|
-
* block at the same position. */
|
|
43
|
-
blockType: string;
|
|
44
|
-
blocks: BlockMeta[];
|
|
45
|
-
onClose: () => void;
|
|
46
|
-
}
|
|
47
|
-
export declare function BlockSidePanel({ editor, initialPos, blockType, blocks, onClose, }: BlockSidePanelProps): React.ReactElement | null;
|
|
48
|
-
/**
|
|
49
|
-
* Clamp + sanitize a candidate panel width against this panel's bounds
|
|
50
|
-
* (`[PANEL_WIDTH_MIN, PANEL_WIDTH_MAX]`, default `PANEL_WIDTH_DEFAULT`).
|
|
51
|
-
* Thin wrapper around the shared `clampPanelWidth` helper from
|
|
52
|
-
* `@pilotiq/pilotiq/react` — kept exported with the panel-specific
|
|
53
|
-
* defaults baked in so existing tests + downstream callers don't have
|
|
54
|
-
* to plumb the bounds themselves.
|
|
55
|
-
*/
|
|
56
|
-
export declare function clampPanelWidth(value: unknown): number;
|
|
57
|
-
/**
|
|
58
|
-
* Per-fieldType coerce of a nested values map (built by
|
|
59
|
-
* `parseFormDataToNested`) against the block's schema. Mirrors the
|
|
60
|
-
* server-side `coerceFormValues` at a small subset suitable for the
|
|
61
|
-
* side panel — we only run on top-level block fields plus the immediate
|
|
62
|
-
* children of any Repeater rows / Builder rows.data, which is all the
|
|
63
|
-
* V2 surface needs.
|
|
64
|
-
*
|
|
65
|
-
* Non-coerce passthrough for: text, textarea, select, radio, date,
|
|
66
|
-
* dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
|
|
67
|
-
* is already a plain string / array of strings.)
|
|
68
|
-
*
|
|
69
|
-
* Coerce branches:
|
|
70
|
-
* - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
|
|
71
|
-
* - `number` / `slider`: parse to Number, null on empty, raw string
|
|
72
|
-
* passthrough on NaN (so a half-typed value isn't lost).
|
|
73
|
-
* - `tagsInput`: JSON-encoded string → string[].
|
|
74
|
-
* - `checkboxList`: JSON-encoded string OR array → string[].
|
|
75
|
-
* - `keyValue`: JSON-encoded string → Record<string, unknown>.
|
|
76
|
-
* - `fileUpload`: single → URL string passthrough; multiple →
|
|
77
|
-
* JSON-encoded string → string[].
|
|
78
|
-
* - `repeater`: each row in the array gets recursive coerce against
|
|
79
|
-
* the field's `template` (the inner field schema definition).
|
|
80
|
-
* - `builder`: each row's `data` gets recursive coerce against the
|
|
81
|
-
* block matching `row.type` from `field.blocks[]`. Unknown block
|
|
82
|
-
* types pass through verbatim — the renderer shows a placeholder
|
|
83
|
-
* and the data round-trips intact across config rollbacks.
|
|
84
|
-
*
|
|
85
|
-
* Exported for unit tests. Pure — no React, no DOM, no editor.
|
|
86
|
-
*/
|
|
87
|
-
export declare function coerceBlockValues(raw: Record<string, unknown>, schema: ReadonlyArray<Record<string, unknown>>): Record<string, unknown>;
|
|
88
|
-
/**
|
|
89
|
-
* Read the resolved field value for a given input event target. Kept
|
|
90
|
-
* for back-compat — V1 used this in the per-event handler. V2 reads
|
|
91
|
-
* the entire form via `FormData` instead, but this helper still maps
|
|
92
|
-
* cleanly onto a single-input read for testing and is exported.
|
|
93
|
-
*
|
|
94
|
-
* String passthrough for the common case; explicit coercion for
|
|
95
|
-
* booleans and numerics so the round-trip into the node attrs preserves
|
|
96
|
-
* shape.
|
|
97
|
-
*/
|
|
98
|
-
export declare function readBlockFieldValue(target: {
|
|
99
|
-
type?: string;
|
|
100
|
-
value: string;
|
|
101
|
-
checked?: boolean;
|
|
102
|
-
}, fieldMeta: {
|
|
103
|
-
fieldType?: unknown;
|
|
104
|
-
}): unknown;
|
|
105
|
-
//# sourceMappingURL=BlockSidePanel.d.ts.map
|