feat: person @mentions edit-mode infrastructure (PR-B1, #362) #369
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
263
frontend/src/lib/components/PersonMentionEditor.svelte
Normal file
263
frontend/src/lib/components/PersonMentionEditor.svelte
Normal 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>
|
||||
385
frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
Normal file
385
frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
Normal 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]');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: () => {}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@ function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
|
||||
sortOrder,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
128
frontend/src/lib/utils/blockConflictMerge.spec.ts
Normal file
128
frontend/src/lib/utils/blockConflictMerge.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
50
frontend/src/lib/utils/blockConflictMerge.ts
Normal file
50
frontend/src/lib/utils/blockConflictMerge.ts
Normal 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]
|
||||
};
|
||||
}
|
||||
@@ -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&T');
|
||||
});
|
||||
|
||||
it('escapes less-than and greater-than', () => {
|
||||
expect(escapeHtml('<script>')).toBe('<script>');
|
||||
});
|
||||
|
||||
it('escapes double quote', () => {
|
||||
expect(escapeHtml('say "hi"')).toBe('say "hi"');
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(escapeHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('escapes ampersand before other entities to avoid double-encoding', () => {
|
||||
expect(escapeHtml('a&<b')).toBe('a&<b');
|
||||
});
|
||||
|
||||
it('escapes apostrophe to '', () => {
|
||||
expect(escapeHtml("d'Artagnan")).toBe('d'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('&')).toBe('&amp;');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── detectMention ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('detectMention', () => {
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
let escaped = escapeHtml(content);
|
||||
|
||||
for (const mention of mentions) {
|
||||
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
||||
const escapedDisplayName = displayName
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
const escapedDisplayName = escapeHtml(displayName);
|
||||
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
|
||||
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
||||
}
|
||||
|
||||
65
frontend/src/lib/utils/personMention.spec.ts
Normal file
65
frontend/src/lib/utils/personMention.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
23
frontend/src/lib/utils/personMention.ts
Normal file
23
frontend/src/lib/utils/personMention.ts
Normal 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;
|
||||
}
|
||||
152
frontend/src/lib/utils/saveBlockWithConflictRetry.spec.ts
Normal file
152
frontend/src/lib/utils/saveBlockWithConflictRetry.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
60
frontend/src/lib/utils/saveBlockWithConflictRetry.ts
Normal file
60
frontend/src/lib/utils/saveBlockWithConflictRetry.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user