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() }));