From 2e142e2e1d5df429ba7f74f9c2a3339c47a4eaf4 Mon Sep 17 00:00:00 2001 From: Gxrvish Date: Sat, 25 Jul 2026 10:48:21 +0530 Subject: [PATCH] fix(core): prefer longest suggestion menu trigger over shadowing shorter one A multi-character trigger like 'img:' never opened because the default single-character emoji ':' menu matched first and shadowed it. handleTextInput looped registered menus in insertion order and returned on the first match, so the shorter trigger always won. Sort candidate triggers by length descending so the most specific trigger wins. Also fix an off-by-one in the multi-character prefix lookup (`from - length` grabbed one extra char), which broke matching when the trigger was preceded by other text on the same line. --- .../SuggestionMenu/SuggestionMenu.test.ts | 64 +++++++++++++++++++ .../SuggestionMenu/SuggestionMenu.ts | 13 +++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts index f739fe9af4..2739a1d18b 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts @@ -188,4 +188,68 @@ describe("SuggestionMenu", () => { editor._tiptapEditor.destroy(); }); + + it("should prefer a multi-character trigger over a shadowing single-character one", () => { + const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + // Register the single-character trigger first so it would shadow the + // multi-character one in insertion order (mirrors the default emoji ":" + // menu registered before a custom "img:" menu). + sm.addSuggestionMenu({ triggerCharacter: ":" }); + 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 ":" of "img:" should open the "img:" menu, not the ":" + // emoji menu. + 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 mid-line (not just at block start)", () => { + 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(); + }); }); diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts index 4809607e6e..d40de6d906 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts @@ -336,10 +336,19 @@ export const SuggestionMenu = createExtension(({ editor }) => { // only on insert if (from === to) { const doc = view.state.doc; - for (const [triggerChar, menuOptions] of suggestionMenus) { + // Match longer trigger characters first, so a multi-character + // trigger (e.g. "img:") wins over a single-character one (e.g. + // the default emoji ":") that would otherwise shadow it. + const orderedMenus = [...suggestionMenus].sort( + ([a], [b]) => b.length - a.length, + ); + for (const [triggerChar, menuOptions] of orderedMenus) { const snippet = triggerChar.length > 1 - ? doc.textBetween(from - triggerChar.length, from) + text + ? // The already-typed prefix is the first `length - 1` + // chars; `text` is the final char being inserted now. + doc.textBetween(from - (triggerChar.length - 1), from) + + text : text; if (triggerChar === snippet) {