@tiptap/suggestion 3.26.0 → 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.
@@ -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
+ }