Compare commits
5 Commits
b4b46a0a79
...
ae868f4110
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae868f4110 | ||
|
|
1fd38830fe | ||
|
|
c9c395eb59 | ||
|
|
c247e1e971 | ||
|
|
eb6e21f032 |
154
frontend/e2e/person-mention-read.spec.ts
Normal file
154
frontend/e2e/person-mention-read.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
const STORAGE_STATE = path.resolve(__dirname, '.auth/user.json');
|
||||
|
||||
/**
|
||||
* E2E for issue #362 — Person @mentions, read-mode rendering + hover card (B20/B21).
|
||||
*
|
||||
* Strategy:
|
||||
* - Create a document, a Person, and a transcription block whose text contains
|
||||
* `@DisplayName` and whose mentionedPersons sidecar links to that person.
|
||||
* - Open the document in read mode.
|
||||
* - B20: page.hover() on the .person-mention link → hover card mounts.
|
||||
* - B21: with context.setHasTouch(true), page.tap() on the link → navigates
|
||||
* to /persons/{id} without ever showing the hover card.
|
||||
*/
|
||||
|
||||
let docId: string;
|
||||
let personId: string;
|
||||
let docHref: string;
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Person mention — read mode', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
// 1. Person we will mention.
|
||||
const personRes = await request.post('/api/persons', {
|
||||
data: {
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
personType: 'PERSON',
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
}
|
||||
});
|
||||
if (!personRes.ok()) throw new Error(`Create person failed: ${personRes.status()}`);
|
||||
const person = await personRes.json();
|
||||
personId = person.id;
|
||||
|
||||
// 2. Document with a PDF so the transcription panel is mountable.
|
||||
const docRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Person Mention Read', documentDate: '1945-05-08' }
|
||||
});
|
||||
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||
const doc = await docRes.json();
|
||||
docId = doc.id;
|
||||
docHref = `${baseURL}/documents/${docId}`;
|
||||
|
||||
await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
documentDate: '1945-05-08',
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Annotation to anchor the block on the page.
|
||||
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
|
||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
|
||||
});
|
||||
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
|
||||
|
||||
// 4. Block text contains @Auguste Raddatz; sidecar links it to personId.
|
||||
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.1,
|
||||
text: 'Brief an @Auguste Raddatz vom Mai 1944',
|
||||
label: null,
|
||||
mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }]
|
||||
}
|
||||
});
|
||||
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (docId) await request.delete(`/api/documents/${docId}`);
|
||||
if (personId) await request.delete(`/api/persons/${personId}`);
|
||||
});
|
||||
|
||||
test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await expect(link).toBeVisible({ timeout: 5000 });
|
||||
await expect(link).toHaveAttribute('href', `/persons/${personId}`);
|
||||
// The @ trigger is stripped from the rendered text per spec
|
||||
await expect(link).toHaveText('Auguste Raddatz');
|
||||
});
|
||||
|
||||
test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await link.hover();
|
||||
|
||||
const card = page.getByTestId('person-hover-card');
|
||||
await expect(card).toBeVisible({ timeout: 5000 });
|
||||
// Loaded state: person displayName is rendered inside the card
|
||||
await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz');
|
||||
// Footer link points to /persons/{id}
|
||||
await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('B20: hover card unmounts on mouseleave', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await link.hover();
|
||||
await expect(page.getByTestId('person-hover-card')).toBeVisible();
|
||||
|
||||
// Move pointer away
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 });
|
||||
});
|
||||
|
||||
test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
...devices['Pixel 7'],
|
||||
storageState: STORAGE_STATE
|
||||
});
|
||||
const touchPage = await context.newPage();
|
||||
try {
|
||||
await touchPage.goto(docHref);
|
||||
await touchPage.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await expect(link).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await link.tap();
|
||||
// The card never mounted — the tap navigated directly per spec
|
||||
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
|
||||
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
240
frontend/src/lib/components/PersonHoverCard.svelte
Normal file
240
frontend/src/lib/components/PersonHoverCard.svelte
Normal file
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
export type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error' }
|
||||
| { status: 'loaded'; person: Person; relationships: RelationshipDTO[] };
|
||||
|
||||
type Props = {
|
||||
personId: string;
|
||||
cardId: string;
|
||||
position: { top: number; left: number };
|
||||
state: LoadState;
|
||||
};
|
||||
|
||||
let { personId, cardId, position, state }: Props = $props();
|
||||
|
||||
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
|
||||
'PARENT_OF',
|
||||
'SPOUSE_OF',
|
||||
'SIBLING_OF'
|
||||
]);
|
||||
const NOTES_MAX = 120;
|
||||
|
||||
const familyChips = $derived(
|
||||
state.status === 'loaded'
|
||||
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
|
||||
: []
|
||||
);
|
||||
|
||||
const dateRange = $derived(
|
||||
state.status === 'loaded'
|
||||
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
|
||||
: ''
|
||||
);
|
||||
|
||||
const notesExcerpt = $derived.by(() => {
|
||||
if (state.status !== 'loaded') return null;
|
||||
const notes = state.person.notes;
|
||||
if (!notes) return null;
|
||||
if (notes.length <= NOTES_MAX) return notes;
|
||||
return notes.slice(0, NOTES_MAX) + '…';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="person-hover-card"
|
||||
data-testid="person-hover-card"
|
||||
id={cardId}
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
style:position="absolute"
|
||||
style:top={`${position.top}px`}
|
||||
style:left={`${position.left}px`}
|
||||
>
|
||||
{#if state.status === 'loading'}
|
||||
<div data-testid="person-hover-card-skeleton" class="skeleton">
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
{:else if state.status === 'error'}
|
||||
<div data-testid="person-hover-card-error" class="error-message">
|
||||
{m.person_mention_load_error()}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div data-testid="person-hover-card-content" class="content">
|
||||
<div class="header">
|
||||
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
|
||||
{#if dateRange}
|
||||
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
||||
{/if}
|
||||
{#if state.person.alias}
|
||||
<div class="maiden" data-testid="person-hover-card-maiden">geb. {state.person.alias}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if familyChips.length > 0}
|
||||
<div class="chips" data-testid="person-hover-card-chips">
|
||||
{#each familyChips as chip (chip.id)}
|
||||
<span class="chip">{chip.relatedPersonDisplayName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if notesExcerpt}
|
||||
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
<span class="hint">{m.person_mention_hover_hint()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.person-hover-card {
|
||||
width: 320px;
|
||||
min-height: 180px;
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
color: var(--c-ink);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* On touch devices the card is suppressed entirely — tap navigates directly. */
|
||||
@media (hover: none) {
|
||||
.person-hover-card {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.skeleton .bar {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--c-line);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton .bar:nth-child(1) {
|
||||
width: 60%;
|
||||
}
|
||||
.skeleton .bar:nth-child(2) {
|
||||
width: 40%;
|
||||
}
|
||||
.skeleton .bar:nth-child(3) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton .bar {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-surface);
|
||||
margin: -14px -16px 12px;
|
||||
padding: 12px 16px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dates,
|
||||
.maiden {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
background-color: var(--c-accent-bg);
|
||||
color: var(--c-ink);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--c-line);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
color: var(--c-ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--c-ink-3);
|
||||
}
|
||||
</style>
|
||||
272
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
272
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
const AUGUSTE: Person = {
|
||||
id: 'p-aug',
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true,
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
} as unknown as Person;
|
||||
|
||||
const POSITION = { top: 100, left: 200 };
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('PersonHoverCard — loading state', () => {
|
||||
it('shows the skeleton when state.status is loading', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders three skeleton bars', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
|
||||
expect(bars.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — error state', () => {
|
||||
it('shows a generic error message when state.status is error', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still allows the link footer to navigate (link present in error state)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
// The card root must show the footer link even when the body errored —
|
||||
// click navigation works regardless of fetch outcome.
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — loaded state', () => {
|
||||
it('renders the person displayName as the header name', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the life-date range when birthYear and deathYear are present', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the life-date line when both years are missing', async () => {
|
||||
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: noDates, relationships: [] }
|
||||
});
|
||||
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
|
||||
expect(dates).toBeNull();
|
||||
});
|
||||
|
||||
it('renders "geb. <alias>" when alias is set', async () => {
|
||||
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withAlias, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the maiden name line when alias is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
|
||||
expect(maiden).toBeNull();
|
||||
});
|
||||
|
||||
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
|
||||
const relationships: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-spouse',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Otto Raddatz',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-sibling',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Marie Sister',
|
||||
relationType: 'SIBLING_OF'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||
});
|
||||
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
|
||||
// Non-family relationship type must be filtered out
|
||||
const friendChip = page.getByText('Karl Friend');
|
||||
await expect.element(friendChip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the chips section entirely when no family relationships', async () => {
|
||||
const onlyFriend: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
|
||||
});
|
||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||
expect(chips).toBeNull();
|
||||
});
|
||||
|
||||
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
|
||||
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withNotes, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates notes longer than 120 characters with an ellipsis', async () => {
|
||||
const long = 'x'.repeat(150);
|
||||
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||
expect(notes.textContent!.length).toBeLessThanOrEqual(122);
|
||||
expect(notes.textContent).toContain('…');
|
||||
});
|
||||
|
||||
it('omits notes section when notes is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
|
||||
expect(notes).toBeNull();
|
||||
});
|
||||
|
||||
it('footer renders an anchor link to /persons/{personId}', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]')!;
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — accessibility', () => {
|
||||
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-xyz',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.id).toBe('card-xyz');
|
||||
});
|
||||
|
||||
it('positions itself absolutely at the given top/left', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: { top: 333, left: 444 },
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
|
||||
expect(root.style.top).toBe('333px');
|
||||
expect(root.style.left).toBe('444px');
|
||||
expect(root.style.position).toBe('absolute');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectMention, escapeHtml, extractContent, renderBody } from './mention';
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
import {
|
||||
detectMention,
|
||||
escapeHtml,
|
||||
extractContent,
|
||||
renderBody,
|
||||
renderTranscriptionBody
|
||||
} from './mention';
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
|
||||
// ─── escapeHtml ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -161,3 +167,144 @@ describe('renderBody', () => {
|
||||
expect(result).not.toContain('\n');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderTranscriptionBody ──────────────────────────────────────────────────
|
||||
|
||||
describe('renderTranscriptionBody', () => {
|
||||
const auguste: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
const hans: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
displayName: 'Hans'
|
||||
};
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(renderTranscriptionBody('', [])).toBe('');
|
||||
});
|
||||
|
||||
it('returns escaped plain text when no mentions', () => {
|
||||
expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('escapes < and > in plain block text', () => {
|
||||
const result = renderTranscriptionBody('<script>alert(1)</script>', []);
|
||||
expect(result).toBe('<script>alert(1)</script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes & in plain block text', () => {
|
||||
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&T');
|
||||
});
|
||||
|
||||
it('replaces @DisplayName with anchor link to /persons/{personId}', () => {
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
||||
expect(result).toContain(`<a href="/persons/${auguste.personId}"`);
|
||||
expect(result).toContain('class="person-mention"');
|
||||
expect(result).toContain(`data-person-id="${auguste.personId}"`);
|
||||
expect(result).toContain('>Auguste Raddatz</a>');
|
||||
});
|
||||
|
||||
it('strips the @ prefix from rendered link text (read mode)', () => {
|
||||
const result = renderTranscriptionBody('Hallo @Auguste Raddatz!', [auguste]);
|
||||
// The anchor body is the bare display name — no leading @
|
||||
expect(result).not.toMatch(/>@Auguste Raddatz</);
|
||||
expect(result).toMatch(/>Auguste Raddatz</);
|
||||
});
|
||||
|
||||
it('removes the trigger @ from the surrounding text (no orphan @ before the link)', () => {
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
||||
// No bare @ remains where the mention was
|
||||
expect(result).not.toMatch(/@<a/);
|
||||
});
|
||||
|
||||
it('replaces all occurrences of the same mention', () => {
|
||||
const result = renderTranscriptionBody('@Auguste Raddatz und @Auguste Raddatz', [auguste]);
|
||||
const anchorCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(anchorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not replace plain-text occurrences without the @ trigger', () => {
|
||||
const result = renderTranscriptionBody('Auguste Raddatz war hier', [auguste]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).toBe('Auguste Raddatz war hier');
|
||||
});
|
||||
|
||||
it('processes longer displayNames first to avoid prefix shadowing', () => {
|
||||
const augusteShort: PersonMention = { personId: 'p-short', displayName: 'Auguste' };
|
||||
const augusteLong: PersonMention = {
|
||||
personId: 'p-long',
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
// Sidecar order is short-first; longer match must still win for the long text
|
||||
const result = renderTranscriptionBody('@Auguste Raddatz schreibt @Auguste', [
|
||||
augusteShort,
|
||||
augusteLong
|
||||
]);
|
||||
expect(result).toContain('href="/persons/p-long"');
|
||||
expect(result).toContain('href="/persons/p-short"');
|
||||
// The "Raddatz" suffix must not leak inside the short-name anchor
|
||||
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
|
||||
});
|
||||
|
||||
it('does not match @ followed by extra word characters (word boundary)', () => {
|
||||
// Sidecar contains "Hans"; text contains "@HansMüller" — no link.
|
||||
const result = renderTranscriptionBody('Brief an @HansMüller', [hans]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).toContain('@HansM');
|
||||
});
|
||||
|
||||
it('first-sidecar-wins when two entries share the same displayName', () => {
|
||||
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
|
||||
const hansFirst: PersonMention = { personId: 'p-first', displayName: 'Hans' };
|
||||
const hansSecond: PersonMention = { personId: 'p-second', displayName: 'Hans' };
|
||||
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
|
||||
expect(result).toContain('href="/persons/p-first"');
|
||||
expect(result).not.toContain('href="/persons/p-second"');
|
||||
const anchorCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(anchorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('escapes HTML in displayName to prevent stored XSS', () => {
|
||||
const xss: PersonMention = {
|
||||
personId: 'p-xss',
|
||||
displayName: '<script>alert(1)</script>'
|
||||
};
|
||||
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes <img onerror=...> payloads in surrounding block text', () => {
|
||||
const result = renderTranscriptionBody('<img src=x onerror=alert(1)> hello', []);
|
||||
expect(result).not.toContain('<img');
|
||||
expect(result).toContain('<img');
|
||||
});
|
||||
|
||||
it('does not double-encode HTML-entity-already-encoded payloads', () => {
|
||||
// `&lt;script&gt;` is already-escaped HTML in the source text.
|
||||
// renderTranscriptionBody must escape the literal & once → `&amp;lt;...`
|
||||
// — never silently decode pre-escaped entities.
|
||||
const result = renderTranscriptionBody('text &lt;script&gt;', []);
|
||||
expect(result).toBe('text &amp;lt;script&amp;gt;');
|
||||
});
|
||||
|
||||
it('escapes quotes in displayName so they cannot break the href attribute', () => {
|
||||
const tricky: PersonMention = {
|
||||
personId: 'p-quote',
|
||||
displayName: 'O"Brien'
|
||||
};
|
||||
const result = renderTranscriptionBody('@O"Brien', [tricky]);
|
||||
// The raw `"` from the displayName must never appear inside the rendered link
|
||||
// — it would terminate the attribute value early and let an attacker craft
|
||||
// arbitrary attributes on the anchor. It must arrive at the browser as ".
|
||||
expect(result).toMatch(/>O"Brien<\/a>/);
|
||||
expect(result).not.toMatch(/>O"Brien<\/a>/);
|
||||
});
|
||||
|
||||
it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => {
|
||||
const result = renderTranscriptionBody('Plain old transcription text.', []);
|
||||
expect(result).toBe('Plain old transcription text.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Given the current textarea value and cursor position, returns the
|
||||
@@ -62,6 +62,50 @@ export function escapeHtml(str: string): string {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a transcription block's text segment as safe HTML for read mode.
|
||||
*
|
||||
* Rules:
|
||||
* 1. The full text is HTML-escaped first (defense against stored XSS).
|
||||
* 2. For each entry in `mentionedPersons`, every `@DisplayName` occurrence is
|
||||
* replaced with `<a href="/persons/{personId}" class="person-mention" …>DisplayName</a>`.
|
||||
* The `@` prefix is stripped from the rendered link text — it is an editor
|
||||
* affordance, not part of the historical text (issue #362).
|
||||
* 3. Longest displayNames are processed first so a short prefix in the sidecar
|
||||
* cannot shadow a longer match in the text (e.g. `@Auguste` vs `@Auguste Raddatz`).
|
||||
* 4. Word-boundary lookahead prevents `@Hans` from matching `@HansMüller`.
|
||||
* 5. First-sidecar-wins for entries that share a displayName (deterministic
|
||||
* rule per Felix decision OQ-1, comment #5339).
|
||||
*/
|
||||
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): string {
|
||||
if (!text) return '';
|
||||
let escaped = escapeHtml(text);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique: PersonMention[] = [];
|
||||
for (const mention of mentionedPersons) {
|
||||
if (seen.has(mention.displayName)) continue;
|
||||
seen.add(mention.displayName);
|
||||
unique.push(mention);
|
||||
}
|
||||
|
||||
const sorted = [...unique].sort((a, b) => b.displayName.length - a.displayName.length);
|
||||
|
||||
for (const mention of sorted) {
|
||||
const escapedDisplayName = escapeHtml(mention.displayName);
|
||||
const escapedPersonId = escapeHtml(mention.personId);
|
||||
const pattern = new RegExp(`@${escapeRegExp(escapedDisplayName)}(?![\\p{L}\\p{N}])`, 'gu');
|
||||
const link = `<a href="/persons/${escapedPersonId}" class="person-mention" data-person-id="${escapedPersonId}">${escapedDisplayName}</a>`;
|
||||
escaped = escaped.replace(pattern, link);
|
||||
}
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment body as safe HTML:
|
||||
* 1. Escapes all HTML-special characters in the raw content
|
||||
|
||||
@@ -329,6 +329,34 @@
|
||||
background-color: color-mix(in srgb, var(--c-accent) 25%, transparent);
|
||||
}
|
||||
|
||||
/* ─── 7b. Person mention link (transcription read mode) ────────────────────── */
|
||||
/*
|
||||
Rendered by renderTranscriptionBody() via {@html ...} in TranscriptionReadView.
|
||||
Underline at rest is required for WCAG AA — colour alone fails 8% of men
|
||||
with red-green colour-blindness. Focus ring uses a box-shadow + border-radius
|
||||
so the rectangle doesn't touch the glyphs.
|
||||
*/
|
||||
.person-mention {
|
||||
color: var(--c-ink);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: color-mix(in srgb, var(--c-accent) 60%, transparent);
|
||||
cursor: pointer;
|
||||
transition: text-decoration-color 0.15s ease;
|
||||
}
|
||||
|
||||
.person-mention:hover {
|
||||
text-decoration-color: var(--c-accent);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.person-mention:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--c-ink);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ─── 8. Base styles ───────────────────────────────────────────────────────── */
|
||||
@layer base {
|
||||
html {
|
||||
|
||||
Reference in New Issue
Block a user