@pilotiq/tiptap 3.5.0 → 3.6.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/dist/extensions/AiInlineDiffExtension.d.ts +82 -0
- package/dist/extensions/AiInlineDiffExtension.d.ts.map +1 -0
- package/dist/extensions/AiInlineDiffExtension.js +205 -0
- package/dist/extensions/AiInlineDiffExtension.js.map +1 -0
- package/dist/extensions/AiSuggestionExtension.d.ts.map +1 -1
- package/dist/extensions/AiSuggestionExtension.js +51 -0
- package/dist/extensions/AiSuggestionExtension.js.map +1 -1
- package/dist/react/AiSuggestionBanner.d.ts +72 -0
- package/dist/react/AiSuggestionBanner.d.ts.map +1 -0
- package/dist/react/AiSuggestionBanner.js +72 -0
- package/dist/react/AiSuggestionBanner.js.map +1 -0
- package/dist/react/MarkdownEditor.d.ts.map +1 -1
- package/dist/react/MarkdownEditor.js +58 -20
- package/dist/react/MarkdownEditor.js.map +1 -1
- package/dist/react/TiptapEditor.d.ts.map +1 -1
- package/dist/react/TiptapEditor.js +43 -18
- package/dist/react/TiptapEditor.js.map +1 -1
- package/dist/react/useAiInlineDiff.d.ts +57 -0
- package/dist/react/useAiInlineDiff.d.ts.map +1 -0
- package/dist/react/useAiInlineDiff.js +121 -0
- package/dist/react/useAiInlineDiff.js.map +1 -0
- package/package.json +24 -22
- package/src/extensions/AiInlineDiffExtension.ts +263 -0
- package/src/extensions/AiSuggestionExtension.ts +51 -0
- package/src/react/AiSuggestionBanner.tsx +184 -0
- package/src/react/MarkdownEditor.tsx +58 -19
- package/src/react/TiptapEditor.tsx +44 -16
- package/src/react/useAiInlineDiff.ts +146 -0
|
@@ -4,6 +4,7 @@ import type { AnyExtension } from '@tiptap/core'
|
|
|
4
4
|
import StarterKit from '@tiptap/starter-kit'
|
|
5
5
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
6
6
|
import Image from '@tiptap/extension-image'
|
|
7
|
+
import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
|
|
7
8
|
// The `tiptap-markdown` chain (incl. CJS-only `markdown-it-task-lists`) is
|
|
8
9
|
// pre-bundled into `dist/markdownExtension.js` at @pilotiq/tiptap build
|
|
9
10
|
// time; importing the wrapper instead of `tiptap-markdown` directly
|
|
@@ -17,7 +18,10 @@ import {
|
|
|
17
18
|
type MarkdownEditorProps,
|
|
18
19
|
} from '@pilotiq/pilotiq/react'
|
|
19
20
|
import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
|
|
21
|
+
import { AiInlineDiffExtension, aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js'
|
|
20
22
|
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
|
|
23
|
+
import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
|
|
24
|
+
import { AiSuggestionBanner } from './AiSuggestionBanner.js'
|
|
21
25
|
|
|
22
26
|
// Inline lucide.dev SVGs — same posture as `toolbarButtons.tsx` so this
|
|
23
27
|
// package doesn't pull `lucide-react` as a peer dep. Keep stroke / size
|
|
@@ -214,12 +218,14 @@ export function MarkdownEditor({
|
|
|
214
218
|
}),
|
|
215
219
|
Image.configure({ inline: false, allowBase64: false }),
|
|
216
220
|
Placeholder.configure({ placeholder: placeholder ?? 'Write in markdown…' }),
|
|
217
|
-
// AI suggestions —
|
|
218
|
-
// inline strikethrough + Approve/Reject chip widgets. Idle until the
|
|
219
|
-
// host calls `editor.commands.addAiSuggestion(...)` via the bridge below.
|
|
220
|
-
// Matches the `TiptapEditor` wiring so suggestion mode works uniformly
|
|
221
|
-
// across RichTextField / MarkdownField / TextField+TextareaField.
|
|
221
|
+
// AI suggestions — chip widget for surgical (range-anchored) edits.
|
|
222
222
|
AiSuggestionExtension,
|
|
223
|
+
// AI inline diff — Tiptap-Pro-style visualization for whole-field
|
|
224
|
+
// suggestions (prosemirror-changeset under the hood). Decorations
|
|
225
|
+
// show green-background inserts inline + red-strikethrough widgets
|
|
226
|
+
// for deleted text. Host's `<AiSuggestionBanner>` drives Accept /
|
|
227
|
+
// Reject via the extension's commands.
|
|
228
|
+
AiInlineDiffExtension,
|
|
223
229
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
224
230
|
...(collabExtensions as any[]),
|
|
225
231
|
],
|
|
@@ -248,23 +254,46 @@ export function MarkdownEditor({
|
|
|
248
254
|
// `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
|
|
249
255
|
// extension. No-op when no provider is mounted (default no-op context).
|
|
250
256
|
//
|
|
251
|
-
// Whole-field handling
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
+
// Whole-field handling: NO chip widget here. The chip's `textContent`
|
|
258
|
+
// renderer surfaces raw markdown (`## Heading\n- item`) as literal text
|
|
259
|
+
// inside the green pill — visually unparseable for multi-paragraph
|
|
260
|
+
// rewrites. Instead, `<AiSuggestionBanner>` mounts above the editor
|
|
261
|
+
// (see render below). Producer-supplied range suggestions still ride
|
|
262
|
+
// the inline chip path — those have a precise anchor worth showing
|
|
263
|
+
// in context.
|
|
264
|
+
const applyWholeField = (value: string): void => {
|
|
265
|
+
if (!editor || editor.isDestroyed) return
|
|
266
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
267
|
+
;(editor.commands as any).setContent(value)
|
|
268
|
+
}
|
|
257
269
|
useAiSuggestionBridge(editor ?? null, name, {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
270
|
+
onApplyWholeField: applyWholeField,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// Inline diff for whole-field suggestions — replaces the editor doc with
|
|
274
|
+
// the proposed markdown so the user sees the structural diff (inserted
|
|
275
|
+
// headings / list items / etc.) before approving. Pipeline:
|
|
276
|
+
// 1. tiptap-markdown's parser turns the source into HTML
|
|
277
|
+
// (`editor.storage.markdown.parser.parse(value)` returns a string).
|
|
278
|
+
// 2. ProseMirror's `DOMParser.fromSchema(schema).parseSlice(...)` turns
|
|
279
|
+
// that HTML into a Slice against THIS editor's schema — same path
|
|
280
|
+
// the editor's own clipboard-paste uses, so the slice is guaranteed
|
|
281
|
+
// schema-valid.
|
|
282
|
+
useAiInlineDiff(editor ?? null, name, {
|
|
283
|
+
parseSuggestion: (ed, value) => {
|
|
284
|
+
try {
|
|
285
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
286
|
+
const parser = (ed.storage as any).markdown?.parser
|
|
287
|
+
if (!parser || typeof parser.parse !== 'function') return null
|
|
288
|
+
const html = parser.parse(value)
|
|
289
|
+
if (typeof html !== 'string') return null
|
|
290
|
+
const container = document.createElement('div')
|
|
291
|
+
container.innerHTML = html
|
|
292
|
+
return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
|
|
293
|
+
} catch { return null }
|
|
266
294
|
},
|
|
267
295
|
})
|
|
296
|
+
const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
|
|
268
297
|
|
|
269
298
|
// First-load seed for collab. Collaboration starts the editor empty
|
|
270
299
|
// regardless of `content`; once the provider syncs from the server we
|
|
@@ -434,6 +463,16 @@ export function MarkdownEditor({
|
|
|
434
463
|
|
|
435
464
|
return (
|
|
436
465
|
<div className="flex flex-col rounded-md border bg-background">
|
|
466
|
+
<AiSuggestionBanner
|
|
467
|
+
fieldName={name}
|
|
468
|
+
onApplyWholeField={applyWholeField}
|
|
469
|
+
{...(isDiffActive && editor
|
|
470
|
+
? {
|
|
471
|
+
onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
|
|
472
|
+
onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
|
|
473
|
+
}
|
|
474
|
+
: {})}
|
|
475
|
+
/>
|
|
437
476
|
{canAttach && (
|
|
438
477
|
<input
|
|
439
478
|
ref={fileInputRef}
|
|
@@ -20,6 +20,9 @@ import type {
|
|
|
20
20
|
} from '@pilotiq/pilotiq/react'
|
|
21
21
|
import { useCollabRoom, getCollabExtensions, onProviderSynced } from '@pilotiq/pilotiq/react'
|
|
22
22
|
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
|
|
23
|
+
import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
|
|
24
|
+
import { AiSuggestionBanner } from './AiSuggestionBanner.js'
|
|
25
|
+
import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
|
|
23
26
|
import type { BlockMeta } from '../Block.js'
|
|
24
27
|
import type { ToolbarGroups, RichTextStorage, ColorSwatch } from '../RichTextField.js'
|
|
25
28
|
import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
|
|
@@ -31,6 +34,7 @@ import { DragHandleExtension } from '../extensions/DragHandleExtension.js'
|
|
|
31
34
|
import { MergeTagExtension } from '../extensions/MergeTagExtension.js'
|
|
32
35
|
import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js'
|
|
33
36
|
import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
|
|
37
|
+
import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js'
|
|
34
38
|
import {
|
|
35
39
|
MentionExtension,
|
|
36
40
|
type MentionState,
|
|
@@ -315,10 +319,11 @@ function ClientEditor(props: ClientEditorProps) {
|
|
|
315
319
|
fieldName: name,
|
|
316
320
|
})] : [MentionExtension]),
|
|
317
321
|
DragHandleExtension,
|
|
318
|
-
// AI suggestions —
|
|
319
|
-
// inline strikethrough + Approve/Reject chip widgets. Idle until the
|
|
320
|
-
// host calls `editor.commands.addAiSuggestion(...)`.
|
|
322
|
+
// AI suggestions — chip widget for surgical (range-anchored) edits.
|
|
321
323
|
AiSuggestionExtension,
|
|
324
|
+
// AI inline diff — Tiptap-Pro-style visualization for whole-field
|
|
325
|
+
// suggestions via prosemirror-changeset. See AiInlineDiffExtension.
|
|
326
|
+
AiInlineDiffExtension,
|
|
322
327
|
// Realtime-collab extensions (Yjs `Collaboration` + cursor) — empty
|
|
323
328
|
// when no `<RecordCollabRoom>` is mounted up-tree, or when no plugin
|
|
324
329
|
// registered a factory via `registerCollabExtensions`.
|
|
@@ -464,22 +469,35 @@ function ClientEditor(props: ClientEditorProps) {
|
|
|
464
469
|
// `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
|
|
465
470
|
// extension. No-op when no provider is mounted (default no-op context).
|
|
466
471
|
//
|
|
467
|
-
// Whole-field handling:
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
472
|
+
// Whole-field handling: NO chip widget here. The chip's `textContent`
|
|
473
|
+
// renderer would surface raw HTML tags as literal text inside the
|
|
474
|
+
// green pill — unparseable on multi-paragraph rewrites. Instead,
|
|
475
|
+
// `<AiSuggestionBanner>` mounts above the editor (see render below).
|
|
476
|
+
// Producer-supplied range suggestions still ride the inline chip —
|
|
477
|
+
// those have a precise anchor worth visualizing in context.
|
|
478
|
+
const applyWholeField = (value: string): void => {
|
|
479
|
+
if (!editor || editor.isDestroyed) return
|
|
480
|
+
editor.commands.setContent(value)
|
|
481
|
+
}
|
|
473
482
|
useAiSuggestionBridge(editor ?? null, name, {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
483
|
+
onApplyWholeField: applyWholeField,
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
// Inline diff for whole-field suggestions. Pipeline mirrors MarkdownEditor:
|
|
487
|
+
// HTML → ProseMirror Slice via the schema's DOMParser. Suggested values
|
|
488
|
+
// on a RichTextField are typically HTML (or marked-up JSON that the
|
|
489
|
+
// schema's DOMParser also handles via its serialized round-trip). For
|
|
490
|
+
// JSON suggestions, the schema may reject — falls back to banner-only.
|
|
491
|
+
useAiInlineDiff(editor ?? null, name, {
|
|
492
|
+
parseSuggestion: (ed, value) => {
|
|
493
|
+
try {
|
|
494
|
+
const container = document.createElement('div')
|
|
495
|
+
container.innerHTML = value
|
|
496
|
+
return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
|
|
497
|
+
} catch { return null }
|
|
481
498
|
},
|
|
482
499
|
})
|
|
500
|
+
const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
|
|
483
501
|
|
|
484
502
|
// Re-render the toolbar when the selection / marks change so active-state
|
|
485
503
|
// booleans stay fresh.
|
|
@@ -488,6 +506,16 @@ function ClientEditor(props: ClientEditorProps) {
|
|
|
488
506
|
return (
|
|
489
507
|
<div className="relative flex flex-col">
|
|
490
508
|
<input type="hidden" name={name} value={serialized} />
|
|
509
|
+
<AiSuggestionBanner
|
|
510
|
+
fieldName={name}
|
|
511
|
+
onApplyWholeField={applyWholeField}
|
|
512
|
+
{...(isDiffActive && editor
|
|
513
|
+
? {
|
|
514
|
+
onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
|
|
515
|
+
onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
|
|
516
|
+
}
|
|
517
|
+
: {})}
|
|
518
|
+
/>
|
|
491
519
|
{editor && toolbarGroups && toolbarGroups.length > 0 && (
|
|
492
520
|
<Toolbar
|
|
493
521
|
editor={editor}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between the host's `<PendingSuggestionsContext>` queue and the
|
|
3
|
+
* editor's `AiInlineDiffExtension`. When a whole-field suggestion arrives
|
|
4
|
+
* for the field, the hook:
|
|
5
|
+
*
|
|
6
|
+
* 1. Parses the suggested value into a ProseMirror `Slice` via the
|
|
7
|
+
* renderer-supplied parser. Each Tiptap surface owns its own
|
|
8
|
+
* content shape — markdown source for `MarkdownEditor`, HTML / JSON
|
|
9
|
+
* for `TiptapEditor`, plain text for `CollabTextRenderer`.
|
|
10
|
+
* 2. Calls `editor.commands.startAiInlineDiff(id, slice)` — the
|
|
11
|
+
* extension snapshots the current doc as the baseline, replaces
|
|
12
|
+
* the doc content with the proposed slice, and starts a
|
|
13
|
+
* `prosemirror-changeset` tracking the diff.
|
|
14
|
+
* 3. Registers an applier on the cross-tree pending-suggestion
|
|
15
|
+
* registry so the host's `<AiSuggestionBanner>` Accept button (and
|
|
16
|
+
* any other surface calling `pendingSuggestions.approve(id)`) runs
|
|
17
|
+
* `acceptAiInlineDiff()` instead of the legacy `onApplyWholeField`
|
|
18
|
+
* callback. The current doc IS the accepted state — no extra
|
|
19
|
+
* content swap needed.
|
|
20
|
+
*
|
|
21
|
+
* Reject handling: not registered on the applier (the registry only
|
|
22
|
+
* tracks Approve). Renderers wire Reject through the banner's
|
|
23
|
+
* `onRejectWithEditor` prop, which calls `rejectAiInlineDiff()` to revert
|
|
24
|
+
* the doc to the baseline before dismissing the suggestion.
|
|
25
|
+
*
|
|
26
|
+
* Defensive: only one inline diff active at a time per editor. If a new
|
|
27
|
+
* synthesized suggestion arrives while one is still pending review, the
|
|
28
|
+
* hook drops it (the producer should have waited). This matches
|
|
29
|
+
* `AiSuggestionExtension`'s chip path which also allows only one
|
|
30
|
+
* suggestion at a time per id.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { useEffect, useRef } from 'react'
|
|
34
|
+
import type { Editor } from '@tiptap/core'
|
|
35
|
+
import type { Slice } from '@tiptap/pm/model'
|
|
36
|
+
import {
|
|
37
|
+
registerPendingSuggestionApplier,
|
|
38
|
+
usePendingSuggestionsForField,
|
|
39
|
+
type PendingSuggestion,
|
|
40
|
+
type PendingSuggestionApplier,
|
|
41
|
+
} from '@pilotiq/pilotiq/react'
|
|
42
|
+
import { aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js'
|
|
43
|
+
|
|
44
|
+
export interface UseAiInlineDiffOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Parse the suggested string value into a ProseMirror Slice that's
|
|
47
|
+
* compatible with this editor's schema. Returns `null` to skip (e.g.
|
|
48
|
+
* malformed content, unsupported markup) — the suggestion stays in
|
|
49
|
+
* the queue but no diff renders, and the host's fallback path (banner
|
|
50
|
+
* with `onApplyWholeField`) takes over.
|
|
51
|
+
*
|
|
52
|
+
* Renderers implement this per content shape:
|
|
53
|
+
* - plain text → wrap each line in a `paragraph` node
|
|
54
|
+
* - markdown → run through the Markdown extension's parseMarkdown
|
|
55
|
+
* - HTML → DOMParser + ProseMirror's DOMParser.parse
|
|
56
|
+
*/
|
|
57
|
+
parseSuggestion: (editor: Editor, value: string) => Slice | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns whether a diff is currently active in the editor. Hosts use
|
|
62
|
+
* this to gate the banner's UI between the legacy `onApplyWholeField`
|
|
63
|
+
* mode and the diff-aware mode (Reject routes through
|
|
64
|
+
* `rejectAiInlineDiff` to revert the doc).
|
|
65
|
+
*/
|
|
66
|
+
export function useIsAiInlineDiffActive(editor: Editor | null): boolean {
|
|
67
|
+
// Re-render on every editor transaction so the hook tracks state
|
|
68
|
+
// changes (start / accept / reject). useEditorState would be the
|
|
69
|
+
// idiomatic way; we read directly here to keep the dep surface tiny.
|
|
70
|
+
const [, force] = useReducerForceUpdate()
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!editor) return
|
|
73
|
+
const handler = () => force()
|
|
74
|
+
editor.on('transaction', handler)
|
|
75
|
+
return () => { editor.off('transaction', handler) }
|
|
76
|
+
}, [editor, force])
|
|
77
|
+
if (!editor) return false
|
|
78
|
+
return aiInlineDiffPluginKey.getState(editor.state) !== null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useAiInlineDiff(
|
|
82
|
+
editor: Editor | null,
|
|
83
|
+
fieldName: string,
|
|
84
|
+
options: UseAiInlineDiffOptions,
|
|
85
|
+
): void {
|
|
86
|
+
const { list } = usePendingSuggestionsForField(fieldName)
|
|
87
|
+
|
|
88
|
+
const parseRef = useRef(options.parseSuggestion)
|
|
89
|
+
useEffect(() => { parseRef.current = options.parseSuggestion }, [options.parseSuggestion])
|
|
90
|
+
|
|
91
|
+
// Track which ids we've handed off to the editor's diff extension
|
|
92
|
+
// so we don't re-start the diff every render or for already-active
|
|
93
|
+
// suggestions.
|
|
94
|
+
const startedRef = useRef<Set<string>>(new Set())
|
|
95
|
+
|
|
96
|
+
// Context → editor: start the diff for each new whole-field suggestion.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!editor) return
|
|
99
|
+
const wholeField = list.filter(s => !hasEditorRange(s))
|
|
100
|
+
for (const s of wholeField) {
|
|
101
|
+
if (startedRef.current.has(s.id)) continue
|
|
102
|
+
if (typeof s.suggestedValue !== 'string') continue
|
|
103
|
+
// Bail when a different diff is already showing — one at a time.
|
|
104
|
+
// Producer should serialize calls; if not, the second suggestion
|
|
105
|
+
// sits in the queue until the first is approved/rejected.
|
|
106
|
+
if (aiInlineDiffPluginKey.getState(editor.state) !== null) continue
|
|
107
|
+
const slice = parseRef.current(editor, s.suggestedValue)
|
|
108
|
+
if (!slice) continue
|
|
109
|
+
editor.commands.startAiInlineDiff(s.id, slice)
|
|
110
|
+
startedRef.current.add(s.id)
|
|
111
|
+
}
|
|
112
|
+
// Cleanup: when a suggestion leaves the context AND we previously
|
|
113
|
+
// started a diff for it, the editor should drop the diff state too.
|
|
114
|
+
// Approve dismisses via context → here we drop from startedRef.
|
|
115
|
+
const contextIds = new Set(list.map(s => s.id))
|
|
116
|
+
for (const id of Array.from(startedRef.current)) {
|
|
117
|
+
if (!contextIds.has(id)) startedRef.current.delete(id)
|
|
118
|
+
}
|
|
119
|
+
}, [editor, list])
|
|
120
|
+
|
|
121
|
+
// Cross-tree applier — when the banner / chat-sidebar pill calls
|
|
122
|
+
// `pendingSuggestions.approve(id)` for one of our tracked suggestions,
|
|
123
|
+
// accept the diff. Editor is the source of truth for the new doc.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!editor) return
|
|
126
|
+
const applier: PendingSuggestionApplier = (suggestion) => {
|
|
127
|
+
if (!startedRef.current.has(suggestion.id)) return
|
|
128
|
+
editor.commands.acceptAiInlineDiff()
|
|
129
|
+
}
|
|
130
|
+
return registerPendingSuggestionApplier(undefined, fieldName, applier)
|
|
131
|
+
}, [editor, fieldName])
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hasEditorRange(s: PendingSuggestion): boolean {
|
|
135
|
+
const meta = (s.meta ?? {}) as Record<string, unknown>
|
|
136
|
+
const range = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
|
|
137
|
+
return !!(range && typeof range.from === 'number' && typeof range.to === 'number')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// useReducer + dispatch is the smallest-API force-update primitive React
|
|
141
|
+
// ships. Hoisted into a helper so the call site stays one line.
|
|
142
|
+
import { useReducer } from 'react'
|
|
143
|
+
function useReducerForceUpdate(): [number, () => void] {
|
|
144
|
+
const [n, inc] = useReducer((x: number) => (x + 1) | 0, 0)
|
|
145
|
+
return [n, () => inc()]
|
|
146
|
+
}
|