fix(editor): render floating popups inside the task dialog (Kanban popup)

The Kanban task detail opens as a native <dialog> via showModal(), which
paints in the browser top-layer. Floating UI appended to document.body
(or teleported to <body>) then renders behind the dialog regardless of
z-index, matching the bug class of #2940 / #1746 / #1899 / #1929.

- Emoji autocomplete popup: append to getPopupContainer(editor) (the open
  dialog ancestor, else body), the same helper the slash menu and mentions
  already use. Also switch its unmount to popupElement.remove() so it works
  no matter which container it was appended to.
- Attachment dropzone overlay: teleport into the topmost open
  dialog.modal-dialog instead of always <body>, mirroring Notification.vue,
  so the drag-and-drop hint is visible while a task detail dialog is open.
This commit is contained in:
kolaente 2026-06-19 20:38:43 +02:00
parent b6af132845
commit ffea278be1
2 changed files with 34 additions and 5 deletions

View File

@ -5,6 +5,7 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
import EmojiList from './EmojiList.vue'
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
import {getPopupContainer} from '../popupContainer'
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
@ -78,7 +79,7 @@ export default function emojiSuggestionSetup() {
popupElement.style.left = '0'
popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement)
getPopupContainer(props.editor).appendChild(popupElement)
const rect = props.clientRect()
if (!rect) {
@ -108,7 +109,7 @@ export default function emojiSuggestionSetup() {
cleanupFloating = null
}
if (popupElement) {
document.body.removeChild(popupElement)
popupElement.remove()
popupElement = null
}
component?.destroy()

View File

@ -123,7 +123,7 @@
</XButton>
<!-- Dropzone -->
<Teleport to="body">
<Teleport :to="dropzoneTeleportTarget">
<div
v-if="editEnabled"
:class="{hidden: !showDropzone}"
@ -185,7 +185,7 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed, watch} from 'vue'
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/User.vue'
@ -322,6 +322,34 @@ const showDropzone = computed(() =>
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
)
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
// the browser's top layer, so the full-screen dropzone overlay teleported to
// <body> would paint behind it regardless of z-index. Teleport it into the
// topmost open dialog instead, mirroring Notification.vue.
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
let dialogObserver: MutationObserver | null = null
function syncDropzoneTeleportTarget() {
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
}
onMounted(() => {
syncDropzoneTeleportTarget()
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
dialogObserver.observe(document.body, {
attributes: true,
attributeFilter: ['open'],
childList: true,
subtree: true,
})
})
onBeforeUnmount(() => {
dialogObserver?.disconnect()
dialogObserver = null
})
watch(() => props.editEnabled, enabled => {
if (!enabled) {
resetDragState()
@ -478,7 +506,7 @@ defineExpose({
inset-inline-start: 0;
inset-block-end: 0;
inset-inline-end: 0;
z-index: 4001; // modal z-index is 4000
z-index: 4001; // above app chrome when teleported to body (no modal open)
text-align: center;
&.hidden {