mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 22:47:40 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user