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