From ab291b056847ba5e680a595d8a3ebc7d946bc3a0 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 28 Jul 2026 18:50:55 +0200 Subject: [PATCH 1/3] Added support & tests for multi-character suggestion menu triggers. --- .../SuggestionMenu/SuggestionMenu.test.ts | 118 ++++++++++++++++++ .../SuggestionMenu/SuggestionMenu.ts | 26 ++-- 2 files changed, 136 insertions(+), 8 deletions(-) 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..b9a2a07a3f 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts @@ -336,13 +336,23 @@ export const SuggestionMenu = createExtension(({ editor }) => { // 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) { + // 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 && @@ -354,7 +364,7 @@ export const SuggestionMenu = createExtension(({ editor }) => { view.dispatch( view.state.tr .setMeta(suggestionMenuPluginKey, { - triggerCharacter: snippet, + triggerCharacter: input, }) .scrollIntoView(), ); From 9acda3e8394c1f8655111bf0fcb950cccec19048 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 28 Jul 2026 19:02:14 +0200 Subject: [PATCH 2/3] Updated docs --- docs/content/docs/react/components/suggestion-menus.mdx | 2 ++ .../core/src/extensions/SuggestionMenu/SuggestionMenu.ts | 6 ++++++ 2 files changed, 8 insertions(+) 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.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts index b9a2a07a3f..8d68cc558a 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 From 6b1d1ebda4a6b1c71a36c998440bafa2864505fb Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 28 Jul 2026 20:02:58 +0200 Subject: [PATCH 3/3] Made suggestion menu triggers work when selection is non-empty --- .../SuggestionMenu/SuggestionMenu.ts | 71 +++++++++---------- 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts index 8d68cc558a..62d01c77a6 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts @@ -338,44 +338,41 @@ export const SuggestionMenu = createExtension(({ editor }) => { }, props: { - handleTextInput(view, from, to, text) { - // only on insert - if (from === to) { - 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; + 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;