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">
|
<script lang="ts">
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
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 {
|
interface Props {
|
||||||
blocks: TranscriptionBlockData[];
|
blocks: TranscriptionBlockData[];
|
||||||
@@ -11,9 +21,172 @@ interface Props {
|
|||||||
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
||||||
|
|
||||||
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
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>
|
</script>
|
||||||
|
|
||||||
<article class="px-6 py-8">
|
<article class="px-6 py-8" use:attachMentionHandlers>
|
||||||
{#each sorted as block (block.id)}
|
{#each sorted as block (block.id)}
|
||||||
<div
|
<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"
|
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)}
|
onclick={() => onParagraphClick(block.annotationId)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
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)}
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes all HTML before injecting mention links; mirrors CommentMessage.svelte -->
|
||||||
{#if segment.type === 'marker'}
|
{@html renderBlockHtml(block)}
|
||||||
<em data-marker class="text-ink-2 italic">{segment.text}</em>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{#if activeCard}
|
||||||
|
<PersonHoverCard
|
||||||
|
personId={activeCard.personId}
|
||||||
|
cardId={activeCard.cardId}
|
||||||
|
position={activeCard.position}
|
||||||
|
state={activeCard.state}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes flash {
|
@keyframes flash {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
@@ -152,3 +152,241 @@ describe('TranscriptionReadView', () => {
|
|||||||
expect(paragraphs.length).toBe(0);
|
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