@pilotiq/tiptap 2.0.1 → 3.1.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/AiSuggestionExtension.d.ts +114 -0
- package/dist/extensions/AiSuggestionExtension.d.ts.map +1 -0
- package/dist/extensions/AiSuggestionExtension.js +302 -0
- package/dist/extensions/AiSuggestionExtension.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/react/TiptapEditor.d.ts.map +1 -1
- package/dist/react/TiptapEditor.js +10 -0
- package/dist/react/TiptapEditor.js.map +1 -1
- package/dist/react/useAiSuggestionBridge.d.ts +27 -0
- package/dist/react/useAiSuggestionBridge.d.ts.map +1 -0
- package/dist/react/useAiSuggestionBridge.js +110 -0
- package/dist/react/useAiSuggestionBridge.js.map +1 -0
- package/package.json +3 -3
- package/src/extensions/AiSuggestionExtension.test.ts +141 -0
- package/src/extensions/AiSuggestionExtension.ts +415 -0
- package/src/index.ts +13 -0
- package/src/react/TiptapEditor.tsx +11 -0
- package/src/react/useAiSuggestionBridge.ts +119 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { registerPendingSuggestionApplier, usePendingSuggestionsForField, } from '@pilotiq/pilotiq/react';
|
|
3
|
+
import { aiSuggestionPluginKey } from '../extensions/AiSuggestionExtension.js';
|
|
4
|
+
/**
|
|
5
|
+
* Two-way sync between the cross-package `<PendingSuggestionsContext>`
|
|
6
|
+
* queue and this editor's `AiSuggestionExtension` state.
|
|
7
|
+
*
|
|
8
|
+
* - **Context → editor**: every entry whose `meta.editorRange = { from, to }`
|
|
9
|
+
* is present and whose `suggestedValue` is a string gets pushed into the
|
|
10
|
+
* editor as an inline-diff hunk via `addAiSuggestion`. Entries leaving the
|
|
11
|
+
* queue are removed from the editor via `rejectAiSuggestion` (no doc edit).
|
|
12
|
+
*
|
|
13
|
+
* - **Editor → context**: when a chip's Approve / Reject button removes a
|
|
14
|
+
* hunk from the editor's plugin state, the matching id is dismissed from
|
|
15
|
+
* the queue (`dismiss(id)`) so other surfaces (e.g. the chat-sidebar pill,
|
|
16
|
+
* a future FieldShell overlay) clear in lock-step. The doc mutation
|
|
17
|
+
* itself happens inside the editor — context is just a notification.
|
|
18
|
+
*
|
|
19
|
+
* Cycle protection: the hook tracks which ids it has personally pushed to
|
|
20
|
+
* the editor (`pushed`). The Context→editor pass never re-pushes an id that's
|
|
21
|
+
* already there, and the Editor→context pass only dismisses ids that this
|
|
22
|
+
* hook had previously pushed (so an id added directly by host code via
|
|
23
|
+
* `editor.commands.addAiSuggestion(...)` doesn't get reflected back through
|
|
24
|
+
* a context that never knew about it).
|
|
25
|
+
*/
|
|
26
|
+
export function useAiSuggestionBridge(editor, fieldName) {
|
|
27
|
+
const { list, dismiss } = usePendingSuggestionsForField(fieldName);
|
|
28
|
+
// Hold the latest `dismiss` in a ref so the editor-side listener — which
|
|
29
|
+
// installs once per editor — always reaches the up-to-date context API.
|
|
30
|
+
const dismissRef = useRef(dismiss);
|
|
31
|
+
useEffect(() => { dismissRef.current = dismiss; }, [dismiss]);
|
|
32
|
+
// Set of ids this hook pushed; used by both directions for cycle control.
|
|
33
|
+
const pushedRef = useRef(new Set());
|
|
34
|
+
// Context → editor.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!editor)
|
|
37
|
+
return;
|
|
38
|
+
const contextIds = new Set(list.map(s => s.id));
|
|
39
|
+
for (const s of list) {
|
|
40
|
+
if (pushedRef.current.has(s.id))
|
|
41
|
+
continue;
|
|
42
|
+
const meta = (s.meta ?? {});
|
|
43
|
+
const range = meta['editorRange'];
|
|
44
|
+
if (!range || typeof range.from !== 'number' || typeof range.to !== 'number')
|
|
45
|
+
continue;
|
|
46
|
+
const replacement = typeof s.suggestedValue === 'string' ? s.suggestedValue : '';
|
|
47
|
+
editor.commands.addAiSuggestion({
|
|
48
|
+
id: s.id,
|
|
49
|
+
from: range.from,
|
|
50
|
+
to: range.to,
|
|
51
|
+
replacement,
|
|
52
|
+
...(s.source ? { source: s.source } : {}),
|
|
53
|
+
});
|
|
54
|
+
pushedRef.current.add(s.id);
|
|
55
|
+
}
|
|
56
|
+
for (const id of Array.from(pushedRef.current)) {
|
|
57
|
+
if (contextIds.has(id))
|
|
58
|
+
continue;
|
|
59
|
+
// Context dropped the suggestion — remove from editor without
|
|
60
|
+
// mutating the doc (rejectAiSuggestion drops state only).
|
|
61
|
+
editor.commands.rejectAiSuggestion(id);
|
|
62
|
+
pushedRef.current.delete(id);
|
|
63
|
+
}
|
|
64
|
+
}, [editor, list]);
|
|
65
|
+
// Editor → context.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!editor)
|
|
68
|
+
return;
|
|
69
|
+
const handler = () => {
|
|
70
|
+
const ps = aiSuggestionPluginKey.getState(editor.state);
|
|
71
|
+
if (!ps)
|
|
72
|
+
return;
|
|
73
|
+
const editorIds = new Set(ps.suggestions.map((s) => s.id));
|
|
74
|
+
for (const id of Array.from(pushedRef.current)) {
|
|
75
|
+
if (editorIds.has(id))
|
|
76
|
+
continue;
|
|
77
|
+
// Chip removed the suggestion (Approve mutated the doc, Reject did
|
|
78
|
+
// not — either way it's gone from editor state). Mirror to context.
|
|
79
|
+
pushedRef.current.delete(id);
|
|
80
|
+
dismissRef.current(id);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
editor.on('transaction', handler);
|
|
84
|
+
return () => { editor.off('transaction', handler); };
|
|
85
|
+
}, [editor]);
|
|
86
|
+
// Cross-tree applier (Phase 8.5). When an aggregate consumer (e.g. a
|
|
87
|
+
// chat-sidebar pending-pill) calls `pendingSuggestions.approve(id)`,
|
|
88
|
+
// the pro provider looks up the applier registered for this
|
|
89
|
+
// `(formId, fieldName)` and invokes it. We translate that into the
|
|
90
|
+
// editor's own approve command — same path the inline chip click takes.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!editor)
|
|
93
|
+
return;
|
|
94
|
+
const applier = (suggestion) => {
|
|
95
|
+
// Bail when the suggestion isn't one of ours (no editor range or
|
|
96
|
+
// bridge-pushed entry). Pro provider falls back to plain dismiss.
|
|
97
|
+
if (!pushedRef.current.has(suggestion.id))
|
|
98
|
+
return;
|
|
99
|
+
editor.chain().focus().approveAiSuggestion(suggestion.id).run();
|
|
100
|
+
// The transaction listener above sees the editor state drop the id
|
|
101
|
+
// and calls `dismiss(id)` on its own — no manual mirror needed.
|
|
102
|
+
};
|
|
103
|
+
// Editor renderers don't currently have access to a `formId` here;
|
|
104
|
+
// pass `undefined` so the wildcard form scope resolves. Phase 8.5+
|
|
105
|
+
// can thread `formId` via the bridge call site if a future multi-
|
|
106
|
+
// form richtext consumer needs it.
|
|
107
|
+
return registerPendingSuggestionApplier(undefined, fieldName, applier);
|
|
108
|
+
}, [editor, fieldName]);
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=useAiSuggestionBridge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useAiSuggestionBridge.js","sourceRoot":"","sources":["../../src/react/useAiSuggestionBridge.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pilotiq/tiptap",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.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": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@tiptap/suggestion": "^3",
|
|
53
53
|
"react": "^18 || ^19",
|
|
54
54
|
"react-dom": "^18 || ^19",
|
|
55
|
-
"@pilotiq/pilotiq": "^0.
|
|
55
|
+
"@pilotiq/pilotiq": "^0.7.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@base-ui/react": "^1",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"react": "^19",
|
|
80
80
|
"react-dom": "^19",
|
|
81
81
|
"typescript": "^5",
|
|
82
|
-
"@pilotiq/pilotiq": "^0.
|
|
82
|
+
"@pilotiq/pilotiq": "^0.7.0"
|
|
83
83
|
},
|
|
84
84
|
"author": "Suleiman Shahbari",
|
|
85
85
|
"scripts": {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
upsertSuggestion,
|
|
5
|
+
upsertSuggestions,
|
|
6
|
+
removeSuggestion,
|
|
7
|
+
remapSuggestions,
|
|
8
|
+
sortForApproveAll,
|
|
9
|
+
clampPos,
|
|
10
|
+
type AiSuggestion,
|
|
11
|
+
} from './AiSuggestionExtension.js'
|
|
12
|
+
|
|
13
|
+
const make = (id: string, from: number, to: number, replacement = '…'): AiSuggestion => ({
|
|
14
|
+
id, from, to, replacement,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('upsertSuggestion', () => {
|
|
18
|
+
it('appends a new suggestion when id is unseen', () => {
|
|
19
|
+
const list = [make('a', 0, 4)]
|
|
20
|
+
const out = upsertSuggestion(list, make('b', 8, 12))
|
|
21
|
+
assert.equal(out.length, 2)
|
|
22
|
+
assert.equal(out[1]!.id, 'b')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('replaces in place when id already exists', () => {
|
|
26
|
+
const list = [make('a', 0, 4, 'old'), make('b', 8, 12, 'old')]
|
|
27
|
+
const out = upsertSuggestion(list, make('a', 0, 4, 'new'))
|
|
28
|
+
assert.equal(out.length, 2)
|
|
29
|
+
assert.equal(out[0]!.replacement, 'new')
|
|
30
|
+
assert.equal(out[1]!.id, 'b')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('does not mutate the input array', () => {
|
|
34
|
+
const list = [make('a', 0, 4)]
|
|
35
|
+
upsertSuggestion(list, make('b', 8, 12))
|
|
36
|
+
assert.equal(list.length, 1)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('upsertSuggestions', () => {
|
|
41
|
+
it('folds multiple inserts and replacements', () => {
|
|
42
|
+
const list = [make('a', 0, 4, 'old')]
|
|
43
|
+
const out = upsertSuggestions(list, [
|
|
44
|
+
make('a', 0, 4, 'new'),
|
|
45
|
+
make('b', 8, 12),
|
|
46
|
+
make('c', 16, 20),
|
|
47
|
+
])
|
|
48
|
+
assert.deepEqual(out.map(s => s.id), ['a', 'b', 'c'])
|
|
49
|
+
assert.equal(out[0]!.replacement, 'new')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('removeSuggestion', () => {
|
|
54
|
+
it('drops only the matching id', () => {
|
|
55
|
+
const list = [make('a', 0, 4), make('b', 8, 12)]
|
|
56
|
+
const out = removeSuggestion(list, 'a')
|
|
57
|
+
assert.deepEqual(out.map(s => s.id), ['b'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns the same shape when id is unseen', () => {
|
|
61
|
+
const list = [make('a', 0, 4)]
|
|
62
|
+
const out = removeSuggestion(list, 'unseen')
|
|
63
|
+
assert.deepEqual(out, list)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('remapSuggestions', () => {
|
|
68
|
+
it('shifts ranges through a forward-shift mapping', () => {
|
|
69
|
+
const list = [make('a', 10, 14)]
|
|
70
|
+
const out = remapSuggestions(list, (pos) => pos + 5)
|
|
71
|
+
assert.equal(out[0]!.from, 15)
|
|
72
|
+
assert.equal(out[0]!.to, 19)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('drops ranges that collapsed past each other', () => {
|
|
76
|
+
const list = [make('a', 10, 14), make('b', 20, 24)]
|
|
77
|
+
// Map collapses everything to position 0 — `to (-1 in mapping bias) < from`.
|
|
78
|
+
const out = remapSuggestions(list, (pos, side) => (side === -1 ? pos : 0))
|
|
79
|
+
assert.equal(out.length, 0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('keeps a pure-insertion range (`from === to`) when it survives the mapping', () => {
|
|
83
|
+
const list = [{ ...make('a', 5, 5), replacement: 'inserted' }]
|
|
84
|
+
const out = remapSuggestions(list, (pos) => pos + 3)
|
|
85
|
+
assert.equal(out.length, 1)
|
|
86
|
+
assert.equal(out[0]!.from, 8)
|
|
87
|
+
assert.equal(out[0]!.to, 8)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('biases sides — `from` left, `to` right — to keep insertions stable under collapsed text', () => {
|
|
91
|
+
const list = [make('a', 5, 10)]
|
|
92
|
+
// A mapping that collapses at exactly `pos = 5` — left bias keeps `from`
|
|
93
|
+
// anchored at 5; right bias for `to = 10` shifts it to 10. Range survives.
|
|
94
|
+
const out = remapSuggestions(list, (pos, side) => {
|
|
95
|
+
if (pos === 5 && side === -1) return 5
|
|
96
|
+
if (pos === 5 && side === 1) return 7
|
|
97
|
+
if (pos === 10 && side === -1) return 7
|
|
98
|
+
if (pos === 10 && side === 1) return 10
|
|
99
|
+
return pos
|
|
100
|
+
})
|
|
101
|
+
assert.equal(out.length, 1)
|
|
102
|
+
assert.equal(out[0]!.from, 5)
|
|
103
|
+
assert.equal(out[0]!.to, 10)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('sortForApproveAll', () => {
|
|
108
|
+
it('orders highest-`from` first so earlier positions are stable across replacements', () => {
|
|
109
|
+
const list = [make('a', 0, 4), make('c', 20, 24), make('b', 10, 14)]
|
|
110
|
+
const out = sortForApproveAll(list)
|
|
111
|
+
assert.deepEqual(out.map(s => s.id), ['c', 'b', 'a'])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('does not mutate the input', () => {
|
|
115
|
+
const list = [make('a', 0, 4), make('b', 10, 14)]
|
|
116
|
+
sortForApproveAll(list)
|
|
117
|
+
assert.deepEqual(list.map(s => s.id), ['a', 'b'])
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('clampPos', () => {
|
|
122
|
+
it('passes through positions within range', () => {
|
|
123
|
+
assert.equal(clampPos(5, 10), 5)
|
|
124
|
+
assert.equal(clampPos(0, 10), 0)
|
|
125
|
+
assert.equal(clampPos(10, 10), 10)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('floors negatives at 0 and ceils overshoots at max', () => {
|
|
129
|
+
assert.equal(clampPos(-5, 10), 0)
|
|
130
|
+
assert.equal(clampPos(99, 10), 10)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('returns 0 for non-finite input', () => {
|
|
134
|
+
assert.equal(clampPos(NaN, 10), 0)
|
|
135
|
+
assert.equal(clampPos(Infinity, 10), 0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('truncates fractional positions', () => {
|
|
139
|
+
assert.equal(clampPos(3.7, 10), 3)
|
|
140
|
+
})
|
|
141
|
+
})
|