Markus flagged the LoadState export from PersonHoverCard.svelte as a view-vs-orchestrator boundary smell — both files own the same shape, and a third caller (admin previews, briefwechsel cards) would create a circular import. Move the types into src/lib/types/personHoverCard.ts so the contract is module-stable. Also harden .prettierignore + eslint.config.js so a stray .svelte-kit.old/ backup directory (rotated by SvelteKit during dev) doesn't break the lint hook — matches the existing .svelte-kit-backup/ convention. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
237 lines
7.2 KiB
Svelte
237 lines
7.2 KiB
Svelte
<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 from './PersonHoverCard.svelte';
|
|
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
|
|
import { goto } from '$app/navigation';
|
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
|
|
type Person = components['schemas']['Person'];
|
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
|
|
|
interface Props {
|
|
blocks: TranscriptionBlockData[];
|
|
onParagraphClick: (annotationId: string) => void;
|
|
highlightBlockId?: string | null;
|
|
}
|
|
|
|
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" 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"
|
|
class:flash-highlight={highlightBlockId === block.id}
|
|
data-block-id={block.id}
|
|
onclick={() => onParagraphClick(block.annotationId)}
|
|
role="button"
|
|
tabindex="0"
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId);
|
|
}}
|
|
>
|
|
<!-- 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% {
|
|
background-color: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
|
|
}
|
|
100% {
|
|
background-color: transparent;
|
|
}
|
|
}
|
|
|
|
.flash-highlight {
|
|
animation: flash 1.2s ease-out;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.flash-highlight {
|
|
animation: none;
|
|
background-color: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
|
|
}
|
|
}
|
|
</style>
|