@tiptap/suggestion 3.2.2 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +122 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +121 -11
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +3 -1
- package/src/suggestion.ts +181 -13
package/dist/index.cjs
CHANGED
|
@@ -23,6 +23,7 @@ __export(index_exports, {
|
|
|
23
23
|
Suggestion: () => Suggestion,
|
|
24
24
|
SuggestionPluginKey: () => SuggestionPluginKey,
|
|
25
25
|
default: () => index_default,
|
|
26
|
+
exitSuggestion: () => exitSuggestion,
|
|
26
27
|
findSuggestionMatch: () => findSuggestionMatch
|
|
27
28
|
});
|
|
28
29
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -76,6 +77,7 @@ function findSuggestionMatch(config) {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
// src/suggestion.ts
|
|
80
|
+
var clickHandlerMap = /* @__PURE__ */ new WeakMap();
|
|
79
81
|
var SuggestionPluginKey = new import_state.PluginKey("suggestion");
|
|
80
82
|
function Suggestion({
|
|
81
83
|
pluginKey = SuggestionPluginKey,
|
|
@@ -97,9 +99,79 @@ function Suggestion({
|
|
|
97
99
|
}) {
|
|
98
100
|
let props;
|
|
99
101
|
const renderer = render == null ? void 0 : render();
|
|
102
|
+
const clientRectFor = (view, decorationNode) => {
|
|
103
|
+
if (!decorationNode) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return () => {
|
|
107
|
+
const state = pluginKey.getState(editor.state);
|
|
108
|
+
const decorationId = state == null ? void 0 : state.decorationId;
|
|
109
|
+
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`);
|
|
110
|
+
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
function dispatchExit(view, pluginKeyRef) {
|
|
114
|
+
var _a;
|
|
115
|
+
try {
|
|
116
|
+
const state = pluginKey.getState(view.state);
|
|
117
|
+
const decorationNode = (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null;
|
|
118
|
+
const exitProps = {
|
|
119
|
+
// @ts-ignore editor is available in closure
|
|
120
|
+
editor,
|
|
121
|
+
range: (state == null ? void 0 : state.range) || { from: 0, to: 0 },
|
|
122
|
+
query: (state == null ? void 0 : state.query) || null,
|
|
123
|
+
text: (state == null ? void 0 : state.text) || null,
|
|
124
|
+
items: [],
|
|
125
|
+
command: (commandProps) => {
|
|
126
|
+
return command({ editor, range: (state == null ? void 0 : state.range) || { from: 0, to: 0 }, props: commandProps });
|
|
127
|
+
},
|
|
128
|
+
decorationNode,
|
|
129
|
+
clientRect: clientRectFor(view, decorationNode)
|
|
130
|
+
};
|
|
131
|
+
(_a = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _a.call(renderer, exitProps);
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true });
|
|
135
|
+
view.dispatch(tr);
|
|
136
|
+
}
|
|
100
137
|
const plugin = new import_state.Plugin({
|
|
101
138
|
key: pluginKey,
|
|
102
|
-
view() {
|
|
139
|
+
view(editorView) {
|
|
140
|
+
const ensureClickHandler = (view) => {
|
|
141
|
+
if (clickHandlerMap.has(view)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const handler = (event) => {
|
|
145
|
+
if (!props) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const decorationNode = props.decorationNode;
|
|
149
|
+
const target = event.target;
|
|
150
|
+
if (!decorationNode) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (target && decorationNode.contains(target)) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (target && view.dom.contains(target)) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (target && target.closest && target.closest(".react-renderer")) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
dispatchExit(view, pluginKey);
|
|
163
|
+
};
|
|
164
|
+
document.addEventListener("mousedown", handler, true);
|
|
165
|
+
clickHandlerMap.set(view, handler);
|
|
166
|
+
};
|
|
167
|
+
const removeClickHandler = (view) => {
|
|
168
|
+
const handler = clickHandlerMap.get(view);
|
|
169
|
+
if (!handler) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
document.removeEventListener("mousedown", handler, true);
|
|
173
|
+
clickHandlerMap.delete(view);
|
|
174
|
+
};
|
|
103
175
|
return {
|
|
104
176
|
update: async (view, prevState) => {
|
|
105
177
|
var _a, _b, _c, _d, _e, _f, _g;
|
|
@@ -131,14 +203,7 @@ function Suggestion({
|
|
|
131
203
|
});
|
|
132
204
|
},
|
|
133
205
|
decorationNode,
|
|
134
|
-
|
|
135
|
-
// this can be used for building popups without a DOM node
|
|
136
|
-
clientRect: decorationNode ? () => {
|
|
137
|
-
var _a2;
|
|
138
|
-
const { decorationId } = (_a2 = this.key) == null ? void 0 : _a2.getState(editor.state);
|
|
139
|
-
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`);
|
|
140
|
-
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
|
|
141
|
-
} : null
|
|
206
|
+
clientRect: clientRectFor(view, decorationNode)
|
|
142
207
|
};
|
|
143
208
|
if (handleStart) {
|
|
144
209
|
(_c = renderer == null ? void 0 : renderer.onBeforeStart) == null ? void 0 : _c.call(renderer, props);
|
|
@@ -161,9 +226,15 @@ function Suggestion({
|
|
|
161
226
|
if (handleStart) {
|
|
162
227
|
(_g = renderer == null ? void 0 : renderer.onStart) == null ? void 0 : _g.call(renderer, props);
|
|
163
228
|
}
|
|
229
|
+
if (next.active) {
|
|
230
|
+
ensureClickHandler(view);
|
|
231
|
+
} else {
|
|
232
|
+
removeClickHandler(editorView);
|
|
233
|
+
}
|
|
164
234
|
},
|
|
165
235
|
destroy: () => {
|
|
166
236
|
var _a;
|
|
237
|
+
removeClickHandler(editorView);
|
|
167
238
|
if (!props) {
|
|
168
239
|
return;
|
|
169
240
|
}
|
|
@@ -193,6 +264,15 @@ function Suggestion({
|
|
|
193
264
|
const { selection } = transaction;
|
|
194
265
|
const { empty, from } = selection;
|
|
195
266
|
const next = { ...prev };
|
|
267
|
+
const meta = transaction.getMeta(pluginKey);
|
|
268
|
+
if (meta && meta.exit) {
|
|
269
|
+
next.active = false;
|
|
270
|
+
next.decorationId = null;
|
|
271
|
+
next.range = { from: 0, to: 0 };
|
|
272
|
+
next.query = null;
|
|
273
|
+
next.text = null;
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
196
276
|
next.composing = composing;
|
|
197
277
|
if (isEditable && (empty || editor.view.composing)) {
|
|
198
278
|
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
|
|
@@ -236,12 +316,38 @@ function Suggestion({
|
|
|
236
316
|
props: {
|
|
237
317
|
// Call the keydown hook if suggestion is active.
|
|
238
318
|
handleKeyDown(view, event) {
|
|
239
|
-
var _a;
|
|
319
|
+
var _a, _b, _c;
|
|
240
320
|
const { active, range } = plugin.getState(view.state);
|
|
241
321
|
if (!active) {
|
|
242
322
|
return false;
|
|
243
323
|
}
|
|
244
|
-
|
|
324
|
+
if (event.key === "Escape" || event.key === "Esc") {
|
|
325
|
+
const state = plugin.getState(view.state);
|
|
326
|
+
const cachedNode = (_a = props == null ? void 0 : props.decorationNode) != null ? _a : null;
|
|
327
|
+
const decorationNode = cachedNode != null ? cachedNode : (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null;
|
|
328
|
+
const exitProps = {
|
|
329
|
+
editor,
|
|
330
|
+
range: state.range,
|
|
331
|
+
query: state.query,
|
|
332
|
+
text: state.text,
|
|
333
|
+
items: [],
|
|
334
|
+
command: (commandProps) => {
|
|
335
|
+
return command({ editor, range: state.range, props: commandProps });
|
|
336
|
+
},
|
|
337
|
+
decorationNode,
|
|
338
|
+
// If we have a cached decoration node, use it for the clientRect
|
|
339
|
+
// to avoid another DOM lookup. If not, leave clientRect null and
|
|
340
|
+
// let consumer decide if they want to query.
|
|
341
|
+
clientRect: decorationNode ? () => {
|
|
342
|
+
return decorationNode.getBoundingClientRect() || null;
|
|
343
|
+
} : null
|
|
344
|
+
};
|
|
345
|
+
(_b = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _b.call(renderer, exitProps);
|
|
346
|
+
dispatchExit(view, pluginKey);
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
const handled = ((_c = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _c.call(renderer, { view, event, range })) || false;
|
|
350
|
+
return handled;
|
|
245
351
|
},
|
|
246
352
|
// Setup decorator on the currently active suggestion.
|
|
247
353
|
decorations(state) {
|
|
@@ -267,6 +373,10 @@ function Suggestion({
|
|
|
267
373
|
});
|
|
268
374
|
return plugin;
|
|
269
375
|
}
|
|
376
|
+
function exitSuggestion(view, pluginKeyRef = SuggestionPluginKey) {
|
|
377
|
+
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true });
|
|
378
|
+
view.dispatch(tr);
|
|
379
|
+
}
|
|
270
380
|
|
|
271
381
|
// src/index.ts
|
|
272
382
|
var index_default = Suggestion;
|
|
@@ -274,6 +384,7 @@ var index_default = Suggestion;
|
|
|
274
384
|
0 && (module.exports = {
|
|
275
385
|
Suggestion,
|
|
276
386
|
SuggestionPluginKey,
|
|
387
|
+
exitSuggestion,
|
|
277
388
|
findSuggestionMatch
|
|
278
389
|
});
|
|
279
390
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/suggestion.ts","../src/findSuggestionMatch.ts"],"sourcesContent":["import { Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport default Suggestion\n","import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState } 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 * 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}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\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 // virtual node for positioning\n // this can be used for building popups without a DOM node\n clientRect: decorationNode\n ? () => {\n // because of `items` can be asynchrounous we’ll search for the current decoration node\n const { decorationId } = this.key?.getState(editor.state) // eslint-disable-line\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n : null,\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 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 ) {\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 return renderer?.onKeyDown?.({ view, event, range }) || false\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","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","_a"]}
|
|
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 } 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\n// Track document click handlers per EditorView instance to avoid accidental\n// leaks when multiple editors are created/destroyed. WeakMap ensures handlers\n// don't keep views alive.\nconst clickHandlerMap: WeakMap<EditorView, (event: MouseEvent) => void> = new WeakMap()\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 * 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}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\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 null\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 // Try to call renderer.onExit so consumer renderers (for example the\n // demos' ReactRenderer) can clean up and unmount immediately. This\n // covers paths where we only dispatch a metadata transaction (like\n // click-outside) and ensures we don't leak DOM nodes / React roots.\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(editorView: EditorView) {\n const ensureClickHandler = (view: EditorView) => {\n if (clickHandlerMap.has(view)) {\n return\n }\n\n const handler = (event: MouseEvent) => {\n if (!props) {\n return\n }\n\n const decorationNode = props.decorationNode\n const target = event.target as Element | null\n\n if (!decorationNode) {\n return\n }\n\n if (target && decorationNode.contains(target)) {\n return\n }\n\n if (target && view.dom.contains(target)) {\n return\n }\n\n if (target && target.closest && target.closest('.react-renderer')) {\n return\n }\n\n dispatchExit(view, pluginKey)\n }\n\n document.addEventListener('mousedown', handler, true)\n clickHandlerMap.set(view, handler)\n }\n\n const removeClickHandler = (view: EditorView) => {\n const handler = clickHandlerMap.get(view)\n if (!handler) {\n return\n }\n\n document.removeEventListener('mousedown', handler, true)\n clickHandlerMap.delete(view)\n }\n\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 // Install / remove click handler depending on suggestion active state\n if (next.active) {\n ensureClickHandler(view)\n } else {\n removeClickHandler(editorView)\n }\n },\n\n destroy: () => {\n removeClickHandler(editorView)\n\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 ) {\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 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;;;ADnEA,IAAM,kBAAoE,oBAAI,QAAQ;AA8K/E,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;AACxB,GAAoC;AAClC,MAAI;AACJ,QAAM,WAAW;AAMjB,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;AAtOnE;AAuOI,QAAI;AAKF,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,KAAK,YAAwB;AAC3B,YAAM,qBAAqB,CAAC,SAAqB;AAC/C,YAAI,gBAAgB,IAAI,IAAI,GAAG;AAC7B;AAAA,QACF;AAEA,cAAM,UAAU,CAAC,UAAsB;AACrC,cAAI,CAAC,OAAO;AACV;AAAA,UACF;AAEA,gBAAM,iBAAiB,MAAM;AAC7B,gBAAM,SAAS,MAAM;AAErB,cAAI,CAAC,gBAAgB;AACnB;AAAA,UACF;AAEA,cAAI,UAAU,eAAe,SAAS,MAAM,GAAG;AAC7C;AAAA,UACF;AAEA,cAAI,UAAU,KAAK,IAAI,SAAS,MAAM,GAAG;AACvC;AAAA,UACF;AAEA,cAAI,UAAU,OAAO,WAAW,OAAO,QAAQ,iBAAiB,GAAG;AACjE;AAAA,UACF;AAEA,uBAAa,MAAM,SAAS;AAAA,QAC9B;AAEA,iBAAS,iBAAiB,aAAa,SAAS,IAAI;AACpD,wBAAgB,IAAI,MAAM,OAAO;AAAA,MACnC;AAEA,YAAM,qBAAqB,CAAC,SAAqB;AAC/C,cAAM,UAAU,gBAAgB,IAAI,IAAI;AACxC,YAAI,CAAC,SAAS;AACZ;AAAA,QACF;AAEA,iBAAS,oBAAoB,aAAa,SAAS,IAAI;AACvD,wBAAgB,OAAO,IAAI;AAAA,MAC7B;AAEA,aAAO;AAAA,QACL,QAAQ,OAAO,MAAM,cAAc;AA5T3C;AA6TU,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;AAGA,cAAI,KAAK,QAAQ;AACf,+BAAmB,IAAI;AAAA,UACzB,OAAO;AACL,+BAAmB,UAAU;AAAA,UAC/B;AAAA,QACF;AAAA,QAEA,SAAS,MAAM;AAtYvB;AAuYU,6BAAmB,UAAU;AAE7B,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,GACD;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;AA3fjC;AA4fQ,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;AAElG,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;;;AD5kBA,IAAO,gBAAQ;","names":["findSuggestionMatch"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -184,5 +184,11 @@ declare const SuggestionPluginKey: PluginKey<any>;
|
|
|
184
184
|
* @see https://tiptap.dev/api/utilities/suggestion
|
|
185
185
|
*/
|
|
186
186
|
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, render, allow, findSuggestionMatch, }: SuggestionOptions<I, TSelected>): Plugin<any>;
|
|
187
|
+
/**
|
|
188
|
+
* Programmatically exit a suggestion plugin by dispatching a metadata-only
|
|
189
|
+
* transaction. This is the safe, recommended API to remove suggestion
|
|
190
|
+
* decorations without touching the document or causing mapping errors.
|
|
191
|
+
*/
|
|
192
|
+
declare function exitSuggestion(view: EditorView, pluginKeyRef?: PluginKey): void;
|
|
187
193
|
|
|
188
|
-
export { Suggestion, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionOptions, SuggestionPluginKey, type SuggestionProps, type Trigger, Suggestion as default, findSuggestionMatch };
|
|
194
|
+
export { Suggestion, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionOptions, SuggestionPluginKey, type SuggestionProps, type Trigger, Suggestion as default, exitSuggestion, findSuggestionMatch };
|
package/dist/index.d.ts
CHANGED
|
@@ -184,5 +184,11 @@ declare const SuggestionPluginKey: PluginKey<any>;
|
|
|
184
184
|
* @see https://tiptap.dev/api/utilities/suggestion
|
|
185
185
|
*/
|
|
186
186
|
declare function Suggestion<I = any, TSelected = any>({ pluginKey, editor, char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine, decorationTag, decorationClass, decorationContent, decorationEmptyClass, command, items, render, allow, findSuggestionMatch, }: SuggestionOptions<I, TSelected>): Plugin<any>;
|
|
187
|
+
/**
|
|
188
|
+
* Programmatically exit a suggestion plugin by dispatching a metadata-only
|
|
189
|
+
* transaction. This is the safe, recommended API to remove suggestion
|
|
190
|
+
* decorations without touching the document or causing mapping errors.
|
|
191
|
+
*/
|
|
192
|
+
declare function exitSuggestion(view: EditorView, pluginKeyRef?: PluginKey): void;
|
|
187
193
|
|
|
188
|
-
export { Suggestion, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionOptions, SuggestionPluginKey, type SuggestionProps, type Trigger, Suggestion as default, findSuggestionMatch };
|
|
194
|
+
export { Suggestion, type SuggestionKeyDownProps, type SuggestionMatch, type SuggestionOptions, SuggestionPluginKey, type SuggestionProps, type Trigger, Suggestion as default, exitSuggestion, findSuggestionMatch };
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,7 @@ function findSuggestionMatch(config) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// src/suggestion.ts
|
|
50
|
+
var clickHandlerMap = /* @__PURE__ */ new WeakMap();
|
|
50
51
|
var SuggestionPluginKey = new PluginKey("suggestion");
|
|
51
52
|
function Suggestion({
|
|
52
53
|
pluginKey = SuggestionPluginKey,
|
|
@@ -68,9 +69,79 @@ function Suggestion({
|
|
|
68
69
|
}) {
|
|
69
70
|
let props;
|
|
70
71
|
const renderer = render == null ? void 0 : render();
|
|
72
|
+
const clientRectFor = (view, decorationNode) => {
|
|
73
|
+
if (!decorationNode) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return () => {
|
|
77
|
+
const state = pluginKey.getState(editor.state);
|
|
78
|
+
const decorationId = state == null ? void 0 : state.decorationId;
|
|
79
|
+
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`);
|
|
80
|
+
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
function dispatchExit(view, pluginKeyRef) {
|
|
84
|
+
var _a;
|
|
85
|
+
try {
|
|
86
|
+
const state = pluginKey.getState(view.state);
|
|
87
|
+
const decorationNode = (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null;
|
|
88
|
+
const exitProps = {
|
|
89
|
+
// @ts-ignore editor is available in closure
|
|
90
|
+
editor,
|
|
91
|
+
range: (state == null ? void 0 : state.range) || { from: 0, to: 0 },
|
|
92
|
+
query: (state == null ? void 0 : state.query) || null,
|
|
93
|
+
text: (state == null ? void 0 : state.text) || null,
|
|
94
|
+
items: [],
|
|
95
|
+
command: (commandProps) => {
|
|
96
|
+
return command({ editor, range: (state == null ? void 0 : state.range) || { from: 0, to: 0 }, props: commandProps });
|
|
97
|
+
},
|
|
98
|
+
decorationNode,
|
|
99
|
+
clientRect: clientRectFor(view, decorationNode)
|
|
100
|
+
};
|
|
101
|
+
(_a = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _a.call(renderer, exitProps);
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true });
|
|
105
|
+
view.dispatch(tr);
|
|
106
|
+
}
|
|
71
107
|
const plugin = new Plugin({
|
|
72
108
|
key: pluginKey,
|
|
73
|
-
view() {
|
|
109
|
+
view(editorView) {
|
|
110
|
+
const ensureClickHandler = (view) => {
|
|
111
|
+
if (clickHandlerMap.has(view)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const handler = (event) => {
|
|
115
|
+
if (!props) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const decorationNode = props.decorationNode;
|
|
119
|
+
const target = event.target;
|
|
120
|
+
if (!decorationNode) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (target && decorationNode.contains(target)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (target && view.dom.contains(target)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (target && target.closest && target.closest(".react-renderer")) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
dispatchExit(view, pluginKey);
|
|
133
|
+
};
|
|
134
|
+
document.addEventListener("mousedown", handler, true);
|
|
135
|
+
clickHandlerMap.set(view, handler);
|
|
136
|
+
};
|
|
137
|
+
const removeClickHandler = (view) => {
|
|
138
|
+
const handler = clickHandlerMap.get(view);
|
|
139
|
+
if (!handler) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
document.removeEventListener("mousedown", handler, true);
|
|
143
|
+
clickHandlerMap.delete(view);
|
|
144
|
+
};
|
|
74
145
|
return {
|
|
75
146
|
update: async (view, prevState) => {
|
|
76
147
|
var _a, _b, _c, _d, _e, _f, _g;
|
|
@@ -102,14 +173,7 @@ function Suggestion({
|
|
|
102
173
|
});
|
|
103
174
|
},
|
|
104
175
|
decorationNode,
|
|
105
|
-
|
|
106
|
-
// this can be used for building popups without a DOM node
|
|
107
|
-
clientRect: decorationNode ? () => {
|
|
108
|
-
var _a2;
|
|
109
|
-
const { decorationId } = (_a2 = this.key) == null ? void 0 : _a2.getState(editor.state);
|
|
110
|
-
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`);
|
|
111
|
-
return (currentDecorationNode == null ? void 0 : currentDecorationNode.getBoundingClientRect()) || null;
|
|
112
|
-
} : null
|
|
176
|
+
clientRect: clientRectFor(view, decorationNode)
|
|
113
177
|
};
|
|
114
178
|
if (handleStart) {
|
|
115
179
|
(_c = renderer == null ? void 0 : renderer.onBeforeStart) == null ? void 0 : _c.call(renderer, props);
|
|
@@ -132,9 +196,15 @@ function Suggestion({
|
|
|
132
196
|
if (handleStart) {
|
|
133
197
|
(_g = renderer == null ? void 0 : renderer.onStart) == null ? void 0 : _g.call(renderer, props);
|
|
134
198
|
}
|
|
199
|
+
if (next.active) {
|
|
200
|
+
ensureClickHandler(view);
|
|
201
|
+
} else {
|
|
202
|
+
removeClickHandler(editorView);
|
|
203
|
+
}
|
|
135
204
|
},
|
|
136
205
|
destroy: () => {
|
|
137
206
|
var _a;
|
|
207
|
+
removeClickHandler(editorView);
|
|
138
208
|
if (!props) {
|
|
139
209
|
return;
|
|
140
210
|
}
|
|
@@ -164,6 +234,15 @@ function Suggestion({
|
|
|
164
234
|
const { selection } = transaction;
|
|
165
235
|
const { empty, from } = selection;
|
|
166
236
|
const next = { ...prev };
|
|
237
|
+
const meta = transaction.getMeta(pluginKey);
|
|
238
|
+
if (meta && meta.exit) {
|
|
239
|
+
next.active = false;
|
|
240
|
+
next.decorationId = null;
|
|
241
|
+
next.range = { from: 0, to: 0 };
|
|
242
|
+
next.query = null;
|
|
243
|
+
next.text = null;
|
|
244
|
+
return next;
|
|
245
|
+
}
|
|
167
246
|
next.composing = composing;
|
|
168
247
|
if (isEditable && (empty || editor.view.composing)) {
|
|
169
248
|
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
|
|
@@ -207,12 +286,38 @@ function Suggestion({
|
|
|
207
286
|
props: {
|
|
208
287
|
// Call the keydown hook if suggestion is active.
|
|
209
288
|
handleKeyDown(view, event) {
|
|
210
|
-
var _a;
|
|
289
|
+
var _a, _b, _c;
|
|
211
290
|
const { active, range } = plugin.getState(view.state);
|
|
212
291
|
if (!active) {
|
|
213
292
|
return false;
|
|
214
293
|
}
|
|
215
|
-
|
|
294
|
+
if (event.key === "Escape" || event.key === "Esc") {
|
|
295
|
+
const state = plugin.getState(view.state);
|
|
296
|
+
const cachedNode = (_a = props == null ? void 0 : props.decorationNode) != null ? _a : null;
|
|
297
|
+
const decorationNode = cachedNode != null ? cachedNode : (state == null ? void 0 : state.decorationId) ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null;
|
|
298
|
+
const exitProps = {
|
|
299
|
+
editor,
|
|
300
|
+
range: state.range,
|
|
301
|
+
query: state.query,
|
|
302
|
+
text: state.text,
|
|
303
|
+
items: [],
|
|
304
|
+
command: (commandProps) => {
|
|
305
|
+
return command({ editor, range: state.range, props: commandProps });
|
|
306
|
+
},
|
|
307
|
+
decorationNode,
|
|
308
|
+
// If we have a cached decoration node, use it for the clientRect
|
|
309
|
+
// to avoid another DOM lookup. If not, leave clientRect null and
|
|
310
|
+
// let consumer decide if they want to query.
|
|
311
|
+
clientRect: decorationNode ? () => {
|
|
312
|
+
return decorationNode.getBoundingClientRect() || null;
|
|
313
|
+
} : null
|
|
314
|
+
};
|
|
315
|
+
(_b = renderer == null ? void 0 : renderer.onExit) == null ? void 0 : _b.call(renderer, exitProps);
|
|
316
|
+
dispatchExit(view, pluginKey);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
const handled = ((_c = renderer == null ? void 0 : renderer.onKeyDown) == null ? void 0 : _c.call(renderer, { view, event, range })) || false;
|
|
320
|
+
return handled;
|
|
216
321
|
},
|
|
217
322
|
// Setup decorator on the currently active suggestion.
|
|
218
323
|
decorations(state) {
|
|
@@ -238,6 +343,10 @@ function Suggestion({
|
|
|
238
343
|
});
|
|
239
344
|
return plugin;
|
|
240
345
|
}
|
|
346
|
+
function exitSuggestion(view, pluginKeyRef = SuggestionPluginKey) {
|
|
347
|
+
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true });
|
|
348
|
+
view.dispatch(tr);
|
|
349
|
+
}
|
|
241
350
|
|
|
242
351
|
// src/index.ts
|
|
243
352
|
var index_default = Suggestion;
|
|
@@ -245,6 +354,7 @@ export {
|
|
|
245
354
|
Suggestion,
|
|
246
355
|
SuggestionPluginKey,
|
|
247
356
|
index_default as default,
|
|
357
|
+
exitSuggestion,
|
|
248
358
|
findSuggestionMatch
|
|
249
359
|
};
|
|
250
360
|
//# sourceMappingURL=index.js.map
|
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 } 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 * 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}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\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 // virtual node for positioning\n // this can be used for building popups without a DOM node\n clientRect: decorationNode\n ? () => {\n // because of `items` can be asynchrounous we’ll search for the current decoration node\n const { decorationId } = this.key?.getState(editor.state) // eslint-disable-line\n const currentDecorationNode = view.dom.querySelector(`[data-decoration-id=\"${decorationId}\"]`)\n\n return currentDecorationNode?.getBoundingClientRect() || null\n }\n : null,\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 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 ) {\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 return renderer?.onKeyDown?.({ view, event, range }) || false\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","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 { Suggestion } from './suggestion.js'\n\nexport * from './findSuggestionMatch.js'\nexport * from './suggestion.js'\n\nexport default Suggestion\n"],"mappings":"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","names":["findSuggestionMatch","_a"]}
|
|
1
|
+
{"version":3,"sources":["../src/suggestion.ts","../src/findSuggestionMatch.ts","../src/index.ts"],"sourcesContent":["import type { Editor, Range } from '@tiptap/core'\nimport type { EditorState } 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\n// Track document click handlers per EditorView instance to avoid accidental\n// leaks when multiple editors are created/destroyed. WeakMap ensures handlers\n// don't keep views alive.\nconst clickHandlerMap: WeakMap<EditorView, (event: MouseEvent) => void> = new WeakMap()\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 * 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}: SuggestionOptions<I, TSelected>) {\n let props: SuggestionProps<I, TSelected> | undefined\n const renderer = render?.()\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 null\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 // Try to call renderer.onExit so consumer renderers (for example the\n // demos' ReactRenderer) can clean up and unmount immediately. This\n // covers paths where we only dispatch a metadata transaction (like\n // click-outside) and ensures we don't leak DOM nodes / React roots.\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(editorView: EditorView) {\n const ensureClickHandler = (view: EditorView) => {\n if (clickHandlerMap.has(view)) {\n return\n }\n\n const handler = (event: MouseEvent) => {\n if (!props) {\n return\n }\n\n const decorationNode = props.decorationNode\n const target = event.target as Element | null\n\n if (!decorationNode) {\n return\n }\n\n if (target && decorationNode.contains(target)) {\n return\n }\n\n if (target && view.dom.contains(target)) {\n return\n }\n\n if (target && target.closest && target.closest('.react-renderer')) {\n return\n }\n\n dispatchExit(view, pluginKey)\n }\n\n document.addEventListener('mousedown', handler, true)\n clickHandlerMap.set(view, handler)\n }\n\n const removeClickHandler = (view: EditorView) => {\n const handler = clickHandlerMap.get(view)\n if (!handler) {\n return\n }\n\n document.removeEventListener('mousedown', handler, true)\n clickHandlerMap.delete(view)\n }\n\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 // Install / remove click handler depending on suggestion active state\n if (next.active) {\n ensureClickHandler(view)\n } else {\n removeClickHandler(editorView)\n }\n },\n\n destroy: () => {\n removeClickHandler(editorView)\n\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 ) {\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 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":";AAEA,SAAS,QAAQ,iBAAiB;AAElC,SAAS,YAAY,qBAAqB;;;ACH1C,SAAS,sBAAsB;AAkBxB,SAAS,oBAAoB,QAAkC;AAnBtE;AAoBE,QAAM,EAAE,MAAM,aAAa,mBAAmB,oBAAoB,iBAAiB,aAAa,UAAU,IAAI;AAE9G,QAAM,cAAc,qBAAqB,CAAC;AAE1C,QAAM,cAAc,eAAe,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;;;ADnEA,IAAM,kBAAoE,oBAAI,QAAQ;AA8K/E,IAAM,sBAAsB,IAAI,UAAU,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;AACxB,GAAoC;AAClC,MAAI;AACJ,QAAM,WAAW;AAMjB,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;AAtOnE;AAuOI,QAAI;AAKF,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,OAAO;AAAA,IACrC,KAAK;AAAA,IAEL,KAAK,YAAwB;AAC3B,YAAM,qBAAqB,CAAC,SAAqB;AAC/C,YAAI,gBAAgB,IAAI,IAAI,GAAG;AAC7B;AAAA,QACF;AAEA,cAAM,UAAU,CAAC,UAAsB;AACrC,cAAI,CAAC,OAAO;AACV;AAAA,UACF;AAEA,gBAAM,iBAAiB,MAAM;AAC7B,gBAAM,SAAS,MAAM;AAErB,cAAI,CAAC,gBAAgB;AACnB;AAAA,UACF;AAEA,cAAI,UAAU,eAAe,SAAS,MAAM,GAAG;AAC7C;AAAA,UACF;AAEA,cAAI,UAAU,KAAK,IAAI,SAAS,MAAM,GAAG;AACvC;AAAA,UACF;AAEA,cAAI,UAAU,OAAO,WAAW,OAAO,QAAQ,iBAAiB,GAAG;AACjE;AAAA,UACF;AAEA,uBAAa,MAAM,SAAS;AAAA,QAC9B;AAEA,iBAAS,iBAAiB,aAAa,SAAS,IAAI;AACpD,wBAAgB,IAAI,MAAM,OAAO;AAAA,MACnC;AAEA,YAAM,qBAAqB,CAAC,SAAqB;AAC/C,cAAM,UAAU,gBAAgB,IAAI,IAAI;AACxC,YAAI,CAAC,SAAS;AACZ;AAAA,QACF;AAEA,iBAAS,oBAAoB,aAAa,SAAS,IAAI;AACvD,wBAAgB,OAAO,IAAI;AAAA,MAC7B;AAEA,aAAO;AAAA,QACL,QAAQ,OAAO,MAAM,cAAc;AA5T3C;AA6TU,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;AAGA,cAAI,KAAK,QAAQ;AACf,+BAAmB,IAAI;AAAA,UACzB,OAAO;AACL,+BAAmB,UAAU;AAAA,UAC/B;AAAA,QACF;AAAA,QAEA,SAAS,MAAM;AAtYvB;AAuYU,6BAAmB,UAAU;AAE7B,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,GACD;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;AA3fjC;AA4fQ,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;AAElG,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,cAAc,OAAO,MAAM,KAAK;AAAA,UACrC,WAAW,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;;;AE5kBA,IAAO,gBAAQ;","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.3.0",
|
|
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.3.0",
|
|
35
|
+
"@tiptap/pm": "^3.3.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@tiptap/core": "^3.
|
|
39
|
-
"@tiptap/pm": "^3.
|
|
38
|
+
"@tiptap/core": "^3.3.0",
|
|
39
|
+
"@tiptap/pm": "^3.3.0"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
package/src/index.ts
CHANGED
package/src/suggestion.ts
CHANGED
|
@@ -6,6 +6,11 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
|
6
6
|
|
|
7
7
|
import { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'
|
|
8
8
|
|
|
9
|
+
// Track document click handlers per EditorView instance to avoid accidental
|
|
10
|
+
// leaks when multiple editors are created/destroyed. WeakMap ensures handlers
|
|
11
|
+
// don't keep views alive.
|
|
12
|
+
const clickHandlerMap: WeakMap<EditorView, (event: MouseEvent) => void> = new WeakMap()
|
|
13
|
+
|
|
9
14
|
export interface SuggestionOptions<I = any, TSelected = any> {
|
|
10
15
|
/**
|
|
11
16
|
* The plugin key for the suggestion plugin.
|
|
@@ -205,10 +210,109 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
205
210
|
let props: SuggestionProps<I, TSelected> | undefined
|
|
206
211
|
const renderer = render?.()
|
|
207
212
|
|
|
213
|
+
// Helper to create a clientRect callback for a given decoration node.
|
|
214
|
+
// Returns null when no decoration node is present. Uses the pluginKey's
|
|
215
|
+
// state to resolve the current decoration node on demand, avoiding a
|
|
216
|
+
// duplicated implementation in multiple places.
|
|
217
|
+
const clientRectFor = (view: EditorView, decorationNode: Element | null) => {
|
|
218
|
+
if (!decorationNode) {
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return () => {
|
|
223
|
+
const state = pluginKey.getState(editor.state)
|
|
224
|
+
const decorationId = state?.decorationId
|
|
225
|
+
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`)
|
|
226
|
+
|
|
227
|
+
return currentDecorationNode?.getBoundingClientRect() || null
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// small helper used internally by the view to dispatch an exit
|
|
231
|
+
function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {
|
|
232
|
+
try {
|
|
233
|
+
// Try to call renderer.onExit so consumer renderers (for example the
|
|
234
|
+
// demos' ReactRenderer) can clean up and unmount immediately. This
|
|
235
|
+
// covers paths where we only dispatch a metadata transaction (like
|
|
236
|
+
// click-outside) and ensures we don't leak DOM nodes / React roots.
|
|
237
|
+
const state = pluginKey.getState(view.state)
|
|
238
|
+
const decorationNode = state?.decorationId
|
|
239
|
+
? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`)
|
|
240
|
+
: null
|
|
241
|
+
|
|
242
|
+
const exitProps: SuggestionProps = {
|
|
243
|
+
// @ts-ignore editor is available in closure
|
|
244
|
+
editor,
|
|
245
|
+
range: state?.range || { from: 0, to: 0 },
|
|
246
|
+
query: state?.query || null,
|
|
247
|
+
text: state?.text || null,
|
|
248
|
+
items: [],
|
|
249
|
+
command: commandProps => {
|
|
250
|
+
return command({ editor, range: state?.range || { from: 0, to: 0 }, props: commandProps as any })
|
|
251
|
+
},
|
|
252
|
+
decorationNode,
|
|
253
|
+
clientRect: clientRectFor(view, decorationNode),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
renderer?.onExit?.(exitProps)
|
|
257
|
+
} catch {
|
|
258
|
+
// ignore errors from consumer renderers
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
|
|
262
|
+
// Dispatch a metadata-only transaction to signal the plugin to exit
|
|
263
|
+
view.dispatch(tr)
|
|
264
|
+
}
|
|
265
|
+
|
|
208
266
|
const plugin: Plugin<any> = new Plugin({
|
|
209
267
|
key: pluginKey,
|
|
210
268
|
|
|
211
|
-
view() {
|
|
269
|
+
view(editorView: EditorView) {
|
|
270
|
+
const ensureClickHandler = (view: EditorView) => {
|
|
271
|
+
if (clickHandlerMap.has(view)) {
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const handler = (event: MouseEvent) => {
|
|
276
|
+
if (!props) {
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const decorationNode = props.decorationNode
|
|
281
|
+
const target = event.target as Element | null
|
|
282
|
+
|
|
283
|
+
if (!decorationNode) {
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (target && decorationNode.contains(target)) {
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (target && view.dom.contains(target)) {
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (target && target.closest && target.closest('.react-renderer')) {
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
dispatchExit(view, pluginKey)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
document.addEventListener('mousedown', handler, true)
|
|
303
|
+
clickHandlerMap.set(view, handler)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const removeClickHandler = (view: EditorView) => {
|
|
307
|
+
const handler = clickHandlerMap.get(view)
|
|
308
|
+
if (!handler) {
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
document.removeEventListener('mousedown', handler, true)
|
|
313
|
+
clickHandlerMap.delete(view)
|
|
314
|
+
}
|
|
315
|
+
|
|
212
316
|
return {
|
|
213
317
|
update: async (view, prevState) => {
|
|
214
318
|
const prev = this.key?.getState(prevState)
|
|
@@ -246,17 +350,7 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
246
350
|
})
|
|
247
351
|
},
|
|
248
352
|
decorationNode,
|
|
249
|
-
|
|
250
|
-
// this can be used for building popups without a DOM node
|
|
251
|
-
clientRect: decorationNode
|
|
252
|
-
? () => {
|
|
253
|
-
// because of `items` can be asynchrounous we’ll search for the current decoration node
|
|
254
|
-
const { decorationId } = this.key?.getState(editor.state) // eslint-disable-line
|
|
255
|
-
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`)
|
|
256
|
-
|
|
257
|
-
return currentDecorationNode?.getBoundingClientRect() || null
|
|
258
|
-
}
|
|
259
|
-
: null,
|
|
353
|
+
clientRect: clientRectFor(view, decorationNode),
|
|
260
354
|
}
|
|
261
355
|
|
|
262
356
|
if (handleStart) {
|
|
@@ -285,9 +379,18 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
285
379
|
if (handleStart) {
|
|
286
380
|
renderer?.onStart?.(props)
|
|
287
381
|
}
|
|
382
|
+
|
|
383
|
+
// Install / remove click handler depending on suggestion active state
|
|
384
|
+
if (next.active) {
|
|
385
|
+
ensureClickHandler(view)
|
|
386
|
+
} else {
|
|
387
|
+
removeClickHandler(editorView)
|
|
388
|
+
}
|
|
288
389
|
},
|
|
289
390
|
|
|
290
391
|
destroy: () => {
|
|
392
|
+
removeClickHandler(editorView)
|
|
393
|
+
|
|
291
394
|
if (!props) {
|
|
292
395
|
return
|
|
293
396
|
}
|
|
@@ -329,6 +432,21 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
329
432
|
const { empty, from } = selection
|
|
330
433
|
const next = { ...prev }
|
|
331
434
|
|
|
435
|
+
// If a transaction carries the exit meta for this plugin, immediately
|
|
436
|
+
// deactivate the suggestion. This allows metadata-only transactions
|
|
437
|
+
// (dispatched by escape or programmatic exit) to deterministically
|
|
438
|
+
// clear decorations without changing the document.
|
|
439
|
+
const meta = transaction.getMeta(pluginKey)
|
|
440
|
+
if (meta && meta.exit) {
|
|
441
|
+
next.active = false
|
|
442
|
+
next.decorationId = null
|
|
443
|
+
next.range = { from: 0, to: 0 }
|
|
444
|
+
next.query = null
|
|
445
|
+
next.text = null
|
|
446
|
+
|
|
447
|
+
return next
|
|
448
|
+
}
|
|
449
|
+
|
|
332
450
|
next.composing = composing
|
|
333
451
|
|
|
334
452
|
// We can only be suggesting if the view is editable, and:
|
|
@@ -394,7 +512,47 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
394
512
|
return false
|
|
395
513
|
}
|
|
396
514
|
|
|
397
|
-
|
|
515
|
+
// If Escape is pressed, call onExit and dispatch a metadata-only
|
|
516
|
+
// transaction to unset the suggestion state. This provides a safe
|
|
517
|
+
// and deterministic way to exit the suggestion without altering the
|
|
518
|
+
// document (avoids transaction mapping/mismatch issues).
|
|
519
|
+
if (event.key === 'Escape' || event.key === 'Esc') {
|
|
520
|
+
const state = plugin.getState(view.state)
|
|
521
|
+
const cachedNode = props?.decorationNode ?? null
|
|
522
|
+
const decorationNode =
|
|
523
|
+
cachedNode ??
|
|
524
|
+
(state?.decorationId ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null)
|
|
525
|
+
|
|
526
|
+
const exitProps: SuggestionProps = {
|
|
527
|
+
editor,
|
|
528
|
+
range: state.range,
|
|
529
|
+
query: state.query,
|
|
530
|
+
text: state.text,
|
|
531
|
+
items: [],
|
|
532
|
+
command: commandProps => {
|
|
533
|
+
return command({ editor, range: state.range, props: commandProps as any })
|
|
534
|
+
},
|
|
535
|
+
decorationNode,
|
|
536
|
+
// If we have a cached decoration node, use it for the clientRect
|
|
537
|
+
// to avoid another DOM lookup. If not, leave clientRect null and
|
|
538
|
+
// let consumer decide if they want to query.
|
|
539
|
+
clientRect: decorationNode
|
|
540
|
+
? () => {
|
|
541
|
+
return decorationNode.getBoundingClientRect() || null
|
|
542
|
+
}
|
|
543
|
+
: null,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
renderer?.onExit?.(exitProps)
|
|
547
|
+
|
|
548
|
+
// dispatch metadata-only transaction to unset the plugin state
|
|
549
|
+
dispatchExit(view, pluginKey)
|
|
550
|
+
|
|
551
|
+
return true
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const handled = renderer?.onKeyDown?.({ view, event, range }) || false
|
|
555
|
+
return handled
|
|
398
556
|
},
|
|
399
557
|
|
|
400
558
|
// Setup decorator on the currently active suggestion.
|
|
@@ -426,3 +584,13 @@ export function Suggestion<I = any, TSelected = any>({
|
|
|
426
584
|
|
|
427
585
|
return plugin
|
|
428
586
|
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Programmatically exit a suggestion plugin by dispatching a metadata-only
|
|
590
|
+
* transaction. This is the safe, recommended API to remove suggestion
|
|
591
|
+
* decorations without touching the document or causing mapping errors.
|
|
592
|
+
*/
|
|
593
|
+
export function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {
|
|
594
|
+
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
|
|
595
|
+
view.dispatch(tr)
|
|
596
|
+
}
|