import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { PluginKey } from '@tiptap/pm/state'
import { describe, expect, it, vi } from 'vite-plus/test'
import { FloatingMenu } from './floating-menu.js'
import { FloatingMenuView } from './floating-menu-plugin.js'
function createEditor(content = '
Hello world
') {
return new Editor({
extensions: [Document, Paragraph, Text],
content,
})
}
function createFloatingMenuView(
editor: Editor,
overrides: Partial[0]> = {},
) {
return new FloatingMenuView({
editor,
element: document.createElement('div'),
view: editor.view,
shouldShow: () => false,
...overrides,
})
}
describe('FloatingMenuView cross-contamination', () => {
it('should only process updateOptions for its own pluginKey (string)', () => {
const editor = createEditor()
const view1 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu1' })
const view2 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu2' })
const spy1 = vi.spyOn(view1, 'updateOptions')
const spy2 = vi.spyOn(view2, 'updateOptions')
const newOptions = { updateDelay: 999 }
editor.view.dispatch(
editor.state.tr.setMeta('floatingMenu1', {
type: 'updateOptions',
options: newOptions,
}),
)
expect(spy1).toHaveBeenCalledTimes(1)
expect(spy1).toHaveBeenCalledWith(newOptions)
expect(spy2).not.toHaveBeenCalled()
view1.destroy()
view2.destroy()
editor.destroy()
})
it('should only process updateOptions for its own pluginKey (PluginKey instance)', () => {
const editor = createEditor()
const key1 = new PluginKey('customFloatingA')
const key2 = new PluginKey('customFloatingB')
const view1 = createFloatingMenuView(editor, { pluginKey: key1 })
const view2 = createFloatingMenuView(editor, { pluginKey: key2 })
const spy1 = vi.spyOn(view1, 'updateOptions')
const spy2 = vi.spyOn(view2, 'updateOptions')
const newOptions = { updateDelay: 500 }
editor.view.dispatch(
editor.state.tr.setMeta('customFloatingA$', {
type: 'updateOptions',
options: newOptions,
}),
)
expect(spy1).toHaveBeenCalledTimes(1)
expect(spy1).toHaveBeenCalledWith(newOptions)
expect(spy2).not.toHaveBeenCalled()
view1.destroy()
view2.destroy()
editor.destroy()
})
it('should only process updatePosition for its own pluginKey', () => {
const editor = createEditor()
const view1 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu1' })
const view2 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu2' })
const spy1 = vi.spyOn(view1, 'updatePosition')
const spy2 = vi.spyOn(view2, 'updatePosition')
editor.view.dispatch(editor.state.tr.setMeta('floatingMenu1', 'updatePosition'))
expect(spy1).toHaveBeenCalledTimes(1)
expect(spy2).not.toHaveBeenCalled()
view1.destroy()
view2.destroy()
editor.destroy()
})
it('should not cross-contaminate options between two instances', () => {
const editor = createEditor()
const view1 = createFloatingMenuView(editor, {
pluginKey: 'floatingMenu1',
updateDelay: 100,
})
const view2 = createFloatingMenuView(editor, {
pluginKey: 'floatingMenu2',
updateDelay: 200,
})
editor.view.dispatch(
editor.state.tr.setMeta('floatingMenu1', {
type: 'updateOptions',
options: { updateDelay: 999 },
}),
)
expect(view1.updateDelay).toBe(999)
expect(view2.updateDelay).toBe(200)
editor.view.dispatch(
editor.state.tr.setMeta('floatingMenu2', {
type: 'updateOptions',
options: { updateDelay: 777 },
}),
)
expect(view1.updateDelay).toBe(999)
expect(view2.updateDelay).toBe(777)
view1.destroy()
view2.destroy()
editor.destroy()
})
it('should use pluginKey in updateFloatingMenuPosition command', () => {
const customPluginKey = 'myFloatingMenu'
const menuElement = document.createElement('div')
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
FloatingMenu.configure({
element: menuElement,
pluginKey: customPluginKey,
shouldShow: () => false,
}),
],
content: 'Hello world
',
})
const dispatchSpy = vi.spyOn(editor.view, 'dispatch')
editor.commands.updateFloatingMenuPosition()
const dispatchedTr = dispatchSpy.mock.calls[0][0]
expect(dispatchedTr.getMeta(customPluginKey)).toBe('updatePosition')
expect(dispatchedTr.getMeta('floatingMenu')).toBeUndefined()
editor.destroy()
})
it('should maintain backward compatibility with default "floatingMenu" pluginKey', () => {
const editor = createEditor()
const view = createFloatingMenuView(editor)
const spy = vi.spyOn(view, 'updateOptions')
const newOptions = { updateDelay: 500 }
editor.view.dispatch(
editor.state.tr.setMeta('floatingMenu', {
type: 'updateOptions',
options: newOptions,
}),
)
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(newOptions)
view.destroy()
editor.destroy()
})
it('should only process show for its own pluginKey', () => {
const editor = createEditor()
const view1 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu1' })
const view2 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu2' })
const spy1 = vi.spyOn(view1, 'show')
const spy2 = vi.spyOn(view2, 'show')
editor.view.dispatch(editor.state.tr.setMeta('floatingMenu1', 'show'))
expect(spy1).toHaveBeenCalledTimes(1)
expect(spy2).not.toHaveBeenCalled()
view1.destroy()
view2.destroy()
editor.destroy()
})
it('should only process hide for its own pluginKey', () => {
const editor = createEditor()
const view1 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu1' })
const view2 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu2' })
const spy1 = vi.spyOn(view1, 'hide')
const spy2 = vi.spyOn(view2, 'hide')
editor.view.dispatch(editor.state.tr.setMeta('floatingMenu1', 'hide'))
expect(spy1).toHaveBeenCalledTimes(1)
expect(spy2).not.toHaveBeenCalled()
view1.destroy()
view2.destroy()
editor.destroy()
})
})
describe('FloatingMenuView destroy safety', () => {
it('updatePosition should not call coordsAtPos when the editor view is detached from the DOM', () => {
const editor = createEditor()
const view = createFloatingMenuView(editor)
const coordsSpy = vi.spyOn(editor.view, 'coordsAtPos')
try {
// Simulate the real-world teardown race: the editor is destroyed while a
// pending updatePosition call (debounced resize/scroll) is still in flight.
// ProseMirror's destroy removes view.dom from its parent and nulls docView;
// without a guard, posToDOMRect -> coordsAtPos throws on the null docView.
editor.destroy()
expect(() => view.updatePosition()).not.toThrow(
/Cannot read properties of null \(reading 'domFromPos'\)/,
)
expect(coordsSpy).not.toHaveBeenCalled()
} finally {
view.destroy()
}
})
})