@pilotiq/tiptap 3.0.0 → 3.1.1

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.
@@ -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.0.0",
3
+ "version": "3.1.1",
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": {
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "dependencies": {},
34
34
  "peerDependencies": {
35
+ "@pilotiq/pilotiq": ">=0.6.0 <1.0.0",
35
36
  "@base-ui/react": "^1",
36
37
  "@tiptap/core": "^3",
37
38
  "@tiptap/pm": "^3",
@@ -51,8 +52,7 @@
51
52
  "@tiptap/extension-details": "3.22.4",
52
53
  "@tiptap/suggestion": "^3",
53
54
  "react": "^18 || ^19",
54
- "react-dom": "^18 || ^19",
55
- "@pilotiq/pilotiq": "^0.6.0"
55
+ "react-dom": "^18 || ^19"
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.6.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
+ })