@tiptap/suggestion 3.26.1 → 3.27.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/index.cjs +619 -270
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +193 -2
- package/dist/index.d.ts +193 -2
- package/dist/index.js +624 -270
- package/dist/index.js.map +1 -1
- package/package.json +10 -5
- package/src/__tests__/suggestion.test.ts +837 -0
- package/src/helpers.ts +129 -0
- package/src/plugin/async.ts +89 -0
- package/src/plugin/floating-ui.ts +204 -0
- package/src/plugin/props.ts +94 -0
- package/src/plugin/state.ts +182 -0
- package/src/plugin/view.ts +236 -0
- package/src/suggestion.ts +97 -606
- package/src/types.ts +439 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core'
|
|
2
|
+
import type { EditorState, PluginKey } from '@tiptap/pm/state'
|
|
3
|
+
import type { EditorView } from '@tiptap/pm/view'
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
PluginState,
|
|
7
|
+
SuggestionFloatingUiOptions,
|
|
8
|
+
SuggestionOptions,
|
|
9
|
+
SuggestionPlacement,
|
|
10
|
+
SuggestionProps,
|
|
11
|
+
} from '../types.js'
|
|
12
|
+
import { createSuggestionAsyncRequestManager } from './async.js'
|
|
13
|
+
import { createMount, createSuggestionFloatingUiConfig } from './floating-ui.js'
|
|
14
|
+
|
|
15
|
+
export interface CreateSuggestionViewOptions {
|
|
16
|
+
editor: Editor
|
|
17
|
+
pluginKey: PluginKey<PluginState>
|
|
18
|
+
items: NonNullable<SuggestionOptions['items']>
|
|
19
|
+
renderer: ReturnType<NonNullable<SuggestionOptions['render']>> | undefined
|
|
20
|
+
minQueryLength: number
|
|
21
|
+
debounce: number
|
|
22
|
+
initialItems?: any[]
|
|
23
|
+
placement: SuggestionPlacement
|
|
24
|
+
offset: { mainAxis?: number; crossAxis?: number }
|
|
25
|
+
container?: string | HTMLElement
|
|
26
|
+
flip: boolean
|
|
27
|
+
floatingUi?: SuggestionFloatingUiOptions
|
|
28
|
+
dismissOnOutsideClick: boolean
|
|
29
|
+
command: NonNullable<SuggestionOptions['command']>
|
|
30
|
+
clientRectFor: (view: EditorView, decorationNode: Element | null) => () => DOMRect | null
|
|
31
|
+
dispatchExit: (view: EditorView) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates the `view` object for the suggestion ProseMirror plugin.
|
|
36
|
+
*
|
|
37
|
+
* Manages the async lifecycle: tracks state transitions, calls renderer hooks,
|
|
38
|
+
* fetches items with debounce and AbortController support.
|
|
39
|
+
*
|
|
40
|
+
* 1. Tracks plugin state transitions (started, updated, stopped) to determine when to call renderer hooks.
|
|
41
|
+
* 2. Calls `onBeforeStart`, `onBeforeUpdate`, `onStart` before fetching to allow the renderer to prepare for first render
|
|
42
|
+
* 3. Manages async fetching of suggestion items with support for debouncing and aborting in-flight requests
|
|
43
|
+
* 4. Calls `onUpdate` after fetching new items to update the renderer with the latest data
|
|
44
|
+
* 5. At the end calls a final `onExit` or `onUpdate` to allow the renderer to clean up or finalize the state
|
|
45
|
+
*/
|
|
46
|
+
export function createSuggestionView({
|
|
47
|
+
editor,
|
|
48
|
+
pluginKey,
|
|
49
|
+
items,
|
|
50
|
+
renderer,
|
|
51
|
+
minQueryLength,
|
|
52
|
+
debounce,
|
|
53
|
+
initialItems,
|
|
54
|
+
placement,
|
|
55
|
+
offset: offsetOption,
|
|
56
|
+
container,
|
|
57
|
+
flip,
|
|
58
|
+
floatingUi,
|
|
59
|
+
dismissOnOutsideClick,
|
|
60
|
+
command,
|
|
61
|
+
clientRectFor,
|
|
62
|
+
dispatchExit,
|
|
63
|
+
}: CreateSuggestionViewOptions) {
|
|
64
|
+
let props: SuggestionProps | undefined
|
|
65
|
+
const asyncRequest = createSuggestionAsyncRequestManager({
|
|
66
|
+
editor,
|
|
67
|
+
items,
|
|
68
|
+
})
|
|
69
|
+
const floatingUiConfig = createSuggestionFloatingUiConfig({
|
|
70
|
+
placement,
|
|
71
|
+
offset: offsetOption,
|
|
72
|
+
flip,
|
|
73
|
+
floatingUi,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
function dispatchStateUpdate(
|
|
77
|
+
state: 'started' | 'updated' | 'stopped',
|
|
78
|
+
dispatchProps: SuggestionProps,
|
|
79
|
+
) {
|
|
80
|
+
switch (state) {
|
|
81
|
+
case 'started':
|
|
82
|
+
renderer?.onStart?.(dispatchProps)
|
|
83
|
+
break
|
|
84
|
+
case 'updated':
|
|
85
|
+
renderer?.onUpdate?.(dispatchProps)
|
|
86
|
+
break
|
|
87
|
+
case 'stopped':
|
|
88
|
+
renderer?.onExit?.(dispatchProps)
|
|
89
|
+
break
|
|
90
|
+
default:
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
update: async (view: EditorView, prevState: EditorState) => {
|
|
97
|
+
const prev = pluginKey.getState(prevState)
|
|
98
|
+
const next = pluginKey.getState(view.state)
|
|
99
|
+
|
|
100
|
+
if (!prev || !next) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let currentState: 'started' | 'updated' | 'stopped' | null = null
|
|
105
|
+
const queryChanged = prev.query !== next.query
|
|
106
|
+
const textChanged = prev.text !== next.text
|
|
107
|
+
const rangeChanged = prev.range.from !== next.range.from || prev.range.to !== next.range.to
|
|
108
|
+
const effectiveQueryChanged = queryChanged || textChanged || rangeChanged
|
|
109
|
+
|
|
110
|
+
if (!prev.active && next.active) {
|
|
111
|
+
currentState = 'started'
|
|
112
|
+
} else if (prev.active && !next.active) {
|
|
113
|
+
currentState = 'stopped'
|
|
114
|
+
} else if (next.active && effectiveQueryChanged) {
|
|
115
|
+
currentState = 'updated'
|
|
116
|
+
} else {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const state = currentState === 'stopped' ? prev : next
|
|
121
|
+
const decorationNode = view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`)
|
|
122
|
+
const clientRect = clientRectFor(view, decorationNode)
|
|
123
|
+
|
|
124
|
+
const exceedsMinQueryLength =
|
|
125
|
+
minQueryLength === 0 || (state.query ? state.query.length >= minQueryLength : false)
|
|
126
|
+
const willFetch =
|
|
127
|
+
(currentState === 'started' || currentState === 'updated') && exceedsMinQueryLength
|
|
128
|
+
|
|
129
|
+
props = {
|
|
130
|
+
editor,
|
|
131
|
+
range: state.range,
|
|
132
|
+
query: state.query || '',
|
|
133
|
+
text: state.text || '',
|
|
134
|
+
items: initialItems ?? [],
|
|
135
|
+
command: commandProps => {
|
|
136
|
+
return command({
|
|
137
|
+
editor,
|
|
138
|
+
range: state.range,
|
|
139
|
+
props: commandProps,
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
decorationNode,
|
|
143
|
+
clientRect,
|
|
144
|
+
loading: willFetch,
|
|
145
|
+
placement,
|
|
146
|
+
offset: { mainAxis: offsetOption.mainAxis ?? 4, crossAxis: offsetOption.crossAxis ?? 0 },
|
|
147
|
+
container,
|
|
148
|
+
flip,
|
|
149
|
+
floatingUi: floatingUiConfig,
|
|
150
|
+
mount: createMount({
|
|
151
|
+
getReferenceRect: clientRect,
|
|
152
|
+
contextElement: view.dom,
|
|
153
|
+
config: floatingUiConfig,
|
|
154
|
+
container,
|
|
155
|
+
dismissOnOutsideClick,
|
|
156
|
+
dismiss: () => dispatchExit(editor.view),
|
|
157
|
+
}),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (currentState === 'started') {
|
|
161
|
+
renderer?.onBeforeStart?.(props)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (currentState === 'updated') {
|
|
165
|
+
renderer?.onBeforeUpdate?.(props)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// we run the start before we fetch
|
|
169
|
+
// to allow for the component to render immediately
|
|
170
|
+
if (currentState === 'started') {
|
|
171
|
+
dispatchStateUpdate(currentState, props)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (currentState === 'started' || currentState === 'updated') {
|
|
175
|
+
if (!willFetch) {
|
|
176
|
+
// Abort any in-flight request so stale results don't overwrite
|
|
177
|
+
asyncRequest.abort()
|
|
178
|
+
props = { ...props, items: initialItems ?? [], loading: false }
|
|
179
|
+
} else {
|
|
180
|
+
// update the renderer with loading state before we start the async fetch
|
|
181
|
+
props = { ...props, items: initialItems ?? [], loading: true }
|
|
182
|
+
currentState = 'updated'
|
|
183
|
+
dispatchStateUpdate(currentState, props)
|
|
184
|
+
|
|
185
|
+
const result = await asyncRequest.fetch(state.query || '', debounce)
|
|
186
|
+
|
|
187
|
+
if (result.status === 'aborted') {
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Re-check plugin state because the suggestion may have been dismissed
|
|
192
|
+
const currentPluginState = pluginKey.getState(view.state)
|
|
193
|
+
if (!currentPluginState?.active) {
|
|
194
|
+
asyncRequest.abort()
|
|
195
|
+
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
props =
|
|
200
|
+
result.status === 'resolved'
|
|
201
|
+
? {
|
|
202
|
+
...props,
|
|
203
|
+
items: result.items,
|
|
204
|
+
loading: false,
|
|
205
|
+
}
|
|
206
|
+
: {
|
|
207
|
+
...props,
|
|
208
|
+
loading: false,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (currentState === 'stopped') {
|
|
214
|
+
// stop running updates immediately and call onExit to allow the renderer to clean up
|
|
215
|
+
asyncRequest.abort()
|
|
216
|
+
dispatchStateUpdate(currentState, props)
|
|
217
|
+
props = undefined
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (currentState === 'updated') {
|
|
222
|
+
dispatchStateUpdate(currentState, props)
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
destroy: () => {
|
|
227
|
+
asyncRequest.abort()
|
|
228
|
+
|
|
229
|
+
if (!props) {
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
renderer?.onExit?.(props)
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
}
|