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
5 changes: 5 additions & 0 deletions .changeset/hip-cats-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/vue-3': patch
---

fix(vue-3): solve BubbleMenu v-if remount crash
83 changes: 51 additions & 32 deletions packages/vue-3/src/menus/BubbleMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu'
import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'
import { PluginKey } from '@tiptap/pm/state'
import type { PropType } from 'vue'
import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { defineComponent, h, onBeforeUnmount, onMounted, Teleport, watchEffect } from 'vue'

export const BubbleMenu = defineComponent({
name: 'BubbleMenu',
Expand Down Expand Up @@ -52,49 +52,68 @@ export const BubbleMenu = defineComponent({
},

setup(props, { slots, attrs }) {
const root = ref<HTMLElement | null>(null)
const resolvedPluginKey = props.pluginKey ?? new PluginKey('bubbleMenu')

// Create the element imperatively, outside Vue's virtual DOM tree.
// This prevents Vue's vnode reconciliation from conflicting with the
// plugin's DOM management (show/hide re-parents the element).
const menuElement = document.createElement('div')

// Reactively forward HTML attributes (class, style, data-*, etc.) from
// the component to the imperatively created menu element.
// This preserves the attribute forwarding that was previously done via
// spreading `...attrs` onto the Vue-managed root element.
watchEffect(() => {
Object.entries(attrs).forEach(([key, value]) => {
if (key === 'class') {
menuElement.className = String(value)
} else if (key === 'style') {
if (typeof value === 'string') {
menuElement.style.cssText = value
} else if (typeof value === 'object' && value !== null) {
Object.assign(menuElement.style, value)
}
} else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
menuElement.setAttribute(key, String(value))
}
})
})

onMounted(() => {
const { editor, options, resizeDelay, appendTo, shouldShow, getReferencedVirtualElement, updateDelay } = props

const el = root.value

if (!el) {
return
}

el.style.visibility = 'hidden'
el.style.position = 'absolute'

// Remove element from DOM; plugin will re-parent it when shown
el.remove()

nextTick(() => {
editor.registerPlugin(
BubbleMenuPlugin({
editor,
element: el,
options,
pluginKey: resolvedPluginKey,
resizeDelay,
appendTo,
shouldShow,
getReferencedVirtualElement,
updateDelay,
}),
)
})
menuElement.style.visibility = 'hidden'
menuElement.style.position = 'absolute'

// The element starts detached — no need to remove it from the DOM.
// The plugin appends it to the editor's parent when the menu should show.
editor.registerPlugin(
BubbleMenuPlugin({
editor,
element: menuElement,
options,
pluginKey: resolvedPluginKey,
resizeDelay,
appendTo,
shouldShow,
getReferencedVirtualElement,
updateDelay,
}),
)
})

onBeforeUnmount(() => {
const { editor } = props

editor.unregisterPlugin(resolvedPluginKey)

// Remove the element from the DOM in case the plugin hasn't already done so
menuElement.remove()
})

// Vue owns this element; attrs are applied reactively by Vue
// Plugin re-parents it when showing the menu
return () => h('div', { ref: root, ...attrs }, slots.default?.())
// Use Teleport to render slot content into the plugin-managed element.
// The plugin controls where menuElement lives in the DOM (show/hide),
// while Vue handles the reactivity of the slot content via Teleport.
return () => h(Teleport as any, { to: menuElement }, slots.default?.())
},
})
Loading