fix(editor): make url bar appear at the correct position when scrolling (#1963)

Resolves https://github.com/go-vikunja/vikunja/issues/1899

* Fixed input prompt popup positioning to remain correctly placed when
scrolling the page.
  * Improved popup cleanup and event listener management when closed.
This commit is contained in:
kolaente
2025-12-10 21:40:53 +01:00
committed by GitHub
parent f88416eed7
commit 48780d729b
2 changed files with 197 additions and 14 deletions

View File

@@ -9,7 +9,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
// Create popup element
const popupElement = document.createElement('div')
popupElement.style.position = 'absolute'
popupElement.style.position = 'fixed'
popupElement.style.top = '0'
popupElement.style.left = '0'
popupElement.style.zIndex = '4700'
@@ -21,27 +21,62 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
popupElement.innerHTML = `<div><input class="input" placeholder="URL" id="${id}" value="${oldValue}"/></div>`
document.body.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)
// Virtual reference for positioning
const virtualReference = {
getBoundingClientRect: () => pos,
getBoundingClientRect: () => currentRect,
}
// Position the popup
computePosition(virtualReference, popupElement, {
placement: 'top-start',
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
],
}).then(({ x, y }) => {
popupElement.style.left = `${x}px`
popupElement.style.top = `${y}px`
})
// Function to update popup position
const updatePosition = () => {
computePosition(virtualReference, popupElement, {
placement: 'top-start',
strategy: 'fixed',
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
],
}).then(({ x, y }) => {
popupElement.style.left = `${x}px`
popupElement.style.top = `${y}px`
})
}
// Position the popup initially
updatePosition()
// Track scroll position
let lastScrollY = window.scrollY
let lastScrollX = window.scrollX
// Update position on scroll
const handleScroll = () => {
const deltaY = window.scrollY - lastScrollY
const deltaX = window.scrollX - lastScrollX
// Update the local mutable rect to account for scroll
currentRect = new DOMRect(
currentRect.x - deltaX,
currentRect.y - deltaY,
currentRect.width,
currentRect.height,
)
lastScrollY = window.scrollY
lastScrollX = window.scrollX
updatePosition()
}
window.addEventListener('scroll', handleScroll, true)
nextTick(() => document.getElementById(id)?.focus())
const cleanup = () => {
window.removeEventListener('scroll', handleScroll, true)
if (document.body.contains(popupElement)) {
document.body.removeChild(popupElement)
}

View File

@@ -1038,4 +1038,152 @@ test.describe('Task', () => {
expect(naturalWidth).toBeGreaterThan(0)
})
})
test.describe('Link functionality in description editor', () => {
test('Should show URL input when clicking link button without scroll', async ({authenticatedPage: page}) => {
const tasks = await TaskFactory.create(1, {
id: 1,
description: 'Test text for link',
})
await page.goto(`/tasks/${tasks[0].id}`)
await page.waitForLoadState('networkidle')
// Click edit button to open editor
const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit')
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
// Wait for editor to be visible
const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
await expect(editor).toBeVisible()
// Select text by triple-clicking
await editor.click({clickCount: 3})
await page.waitForTimeout(200)
// Wait for bubble menu to appear and click Link button (6th button - chain icon)
const bubbleMenu = page.locator('.editor-bubble__wrapper')
await expect(bubbleMenu).toBeVisible({timeout: 5000})
const linkButton = bubbleMenu.locator('button').nth(5)
await linkButton.click()
// Verify URL input popup appears
const urlInput = page.locator('input[placeholder="URL"]')
await expect(urlInput).toBeVisible({timeout: 2000})
// Verify input is positioned near the toolbar button (not at top/bottom of viewport)
const urlInputBox = await urlInput.boundingBox()
const linkButtonBox = await linkButton.boundingBox()
expect(urlInputBox).not.toBeNull()
expect(linkButtonBox).not.toBeNull()
// URL input should be near the link button (within 200px vertically)
const verticalDistance = Math.abs(urlInputBox!.y - linkButtonBox!.y)
expect(verticalDistance).toBeLessThan(200)
})
test('Should position URL input correctly when page is scrolled (issue #1899)', async ({authenticatedPage: page}) => {
const tasks = await TaskFactory.create(1, {
id: 1,
description: 'Test text for link',
})
await page.goto(`/tasks/${tasks[0].id}`)
await page.waitForLoadState('networkidle')
// Scroll the page down
await page.evaluate(() => window.scrollBy(0, 500))
await page.waitForTimeout(100)
// Click edit button to open editor
const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit')
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
// Wait for editor to be visible
const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
await expect(editor).toBeVisible()
// Select text by triple-clicking
await editor.click({clickCount: 3})
await page.waitForTimeout(200)
// Wait for bubble menu and click Link button
const bubbleMenu = page.locator('.editor-bubble__wrapper')
await expect(bubbleMenu).toBeVisible({timeout: 5000})
const linkButton = bubbleMenu.locator('button').nth(5)
await linkButton.click()
// Verify URL input popup appears and is positioned correctly (not off-screen)
const urlInput = page.locator('input[placeholder="URL"]')
await expect(urlInput).toBeVisible({timeout: 2000})
// Verify input is positioned near the toolbar button
const urlInputBox = await urlInput.boundingBox()
const linkButtonBox = await linkButton.boundingBox()
expect(urlInputBox).not.toBeNull()
expect(linkButtonBox).not.toBeNull()
// URL input should be near the link button even after scroll
const verticalDistance = Math.abs(urlInputBox!.y - linkButtonBox!.y)
expect(verticalDistance).toBeLessThan(200)
// Verify URL input is visible in viewport (not off-screen at top)
const viewportHeight = page.viewportSize()!.height
expect(urlInputBox!.y).toBeGreaterThan(0)
expect(urlInputBox!.y).toBeLessThan(viewportHeight)
})
test('Should follow scroll when URL input is open', async ({authenticatedPage: page}) => {
const tasks = await TaskFactory.create(1, {
id: 1,
description: 'Test text for link',
})
await page.goto(`/tasks/${tasks[0].id}`)
await page.waitForLoadState('networkidle')
// Click edit button to open editor
const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit')
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
// Wait for editor and select text
const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
await expect(editor).toBeVisible()
await editor.click({clickCount: 3})
await page.waitForTimeout(200)
// Click Link button to open URL input
const bubbleMenu = page.locator('.editor-bubble__wrapper')
await expect(bubbleMenu).toBeVisible({timeout: 5000})
const linkButton = bubbleMenu.locator('button').nth(5)
await linkButton.click()
// Verify URL input is visible
const urlInput = page.locator('input[placeholder="URL"]')
await expect(urlInput).toBeVisible({timeout: 2000})
// Get initial position
const initialBox = await urlInput.boundingBox()
expect(initialBox).not.toBeNull()
// Scroll down while URL input is open
await page.evaluate(() => window.scrollBy(0, 300))
await page.waitForTimeout(400)
// Get new position after scroll
const afterScrollBox = await urlInput.boundingBox()
expect(afterScrollBox).not.toBeNull()
// URL input should have moved with the scroll (Y position should change)
// The input should follow the content, so its position relative to viewport should adjust
const positionChanged = Math.abs(afterScrollBox!.y - initialBox!.y) > 50
expect(positionChanged).toBe(true)
// Verify input is still near the link button after scroll
const linkButtonBox = await linkButton.boundingBox()
expect(linkButtonBox).not.toBeNull()
const verticalDistance = Math.abs(afterScrollBox!.y - linkButtonBox!.y)
expect(verticalDistance).toBeLessThan(200)
})
})
})