diff --git a/docs/content/docs/2.components/editor-emoji-menu.md b/docs/content/docs/2.components/editor-emoji-menu.md index 143ad86808..c186dfa548 100644 --- a/docs/content/docs/2.components/editor-emoji-menu.md +++ b/docs/content/docs/2.components/editor-emoji-menu.md @@ -73,6 +73,26 @@ Use the `char` prop to change the trigger character. Defaults to `:`{lang="ts-ty ``` +### Suggestion :badge{label="Soon" class="align-text-top"} + +Use the `suggestion` prop to customize TipTap's [Suggestion matching behavior](https://tiptap.dev/docs/editor/api/utilities/suggestion#settings). + +This is useful when the trigger character should open directly after other characters instead of requiring the default whitespace prefix. + +```vue + +``` + ### Options Use the `options` prop to customize the positioning behavior using [Floating UI options](https://floating-ui.com/docs/computeposition#options). diff --git a/docs/content/docs/2.components/editor-mention-menu.md b/docs/content/docs/2.components/editor-mention-menu.md index 57317fdbc6..46035a0818 100644 --- a/docs/content/docs/2.components/editor-mention-menu.md +++ b/docs/content/docs/2.components/editor-mention-menu.md @@ -81,6 +81,27 @@ You can use multiple `EditorMentionMenu` components on the same editor with diff ``` :: +### Suggestion :badge{label="Soon" class="align-text-top"} + +Use the `suggestion` prop to customize TipTap's [Suggestion matching behavior](https://tiptap.dev/docs/editor/api/utilities/suggestion#settings). + +This is useful when the trigger character should open directly after other characters instead of requiring the default whitespace prefix. + +```vue + +``` + ### Options Use the `options` prop to customize the positioning behavior using [Floating UI options](https://floating-ui.com/docs/computeposition#options). diff --git a/docs/content/docs/2.components/editor-suggestion-menu.md b/docs/content/docs/2.components/editor-suggestion-menu.md index 0d5bb426c4..84d85abbf7 100644 --- a/docs/content/docs/2.components/editor-suggestion-menu.md +++ b/docs/content/docs/2.components/editor-suggestion-menu.md @@ -69,6 +69,27 @@ Use the `char` prop to change the trigger character. Defaults to `/`{lang="ts-ty ``` +### Suggestion :badge{label="Soon" class="align-text-top"} + +Use the `suggestion` prop to customize TipTap's [Suggestion matching behavior](https://tiptap.dev/docs/editor/api/utilities/suggestion#settings). + +This is useful when the trigger character should open directly after other characters instead of requiring the default whitespace prefix. + +```vue + +``` + ### Options Use the `options` prop to customize the positioning behavior using [Floating UI options](https://floating-ui.com/docs/computeposition#options). diff --git a/src/runtime/components/EditorEmojiMenu.vue b/src/runtime/components/EditorEmojiMenu.vue index cb3ab986eb..187eb8d91c 100644 --- a/src/runtime/components/EditorEmojiMenu.vue +++ b/src/runtime/components/EditorEmojiMenu.vue @@ -16,7 +16,7 @@ export interface EditorEmojiMenuItem { [key: string]: any } -export interface EditorEmojiMenuProps extends Partial, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'appendTo'>> { +export interface EditorEmojiMenuProps extends Partial, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'suggestion' | 'appendTo'>> { /** * @defaultValue 'md' */ @@ -64,6 +64,7 @@ onMounted(async () => { filterFields: props.filterFields, limit: props.limit, options: props.options, + suggestion: props.suggestion, appendTo: props.appendTo, ui, onSelect: (editor, range, item) => { diff --git a/src/runtime/components/EditorMentionMenu.vue b/src/runtime/components/EditorMentionMenu.vue index f776b6c8f3..96ac3acd9a 100644 --- a/src/runtime/components/EditorMentionMenu.vue +++ b/src/runtime/components/EditorMentionMenu.vue @@ -20,7 +20,7 @@ export interface EditorMentionMenuItem { [key: string]: any } -export interface EditorMentionMenuProps extends Partial, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'appendTo' | 'ignoreFilter'>> { +export interface EditorMentionMenuProps extends Partial, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'suggestion' | 'appendTo' | 'ignoreFilter'>> { /** * @defaultValue 'md' */ @@ -72,6 +72,7 @@ onMounted(async () => { ignoreFilter: props.ignoreFilter, limit: props.limit, options: props.options, + suggestion: props.suggestion, appendTo: props.appendTo, searchTerm, ui, diff --git a/src/runtime/components/EditorSuggestionMenu.vue b/src/runtime/components/EditorSuggestionMenu.vue index d4bce9a317..0d2f65e30f 100644 --- a/src/runtime/components/EditorSuggestionMenu.vue +++ b/src/runtime/components/EditorSuggestionMenu.vue @@ -39,7 +39,7 @@ export type EditorSuggestionMenuItem -export interface EditorSuggestionMenuProps extends Partial, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'appendTo'>> { +export interface EditorSuggestionMenuProps extends Partial, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'suggestion' | 'appendTo'>> { /** * @defaultValue 'md' */ @@ -90,6 +90,7 @@ onMounted(async () => { filterFields: props.filterFields, limit: props.limit, options: props.options, + suggestion: props.suggestion, appendTo: props.appendTo, ui, onSelect: (editor, range, item) => { diff --git a/src/runtime/composables/useEditorMenu.ts b/src/runtime/composables/useEditorMenu.ts index d62845ce2a..049686b816 100644 --- a/src/runtime/composables/useEditorMenu.ts +++ b/src/runtime/composables/useEditorMenu.ts @@ -6,7 +6,7 @@ import { computePosition } from '@floating-ui/dom' import type { Strategy, Placement } from '@floating-ui/dom' import type { Editor } from '@tiptap/vue-3' import { VueRenderer } from '@tiptap/vue-3' -import type { SuggestionProps } from '@tiptap/suggestion' +import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion' import Suggestion from '@tiptap/suggestion' import { PluginKey } from '@tiptap/pm/state' import type { FloatingUIOptions } from '../types/editor' @@ -65,6 +65,10 @@ export interface EditorMenuOptions { * @see https://floating-ui.com/docs/computePosition#options */ options?: FloatingUIOptions + /** + * Optional TipTap Suggestion matching options. + */ + suggestion?: Omit, 'pluginKey' | 'editor' | 'char' | 'items' | 'command' | 'render'> /** * The DOM element to append the menu to. Default is the editor's parent element. * @@ -333,7 +337,8 @@ export function useEditorMenu(options: EditorMenuOptions) { element.addEventListener('mousedown', handleMouseDown) const appendToElement = typeof options.appendTo === 'function' ? options.appendTo() : options.appendTo - ;(appendToElement ?? options.editor.view.dom.parentElement)?.appendChild(element) + const container = appendToElement ?? options.editor.view.dom.parentElement + container?.appendChild(element) if (renderer.element) { element.appendChild(renderer.element) } @@ -490,6 +495,7 @@ export function useEditorMenu(options: EditorMenuOptions) { // Create the suggestion plugin const plugin = Suggestion({ + ...(options.suggestion || {}), pluginKey: pluginKeyInstance, editor: options.editor, char: options.char, diff --git a/test/composables/useEditorMenu.spec.ts b/test/composables/useEditorMenu.spec.ts new file mode 100644 index 0000000000..dfc8b793ea --- /dev/null +++ b/test/composables/useEditorMenu.spec.ts @@ -0,0 +1,149 @@ +import { computed } from 'vue' +import { beforeEach, describe, expect, it, vi, expectTypeOf } from 'vitest' +import type { SuggestionOptions } from '@tiptap/suggestion' +import { PluginKey } from '@tiptap/pm/state' +import { useEditorMenu } from '../../src/runtime/composables/useEditorMenu' +import type { EditorMenuOptions } from '../../src/runtime/composables/useEditorMenu' +import type { EditorSuggestionMenuProps } from '../../src/runtime/components/EditorSuggestionMenu.vue' +import type { EditorMentionMenuProps } from '../../src/runtime/components/EditorMentionMenu.vue' +import type { EditorEmojiMenuProps } from '../../src/runtime/components/EditorEmojiMenu.vue' + +const { suggestionMock } = vi.hoisted(() => ({ + suggestionMock: vi.fn((config: any) => config) +})) + +vi.mock('@tiptap/suggestion', () => ({ + default: suggestionMock +})) + +function createEditor() { + const dom = document.createElement('div') + const parent = document.createElement('div') + parent.appendChild(dom) + + return { + isDestroyed: false, + view: { + dom, + state: { + tr: { + setMeta: vi.fn(() => ({})) + } + }, + dispatch: vi.fn() + } + } as any +} + +function createOptions(overrides: Partial> = {}): EditorMenuOptions<{ label: string }> { + return { + editor: createEditor(), + char: ':', + pluginKey: 'suggestion-menu', + items: [{ label: 'Alpha' }, { label: 'Beta' }], + onSelect: vi.fn(), + renderItem: vi.fn(() => []), + ui: computed(() => ({ + content: () => '', + viewport: () => '', + group: () => '', + label: () => '', + separator: () => '', + item: () => '', + itemLeadingIcon: () => '', + itemWrapper: () => '', + itemLabel: () => '', + itemDescription: () => '' + })), + ...overrides + } +} + +function getSuggestionConfig() { + if (suggestionMock.mock.calls.length !== 1) { + throw new Error(`Suggestion should be called exactly once, but was called ${suggestionMock.mock.calls.length} times`) + } + + return suggestionMock.mock.calls[0]![0] +} + +describe('useEditorMenu', () => { + beforeEach(() => { + suggestionMock.mockClear() + }) + + it('forwards suggestion matching options', () => { + useEditorMenu(createOptions({ + suggestion: { + allowedPrefixes: null, + allowSpaces: true, + startOfLine: true + } + })) + + const config = getSuggestionConfig() + + expect(config.allowedPrefixes).toBeNull() + expect(config.allowSpaces).toBe(true) + expect(config.startOfLine).toBe(true) + expect(config.char).toBe(':') + }) + + it('keeps existing defaults when suggestion is omitted', () => { + useEditorMenu(createOptions()) + + const config = getSuggestionConfig() + const items = config.items({ query: 'al' }) + + expect(config).not.toHaveProperty('allowedPrefixes') + expect(items).toEqual([{ label: 'Alpha' }]) + }) + + it('keeps plugin identity fields authoritative over suggestion overrides', () => { + const wrongPluginKey = new PluginKey('wrong-plugin-key') + const wrongEditor = createEditor() + + useEditorMenu(createOptions({ + suggestion: { + pluginKey: wrongPluginKey, + editor: wrongEditor, + char: '@' + } as Partial + })) + + const config = getSuggestionConfig() + + expect(config.pluginKey).not.toBe(wrongPluginKey) + expect(config.editor).not.toBe(wrongEditor) + expect(config.char).toBe(':') + }) + + it('keeps menu callbacks authoritative over suggestion overrides', () => { + const suggestionItems = vi.fn(() => []) + const suggestionCommand = vi.fn() + const suggestionRender = vi.fn() + + useEditorMenu(createOptions({ + suggestion: { + items: suggestionItems, + command: suggestionCommand, + render: suggestionRender + } as Partial + })) + + const config = getSuggestionConfig() + + expect(config.items).not.toBe(suggestionItems) + expect(config.command).not.toBe(suggestionCommand) + expect(config.render).not.toBe(suggestionRender) + }) + + it('types suggestion options on the composable and component props', () => { + type ExpectedType = Omit, 'pluginKey' | 'editor' | 'char' | 'items' | 'command' | 'render'> | undefined + + expectTypeOf['suggestion']>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) +})