diff --git a/package.json b/package.json index 14f7928b..294d3e1c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Editor.css b/src/components/Editor.css index 0aa275e3..e102b82e 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -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; diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index 979cf936..fd10f0ea 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -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 --- */ diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 8d662652..746d9ecc 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -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) { @@ -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(null) const onImageUrl = useInsertImageCallback(editor) const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath }) + useEditorLinkActivation(containerRef, onNavigateWikilink) const handleContainerClick = useCallback((e: React.MouseEvent) => { 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( diff --git a/src/components/useEditorLinkActivation.test.tsx b/src/components/useEditorLinkActivation.test.tsx new file mode 100644 index 00000000..e2f60c12 --- /dev/null +++ b/src/components/useEditorLinkActivation.test.tsx @@ -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(null) + useEditorLinkActivation(containerRef, onNavigateWikilink) + return
+} + +function renderHarness(onNavigateWikilink = vi.fn()) { + render() + 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) + }) +}) diff --git a/src/components/useEditorLinkActivation.ts b/src/components/useEditorLinkActivation.ts new file mode 100644 index 00000000..4b169535 --- /dev/null +++ b/src/components/useEditorLinkActivation.ts @@ -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('.wikilink[data-target]')?.dataset.target ?? null +} + +function resolveUrlTarget(target: HTMLElement) { + const href = target.closest('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, + 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]) +} diff --git a/tests/smoke/wikilink-path-fix.spec.ts b/tests/smoke/wikilink-path-fix.spec.ts index 4c39cbbe..b5fa8773 100644 --- a/tests/smoke/wikilink-path-fix.spec.ts +++ b/tests/smoke/wikilink-path-fix.spec.ts @@ -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) }) })