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) {