fix(editor): render link prompt inside the task dialog so it works in the Kanban popup (#2940)

The Kanban task popup renders the description editor inside a native
<dialog> opened via showModal(), which lives in the browser's top-layer.
inputPrompt appended its URL <input> to document.body, so it was painted
behind the top-layer dialog (z-index cannot beat the top-layer) and could
not be focused through the dialog's focus trap. As a result clicking "Link"
in the popup did nothing, while it worked on the full task page (no modal).

Thread the TipTap editor through inputPrompt and append the prompt to
getPopupContainer(editor) — the open dialog ancestor when present, falling
back to document.body otherwise, so non-modal usage is unchanged. This is
the same helper the slash menu and mentions already use to escape the
top-layer (#1746).

Fixes #2940
This commit is contained in:
kolaente 2026-06-19 20:39:57 +02:00
parent b6af132845
commit 2126130fdd
4 changed files with 80 additions and 6 deletions

View File

@ -722,7 +722,7 @@ async function addImage(event: Event) {
return
}
const url = await inputPrompt(event.target.getBoundingClientRect())
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
if (url) {
editor.value?.chain().focus().setImage({src: url}).run()

View File

@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
const previousUrl = editor?.getAttributes('link').href || ''
const url = await inputPrompt(pos, previousUrl)
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
// empty
if (url === '') {

View File

@ -2,10 +2,17 @@ import {createRandomID} from '@/helpers/randomId'
import {computePosition, flip, shift, offset} from '@floating-ui/dom'
import {nextTick} from 'vue'
import {eventToShortcutString} from '@/helpers/shortcut'
import type {Editor} from '@tiptap/core'
import {getPopupContainer} from '@/components/input/editor/popupContainer'
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise<string> {
return new Promise((resolve) => {
const id = 'link-input-' + createRandomID()
// Append inside the open task <dialog> (top-layer) when present, otherwise
// document.body. A body-level popup is painted behind a showModal() dialog
// and unfocusable through its focus trap, breaking the link prompt in the
// Kanban task popup (#2940).
const container = getPopupContainer(editor)
// Create popup element
const popupElement = document.createElement('div')
@ -26,7 +33,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
inputElement.value = oldValue
wrapperDiv.appendChild(inputElement)
popupElement.appendChild(wrapperDiv)
document.body.appendChild(popupElement)
container.appendChild(popupElement)
// Create a local mutable copy of the position for scroll tracking
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
@ -84,8 +91,8 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
const cleanup = () => {
window.removeEventListener('scroll', handleScroll, true)
if (document.body.contains(popupElement)) {
document.body.removeChild(popupElement)
if (container.contains(popupElement)) {
container.removeChild(popupElement)
}
}

View File

@ -0,0 +1,67 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
import {BucketFactory} from '../../factories/bucket'
import {TaskFactory} from '../../factories/task'
import {TaskBucketFactory} from '../../factories/task_buckets'
// Regression test for #2940: in the Kanban task popup the description editor is
// rendered inside a native <dialog> opened via showModal() (browser top-layer).
// The link prompt used to be appended to document.body, so it was painted behind
// the dialog and unfocusable through its focus trap, making "set link" a no-op.
test.describe('Editor link prompt inside the Kanban task popup', () => {
test('creates a link in the description when opened as the Kanban popup', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(1, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
description: 'link me',
index: 1,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
await expect(card).toBeVisible()
await card.click()
// The task popup must be a native <dialog> in the top layer.
const dialog = page.locator('dialog[open]')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'})
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
const description = dialog.locator('.details.content.description')
const editor = description.locator('[contenteditable="true"]').first()
await expect(editor).toBeVisible({timeout: 10000})
await editor.click()
await page.keyboard.press('ControlOrMeta+a')
await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click()
const urlInput = dialog.locator('input.input[placeholder="URL"]')
await expect(urlInput).toBeVisible()
await urlInput.fill('https://vikunja.io')
await urlInput.press('Enter')
const link = editor.locator('a[href="https://vikunja.io"]')
await expect(link).toBeVisible()
await expect(link).toHaveText('link me')
})
})