feat: person @mentions edit-mode infrastructure (PR-B1, #362) #369
@@ -420,6 +420,12 @@
|
|||||||
"notification_unread": "ungelesen",
|
"notification_unread": "ungelesen",
|
||||||
"mention_btn_label": "Person erwähnen",
|
"mention_btn_label": "Person erwähnen",
|
||||||
"mention_popup_empty": "Keine Nutzer gefunden",
|
"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_home": "Archiv",
|
||||||
"page_title_persons": "Personen",
|
"page_title_persons": "Personen",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
|
|||||||
@@ -420,6 +420,12 @@
|
|||||||
"notification_unread": "unread",
|
"notification_unread": "unread",
|
||||||
"mention_btn_label": "Mention person",
|
"mention_btn_label": "Mention person",
|
||||||
"mention_popup_empty": "No users found",
|
"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_home": "Archive",
|
||||||
"page_title_persons": "Persons",
|
"page_title_persons": "Persons",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
|
|||||||
@@ -420,6 +420,12 @@
|
|||||||
"notification_unread": "no leído",
|
"notification_unread": "no leído",
|
||||||
"mention_btn_label": "Mencionar persona",
|
"mention_btn_label": "Mencionar persona",
|
||||||
"mention_popup_empty": "No se encontraron usuarios",
|
"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_home": "Archivo",
|
||||||
"page_title_persons": "Personas",
|
"page_title_persons": "Personas",
|
||||||
"page_title_admin": "Administración",
|
"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 { m } from '$lib/paraglide/messages.js';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
import CommentThread from './CommentThread.svelte';
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
import PersonMentionEditor from './PersonMentionEditor.svelte';
|
||||||
|
import type { PersonMention } from '$lib/types';
|
||||||
|
|
||||||
const { confirm } = getConfirmService();
|
const { confirm } = getConfirmService();
|
||||||
|
|
||||||
@@ -12,13 +14,14 @@ type Props = {
|
|||||||
documentId: string;
|
documentId: string;
|
||||||
blockNumber: number;
|
blockNumber: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
mentionedPersons: PersonMention[];
|
||||||
label: string | null;
|
label: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
reviewed: boolean;
|
reviewed: boolean;
|
||||||
saveState: SaveState;
|
saveState: SaveState;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
onTextChange: (text: string) => void;
|
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
@@ -35,6 +38,7 @@ let {
|
|||||||
documentId,
|
documentId,
|
||||||
blockNumber,
|
blockNumber,
|
||||||
text,
|
text,
|
||||||
|
mentionedPersons,
|
||||||
label = null,
|
label = null,
|
||||||
active,
|
active,
|
||||||
reviewed,
|
reviewed,
|
||||||
@@ -54,10 +58,10 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let localText = $state(text);
|
let localText = $state(text);
|
||||||
|
let localMentions = $state<PersonMention[]>([...mentionedPersons]);
|
||||||
let commentOpen = $state(false);
|
let commentOpen = $state(false);
|
||||||
let commentCount = $state(0);
|
let commentCount = $state(0);
|
||||||
let selectedQuote = $state<string | null>(null);
|
let selectedQuote = $state<string | null>(null);
|
||||||
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
|
||||||
|
|
||||||
const hasComments = $derived(commentCount > 0);
|
const hasComments = $derived(commentCount > 0);
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ let prevBlockId = $state(blockId);
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (blockId !== prevBlockId) {
|
if (blockId !== prevBlockId) {
|
||||||
localText = text;
|
localText = text;
|
||||||
|
localMentions = [...mentionedPersons];
|
||||||
prevBlockId = blockId;
|
prevBlockId = blockId;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -74,29 +79,19 @@ let leftBorderClass = $derived(
|
|||||||
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
|
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;
|
textareaEl = node;
|
||||||
function resize() {
|
return () => {
|
||||||
node.style.height = 'auto';
|
textareaEl = null;
|
||||||
node.style.height = `${node.scrollHeight}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
resize();
|
|
||||||
|
|
||||||
return {
|
|
||||||
update() {
|
|
||||||
resize();
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
textareaEl = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInput(event: Event) {
|
function emitChange() {
|
||||||
const target = event.target as HTMLTextAreaElement;
|
onTextChange(localText, localMentions);
|
||||||
localText = target.value;
|
|
||||||
onTextChange(target.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -181,17 +176,24 @@ function handleTextareaMouseUp() {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Textarea -->
|
<!-- Textarea (now powered by PersonMentionEditor for @-mention typeahead) -->
|
||||||
<textarea
|
<div onmouseup={handleTextareaMouseUp} role="presentation">
|
||||||
use:autoresize={localText}
|
<PersonMentionEditor
|
||||||
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
|
bind:value={() => localText,
|
||||||
placeholder={m.transcription_block_placeholder()}
|
(v) => {
|
||||||
rows={1}
|
localText = v;
|
||||||
value={localText}
|
emitChange();
|
||||||
oninput={handleInput}
|
}}
|
||||||
onfocus={onFocus}
|
bind:mentionedPersons={() => localMentions,
|
||||||
onmouseup={handleTextareaMouseUp}
|
(next) => {
|
||||||
></textarea>
|
localMentions = next;
|
||||||
|
emitChange();
|
||||||
|
}}
|
||||||
|
placeholder={m.transcription_block_placeholder()}
|
||||||
|
onfocus={onFocus}
|
||||||
|
captureTextarea={captureTextarea}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if selectedQuote}
|
{#if selectedQuote}
|
||||||
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>
|
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { provideConfirmService, type ConfirmService } from '$lib/services/confirm.svelte.js';
|
import { provideConfirmService, type ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
|
import type { PersonMention } from '$lib/types';
|
||||||
|
|
||||||
type BlockProps = {
|
type BlockProps = {
|
||||||
blockId: string;
|
blockId: string;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
blockNumber: number;
|
blockNumber: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
mentionedPersons?: PersonMention[];
|
||||||
label: string | null;
|
label: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
saveState: 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
saveState: 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
onTextChange: (text: string) => void;
|
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
|
onReviewToggle?: () => void;
|
||||||
onMoveUp?: () => void;
|
onMoveUp?: () => void;
|
||||||
onMoveDown?: () => void;
|
onMoveDown?: () => void;
|
||||||
isFirst?: boolean;
|
isFirst?: boolean;
|
||||||
@@ -24,13 +27,22 @@ type BlockProps = {
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
onServiceReady,
|
onServiceReady,
|
||||||
|
mentionedPersons = [],
|
||||||
|
reviewed = false,
|
||||||
|
onReviewToggle = () => {},
|
||||||
...blockProps
|
...blockProps
|
||||||
}: BlockProps & {
|
}: BlockProps & {
|
||||||
onServiceReady: (s: ConfirmService) => void;
|
onServiceReady: (s: ConfirmService) => void;
|
||||||
|
reviewed?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const service = provideConfirmService();
|
const service = provideConfirmService();
|
||||||
onServiceReady(service);
|
onServiceReady(service);
|
||||||
</script>
|
</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 TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
import OcrTrigger from './OcrTrigger.svelte';
|
import OcrTrigger from './OcrTrigger.svelte';
|
||||||
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.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 { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||||
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ type Props = {
|
|||||||
storedScriptType?: string;
|
storedScriptType?: string;
|
||||||
canRunOcr?: boolean;
|
canRunOcr?: boolean;
|
||||||
onBlockFocus: (blockId: string) => void;
|
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>;
|
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||||
onReviewToggle: (blockId: string) => Promise<void>;
|
onReviewToggle: (blockId: string) => Promise<void>;
|
||||||
onMarkAllReviewed?: () => Promise<void>;
|
onMarkAllReviewed?: () => Promise<void>;
|
||||||
@@ -245,16 +245,19 @@ async function handleLabelToggle(label: string) {
|
|||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
blockNumber={i + 1}
|
blockNumber={i + 1}
|
||||||
text={block.text}
|
text={block.text}
|
||||||
|
mentionedPersons={block.mentionedPersons ?? []}
|
||||||
label={block.label}
|
label={block.label}
|
||||||
active={activeBlockId === block.id}
|
active={activeBlockId === block.id}
|
||||||
reviewed={block.reviewed ?? false}
|
reviewed={block.reviewed ?? false}
|
||||||
saveState={autoSave.getSaveState(block.id)}
|
saveState={autoSave.getSaveState(block.id)}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
|
onTextChange={(text, mentions) =>
|
||||||
|
autoSave.handleTextChange(block.id, text, mentions)}
|
||||||
onFocus={() => handleFocus(block.id)}
|
onFocus={() => handleFocus(block.id)}
|
||||||
onDeleteClick={() => handleDelete(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)}
|
onReviewToggle={() => onReviewToggle(block.id)}
|
||||||
onMoveUp={() => handleMoveUp(block.id)}
|
onMoveUp={() => handleMoveUp(block.id)}
|
||||||
onMoveDown={() => handleMoveDown(block.id)}
|
onMoveDown={() => handleMoveDown(block.id)}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const block1 = {
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
version: 0,
|
version: 0,
|
||||||
source: 'MANUAL' as const,
|
source: 'MANUAL' as const,
|
||||||
reviewed: false
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
};
|
};
|
||||||
const block2 = {
|
const block2 = {
|
||||||
id: 'b2',
|
id: 'b2',
|
||||||
@@ -26,7 +27,8 @@ const block2 = {
|
|||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
version: 0,
|
version: 0,
|
||||||
source: 'OCR' as const,
|
source: 'OCR' as const,
|
||||||
reviewed: true
|
reviewed: true,
|
||||||
|
mentionedPersons: []
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
|
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
|
||||||
@@ -141,7 +143,28 @@ describe('TranscriptionEditView — auto-save debounce', () => {
|
|||||||
vi.advanceTimersByTime(1500);
|
vi.advanceTimersByTime(1500);
|
||||||
await vi.runAllTimersAsync();
|
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();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,7 +188,7 @@ describe('TranscriptionEditView — auto-save debounce', () => {
|
|||||||
|
|
||||||
// Only one save with the final value
|
// Only one save with the final value
|
||||||
expect(onSaveBlock).toHaveBeenCalledTimes(1);
|
expect(onSaveBlock).toHaveBeenCalledTimes(1);
|
||||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second');
|
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -220,7 +243,7 @@ describe('TranscriptionEditView — flush on blur', () => {
|
|||||||
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text');
|
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ const blocks: TranscriptionBlockData[] = [
|
|||||||
text: 'First paragraph text.',
|
text: 'First paragraph text.',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
version: 1
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'b2',
|
id: 'b2',
|
||||||
@@ -21,7 +24,10 @@ const blocks: TranscriptionBlockData[] = [
|
|||||||
text: 'Second paragraph text.',
|
text: 'Second paragraph text.',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
version: 1
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,7 +55,10 @@ describe('TranscriptionReadView', () => {
|
|||||||
text: 'Text before [unleserlich] text after',
|
text: 'Text before [unleserlich] text after',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
version: 1
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
onParagraphClick: () => {}
|
onParagraphClick: () => {}
|
||||||
@@ -71,7 +80,10 @@ describe('TranscriptionReadView', () => {
|
|||||||
text: 'Some [...] text',
|
text: 'Some [...] text',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
version: 1
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
onParagraphClick: () => {}
|
onParagraphClick: () => {}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
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');
|
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
|
||||||
|
|
||||||
@@ -22,25 +26,25 @@ describe('createBlockAutoSave', () => {
|
|||||||
|
|
||||||
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
|
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text 1');
|
as.handleTextChange('block-1', 'text 1', NO_MENTIONS);
|
||||||
as.handleTextChange('block-1', 'text 2');
|
as.handleTextChange('block-1', 'text 2', NO_MENTIONS);
|
||||||
as.handleTextChange('block-1', 'text 3');
|
as.handleTextChange('block-1', 'text 3', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
expect(mockSaveFn).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('handles concurrent blocks independently', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'hello');
|
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
|
||||||
as.handleTextChange('block-2', 'world');
|
as.handleTextChange('block-2', 'world', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets save state to saving then saved on success', async () => {
|
it('sets save state to saving then saved on success', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
vi.advanceTimersByTime(1500);
|
vi.advanceTimersByTime(1500);
|
||||||
expect(as.getSaveState('block-1')).toBe('saving');
|
expect(as.getSaveState('block-1')).toBe('saving');
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -50,7 +54,7 @@ describe('createBlockAutoSave', () => {
|
|||||||
it('sets save state to error on save failure', async () => {
|
it('sets save state to error on save failure', async () => {
|
||||||
mockSaveFn.mockRejectedValue(new Error('save failed'));
|
mockSaveFn.mockRejectedValue(new Error('save failed'));
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
expect(as.getSaveState('block-1')).toBe('error');
|
expect(as.getSaveState('block-1')).toBe('error');
|
||||||
});
|
});
|
||||||
@@ -59,24 +63,49 @@ describe('createBlockAutoSave', () => {
|
|||||||
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
|
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
|
||||||
mockSaveFn.mockResolvedValueOnce(undefined);
|
mockSaveFn.mockResolvedValueOnce(undefined);
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'original');
|
as.handleTextChange('block-1', 'original', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
expect(as.getSaveState('block-1')).toBe('error');
|
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(mockSaveFn).toHaveBeenCalledTimes(2);
|
||||||
expect(as.getSaveState('block-1')).toBe('saved');
|
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', () => {
|
it('clearBlock removes all state for a block', () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
as.clearBlock('block-1');
|
as.clearBlock('block-1');
|
||||||
expect(as.getSaveState('block-1')).toBe('idle');
|
expect(as.getSaveState('block-1')).toBe('idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('destroy clears all pending timers so no save occurs', async () => {
|
it('destroy clears all pending timers so no save occurs', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
as.destroy();
|
as.destroy();
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
expect(mockSaveFn).not.toHaveBeenCalled();
|
expect(mockSaveFn).not.toHaveBeenCalled();
|
||||||
@@ -101,8 +130,8 @@ describe('flushOnUnload', () => {
|
|||||||
|
|
||||||
it('sends a PUT request with keepalive:true for each pending block', () => {
|
it('sends a PUT request with keepalive:true for each pending block', () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'hello');
|
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
|
||||||
as.handleTextChange('block-2', 'world');
|
as.handleTextChange('block-2', 'world', NO_MENTIONS);
|
||||||
as.flushOnUnload();
|
as.flushOnUnload();
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
@@ -111,7 +140,7 @@ describe('flushOnUnload', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
body: JSON.stringify({ text: 'hello' })
|
body: JSON.stringify({ text: 'hello', mentionedPersons: [] })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
@@ -119,7 +148,7 @@ describe('flushOnUnload', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
keepalive: true,
|
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', () => {
|
it('does not call navigator.sendBeacon', () => {
|
||||||
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
|
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
as.flushOnUnload();
|
as.flushOnUnload();
|
||||||
|
|
||||||
expect(sendBeaconSpy).not.toHaveBeenCalled();
|
expect(sendBeaconSpy).not.toHaveBeenCalled();
|
||||||
@@ -142,7 +171,7 @@ describe('flushOnUnload', () => {
|
|||||||
|
|
||||||
it('cancels the debounce timer so saveFn is not also called', async () => {
|
it('cancels the debounce timer so saveFn is not also called', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
as.flushOnUnload();
|
as.flushOnUnload();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
@@ -151,13 +180,26 @@ describe('flushOnUnload', () => {
|
|||||||
|
|
||||||
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
|
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
// debounce has fired; pendingTexts should be empty now
|
|
||||||
mockFetch.mockClear();
|
mockFetch.mockClear();
|
||||||
|
|
||||||
as.flushOnUnload();
|
as.flushOnUnload();
|
||||||
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
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,
|
sortOrder,
|
||||||
version: 1,
|
version: 1,
|
||||||
source: 'MANUAL',
|
source: 'MANUAL',
|
||||||
reviewed: false
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import type { PersonMention } from '$lib/types';
|
||||||
|
|
||||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
saveFn: (blockId: string, text: string) => Promise<void>;
|
saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
|||||||
const saveStates = new SvelteMap<string, SaveState>();
|
const saveStates = new SvelteMap<string, SaveState>();
|
||||||
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
||||||
const pendingTexts = new SvelteMap<string, string>();
|
const pendingTexts = new SvelteMap<string, string>();
|
||||||
|
const pendingMentions = new SvelteMap<string, PersonMention[]>();
|
||||||
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
|
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
function getSaveState(blockId: string): SaveState {
|
function getSaveState(blockId: string): SaveState {
|
||||||
@@ -25,14 +27,19 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
|||||||
const text = pendingTexts.get(blockId);
|
const text = pendingTexts.get(blockId);
|
||||||
if (text === undefined) return;
|
if (text === undefined) return;
|
||||||
|
|
||||||
|
const mentions = pendingMentions.get(blockId) ?? [];
|
||||||
pendingTexts.delete(blockId);
|
pendingTexts.delete(blockId);
|
||||||
|
pendingMentions.delete(blockId);
|
||||||
setSaveState(blockId, 'saving');
|
setSaveState(blockId, 'saving');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveFn(blockId, text);
|
await saveFn(blockId, text, mentions);
|
||||||
setSaveState(blockId, 'saved');
|
setSaveState(blockId, 'saved');
|
||||||
scheduleSavedFade(blockId);
|
scheduleSavedFade(blockId);
|
||||||
} catch {
|
} catch {
|
||||||
|
// Preserve in-flight payload so the user can retry without re-typing.
|
||||||
|
pendingTexts.set(blockId, text);
|
||||||
|
pendingMentions.set(blockId, mentions);
|
||||||
setSaveState(blockId, 'error');
|
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);
|
pendingTexts.set(blockId, text);
|
||||||
|
pendingMentions.set(blockId, mentionedPersons);
|
||||||
scheduleDebounce(blockId);
|
scheduleDebounce(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,29 +93,37 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRetry(blockId: string, currentText: string): Promise<void> {
|
async function handleRetry(
|
||||||
const pending = pendingTexts.get(blockId);
|
blockId: string,
|
||||||
const text = pending ?? currentText;
|
currentText: string,
|
||||||
|
currentMentions: PersonMention[]
|
||||||
|
): Promise<void> {
|
||||||
|
const text = pendingTexts.get(blockId) ?? currentText;
|
||||||
|
const mentions = pendingMentions.get(blockId) ?? currentMentions;
|
||||||
pendingTexts.set(blockId, text);
|
pendingTexts.set(blockId, text);
|
||||||
|
pendingMentions.set(blockId, mentions);
|
||||||
await executeSave(blockId);
|
await executeSave(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBlock(blockId: string): void {
|
function clearBlock(blockId: string): void {
|
||||||
clearDebounce(blockId);
|
clearDebounce(blockId);
|
||||||
pendingTexts.delete(blockId);
|
pendingTexts.delete(blockId);
|
||||||
|
pendingMentions.delete(blockId);
|
||||||
saveStates.delete(blockId);
|
saveStates.delete(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushOnUnload(): void {
|
function flushOnUnload(): void {
|
||||||
for (const [blockId, text] of pendingTexts) {
|
for (const [blockId, text] of pendingTexts) {
|
||||||
|
const mentions = pendingMentions.get(blockId) ?? [];
|
||||||
clearDebounce(blockId);
|
clearDebounce(blockId);
|
||||||
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text }),
|
body: JSON.stringify({ text, mentionedPersons: mentions }),
|
||||||
keepalive: true
|
keepalive: true
|
||||||
});
|
});
|
||||||
pendingTexts.delete(blockId);
|
pendingTexts.delete(blockId);
|
||||||
|
pendingMentions.delete(blockId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export type Comment = {
|
|||||||
|
|
||||||
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||||
|
|
||||||
|
export type PersonMention = {
|
||||||
|
personId: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TranscriptionBlockData = {
|
export type TranscriptionBlockData = {
|
||||||
id: string;
|
id: string;
|
||||||
annotationId: string;
|
annotationId: string;
|
||||||
@@ -47,6 +52,7 @@ export type TranscriptionBlockData = {
|
|||||||
version: number;
|
version: number;
|
||||||
source: 'MANUAL' | 'OCR';
|
source: 'MANUAL' | 'OCR';
|
||||||
reviewed: boolean;
|
reviewed: boolean;
|
||||||
|
mentionedPersons: PersonMention[];
|
||||||
updatedAt?: string | null;
|
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 { describe, it, expect } from 'vitest';
|
||||||
import { detectMention, extractContent, renderBody } from './mention';
|
import { detectMention, escapeHtml, extractContent, renderBody } from './mention';
|
||||||
import type { MentionDTO } from '$lib/types';
|
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 ────────────────────────────────────────────────────────────
|
// ─── detectMention ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('detectMention', () => {
|
describe('detectMention', () => {
|
||||||
|
|||||||
@@ -44,6 +44,24 @@ export function extractContent(
|
|||||||
return { content: text, mentionedUserIds: [...seen] };
|
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:
|
* Renders a comment body as safe HTML:
|
||||||
* 1. Escapes all HTML-special characters in the raw content
|
* 1. Escapes all HTML-special characters in the raw content
|
||||||
@@ -51,19 +69,11 @@ export function extractContent(
|
|||||||
* 3. Converts newlines to <br>
|
* 3. Converts newlines to <br>
|
||||||
*/
|
*/
|
||||||
export function renderBody(content: string, mentions: MentionDTO[]): string {
|
export function renderBody(content: string, mentions: MentionDTO[]): string {
|
||||||
let escaped = content
|
let escaped = escapeHtml(content);
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"');
|
|
||||||
|
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
||||||
const escapedDisplayName = displayName
|
const escapedDisplayName = escapeHtml(displayName);
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"');
|
|
||||||
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
|
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
|
||||||
escaped = escaped.replaceAll(`@${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) {
|
async function saveBlock(
|
||||||
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, {
|
blockId: string,
|
||||||
method: 'PUT',
|
text: string,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
mentionedPersons: import('$lib/types').PersonMention[]
|
||||||
body: JSON.stringify({ text })
|
) {
|
||||||
});
|
const { saveBlockWithConflictRetry } = await import('$lib/utils/saveBlockWithConflictRetry');
|
||||||
if (!res.ok) throw new Error('Save failed');
|
const { BlockConflictResolvedError } = await import('$lib/utils/blockConflictMerge');
|
||||||
const updated = await res.json();
|
try {
|
||||||
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
|
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) {
|
async function deleteBlock(blockId: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user