@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/src/helpers.ts ADDED
@@ -0,0 +1,129 @@
1
+ import type { Editor, Range } from '@tiptap/core'
2
+ import type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'
3
+ import type { EditorView } from '@tiptap/pm/view'
4
+
5
+ import type { SuggestionMatch } from './findSuggestionMatch.js'
6
+ import type { SuggestionOptions, SuggestionPluginState } from './types.js'
7
+
8
+ /**
9
+ * Returns true if the transaction inserted any whitespace or newline character.
10
+ * Used to determine when a dismissed suggestion should become active again.
11
+ */
12
+ export function hasInsertedWhitespace(transaction: Transaction): boolean {
13
+ if (!transaction.docChanged) {
14
+ return false
15
+ }
16
+ return transaction.steps.some(step => {
17
+ const slice = (step as any).slice
18
+ if (!slice?.content) {
19
+ return false
20
+ }
21
+ // textBetween with '\n' as block separator catches both inline spaces and newlines
22
+ const inserted = slice.content.textBetween(0, slice.content.size, '\n')
23
+ return /\s/.test(inserted)
24
+ })
25
+ }
26
+
27
+ /**
28
+ * Gets the DOM rectangle corresponding to the current editor cursor anchor position.
29
+ * Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object.
30
+ */
31
+ export function getAnchorClientRect(editor: Editor): () => DOMRect | null {
32
+ return () => {
33
+ const pos = editor.state.selection.$anchor.pos
34
+ const coords = editor.view.coordsAtPos(pos)
35
+ const { top, right, bottom, left } = coords
36
+
37
+ try {
38
+ return new DOMRect(left, top, right - left, bottom - top)
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Creates a clientRect callback for a given decoration node.
47
+ * Returns the anchor rect when no decoration node is present.
48
+ * Uses the pluginKey's state to resolve the current decoration node on demand.
49
+ */
50
+ export function clientRectFor(
51
+ editor: Editor,
52
+ view: EditorView,
53
+ decorationNode: Element | null,
54
+ pluginKey: PluginKey,
55
+ ): () => DOMRect | null {
56
+ if (!decorationNode) {
57
+ return getAnchorClientRect(editor)
58
+ }
59
+
60
+ return () => {
61
+ const state: SuggestionPluginState = pluginKey.getState(editor.state) as any
62
+ const decorationId = state?.decorationId
63
+ const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`)
64
+
65
+ return currentDecorationNode?.getBoundingClientRect() || null
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Determines whether a dismissed suggestion should stay dismissed.
71
+ * Returns `true` (keep dismissed) or `false` (allow reactivation).
72
+ */
73
+ export function shouldKeepDismissed({
74
+ match,
75
+ dismissedRange,
76
+ state,
77
+ transaction,
78
+ editor,
79
+ shouldResetDismissed,
80
+ effectiveAllowSpaces,
81
+ }: {
82
+ match: Exclude<SuggestionMatch, null>
83
+ dismissedRange: Range
84
+ state: EditorState
85
+ transaction: Transaction
86
+ editor: Editor
87
+ shouldResetDismissed?: SuggestionOptions['shouldResetDismissed']
88
+ effectiveAllowSpaces: boolean
89
+ }): boolean {
90
+ if (
91
+ shouldResetDismissed?.({
92
+ editor,
93
+ state,
94
+ range: dismissedRange,
95
+ match,
96
+ transaction,
97
+ allowSpaces: effectiveAllowSpaces,
98
+ })
99
+ ) {
100
+ return false
101
+ }
102
+
103
+ if (effectiveAllowSpaces) {
104
+ return match.range.from === dismissedRange.from
105
+ }
106
+
107
+ return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)
108
+ }
109
+
110
+ /**
111
+ * Dispatch an exit of the suggestion plugin by dispatching a metadata-only
112
+ * transaction to clear the plugin state. The renderer's onExit hook is NOT
113
+ * called here — it fires via the plugin view's stopped transition, which
114
+ * builds SuggestionProps consistently with the normal lifecycle.
115
+ *
116
+ * This prevents a double onExit call (one from dispatchExit, one from the
117
+ * view's update) and keeps exitSuggestion consistent with Escape-triggered
118
+ * exits.
119
+ */
120
+ export function dispatchExit({
121
+ view,
122
+ pluginKeyRef,
123
+ }: {
124
+ view: EditorView
125
+ pluginKeyRef: PluginKey
126
+ }): void {
127
+ const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
128
+ view.dispatch(tr)
129
+ }
@@ -0,0 +1,89 @@
1
+ import type { Editor } from '@tiptap/core'
2
+
3
+ import type { SuggestionOptions } from '../types.js'
4
+
5
+ export interface CreateSuggestionAsyncRequestManagerOptions<I = any> {
6
+ editor: Editor
7
+ items: NonNullable<SuggestionOptions<I>['items']>
8
+ }
9
+
10
+ type AsyncRequestResult<I> =
11
+ | { status: 'resolved'; items: I[] }
12
+ | { status: 'aborted' }
13
+ | { status: 'error' }
14
+
15
+ export function createSuggestionAsyncRequestManager<I = any>({
16
+ editor,
17
+ items,
18
+ }: CreateSuggestionAsyncRequestManagerOptions<I>) {
19
+ let abortController: AbortController | null = null
20
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
21
+ let debounceResolve: (() => void) | null = null
22
+
23
+ const clearDebounceTimer = () => {
24
+ if (debounceTimer !== null) {
25
+ clearTimeout(debounceTimer)
26
+ debounceTimer = null
27
+ }
28
+
29
+ debounceResolve?.()
30
+ debounceResolve = null
31
+ }
32
+
33
+ const waitForDebounce = (delay: number) => {
34
+ return new Promise<void>(resolve => {
35
+ debounceResolve = resolve
36
+ debounceTimer = setTimeout(() => {
37
+ debounceTimer = null
38
+ const pendingResolve = debounceResolve
39
+ debounceResolve = null
40
+ pendingResolve?.()
41
+ }, delay)
42
+ })
43
+ }
44
+
45
+ const abort = () => {
46
+ abortController?.abort()
47
+ clearDebounceTimer()
48
+ abortController = null
49
+ }
50
+
51
+ const fetch = async (query: string, debounce: number): Promise<AsyncRequestResult<I>> => {
52
+ abort()
53
+ abortController = new AbortController()
54
+ const controller = abortController
55
+
56
+ if (debounce > 0) {
57
+ await waitForDebounce(debounce)
58
+ }
59
+
60
+ if (abortController !== controller || controller.signal.aborted) {
61
+ return { status: 'aborted' }
62
+ }
63
+
64
+ try {
65
+ const result = await items({
66
+ editor,
67
+ query,
68
+ signal: controller.signal,
69
+ })
70
+
71
+ if (abortController !== controller || controller.signal.aborted) {
72
+ return { status: 'aborted' }
73
+ }
74
+
75
+ return { status: 'resolved', items: result }
76
+ } catch {
77
+ if (abortController !== controller || controller.signal.aborted) {
78
+ return { status: 'aborted' }
79
+ }
80
+
81
+ return { status: 'error' }
82
+ }
83
+ }
84
+
85
+ return {
86
+ abort,
87
+ fetch,
88
+ }
89
+ }
@@ -0,0 +1,204 @@
1
+ import type { Middleware, VirtualElement } from '@floating-ui/dom'
2
+ import {
3
+ autoUpdate,
4
+ computePosition,
5
+ flip as floatingUiFlip,
6
+ offset as floatingUiOffset,
7
+ } from '@floating-ui/dom'
8
+
9
+ import type {
10
+ SuggestionFloatingUiConfig,
11
+ SuggestionFloatingUiOptions,
12
+ SuggestionMount,
13
+ SuggestionPlacement,
14
+ } from '../types.js'
15
+
16
+ export interface CreateSuggestionFloatingUiConfigOptions {
17
+ placement: SuggestionPlacement
18
+ offset: { mainAxis?: number; crossAxis?: number }
19
+ flip: boolean
20
+ floatingUi?: SuggestionFloatingUiOptions
21
+ }
22
+
23
+ export function createSuggestionFloatingUiConfig({
24
+ placement,
25
+ offset,
26
+ flip,
27
+ floatingUi,
28
+ }: CreateSuggestionFloatingUiConfigOptions): SuggestionFloatingUiConfig {
29
+ const middleware: Middleware[] = [
30
+ floatingUiOffset({
31
+ mainAxis: offset.mainAxis ?? 4,
32
+ crossAxis: offset.crossAxis ?? 0,
33
+ }),
34
+ ]
35
+
36
+ if (flip) {
37
+ middleware.push(floatingUiFlip())
38
+ }
39
+
40
+ if (floatingUi?.middleware?.length) {
41
+ middleware.push(...floatingUi.middleware)
42
+ }
43
+
44
+ return {
45
+ placement,
46
+ strategy: floatingUi?.strategy ?? 'absolute',
47
+ middleware,
48
+ }
49
+ }
50
+
51
+ export interface CreateMountOptions {
52
+ /** Returns the current cursor/anchor rect the popup should track. */
53
+ getReferenceRect: () => DOMRect | null
54
+ /**
55
+ * An element inside the editor's layout/scroll context. Floating UI walks up
56
+ * from here to discover the scroll ancestors (and the window) to observe, so
57
+ * the scroll container does not need to be configured manually.
58
+ */
59
+ contextElement: Element
60
+ /** Resolved Floating UI config (placement, strategy, middleware). */
61
+ config: SuggestionFloatingUiConfig
62
+ /**
63
+ * CSS selector or element the popup should be mounted into. Defaults to
64
+ * `document.body`. Used to portal the popup inside dialogs/modals so it
65
+ * renders on top of (and clips within) the right context.
66
+ */
67
+ container?: string | HTMLElement
68
+ /**
69
+ * When `true`, a pointerdown outside both the popup and the editor dismisses
70
+ * the suggestion. Wired up and torn down alongside the mounted element.
71
+ */
72
+ dismissOnOutsideClick: boolean
73
+ /** Dismisses the active suggestion (used by outside-click handling). */
74
+ dismiss: () => void
75
+ }
76
+
77
+ /**
78
+ * Resolves a container option (selector or element) to a mount target,
79
+ * falling back to `document.body` when it can't be resolved.
80
+ */
81
+ function resolveContainer(container?: string | HTMLElement): HTMLElement {
82
+ if (container instanceof HTMLElement) {
83
+ return container
84
+ }
85
+
86
+ if (typeof container === 'string') {
87
+ try {
88
+ // `container` is consumer-provided; an invalid selector throws a
89
+ // DOMException, so fall back to document.body instead of crashing.
90
+ const found = document.querySelector<HTMLElement>(container)
91
+
92
+ if (found) {
93
+ return found
94
+ }
95
+ } catch {
96
+ return document.body
97
+ }
98
+ }
99
+
100
+ return document.body
101
+ }
102
+
103
+ /**
104
+ * Builds the `mount` function handed to the renderer on `SuggestionProps`.
105
+ *
106
+ * Mounts the popup into the container, then wires Floating UI's `autoUpdate`
107
+ * against a virtual reference that re-reads the live cursor rect, so the popup
108
+ * stays anchored across scroll, resize, and layout shifts without the consumer
109
+ * attaching any listeners. The returned `unmount` tears all of that down.
110
+ */
111
+ export function createMount({
112
+ getReferenceRect,
113
+ contextElement,
114
+ config,
115
+ container,
116
+ dismissOnOutsideClick,
117
+ dismiss,
118
+ }: CreateMountOptions): SuggestionMount {
119
+ return (element, options = {}) => {
120
+ const reference: VirtualElement = {
121
+ getBoundingClientRect: () => getReferenceRect() ?? new DOMRect(),
122
+ contextElement,
123
+ }
124
+
125
+ let positioned = false
126
+
127
+ // Mount the popup into the container (default `document.body`) unless the
128
+ // consumer already placed it in the DOM themselves — in which case we leave
129
+ // mounting (and unmounting) to them.
130
+ const mountedByUs = !element.isConnected
131
+
132
+ if (mountedByUs) {
133
+ resolveContainer(container).appendChild(element)
134
+ }
135
+
136
+ // Hide the element until the first measurement resolves so it doesn't flash
137
+ // at its initial coordinates. Skipped when the consumer owns applying the
138
+ // position via `onPosition`.
139
+ if (!options.onPosition) {
140
+ element.style.visibility = 'hidden'
141
+ element.style.width = 'max-content'
142
+ }
143
+
144
+ const update = () => {
145
+ computePosition(reference, element, {
146
+ placement: config.placement,
147
+ strategy: config.strategy,
148
+ middleware: config.middleware,
149
+ }).then(({ x, y, placement, strategy }) => {
150
+ if (options.onPosition) {
151
+ options.onPosition({ x, y, placement: placement as SuggestionPlacement, strategy })
152
+ return
153
+ }
154
+
155
+ Object.assign(element.style, {
156
+ position: strategy,
157
+ left: `${x}px`,
158
+ top: `${y}px`,
159
+ })
160
+
161
+ if (!positioned) {
162
+ positioned = true
163
+ element.style.visibility = ''
164
+ }
165
+ })
166
+ }
167
+
168
+ const cleanupAutoUpdate = autoUpdate(reference, element, update, options.autoUpdate)
169
+
170
+ // Dismiss when the user interacts outside both the popup and the editor.
171
+ // Capture phase so a parent that stops propagation can't swallow it.
172
+ let onOutsidePointerDown: ((event: PointerEvent) => void) | undefined
173
+
174
+ if (dismissOnOutsideClick) {
175
+ onOutsidePointerDown = event => {
176
+ const target = event.target
177
+
178
+ if (
179
+ !(target instanceof Node) ||
180
+ element.contains(target) ||
181
+ contextElement.contains(target)
182
+ ) {
183
+ return
184
+ }
185
+
186
+ dismiss()
187
+ }
188
+
189
+ document.addEventListener('pointerdown', onOutsidePointerDown, true)
190
+ }
191
+
192
+ return () => {
193
+ cleanupAutoUpdate()
194
+
195
+ if (onOutsidePointerDown) {
196
+ document.removeEventListener('pointerdown', onOutsidePointerDown, true)
197
+ }
198
+
199
+ if (mountedByUs) {
200
+ element.remove()
201
+ }
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,94 @@
1
+ import type { EditorState, PluginKey } from '@tiptap/pm/state'
2
+ import type { EditorView } from '@tiptap/pm/view'
3
+ import { Decoration, DecorationSet } from '@tiptap/pm/view'
4
+
5
+ import type { SuggestionKeyDownProps, SuggestionPluginState } from '../types.js'
6
+
7
+ /**
8
+ * Creates the `props` object for the suggestion ProseMirror plugin.
9
+ * Contains `handleKeyDown` for keyboard handling and `decorations`
10
+ * for rendering the suggestion highlight.
11
+ */
12
+ export interface CreateSuggestionPropsOptions {
13
+ pluginKey: PluginKey
14
+ decorationTag: string
15
+ decorationClass: string
16
+ decorationContent: string
17
+ decorationEmptyClass: string
18
+ renderer: { onKeyDown?: (props: SuggestionKeyDownProps) => boolean } | undefined
19
+ dispatchExit: (view: EditorView) => void
20
+ }
21
+
22
+ /**
23
+ * Creates the `props` object for the suggestion ProseMirror plugin.
24
+ * Contains `handleKeyDown` for keyboard handling and `decorations`
25
+ * for rendering the suggestion highlight.
26
+ */
27
+ export function createSuggestionProps({
28
+ pluginKey,
29
+ decorationTag,
30
+ decorationClass,
31
+ decorationContent,
32
+ decorationEmptyClass,
33
+ renderer,
34
+ dispatchExit,
35
+ }: CreateSuggestionPropsOptions) {
36
+ return {
37
+ /**
38
+ * Call the keydown hook if suggestion is active.
39
+ */
40
+ handleKeyDown(view: EditorView, event: KeyboardEvent) {
41
+ const state: SuggestionPluginState = pluginKey.getState(view.state) as any
42
+
43
+ if (!state.active) {
44
+ return false
45
+ }
46
+
47
+ // If Escape is pressed, call onKeyDown and dispatch a metadata-only
48
+ // transaction to unset the suggestion state. This provides a safe
49
+ // and deterministic way to exit the suggestion without altering the
50
+ // document (avoids transaction mapping/mismatch issues).
51
+ if (event.key === 'Escape' || event.key === 'Esc') {
52
+ // Allow the consumer to react to Escape, but always clear the
53
+ // suggestion state afterward so the decoration is removed too.
54
+ renderer?.onKeyDown?.({ view, event, range: state.range })
55
+
56
+ // dispatch metadata-only transaction to unset the plugin state
57
+ dispatchExit(view)
58
+
59
+ return true
60
+ }
61
+
62
+ const handled = renderer?.onKeyDown?.({ view, event, range: state.range }) || false
63
+ return handled
64
+ },
65
+
66
+ /**
67
+ * Setup decorator on the currently active suggestion.
68
+ */
69
+ decorations(state: EditorState) {
70
+ const pluginState: SuggestionPluginState = pluginKey.getState(state) as any
71
+ const { active, range, decorationId, query } = pluginState
72
+
73
+ if (!active) {
74
+ return null
75
+ }
76
+
77
+ const isEmpty = !query?.length
78
+ const classNames = [decorationClass]
79
+
80
+ if (isEmpty) {
81
+ classNames.push(decorationEmptyClass)
82
+ }
83
+
84
+ return DecorationSet.create(state.doc, [
85
+ Decoration.inline(range.from, range.to, {
86
+ nodeName: decorationTag,
87
+ class: classNames.join(' '),
88
+ 'data-decoration-id': decorationId || undefined,
89
+ 'data-decoration-content': decorationContent,
90
+ }),
91
+ ])
92
+ },
93
+ }
94
+ }
@@ -0,0 +1,182 @@
1
+ import type { Editor, Range } from '@tiptap/core'
2
+ import type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'
3
+
4
+ import type {
5
+ findSuggestionMatch as defaultFindSuggestionMatch,
6
+ SuggestionMatch,
7
+ } from '../findSuggestionMatch.js'
8
+ import type { SuggestionOptions, SuggestionPluginState } from '../types.js'
9
+
10
+ export interface CreateSuggestionStateOptions {
11
+ editor: Editor
12
+ char: string
13
+ effectiveAllowSpaces: boolean
14
+ allowToIncludeChar: boolean
15
+ allowedPrefixes: string[] | null
16
+ startOfLine: boolean
17
+ findSuggestionMatch: typeof defaultFindSuggestionMatch
18
+ allow: Exclude<SuggestionOptions['allow'], undefined>
19
+ shouldShow?: SuggestionOptions['shouldShow']
20
+ shouldKeepDismissed: (props: {
21
+ match: Exclude<SuggestionMatch, null>
22
+ dismissedRange: Range
23
+ state: EditorState
24
+ transaction: Transaction
25
+ }) => boolean
26
+ pluginKey: PluginKey
27
+ }
28
+
29
+ /**
30
+ * Creates the `state` object for the suggestion ProseMirror plugin.
31
+ * Contains `init()` and `apply()` for managing the plugin's internal state
32
+ * across transactions.
33
+ */
34
+ export function createSuggestionState({
35
+ editor,
36
+ char,
37
+ effectiveAllowSpaces,
38
+ allowToIncludeChar,
39
+ allowedPrefixes,
40
+ startOfLine,
41
+ findSuggestionMatch,
42
+ allow,
43
+ shouldShow,
44
+ shouldKeepDismissed,
45
+ pluginKey,
46
+ }: CreateSuggestionStateOptions) {
47
+ return {
48
+ /**
49
+ * Initialize the plugin's internal state.
50
+ */
51
+ init(): SuggestionPluginState {
52
+ return {
53
+ active: false,
54
+ range: { from: 0, to: 0 },
55
+ query: null,
56
+ text: null,
57
+ composing: false,
58
+ dismissedRange: null,
59
+ }
60
+ },
61
+
62
+ /**
63
+ * Apply changes to the plugin state from a view transaction.
64
+ */
65
+ apply(
66
+ transaction: Transaction,
67
+ prev: SuggestionPluginState,
68
+ _oldState: EditorState,
69
+ state: EditorState,
70
+ ): SuggestionPluginState {
71
+ const { isEditable } = editor
72
+ const { composing } = editor.view
73
+ const { selection } = transaction
74
+ const { empty, from } = selection
75
+ const next = { ...prev }
76
+
77
+ // If a transaction carries the exit meta for this plugin, immediately
78
+ // deactivate the suggestion. This allows metadata-only transactions
79
+ // (dispatched by escape or programmatic exit) to deterministically
80
+ // clear decorations without changing the document.
81
+ const meta = transaction.getMeta(pluginKey)
82
+ if (meta && meta.exit) {
83
+ next.active = false
84
+ next.decorationId = null
85
+ next.range = { from: 0, to: 0 }
86
+ next.query = null
87
+ next.text = null
88
+ next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange
89
+
90
+ return next
91
+ }
92
+
93
+ next.composing = composing
94
+
95
+ if (transaction.docChanged && next.dismissedRange !== null) {
96
+ next.dismissedRange = {
97
+ from: transaction.mapping.map(next.dismissedRange.from),
98
+ to: transaction.mapping.map(next.dismissedRange.to),
99
+ }
100
+ }
101
+
102
+ // We can only be suggesting if the view is editable, and:
103
+ // * there is no selection, or
104
+ // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)
105
+ if (isEditable && (empty || editor.view.composing)) {
106
+ // Reset active state if we just left the previous suggestion range
107
+ if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
108
+ next.active = false
109
+ }
110
+
111
+ // Try to match against where our cursor currently is
112
+ const match = findSuggestionMatch({
113
+ char,
114
+ allowSpaces: effectiveAllowSpaces,
115
+ allowToIncludeChar,
116
+ allowedPrefixes,
117
+ startOfLine,
118
+ $position: selection.$from,
119
+ })
120
+ const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`
121
+
122
+ // If we found a match, update the current state to show it
123
+ if (
124
+ match &&
125
+ allow({
126
+ editor,
127
+ state,
128
+ range: match.range,
129
+ isActive: prev.active,
130
+ }) &&
131
+ (!shouldShow ||
132
+ shouldShow({
133
+ editor,
134
+ range: match.range,
135
+ query: match.query,
136
+ text: match.text,
137
+ transaction,
138
+ }))
139
+ ) {
140
+ if (
141
+ next.dismissedRange !== null &&
142
+ !shouldKeepDismissed({
143
+ match,
144
+ dismissedRange: next.dismissedRange,
145
+ state,
146
+ transaction,
147
+ })
148
+ ) {
149
+ next.dismissedRange = null
150
+ }
151
+
152
+ if (next.dismissedRange === null) {
153
+ next.active = true
154
+ next.decorationId = prev.decorationId || decorationId
155
+ next.range = match.range
156
+ next.query = match.query
157
+ next.text = match.text
158
+ } else {
159
+ next.active = false
160
+ }
161
+ } else {
162
+ if (!match) {
163
+ next.dismissedRange = null
164
+ }
165
+ next.active = false
166
+ }
167
+ } else {
168
+ next.active = false
169
+ }
170
+
171
+ // Make sure to empty the range if suggestion is inactive
172
+ if (!next.active) {
173
+ next.decorationId = null
174
+ next.range = { from: 0, to: 0 }
175
+ next.query = null
176
+ next.text = null
177
+ }
178
+
179
+ return next
180
+ },
181
+ }
182
+ }