Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/content/docs/react/components/suggestion-menus.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:`.

<Example name="custom-schema/suggestion-menus-mentions" />

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).
Expand Down
118 changes: 118 additions & 0 deletions packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)!;
Expand Down
67 changes: 40 additions & 27 deletions packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading