fix: require cmd-click for editor links
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/getting-started-template.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* Wikilink inline content — color is set via inline style per note type */
|
||||
.wikilink {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
.editor__blocknote-container .bn-editor a {
|
||||
color: var(--inline-styles-link-color);
|
||||
text-decoration: var(--inline-styles-link-text-decoration);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* --- Wikilinks (override from Editor.css with theme vars) --- */
|
||||
@@ -221,7 +222,12 @@
|
||||
color: var(--inline-styles-wikilink-color);
|
||||
text-decoration: var(--inline-styles-wikilink-text-decoration);
|
||||
border-bottom: var(--inline-styles-wikilink-border-bottom);
|
||||
cursor: var(--inline-styles-wikilink-cursor);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.editor__blocknote-container[data-follow-links] .bn-editor a,
|
||||
.editor__blocknote-container[data-follow-links] .wikilink {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- Code blocks --- */
|
||||
|
||||
@@ -11,6 +11,7 @@ import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionE
|
||||
import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { _wikilinkEntriesRef } from './editorSchema'
|
||||
import { useEditorLinkActivation } from './useEditorLinkActivation'
|
||||
|
||||
/** Insert an image block after the current cursor position. */
|
||||
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
@@ -32,12 +33,11 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
vaultPath?: string
|
||||
editable?: boolean
|
||||
}) {
|
||||
const navigateRef = useRef(onNavigateWikilink)
|
||||
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
|
||||
const { cssVars } = useEditorTheme()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const onImageUrl = useInsertImageCallback(editor)
|
||||
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
|
||||
useEditorLinkActivation(containerRef, onNavigateWikilink)
|
||||
|
||||
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!editable) return
|
||||
@@ -54,22 +54,6 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
_wikilinkEntriesRef.current = entries
|
||||
}, [entries])
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.querySelector('.editor__blocknote-container')
|
||||
if (!container) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
const wikilink = (e.target as HTMLElement).closest('.wikilink')
|
||||
if (wikilink) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const target = (wikilink as HTMLElement).dataset.target
|
||||
if (target) navigateRef.current(target)
|
||||
}
|
||||
}
|
||||
container.addEventListener('click', handler as EventListener, true)
|
||||
return () => container.removeEventListener('click', handler as EventListener, true)
|
||||
}, [editor])
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
|
||||
99
src/components/useEditorLinkActivation.test.tsx
Normal file
99
src/components/useEditorLinkActivation.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useRef } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../utils/url', async () => {
|
||||
const actual = await vi.importActual('../utils/url') as typeof import('../utils/url')
|
||||
return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) }
|
||||
})
|
||||
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import { useEditorLinkActivation } from './useEditorLinkActivation'
|
||||
|
||||
const mockOpenExternalUrl = vi.mocked(openExternalUrl)
|
||||
|
||||
function Harness({ onNavigateWikilink }: { onNavigateWikilink: (target: string) => void }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
useEditorLinkActivation(containerRef, onNavigateWikilink)
|
||||
return <div ref={containerRef} data-testid="editor-link-container" />
|
||||
}
|
||||
|
||||
function renderHarness(onNavigateWikilink = vi.fn()) {
|
||||
render(<Harness onNavigateWikilink={onNavigateWikilink} />)
|
||||
return {
|
||||
container: screen.getByTestId('editor-link-container') as HTMLDivElement,
|
||||
onNavigateWikilink,
|
||||
}
|
||||
}
|
||||
|
||||
function appendWikilink(container: HTMLElement, target: string) {
|
||||
const wikilink = document.createElement('span')
|
||||
wikilink.className = 'wikilink'
|
||||
wikilink.dataset.target = target
|
||||
container.appendChild(wikilink)
|
||||
return wikilink
|
||||
}
|
||||
|
||||
function appendUrl(container: HTMLElement, href: string) {
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', href)
|
||||
link.textContent = href
|
||||
link.addEventListener('click', (event) => {
|
||||
if (!(event as MouseEvent).metaKey) event.preventDefault()
|
||||
})
|
||||
container.appendChild(link)
|
||||
return link
|
||||
}
|
||||
|
||||
describe('useEditorLinkActivation', () => {
|
||||
beforeEach(() => {
|
||||
mockOpenExternalUrl.mockClear()
|
||||
})
|
||||
|
||||
it('navigates wikilinks only on Cmd+click', () => {
|
||||
const { container, onNavigateWikilink } = renderHarness()
|
||||
const wikilink = appendWikilink(container, 'Alpha Project')
|
||||
|
||||
fireEvent.click(wikilink)
|
||||
expect(onNavigateWikilink).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(wikilink, { metaKey: true })
|
||||
expect(onNavigateWikilink).toHaveBeenCalledWith('Alpha Project')
|
||||
})
|
||||
|
||||
it('opens URLs only on Cmd+click', () => {
|
||||
const { container } = renderHarness()
|
||||
const link = appendUrl(container, 'https://example.com')
|
||||
|
||||
fireEvent.click(link)
|
||||
expect(mockOpenExternalUrl).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(link, { metaKey: true })
|
||||
expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://example.com')
|
||||
})
|
||||
|
||||
it('ignores malformed URLs and links inside code blocks', () => {
|
||||
const { container, onNavigateWikilink } = renderHarness()
|
||||
const codeBlock = document.createElement('div')
|
||||
codeBlock.setAttribute('data-content-type', 'codeBlock')
|
||||
codeBlock.appendChild(appendWikilink(codeBlock, 'Inside Code'))
|
||||
container.appendChild(codeBlock)
|
||||
const badLink = appendUrl(container, 'not a url')
|
||||
|
||||
fireEvent.click(codeBlock.firstElementChild!, { metaKey: true })
|
||||
fireEvent.click(badLink, { metaKey: true })
|
||||
|
||||
expect(onNavigateWikilink).not.toHaveBeenCalled()
|
||||
expect(mockOpenExternalUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles follow-link cursor mode while Cmd is held', () => {
|
||||
const { container } = renderHarness()
|
||||
|
||||
expect(container.hasAttribute('data-follow-links')).toBe(false)
|
||||
fireEvent.keyDown(window, { key: 'Meta', metaKey: true })
|
||||
expect(container.hasAttribute('data-follow-links')).toBe(true)
|
||||
fireEvent.keyUp(window, { key: 'Meta' })
|
||||
expect(container.hasAttribute('data-follow-links')).toBe(false)
|
||||
})
|
||||
})
|
||||
79
src/components/useEditorLinkActivation.ts
Normal file
79
src/components/useEditorLinkActivation.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
import { isUrlValue, normalizeUrl, openExternalUrl } from '../utils/url'
|
||||
|
||||
const CODE_CONTEXT_SELECTOR = '[data-content-type="codeBlock"], pre, code'
|
||||
|
||||
function hasFollowModifier(event: KeyboardEvent | MouseEvent) {
|
||||
return event.metaKey || event.ctrlKey
|
||||
}
|
||||
|
||||
function isInsideCodeContext(target: HTMLElement) {
|
||||
return !!target.closest(CODE_CONTEXT_SELECTOR)
|
||||
}
|
||||
|
||||
function resolveWikilinkTarget(target: HTMLElement) {
|
||||
return target.closest<HTMLElement>('.wikilink[data-target]')?.dataset.target ?? null
|
||||
}
|
||||
|
||||
function resolveUrlTarget(target: HTMLElement) {
|
||||
const href = target.closest<HTMLAnchorElement>('a[href]')?.getAttribute('href')?.trim()
|
||||
if (!href || !isUrlValue(href)) return null
|
||||
return normalizeUrl(href)
|
||||
}
|
||||
|
||||
function setFollowLinksActive(container: HTMLElement, active: boolean) {
|
||||
if (active) container.setAttribute('data-follow-links', '')
|
||||
else container.removeAttribute('data-follow-links')
|
||||
}
|
||||
|
||||
export function useEditorLinkActivation(
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
onNavigateWikilink: (target: string) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const resetModifierState = () => setFollowLinksActive(container, false)
|
||||
const handleModifierChange = (event: KeyboardEvent) => {
|
||||
setFollowLinksActive(container, hasFollowModifier(event))
|
||||
}
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') resetModifierState()
|
||||
}
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!hasFollowModifier(event)) return
|
||||
if (!(event.target instanceof HTMLElement) || isInsideCodeContext(event.target)) return
|
||||
|
||||
const wikilinkTarget = resolveWikilinkTarget(event.target)
|
||||
if (wikilinkTarget) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onNavigateWikilink(wikilinkTarget)
|
||||
return
|
||||
}
|
||||
|
||||
const urlTarget = resolveUrlTarget(event.target)
|
||||
if (!urlTarget) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openExternalUrl(urlTarget).catch(() => {})
|
||||
}
|
||||
|
||||
container.addEventListener('click', handleClick, true)
|
||||
window.addEventListener('keydown', handleModifierChange)
|
||||
window.addEventListener('keyup', handleModifierChange)
|
||||
window.addEventListener('blur', resetModifierState)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick, true)
|
||||
window.removeEventListener('keydown', handleModifierChange)
|
||||
window.removeEventListener('keyup', handleModifierChange)
|
||||
window.removeEventListener('blur', resetModifierState)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
resetModifierState()
|
||||
}
|
||||
}, [containerRef, onNavigateWikilink])
|
||||
}
|
||||
@@ -49,7 +49,7 @@ test.describe('Wikilink insertion and navigation', () => {
|
||||
expect(target).toBeTruthy()
|
||||
})
|
||||
|
||||
test('clicking an inserted wikilink navigates to the note', async ({ page }) => {
|
||||
test('@smoke Cmd+clicking an inserted wikilink navigates to the note', async ({ page }) => {
|
||||
// Insert a wikilink via autocomplete
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 5000 })
|
||||
@@ -69,15 +69,17 @@ test.describe('Wikilink insertion and navigation', () => {
|
||||
await expect(wikilink).toBeVisible()
|
||||
const targetTitle = await wikilink.textContent()
|
||||
|
||||
// Click the wikilink to navigate
|
||||
await wikilink.click()
|
||||
// Cmd+click the wikilink to navigate
|
||||
await wikilink.click({ modifiers: ['Meta'] })
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// The editor should now show the target note
|
||||
const heading = page.locator('.bn-editor h1')
|
||||
await expect(heading).toBeVisible({ timeout: 3000 })
|
||||
const headingText = await heading.textContent()
|
||||
// The heading should match the beginning of the target note's title
|
||||
expect(headingText?.toLowerCase()).toContain(targetTitle?.toLowerCase().substring(0, 4) || '')
|
||||
const expected = targetTitle?.substring(0, 4) ?? ''
|
||||
await expect.poll(async () => {
|
||||
const titleInput = page.getByTestId('title-field-input')
|
||||
if (await titleInput.count()) return await titleInput.inputValue()
|
||||
const heading = page.locator('.bn-editor h1').first()
|
||||
if (await heading.count()) return (await heading.textContent()) ?? ''
|
||||
return ''
|
||||
}, { timeout: 5000 }).toContain(expected)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user