@tiptap/suggestion 3.21.0 → 3.22.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 +69 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +69 -33
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/suggestion.test.ts +283 -1
- package/src/suggestion.ts +104 -40
package/dist/index.cjs
CHANGED
|
@@ -77,6 +77,19 @@ function findSuggestionMatch(config) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
// src/suggestion.ts
|
|
80
|
+
function hasInsertedWhitespace(transaction) {
|
|
81
|
+
if (!transaction.docChanged) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return transaction.steps.some((step) => {
|
|
85
|
+
const slice = step.slice;
|
|
86
|
+
if (!(slice == null ? void 0 : slice.content)) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const inserted = slice.content.textBetween(0, slice.content.size, "\n");
|
|
90
|
+
return /\s/.test(inserted);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
80
93
|
var SuggestionPluginKey = new import_state.PluginKey("suggestion");
|
|
81
94
|
function Suggestion({
|
|
82
95
|
pluginKey = SuggestionPluginKey,
|
|
@@ -95,10 +108,12 @@ function Suggestion({
|
|
|
95
108
|
render = () => ({}),
|
|
96
109
|
allow = () => true,
|
|
97
110
|
findSuggestionMatch: findSuggestionMatch2 = findSuggestionMatch,
|
|
98
|
-
shouldShow
|
|
111
|
+
shouldShow,
|
|
112
|
+
shouldResetDismissed
|
|
99
113
|
}) {
|
|
100
114
|
let props;
|
|
101
115
|
const renderer = render == null ? void 0 : render();
|
|
116
|
+
const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar;
|
|
102
117
|
const getAnchorClientRect = () => {
|
|
103
118
|
const pos = editor.state.selection.$anchor.pos;
|
|
104
119
|
const coords = editor.view.coordsAtPos(pos);
|
|
@@ -120,6 +135,27 @@ function Suggestion({
|
|
|
120
135
|
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
|
|
121
136
|
};
|
|
122
137
|
};
|
|
138
|
+
const shouldKeepDismissed = ({
|
|
139
|
+
match,
|
|
140
|
+
dismissedRange,
|
|
141
|
+
state,
|
|
142
|
+
transaction
|
|
143
|
+
}) => {
|
|
144
|
+
if (shouldResetDismissed == null ? void 0 : shouldResetDismissed({
|
|
145
|
+
editor,
|
|
146
|
+
state,
|
|
147
|
+
range: dismissedRange,
|
|
148
|
+
match,
|
|
149
|
+
transaction,
|
|
150
|
+
allowSpaces: effectiveAllowSpaces
|
|
151
|
+
})) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (effectiveAllowSpaces) {
|
|
155
|
+
return match.range.from === dismissedRange.from;
|
|
156
|
+
}
|
|
157
|
+
return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction);
|
|
158
|
+
};
|
|
123
159
|
function dispatchExit(view, pluginKeyRef) {
|
|
124
160
|
var _a;
|
|
125
161
|
try {
|
|
@@ -222,7 +258,8 @@ function Suggestion({
|
|
|
222
258
|
},
|
|
223
259
|
query: null,
|
|
224
260
|
text: null,
|
|
225
|
-
composing: false
|
|
261
|
+
composing: false,
|
|
262
|
+
dismissedRange: null
|
|
226
263
|
};
|
|
227
264
|
return state;
|
|
228
265
|
},
|
|
@@ -240,9 +277,16 @@ function Suggestion({
|
|
|
240
277
|
next.range = { from: 0, to: 0 };
|
|
241
278
|
next.query = null;
|
|
242
279
|
next.text = null;
|
|
280
|
+
next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange;
|
|
243
281
|
return next;
|
|
244
282
|
}
|
|
245
283
|
next.composing = composing;
|
|
284
|
+
if (transaction.docChanged && next.dismissedRange !== null) {
|
|
285
|
+
next.dismissedRange = {
|
|
286
|
+
from: transaction.mapping.map(next.dismissedRange.from),
|
|
287
|
+
to: transaction.mapping.map(next.dismissedRange.to)
|
|
288
|
+
};
|
|
289
|
+
}
|
|
246
290
|
if (isEditable && (empty || editor.view.composing)) {
|
|
247
291
|
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
|
|
248
292
|
next.active = false;
|
|
@@ -268,12 +312,27 @@ function Suggestion({
|
|
|
268
312
|
text: match.text,
|
|
269
313
|
transaction
|
|
270
314
|
}))) {
|
|
271
|
-
next.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
315
|
+
if (next.dismissedRange !== null && !shouldKeepDismissed({
|
|
316
|
+
match,
|
|
317
|
+
dismissedRange: next.dismissedRange,
|
|
318
|
+
state,
|
|
319
|
+
transaction
|
|
320
|
+
})) {
|
|
321
|
+
next.dismissedRange = null;
|
|
322
|
+
}
|
|
323
|
+
if (next.dismissedRange === null) {
|
|
324
|
+
next.active = true;
|
|
325
|
+
next.decorationId = prev.decorationId ? prev.decorationId : decorationId;
|
|
326
|
+
next.range = match.range;
|
|
327
|
+
next.query = match.query;
|
|
328
|
+
next.text = match.text;
|
|
329
|
+
} else {
|
|
330
|
+
next.active = false;
|
|
331
|
+
}
|
|
276
332
|
} else {
|
|
333
|
+
if (!match) {
|
|
334
|
+
next.dismissedRange = null;
|
|
335
|
+
}
|
|
277
336
|
next.active = false;
|
|
278
337
|
}
|
|
279
338
|
} else {
|
|
@@ -291,41 +350,18 @@ function Suggestion({
|
|
|
291
350
|
props: {
|
|
292
351
|
// Call the keydown hook if suggestion is active.
|
|
293
352
|
handleKeyDown(view, event) {
|
|
294
|
-
var _a, _b
|
|
353
|
+
var _a, _b;
|
|
295
354
|
const { active, range } = plugin.getState(view.state);
|
|
296
355
|
if (!active) {
|
|
297
356
|
return false;
|
|
298
357
|
}
|
|
299
358
|
if (event.key === "Escape" || event.key === "Esc") {
|
|
300
359
|
const state = plugin.getState(view.state);
|
|
301
|
-
|
|
302
|
-
const decorationNode = cachedNode != null ? cachedNode : (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null;
|
|
303
|
-
const handledByKeyDown = ((_b = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _b.call(renderer, { view, event, range: state.range })) || false;
|
|
304
|
-
if (handledByKeyDown) {
|
|
305
|
-
return true;
|
|
306
|
-
}
|
|
307
|
-
const exitProps = {
|
|
308
|
-
editor,
|
|
309
|
-
range: state.range,
|
|
310
|
-
query: state.query,
|
|
311
|
-
text: state.text,
|
|
312
|
-
items: [],
|
|
313
|
-
command: (commandProps) => {
|
|
314
|
-
return command({ editor, range: state.range, props: commandProps });
|
|
315
|
-
},
|
|
316
|
-
decorationNode,
|
|
317
|
-
// If we have a cached decoration node, use it for the clientRect
|
|
318
|
-
// to avoid another DOM lookup. If not, leave clientRect null and
|
|
319
|
-
// let consumer decide if they want to query.
|
|
320
|
-
clientRect: decorationNode ? () => {
|
|
321
|
-
return decorationNode.getBoundingClientRect() || null;
|
|
322
|
-
} : null
|
|
323
|
-
};
|
|
324
|
-
(_c = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _c.call(renderer, exitProps);
|
|
360
|
+
(_a = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _a.call(renderer, { view, event, range: state.range });
|
|
325
361
|
dispatchExit(view, pluginKey);
|
|
326
362
|
return true;
|
|
327
363
|
}
|
|
328
|
-
const handled = ((
|
|
364
|
+
const handled = ((_b = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _b.call(renderer, { view, event, range })) || false;
|
|
329
365
|
return handled;
|
|
330
366
|
},
|
|
331
367
|
// Setup decorator on the currently active suggestion.
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/suggestion.ts","../src/findSuggestionMatch.ts"],"sourcesContent":["import { exitSuggestion, Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport { exitSuggestion }\n\nexport default Suggestion\n","import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nimport { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'\n\nexport interface SuggestionOptions<I = any, TSelected = any> {\n /**\n * The plugin key for the suggestion plugin.\n * @default 'suggestion'\n * @example 'mention'\n */\n pluginKey?: PluginKey\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * This is useful to prevent suggestions from opening for remote users in collaborative environments.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.query The current suggestion query.\n * @param props.text The current suggestion text.\n * @param props.transaction The current transaction.\n * @returns {boolean}\n * @example ({ transaction }) => isChangeOrigin(transaction)\n */\n shouldShow?: (props: {\n editor: Editor\n range: Range\n query: string\n text: string\n transaction: Transaction\n }) => boolean\n\n /**\n * The editor instance.\n * @default null\n */\n editor: Editor\n\n /**\n * The character that triggers the suggestion.\n * @default '@'\n * @example '#'\n */\n char?: string\n\n /**\n * Allow spaces in the suggestion query. Not compatible with `allowToIncludeChar`. Will be disabled if `allowToIncludeChar` is set to `true`.\n * @default false\n * @example true\n */\n allowSpaces?: boolean\n\n /**\n * Allow the character to be included in the suggestion query. Not compatible with `allowSpaces`.\n * @default false\n */\n allowToIncludeChar?: boolean\n\n /**\n * Allow prefixes in the suggestion query.\n * @default [' ']\n * @example [' ', '@']\n */\n allowedPrefixes?: string[] | null\n\n /**\n * Only match suggestions at the start of the line.\n * @default false\n * @example true\n */\n startOfLine?: boolean\n\n /**\n * The tag name of the decoration node.\n * @default 'span'\n * @example 'div'\n */\n decorationTag?: string\n\n /**\n * The class name of the decoration node.\n * @default 'suggestion'\n * @example 'mention'\n */\n decorationClass?: string\n\n /**\n * Creates a decoration with the provided content.\n * @param decorationContent - The content to display in the decoration\n * @default \"\" - Creates an empty decoration if no content provided\n */\n decorationContent?: string\n\n /**\n * The class name of the decoration node when it is empty.\n * @default 'is-empty'\n * @example 'is-empty'\n */\n decorationEmptyClass?: string\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.props The props of the selected suggestion.\n * @returns void\n * @example ({ editor, range, props }) => { props.command(props.props) }\n */\n command?: (props: { editor: Editor; range: Range; props: TSelected }) => void\n\n /**\n * A function that returns the suggestion items in form of an array.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.query The current suggestion query.\n * @returns An array of suggestion items.\n * @example ({ editor, query }) => [{ id: 1, label: 'John Doe' }]\n */\n items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>\n\n /**\n * The render function for the suggestion.\n * @returns An object with render functions.\n */\n render?: () => {\n onBeforeStart?: (props: SuggestionProps<I, TSelected>) => void\n onStart?: (props: SuggestionProps<I, TSelected>) => void\n onBeforeUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onExit?: (props: SuggestionProps<I, TSelected>) => void\n onKeyDown?: (props: SuggestionKeyDownProps) => boolean\n }\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * @param props The props object.\n * @returns {boolean}\n */\n allow?: (props: { editor: Editor; state: EditorState; range: Range; isActive?: boolean }) => boolean\n findSuggestionMatch?: typeof defaultFindSuggestionMatch\n}\n\nexport interface SuggestionProps<I = any, TSelected = any> {\n /**\n * The editor instance.\n */\n editor: Editor\n\n /**\n * The range of the suggestion.\n */\n range: Range\n\n /**\n * The current suggestion query.\n */\n query: string\n\n /**\n * The current suggestion text.\n */\n text: string\n\n /**\n * The suggestion items array.\n */\n items: I[]\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @returns void\n */\n command: (props: TSelected) => void\n\n /**\n * The decoration node HTML element\n * @default null\n */\n decorationNode: Element | null\n\n /**\n * The function that returns the client rect\n * @default null\n * @example () => new DOMRect(0, 0, 0, 0)\n */\n clientRect?: (() => DOMRect | null) | null\n}\n\nexport interface SuggestionKeyDownProps {\n view: EditorView\n event: KeyboardEvent\n range: Range\n}\n\nexport const SuggestionPluginKey = new PluginKey('suggestion')\n\n/**\n * This utility allows you to create suggestions.\n * @see https://tiptap.dev/api/utilities/suggestion\n */\nexport function Suggestion<I = any, TSelected = any>({\n pluginKey = SuggestionPluginKey,\n editor,\n char = '@',\n allowSpaces = false,\n allowToIncludeChar = false,\n allowedPrefixes = [' '],\n startOfLine = false,\n decorationTag = 'span',\n decorationClass = 'suggestion',\n decorationContent = '',\n decorationEmptyClass = 'is-empty',\n command = () => null,\n items = () => [],\n render = () => ({}),\n allow = () => true,\n findSuggestionMatch = defaultFindSuggestionMatch,\n shouldShow,\n}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\n\n // Gets the DOM rectangle corresponding to the current editor cursor anchor position\n // Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object\n const getAnchorClientRect = () => {\n const pos = editor.state.selection.$anchor.pos\n const coords = editor.view.coordsAtPos(pos)\n const { top, right, bottom, left } = coords\n\n try {\n return new DOMRect(left, top, right - left, bottom - top)\n } catch {\n return null\n }\n }\n\n // Helper to create a clientRect callback for a given decoration node.\n // Returns null when no decoration node is present. Uses the pluginKey's\n // state to resolve the current decoration node on demand, avoiding a\n // duplicated implementation in multiple places.\n const clientRectFor = (view: EditorView, decorationNode: Element | null) => {\n if (!decorationNode) {\n return getAnchorClientRect\n }\n\n return () => {\n const state = pluginKey.getState(editor.state)\n const decorationId = state?.decorationId\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n }\n // small helper used internally by the view to dispatch an exit\n function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {\n try {\n const state = pluginKey.getState(view.state)\n const decorationNode = state?.decorationId\n ? view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n : null\n\n const exitProps: SuggestionProps = {\n // @ts-ignore editor is available in closure\n editor,\n range: state?.range || { from: 0, to: 0 },\n query: state?.query || null,\n text: state?.text || null,\n items: [],\n command: commandProps => {\n return command({ editor, range: state?.range || { from: 0, to: 0 }, props: commandProps as any })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n renderer?.onExit?.(exitProps)\n } catch {\n // ignore errors from consumer renderers\n }\n\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n // Dispatch a metadata-only transaction to signal the plugin to exit\n view.dispatch(tr)\n }\n\n const plugin: Plugin<any> = new Plugin({\n key: pluginKey,\n\n view() {\n return {\n update: async (view, prevState) => {\n const prev = this.key?.getState(prevState)\n const next = this.key?.getState(view.state)\n\n // See how the state changed\n const moved = prev.active && next.active && prev.range.from !== next.range.from\n const started = !prev.active && next.active\n const stopped = prev.active && !next.active\n const changed = !started && !stopped && prev.query !== next.query\n\n const handleStart = started || (moved && changed)\n const handleChange = changed || moved\n const handleExit = stopped || (moved && changed)\n\n // Cancel when suggestion isn't active\n if (!handleStart && !handleChange && !handleExit) {\n return\n }\n\n const state = handleExit && !handleStart ? prev : next\n const decorationNode = view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n\n props = {\n editor,\n range: state.range,\n query: state.query,\n text: state.text,\n items: [],\n command: commandProps => {\n return command({\n editor,\n range: state.range,\n props: commandProps,\n })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n if (handleStart) {\n renderer?.onBeforeStart?.(props)\n }\n\n if (handleChange) {\n renderer?.onBeforeUpdate?.(props)\n }\n\n if (handleChange || handleStart) {\n props.items = await items({\n editor,\n query: state.query,\n })\n }\n\n if (handleExit) {\n renderer?.onExit?.(props)\n }\n\n if (handleChange) {\n renderer?.onUpdate?.(props)\n }\n\n if (handleStart) {\n renderer?.onStart?.(props)\n }\n },\n\n destroy: () => {\n if (!props) {\n return\n }\n\n renderer?.onExit?.(props)\n },\n }\n },\n\n state: {\n // Initialize the plugin's internal state.\n init() {\n const state: {\n active: boolean\n range: Range\n query: null | string\n text: null | string\n composing: boolean\n decorationId?: string | null\n } = {\n active: false,\n range: {\n from: 0,\n to: 0,\n },\n query: null,\n text: null,\n composing: false,\n }\n\n return state\n },\n\n // Apply changes to the plugin state from a view transaction.\n apply(transaction, prev, _oldState, state) {\n const { isEditable } = editor\n const { composing } = editor.view\n const { selection } = transaction\n const { empty, from } = selection\n const next = { ...prev }\n\n // If a transaction carries the exit meta for this plugin, immediately\n // deactivate the suggestion. This allows metadata-only transactions\n // (dispatched by escape or programmatic exit) to deterministically\n // clear decorations without changing the document.\n const meta = transaction.getMeta(pluginKey)\n if (meta && meta.exit) {\n next.active = false\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n\n return next\n }\n\n next.composing = composing\n\n // We can only be suggesting if the view is editable, and:\n // * there is no selection, or\n // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)\n if (isEditable && (empty || editor.view.composing)) {\n // Reset active state if we just left the previous suggestion range\n if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {\n next.active = false\n }\n\n // Try to match against where our cursor currently is\n const match = findSuggestionMatch({\n char,\n allowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position: selection.$from,\n })\n const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`\n\n // If we found a match, update the current state to show it\n if (\n match &&\n allow({\n editor,\n state,\n range: match.range,\n isActive: prev.active,\n }) &&\n (!shouldShow ||\n shouldShow({\n editor,\n range: match.range,\n query: match.query,\n text: match.text,\n transaction,\n }))\n ) {\n next.active = true\n next.decorationId = prev.decorationId ? prev.decorationId : decorationId\n next.range = match.range\n next.query = match.query\n next.text = match.text\n } else {\n next.active = false\n }\n } else {\n next.active = false\n }\n\n // Make sure to empty the range if suggestion is inactive\n if (!next.active) {\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n }\n\n return next\n },\n },\n\n props: {\n // Call the keydown hook if suggestion is active.\n handleKeyDown(view, event) {\n const { active, range } = plugin.getState(view.state)\n\n if (!active) {\n return false\n }\n\n // If Escape is pressed, call onExit and dispatch a metadata-only\n // transaction to unset the suggestion state. This provides a safe\n // and deterministic way to exit the suggestion without altering the\n // document (avoids transaction mapping/mismatch issues).\n if (event.key === 'Escape' || event.key === 'Esc') {\n const state = plugin.getState(view.state)\n const cachedNode = props?.decorationNode ?? null\n const decorationNode =\n cachedNode ??\n (state?.decorationId ? view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`) : null)\n\n // Give the consumer a chance to handle Escape via onKeyDown first.\n // If the consumer returns `true` we assume they handled the event and\n // we won't call onExit/dispatchExit so they can both prevent\n // propagation and decide whether to close the suggestion themselves.\n const handledByKeyDown = renderer?.onKeyDown?.({ view, event, range: state.range }) || false\n\n if (handledByKeyDown) {\n return true\n }\n\n const exitProps: SuggestionProps = {\n editor,\n range: state.range,\n query: state.query,\n text: state.text,\n items: [],\n command: commandProps => {\n return command({ editor, range: state.range, props: commandProps as any })\n },\n decorationNode,\n // If we have a cached decoration node, use it for the clientRect\n // to avoid another DOM lookup. If not, leave clientRect null and\n // let consumer decide if they want to query.\n clientRect: decorationNode\n ? () => {\n return decorationNode.getBoundingClientRect() || null\n }\n : null,\n }\n\n renderer?.onExit?.(exitProps)\n\n // dispatch metadata-only transaction to unset the plugin state\n dispatchExit(view, pluginKey)\n\n return true\n }\n\n const handled = renderer?.onKeyDown?.({ view, event, range }) || false\n return handled\n },\n\n // Setup decorator on the currently active suggestion.\n decorations(state) {\n const { active, range, decorationId, query } = plugin.getState(state)\n\n if (!active) {\n return null\n }\n\n const isEmpty = !query?.length\n const classNames = [decorationClass]\n\n if (isEmpty) {\n classNames.push(decorationEmptyClass)\n }\n\n return DecorationSet.create(state.doc, [\n Decoration.inline(range.from, range.to, {\n nodeName: decorationTag,\n class: classNames.join(' '),\n 'data-decoration-id': decorationId,\n 'data-decoration-content': decorationContent,\n }),\n ])\n },\n },\n })\n\n return plugin\n}\n\n/**\n * Programmatically exit a suggestion plugin by dispatching a metadata-only\n * transaction. This is the safe, recommended API to remove suggestion\n * decorations without touching the document or causing mapping errors.\n */\nexport function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { Range } from '@tiptap/core'\nimport { escapeForRegEx } from '@tiptap/core'\nimport type { ResolvedPos } from '@tiptap/pm/model'\n\nexport interface Trigger {\n char: string\n allowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n $position: ResolvedPos\n}\n\nexport type SuggestionMatch = {\n range: Range\n query: string\n text: string\n} | null\n\nexport function findSuggestionMatch(config: Trigger): SuggestionMatch {\n const { char, allowSpaces: allowSpacesOption, allowToIncludeChar, allowedPrefixes, startOfLine, $position } = config\n\n const allowSpaces = allowSpacesOption && !allowToIncludeChar\n\n const escapedChar = escapeForRegEx(char)\n const suffix = new RegExp(`\\\\s${escapedChar}$`)\n const prefix = startOfLine ? '^' : ''\n const finalEscapedChar = allowToIncludeChar ? '' : escapedChar\n const regexp = allowSpaces\n ? new RegExp(`${prefix}${escapedChar}.*?(?=\\\\s${finalEscapedChar}|$)`, 'gm')\n : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\\\s${finalEscapedChar}]*`, 'gm')\n\n const text = $position.nodeBefore?.isText && $position.nodeBefore.text\n\n if (!text) {\n return null\n }\n\n const textFrom = $position.pos - text.length\n const match = Array.from(text.matchAll(regexp)).pop()\n\n if (!match || match.input === undefined || match.index === undefined) {\n return null\n }\n\n // JavaScript doesn't have lookbehinds. This hacks a check that first character\n // is a space or the start of the line\n const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)\n const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join('')}\\0]?$`).test(matchPrefix)\n\n if (allowedPrefixes !== null && !matchPrefixIsAllowed) {\n return null\n }\n\n // The absolute position of the match in the document\n const from = textFrom + match.index\n let to = from + match[0].length\n\n // Edge case handling; if spaces are allowed and we're directly in between\n // two triggers\n if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {\n match[0] += ' '\n to += 1\n }\n\n // If the $position is located within the matched substring, return that range\n if (from < $position.pos && to >= $position.pos) {\n return {\n range: {\n from,\n to,\n },\n query: match[0].slice(char.length),\n text: match[0],\n }\n }\n\n return null\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACEA,mBAAkC;AAElC,kBAA0C;;;ACH1C,kBAA+B;AAkBxB,SAAS,oBAAoB,QAAkC;AAnBtE;AAoBE,QAAM,EAAE,MAAM,aAAa,mBAAmB,oBAAoB,iBAAiB,aAAa,UAAU,IAAI;AAE9G,QAAM,cAAc,qBAAqB,CAAC;AAE1C,QAAM,kBAAc,4BAAe,IAAI;AACvC,QAAM,SAAS,IAAI,OAAO,MAAM,WAAW,GAAG;AAC9C,QAAM,SAAS,cAAc,MAAM;AACnC,QAAM,mBAAmB,qBAAqB,KAAK;AACnD,QAAM,SAAS,cACX,IAAI,OAAO,GAAG,MAAM,GAAG,WAAW,YAAY,gBAAgB,OAAO,IAAI,IACzE,IAAI,OAAO,GAAG,MAAM,SAAS,WAAW,QAAQ,gBAAgB,MAAM,IAAI;AAE9E,QAAM,SAAO,eAAU,eAAV,mBAAsB,WAAU,UAAU,WAAW;AAElE,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,UAAU,MAAM,KAAK;AACtC,QAAM,QAAQ,MAAM,KAAK,KAAK,SAAS,MAAM,CAAC,EAAE,IAAI;AAEpD,MAAI,CAAC,SAAS,MAAM,UAAU,UAAa,MAAM,UAAU,QAAW;AACpE,WAAO;AAAA,EACT;AAIA,QAAM,cAAc,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG,MAAM,QAAQ,CAAC,GAAG,MAAM,KAAK;AAC/E,QAAM,uBAAuB,IAAI,OAAO,KAAK,mDAAiB,KAAK,GAAG,OAAO,EAAE,KAAK,WAAW;AAE/F,MAAI,oBAAoB,QAAQ,CAAC,sBAAsB;AACrD,WAAO;AAAA,EACT;AAGA,QAAM,OAAO,WAAW,MAAM;AAC9B,MAAI,KAAK,OAAO,MAAM,CAAC,EAAE;AAIzB,MAAI,eAAe,OAAO,KAAK,KAAK,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,GAAG;AAC1D,UAAM,CAAC,KAAK;AACZ,UAAM;AAAA,EACR;AAGA,MAAI,OAAO,UAAU,OAAO,MAAM,UAAU,KAAK;AAC/C,WAAO;AAAA,MACL,OAAO;AAAA,QACL;AAAA,QACA;AAAA,MACF;AAAA,MACA,OAAO,MAAM,CAAC,EAAE,MAAM,KAAK,MAAM;AAAA,MACjC,MAAM,MAAM,CAAC;AAAA,IACf;AAAA,EACF;AAEA,SAAO;AACT;;;AD0HO,IAAM,sBAAsB,IAAI,uBAAU,YAAY;AAMtD,SAAS,WAAqC;AAAA,EACnD,YAAY;AAAA,EACZ;AAAA,EACA,OAAO;AAAA,EACP,cAAc;AAAA,EACd,qBAAqB;AAAA,EACrB,kBAAkB,CAAC,GAAG;AAAA,EACtB,cAAc;AAAA,EACd,gBAAgB;AAAA,EAChB,kBAAkB;AAAA,EAClB,oBAAoB;AAAA,EACpB,uBAAuB;AAAA,EACvB,UAAU,MAAM;AAAA,EAChB,QAAQ,MAAM,CAAC;AAAA,EACf,SAAS,OAAO,CAAC;AAAA,EACjB,QAAQ,MAAM;AAAA,EACd,qBAAAA,uBAAsB;AAAA,EACtB;AACF,GAAoC;AAClC,MAAI;AACJ,QAAM,WAAW;AAIjB,QAAM,sBAAsB,MAAM;AAChC,UAAM,MAAM,OAAO,MAAM,UAAU,QAAQ;AAC3C,UAAM,SAAS,OAAO,KAAK,YAAY,GAAG;AAC1C,UAAM,EAAE,KAAK,OAAO,QAAQ,KAAK,IAAI;AAErC,QAAI;AACF,aAAO,IAAI,QAAQ,MAAM,KAAK,QAAQ,MAAM,SAAS,GAAG;AAAA,IAC1D,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAMA,QAAM,gBAAgB,CAAC,MAAkB,mBAAmC;AAC1E,QAAI,CAAC,gBAAgB;AACnB,aAAO;AAAA,IACT;AAEA,WAAO,MAAM;AACX,YAAM,QAAQ,UAAU,SAAS,OAAO,KAAK;AAC7C,YAAM,eAAe,+BAAO;AAC5B,YAAM,wBAAwB,KAAK,IAAI,cAAc,wBAAwB,YAAY,IAAI;AAE7F,cAAO,+DAAuB,4BAA2B;AAAA,IAC3D;AAAA,EACF;AAEA,WAAS,aAAa,MAAkB,cAAyB;AApQnE;AAqQI,QAAI;AACF,YAAM,QAAQ,UAAU,SAAS,KAAK,KAAK;AAC3C,YAAM,kBAAiB,+BAAO,gBAC1B,KAAK,IAAI,cAAc,wBAAwB,MAAM,YAAY,IAAI,IACrE;AAEJ,YAAM,YAA6B;AAAA;AAAA,QAEjC;AAAA,QACA,QAAO,+BAAO,UAAS,EAAE,MAAM,GAAG,IAAI,EAAE;AAAA,QACxC,QAAO,+BAAO,UAAS;AAAA,QACvB,OAAM,+BAAO,SAAQ;AAAA,QACrB,OAAO,CAAC;AAAA,QACR,SAAS,kBAAgB;AACvB,iBAAO,QAAQ,EAAE,QAAQ,QAAO,+BAAO,UAAS,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,OAAO,aAAoB,CAAC;AAAA,QAClG;AAAA,QACA;AAAA,QACA,YAAY,cAAc,MAAM,cAAc;AAAA,MAChD;AAEA,iDAAU,WAAV,kCAAmB;AAAA,IACrB,QAAQ;AAAA,IAER;AAEA,UAAM,KAAK,KAAK,MAAM,GAAG,QAAQ,cAAc,EAAE,MAAM,KAAK,CAAC;AAE7D,SAAK,SAAS,EAAE;AAAA,EAClB;AAEA,QAAM,SAAsB,IAAI,oBAAO;AAAA,IACrC,KAAK;AAAA,IAEL,OAAO;AACL,aAAO;AAAA,QACL,QAAQ,OAAO,MAAM,cAAc;AAxS3C;AAySU,gBAAM,QAAO,UAAK,QAAL,mBAAU,SAAS;AAChC,gBAAM,QAAO,UAAK,QAAL,mBAAU,SAAS,KAAK;AAGrC,gBAAM,QAAQ,KAAK,UAAU,KAAK,UAAU,KAAK,MAAM,SAAS,KAAK,MAAM;AAC3E,gBAAM,UAAU,CAAC,KAAK,UAAU,KAAK;AACrC,gBAAM,UAAU,KAAK,UAAU,CAAC,KAAK;AACrC,gBAAM,UAAU,CAAC,WAAW,CAAC,WAAW,KAAK,UAAU,KAAK;AAE5D,gBAAM,cAAc,WAAY,SAAS;AACzC,gBAAM,eAAe,WAAW;AAChC,gBAAM,aAAa,WAAY,SAAS;AAGxC,cAAI,CAAC,eAAe,CAAC,gBAAgB,CAAC,YAAY;AAChD;AAAA,UACF;AAEA,gBAAM,QAAQ,cAAc,CAAC,cAAc,OAAO;AAClD,gBAAM,iBAAiB,KAAK,IAAI,cAAc,wBAAwB,MAAM,YAAY,IAAI;AAE5F,kBAAQ;AAAA,YACN;AAAA,YACA,OAAO,MAAM;AAAA,YACb,OAAO,MAAM;AAAA,YACb,MAAM,MAAM;AAAA,YACZ,OAAO,CAAC;AAAA,YACR,SAAS,kBAAgB;AACvB,qBAAO,QAAQ;AAAA,gBACb;AAAA,gBACA,OAAO,MAAM;AAAA,gBACb,OAAO;AAAA,cACT,CAAC;AAAA,YACH;AAAA,YACA;AAAA,YACA,YAAY,cAAc,MAAM,cAAc;AAAA,UAChD;AAEA,cAAI,aAAa;AACf,uDAAU,kBAAV,kCAA0B;AAAA,UAC5B;AAEA,cAAI,cAAc;AAChB,uDAAU,mBAAV,kCAA2B;AAAA,UAC7B;AAEA,cAAI,gBAAgB,aAAa;AAC/B,kBAAM,QAAQ,MAAM,MAAM;AAAA,cACxB;AAAA,cACA,OAAO,MAAM;AAAA,YACf,CAAC;AAAA,UACH;AAEA,cAAI,YAAY;AACd,uDAAU,WAAV,kCAAmB;AAAA,UACrB;AAEA,cAAI,cAAc;AAChB,uDAAU,aAAV,kCAAqB;AAAA,UACvB;AAEA,cAAI,aAAa;AACf,uDAAU,YAAV,kCAAoB;AAAA,UACtB;AAAA,QACF;AAAA,QAEA,SAAS,MAAM;AA3WvB;AA4WU,cAAI,CAAC,OAAO;AACV;AAAA,UACF;AAEA,qDAAU,WAAV,kCAAmB;AAAA,QACrB;AAAA,MACF;AAAA,IACF;AAAA,IAEA,OAAO;AAAA;AAAA,MAEL,OAAO;AACL,cAAM,QAOF;AAAA,UACF,QAAQ;AAAA,UACR,OAAO;AAAA,YACL,MAAM;AAAA,YACN,IAAI;AAAA,UACN;AAAA,UACA,OAAO;AAAA,UACP,MAAM;AAAA,UACN,WAAW;AAAA,QACb;AAEA,eAAO;AAAA,MACT;AAAA;AAAA,MAGA,MAAM,aAAa,MAAM,WAAW,OAAO;AACzC,cAAM,EAAE,WAAW,IAAI;AACvB,cAAM,EAAE,UAAU,IAAI,OAAO;AAC7B,cAAM,EAAE,UAAU,IAAI;AACtB,cAAM,EAAE,OAAO,KAAK,IAAI;AACxB,cAAM,OAAO,EAAE,GAAG,KAAK;AAMvB,cAAM,OAAO,YAAY,QAAQ,SAAS;AAC1C,YAAI,QAAQ,KAAK,MAAM;AACrB,eAAK,SAAS;AACd,eAAK,eAAe;AACpB,eAAK,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE;AAC9B,eAAK,QAAQ;AACb,eAAK,OAAO;AAEZ,iBAAO;AAAA,QACT;AAEA,aAAK,YAAY;AAKjB,YAAI,eAAe,SAAS,OAAO,KAAK,YAAY;AAElD,eAAK,OAAO,KAAK,MAAM,QAAQ,OAAO,KAAK,MAAM,OAAO,CAAC,aAAa,CAAC,KAAK,WAAW;AACrF,iBAAK,SAAS;AAAA,UAChB;AAGA,gBAAM,QAAQA,qBAAoB;AAAA,YAChC;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA,WAAW,UAAU;AAAA,UACvB,CAAC;AACD,gBAAM,eAAe,MAAM,KAAK,MAAM,KAAK,OAAO,IAAI,UAAU,CAAC;AAGjE,cACE,SACA,MAAM;AAAA,YACJ;AAAA,YACA;AAAA,YACA,OAAO,MAAM;AAAA,YACb,UAAU,KAAK;AAAA,UACjB,CAAC,MACA,CAAC,cACA,WAAW;AAAA,YACT;AAAA,YACA,OAAO,MAAM;AAAA,YACb,OAAO,MAAM;AAAA,YACb,MAAM,MAAM;AAAA,YACZ;AAAA,UACF,CAAC,IACH;AACA,iBAAK,SAAS;AACd,iBAAK,eAAe,KAAK,eAAe,KAAK,eAAe;AAC5D,iBAAK,QAAQ,MAAM;AACnB,iBAAK,QAAQ,MAAM;AACnB,iBAAK,OAAO,MAAM;AAAA,UACpB,OAAO;AACL,iBAAK,SAAS;AAAA,UAChB;AAAA,QACF,OAAO;AACL,eAAK,SAAS;AAAA,QAChB;AAGA,YAAI,CAAC,KAAK,QAAQ;AAChB,eAAK,eAAe;AACpB,eAAK,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE;AAC9B,eAAK,QAAQ;AACb,eAAK,OAAO;AAAA,QACd;AAEA,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,OAAO;AAAA;AAAA,MAEL,cAAc,MAAM,OAAO;AAtejC;AAueQ,cAAM,EAAE,QAAQ,MAAM,IAAI,OAAO,SAAS,KAAK,KAAK;AAEpD,YAAI,CAAC,QAAQ;AACX,iBAAO;AAAA,QACT;AAMA,YAAI,MAAM,QAAQ,YAAY,MAAM,QAAQ,OAAO;AACjD,gBAAM,QAAQ,OAAO,SAAS,KAAK,KAAK;AACxC,gBAAM,cAAa,oCAAO,mBAAP,YAAyB;AAC5C,gBAAM,iBACJ,mCACC,+BAAO,gBAAe,KAAK,IAAI,cAAc,wBAAwB,MAAM,YAAY,IAAI,IAAI;AAMlG,gBAAM,qBAAmB,0CAAU,cAAV,kCAAsB,EAAE,MAAM,OAAO,OAAO,MAAM,MAAM,OAAM;AAEvF,cAAI,kBAAkB;AACpB,mBAAO;AAAA,UACT;AAEA,gBAAM,YAA6B;AAAA,YACjC;AAAA,YACA,OAAO,MAAM;AAAA,YACb,OAAO,MAAM;AAAA,YACb,MAAM,MAAM;AAAA,YACZ,OAAO,CAAC;AAAA,YACR,SAAS,kBAAgB;AACvB,qBAAO,QAAQ,EAAE,QAAQ,OAAO,MAAM,OAAO,OAAO,aAAoB,CAAC;AAAA,YAC3E;AAAA,YACA;AAAA;AAAA;AAAA;AAAA,YAIA,YAAY,iBACR,MAAM;AACJ,qBAAO,eAAe,sBAAsB,KAAK;AAAA,YACnD,IACA;AAAA,UACN;AAEA,qDAAU,WAAV,kCAAmB;AAGnB,uBAAa,MAAM,SAAS;AAE5B,iBAAO;AAAA,QACT;AAEA,cAAM,YAAU,0CAAU,cAAV,kCAAsB,EAAE,MAAM,OAAO,MAAM,OAAM;AACjE,eAAO;AAAA,MACT;AAAA;AAAA,MAGA,YAAY,OAAO;AACjB,cAAM,EAAE,QAAQ,OAAO,cAAc,MAAM,IAAI,OAAO,SAAS,KAAK;AAEpE,YAAI,CAAC,QAAQ;AACX,iBAAO;AAAA,QACT;AAEA,cAAM,UAAU,EAAC,+BAAO;AACxB,cAAM,aAAa,CAAC,eAAe;AAEnC,YAAI,SAAS;AACX,qBAAW,KAAK,oBAAoB;AAAA,QACtC;AAEA,eAAO,0BAAc,OAAO,MAAM,KAAK;AAAA,UACrC,uBAAW,OAAO,MAAM,MAAM,MAAM,IAAI;AAAA,YACtC,UAAU;AAAA,YACV,OAAO,WAAW,KAAK,GAAG;AAAA,YAC1B,sBAAsB;AAAA,YACtB,2BAA2B;AAAA,UAC7B,CAAC;AAAA,QACH,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF,CAAC;AAED,SAAO;AACT;AAOO,SAAS,eAAe,MAAkB,eAA0B,qBAAqB;AAC9F,QAAM,KAAK,KAAK,MAAM,GAAG,QAAQ,cAAc,EAAE,MAAM,KAAK,CAAC;AAC7D,OAAK,SAAS,EAAE;AAClB;;;ADjkBA,IAAO,gBAAQ;","names":["findSuggestionMatch"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/suggestion.ts","../src/findSuggestionMatch.ts"],"sourcesContent":["import { exitSuggestion, Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport { exitSuggestion }\n\nexport default Suggestion\n","import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nimport type { SuggestionMatch } from './findSuggestionMatch.js'\nimport { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'\n\n/**\n * Returns true if the transaction inserted any whitespace or newline character.\n * Used to determine when a dismissed suggestion should become active again.\n */\nfunction hasInsertedWhitespace(transaction: Transaction): boolean {\n if (!transaction.docChanged) {\n return false\n }\n return transaction.steps.some(step => {\n const slice = (step as any).slice\n if (!slice?.content) {\n return false\n }\n // textBetween with '\\n' as block separator catches both inline spaces and newlines\n const inserted = slice.content.textBetween(0, slice.content.size, '\\n')\n return /\\s/.test(inserted)\n })\n}\n\nexport interface SuggestionOptions<I = any, TSelected = any> {\n /**\n * The plugin key for the suggestion plugin.\n * @default 'suggestion'\n * @example 'mention'\n */\n pluginKey?: PluginKey\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * This is useful to prevent suggestions from opening for remote users in collaborative environments.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.query The current suggestion query.\n * @param props.text The current suggestion text.\n * @param props.transaction The current transaction.\n * @returns {boolean}\n * @example ({ transaction }) => isChangeOrigin(transaction)\n */\n shouldShow?: (props: {\n editor: Editor\n range: Range\n query: string\n text: string\n transaction: Transaction\n }) => boolean\n\n /**\n * Controls when a dismissed suggestion becomes active again.\n * Return `true` to clear the dismissed context for the current transaction.\n */\n shouldResetDismissed?: (props: {\n editor: Editor\n state: EditorState\n range: Range\n match: Exclude<SuggestionMatch, null>\n transaction: Transaction\n allowSpaces: boolean\n }) => boolean\n\n /**\n * The editor instance.\n * @default null\n */\n editor: Editor\n\n /**\n * The character that triggers the suggestion.\n * @default '@'\n * @example '#'\n */\n char?: string\n\n /**\n * Allow spaces in the suggestion query. Not compatible with `allowToIncludeChar`. Will be disabled if `allowToIncludeChar` is set to `true`.\n * @default false\n * @example true\n */\n allowSpaces?: boolean\n\n /**\n * Allow the character to be included in the suggestion query. Not compatible with `allowSpaces`.\n * @default false\n */\n allowToIncludeChar?: boolean\n\n /**\n * Allow prefixes in the suggestion query.\n * @default [' ']\n * @example [' ', '@']\n */\n allowedPrefixes?: string[] | null\n\n /**\n * Only match suggestions at the start of the line.\n * @default false\n * @example true\n */\n startOfLine?: boolean\n\n /**\n * The tag name of the decoration node.\n * @default 'span'\n * @example 'div'\n */\n decorationTag?: string\n\n /**\n * The class name of the decoration node.\n * @default 'suggestion'\n * @example 'mention'\n */\n decorationClass?: string\n\n /**\n * Creates a decoration with the provided content.\n * @param decorationContent - The content to display in the decoration\n * @default \"\" - Creates an empty decoration if no content provided\n */\n decorationContent?: string\n\n /**\n * The class name of the decoration node when it is empty.\n * @default 'is-empty'\n * @example 'is-empty'\n */\n decorationEmptyClass?: string\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.props The props of the selected suggestion.\n * @returns void\n * @example ({ editor, range, props }) => { props.command(props.props) }\n */\n command?: (props: { editor: Editor; range: Range; props: TSelected }) => void\n\n /**\n * A function that returns the suggestion items in form of an array.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.query The current suggestion query.\n * @returns An array of suggestion items.\n * @example ({ editor, query }) => [{ id: 1, label: 'John Doe' }]\n */\n items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>\n\n /**\n * The render function for the suggestion.\n * @returns An object with render functions.\n */\n render?: () => {\n onBeforeStart?: (props: SuggestionProps<I, TSelected>) => void\n onStart?: (props: SuggestionProps<I, TSelected>) => void\n onBeforeUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onExit?: (props: SuggestionProps<I, TSelected>) => void\n onKeyDown?: (props: SuggestionKeyDownProps) => boolean\n }\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * @param props The props object.\n * @returns {boolean}\n */\n allow?: (props: { editor: Editor; state: EditorState; range: Range; isActive?: boolean }) => boolean\n findSuggestionMatch?: typeof defaultFindSuggestionMatch\n}\n\nexport interface SuggestionProps<I = any, TSelected = any> {\n /**\n * The editor instance.\n */\n editor: Editor\n\n /**\n * The range of the suggestion.\n */\n range: Range\n\n /**\n * The current suggestion query.\n */\n query: string\n\n /**\n * The current suggestion text.\n */\n text: string\n\n /**\n * The suggestion items array.\n */\n items: I[]\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @returns void\n */\n command: (props: TSelected) => void\n\n /**\n * The decoration node HTML element\n * @default null\n */\n decorationNode: Element | null\n\n /**\n * The function that returns the client rect\n * @default null\n * @example () => new DOMRect(0, 0, 0, 0)\n */\n clientRect?: (() => DOMRect | null) | null\n}\n\nexport interface SuggestionKeyDownProps {\n view: EditorView\n event: KeyboardEvent\n range: Range\n}\n\nexport const SuggestionPluginKey = new PluginKey('suggestion')\n\n/**\n * This utility allows you to create suggestions.\n * @see https://tiptap.dev/api/utilities/suggestion\n */\nexport function Suggestion<I = any, TSelected = any>({\n pluginKey = SuggestionPluginKey,\n editor,\n char = '@',\n allowSpaces = false,\n allowToIncludeChar = false,\n allowedPrefixes = [' '],\n startOfLine = false,\n decorationTag = 'span',\n decorationClass = 'suggestion',\n decorationContent = '',\n decorationEmptyClass = 'is-empty',\n command = () => null,\n items = () => [],\n render = () => ({}),\n allow = () => true,\n findSuggestionMatch = defaultFindSuggestionMatch,\n shouldShow,\n shouldResetDismissed,\n}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\n const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar\n\n // Gets the DOM rectangle corresponding to the current editor cursor anchor position\n // Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object\n const getAnchorClientRect = () => {\n const pos = editor.state.selection.$anchor.pos\n const coords = editor.view.coordsAtPos(pos)\n const { top, right, bottom, left } = coords\n\n try {\n return new DOMRect(left, top, right - left, bottom - top)\n } catch {\n return null\n }\n }\n\n // Helper to create a clientRect callback for a given decoration node.\n // Returns null when no decoration node is present. Uses the pluginKey's\n // state to resolve the current decoration node on demand, avoiding a\n // duplicated implementation in multiple places.\n const clientRectFor = (view: EditorView, decorationNode: Element | null) => {\n if (!decorationNode) {\n return getAnchorClientRect\n }\n\n return () => {\n const state = pluginKey.getState(editor.state)\n const decorationId = state?.decorationId\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n }\n\n const shouldKeepDismissed = ({\n match,\n dismissedRange,\n state,\n transaction,\n }: {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n }) => {\n if (\n shouldResetDismissed?.({\n editor,\n state,\n range: dismissedRange,\n match,\n transaction,\n allowSpaces: effectiveAllowSpaces,\n })\n ) {\n return false\n }\n\n if (effectiveAllowSpaces) {\n return match.range.from === dismissedRange.from\n }\n\n return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)\n }\n\n // small helper used internally by the view to dispatch an exit\n function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {\n try {\n const state = pluginKey.getState(view.state)\n const decorationNode = state?.decorationId\n ? view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n : null\n\n const exitProps: SuggestionProps = {\n // @ts-ignore editor is available in closure\n editor,\n range: state?.range || { from: 0, to: 0 },\n query: state?.query || null,\n text: state?.text || null,\n items: [],\n command: commandProps => {\n return command({ editor, range: state?.range || { from: 0, to: 0 }, props: commandProps as any })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n renderer?.onExit?.(exitProps)\n } catch {\n // ignore errors from consumer renderers\n }\n\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n // Dispatch a metadata-only transaction to signal the plugin to exit\n view.dispatch(tr)\n }\n\n const plugin: Plugin<any> = new Plugin({\n key: pluginKey,\n\n view() {\n return {\n update: async (view, prevState) => {\n const prev = this.key?.getState(prevState)\n const next = this.key?.getState(view.state)\n\n // See how the state changed\n const moved = prev.active && next.active && prev.range.from !== next.range.from\n const started = !prev.active && next.active\n const stopped = prev.active && !next.active\n const changed = !started && !stopped && prev.query !== next.query\n\n const handleStart = started || (moved && changed)\n const handleChange = changed || moved\n const handleExit = stopped || (moved && changed)\n\n // Cancel when suggestion isn't active\n if (!handleStart && !handleChange && !handleExit) {\n return\n }\n\n const state = handleExit && !handleStart ? prev : next\n const decorationNode = view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n\n props = {\n editor,\n range: state.range,\n query: state.query,\n text: state.text,\n items: [],\n command: commandProps => {\n return command({\n editor,\n range: state.range,\n props: commandProps,\n })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n if (handleStart) {\n renderer?.onBeforeStart?.(props)\n }\n\n if (handleChange) {\n renderer?.onBeforeUpdate?.(props)\n }\n\n if (handleChange || handleStart) {\n props.items = await items({\n editor,\n query: state.query,\n })\n }\n\n if (handleExit) {\n renderer?.onExit?.(props)\n }\n\n if (handleChange) {\n renderer?.onUpdate?.(props)\n }\n\n if (handleStart) {\n renderer?.onStart?.(props)\n }\n },\n\n destroy: () => {\n if (!props) {\n return\n }\n\n renderer?.onExit?.(props)\n },\n }\n },\n\n state: {\n // Initialize the plugin's internal state.\n init() {\n const state: {\n active: boolean\n range: Range\n query: null | string\n text: null | string\n composing: boolean\n decorationId?: string | null\n dismissedRange: Range | null\n } = {\n active: false,\n range: {\n from: 0,\n to: 0,\n },\n query: null,\n text: null,\n composing: false,\n dismissedRange: null,\n }\n\n return state\n },\n\n // Apply changes to the plugin state from a view transaction.\n apply(transaction, prev, _oldState, state) {\n const { isEditable } = editor\n const { composing } = editor.view\n const { selection } = transaction\n const { empty, from } = selection\n const next = { ...prev }\n\n // If a transaction carries the exit meta for this plugin, immediately\n // deactivate the suggestion. This allows metadata-only transactions\n // (dispatched by escape or programmatic exit) to deterministically\n // clear decorations without changing the document.\n const meta = transaction.getMeta(pluginKey)\n if (meta && meta.exit) {\n next.active = false\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange\n\n return next\n }\n\n next.composing = composing\n\n if (transaction.docChanged && next.dismissedRange !== null) {\n next.dismissedRange = {\n from: transaction.mapping.map(next.dismissedRange.from),\n to: transaction.mapping.map(next.dismissedRange.to),\n }\n }\n\n // We can only be suggesting if the view is editable, and:\n // * there is no selection, or\n // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)\n if (isEditable && (empty || editor.view.composing)) {\n // Reset active state if we just left the previous suggestion range\n if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {\n next.active = false\n }\n\n // Try to match against where our cursor currently is\n const match = findSuggestionMatch({\n char,\n allowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position: selection.$from,\n })\n const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`\n\n // If we found a match, update the current state to show it\n if (\n match &&\n allow({\n editor,\n state,\n range: match.range,\n isActive: prev.active,\n }) &&\n (!shouldShow ||\n shouldShow({\n editor,\n range: match.range,\n query: match.query,\n text: match.text,\n transaction,\n }))\n ) {\n if (\n next.dismissedRange !== null &&\n !shouldKeepDismissed({\n match,\n dismissedRange: next.dismissedRange,\n state,\n transaction,\n })\n ) {\n next.dismissedRange = null\n }\n\n if (next.dismissedRange === null) {\n next.active = true\n next.decorationId = prev.decorationId ? prev.decorationId : decorationId\n next.range = match.range\n next.query = match.query\n next.text = match.text\n } else {\n next.active = false\n }\n } else {\n if (!match) {\n next.dismissedRange = null\n }\n next.active = false\n }\n } else {\n next.active = false\n }\n\n // Make sure to empty the range if suggestion is inactive\n if (!next.active) {\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n }\n\n return next\n },\n },\n\n props: {\n // Call the keydown hook if suggestion is active.\n handleKeyDown(view, event) {\n const { active, range } = plugin.getState(view.state)\n\n if (!active) {\n return false\n }\n\n // If Escape is pressed, call onExit and dispatch a metadata-only\n // transaction to unset the suggestion state. This provides a safe\n // and deterministic way to exit the suggestion without altering the\n // document (avoids transaction mapping/mismatch issues).\n if (event.key === 'Escape' || event.key === 'Esc') {\n const state = plugin.getState(view.state)\n\n // Allow the consumer to react to Escape, but always clear the\n // suggestion state afterward so the decoration is removed too.\n renderer?.onKeyDown?.({ view, event, range: state.range })\n\n // dispatch metadata-only transaction to unset the plugin state\n dispatchExit(view, pluginKey)\n\n return true\n }\n\n const handled = renderer?.onKeyDown?.({ view, event, range }) || false\n return handled\n },\n\n // Setup decorator on the currently active suggestion.\n decorations(state) {\n const { active, range, decorationId, query } = plugin.getState(state)\n\n if (!active) {\n return null\n }\n\n const isEmpty = !query?.length\n const classNames = [decorationClass]\n\n if (isEmpty) {\n classNames.push(decorationEmptyClass)\n }\n\n return DecorationSet.create(state.doc, [\n Decoration.inline(range.from, range.to, {\n nodeName: decorationTag,\n class: classNames.join(' '),\n 'data-decoration-id': decorationId,\n 'data-decoration-content': decorationContent,\n }),\n ])\n },\n },\n })\n\n return plugin\n}\n\n/**\n * Programmatically exit a suggestion plugin by dispatching a metadata-only\n * transaction. This is the safe, recommended API to remove suggestion\n * decorations without touching the document or causing mapping errors.\n */\nexport function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { Range } from '@tiptap/core'\nimport { escapeForRegEx } from '@tiptap/core'\nimport type { ResolvedPos } from '@tiptap/pm/model'\n\nexport interface Trigger {\n char: string\n allowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n $position: ResolvedPos\n}\n\nexport type SuggestionMatch = {\n range: Range\n query: string\n text: string\n} | null\n\nexport function findSuggestionMatch(config: Trigger): SuggestionMatch {\n const { char, allowSpaces: allowSpacesOption, allowToIncludeChar, allowedPrefixes, startOfLine, $position } = config\n\n const allowSpaces = allowSpacesOption && !allowToIncludeChar\n\n const escapedChar = escapeForRegEx(char)\n const suffix = new RegExp(`\\\\s${escapedChar}$`)\n const prefix = startOfLine ? '^' : ''\n const finalEscapedChar = allowToIncludeChar ? '' : escapedChar\n const regexp = allowSpaces\n ? new RegExp(`${prefix}${escapedChar}.*?(?=\\\\s${finalEscapedChar}|$)`, 'gm')\n : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\\\s${finalEscapedChar}]*`, 'gm')\n\n const text = $position.nodeBefore?.isText && $position.nodeBefore.text\n\n if (!text) {\n return null\n }\n\n const textFrom = $position.pos - text.length\n const match = Array.from(text.matchAll(regexp)).pop()\n\n if (!match || match.input === undefined || match.index === undefined) {\n return null\n }\n\n // JavaScript doesn't have lookbehinds. This hacks a check that first character\n // is a space or the start of the line\n const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)\n const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join('')}\\0]?$`).test(matchPrefix)\n\n if (allowedPrefixes !== null && !matchPrefixIsAllowed) {\n return null\n }\n\n // The absolute position of the match in the document\n const from = textFrom + match.index\n let to = from + match[0].length\n\n // Edge case handling; if spaces are allowed and we're directly in between\n // two triggers\n if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {\n match[0] += ' '\n to += 1\n }\n\n // If the $position is located within the matched substring, return that range\n if (from < $position.pos && to >= $position.pos) {\n return {\n range: {\n from,\n to,\n },\n query: match[0].slice(char.length),\n text: match[0],\n }\n }\n\n return null\n}\n"],"mappings":"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","names":["findSuggestionMatch"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -44,6 +44,18 @@ interface SuggestionOptions<I = any, TSelected = any> {
|
|
|
44
44
|
text: string;
|
|
45
45
|
transaction: Transaction;
|
|
46
46
|
}) => boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Controls when a dismissed suggestion becomes active again.
|
|
49
|
+
* Return `true` to clear the dismissed context for the current transaction.
|
|
50
|
+
*/
|
|
51
|
+
shouldResetDismissed?: (props: {
|
|
52
|
+
editor: Editor;
|
|
53
|
+
state: EditorState;
|
|
54
|
+
range: Range;
|
|
55
|
+
match: Exclude<SuggestionMatch, null>;
|
|
56
|
+
transaction: Transaction;
|
|
57
|
+
allowSpaces: boolean;
|
|
58
|
+
}) => boolean;
|
|
47
59
|
/**
|
|
48
60
|
* The editor instance.
|
|
49
61
|
* @default null
|
|
@@ -202,7 +214,7 @@ declare const SuggestionPluginKey: PluginKey<any>;
|
|
|
202
214
|
* This utility allows you to create suggestions.
|
|
203
215
|
* @see https://tiptap.dev/api/utilities/suggestion
|
|
204
216
|
*/
|
|
205
|
-
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, render, allow, findSuggestionMatch, shouldShow, }: SuggestionOptions<I, TSelected>): Plugin<any>;
|
|
217
|
+
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, render, allow, findSuggestionMatch, shouldShow, shouldResetDismissed, }: SuggestionOptions<I, TSelected>): Plugin<any>;
|
|
206
218
|
/**
|
|
207
219
|
* Programmatically exit a suggestion plugin by dispatching a metadata-only
|
|
208
220
|
* transaction. This is the safe, recommended API to remove suggestion
|
package/dist/index.d.ts
CHANGED
|
@@ -44,6 +44,18 @@ interface SuggestionOptions<I = any, TSelected = any> {
|
|
|
44
44
|
text: string;
|
|
45
45
|
transaction: Transaction;
|
|
46
46
|
}) => boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Controls when a dismissed suggestion becomes active again.
|
|
49
|
+
* Return `true` to clear the dismissed context for the current transaction.
|
|
50
|
+
*/
|
|
51
|
+
shouldResetDismissed?: (props: {
|
|
52
|
+
editor: Editor;
|
|
53
|
+
state: EditorState;
|
|
54
|
+
range: Range;
|
|
55
|
+
match: Exclude<SuggestionMatch, null>;
|
|
56
|
+
transaction: Transaction;
|
|
57
|
+
allowSpaces: boolean;
|
|
58
|
+
}) => boolean;
|
|
47
59
|
/**
|
|
48
60
|
* The editor instance.
|
|
49
61
|
* @default null
|
|
@@ -202,7 +214,7 @@ declare const SuggestionPluginKey: PluginKey<any>;
|
|
|
202
214
|
* This utility allows you to create suggestions.
|
|
203
215
|
* @see https://tiptap.dev/api/utilities/suggestion
|
|
204
216
|
*/
|
|
205
|
-
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, render, allow, findSuggestionMatch, shouldShow, }: SuggestionOptions<I, TSelected>): Plugin<any>;
|
|
217
|
+
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, render, allow, findSuggestionMatch, shouldShow, shouldResetDismissed, }: SuggestionOptions<I, TSelected>): Plugin<any>;
|
|
206
218
|
/**
|
|
207
219
|
* Programmatically exit a suggestion plugin by dispatching a metadata-only
|
|
208
220
|
* transaction. This is the safe, recommended API to remove suggestion
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,19 @@ function findSuggestionMatch(config) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// src/suggestion.ts
|
|
50
|
+
function hasInsertedWhitespace(transaction) {
|
|
51
|
+
if (!transaction.docChanged) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return transaction.steps.some((step) => {
|
|
55
|
+
const slice = step.slice;
|
|
56
|
+
if (!(slice == null ? void 0 : slice.content)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const inserted = slice.content.textBetween(0, slice.content.size, "\n");
|
|
60
|
+
return /\s/.test(inserted);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
50
63
|
var SuggestionPluginKey = new PluginKey("suggestion");
|
|
51
64
|
function Suggestion({
|
|
52
65
|
pluginKey = SuggestionPluginKey,
|
|
@@ -65,10 +78,12 @@ function Suggestion({
|
|
|
65
78
|
render = () => ({}),
|
|
66
79
|
allow = () => true,
|
|
67
80
|
findSuggestionMatch: findSuggestionMatch2 = findSuggestionMatch,
|
|
68
|
-
shouldShow
|
|
81
|
+
shouldShow,
|
|
82
|
+
shouldResetDismissed
|
|
69
83
|
}) {
|
|
70
84
|
let props;
|
|
71
85
|
const renderer = render == null ? void 0 : render();
|
|
86
|
+
const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar;
|
|
72
87
|
const getAnchorClientRect = () => {
|
|
73
88
|
const pos = editor.state.selection.$anchor.pos;
|
|
74
89
|
const coords = editor.view.coordsAtPos(pos);
|
|
@@ -90,6 +105,27 @@ function Suggestion({
|
|
|
90
105
|
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
|
|
91
106
|
};
|
|
92
107
|
};
|
|
108
|
+
const shouldKeepDismissed = ({
|
|
109
|
+
match,
|
|
110
|
+
dismissedRange,
|
|
111
|
+
state,
|
|
112
|
+
transaction
|
|
113
|
+
}) => {
|
|
114
|
+
if (shouldResetDismissed == null ? void 0 : shouldResetDismissed({
|
|
115
|
+
editor,
|
|
116
|
+
state,
|
|
117
|
+
range: dismissedRange,
|
|
118
|
+
match,
|
|
119
|
+
transaction,
|
|
120
|
+
allowSpaces: effectiveAllowSpaces
|
|
121
|
+
})) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (effectiveAllowSpaces) {
|
|
125
|
+
return match.range.from === dismissedRange.from;
|
|
126
|
+
}
|
|
127
|
+
return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction);
|
|
128
|
+
};
|
|
93
129
|
function dispatchExit(view, pluginKeyRef) {
|
|
94
130
|
var _a;
|
|
95
131
|
try {
|
|
@@ -192,7 +228,8 @@ function Suggestion({
|
|
|
192
228
|
},
|
|
193
229
|
query: null,
|
|
194
230
|
text: null,
|
|
195
|
-
composing: false
|
|
231
|
+
composing: false,
|
|
232
|
+
dismissedRange: null
|
|
196
233
|
};
|
|
197
234
|
return state;
|
|
198
235
|
},
|
|
@@ -210,9 +247,16 @@ function Suggestion({
|
|
|
210
247
|
next.range = { from: 0, to: 0 };
|
|
211
248
|
next.query = null;
|
|
212
249
|
next.text = null;
|
|
250
|
+
next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange;
|
|
213
251
|
return next;
|
|
214
252
|
}
|
|
215
253
|
next.composing = composing;
|
|
254
|
+
if (transaction.docChanged && next.dismissedRange !== null) {
|
|
255
|
+
next.dismissedRange = {
|
|
256
|
+
from: transaction.mapping.map(next.dismissedRange.from),
|
|
257
|
+
to: transaction.mapping.map(next.dismissedRange.to)
|
|
258
|
+
};
|
|
259
|
+
}
|
|
216
260
|
if (isEditable && (empty || editor.view.composing)) {
|
|
217
261
|
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
|
|
218
262
|
next.active = false;
|
|
@@ -238,12 +282,27 @@ function Suggestion({
|
|
|
238
282
|
text: match.text,
|
|
239
283
|
transaction
|
|
240
284
|
}))) {
|
|
241
|
-
next.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
285
|
+
if (next.dismissedRange !== null && !shouldKeepDismissed({
|
|
286
|
+
match,
|
|
287
|
+
dismissedRange: next.dismissedRange,
|
|
288
|
+
state,
|
|
289
|
+
transaction
|
|
290
|
+
})) {
|
|
291
|
+
next.dismissedRange = null;
|
|
292
|
+
}
|
|
293
|
+
if (next.dismissedRange === null) {
|
|
294
|
+
next.active = true;
|
|
295
|
+
next.decorationId = prev.decorationId ? prev.decorationId : decorationId;
|
|
296
|
+
next.range = match.range;
|
|
297
|
+
next.query = match.query;
|
|
298
|
+
next.text = match.text;
|
|
299
|
+
} else {
|
|
300
|
+
next.active = false;
|
|
301
|
+
}
|
|
246
302
|
} else {
|
|
303
|
+
if (!match) {
|
|
304
|
+
next.dismissedRange = null;
|
|
305
|
+
}
|
|
247
306
|
next.active = false;
|
|
248
307
|
}
|
|
249
308
|
} else {
|
|
@@ -261,41 +320,18 @@ function Suggestion({
|
|
|
261
320
|
props: {
|
|
262
321
|
// Call the keydown hook if suggestion is active.
|
|
263
322
|
handleKeyDown(view, event) {
|
|
264
|
-
var _a, _b
|
|
323
|
+
var _a, _b;
|
|
265
324
|
const { active, range } = plugin.getState(view.state);
|
|
266
325
|
if (!active) {
|
|
267
326
|
return false;
|
|
268
327
|
}
|
|
269
328
|
if (event.key === "Escape" || event.key === "Esc") {
|
|
270
329
|
const state = plugin.getState(view.state);
|
|
271
|
-
|
|
272
|
-
const decorationNode = cachedNode != null ? cachedNode : (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null;
|
|
273
|
-
const handledByKeyDown = ((_b = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _b.call(renderer, { view, event, range: state.range })) || false;
|
|
274
|
-
if (handledByKeyDown) {
|
|
275
|
-
return true;
|
|
276
|
-
}
|
|
277
|
-
const exitProps = {
|
|
278
|
-
editor,
|
|
279
|
-
range: state.range,
|
|
280
|
-
query: state.query,
|
|
281
|
-
text: state.text,
|
|
282
|
-
items: [],
|
|
283
|
-
command: (commandProps) => {
|
|
284
|
-
return command({ editor, range: state.range, props: commandProps });
|
|
285
|
-
},
|
|
286
|
-
decorationNode,
|
|
287
|
-
// If we have a cached decoration node, use it for the clientRect
|
|
288
|
-
// to avoid another DOM lookup. If not, leave clientRect null and
|
|
289
|
-
// let consumer decide if they want to query.
|
|
290
|
-
clientRect: decorationNode ? () => {
|
|
291
|
-
return decorationNode.getBoundingClientRect() || null;
|
|
292
|
-
} : null
|
|
293
|
-
};
|
|
294
|
-
(_c = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _c.call(renderer, exitProps);
|
|
330
|
+
(_a = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _a.call(renderer, { view, event, range: state.range });
|
|
295
331
|
dispatchExit(view, pluginKey);
|
|
296
332
|
return true;
|
|
297
333
|
}
|
|
298
|
-
const handled = ((
|
|
334
|
+
const handled = ((_b = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _b.call(renderer, { view, event, range })) || false;
|
|
299
335
|
return handled;
|
|
300
336
|
},
|
|
301
337
|
// Setup decorator on the currently active suggestion.
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/suggestion.ts","../src/findSuggestionMatch.ts","../src/index.ts"],"sourcesContent":["import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nimport { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'\n\nexport interface SuggestionOptions<I = any, TSelected = any> {\n /**\n * The plugin key for the suggestion plugin.\n * @default 'suggestion'\n * @example 'mention'\n */\n pluginKey?: PluginKey\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * This is useful to prevent suggestions from opening for remote users in collaborative environments.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.query The current suggestion query.\n * @param props.text The current suggestion text.\n * @param props.transaction The current transaction.\n * @returns {boolean}\n * @example ({ transaction }) => isChangeOrigin(transaction)\n */\n shouldShow?: (props: {\n editor: Editor\n range: Range\n query: string\n text: string\n transaction: Transaction\n }) => boolean\n\n /**\n * The editor instance.\n * @default null\n */\n editor: Editor\n\n /**\n * The character that triggers the suggestion.\n * @default '@'\n * @example '#'\n */\n char?: string\n\n /**\n * Allow spaces in the suggestion query. Not compatible with `allowToIncludeChar`. Will be disabled if `allowToIncludeChar` is set to `true`.\n * @default false\n * @example true\n */\n allowSpaces?: boolean\n\n /**\n * Allow the character to be included in the suggestion query. Not compatible with `allowSpaces`.\n * @default false\n */\n allowToIncludeChar?: boolean\n\n /**\n * Allow prefixes in the suggestion query.\n * @default [' ']\n * @example [' ', '@']\n */\n allowedPrefixes?: string[] | null\n\n /**\n * Only match suggestions at the start of the line.\n * @default false\n * @example true\n */\n startOfLine?: boolean\n\n /**\n * The tag name of the decoration node.\n * @default 'span'\n * @example 'div'\n */\n decorationTag?: string\n\n /**\n * The class name of the decoration node.\n * @default 'suggestion'\n * @example 'mention'\n */\n decorationClass?: string\n\n /**\n * Creates a decoration with the provided content.\n * @param decorationContent - The content to display in the decoration\n * @default \"\" - Creates an empty decoration if no content provided\n */\n decorationContent?: string\n\n /**\n * The class name of the decoration node when it is empty.\n * @default 'is-empty'\n * @example 'is-empty'\n */\n decorationEmptyClass?: string\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.props The props of the selected suggestion.\n * @returns void\n * @example ({ editor, range, props }) => { props.command(props.props) }\n */\n command?: (props: { editor: Editor; range: Range; props: TSelected }) => void\n\n /**\n * A function that returns the suggestion items in form of an array.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.query The current suggestion query.\n * @returns An array of suggestion items.\n * @example ({ editor, query }) => [{ id: 1, label: 'John Doe' }]\n */\n items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>\n\n /**\n * The render function for the suggestion.\n * @returns An object with render functions.\n */\n render?: () => {\n onBeforeStart?: (props: SuggestionProps<I, TSelected>) => void\n onStart?: (props: SuggestionProps<I, TSelected>) => void\n onBeforeUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onExit?: (props: SuggestionProps<I, TSelected>) => void\n onKeyDown?: (props: SuggestionKeyDownProps) => boolean\n }\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * @param props The props object.\n * @returns {boolean}\n */\n allow?: (props: { editor: Editor; state: EditorState; range: Range; isActive?: boolean }) => boolean\n findSuggestionMatch?: typeof defaultFindSuggestionMatch\n}\n\nexport interface SuggestionProps<I = any, TSelected = any> {\n /**\n * The editor instance.\n */\n editor: Editor\n\n /**\n * The range of the suggestion.\n */\n range: Range\n\n /**\n * The current suggestion query.\n */\n query: string\n\n /**\n * The current suggestion text.\n */\n text: string\n\n /**\n * The suggestion items array.\n */\n items: I[]\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @returns void\n */\n command: (props: TSelected) => void\n\n /**\n * The decoration node HTML element\n * @default null\n */\n decorationNode: Element | null\n\n /**\n * The function that returns the client rect\n * @default null\n * @example () => new DOMRect(0, 0, 0, 0)\n */\n clientRect?: (() => DOMRect | null) | null\n}\n\nexport interface SuggestionKeyDownProps {\n view: EditorView\n event: KeyboardEvent\n range: Range\n}\n\nexport const SuggestionPluginKey = new PluginKey('suggestion')\n\n/**\n * This utility allows you to create suggestions.\n * @see https://tiptap.dev/api/utilities/suggestion\n */\nexport function Suggestion<I = any, TSelected = any>({\n pluginKey = SuggestionPluginKey,\n editor,\n char = '@',\n allowSpaces = false,\n allowToIncludeChar = false,\n allowedPrefixes = [' '],\n startOfLine = false,\n decorationTag = 'span',\n decorationClass = 'suggestion',\n decorationContent = '',\n decorationEmptyClass = 'is-empty',\n command = () => null,\n items = () => [],\n render = () => ({}),\n allow = () => true,\n findSuggestionMatch = defaultFindSuggestionMatch,\n shouldShow,\n}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\n\n // Gets the DOM rectangle corresponding to the current editor cursor anchor position\n // Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object\n const getAnchorClientRect = () => {\n const pos = editor.state.selection.$anchor.pos\n const coords = editor.view.coordsAtPos(pos)\n const { top, right, bottom, left } = coords\n\n try {\n return new DOMRect(left, top, right - left, bottom - top)\n } catch {\n return null\n }\n }\n\n // Helper to create a clientRect callback for a given decoration node.\n // Returns null when no decoration node is present. Uses the pluginKey's\n // state to resolve the current decoration node on demand, avoiding a\n // duplicated implementation in multiple places.\n const clientRectFor = (view: EditorView, decorationNode: Element | null) => {\n if (!decorationNode) {\n return getAnchorClientRect\n }\n\n return () => {\n const state = pluginKey.getState(editor.state)\n const decorationId = state?.decorationId\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n }\n // small helper used internally by the view to dispatch an exit\n function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {\n try {\n const state = pluginKey.getState(view.state)\n const decorationNode = state?.decorationId\n ? view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n : null\n\n const exitProps: SuggestionProps = {\n // @ts-ignore editor is available in closure\n editor,\n range: state?.range || { from: 0, to: 0 },\n query: state?.query || null,\n text: state?.text || null,\n items: [],\n command: commandProps => {\n return command({ editor, range: state?.range || { from: 0, to: 0 }, props: commandProps as any })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n renderer?.onExit?.(exitProps)\n } catch {\n // ignore errors from consumer renderers\n }\n\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n // Dispatch a metadata-only transaction to signal the plugin to exit\n view.dispatch(tr)\n }\n\n const plugin: Plugin<any> = new Plugin({\n key: pluginKey,\n\n view() {\n return {\n update: async (view, prevState) => {\n const prev = this.key?.getState(prevState)\n const next = this.key?.getState(view.state)\n\n // See how the state changed\n const moved = prev.active && next.active && prev.range.from !== next.range.from\n const started = !prev.active && next.active\n const stopped = prev.active && !next.active\n const changed = !started && !stopped && prev.query !== next.query\n\n const handleStart = started || (moved && changed)\n const handleChange = changed || moved\n const handleExit = stopped || (moved && changed)\n\n // Cancel when suggestion isn't active\n if (!handleStart && !handleChange && !handleExit) {\n return\n }\n\n const state = handleExit && !handleStart ? prev : next\n const decorationNode = view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n\n props = {\n editor,\n range: state.range,\n query: state.query,\n text: state.text,\n items: [],\n command: commandProps => {\n return command({\n editor,\n range: state.range,\n props: commandProps,\n })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n if (handleStart) {\n renderer?.onBeforeStart?.(props)\n }\n\n if (handleChange) {\n renderer?.onBeforeUpdate?.(props)\n }\n\n if (handleChange || handleStart) {\n props.items = await items({\n editor,\n query: state.query,\n })\n }\n\n if (handleExit) {\n renderer?.onExit?.(props)\n }\n\n if (handleChange) {\n renderer?.onUpdate?.(props)\n }\n\n if (handleStart) {\n renderer?.onStart?.(props)\n }\n },\n\n destroy: () => {\n if (!props) {\n return\n }\n\n renderer?.onExit?.(props)\n },\n }\n },\n\n state: {\n // Initialize the plugin's internal state.\n init() {\n const state: {\n active: boolean\n range: Range\n query: null | string\n text: null | string\n composing: boolean\n decorationId?: string | null\n } = {\n active: false,\n range: {\n from: 0,\n to: 0,\n },\n query: null,\n text: null,\n composing: false,\n }\n\n return state\n },\n\n // Apply changes to the plugin state from a view transaction.\n apply(transaction, prev, _oldState, state) {\n const { isEditable } = editor\n const { composing } = editor.view\n const { selection } = transaction\n const { empty, from } = selection\n const next = { ...prev }\n\n // If a transaction carries the exit meta for this plugin, immediately\n // deactivate the suggestion. This allows metadata-only transactions\n // (dispatched by escape or programmatic exit) to deterministically\n // clear decorations without changing the document.\n const meta = transaction.getMeta(pluginKey)\n if (meta && meta.exit) {\n next.active = false\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n\n return next\n }\n\n next.composing = composing\n\n // We can only be suggesting if the view is editable, and:\n // * there is no selection, or\n // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)\n if (isEditable && (empty || editor.view.composing)) {\n // Reset active state if we just left the previous suggestion range\n if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {\n next.active = false\n }\n\n // Try to match against where our cursor currently is\n const match = findSuggestionMatch({\n char,\n allowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position: selection.$from,\n })\n const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`\n\n // If we found a match, update the current state to show it\n if (\n match &&\n allow({\n editor,\n state,\n range: match.range,\n isActive: prev.active,\n }) &&\n (!shouldShow ||\n shouldShow({\n editor,\n range: match.range,\n query: match.query,\n text: match.text,\n transaction,\n }))\n ) {\n next.active = true\n next.decorationId = prev.decorationId ? prev.decorationId : decorationId\n next.range = match.range\n next.query = match.query\n next.text = match.text\n } else {\n next.active = false\n }\n } else {\n next.active = false\n }\n\n // Make sure to empty the range if suggestion is inactive\n if (!next.active) {\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n }\n\n return next\n },\n },\n\n props: {\n // Call the keydown hook if suggestion is active.\n handleKeyDown(view, event) {\n const { active, range } = plugin.getState(view.state)\n\n if (!active) {\n return false\n }\n\n // If Escape is pressed, call onExit and dispatch a metadata-only\n // transaction to unset the suggestion state. This provides a safe\n // and deterministic way to exit the suggestion without altering the\n // document (avoids transaction mapping/mismatch issues).\n if (event.key === 'Escape' || event.key === 'Esc') {\n const state = plugin.getState(view.state)\n const cachedNode = props?.decorationNode ?? null\n const decorationNode =\n cachedNode ??\n (state?.decorationId ? view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`) : null)\n\n // Give the consumer a chance to handle Escape via onKeyDown first.\n // If the consumer returns `true` we assume they handled the event and\n // we won't call onExit/dispatchExit so they can both prevent\n // propagation and decide whether to close the suggestion themselves.\n const handledByKeyDown = renderer?.onKeyDown?.({ view, event, range: state.range }) || false\n\n if (handledByKeyDown) {\n return true\n }\n\n const exitProps: SuggestionProps = {\n editor,\n range: state.range,\n query: state.query,\n text: state.text,\n items: [],\n command: commandProps => {\n return command({ editor, range: state.range, props: commandProps as any })\n },\n decorationNode,\n // If we have a cached decoration node, use it for the clientRect\n // to avoid another DOM lookup. If not, leave clientRect null and\n // let consumer decide if they want to query.\n clientRect: decorationNode\n ? () => {\n return decorationNode.getBoundingClientRect() || null\n }\n : null,\n }\n\n renderer?.onExit?.(exitProps)\n\n // dispatch metadata-only transaction to unset the plugin state\n dispatchExit(view, pluginKey)\n\n return true\n }\n\n const handled = renderer?.onKeyDown?.({ view, event, range }) || false\n return handled\n },\n\n // Setup decorator on the currently active suggestion.\n decorations(state) {\n const { active, range, decorationId, query } = plugin.getState(state)\n\n if (!active) {\n return null\n }\n\n const isEmpty = !query?.length\n const classNames = [decorationClass]\n\n if (isEmpty) {\n classNames.push(decorationEmptyClass)\n }\n\n return DecorationSet.create(state.doc, [\n Decoration.inline(range.from, range.to, {\n nodeName: decorationTag,\n class: classNames.join(' '),\n 'data-decoration-id': decorationId,\n 'data-decoration-content': decorationContent,\n }),\n ])\n },\n },\n })\n\n return plugin\n}\n\n/**\n * Programmatically exit a suggestion plugin by dispatching a metadata-only\n * transaction. This is the safe, recommended API to remove suggestion\n * decorations without touching the document or causing mapping errors.\n */\nexport function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { Range } from '@tiptap/core'\nimport { escapeForRegEx } from '@tiptap/core'\nimport type { ResolvedPos } from '@tiptap/pm/model'\n\nexport interface Trigger {\n char: string\n allowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n $position: ResolvedPos\n}\n\nexport type SuggestionMatch = {\n range: Range\n query: string\n text: string\n} | null\n\nexport function findSuggestionMatch(config: Trigger): SuggestionMatch {\n const { char, allowSpaces: allowSpacesOption, allowToIncludeChar, allowedPrefixes, startOfLine, $position } = config\n\n const allowSpaces = allowSpacesOption && !allowToIncludeChar\n\n const escapedChar = escapeForRegEx(char)\n const suffix = new RegExp(`\\\\s${escapedChar}$`)\n const prefix = startOfLine ? '^' : ''\n const finalEscapedChar = allowToIncludeChar ? '' : escapedChar\n const regexp = allowSpaces\n ? new RegExp(`${prefix}${escapedChar}.*?(?=\\\\s${finalEscapedChar}|$)`, 'gm')\n : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\\\s${finalEscapedChar}]*`, 'gm')\n\n const text = $position.nodeBefore?.isText && $position.nodeBefore.text\n\n if (!text) {\n return null\n }\n\n const textFrom = $position.pos - text.length\n const match = Array.from(text.matchAll(regexp)).pop()\n\n if (!match || match.input === undefined || match.index === undefined) {\n return null\n }\n\n // JavaScript doesn't have lookbehinds. This hacks a check that first character\n // is a space or the start of the line\n const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)\n const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join('')}\\0]?$`).test(matchPrefix)\n\n if (allowedPrefixes !== null && !matchPrefixIsAllowed) {\n return null\n }\n\n // The absolute position of the match in the document\n const from = textFrom + match.index\n let to = from + match[0].length\n\n // Edge case handling; if spaces are allowed and we're directly in between\n // two triggers\n if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {\n match[0] += ' '\n to += 1\n }\n\n // If the $position is located within the matched substring, return that range\n if (from < $position.pos && to >= $position.pos) {\n return {\n range: {\n from,\n to,\n },\n query: match[0].slice(char.length),\n text: match[0],\n }\n }\n\n return null\n}\n","import { exitSuggestion, Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport { exitSuggestion }\n\nexport default Suggestion\n"],"mappings":"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","names":["findSuggestionMatch"]}
|
|
1
|
+
{"version":3,"sources":["../src/suggestion.ts","../src/findSuggestionMatch.ts","../src/index.ts"],"sourcesContent":["import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nimport type { SuggestionMatch } from './findSuggestionMatch.js'\nimport { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'\n\n/**\n * Returns true if the transaction inserted any whitespace or newline character.\n * Used to determine when a dismissed suggestion should become active again.\n */\nfunction hasInsertedWhitespace(transaction: Transaction): boolean {\n if (!transaction.docChanged) {\n return false\n }\n return transaction.steps.some(step => {\n const slice = (step as any).slice\n if (!slice?.content) {\n return false\n }\n // textBetween with '\\n' as block separator catches both inline spaces and newlines\n const inserted = slice.content.textBetween(0, slice.content.size, '\\n')\n return /\\s/.test(inserted)\n })\n}\n\nexport interface SuggestionOptions<I = any, TSelected = any> {\n /**\n * The plugin key for the suggestion plugin.\n * @default 'suggestion'\n * @example 'mention'\n */\n pluginKey?: PluginKey\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * This is useful to prevent suggestions from opening for remote users in collaborative environments.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.query The current suggestion query.\n * @param props.text The current suggestion text.\n * @param props.transaction The current transaction.\n * @returns {boolean}\n * @example ({ transaction }) => isChangeOrigin(transaction)\n */\n shouldShow?: (props: {\n editor: Editor\n range: Range\n query: string\n text: string\n transaction: Transaction\n }) => boolean\n\n /**\n * Controls when a dismissed suggestion becomes active again.\n * Return `true` to clear the dismissed context for the current transaction.\n */\n shouldResetDismissed?: (props: {\n editor: Editor\n state: EditorState\n range: Range\n match: Exclude<SuggestionMatch, null>\n transaction: Transaction\n allowSpaces: boolean\n }) => boolean\n\n /**\n * The editor instance.\n * @default null\n */\n editor: Editor\n\n /**\n * The character that triggers the suggestion.\n * @default '@'\n * @example '#'\n */\n char?: string\n\n /**\n * Allow spaces in the suggestion query. Not compatible with `allowToIncludeChar`. Will be disabled if `allowToIncludeChar` is set to `true`.\n * @default false\n * @example true\n */\n allowSpaces?: boolean\n\n /**\n * Allow the character to be included in the suggestion query. Not compatible with `allowSpaces`.\n * @default false\n */\n allowToIncludeChar?: boolean\n\n /**\n * Allow prefixes in the suggestion query.\n * @default [' ']\n * @example [' ', '@']\n */\n allowedPrefixes?: string[] | null\n\n /**\n * Only match suggestions at the start of the line.\n * @default false\n * @example true\n */\n startOfLine?: boolean\n\n /**\n * The tag name of the decoration node.\n * @default 'span'\n * @example 'div'\n */\n decorationTag?: string\n\n /**\n * The class name of the decoration node.\n * @default 'suggestion'\n * @example 'mention'\n */\n decorationClass?: string\n\n /**\n * Creates a decoration with the provided content.\n * @param decorationContent - The content to display in the decoration\n * @default \"\" - Creates an empty decoration if no content provided\n */\n decorationContent?: string\n\n /**\n * The class name of the decoration node when it is empty.\n * @default 'is-empty'\n * @example 'is-empty'\n */\n decorationEmptyClass?: string\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.range The range of the suggestion.\n * @param props.props The props of the selected suggestion.\n * @returns void\n * @example ({ editor, range, props }) => { props.command(props.props) }\n */\n command?: (props: { editor: Editor; range: Range; props: TSelected }) => void\n\n /**\n * A function that returns the suggestion items in form of an array.\n * @param props The props object.\n * @param props.editor The editor instance.\n * @param props.query The current suggestion query.\n * @returns An array of suggestion items.\n * @example ({ editor, query }) => [{ id: 1, label: 'John Doe' }]\n */\n items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>\n\n /**\n * The render function for the suggestion.\n * @returns An object with render functions.\n */\n render?: () => {\n onBeforeStart?: (props: SuggestionProps<I, TSelected>) => void\n onStart?: (props: SuggestionProps<I, TSelected>) => void\n onBeforeUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onUpdate?: (props: SuggestionProps<I, TSelected>) => void\n onExit?: (props: SuggestionProps<I, TSelected>) => void\n onKeyDown?: (props: SuggestionKeyDownProps) => boolean\n }\n\n /**\n * A function that returns a boolean to indicate if the suggestion should be active.\n * @param props The props object.\n * @returns {boolean}\n */\n allow?: (props: { editor: Editor; state: EditorState; range: Range; isActive?: boolean }) => boolean\n findSuggestionMatch?: typeof defaultFindSuggestionMatch\n}\n\nexport interface SuggestionProps<I = any, TSelected = any> {\n /**\n * The editor instance.\n */\n editor: Editor\n\n /**\n * The range of the suggestion.\n */\n range: Range\n\n /**\n * The current suggestion query.\n */\n query: string\n\n /**\n * The current suggestion text.\n */\n text: string\n\n /**\n * The suggestion items array.\n */\n items: I[]\n\n /**\n * A function that is called when a suggestion is selected.\n * @param props The props object.\n * @returns void\n */\n command: (props: TSelected) => void\n\n /**\n * The decoration node HTML element\n * @default null\n */\n decorationNode: Element | null\n\n /**\n * The function that returns the client rect\n * @default null\n * @example () => new DOMRect(0, 0, 0, 0)\n */\n clientRect?: (() => DOMRect | null) | null\n}\n\nexport interface SuggestionKeyDownProps {\n view: EditorView\n event: KeyboardEvent\n range: Range\n}\n\nexport const SuggestionPluginKey = new PluginKey('suggestion')\n\n/**\n * This utility allows you to create suggestions.\n * @see https://tiptap.dev/api/utilities/suggestion\n */\nexport function Suggestion<I = any, TSelected = any>({\n pluginKey = SuggestionPluginKey,\n editor,\n char = '@',\n allowSpaces = false,\n allowToIncludeChar = false,\n allowedPrefixes = [' '],\n startOfLine = false,\n decorationTag = 'span',\n decorationClass = 'suggestion',\n decorationContent = '',\n decorationEmptyClass = 'is-empty',\n command = () => null,\n items = () => [],\n render = () => ({}),\n allow = () => true,\n findSuggestionMatch = defaultFindSuggestionMatch,\n shouldShow,\n shouldResetDismissed,\n}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\n const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar\n\n // Gets the DOM rectangle corresponding to the current editor cursor anchor position\n // Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object\n const getAnchorClientRect = () => {\n const pos = editor.state.selection.$anchor.pos\n const coords = editor.view.coordsAtPos(pos)\n const { top, right, bottom, left } = coords\n\n try {\n return new DOMRect(left, top, right - left, bottom - top)\n } catch {\n return null\n }\n }\n\n // Helper to create a clientRect callback for a given decoration node.\n // Returns null when no decoration node is present. Uses the pluginKey's\n // state to resolve the current decoration node on demand, avoiding a\n // duplicated implementation in multiple places.\n const clientRectFor = (view: EditorView, decorationNode: Element | null) => {\n if (!decorationNode) {\n return getAnchorClientRect\n }\n\n return () => {\n const state = pluginKey.getState(editor.state)\n const decorationId = state?.decorationId\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n }\n\n const shouldKeepDismissed = ({\n match,\n dismissedRange,\n state,\n transaction,\n }: {\n match: Exclude<SuggestionMatch, null>\n dismissedRange: Range\n state: EditorState\n transaction: Transaction\n }) => {\n if (\n shouldResetDismissed?.({\n editor,\n state,\n range: dismissedRange,\n match,\n transaction,\n allowSpaces: effectiveAllowSpaces,\n })\n ) {\n return false\n }\n\n if (effectiveAllowSpaces) {\n return match.range.from === dismissedRange.from\n }\n\n return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)\n }\n\n // small helper used internally by the view to dispatch an exit\n function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {\n try {\n const state = pluginKey.getState(view.state)\n const decorationNode = state?.decorationId\n ? view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n : null\n\n const exitProps: SuggestionProps = {\n // @ts-ignore editor is available in closure\n editor,\n range: state?.range || { from: 0, to: 0 },\n query: state?.query || null,\n text: state?.text || null,\n items: [],\n command: commandProps => {\n return command({ editor, range: state?.range || { from: 0, to: 0 }, props: commandProps as any })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n renderer?.onExit?.(exitProps)\n } catch {\n // ignore errors from consumer renderers\n }\n\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n // Dispatch a metadata-only transaction to signal the plugin to exit\n view.dispatch(tr)\n }\n\n const plugin: Plugin<any> = new Plugin({\n key: pluginKey,\n\n view() {\n return {\n update: async (view, prevState) => {\n const prev = this.key?.getState(prevState)\n const next = this.key?.getState(view.state)\n\n // See how the state changed\n const moved = prev.active && next.active && prev.range.from !== next.range.from\n const started = !prev.active && next.active\n const stopped = prev.active && !next.active\n const changed = !started && !stopped && prev.query !== next.query\n\n const handleStart = started || (moved && changed)\n const handleChange = changed || moved\n const handleExit = stopped || (moved && changed)\n\n // Cancel when suggestion isn't active\n if (!handleStart && !handleChange && !handleExit) {\n return\n }\n\n const state = handleExit && !handleStart ? prev : next\n const decorationNode = view.dom.querySelector(`[data-decoration-id=\"${state.decorationId}\"]`)\n\n props = {\n editor,\n range: state.range,\n query: state.query,\n text: state.text,\n items: [],\n command: commandProps => {\n return command({\n editor,\n range: state.range,\n props: commandProps,\n })\n },\n decorationNode,\n clientRect: clientRectFor(view, decorationNode),\n }\n\n if (handleStart) {\n renderer?.onBeforeStart?.(props)\n }\n\n if (handleChange) {\n renderer?.onBeforeUpdate?.(props)\n }\n\n if (handleChange || handleStart) {\n props.items = await items({\n editor,\n query: state.query,\n })\n }\n\n if (handleExit) {\n renderer?.onExit?.(props)\n }\n\n if (handleChange) {\n renderer?.onUpdate?.(props)\n }\n\n if (handleStart) {\n renderer?.onStart?.(props)\n }\n },\n\n destroy: () => {\n if (!props) {\n return\n }\n\n renderer?.onExit?.(props)\n },\n }\n },\n\n state: {\n // Initialize the plugin's internal state.\n init() {\n const state: {\n active: boolean\n range: Range\n query: null | string\n text: null | string\n composing: boolean\n decorationId?: string | null\n dismissedRange: Range | null\n } = {\n active: false,\n range: {\n from: 0,\n to: 0,\n },\n query: null,\n text: null,\n composing: false,\n dismissedRange: null,\n }\n\n return state\n },\n\n // Apply changes to the plugin state from a view transaction.\n apply(transaction, prev, _oldState, state) {\n const { isEditable } = editor\n const { composing } = editor.view\n const { selection } = transaction\n const { empty, from } = selection\n const next = { ...prev }\n\n // If a transaction carries the exit meta for this plugin, immediately\n // deactivate the suggestion. This allows metadata-only transactions\n // (dispatched by escape or programmatic exit) to deterministically\n // clear decorations without changing the document.\n const meta = transaction.getMeta(pluginKey)\n if (meta && meta.exit) {\n next.active = false\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange\n\n return next\n }\n\n next.composing = composing\n\n if (transaction.docChanged && next.dismissedRange !== null) {\n next.dismissedRange = {\n from: transaction.mapping.map(next.dismissedRange.from),\n to: transaction.mapping.map(next.dismissedRange.to),\n }\n }\n\n // We can only be suggesting if the view is editable, and:\n // * there is no selection, or\n // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)\n if (isEditable && (empty || editor.view.composing)) {\n // Reset active state if we just left the previous suggestion range\n if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {\n next.active = false\n }\n\n // Try to match against where our cursor currently is\n const match = findSuggestionMatch({\n char,\n allowSpaces,\n allowToIncludeChar,\n allowedPrefixes,\n startOfLine,\n $position: selection.$from,\n })\n const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`\n\n // If we found a match, update the current state to show it\n if (\n match &&\n allow({\n editor,\n state,\n range: match.range,\n isActive: prev.active,\n }) &&\n (!shouldShow ||\n shouldShow({\n editor,\n range: match.range,\n query: match.query,\n text: match.text,\n transaction,\n }))\n ) {\n if (\n next.dismissedRange !== null &&\n !shouldKeepDismissed({\n match,\n dismissedRange: next.dismissedRange,\n state,\n transaction,\n })\n ) {\n next.dismissedRange = null\n }\n\n if (next.dismissedRange === null) {\n next.active = true\n next.decorationId = prev.decorationId ? prev.decorationId : decorationId\n next.range = match.range\n next.query = match.query\n next.text = match.text\n } else {\n next.active = false\n }\n } else {\n if (!match) {\n next.dismissedRange = null\n }\n next.active = false\n }\n } else {\n next.active = false\n }\n\n // Make sure to empty the range if suggestion is inactive\n if (!next.active) {\n next.decorationId = null\n next.range = { from: 0, to: 0 }\n next.query = null\n next.text = null\n }\n\n return next\n },\n },\n\n props: {\n // Call the keydown hook if suggestion is active.\n handleKeyDown(view, event) {\n const { active, range } = plugin.getState(view.state)\n\n if (!active) {\n return false\n }\n\n // If Escape is pressed, call onExit and dispatch a metadata-only\n // transaction to unset the suggestion state. This provides a safe\n // and deterministic way to exit the suggestion without altering the\n // document (avoids transaction mapping/mismatch issues).\n if (event.key === 'Escape' || event.key === 'Esc') {\n const state = plugin.getState(view.state)\n\n // Allow the consumer to react to Escape, but always clear the\n // suggestion state afterward so the decoration is removed too.\n renderer?.onKeyDown?.({ view, event, range: state.range })\n\n // dispatch metadata-only transaction to unset the plugin state\n dispatchExit(view, pluginKey)\n\n return true\n }\n\n const handled = renderer?.onKeyDown?.({ view, event, range }) || false\n return handled\n },\n\n // Setup decorator on the currently active suggestion.\n decorations(state) {\n const { active, range, decorationId, query } = plugin.getState(state)\n\n if (!active) {\n return null\n }\n\n const isEmpty = !query?.length\n const classNames = [decorationClass]\n\n if (isEmpty) {\n classNames.push(decorationEmptyClass)\n }\n\n return DecorationSet.create(state.doc, [\n Decoration.inline(range.from, range.to, {\n nodeName: decorationTag,\n class: classNames.join(' '),\n 'data-decoration-id': decorationId,\n 'data-decoration-content': decorationContent,\n }),\n ])\n },\n },\n })\n\n return plugin\n}\n\n/**\n * Programmatically exit a suggestion plugin by dispatching a metadata-only\n * transaction. This is the safe, recommended API to remove suggestion\n * decorations without touching the document or causing mapping errors.\n */\nexport function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {\n const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })\n view.dispatch(tr)\n}\n","import type { Range } from '@tiptap/core'\nimport { escapeForRegEx } from '@tiptap/core'\nimport type { ResolvedPos } from '@tiptap/pm/model'\n\nexport interface Trigger {\n char: string\n allowSpaces: boolean\n allowToIncludeChar: boolean\n allowedPrefixes: string[] | null\n startOfLine: boolean\n $position: ResolvedPos\n}\n\nexport type SuggestionMatch = {\n range: Range\n query: string\n text: string\n} | null\n\nexport function findSuggestionMatch(config: Trigger): SuggestionMatch {\n const { char, allowSpaces: allowSpacesOption, allowToIncludeChar, allowedPrefixes, startOfLine, $position } = config\n\n const allowSpaces = allowSpacesOption && !allowToIncludeChar\n\n const escapedChar = escapeForRegEx(char)\n const suffix = new RegExp(`\\\\s${escapedChar}$`)\n const prefix = startOfLine ? '^' : ''\n const finalEscapedChar = allowToIncludeChar ? '' : escapedChar\n const regexp = allowSpaces\n ? new RegExp(`${prefix}${escapedChar}.*?(?=\\\\s${finalEscapedChar}|$)`, 'gm')\n : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\\\s${finalEscapedChar}]*`, 'gm')\n\n const text = $position.nodeBefore?.isText && $position.nodeBefore.text\n\n if (!text) {\n return null\n }\n\n const textFrom = $position.pos - text.length\n const match = Array.from(text.matchAll(regexp)).pop()\n\n if (!match || match.input === undefined || match.index === undefined) {\n return null\n }\n\n // JavaScript doesn't have lookbehinds. This hacks a check that first character\n // is a space or the start of the line\n const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)\n const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join('')}\\0]?$`).test(matchPrefix)\n\n if (allowedPrefixes !== null && !matchPrefixIsAllowed) {\n return null\n }\n\n // The absolute position of the match in the document\n const from = textFrom + match.index\n let to = from + match[0].length\n\n // Edge case handling; if spaces are allowed and we're directly in between\n // two triggers\n if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {\n match[0] += ' '\n to += 1\n }\n\n // If the $position is located within the matched substring, return that range\n if (from < $position.pos && to >= $position.pos) {\n return {\n range: {\n from,\n to,\n },\n query: match[0].slice(char.length),\n text: match[0],\n }\n }\n\n return null\n}\n","import { exitSuggestion, Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport { exitSuggestion }\n\nexport default Suggestion\n"],"mappings":"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","names":["findSuggestionMatch"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiptap/suggestion",
|
|
3
3
|
"description": "suggestion plugin for tiptap",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.22.1",
|
|
5
5
|
"homepage": "https://tiptap.dev",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"tiptap",
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
"dist"
|
|
32
32
|
],
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@tiptap/core": "^3.
|
|
35
|
-
"@tiptap/pm": "^3.
|
|
34
|
+
"@tiptap/core": "^3.22.1",
|
|
35
|
+
"@tiptap/pm": "^3.22.1"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@tiptap/core": "^3.
|
|
39
|
-
"@tiptap/pm": "^3.
|
|
38
|
+
"@tiptap/core": "^3.22.1",
|
|
39
|
+
"@tiptap/pm": "^3.22.1"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
|
@@ -2,7 +2,7 @@ import { Editor, Extension } from '@tiptap/core'
|
|
|
2
2
|
import StarterKit from '@tiptap/starter-kit'
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
|
|
5
|
-
import { Suggestion } from '../suggestion.js'
|
|
5
|
+
import { exitSuggestion, Suggestion, SuggestionPluginKey } from '../suggestion.js'
|
|
6
6
|
|
|
7
7
|
describe('suggestion integration', () => {
|
|
8
8
|
it('should respect shouldShow returning false', async () => {
|
|
@@ -125,3 +125,285 @@ describe('suggestion integration', () => {
|
|
|
125
125
|
editor.destroy()
|
|
126
126
|
})
|
|
127
127
|
})
|
|
128
|
+
|
|
129
|
+
describe('suggestion dismissal', () => {
|
|
130
|
+
/** Builds a minimal editor with a single @-mention suggestion and returns helpers. */
|
|
131
|
+
function setup(
|
|
132
|
+
options: {
|
|
133
|
+
allowSpaces?: boolean
|
|
134
|
+
allowToIncludeChar?: boolean
|
|
135
|
+
shouldResetDismissed?: Parameters<typeof Suggestion>[0]['shouldResetDismissed']
|
|
136
|
+
} = {},
|
|
137
|
+
) {
|
|
138
|
+
const onStart = vi.fn()
|
|
139
|
+
const onUpdate = vi.fn()
|
|
140
|
+
const onExit = vi.fn()
|
|
141
|
+
|
|
142
|
+
const MentionExtension = Extension.create({
|
|
143
|
+
name: 'mention-dismiss',
|
|
144
|
+
addProseMirrorPlugins() {
|
|
145
|
+
return [
|
|
146
|
+
Suggestion({
|
|
147
|
+
editor: this.editor,
|
|
148
|
+
char: '@',
|
|
149
|
+
allowSpaces: options.allowSpaces,
|
|
150
|
+
allowToIncludeChar: options.allowToIncludeChar,
|
|
151
|
+
items: () => [],
|
|
152
|
+
shouldResetDismissed: options.shouldResetDismissed,
|
|
153
|
+
render: () => ({ onStart, onUpdate, onExit }),
|
|
154
|
+
}),
|
|
155
|
+
]
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const editor = new Editor({
|
|
160
|
+
extensions: [StarterKit, MentionExtension],
|
|
161
|
+
content: '<p></p>',
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return { editor, onStart, onUpdate, onExit }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
it('does not re-open the suggestion when the user keeps typing in the same word after dismissal', async () => {
|
|
168
|
+
const { editor, onStart, onUpdate } = setup()
|
|
169
|
+
|
|
170
|
+
// Trigger suggestion
|
|
171
|
+
editor.chain().insertContent('@fo').run()
|
|
172
|
+
await Promise.resolve()
|
|
173
|
+
expect(onStart).toHaveBeenCalledTimes(1)
|
|
174
|
+
|
|
175
|
+
// Dismiss via exitSuggestion (same as pressing Escape)
|
|
176
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
177
|
+
await Promise.resolve()
|
|
178
|
+
|
|
179
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
180
|
+
const updateCallsBefore = onUpdate.mock.calls.length
|
|
181
|
+
|
|
182
|
+
// Keep typing in the same word
|
|
183
|
+
editor.chain().insertContent('o').run()
|
|
184
|
+
await Promise.resolve()
|
|
185
|
+
|
|
186
|
+
expect(onStart.mock.calls.length).toBe(startCallsBefore)
|
|
187
|
+
expect(onUpdate.mock.calls.length).toBe(updateCallsBefore)
|
|
188
|
+
|
|
189
|
+
editor.destroy()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('removes the suggestion decoration when the suggestion is dismissed', async () => {
|
|
193
|
+
const { editor } = setup()
|
|
194
|
+
|
|
195
|
+
editor.chain().insertContent('@foo').run()
|
|
196
|
+
await Promise.resolve()
|
|
197
|
+
|
|
198
|
+
expect(editor.view.dom.querySelector('.suggestion')).not.toBeNull()
|
|
199
|
+
|
|
200
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
201
|
+
await Promise.resolve()
|
|
202
|
+
|
|
203
|
+
expect(editor.view.dom.querySelector('.suggestion')).toBeNull()
|
|
204
|
+
|
|
205
|
+
editor.destroy()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('removes the suggestion decoration on Escape even when the renderer handles the keydown', async () => {
|
|
209
|
+
const MentionExtension = Extension.create({
|
|
210
|
+
name: 'mention-escape-handled',
|
|
211
|
+
addProseMirrorPlugins() {
|
|
212
|
+
return [
|
|
213
|
+
Suggestion({
|
|
214
|
+
editor: this.editor,
|
|
215
|
+
char: '@',
|
|
216
|
+
items: () => [],
|
|
217
|
+
render: () => ({
|
|
218
|
+
onKeyDown: ({ event }) => event.key === 'Escape',
|
|
219
|
+
}),
|
|
220
|
+
}),
|
|
221
|
+
]
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const editor = new Editor({
|
|
226
|
+
extensions: [StarterKit, MentionExtension],
|
|
227
|
+
content: '<p></p>',
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
editor.chain().insertContent('@foo').run()
|
|
231
|
+
await Promise.resolve()
|
|
232
|
+
|
|
233
|
+
expect(editor.view.dom.querySelector('.suggestion')).not.toBeNull()
|
|
234
|
+
|
|
235
|
+
editor.view.someProp('handleKeyDown', f => f(editor.view, new KeyboardEvent('keydown', { key: 'Escape' })))
|
|
236
|
+
await Promise.resolve()
|
|
237
|
+
|
|
238
|
+
expect(editor.view.dom.querySelector('.suggestion')).toBeNull()
|
|
239
|
+
|
|
240
|
+
editor.destroy()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('keeps the suggestion decoration removed while dismissal is being preserved', async () => {
|
|
244
|
+
const { editor } = setup({ allowSpaces: true })
|
|
245
|
+
|
|
246
|
+
editor.chain().insertContent('@foo').run()
|
|
247
|
+
await Promise.resolve()
|
|
248
|
+
|
|
249
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
250
|
+
await Promise.resolve()
|
|
251
|
+
|
|
252
|
+
editor.chain().insertContent(' bar').run()
|
|
253
|
+
await Promise.resolve()
|
|
254
|
+
|
|
255
|
+
expect(editor.view.dom.querySelector('.suggestion')).toBeNull()
|
|
256
|
+
|
|
257
|
+
editor.destroy()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('re-opens the suggestion after a space is inserted following dismissal', async () => {
|
|
261
|
+
const { editor, onStart } = setup()
|
|
262
|
+
|
|
263
|
+
// Trigger and dismiss
|
|
264
|
+
editor.chain().insertContent('@foo').run()
|
|
265
|
+
await Promise.resolve()
|
|
266
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
267
|
+
await Promise.resolve()
|
|
268
|
+
|
|
269
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
270
|
+
|
|
271
|
+
// Space clears dismissed state; typing a new @ afterwards should open suggestion
|
|
272
|
+
editor.chain().insertContent(' @').run()
|
|
273
|
+
await Promise.resolve()
|
|
274
|
+
|
|
275
|
+
expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore)
|
|
276
|
+
|
|
277
|
+
editor.destroy()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('re-opens the suggestion after a newline is inserted following dismissal', async () => {
|
|
281
|
+
const { editor, onStart } = setup()
|
|
282
|
+
|
|
283
|
+
// Trigger and dismiss
|
|
284
|
+
editor.chain().insertContent('@foo').run()
|
|
285
|
+
await Promise.resolve()
|
|
286
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
287
|
+
await Promise.resolve()
|
|
288
|
+
|
|
289
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
290
|
+
|
|
291
|
+
// Newline clears dismissed state; typing a new @ afterwards should open suggestion
|
|
292
|
+
editor.commands.enter()
|
|
293
|
+
await Promise.resolve()
|
|
294
|
+
editor.chain().insertContent('@').run()
|
|
295
|
+
await Promise.resolve()
|
|
296
|
+
|
|
297
|
+
expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore)
|
|
298
|
+
|
|
299
|
+
editor.destroy()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('keeps the suggestion dismissed across spaces when allowSpaces is enabled', async () => {
|
|
303
|
+
const { editor, onStart, onUpdate } = setup({ allowSpaces: true })
|
|
304
|
+
|
|
305
|
+
editor.chain().insertContent('@foo').run()
|
|
306
|
+
await Promise.resolve()
|
|
307
|
+
expect(onStart).toHaveBeenCalledTimes(1)
|
|
308
|
+
|
|
309
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
310
|
+
await Promise.resolve()
|
|
311
|
+
|
|
312
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
313
|
+
const updateCallsBefore = onUpdate.mock.calls.length
|
|
314
|
+
|
|
315
|
+
editor.chain().insertContent(' bar').run()
|
|
316
|
+
await Promise.resolve()
|
|
317
|
+
|
|
318
|
+
expect(onStart.mock.calls.length).toBe(startCallsBefore)
|
|
319
|
+
expect(onUpdate.mock.calls.length).toBe(updateCallsBefore)
|
|
320
|
+
|
|
321
|
+
editor.destroy()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('does not treat spaces as part of the dismissed context when allowToIncludeChar disables allowSpaces', async () => {
|
|
325
|
+
const { editor, onStart } = setup({ allowSpaces: true, allowToIncludeChar: true })
|
|
326
|
+
|
|
327
|
+
editor.chain().insertContent('@foo').run()
|
|
328
|
+
await Promise.resolve()
|
|
329
|
+
expect(onStart).toHaveBeenCalledTimes(1)
|
|
330
|
+
|
|
331
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
332
|
+
await Promise.resolve()
|
|
333
|
+
|
|
334
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
335
|
+
|
|
336
|
+
editor.chain().insertContent(' @').run()
|
|
337
|
+
await Promise.resolve()
|
|
338
|
+
|
|
339
|
+
expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore)
|
|
340
|
+
|
|
341
|
+
editor.destroy()
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('re-opens the suggestion when the trigger char is deleted and retyped', async () => {
|
|
345
|
+
const { editor, onStart } = setup()
|
|
346
|
+
|
|
347
|
+
// Trigger and dismiss
|
|
348
|
+
editor.chain().insertContent('@').run()
|
|
349
|
+
await Promise.resolve()
|
|
350
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
351
|
+
await Promise.resolve()
|
|
352
|
+
|
|
353
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
354
|
+
|
|
355
|
+
// Delete the @ — cursor leaves trigger context, dismissedFrom clears
|
|
356
|
+
editor.commands.deleteRange({ from: 1, to: 2 })
|
|
357
|
+
await Promise.resolve()
|
|
358
|
+
|
|
359
|
+
// Retype @
|
|
360
|
+
editor.chain().insertContent('@').run()
|
|
361
|
+
await Promise.resolve()
|
|
362
|
+
|
|
363
|
+
expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore)
|
|
364
|
+
|
|
365
|
+
editor.destroy()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('re-opens the suggestion when a different trigger is typed elsewhere', async () => {
|
|
369
|
+
const { editor, onStart } = setup()
|
|
370
|
+
|
|
371
|
+
// Trigger and dismiss at first @
|
|
372
|
+
editor.chain().insertContent('@foo').run()
|
|
373
|
+
await Promise.resolve()
|
|
374
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
375
|
+
await Promise.resolve()
|
|
376
|
+
|
|
377
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
378
|
+
|
|
379
|
+
// Move to a new word and type a fresh @
|
|
380
|
+
editor.chain().insertContent(' @').run()
|
|
381
|
+
await Promise.resolve()
|
|
382
|
+
|
|
383
|
+
expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore)
|
|
384
|
+
|
|
385
|
+
editor.destroy()
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('allows consumers to reset the dismissed context manually', async () => {
|
|
389
|
+
const shouldResetDismissed = vi.fn(({ transaction }) =>
|
|
390
|
+
transaction.doc.textBetween(0, transaction.doc.content.size, '\n').includes('.'),
|
|
391
|
+
)
|
|
392
|
+
const { editor, onStart } = setup({ shouldResetDismissed })
|
|
393
|
+
|
|
394
|
+
editor.chain().insertContent('@foo').run()
|
|
395
|
+
await Promise.resolve()
|
|
396
|
+
exitSuggestion(editor.view, SuggestionPluginKey)
|
|
397
|
+
await Promise.resolve()
|
|
398
|
+
|
|
399
|
+
const startCallsBefore = onStart.mock.calls.length
|
|
400
|
+
|
|
401
|
+
editor.chain().insertContent('.').run()
|
|
402
|
+
await Promise.resolve()
|
|
403
|
+
|
|
404
|
+
expect(shouldResetDismissed).toHaveBeenCalled()
|
|
405
|
+
expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore)
|
|
406
|
+
|
|
407
|
+
editor.destroy()
|
|
408
|
+
})
|
|
409
|
+
})
|
package/src/suggestion.ts
CHANGED
|
@@ -4,8 +4,28 @@ import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
|
4
4
|
import type { EditorView } from '@tiptap/pm/view'
|
|
5
5
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
6
6
|
|
|
7
|
+
import type { SuggestionMatch } from './findSuggestionMatch.js'
|
|
7
8
|
import { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Returns true if the transaction inserted any whitespace or newline character.
|
|
12
|
+
* Used to determine when a dismissed suggestion should become active again.
|
|
13
|
+
*/
|
|
14
|
+
function hasInsertedWhitespace(transaction: Transaction): boolean {
|
|
15
|
+
if (!transaction.docChanged) {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
return transaction.steps.some(step => {
|
|
19
|
+
const slice = (step as any).slice
|
|
20
|
+
if (!slice?.content) {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
// textBetween with '\n' as block separator catches both inline spaces and newlines
|
|
24
|
+
const inserted = slice.content.textBetween(0, slice.content.size, '\n')
|
|
25
|
+
return /\s/.test(inserted)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
9
29
|
export interface SuggestionOptions<I = any, TSelected = any> {
|
|
10
30
|
/**
|
|
11
31
|
* The plugin key for the suggestion plugin.
|
|
@@ -34,6 +54,19 @@ export interface SuggestionOptions<I = any, TSelected = any> {
|
|
|
34
54
|
transaction: Transaction
|
|
35
55
|
}) => boolean
|
|
36
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Controls when a dismissed suggestion becomes active again.
|
|
59
|
+
* Return `true` to clear the dismissed context for the current transaction.
|
|
60
|
+
*/
|
|
61
|
+
shouldResetDismissed?: (props: {
|
|
62
|
+
editor: Editor
|
|
63
|
+
state: EditorState
|
|
64
|
+
range: Range
|
|
65
|
+
match: Exclude<SuggestionMatch, null>
|
|
66
|
+
transaction: Transaction
|
|
67
|
+
allowSpaces: boolean
|
|
68
|
+
}) => boolean
|
|
69
|
+
|
|
37
70
|
/**
|
|
38
71
|
* The editor instance.
|
|
39
72
|
* @default null
|
|
@@ -222,9 +255,11 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
222
255
|
allow = () => true,
|
|
223
256
|
findSuggestionMatch = defaultFindSuggestionMatch,
|
|
224
257
|
shouldShow,
|
|
258
|
+
shouldResetDismissed,
|
|
225
259
|
}: SuggestionOptions<I, TSelected>) {
|
|
226
260
|
let props: SuggestionProps<I, TSelected> | undefined
|
|
227
261
|
const renderer = render?.()
|
|
262
|
+
const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar
|
|
228
263
|
|
|
229
264
|
// Gets the DOM rectangle corresponding to the current editor cursor anchor position
|
|
230
265
|
// Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object
|
|
@@ -257,6 +292,38 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
257
292
|
return currentDecorationNode?.getBoundingClientRect() || null
|
|
258
293
|
}
|
|
259
294
|
}
|
|
295
|
+
|
|
296
|
+
const shouldKeepDismissed = ({
|
|
297
|
+
match,
|
|
298
|
+
dismissedRange,
|
|
299
|
+
state,
|
|
300
|
+
transaction,
|
|
301
|
+
}: {
|
|
302
|
+
match: Exclude<SuggestionMatch, null>
|
|
303
|
+
dismissedRange: Range
|
|
304
|
+
state: EditorState
|
|
305
|
+
transaction: Transaction
|
|
306
|
+
}) => {
|
|
307
|
+
if (
|
|
308
|
+
shouldResetDismissed?.({
|
|
309
|
+
editor,
|
|
310
|
+
state,
|
|
311
|
+
range: dismissedRange,
|
|
312
|
+
match,
|
|
313
|
+
transaction,
|
|
314
|
+
allowSpaces: effectiveAllowSpaces,
|
|
315
|
+
})
|
|
316
|
+
) {
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (effectiveAllowSpaces) {
|
|
321
|
+
return match.range.from === dismissedRange.from
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)
|
|
325
|
+
}
|
|
326
|
+
|
|
260
327
|
// small helper used internally by the view to dispatch an exit
|
|
261
328
|
function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {
|
|
262
329
|
try {
|
|
@@ -381,6 +448,7 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
381
448
|
text: null | string
|
|
382
449
|
composing: boolean
|
|
383
450
|
decorationId?: string | null
|
|
451
|
+
dismissedRange: Range | null
|
|
384
452
|
} = {
|
|
385
453
|
active: false,
|
|
386
454
|
range: {
|
|
@@ -390,6 +458,7 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
390
458
|
query: null,
|
|
391
459
|
text: null,
|
|
392
460
|
composing: false,
|
|
461
|
+
dismissedRange: null,
|
|
393
462
|
}
|
|
394
463
|
|
|
395
464
|
return state
|
|
@@ -414,12 +483,20 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
414
483
|
next.range = { from: 0, to: 0 }
|
|
415
484
|
next.query = null
|
|
416
485
|
next.text = null
|
|
486
|
+
next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange
|
|
417
487
|
|
|
418
488
|
return next
|
|
419
489
|
}
|
|
420
490
|
|
|
421
491
|
next.composing = composing
|
|
422
492
|
|
|
493
|
+
if (transaction.docChanged && next.dismissedRange !== null) {
|
|
494
|
+
next.dismissedRange = {
|
|
495
|
+
from: transaction.mapping.map(next.dismissedRange.from),
|
|
496
|
+
to: transaction.mapping.map(next.dismissedRange.to),
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
423
500
|
// We can only be suggesting if the view is editable, and:
|
|
424
501
|
// * there is no selection, or
|
|
425
502
|
// * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)
|
|
@@ -458,12 +535,31 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
458
535
|
transaction,
|
|
459
536
|
}))
|
|
460
537
|
) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
538
|
+
if (
|
|
539
|
+
next.dismissedRange !== null &&
|
|
540
|
+
!shouldKeepDismissed({
|
|
541
|
+
match,
|
|
542
|
+
dismissedRange: next.dismissedRange,
|
|
543
|
+
state,
|
|
544
|
+
transaction,
|
|
545
|
+
})
|
|
546
|
+
) {
|
|
547
|
+
next.dismissedRange = null
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (next.dismissedRange === null) {
|
|
551
|
+
next.active = true
|
|
552
|
+
next.decorationId = prev.decorationId ? prev.decorationId : decorationId
|
|
553
|
+
next.range = match.range
|
|
554
|
+
next.query = match.query
|
|
555
|
+
next.text = match.text
|
|
556
|
+
} else {
|
|
557
|
+
next.active = false
|
|
558
|
+
}
|
|
466
559
|
} else {
|
|
560
|
+
if (!match) {
|
|
561
|
+
next.dismissedRange = null
|
|
562
|
+
}
|
|
467
563
|
next.active = false
|
|
468
564
|
}
|
|
469
565
|
} else {
|
|
@@ -497,42 +593,10 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
497
593
|
// document (avoids transaction mapping/mismatch issues).
|
|
498
594
|
if (event.key === 'Escape' || event.key === 'Esc') {
|
|
499
595
|
const state = plugin.getState(view.state)
|
|
500
|
-
const cachedNode = props?.decorationNode ?? null
|
|
501
|
-
const decorationNode =
|
|
502
|
-
cachedNode ??
|
|
503
|
-
(state?.decorationId ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null)
|
|
504
|
-
|
|
505
|
-
// Give the consumer a chance to handle Escape via onKeyDown first.
|
|
506
|
-
// If the consumer returns `true` we assume they handled the event and
|
|
507
|
-
// we won't call onExit/dispatchExit so they can both prevent
|
|
508
|
-
// propagation and decide whether to close the suggestion themselves.
|
|
509
|
-
const handledByKeyDown = renderer?.onKeyDown?.({ view, event, range: state.range }) || false
|
|
510
|
-
|
|
511
|
-
if (handledByKeyDown) {
|
|
512
|
-
return true
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
const exitProps: SuggestionProps = {
|
|
516
|
-
editor,
|
|
517
|
-
range: state.range,
|
|
518
|
-
query: state.query,
|
|
519
|
-
text: state.text,
|
|
520
|
-
items: [],
|
|
521
|
-
command: commandProps => {
|
|
522
|
-
return command({ editor, range: state.range, props: commandProps as any })
|
|
523
|
-
},
|
|
524
|
-
decorationNode,
|
|
525
|
-
// If we have a cached decoration node, use it for the clientRect
|
|
526
|
-
// to avoid another DOM lookup. If not, leave clientRect null and
|
|
527
|
-
// let consumer decide if they want to query.
|
|
528
|
-
clientRect: decorationNode
|
|
529
|
-
? () => {
|
|
530
|
-
return decorationNode.getBoundingClientRect() || null
|
|
531
|
-
}
|
|
532
|
-
: null,
|
|
533
|
-
}
|
|
534
596
|
|
|
535
|
-
|
|
597
|
+
// Allow the consumer to react to Escape, but always clear the
|
|
598
|
+
// suggestion state afterward so the decoration is removed too.
|
|
599
|
+
renderer?.onKeyDown?.({ view, event, range: state.range })
|
|
536
600
|
|
|
537
601
|
// dispatch metadata-only transaction to unset the plugin state
|
|
538
602
|
dispatchExit(view, pluginKey)
|