fix(editor): guard stale wikilink click targets
This commit is contained in:
@@ -50,8 +50,8 @@ function appendUrl(container: HTMLElement, href: string) {
|
||||
return link
|
||||
}
|
||||
|
||||
function dispatchClick(target: HTMLElement, options: MouseEventInit = {}) {
|
||||
const event = new MouseEvent('click', {
|
||||
function dispatchMouseEvent(target: HTMLElement, type: string, options: MouseEventInit = {}) {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...options,
|
||||
@@ -65,18 +65,34 @@ describe('useEditorLinkActivation', () => {
|
||||
mockOpenExternalUrl.mockClear()
|
||||
})
|
||||
|
||||
it('navigates wikilinks only on Cmd+click', () => {
|
||||
it('navigates wikilinks only on Cmd+click after the native click stack settles', async () => {
|
||||
const { container, onNavigateWikilink } = renderHarness()
|
||||
const wikilink = appendWikilink(container, 'Alpha Project')
|
||||
|
||||
fireEvent.click(wikilink)
|
||||
dispatchMouseEvent(wikilink, 'click')
|
||||
expect(onNavigateWikilink).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(wikilink, { metaKey: true })
|
||||
const modifiedClick = dispatchMouseEvent(wikilink, 'click', { metaKey: true })
|
||||
expect(modifiedClick.defaultPrevented).toBe(true)
|
||||
expect(onNavigateWikilink).not.toHaveBeenCalled()
|
||||
|
||||
await Promise.resolve()
|
||||
expect(onNavigateWikilink).toHaveBeenCalledWith('Alpha Project')
|
||||
})
|
||||
|
||||
it('blurs an active editor before navigating a Cmd-clicked wikilink', () => {
|
||||
it('consumes plain wikilink mousedown and click events before editor internals see stale link nodes', () => {
|
||||
const { container, onNavigateWikilink } = renderHarness()
|
||||
const wikilink = appendWikilink(container, 'Alpha Project')
|
||||
|
||||
const mouseDown = dispatchMouseEvent(wikilink, 'mousedown')
|
||||
const click = dispatchMouseEvent(wikilink, 'click')
|
||||
|
||||
expect(mouseDown.defaultPrevented).toBe(true)
|
||||
expect(click.defaultPrevented).toBe(true)
|
||||
expect(onNavigateWikilink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blurs an active editor before navigating a Cmd-clicked wikilink', async () => {
|
||||
const { container, onNavigateWikilink } = renderHarness()
|
||||
const { editable, wikilink } = appendEditableWikilink(container, 'Alpha Project')
|
||||
|
||||
@@ -85,19 +101,20 @@ describe('useEditorLinkActivation', () => {
|
||||
|
||||
fireEvent.click(wikilink, { metaKey: true })
|
||||
|
||||
expect(onNavigateWikilink).toHaveBeenCalledWith('Alpha Project')
|
||||
expect(document.activeElement).not.toBe(editable)
|
||||
await Promise.resolve()
|
||||
expect(onNavigateWikilink).toHaveBeenCalledWith('Alpha Project')
|
||||
})
|
||||
|
||||
it('opens URLs only on Cmd+click', () => {
|
||||
const { container } = renderHarness()
|
||||
const link = appendUrl(container, 'https://example.com')
|
||||
|
||||
const plainClick = dispatchClick(link)
|
||||
const plainClick = dispatchMouseEvent(link, 'click')
|
||||
expect(mockOpenExternalUrl).not.toHaveBeenCalled()
|
||||
expect(plainClick.defaultPrevented).toBe(true)
|
||||
|
||||
const modifiedClick = dispatchClick(link, { metaKey: true })
|
||||
const modifiedClick = dispatchMouseEvent(link, 'click', { metaKey: true })
|
||||
expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://example.com')
|
||||
expect(modifiedClick.defaultPrevented).toBe(true)
|
||||
})
|
||||
@@ -106,8 +123,8 @@ describe('useEditorLinkActivation', () => {
|
||||
const { container } = renderHarness()
|
||||
const link = appendUrl(container, 'https://exa mple.com')
|
||||
|
||||
const plainClick = dispatchClick(link)
|
||||
const modifiedClick = dispatchClick(link, { metaKey: true })
|
||||
const plainClick = dispatchMouseEvent(link, 'click')
|
||||
const modifiedClick = dispatchMouseEvent(link, 'click', { metaKey: true })
|
||||
|
||||
expect(plainClick.defaultPrevented).toBe(true)
|
||||
expect(modifiedClick.defaultPrevented).toBe(true)
|
||||
|
||||
@@ -31,26 +31,32 @@ function setFollowLinksActive(container: HTMLElement, active: boolean) {
|
||||
else container.removeAttribute('data-follow-links')
|
||||
}
|
||||
|
||||
function consumeEditorLinkClick(event: MouseEvent) {
|
||||
function consumeEditorLinkEvent(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function scheduleAfterNativeClick(callback: () => void) {
|
||||
if (typeof queueMicrotask === 'function') queueMicrotask(callback)
|
||||
else window.setTimeout(callback, 0)
|
||||
}
|
||||
|
||||
function activateWikilink(
|
||||
event: MouseEvent,
|
||||
container: HTMLElement,
|
||||
target: string,
|
||||
onNavigateWikilink: (target: string) => void,
|
||||
) {
|
||||
consumeEditorLinkEvent(event)
|
||||
|
||||
if (!hasFollowModifier(event)) return
|
||||
|
||||
consumeEditorLinkClick(event)
|
||||
blurActiveEditable(container)
|
||||
onNavigateWikilink(target)
|
||||
scheduleAfterNativeClick(() => onNavigateWikilink(target))
|
||||
}
|
||||
|
||||
function activateExternalUrl(event: MouseEvent, href: string) {
|
||||
consumeEditorLinkClick(event)
|
||||
consumeEditorLinkEvent(event)
|
||||
|
||||
if (!hasFollowModifier(event)) return
|
||||
|
||||
@@ -77,6 +83,12 @@ function handleEditorLinkClick(
|
||||
if (href) activateExternalUrl(event, href)
|
||||
}
|
||||
|
||||
function handleEditorLinkMouseDown(event: MouseEvent) {
|
||||
if (!(event.target instanceof HTMLElement) || isInsideCodeContext(event.target)) return
|
||||
|
||||
if (resolveWikilinkTarget(event.target)) consumeEditorLinkEvent(event)
|
||||
}
|
||||
|
||||
export function useEditorLinkActivation(
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
onNavigateWikilink: (target: string) => void,
|
||||
@@ -96,6 +108,7 @@ export function useEditorLinkActivation(
|
||||
handleEditorLinkClick(event, container, onNavigateWikilink)
|
||||
}
|
||||
|
||||
container.addEventListener('mousedown', handleEditorLinkMouseDown, true)
|
||||
container.addEventListener('click', handleClick, true)
|
||||
window.addEventListener('keydown', handleModifierChange)
|
||||
window.addEventListener('keyup', handleModifierChange)
|
||||
@@ -103,6 +116,7 @@ export function useEditorLinkActivation(
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousedown', handleEditorLinkMouseDown, true)
|
||||
container.removeEventListener('click', handleClick, true)
|
||||
window.removeEventListener('keydown', handleModifierChange)
|
||||
window.removeEventListener('keyup', handleModifierChange)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
@@ -16,6 +18,41 @@ async function expectActiveHeading(page: Page, title: string) {
|
||||
await expect(page.locator('.bn-editor h1').first()).toHaveText(title, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
function trackStaleWikilinkClickErrors(page: Page): string[] {
|
||||
const messages: string[] = []
|
||||
page.on('pageerror', (error) => {
|
||||
if (error.message.includes('dispatchEvent')) messages.push(error.message)
|
||||
})
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && message.text().includes('dispatchEvent')) {
|
||||
messages.push(message.text())
|
||||
}
|
||||
})
|
||||
return messages
|
||||
}
|
||||
|
||||
async function appendToProjectParagraph(page: Page, marker: string): Promise<void> {
|
||||
const paragraph = page.locator('.bn-editor p')
|
||||
.filter({ hasText: 'This is a test project that references other notes.' })
|
||||
.first()
|
||||
await expect(paragraph).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
const box = await paragraph.boundingBox()
|
||||
if (!box) throw new Error('Expected editable paragraph bounds')
|
||||
await paragraph.click({
|
||||
position: {
|
||||
x: Math.max(1, box.width - 2),
|
||||
y: Math.max(1, box.height / 2),
|
||||
},
|
||||
})
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.type(` ${marker}`)
|
||||
}
|
||||
|
||||
async function expectFileToContain(filePath: string, marker: string): Promise<void> {
|
||||
await expect.poll(() => fs.readFileSync(filePath, 'utf8'), { timeout: 10_000 }).toContain(marker)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
await page.goto('/')
|
||||
@@ -50,3 +87,26 @@ test('@smoke Cmd-clicking an existing wikilink preserves Back/Forward history',
|
||||
await page.keyboard.press('Meta+ArrowLeft')
|
||||
await expectActiveHeading(page, 'Note B')
|
||||
})
|
||||
|
||||
test('Cmd-clicking a wikilink after rich-edit autosave does not dispatch through stale link nodes', async ({ page }) => {
|
||||
const staleClickErrors = trackStaleWikilinkClickErrors(page)
|
||||
const marker = `autosaved wikilink click ${Date.now()}`
|
||||
const alphaPath = path.join(tempVaultDir, 'project', 'alpha-project.md')
|
||||
|
||||
await openNote(page, 'Alpha Project')
|
||||
await expectActiveHeading(page, 'Alpha Project')
|
||||
|
||||
await appendToProjectParagraph(page, marker)
|
||||
await expectFileToContain(alphaPath, marker)
|
||||
|
||||
const wikilink = page.locator('.bn-editor .wikilink').filter({ hasText: 'Note B' }).first()
|
||||
await expect(wikilink).toBeVisible()
|
||||
|
||||
await wikilink.click()
|
||||
await expectActiveHeading(page, 'Alpha Project')
|
||||
expect(staleClickErrors).toEqual([])
|
||||
|
||||
await wikilink.click({ modifiers: ['Meta'] })
|
||||
await expectActiveHeading(page, 'Note B')
|
||||
expect(staleClickErrors).toEqual([])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user