Skip to content

Commit 4427824

Browse files
feat(EditorSuggestionMenu): expose suggestion matching options (#6234)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
1 parent 5155e27 commit 4427824

8 files changed

Lines changed: 225 additions & 5 deletions

File tree

docs/content/docs/2.components/editor-emoji-menu.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,26 @@ Use the `char` prop to change the trigger character. Defaults to `:`{lang="ts-ty
7373
</template>
7474
```
7575

76+
### Suggestion :badge{label="Soon" class="align-text-top"}
77+
78+
Use the `suggestion` prop to customize TipTap's [Suggestion matching behavior](https://tiptap.dev/docs/editor/api/utilities/suggestion#settings).
79+
80+
This is useful when the trigger character should open directly after other characters instead of requiring the default whitespace prefix.
81+
82+
```vue
83+
<template>
84+
<UEditor v-slot="{ editor }">
85+
<UEditorEmojiMenu
86+
:editor="editor"
87+
:items="items"
88+
:suggestion="{
89+
allowedPrefixes: null
90+
}"
91+
/>
92+
</UEditor>
93+
</template>
94+
```
95+
7696
### Options
7797

7898
Use the `options` prop to customize the positioning behavior using [Floating UI options](https://floating-ui.com/docs/computeposition#options).

docs/content/docs/2.components/editor-mention-menu.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ You can use multiple `EditorMentionMenu` components on the same editor with diff
8181
```
8282
::
8383

84+
### Suggestion :badge{label="Soon" class="align-text-top"}
85+
86+
Use the `suggestion` prop to customize TipTap's [Suggestion matching behavior](https://tiptap.dev/docs/editor/api/utilities/suggestion#settings).
87+
88+
This is useful when the trigger character should open directly after other characters instead of requiring the default whitespace prefix.
89+
90+
```vue
91+
<template>
92+
<UEditor v-slot="{ editor }">
93+
<UEditorMentionMenu
94+
:editor="editor"
95+
:items="items"
96+
char="#"
97+
:suggestion="{
98+
allowedPrefixes: null
99+
}"
100+
/>
101+
</UEditor>
102+
</template>
103+
```
104+
84105
### Options
85106

86107
Use the `options` prop to customize the positioning behavior using [Floating UI options](https://floating-ui.com/docs/computeposition#options).

docs/content/docs/2.components/editor-suggestion-menu.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,27 @@ Use the `char` prop to change the trigger character. Defaults to `/`{lang="ts-ty
6969
</template>
7070
```
7171

72+
### Suggestion :badge{label="Soon" class="align-text-top"}
73+
74+
Use the `suggestion` prop to customize TipTap's [Suggestion matching behavior](https://tiptap.dev/docs/editor/api/utilities/suggestion#settings).
75+
76+
This is useful when the trigger character should open directly after other characters instead of requiring the default whitespace prefix.
77+
78+
```vue
79+
<template>
80+
<UEditor v-slot="{ editor }">
81+
<UEditorSuggestionMenu
82+
:editor="editor"
83+
:items="items"
84+
char=":"
85+
:suggestion="{
86+
allowedPrefixes: null
87+
}"
88+
/>
89+
</UEditor>
90+
</template>
91+
```
92+
7293
### Options
7394

7495
Use the `options` prop to customize the positioning behavior using [Floating UI options](https://floating-ui.com/docs/computeposition#options).

src/runtime/components/EditorEmojiMenu.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface EditorEmojiMenuItem {
1616
[key: string]: any
1717
}
1818
19-
export interface EditorEmojiMenuProps<T extends EditorEmojiMenuItem = EditorEmojiMenuItem> extends Partial<Pick<EditorMenuOptions<T>, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'appendTo'>> {
19+
export interface EditorEmojiMenuProps<T extends EditorEmojiMenuItem = EditorEmojiMenuItem> extends Partial<Pick<EditorMenuOptions<T>, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'suggestion' | 'appendTo'>> {
2020
/**
2121
* @defaultValue 'md'
2222
*/
@@ -64,6 +64,7 @@ onMounted(async () => {
6464
filterFields: props.filterFields,
6565
limit: props.limit,
6666
options: props.options,
67+
suggestion: props.suggestion,
6768
appendTo: props.appendTo,
6869
ui,
6970
onSelect: (editor, range, item) => {

src/runtime/components/EditorMentionMenu.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface EditorMentionMenuItem {
2020
[key: string]: any
2121
}
2222
23-
export interface EditorMentionMenuProps<T extends EditorMentionMenuItem = EditorMentionMenuItem> extends Partial<Pick<EditorMenuOptions<T>, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'appendTo' | 'ignoreFilter'>> {
23+
export interface EditorMentionMenuProps<T extends EditorMentionMenuItem = EditorMentionMenuItem> extends Partial<Pick<EditorMenuOptions<T>, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'suggestion' | 'appendTo' | 'ignoreFilter'>> {
2424
/**
2525
* @defaultValue 'md'
2626
*/
@@ -72,6 +72,7 @@ onMounted(async () => {
7272
ignoreFilter: props.ignoreFilter,
7373
limit: props.limit,
7474
options: props.options,
75+
suggestion: props.suggestion,
7576
appendTo: props.appendTo,
7677
searchTerm,
7778
ui,

src/runtime/components/EditorSuggestionMenu.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export type EditorSuggestionMenuItem<H extends EditorCustomHandlers = EditorCust
3939
| EditorSuggestionMenuSeparatorItem
4040
| EditorSuggestionMenuActionItem<H>
4141
42-
export interface EditorSuggestionMenuProps<T extends EditorSuggestionMenuItem = EditorSuggestionMenuItem> extends Partial<Pick<EditorMenuOptions<T>, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'appendTo'>> {
42+
export interface EditorSuggestionMenuProps<T extends EditorSuggestionMenuItem = EditorSuggestionMenuItem> extends Partial<Pick<EditorMenuOptions<T>, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'suggestion' | 'appendTo'>> {
4343
/**
4444
* @defaultValue 'md'
4545
*/
@@ -90,6 +90,7 @@ onMounted(async () => {
9090
filterFields: props.filterFields,
9191
limit: props.limit,
9292
options: props.options,
93+
suggestion: props.suggestion,
9394
appendTo: props.appendTo,
9495
ui,
9596
onSelect: (editor, range, item) => {

src/runtime/composables/useEditorMenu.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { computePosition } from '@floating-ui/dom'
66
import type { Strategy, Placement } from '@floating-ui/dom'
77
import type { Editor } from '@tiptap/vue-3'
88
import { VueRenderer } from '@tiptap/vue-3'
9-
import type { SuggestionProps } from '@tiptap/suggestion'
9+
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
1010
import Suggestion from '@tiptap/suggestion'
1111
import { PluginKey } from '@tiptap/pm/state'
1212
import type { FloatingUIOptions } from '../types/editor'
@@ -65,6 +65,10 @@ export interface EditorMenuOptions<T = any> {
6565
* @see https://floating-ui.com/docs/computePosition#options
6666
*/
6767
options?: FloatingUIOptions
68+
/**
69+
* Optional TipTap Suggestion matching options.
70+
*/
71+
suggestion?: Omit<Partial<SuggestionOptions>, 'pluginKey' | 'editor' | 'char' | 'items' | 'command' | 'render'>
6872
/**
6973
* The DOM element to append the menu to. Default is the editor's parent element.
7074
*
@@ -333,7 +337,8 @@ export function useEditorMenu<T = any>(options: EditorMenuOptions<T>) {
333337
element.addEventListener('mousedown', handleMouseDown)
334338

335339
const appendToElement = typeof options.appendTo === 'function' ? options.appendTo() : options.appendTo
336-
;(appendToElement ?? options.editor.view.dom.parentElement)?.appendChild(element)
340+
const container = appendToElement ?? options.editor.view.dom.parentElement
341+
container?.appendChild(element)
337342
if (renderer.element) {
338343
element.appendChild(renderer.element)
339344
}
@@ -490,6 +495,7 @@ export function useEditorMenu<T = any>(options: EditorMenuOptions<T>) {
490495

491496
// Create the suggestion plugin
492497
const plugin = Suggestion({
498+
...(options.suggestion || {}),
493499
pluginKey: pluginKeyInstance,
494500
editor: options.editor,
495501
char: options.char,
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { computed } from 'vue'
2+
import { beforeEach, describe, expect, it, vi, expectTypeOf } from 'vitest'
3+
import type { SuggestionOptions } from '@tiptap/suggestion'
4+
import { PluginKey } from '@tiptap/pm/state'
5+
import { useEditorMenu } from '../../src/runtime/composables/useEditorMenu'
6+
import type { EditorMenuOptions } from '../../src/runtime/composables/useEditorMenu'
7+
import type { EditorSuggestionMenuProps } from '../../src/runtime/components/EditorSuggestionMenu.vue'
8+
import type { EditorMentionMenuProps } from '../../src/runtime/components/EditorMentionMenu.vue'
9+
import type { EditorEmojiMenuProps } from '../../src/runtime/components/EditorEmojiMenu.vue'
10+
11+
const { suggestionMock } = vi.hoisted(() => ({
12+
suggestionMock: vi.fn((config: any) => config)
13+
}))
14+
15+
vi.mock('@tiptap/suggestion', () => ({
16+
default: suggestionMock
17+
}))
18+
19+
function createEditor() {
20+
const dom = document.createElement('div')
21+
const parent = document.createElement('div')
22+
parent.appendChild(dom)
23+
24+
return {
25+
isDestroyed: false,
26+
view: {
27+
dom,
28+
state: {
29+
tr: {
30+
setMeta: vi.fn(() => ({}))
31+
}
32+
},
33+
dispatch: vi.fn()
34+
}
35+
} as any
36+
}
37+
38+
function createOptions(overrides: Partial<EditorMenuOptions<{ label: string }>> = {}): EditorMenuOptions<{ label: string }> {
39+
return {
40+
editor: createEditor(),
41+
char: ':',
42+
pluginKey: 'suggestion-menu',
43+
items: [{ label: 'Alpha' }, { label: 'Beta' }],
44+
onSelect: vi.fn(),
45+
renderItem: vi.fn(() => []),
46+
ui: computed(() => ({
47+
content: () => '',
48+
viewport: () => '',
49+
group: () => '',
50+
label: () => '',
51+
separator: () => '',
52+
item: () => '',
53+
itemLeadingIcon: () => '',
54+
itemWrapper: () => '',
55+
itemLabel: () => '',
56+
itemDescription: () => ''
57+
})),
58+
...overrides
59+
}
60+
}
61+
62+
function getSuggestionConfig() {
63+
if (suggestionMock.mock.calls.length !== 1) {
64+
throw new Error(`Suggestion should be called exactly once, but was called ${suggestionMock.mock.calls.length} times`)
65+
}
66+
67+
return suggestionMock.mock.calls[0]![0]
68+
}
69+
70+
describe('useEditorMenu', () => {
71+
beforeEach(() => {
72+
suggestionMock.mockClear()
73+
})
74+
75+
it('forwards suggestion matching options', () => {
76+
useEditorMenu(createOptions({
77+
suggestion: {
78+
allowedPrefixes: null,
79+
allowSpaces: true,
80+
startOfLine: true
81+
}
82+
}))
83+
84+
const config = getSuggestionConfig()
85+
86+
expect(config.allowedPrefixes).toBeNull()
87+
expect(config.allowSpaces).toBe(true)
88+
expect(config.startOfLine).toBe(true)
89+
expect(config.char).toBe(':')
90+
})
91+
92+
it('keeps existing defaults when suggestion is omitted', () => {
93+
useEditorMenu(createOptions())
94+
95+
const config = getSuggestionConfig()
96+
const items = config.items({ query: 'al' })
97+
98+
expect(config).not.toHaveProperty('allowedPrefixes')
99+
expect(items).toEqual([{ label: 'Alpha' }])
100+
})
101+
102+
it('keeps plugin identity fields authoritative over suggestion overrides', () => {
103+
const wrongPluginKey = new PluginKey('wrong-plugin-key')
104+
const wrongEditor = createEditor()
105+
106+
useEditorMenu(createOptions({
107+
suggestion: {
108+
pluginKey: wrongPluginKey,
109+
editor: wrongEditor,
110+
char: '@'
111+
} as Partial<SuggestionOptions>
112+
}))
113+
114+
const config = getSuggestionConfig()
115+
116+
expect(config.pluginKey).not.toBe(wrongPluginKey)
117+
expect(config.editor).not.toBe(wrongEditor)
118+
expect(config.char).toBe(':')
119+
})
120+
121+
it('keeps menu callbacks authoritative over suggestion overrides', () => {
122+
const suggestionItems = vi.fn(() => [])
123+
const suggestionCommand = vi.fn()
124+
const suggestionRender = vi.fn()
125+
126+
useEditorMenu(createOptions({
127+
suggestion: {
128+
items: suggestionItems,
129+
command: suggestionCommand,
130+
render: suggestionRender
131+
} as Partial<SuggestionOptions>
132+
}))
133+
134+
const config = getSuggestionConfig()
135+
136+
expect(config.items).not.toBe(suggestionItems)
137+
expect(config.command).not.toBe(suggestionCommand)
138+
expect(config.render).not.toBe(suggestionRender)
139+
})
140+
141+
it('types suggestion options on the composable and component props', () => {
142+
type ExpectedType = Omit<Partial<SuggestionOptions>, 'pluginKey' | 'editor' | 'char' | 'items' | 'command' | 'render'> | undefined
143+
144+
expectTypeOf<EditorMenuOptions<{ label: string }>['suggestion']>().toEqualTypeOf<ExpectedType>()
145+
expectTypeOf<EditorSuggestionMenuProps['suggestion']>().toEqualTypeOf<ExpectedType>()
146+
expectTypeOf<EditorMentionMenuProps['suggestion']>().toEqualTypeOf<ExpectedType>()
147+
expectTypeOf<EditorEmojiMenuProps['suggestion']>().toEqualTypeOf<ExpectedType>()
148+
})
149+
})

0 commit comments

Comments
 (0)