feat: person @mentions edit-mode infrastructure (PR-B1, #362) #369

Merged
marcel merged 26 commits from feat/person-mentions-issue-362-frontend-b1 into main 2026-04-29 08:05:49 +02:00
24 changed files with 1450 additions and 96 deletions

View File

@@ -420,6 +420,12 @@
"notification_unread": "ungelesen",
"mention_btn_label": "Person erwähnen",
"mention_popup_empty": "Keine Nutzer gefunden",
"person_mention_open_link": "Zur Person",
"person_mention_hover_hint": "Klick öffnet Seite",
"person_mention_load_error": "Person konnte nicht geladen werden.",
"person_mention_popup_empty": "Keine Personen gefunden",
"person_mention_btn_label": "Person verlinken",
"person_mention_create_new": "Neue Person anlegen",
"page_title_home": "Archiv",
"page_title_persons": "Personen",
"page_title_admin": "Administration",

View File

@@ -420,6 +420,12 @@
"notification_unread": "unread",
"mention_btn_label": "Mention person",
"mention_popup_empty": "No users found",
"person_mention_open_link": "Open person",
"person_mention_hover_hint": "Click opens the page",
"person_mention_load_error": "Could not load person.",
"person_mention_popup_empty": "No persons found",
"person_mention_btn_label": "Link person",
"person_mention_create_new": "Create new person",
"page_title_home": "Archive",
"page_title_persons": "Persons",
"page_title_admin": "Administration",

View File

@@ -420,6 +420,12 @@
"notification_unread": "no leído",
"mention_btn_label": "Mencionar persona",
"mention_popup_empty": "No se encontraron usuarios",
"person_mention_open_link": "Ir a la persona",
"person_mention_hover_hint": "Clic abre la página",
"person_mention_load_error": "No se pudo cargar la persona.",
"person_mention_popup_empty": "No se encontraron personas",
"person_mention_btn_label": "Vincular persona",
"person_mention_create_new": "Crear nueva persona",
"page_title_home": "Archivo",
"page_title_persons": "Personas",
"page_title_admin": "Administración",

View File

@@ -0,0 +1,263 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { detectPersonMention } from '$lib/utils/personMention';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
type Props = {
value: string;
mentionedPersons: PersonMention[];
placeholder?: string;
rows?: number;
disabled?: boolean;
onfocus?: () => void;
onblur?: () => void;
// Optional escape hatch: lets the parent observe the underlying textarea node
// (e.g. to read selection bounds for quote-selection features). Returning a
// cleanup function from the parent is not required.
captureTextarea?: (node: HTMLTextAreaElement) => void | (() => void);
};
let {
value = $bindable(''),
mentionedPersons = $bindable([]),
placeholder = '',
rows = 1,
disabled = false,
onfocus,
onblur,
captureTextarea
}: Props = $props();
let query: string | null = $state(null);
let results: Person[] = $state([]);
let highlightedIndex = $state(0);
let mentionStart = $state(0);
let loading = $state(false);
let textarea: HTMLTextAreaElement | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function attachTextarea(node: HTMLTextAreaElement) {
textarea = node;
resizeTextarea();
const parentCleanup = captureTextarea?.(node);
return () => {
parentCleanup?.();
textarea = null;
};
}
function resizeTextarea() {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}
// Autoresize on every value change — read `value` so this $effect
// re-runs whenever the bound prop is reassigned.
$effect(() => {
void value;
resizeTextarea();
});
function handleInput() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const detected = detectPersonMention(value, cursorPos);
if (detected === null) {
closePopup();
return;
}
const before = value.slice(0, cursorPos);
mentionStart = before.lastIndexOf('@');
if (query !== detected) {
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
function scheduleSearch(q: string) {
clearTimeout(debounceTimer);
if (!q.trim()) {
// Empty query: keep popup open with last results so the user can browse,
// but don't fire a backend call until they actually type something.
results = [];
loading = false;
return;
}
loading = true;
debounceTimer = setTimeout(async () => {
try {
// SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token
// cookie as the Authorization header (vite.config.ts) and on the
// browser's same-origin policy for the /api/* path. Mounted in
// transcribe mode behind WRITE_ALL — never reachable to unauthenticated
// users.
const res = await fetch(`/api/persons?q=${encodeURIComponent(q)}`);
if (res.ok) {
const data: Person[] = await res.json();
results = data.slice(0, 5);
} else {
results = [];
}
} catch {
results = [];
} finally {
loading = false;
}
}, 200);
}
async function selectPerson(person: Person) {
if (!textarea) return;
const displayName = person.displayName ?? '';
const replacement = `@${displayName} `;
const cursorPos = textarea.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursorPos);
value = before + replacement + after;
if (!mentionedPersons.some((existing) => existing.personId === person.id)) {
mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName }];
}
closePopup();
await tick();
if (!textarea) return;
const pos = mentionStart + replacement.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
textarea.focus();
}
function closePopup() {
query = null;
results = [];
highlightedIndex = 0;
loading = false;
clearTimeout(debounceTimer);
}
function handleBlur() {
// Small delay so an option's onmousedown can fire and select before the
// popup unmounts. Without this, clicking a result on the way out would
// race with blur and lose the selection.
setTimeout(() => closePopup(), 150);
onblur?.();
}
function handleKeydown(e: KeyboardEvent) {
if (query === null) return;
if (e.key === 'Escape') {
e.preventDefault();
closePopup();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex + 1) % results.length;
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
}
return;
}
if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
selectPerson(results[highlightedIndex]);
return;
}
}
onDestroy(() => clearTimeout(debounceTimer));
const popupOpen = $derived(query !== null);
</script>
<div class="relative">
<textarea
{@attach attachTextarea}
class="block min-h-[44px] w-full resize-none rounded-sm border border-transparent bg-transparent px-1 py-2.5 font-serif text-base leading-relaxed text-ink placeholder:text-ink-3 focus-visible:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-mint/40 focus-visible:outline-none"
rows={rows}
placeholder={placeholder}
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={onfocus}
onblur={handleBlur}
></textarea>
{#if popupOpen}
<div
class="absolute z-20 mt-1 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.person_mention_btn_label()}
>
{#if loading}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.comp_typeahead_loading()}</p>
{:else if results.length === 0}
<div class="flex flex-col gap-2 px-3 py-2.5">
<p class="font-sans text-sm text-ink-3">{m.person_mention_popup_empty()}</p>
<a
href="/persons/new?name={encodeURIComponent(query ?? '')}"
target="_blank"
rel="noopener"
class="font-sans text-sm font-medium text-brand-navy underline-offset-2 hover:underline"
>
{m.person_mention_create_new()}
</a>
</div>
{:else}
{#each results as person, i (person.id)}
<div
class={[
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
// Keyboard-highlighted row gets a stronger token than hover so
// keyboard users (and tablet stylus users sweeping over rows)
// can tell the cursor position apart from a hover (Leonie #5507 §3,
// WCAG 1.4.11 Non-Text Contrast).
i === highlightedIndex &&
'bg-brand-mint/20 ring-2 ring-brand-mint ring-inset'
]}
role="option"
aria-selected={i === highlightedIndex}
data-test-person-id={person.id}
tabindex="-1"
onmousedown={(e) => {
e.preventDefault();
selectPerson(person);
}}
>
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
<span class="truncate font-sans text-xs text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</span>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,385 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
// Editor's internal search debounce is 200ms — drive it via fake timers
// so tests are deterministic and fast (Tester #5506 §1).
const DEBOUNCE_MS = 200;
async function flushDebounce() {
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
// Let the awaited fetch resolve and the resulting state assignments flush.
await vi.runAllTimersAsync();
}
async function tick() {
await vi.advanceTimersByTimeAsync(0);
}
const AUGUSTE: Person = {
id: 'p-aug',
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
birthYear: 1882,
deathYear: 1944
} as unknown as Person;
const ANNA: Person = {
id: 'p-anna',
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
birthYear: 1860
} as unknown as Person;
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) });
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
function mockFetchEmpty() {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
function mockFetchRejects() {
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
function getTextarea(): HTMLTextAreaElement {
return document.querySelector('textarea')!;
}
function clickOption(personId: string) {
const opt = document.querySelector(
`[role="option"][data-test-person-id="${personId}"]`
) as HTMLElement;
opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
}
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) {
let snapshot: Snapshot = {
value: initial.value ?? '',
mentionedPersons: initial.mentionedPersons ?? []
};
render(PersonMentionEditorHost, {
initialValue: initial.value ?? '',
initialMentions: initial.mentionedPersons ?? [],
onChange: (snap: Snapshot) => {
snapshot = snap;
}
});
return {
get snapshot() {
return snapshot;
}
};
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMentionEditor — rendering', () => {
it('renders the textarea with placeholder', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
placeholder: 'Transkription…',
onChange: () => {}
});
await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument();
});
it('reflects bound initial value', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Hallo Welt',
initialMentions: [],
onChange: () => {}
});
await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt');
});
});
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
describe('PersonMentionEditor — typeahead', () => {
it('opens the popup when typing @ + query and shows results', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
it('hits /api/persons?q= with the typed query', async () => {
const fetchMock = mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
});
it('shows life dates next to the name in the dropdown', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument();
});
it('shows empty state when no persons match', async () => {
mockFetchEmpty();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@xyz';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
});
it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => {
mockFetchRejects();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
});
it('keeps the popup open when the query has a trailing space (multi-word names)', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Auguste ';
ta.selectionStart = 9;
ta.selectionEnd = 9;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
});
// ─── Selection writes text + sidecar ─────────────────────────────────────────
describe('PersonMentionEditor — selecting a person', () => {
it('inserts @DisplayName followed by a trailing space into the textarea', async () => {
mockFetchWithPersons();
const host = renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
clickOption('p-aug');
await tick();
expect(host.snapshot.value).toBe('@Auguste Raddatz ');
});
it('pushes {personId, displayName} into the bound mentionedPersons array', async () => {
mockFetchWithPersons();
const host = renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
clickOption('p-aug');
await tick();
expect(host.snapshot.mentionedPersons).toEqual([
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
]);
});
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
mockFetchWithPersons();
const host = renderHost({
value: '@Auguste Raddatz ',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
});
const ta = getTextarea();
ta.focus();
ta.value = '@Auguste Raddatz @Aug';
ta.selectionStart = ta.value.length;
ta.selectionEnd = ta.value.length;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
clickOption('p-aug');
await tick();
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
});
// ─── Keyboard navigation (B11b) ──────────────────────────────────────────────
describe('PersonMentionEditor — keyboard navigation (B11b)', () => {
it('ArrowDown / ArrowUp cycle the highlighted result', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@A';
ta.selectionStart = 2;
ta.selectionEnd = 2;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
const optAuguste = document.querySelector(
'[role="option"][data-test-person-id="p-aug"]'
) as HTMLElement;
const optAnna = document.querySelector(
'[role="option"][data-test-person-id="p-anna"]'
) as HTMLElement;
expect(optAuguste.getAttribute('aria-selected')).toBe('true');
expect(optAnna.getAttribute('aria-selected')).toBe('false');
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await tick();
expect(optAuguste.getAttribute('aria-selected')).toBe('false');
expect(optAnna.getAttribute('aria-selected')).toBe('true');
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await tick();
expect(optAuguste.getAttribute('aria-selected')).toBe('true');
expect(optAnna.getAttribute('aria-selected')).toBe('false');
});
it('Enter selects the currently highlighted result', async () => {
mockFetchWithPersons();
const host = renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@A';
ta.selectionStart = 2;
ta.selectionEnd = 2;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await tick();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await tick();
expect(host.snapshot.mentionedPersons).toEqual([
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
]);
});
it('Escape closes the popup without inserting anything', async () => {
mockFetchWithPersons();
const host = renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await tick();
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
expect(host.snapshot.value).toBe('@Aug');
expect(host.snapshot.mentionedPersons).toEqual([]);
});
});
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
describe('PersonMentionEditor — touch target', () => {
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
const option = document.querySelector('[role="option"]') as HTMLElement;
expect(option).not.toBeNull();
expect(option.className).toContain('min-h-[44px]');
});
});

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { untrack } from 'svelte';
import PersonMentionEditor from './PersonMentionEditor.svelte';
import type { components } from '$lib/generated/api';
type PersonMention = components['schemas']['PersonMention'];
type Props = {
initialValue?: string;
initialMentions?: PersonMention[];
placeholder?: string;
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
};
let { initialValue = '', initialMentions = [], placeholder, onChange }: Props = $props();
// initial* props seed mount-time state; reading them inside untrack signals
// the intentional one-shot capture and silences state_referenced_locally.
let value = $state(untrack(() => initialValue));
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
$effect(() => {
onChange({ value, mentionedPersons: [...mentionedPersons] });
});
</script>
<PersonMentionEditor
bind:value={value}
bind:mentionedPersons={mentionedPersons}
placeholder={placeholder}
/>

View File

@@ -2,6 +2,8 @@
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import CommentThread from './CommentThread.svelte';
import PersonMentionEditor from './PersonMentionEditor.svelte';
import type { PersonMention } from '$lib/types';
const { confirm } = getConfirmService();
@@ -12,13 +14,14 @@ type Props = {
documentId: string;
blockNumber: number;
text: string;
mentionedPersons: PersonMention[];
label: string | null;
active: boolean;
reviewed: boolean;
saveState: SaveState;
canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string) => void;
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
@@ -35,6 +38,7 @@ let {
documentId,
blockNumber,
text,
mentionedPersons,
label = null,
active,
reviewed,
@@ -54,10 +58,10 @@ let {
}: Props = $props();
let localText = $state(text);
let localMentions = $state<PersonMention[]>([...mentionedPersons]);
let commentOpen = $state(false);
let commentCount = $state(0);
let selectedQuote = $state<string | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(null);
const hasComments = $derived(commentCount > 0);
@@ -66,6 +70,7 @@ let prevBlockId = $state(blockId);
$effect(() => {
if (blockId !== prevBlockId) {
localText = text;
localMentions = [...mentionedPersons];
prevBlockId = blockId;
}
});
@@ -74,29 +79,19 @@ let leftBorderClass = $derived(
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
);
function autoresize(node: HTMLTextAreaElement) {
// Single source of truth for the editor's textarea — stored on attach so
// we can read selection bounds for quote selection without re-querying the DOM.
let textareaEl: HTMLTextAreaElement | null = null;
function captureTextarea(node: HTMLTextAreaElement) {
textareaEl = node;
function resize() {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
}
resize();
return {
update() {
resize();
},
destroy() {
textareaEl = null;
}
return () => {
textareaEl = null;
};
}
function handleInput(event: Event) {
const target = event.target as HTMLTextAreaElement;
localText = target.value;
onTextChange(target.value);
function emitChange() {
onTextChange(localText, localMentions);
}
async function handleDelete() {
@@ -181,17 +176,24 @@ function handleTextareaMouseUp() {
{/if}
</div>
<!-- Textarea -->
<textarea
use:autoresize={localText}
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
placeholder={m.transcription_block_placeholder()}
rows={1}
value={localText}
oninput={handleInput}
onfocus={onFocus}
onmouseup={handleTextareaMouseUp}
></textarea>
<!-- Textarea (now powered by PersonMentionEditor for @-mention typeahead) -->
<div onmouseup={handleTextareaMouseUp} role="presentation">
<PersonMentionEditor
bind:value={() => localText,
(v) => {
localText = v;
emitChange();
}}
bind:mentionedPersons={() => localMentions,
(next) => {
localMentions = next;
emitChange();
}}
placeholder={m.transcription_block_placeholder()}
onfocus={onFocus}
captureTextarea={captureTextarea}
/>
</div>
{#if selectedQuote}
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>

View File

@@ -1,21 +1,24 @@
<script lang="ts">
import { provideConfirmService, type ConfirmService } from '$lib/services/confirm.svelte.js';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import type { PersonMention } from '$lib/types';
type BlockProps = {
blockId: string;
documentId: string;
blockNumber: number;
text: string;
mentionedPersons?: PersonMention[];
label: string | null;
active: boolean;
saveState: 'idle' | 'saving' | 'saved' | 'fading' | 'error';
canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string) => void;
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
onReviewToggle?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
@@ -24,13 +27,22 @@ type BlockProps = {
let {
onServiceReady,
mentionedPersons = [],
reviewed = false,
onReviewToggle = () => {},
...blockProps
}: BlockProps & {
onServiceReady: (s: ConfirmService) => void;
reviewed?: boolean;
} = $props();
const service = provideConfirmService();
onServiceReady(service);
</script>
<TranscriptionBlock {...blockProps} />
<TranscriptionBlock
{...blockProps}
mentionedPersons={mentionedPersons}
reviewed={reviewed}
onReviewToggle={onReviewToggle}
/>

View File

@@ -3,7 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import OcrTrigger from './OcrTrigger.svelte';
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
import type { TranscriptionBlockData } from '$lib/types';
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
@@ -16,7 +16,7 @@ type Props = {
storedScriptType?: string;
canRunOcr?: boolean;
onBlockFocus: (blockId: string) => void;
onSaveBlock: (blockId: string, text: string) => Promise<void>;
onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>;
onReviewToggle: (blockId: string) => Promise<void>;
onMarkAllReviewed?: () => Promise<void>;
@@ -245,16 +245,19 @@ async function handleLabelToggle(label: string) {
documentId={documentId}
blockNumber={i + 1}
text={block.text}
mentionedPersons={block.mentionedPersons ?? []}
label={block.label}
active={activeBlockId === block.id}
reviewed={block.reviewed ?? false}
saveState={autoSave.getSaveState(block.id)}
canComment={canComment}
currentUserId={currentUserId}
onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
onTextChange={(text, mentions) =>
autoSave.handleTextChange(block.id, text, mentions)}
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
onRetry={() => autoSave.handleRetry(block.id, block.text)}
onRetry={() =>
autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])}
onReviewToggle={() => onReviewToggle(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}

View File

@@ -15,7 +15,8 @@ const block1 = {
sortOrder: 0,
version: 0,
source: 'MANUAL' as const,
reviewed: false
reviewed: false,
mentionedPersons: []
};
const block2 = {
id: 'b2',
@@ -26,7 +27,8 @@ const block2 = {
sortOrder: 1,
version: 0,
source: 'OCR' as const,
reviewed: true
reviewed: true,
mentionedPersons: []
};
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
@@ -141,7 +143,28 @@ describe('TranscriptionEditView — auto-save debounce', () => {
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile');
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []);
vi.useRealTimers();
});
it('passes the block mentionedPersons array as the 3rd save argument', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
const blockWithMention = {
...block1,
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
};
renderView({ blocks: [blockWithMention], onSaveBlock });
const textarea = page.getByRole('textbox').first();
await textarea.fill('Hallo @Auguste Raddatz');
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
]);
vi.useRealTimers();
});
@@ -165,7 +188,7 @@ describe('TranscriptionEditView — auto-save debounce', () => {
// Only one save with the final value
expect(onSaveBlock).toHaveBeenCalledTimes(1);
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second');
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []);
vi.useRealTimers();
});
});
@@ -220,7 +243,7 @@ describe('TranscriptionEditView — flush on blur', () => {
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text');
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []);
vi.useRealTimers();
});
});

View File

@@ -12,7 +12,10 @@ const blocks: TranscriptionBlockData[] = [
text: 'First paragraph text.',
label: null,
sortOrder: 1,
version: 1
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
},
{
id: 'b2',
@@ -21,7 +24,10 @@ const blocks: TranscriptionBlockData[] = [
text: 'Second paragraph text.',
label: null,
sortOrder: 2,
version: 1
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
}
];
@@ -49,7 +55,10 @@ describe('TranscriptionReadView', () => {
text: 'Text before [unleserlich] text after',
label: null,
sortOrder: 1,
version: 1
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
}
],
onParagraphClick: () => {}
@@ -71,7 +80,10 @@ describe('TranscriptionReadView', () => {
text: 'Some [...] text',
label: null,
sortOrder: 1,
version: 1
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
}
],
onParagraphClick: () => {}

View File

@@ -1,6 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import type { PersonMention } from '$lib/types';
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
const mockSaveFn =
vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>>();
const NO_MENTIONS: PersonMention[] = [];
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
@@ -22,25 +26,25 @@ describe('createBlockAutoSave', () => {
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text 1');
as.handleTextChange('block-1', 'text 2');
as.handleTextChange('block-1', 'text 3');
as.handleTextChange('block-1', 'text 1', NO_MENTIONS);
as.handleTextChange('block-1', 'text 2', NO_MENTIONS);
as.handleTextChange('block-1', 'text 3', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(1);
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3');
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS);
});
it('handles concurrent blocks independently', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello');
as.handleTextChange('block-2', 'world');
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
as.handleTextChange('block-2', 'world', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
});
it('sets save state to saving then saved on success', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.handleTextChange('block-1', 'text', NO_MENTIONS);
vi.advanceTimersByTime(1500);
expect(as.getSaveState('block-1')).toBe('saving');
await Promise.resolve();
@@ -50,7 +54,7 @@ describe('createBlockAutoSave', () => {
it('sets save state to error on save failure', async () => {
mockSaveFn.mockRejectedValue(new Error('save failed'));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.handleTextChange('block-1', 'text', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
});
@@ -59,24 +63,49 @@ describe('createBlockAutoSave', () => {
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
mockSaveFn.mockResolvedValueOnce(undefined);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'original');
as.handleTextChange('block-1', 'original', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
await as.handleRetry('block-1', 'original');
await as.handleRetry('block-1', 'original', NO_MENTIONS);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
expect(as.getSaveState('block-1')).toBe('saved');
});
it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => {
// Hold the second saveFn so we can observe the saving→saved transition
// (Tester #5506 §5).
let resolveSecond!: () => void;
mockSaveFn.mockRejectedValueOnce(new Error('boom'));
mockSaveFn.mockReturnValueOnce(new Promise<void>((r) => (resolveSecond = r)));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
// Retry without re-passing the data — the hook resends the preserved payload.
const retryPromise = as.handleRetry('block-1', 'should-not-be-used', []);
// Yield once so executeSave runs synchronously up to the saveFn await.
await Promise.resolve();
expect(as.getSaveState('block-1')).toBe('saving');
expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions);
resolveSecond();
await retryPromise;
expect(as.getSaveState('block-1')).toBe('saved');
});
it('clearBlock removes all state for a block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.clearBlock('block-1');
expect(as.getSaveState('block-1')).toBe('idle');
});
it('destroy clears all pending timers so no save occurs', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.destroy();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
@@ -101,8 +130,8 @@ describe('flushOnUnload', () => {
it('sends a PUT request with keepalive:true for each pending block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello');
as.handleTextChange('block-2', 'world');
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
as.handleTextChange('block-2', 'world', NO_MENTIONS);
as.flushOnUnload();
expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -111,7 +140,7 @@ describe('flushOnUnload', () => {
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'hello' })
body: JSON.stringify({ text: 'hello', mentionedPersons: [] })
})
);
expect(mockFetch).toHaveBeenCalledWith(
@@ -119,7 +148,7 @@ describe('flushOnUnload', () => {
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'world' })
body: JSON.stringify({ text: 'world', mentionedPersons: [] })
})
);
});
@@ -127,7 +156,7 @@ describe('flushOnUnload', () => {
it('does not call navigator.sendBeacon', () => {
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.flushOnUnload();
expect(sendBeaconSpy).not.toHaveBeenCalled();
@@ -142,7 +171,7 @@ describe('flushOnUnload', () => {
it('cancels the debounce timer so saveFn is not also called', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.flushOnUnload();
await vi.advanceTimersByTimeAsync(2000);
@@ -151,13 +180,26 @@ describe('flushOnUnload', () => {
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.handleTextChange('block-1', 'text', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
// debounce has fired; pendingTexts should be empty now
mockFetch.mockClear();
as.flushOnUnload();
expect(mockFetch).not.toHaveBeenCalled();
});
it('flushes the pending mentionedPersons sidecar alongside text', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }];
as.handleTextChange('block-1', '@Auguste Raddatz', mentions);
as.flushOnUnload();
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-1',
expect.objectContaining({
body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions })
})
);
});
});

View File

@@ -12,7 +12,8 @@ function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
sortOrder,
version: 1,
source: 'MANUAL',
reviewed: false
reviewed: false,
mentionedPersons: []
};
}

View File

@@ -1,9 +1,10 @@
import { SvelteMap } from 'svelte/reactivity';
import type { PersonMention } from '$lib/types';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Options = {
saveFn: (blockId: string, text: string) => Promise<void>;
saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
documentId: string;
};
@@ -11,6 +12,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
const saveStates = new SvelteMap<string, SaveState>();
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
const pendingTexts = new SvelteMap<string, string>();
const pendingMentions = new SvelteMap<string, PersonMention[]>();
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
function getSaveState(blockId: string): SaveState {
@@ -25,14 +27,19 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
const mentions = pendingMentions.get(blockId) ?? [];
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
setSaveState(blockId, 'saving');
try {
await saveFn(blockId, text);
await saveFn(blockId, text, mentions);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
// Preserve in-flight payload so the user can retry without re-typing.
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentions);
setSaveState(blockId, 'error');
}
}
@@ -69,8 +76,13 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
}
}
function handleTextChange(blockId: string, text: string): void {
function handleTextChange(
blockId: string,
text: string,
mentionedPersons: PersonMention[]
): void {
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentionedPersons);
scheduleDebounce(blockId);
}
@@ -81,29 +93,37 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
}
}
async function handleRetry(blockId: string, currentText: string): Promise<void> {
const pending = pendingTexts.get(blockId);
const text = pending ?? currentText;
async function handleRetry(
blockId: string,
currentText: string,
currentMentions: PersonMention[]
): Promise<void> {
const text = pendingTexts.get(blockId) ?? currentText;
const mentions = pendingMentions.get(blockId) ?? currentMentions;
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentions);
await executeSave(blockId);
}
function clearBlock(blockId: string): void {
clearDebounce(blockId);
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
saveStates.delete(blockId);
}
function flushOnUnload(): void {
for (const [blockId, text] of pendingTexts) {
const mentions = pendingMentions.get(blockId) ?? [];
clearDebounce(blockId);
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
body: JSON.stringify({ text, mentionedPersons: mentions }),
keepalive: true
});
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
}
}

View File

@@ -37,6 +37,11 @@ export type Comment = {
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
export type PersonMention = {
personId: string;
displayName: string;
};
export type TranscriptionBlockData = {
id: string;
annotationId: string;
@@ -47,6 +52,7 @@ export type TranscriptionBlockData = {
version: number;
source: 'MANUAL' | 'OCR';
reviewed: boolean;
mentionedPersons: PersonMention[];
updatedAt?: string | null;
};

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { BlockConflictResolvedError, mergeBlockOnConflict } from './blockConflictMerge';
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
const baseBlock: TranscriptionBlockData = {
id: 'b1',
annotationId: 'a1',
documentId: 'd1',
text: 'old text from server',
label: null,
sortOrder: 0,
version: 7,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
};
describe('mergeBlockOnConflict', () => {
it('keeps the local unsaved text — never overwritten by server text (B12b)', () => {
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, text: 'server-side text' },
localText: 'transcriber unsaved input',
localMentions: []
});
expect(merged.text).toBe('transcriber unsaved input');
});
it('takes server-side displayName for personIds present on both sides (rename win)', () => {
const localMentions: PersonMention[] = [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale: server renamed her
];
const serverMentions: PersonMention[] = [
{ personId: 'p-aug', displayName: 'Augusta Raddatz' } // post-rename
];
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
localText: '@Augusta Raddatz',
localMentions
});
expect(merged.mentionedPersons).toEqual([
{ personId: 'p-aug', displayName: 'Augusta Raddatz' }
]);
});
it('keeps local-only mentions added since last save', () => {
const localMentions: PersonMention[] = [
{ personId: 'p-anna', displayName: 'Anna Schmidt' } // typed since last save
];
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, mentionedPersons: [] },
localText: '@Anna Schmidt',
localMentions
});
expect(merged.mentionedPersons).toContainEqual({
personId: 'p-anna',
displayName: 'Anna Schmidt'
});
});
it('returns a union of personIds when local and server diverge', () => {
const localMentions: PersonMention[] = [{ personId: 'p-anna', displayName: 'Anna Schmidt' }];
const serverMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }];
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
localText: '@Augusta Raddatz und @Anna Schmidt',
localMentions
});
expect(merged.mentionedPersons).toHaveLength(2);
expect(merged.mentionedPersons).toContainEqual({
personId: 'p-aug',
displayName: 'Augusta Raddatz'
});
expect(merged.mentionedPersons).toContainEqual({
personId: 'p-anna',
displayName: 'Anna Schmidt'
});
});
it('carries server version forward so the next save sends the latest revision', () => {
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, version: 42 },
localText: 'x',
localMentions: []
});
expect(merged.version).toBe(42);
});
it('carries server-only mention array through when local has none', () => {
const merged = mergeBlockOnConflict({
serverBlock: {
...baseBlock,
mentionedPersons: [
{ personId: 'p-aug', displayName: 'Augusta Raddatz' },
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
]
},
localText: 'x',
localMentions: []
});
expect(merged.mentionedPersons).toHaveLength(2);
});
it('carries other server fields (sortOrder, reviewed, updatedAt) forward', () => {
const merged = mergeBlockOnConflict({
serverBlock: {
...baseBlock,
sortOrder: 9,
reviewed: true,
updatedAt: '2026-04-29T10:00:00Z'
},
localText: 'x',
localMentions: []
});
expect(merged.sortOrder).toBe(9);
expect(merged.reviewed).toBe(true);
expect(merged.updatedAt).toBe('2026-04-29T10:00:00Z');
});
});
describe('BlockConflictResolvedError', () => {
it('is an Error with code = CONFLICT_RESOLVED', () => {
const err = new BlockConflictResolvedError('block-1');
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('CONFLICT_RESOLVED');
expect(err.name).toBe('BlockConflictResolvedError');
expect(err.message).toContain('block-1');
});
});

View File

@@ -0,0 +1,50 @@
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
/**
* Sentinel thrown by saveBlockWithConflictRetry after a 409 rename-mid-edit
* has been merged into local state. Surfaces to the autosave hook as an
* error (so the UI shows the retry indicator), but distinguishable from a
* genuine network failure via the code. Carries the merged block snapshot
* on its `merged` property so the caller can update local state without
* a second roundtrip.
*/
export class BlockConflictResolvedError extends Error {
readonly code = 'CONFLICT_RESOLVED' as const;
merged?: TranscriptionBlockData;
constructor(blockId: string) {
super(
`Block ${blockId} was rebased onto the latest server snapshot — retry to save the merged result`
);
this.name = 'BlockConflictResolvedError';
}
}
type MergeArgs = {
serverBlock: TranscriptionBlockData;
localText: string;
localMentions: PersonMention[];
};
/**
* Resolves a 409-Conflict from the server by combining the latest server
* snapshot with the transcriber's unsaved local edits (B12b).
*
* Rules:
* - The transcriber's typed text always wins — never overwrite their input.
* - Server is the source of truth for the displayName of any person it
* knows about; renames that just landed on the server replace stale local
* names by personId.
* - Local-only mentions added since the last save are preserved.
* - All non-mention fields (version, sortOrder, reviewed, updatedAt, ...)
* come from the server snapshot so the next save sends the current
* revision and matches the latest persisted state.
*/
export function mergeBlockOnConflict(args: MergeArgs): TranscriptionBlockData {
const serverIds = new Set(args.serverBlock.mentionedPersons.map((m) => m.personId));
const localOnly = args.localMentions.filter((m) => !serverIds.has(m.personId));
return {
...args.serverBlock,
text: args.localText,
mentionedPersons: [...args.serverBlock.mentionedPersons, ...localOnly]
};
}

View File

@@ -1,7 +1,42 @@
import { describe, it, expect } from 'vitest';
import { detectMention, extractContent, renderBody } from './mention';
import { detectMention, escapeHtml, extractContent, renderBody } from './mention';
import type { MentionDTO } from '$lib/types';
// ─── escapeHtml ───────────────────────────────────────────────────────────────
describe('escapeHtml', () => {
it('escapes ampersand', () => {
expect(escapeHtml('AT&T')).toBe('AT&amp;T');
});
it('escapes less-than and greater-than', () => {
expect(escapeHtml('<script>')).toBe('&lt;script&gt;');
});
it('escapes double quote', () => {
expect(escapeHtml('say "hi"')).toBe('say &quot;hi&quot;');
});
it('returns empty string unchanged', () => {
expect(escapeHtml('')).toBe('');
});
it('escapes ampersand before other entities to avoid double-encoding', () => {
expect(escapeHtml('a&<b')).toBe('a&amp;&lt;b');
});
it('escapes apostrophe to &#39;', () => {
expect(escapeHtml("d'Artagnan")).toBe('d&#39;Artagnan');
});
it('does not collapse already-encoded entities (re-escapes the &)', () => {
// escapeHtml is idempotent by composition: the second pass re-escapes
// the & that was added by the first. Pin the property so the helper
// can't be "cleverly" optimised to skip it.
expect(escapeHtml('&amp;')).toBe('&amp;amp;');
});
});
// ─── detectMention ────────────────────────────────────────────────────────────
describe('detectMention', () => {

View File

@@ -44,6 +44,24 @@ export function extractContent(
return { content: text, mentionedUserIds: [...seen] };
}
/**
* Escapes the five HTML-special characters that can break out of text content
* or attribute values. & must be escaped first to avoid double-encoding.
*
* Includes the apostrophe so the helper is safe in single-quoted attribute
* values too — the renderTranscriptionBody anchor template in PR-B2 uses
* double quotes today, but a future template change shouldn't open a
* stored-XSS hole (Sina #5505 action item).
*/
export function escapeHtml(str: string): string {
return str
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
/**
* Renders a comment body as safe HTML:
* 1. Escapes all HTML-special characters in the raw content
@@ -51,19 +69,11 @@ export function extractContent(
* 3. Converts newlines to <br>
*/
export function renderBody(content: string, mentions: MentionDTO[]): string {
let escaped = content
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
let escaped = escapeHtml(content);
for (const mention of mentions) {
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
const escapedDisplayName = displayName
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const escapedDisplayName = escapeHtml(displayName);
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { detectPersonMention } from './personMention';
describe('detectPersonMention', () => {
it('returns null when text has no @', () => {
expect(detectPersonMention('hello world', 11)).toBeNull();
});
it('returns null when @ is preceded by a non-whitespace character (email pattern)', () => {
expect(detectPersonMention('user@example', 12)).toBeNull();
});
it('returns query for @ at the very start of string', () => {
expect(detectPersonMention('@Aug', 4)).toBe('Aug');
});
it('returns empty string immediately after @', () => {
expect(detectPersonMention('@', 1)).toBe('');
});
it('returns single-word query', () => {
expect(detectPersonMention('hi @Auguste', 11)).toBe('Auguste');
});
it('keeps the trigger active when the query has a trailing space', () => {
expect(detectPersonMention('hi @Auguste ', 12)).toBe('Auguste ');
});
it('returns multi-word query (spaces allowed)', () => {
expect(detectPersonMention('hi @Auguste Raddatz', 19)).toBe('Auguste Raddatz');
});
it('returns single-character query', () => {
expect(detectPersonMention('@M', 2)).toBe('M');
});
it('returns null when the query crosses a newline', () => {
expect(detectPersonMention('@Aug\nfoo', 8)).toBeNull();
});
it('returns null when a second @ appears in the query (next mention starts)', () => {
expect(detectPersonMention('@Aug@bar', 8)).toBeNull();
});
it('uses the most recent @ when separated by whitespace', () => {
// '@Aug @Bert' with cursor at end — the second @ is the trigger.
expect(detectPersonMention('@Aug @Bert', 10)).toBe('Bert');
});
it('returns the query when the cursor sits exactly at a newline boundary', () => {
// '@Aug\nfoo' with cursor at index 4 — right at the newline before it
// is consumed. The query is still 'Aug' because nothing past the cursor
// counts.
expect(detectPersonMention('@Aug\nfoo', 4)).toBe('Aug');
});
it('returns null when cursor is before the @', () => {
expect(detectPersonMention('@Hans', 0)).toBeNull();
});
it('uses the most recent @ in the text', () => {
// cursor is just after the second @ + a few chars
expect(detectPersonMention('hi @Anna and @Bert', 18)).toBe('Bert');
});
});

View File

@@ -0,0 +1,23 @@
/**
* Given the current textarea value and cursor position, returns the
* @-person-mention query being typed (the text after the last triggering @),
* or null if no person-mention is active.
*
* Rules — distinct from comment-mentions in `mention.ts`:
* - @ must be at the start of the string or preceded by whitespace
* - The query may contain spaces (historical persons commonly have multi-word
* display names — "Auguste Raddatz", "Maria von Müller-Schultz")
* - The query stops at a newline or at a second @ (the next mention starts)
*/
export function detectPersonMention(text: string, cursorPos: number): string | null {
const before = text.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
if (atIndex === -1) return null;
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
const query = before.slice(atIndex + 1);
if (query.includes('\n') || query.includes('@')) return null;
return query;
}

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi } from 'vitest';
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
import { BlockConflictResolvedError } from './blockConflictMerge';
import type { PersonMention } from '$lib/types';
const DOC = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const BLK = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const SERVER_BLOCK_AFTER_RENAME = {
id: BLK,
annotationId: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
documentId: DOC,
text: 'old text from server',
label: null,
sortOrder: 0,
version: 7,
source: 'MANUAL' as const,
reviewed: false,
mentionedPersons: [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }]
};
function mkResponse(status: number, body?: unknown): Response {
return new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
describe('saveBlockWithConflictRetry', () => {
it('returns the server-saved block on a successful PUT', async () => {
const updated = { ...SERVER_BLOCK_AFTER_RENAME, text: 'persisted text' };
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(200, updated));
const result = await saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'persisted text',
mentionedPersons: []
});
expect(result).toEqual(updated);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl).toHaveBeenCalledWith(
`/api/documents/${DOC}/transcription-blocks/${BLK}`,
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ text: 'persisted text', mentionedPersons: [] })
})
);
});
it('throws BlockConflictResolvedError carrying the merged block on 409', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(mkResponse(409))
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
const localMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'transcriber unsaved input',
mentionedPersons: localMentions
})
).rejects.toThrow(BlockConflictResolvedError);
expect(fetchImpl).toHaveBeenCalledTimes(2);
// First call PUT, second is the GET refetch.
expect(fetchImpl.mock.calls[0]?.[1]?.method).toBe('PUT');
expect(fetchImpl.mock.calls[1]?.[1]).toBeUndefined();
});
it('attaches the merged block to err.merged so callers can update local state', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(mkResponse(409))
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
const localMentions: PersonMention[] = [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale displayName
];
try {
await saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'transcriber unsaved input',
mentionedPersons: localMentions
});
throw new Error('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(BlockConflictResolvedError);
const merged = (err as BlockConflictResolvedError).merged!;
// Local text wins.
expect(merged.text).toBe('transcriber unsaved input');
// Server displayName wins for shared personId.
expect(merged.mentionedPersons).toEqual([
{ personId: 'p-aug', displayName: 'Augusta Raddatz' }
]);
// Server version carried forward.
expect(merged.version).toBe(7);
}
});
it('throws BlockConflictResolvedError without merged when refetch fails', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(mkResponse(409))
.mockResolvedValueOnce(mkResponse(500));
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'x',
mentionedPersons: []
})
).rejects.toMatchObject({ code: 'CONFLICT_RESOLVED', merged: undefined });
});
it('throws Save failed for any other non-OK response', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(500));
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'x',
mentionedPersons: []
})
).rejects.toThrow('Save failed');
});
it('rejects ids that are not UUIDs (path-injection guard)', async () => {
const fetchImpl = vi.fn();
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: '../../etc/passwd',
text: 'x',
mentionedPersons: []
})
).rejects.toThrow(/Invalid id/);
expect(fetchImpl).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,60 @@
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
import { BlockConflictResolvedError, mergeBlockOnConflict } from '$lib/utils/blockConflictMerge';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type Args = {
fetchImpl: typeof fetch;
documentId: string;
blockId: string;
text: string;
mentionedPersons: PersonMention[];
};
/**
* Persists a transcription block edit, with built-in handling for the
* rename-mid-edit conflict (B12b).
*
* - 200/204 → resolves with the server's updated block.
* - 409 → refetches the latest server block, merges it with the
* transcriber's unsaved input via mergeBlockOnConflict, and
* throws BlockConflictResolvedError carrying the merged
* snapshot. The caller is responsible for updating local
* state with `err.merged` before surfacing the error.
* - other → throws Error('Save failed').
*
* Validates both ids against the UUID pattern before any fetch fires
* (Sina #5505 — defence-in-depth path-injection guard).
*/
export async function saveBlockWithConflictRetry(args: Args): Promise<TranscriptionBlockData> {
const { fetchImpl, documentId, blockId, text, mentionedPersons } = args;
if (!UUID_RE.test(documentId) || !UUID_RE.test(blockId)) {
throw new Error(`Invalid id for save: doc=${documentId} block=${blockId}`);
}
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
const res = await fetchImpl(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons })
});
if (res.status === 409) {
const fresh = await fetchImpl(url);
if (!fresh.ok) {
throw new BlockConflictResolvedError(blockId);
}
const serverBlock = (await fresh.json()) as TranscriptionBlockData;
const merged = mergeBlockOnConflict({
serverBlock,
localText: text,
localMentions: mentionedPersons
});
const err = new BlockConflictResolvedError(blockId);
(err as BlockConflictResolvedError & { merged: TranscriptionBlockData }).merged = merged;
throw err;
}
if (!res.ok) throw new Error('Save failed');
return (await res.json()) as TranscriptionBlockData;
}

View File

@@ -88,15 +88,28 @@ async function loadTranscriptionBlocks() {
}
}
async function saveBlock(blockId: string, text: string) {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (!res.ok) throw new Error('Save failed');
const updated = await res.json();
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
async function saveBlock(
blockId: string,
text: string,
mentionedPersons: import('$lib/types').PersonMention[]
) {
const { saveBlockWithConflictRetry } = await import('$lib/utils/saveBlockWithConflictRetry');
const { BlockConflictResolvedError } = await import('$lib/utils/blockConflictMerge');
try {
const updated = await saveBlockWithConflictRetry({
fetchImpl: fetch,
documentId: doc.id,
blockId,
text,
mentionedPersons
});
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
} catch (err) {
if (err instanceof BlockConflictResolvedError && err.merged) {
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? err.merged! : b));
}
throw err;
}
}
async function deleteBlock(blockId: string) {