refactor: move document transcription, annotation, viewer sub-packages
- transcription/: TranscriptionBlock, Column, EditView, PanelHeader, ReadView, Section + transcriptionMarkers, blockConflictMerge, saveBlockWithConflictRetry + useBlockAutoSave, useBlockDragDrop hooks - annotation/: AnnotationLayer, AnnotationShape, AnnotationEditOverlay - viewer/: PdfViewer, PdfControls + useFileLoader, usePdfRenderer hooks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
<script lang="ts">
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { splitByMarkers } from '$lib/document/transcription/transcriptionMarkers';
|
||||
import {
|
||||
renderTranscriptionBody,
|
||||
type SafeHtml,
|
||||
PERSON_MENTION_SELECTOR
|
||||
} from '$lib/utils/mention';
|
||||
import { computeHoverCardPosition } from '$lib/utils/hoverCardPosition';
|
||||
import PersonHoverCard from '$lib/components/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-component (per-mount) 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.
|
||||
//
|
||||
// Trade-off: closing and re-opening the transcription panel rebuilds this cache
|
||||
// (Elicit OQ-372-02). That's intentional — staleness from another tab deleting
|
||||
// a person is rare in this read-only view, and a per-document/global cache would
|
||||
// complicate invalidation. If user reports on stale cards accumulate, revisit.
|
||||
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);
|
||||
|
||||
// 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): SafeHtml {
|
||||
return splitByMarkers(block.text)
|
||||
.map((segment) => {
|
||||
if (segment.type === 'marker') {
|
||||
// splitByMarkers only emits the literal markers [unleserlich] and [...],
|
||||
// no user input — safe to embed directly. Wrap in SafeHtml to satisfy
|
||||
// the brand contract.
|
||||
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>` as SafeHtml;
|
||||
}
|
||||
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
||||
})
|
||||
.join('') as SafeHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches person + relationships from the backend. 404 returns null
|
||||
* (deleted person — caller marks the link as tombstoned). Any other
|
||||
* non-OK response throws so the caller can render the error state.
|
||||
*/
|
||||
async function loadHoverData(personId: string): Promise<HoverData | null> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Cache wrapper around `loadHoverData` — first hover fires the fetch, all
|
||||
* subsequent hovers (and concurrent in-flight ones) share the same Promise. */
|
||||
function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
|
||||
const cached = hoverCache.get(personId);
|
||||
if (cached) return cached;
|
||||
const promise = loadHoverData(personId);
|
||||
hoverCache.set(personId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function currentViewport() {
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight
|
||||
};
|
||||
}
|
||||
|
||||
let closeTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function scheduleCardClose() {
|
||||
// 150ms: long enough for pointer movement from mention to card, short enough
|
||||
// to feel responsive. Matches the Radix/shadcn hover card delay.
|
||||
closeTimer = setTimeout(() => {
|
||||
activeCard = null;
|
||||
closeTimer = null;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function cancelCardClose() {
|
||||
if (closeTimer !== null) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMentionEnter(event: Event) {
|
||||
cancelCardClose();
|
||||
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 = computeHoverCardPosition(rect, currentViewport());
|
||||
|
||||
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
||||
|
||||
try {
|
||||
const data = await getOrFetchHoverData(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 scheduleMentionLeave(event: Event) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
link.removeAttribute('aria-describedby');
|
||||
scheduleCardClose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Modified clicks (ctrl/meta/shift/alt) and middle-clicks must fall through to
|
||||
* the browser's default anchor behaviour so users can open the person page in
|
||||
* a new tab/window. Felix #7. Only the plain primary-button click navigates
|
||||
* via SPA goto().
|
||||
*/
|
||||
function isPlainPrimaryClick(event: MouseEvent): boolean {
|
||||
return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
|
||||
}
|
||||
|
||||
async function handleMentionClick(event: MouseEvent) {
|
||||
if (!isPlainPrimaryClick(event)) return;
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
const personId = link.dataset.personId;
|
||||
if (!personId) return;
|
||||
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.
|
||||
//
|
||||
// Keyboard parity (Leonie FINDING-01, WCAG 2.1.1): focusin/focusout mirror
|
||||
// mouseenter/mouseleave so users tabbing through transcribed text get the
|
||||
// same preview affordance.
|
||||
function attachMentionHandlers(node: HTMLElement) {
|
||||
function onEnter(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionEnter(e);
|
||||
}
|
||||
function onLeave(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) scheduleMentionLeave(e);
|
||||
}
|
||||
function onClick(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionClick(e);
|
||||
}
|
||||
// mouseenter does not bubble — capture it.
|
||||
node.addEventListener('mouseenter', onEnter, true);
|
||||
node.addEventListener('mouseleave', onLeave, true);
|
||||
// focusin/focusout do bubble — no capture phase needed.
|
||||
node.addEventListener('focusin', onEnter);
|
||||
node.addEventListener('focusout', onLeave);
|
||||
node.addEventListener('click', onClick);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('mouseenter', onEnter, true);
|
||||
node.removeEventListener('mouseleave', onLeave, true);
|
||||
node.removeEventListener('focusin', onEnter);
|
||||
node.removeEventListener('focusout', onLeave);
|
||||
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}
|
||||
onmouseenter={cancelCardClose}
|
||||
onmouseleave={() => { activeCard = null; }}
|
||||
/>
|
||||
{/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>
|
||||
Reference in New Issue
Block a user