diff --git a/docs/content/docs/react/components/suggestion-menus.mdx b/docs/content/docs/react/components/suggestion-menus.mdx index c44f18af45..2e25c6efb8 100644 --- a/docs/content/docs/react/components/suggestion-menus.mdx +++ b/docs/content/docs/react/components/suggestion-menus.mdx @@ -136,6 +136,8 @@ Now, we also pass a component to its `suggestionMenuComponent` prop. The `sugges You can add additional Suggestion Menus to the editor, which can use any trigger character. The demo below adds an example Suggestion Menu for mentions, which opens with the `@` character. +Typically, suggestion menus are meant to be triggered using a single character. However, longer triggers are also supported, like `img:` or `ref:`. + Changing the items in the new Suggestion Menu, or the component used to render it, is done the same way as for the [Slash Menu](/docs/react/components/suggestion-menus#slash-menu). For more information about how the mentions elements work, see [Custom Inline Content](/docs/features/custom-schemas/custom-inline-content). diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts index f739fe9af4..bd1b98cb78 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts @@ -140,6 +140,124 @@ describe("SuggestionMenu", () => { editor._tiptapEditor.destroy(); }); + it("should open a suggestion menu with a multi-character trigger", () => { + const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + sm.addSuggestionMenu({ triggerCharacter: "img:" }); + + editor.replaceBlocks(editor.document, [ + { + id: "paragraph-0", + type: "paragraph", + content: "img", + }, + ]); + + editor.setTextCursorPosition("paragraph-0", "end"); + + expect(getSuggestionPluginState(editor)).toBeUndefined(); + + // Typing the final ":" completes the "img:" trigger. + const handled = simulateTextInput(editor, ":"); + + expect(handled).toBe(true); + + const pluginState = getSuggestionPluginState(editor); + expect(pluginState).toBeDefined(); + expect(pluginState.triggerCharacter).toBe("img:"); + + editor._tiptapEditor.destroy(); + }); + + it("should match a multi-character trigger that is preceded by other text", () => { + const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + sm.addSuggestionMenu({ triggerCharacter: "img:" }); + + editor.replaceBlocks(editor.document, [ + { + id: "paragraph-0", + type: "paragraph", + content: "hello img", + }, + ]); + + editor.setTextCursorPosition("paragraph-0", "end"); + + expect(getSuggestionPluginState(editor)).toBeUndefined(); + + const handled = simulateTextInput(editor, ":"); + + expect(handled).toBe(true); + + const pluginState = getSuggestionPluginState(editor); + expect(pluginState).toBeDefined(); + expect(pluginState.triggerCharacter).toBe("img:"); + + editor._tiptapEditor.destroy(); + }); + + it("should prefer a longer trigger over a shorter one that would shadow it", () => { + const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + // Register the shorter ":" trigger first so it would win in insertion + // order, then the longer "img:" trigger that ends in the same character. + sm.addSuggestionMenu({ triggerCharacter: ":" }); + sm.addSuggestionMenu({ triggerCharacter: "img:" }); + + editor.replaceBlocks(editor.document, [ + { + id: "paragraph-0", + type: "paragraph", + content: "img", + }, + ]); + + editor.setTextCursorPosition("paragraph-0", "end"); + + const handled = simulateTextInput(editor, ":"); + + expect(handled).toBe(true); + + const pluginState = getSuggestionPluginState(editor); + expect(pluginState).toBeDefined(); + expect(pluginState.triggerCharacter).toBe("img:"); + + editor._tiptapEditor.destroy(); + }); + + it("should fall back to the shorter trigger when the longer one does not match", () => { + const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + sm.addSuggestionMenu({ triggerCharacter: ":" }); + sm.addSuggestionMenu({ triggerCharacter: "img:" }); + + editor.replaceBlocks(editor.document, [ + { + id: "paragraph-0", + type: "paragraph", + content: "hello", + }, + ]); + + editor.setTextCursorPosition("paragraph-0", "end"); + + // "hello" + ":" doesn't complete "img:", so the bare ":" trigger wins. + const handled = simulateTextInput(editor, ":"); + + expect(handled).toBe(true); + + const pluginState = getSuggestionPluginState(editor); + expect(pluginState).toBeDefined(); + expect(pluginState.triggerCharacter).toBe(":"); + + editor._tiptapEditor.destroy(); + }); + it("should still allow suggestion menus without shouldTrigger in table content", () => { const editor = createEditor(); const sm = editor.getExtension(SuggestionMenu)!; diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts index 4809607e6e..62d01c77a6 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts @@ -150,6 +150,12 @@ type SuggestionPluginState = | undefined; export type SuggestionMenuOptions = { + /** + * The string that opens the suggestion menu when typed. Usually a single + * character (e.g. `"/"` or `"@"`), but multi-character strings such as + * `"img:"` are also supported. When multiple triggers could match the typed + * text, the longest one takes precedence. + */ triggerCharacter: string; /** * Optional callback to determine whether the suggestion menu should be @@ -332,34 +338,41 @@ export const SuggestionMenu = createExtension(({ editor }) => { }, props: { - handleTextInput(view, from, to, text) { - // only on insert - if (from === to) { - const doc = view.state.doc; - for (const [triggerChar, menuOptions] of suggestionMenus) { - const snippet = - triggerChar.length > 1 - ? doc.textBetween(from - triggerChar.length, from) + text - : text; - - if (triggerChar === snippet) { - // Check the per-suggestion-menu filter before activating. - if ( - menuOptions.shouldOpen && - !menuOptions.shouldOpen(view.state.tr) - ) { - continue; - } - view.dispatch(view.state.tr.insertText(text)); - view.dispatch( - view.state.tr - .setMeta(suggestionMenuPluginKey, { - triggerCharacter: snippet, - }) - .scrollIntoView(), - ); - return true; + handleTextInput(view, from, _to, text) { + const doc = view.state.doc; + // Sort triggers by length (longest first) so that a more specific + // multi-character trigger (e.g. "img:") is preferred over a shorter + // one (e.g. ":") that would otherwise shadow it by matching first. + const triggers = [...suggestionMenus.entries()].sort( + ([a], [b]) => b.length - a.length, + ); + for (const [trigger, menuOptions] of triggers) { + const input = + doc.textBetween( + // If the cursor is at the very start of the document, and we are checking if + // an n-character trigger has been entered (e.g. "img:"), we need to get the n + // characters before the text cursor to compare do so. However, the text cursor + // may be near the start of the document and not have n characters before it. + Math.max(0, from - (trigger.length - text.length)), + from, + ) + text; + if (trigger === input) { + // Check the per-suggestion-menu filter before activating. + if ( + menuOptions.shouldOpen && + !menuOptions.shouldOpen(view.state.tr) + ) { + continue; } + view.dispatch(view.state.tr.insertText(text)); + view.dispatch( + view.state.tr + .setMeta(suggestionMenuPluginKey, { + triggerCharacter: input, + }) + .scrollIntoView(), + ); + return true; } } return false;