From 3faac13533c966c00da3aa336308f8e4367a72b4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 08:56:23 +0200 Subject: [PATCH] fix(person-mention): respect modified-click and middle-click for new-tab nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felix #7: handleMentionClick unconditionally preventDefault'd and goto'd, breaking ctrl-click / cmd-click / shift-click / alt-click / middle-click — "open in new tab" is a real workflow for researchers comparing two persons. Add isPlainPrimaryClick() guard. Modified clicks fall through to the browser's default anchor handling (the opens in the new tab as expected). Plain left-clicks still SPA-navigate via goto(). Three new tests assert ctrl-click, meta-click, and middle-click are not preventDefault'd. Co-Authored-By: Claude Sonnet 4.6 --- .../components/TranscriptionReadView.svelte | 11 +++++++ .../TranscriptionReadView.svelte.test.ts | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index b0ccdf71..738ab791 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -143,7 +143,18 @@ function handleMentionLeave(event: Event) { activeCard = null; } +/** + * Modified clicks (ctrl/meta/shift/alt) and middle-clicks must fall through to + * the browser's default anchor behaviour so users can open the person page in + * a new tab/window. Felix #7. Only the plain primary-button click navigates + * via SPA goto(). + */ +function isPlainPrimaryClick(event: MouseEvent): boolean { + return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey; +} + async function handleMentionClick(event: MouseEvent) { + if (!isPlainPrimaryClick(event)) return; const link = event.target as HTMLAnchorElement; const personId = link.dataset.personId; if (!personId) return; diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts index 9d938d14..7b694200 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts +++ b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts @@ -370,6 +370,38 @@ describe('TranscriptionReadView — person-mention rendering', () => { expect(card).toBeNull(); }); + it('lets ctrl-click and meta-click fall through so users can open in a new tab', async () => { + render(TranscriptionReadView, { + blocks: [mentionBlock], + onParagraphClick: () => {} + }); + + const link = document.querySelector('a.person-mention')! as HTMLAnchorElement; + + // ctrl-click (Linux/Win "open in new tab") + const ctrlClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }); + const ctrlPrevented = !link.dispatchEvent(ctrlClick); + expect(ctrlPrevented).toBe(false); + + // meta-click (macOS "open in new tab") + const metaClick = new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true }); + const metaPrevented = !link.dispatchEvent(metaClick); + expect(metaPrevented).toBe(false); + }); + + it('lets middle-click fall through so users can open in a background tab', async () => { + render(TranscriptionReadView, { + blocks: [mentionBlock], + onParagraphClick: () => {} + }); + + const link = document.querySelector('a.person-mention')! as HTMLAnchorElement; + // button === 1 is middle mouse button + const middleClick = new MouseEvent('click', { bubbles: true, cancelable: true, button: 1 }); + const prevented = !link.dispatchEvent(middleClick); + expect(prevented).toBe(false); + }); + it('degrades to plain unlinked text when the person fetch returns 404', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));