feat(person-mention): TranscriptionReadView wires hover card and click nav
Composes splitByMarkers + renderTranscriptionBody so [unleserlich]
markers render as <em data-marker> siblings of the mention anchor —
neither nested inside the other (B19b).
Hover card lifecycle on each .person-mention anchor:
mouseenter → set aria-describedby, place card via getBoundingClientRect
(default below-right; flip up if <200px from bottom or
mention is in bottom 30% of viewport; flip left if
<300px from right), fire fetch, mount card with
skeleton state
resolved → swap card to loaded state with person + family
relationships (PARENT_OF / SPOUSE_OF / SIBLING_OF only)
404 → degrade: mark anchor with data-person-deleted="true",
unmount card, suppress future hovers/clicks
network → swap card to error state — link still navigates
mouseleave → drop aria-describedby, unmount card
Per-page SvelteMap<personId, Promise> cache (B15.5) so a sweep across
N mentions of the same person fires the backend once. Click handler
calls goto() so SvelteKit handles routing without a full reload.
Event listeners are attached once per article via a Svelte action
because the anchor HTML is injected via {@html ...} and would not
receive declarative bindings. The eslint-disable comment mirrors
the rationale on CommentMessage.svelte:88-89.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
||||
import { renderTranscriptionBody } from '$lib/utils/mention';
|
||||
import PersonHoverCard, { type LoadState } from './PersonHoverCard.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
type HoverData = { person: Person; relationships: RelationshipDTO[] };
|
||||
|
||||
interface Props {
|
||||
blocks: TranscriptionBlockData[];
|
||||
@@ -11,9 +21,172 @@ interface Props {
|
||||
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
||||
|
||||
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
|
||||
// Per-page in-memory cache: a sweep across 20 mentions of the same person
|
||||
// must not fire 20 backend calls (B15.5). The Promise<HoverData | null> shape
|
||||
// lets simultaneous hovers share the same in-flight fetch.
|
||||
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
|
||||
const deletedPersonIds = new SvelteSet<string>();
|
||||
|
||||
let activeCard: {
|
||||
personId: string;
|
||||
cardId: string;
|
||||
state: LoadState;
|
||||
position: { top: number; left: number };
|
||||
} | null = $state(null);
|
||||
|
||||
const CARD_WIDTH = 320;
|
||||
const CARD_HEIGHT = 180;
|
||||
const CARD_GAP = 6;
|
||||
|
||||
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
|
||||
// as <em data-marker> tags; text segments run through HTML-escaping + mention
|
||||
// substitution. The two are concatenated to preserve marker boundaries — markers
|
||||
// never end up nested inside an anchor (Felix #5324 B19b).
|
||||
function renderBlockHtml(block: TranscriptionBlockData): string {
|
||||
return splitByMarkers(block.text)
|
||||
.map((segment) => {
|
||||
if (segment.type === 'marker') {
|
||||
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>`;
|
||||
}
|
||||
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function fetchHoverData(personId: string): Promise<HoverData | null> {
|
||||
let cached = hoverCache.get(personId);
|
||||
if (cached) return cached;
|
||||
|
||||
cached = (async () => {
|
||||
const personRes = await fetch(`/api/persons/${personId}`);
|
||||
if (personRes.status === 404) return null;
|
||||
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
|
||||
const person = (await personRes.json()) as Person;
|
||||
|
||||
const relRes = await fetch(`/api/persons/${personId}/relationships`);
|
||||
const relationships: RelationshipDTO[] = relRes.ok
|
||||
? ((await relRes.json()) as RelationshipDTO[])
|
||||
: [];
|
||||
return { person, relationships };
|
||||
})();
|
||||
|
||||
hoverCache.set(personId, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function computeCardPosition(rect: DOMRect): { top: number; left: number } {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let top = rect.bottom + CARD_GAP;
|
||||
let left = rect.left;
|
||||
|
||||
// Flip up if the card would overflow the bottom edge OR the mention sits in
|
||||
// the bottom 30% of the viewport (Leonie #5329).
|
||||
if (vh - rect.bottom < CARD_HEIGHT + CARD_GAP || rect.top > vh * 0.7) {
|
||||
top = rect.top - CARD_HEIGHT - CARD_GAP;
|
||||
}
|
||||
|
||||
// Flip left if <300px from the right edge.
|
||||
if (vw - rect.left < 300) {
|
||||
left = rect.right - CARD_WIDTH;
|
||||
}
|
||||
|
||||
return {
|
||||
top: Math.max(0, top + window.scrollY),
|
||||
left: Math.max(0, left + window.scrollX)
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMentionEnter(event: Event) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
const personId = link.dataset.personId;
|
||||
if (!personId) return;
|
||||
if (deletedPersonIds.has(personId)) return;
|
||||
|
||||
const cardId = `person-hover-card-${personId}`;
|
||||
link.setAttribute('aria-describedby', cardId);
|
||||
|
||||
const rect = link.getBoundingClientRect();
|
||||
const position = computeCardPosition(rect);
|
||||
|
||||
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
||||
|
||||
try {
|
||||
const data = await fetchHoverData(personId);
|
||||
// Bail if a different mention is now active
|
||||
if (!activeCard || activeCard.personId !== personId) return;
|
||||
|
||||
if (data === null) {
|
||||
deletedPersonIds.add(personId);
|
||||
link.setAttribute('data-person-deleted', 'true');
|
||||
activeCard = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeCard = {
|
||||
personId,
|
||||
cardId,
|
||||
position,
|
||||
state: { status: 'loaded', person: data.person, relationships: data.relationships }
|
||||
};
|
||||
} catch {
|
||||
if (!activeCard || activeCard.personId !== personId) return;
|
||||
activeCard = { personId, cardId, position, state: { status: 'error' } };
|
||||
}
|
||||
}
|
||||
|
||||
function handleMentionLeave(event: Event) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
link.removeAttribute('aria-describedby');
|
||||
activeCard = null;
|
||||
}
|
||||
|
||||
async function handleMentionClick(event: MouseEvent) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
const personId = link.dataset.personId;
|
||||
if (!personId) return;
|
||||
if (deletedPersonIds.has(personId)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
await goto(`/persons/${personId}`);
|
||||
}
|
||||
|
||||
// Attach delegated event listeners on each rendered block. Using {@html ...}
|
||||
// for the body means we cannot bind events declaratively to the injected
|
||||
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
|
||||
function attachMentionHandlers(node: HTMLElement) {
|
||||
function onEnter(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.('a.person-mention')) handleMentionEnter(e);
|
||||
}
|
||||
function onLeave(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.('a.person-mention')) handleMentionLeave(e);
|
||||
}
|
||||
function onClick(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.('a.person-mention')) handleMentionClick(e);
|
||||
}
|
||||
// mouseenter does not bubble — capture it.
|
||||
node.addEventListener('mouseenter', onEnter, true);
|
||||
node.addEventListener('mouseleave', onLeave, true);
|
||||
node.addEventListener('click', onClick);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('mouseenter', onEnter, true);
|
||||
node.removeEventListener('mouseleave', onLeave, true);
|
||||
node.removeEventListener('click', onClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="px-6 py-8">
|
||||
<article class="px-6 py-8" use:attachMentionHandlers>
|
||||
{#each sorted as block (block.id)}
|
||||
<div
|
||||
class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-turquoise/10"
|
||||
@@ -22,19 +195,25 @@ let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
onclick={() => onParagraphClick(block.annotationId)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId);
|
||||
}}
|
||||
>
|
||||
{#each splitByMarkers(block.text) as segment, i (i)}
|
||||
{#if segment.type === 'marker'}
|
||||
<em data-marker class="text-ink-2 italic">{segment.text}</em>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes all HTML before injecting mention links; mirrors CommentMessage.svelte -->
|
||||
{@html renderBlockHtml(block)}
|
||||
</div>
|
||||
{/each}
|
||||
</article>
|
||||
|
||||
{#if activeCard}
|
||||
<PersonHoverCard
|
||||
personId={activeCard.personId}
|
||||
cardId={activeCard.cardId}
|
||||
position={activeCard.position}
|
||||
state={activeCard.state}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes flash {
|
||||
0% {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
@@ -152,3 +152,241 @@ describe('TranscriptionReadView', () => {
|
||||
expect(paragraphs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TranscriptionReadView — person-mention rendering', () => {
|
||||
const PERSON_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
const mentionBlock: TranscriptionBlockData = {
|
||||
id: 'b1',
|
||||
annotationId: 'ann-1',
|
||||
documentId: 'doc-1',
|
||||
text: 'Brief an @Auguste Raddatz vom Mai',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Default: any /api/persons/{id} call returns 404 unless a test overrides it.
|
||||
// Tests that need loaded data stub fetch themselves.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders a person mention as an anchor link with the person URL', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector(`a.person-mention[data-person-id="${PERSON_ID}"]`)!;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.getAttribute('href')).toBe(`/persons/${PERSON_ID}`);
|
||||
expect(link.textContent).toBe('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('strips the @ trigger from the rendered link text (read mode)', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(block.textContent).not.toContain('@Auguste Raddatz');
|
||||
expect(block.textContent).toContain('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('renders mention link AND [unleserlich] marker correctly when both occur in the same block (B19b)', async () => {
|
||||
const block: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: 'Hallo @Auguste Raddatz [unleserlich] Marie'
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [block],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
// Mention rendered as an anchor
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.textContent).toBe('Auguste Raddatz');
|
||||
|
||||
// Marker rendered as <em data-marker>
|
||||
const marker = document.querySelector('[data-marker]')!;
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker.textContent).toBe('[unleserlich]');
|
||||
|
||||
// Marker text is NOT inside the anchor — they are siblings, not nested
|
||||
expect(link.contains(marker)).toBe(false);
|
||||
|
||||
// No double-escape — text content reads cleanly
|
||||
const blockEl = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(blockEl.textContent).not.toContain('&');
|
||||
expect(blockEl.textContent).not.toContain('<');
|
||||
});
|
||||
|
||||
it('does not render mention link for plain text without the @ trigger', async () => {
|
||||
const plain: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: 'Auguste Raddatz war hier',
|
||||
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [plain],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention');
|
||||
expect(link).toBeNull();
|
||||
});
|
||||
|
||||
it('escapes HTML in the block text — no stored XSS via raw text', async () => {
|
||||
const xss: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: '<img src=x onerror=alert(1)>',
|
||||
mentionedPersons: []
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [xss],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
// No raw <img> tag in DOM
|
||||
expect(document.querySelector('[data-block-id="b1"] img')).toBeNull();
|
||||
// The escaped text is visible
|
||||
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(block.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('triggers fetch for the person on mention mouseenter (B15.5 cache, single call)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
json: vi.fn()
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const personFetches = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
|
||||
);
|
||||
expect(personFetches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
json: vi.fn()
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// Two blocks both mention the same person
|
||||
const block2: TranscriptionBlockData = { ...mentionBlock, id: 'b2', annotationId: 'ann-2' };
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock, block2],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const links = document.querySelectorAll('a.person-mention');
|
||||
links.forEach((link) => link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })));
|
||||
// Plus a re-hover on the first
|
||||
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const personFetches = fetchMock.mock.calls.filter(
|
||||
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
|
||||
);
|
||||
expect(personFetches.length).toBe(1);
|
||||
});
|
||||
|
||||
it('mounts the hover card on mouseenter when the fetch loads', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if (url.endsWith('/relationships')) {
|
||||
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: PERSON_ID,
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true,
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).not.toBeNull();
|
||||
});
|
||||
|
||||
it('unmounts the hover card on mouseleave', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
|
||||
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() }));
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// 404 → no card mounted
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
|
||||
const stillLink = document.querySelector('a.person-mention')!;
|
||||
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user