From 02d3e2ab618a2187f8379335f90875cb8aacc5af Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 00:32:09 +0200 Subject: [PATCH] feat(transcription): swap plain textarea for PersonMentionEditor and thread mentionedPersons through autosave - TranscriptionBlockData now carries mentionedPersons (matches backend schema added in PR-A). - useBlockAutoSave.saveFn signature widens to (blockId, text, mentions); pendingMentions is tracked alongside pendingTexts and is preserved on failure so a retry resends the in-flight payload (B12). - TranscriptionBlock.svelte renders , exposing the textarea node back through a captureTextarea callback so the existing quote-selection feature still works. - saveBlock in routes/documents/[id]/+page.svelte forwards mentions on PUT. - flushOnUnload sends mentions in the keepalive payload too. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/PersonMentionEditor.svelte | 9 ++- .../lib/components/TranscriptionBlock.svelte | 79 +++++++++++-------- .../TranscriptionBlock.test-host.svelte | 16 +++- .../components/TranscriptionEditView.svelte | 11 ++- .../TranscriptionEditView.svelte.spec.ts | 33 ++++++-- .../TranscriptionReadView.svelte.test.ts | 15 +++- .../__tests__/useBlockAutoSave.svelte.test.ts | 59 +++++++++----- .../__tests__/useBlockDragDrop.svelte.test.ts | 3 +- .../src/lib/hooks/useBlockAutoSave.svelte.ts | 46 +++++++++-- frontend/src/lib/types.ts | 6 ++ .../src/routes/documents/[id]/+page.svelte | 8 +- 11 files changed, 207 insertions(+), 78 deletions(-) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index 45029bf9..f6c563de 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -16,6 +16,10 @@ type Props = { 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 { @@ -25,7 +29,8 @@ let { rows = 1, disabled = false, onfocus, - onblur + onblur, + captureTextarea }: Props = $props(); let query: string | null = $state(null); @@ -38,7 +43,9 @@ let debounceTimer: ReturnType | undefined; function attachTextarea(node: HTMLTextAreaElement) { textarea = node; + const parentCleanup = captureTextarea?.(node); return () => { + parentCleanup?.(); textarea = null; }; } diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 74598c7e..97d4fcde 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -2,6 +2,8 @@ import { m } from '$lib/paraglide/messages.js'; import { getConfirmService } from '$lib/services/confirm.svelte.js'; import CommentThread from './CommentThread.svelte'; +import PersonMentionEditor from './PersonMentionEditor.svelte'; +import type { PersonMention } from '$lib/types'; const { confirm } = getConfirmService(); @@ -12,13 +14,14 @@ type Props = { documentId: string; blockNumber: number; text: string; + mentionedPersons: PersonMention[]; label: string | null; active: boolean; reviewed: boolean; saveState: SaveState; canComment: boolean; currentUserId: string | null; - onTextChange: (text: string) => void; + onTextChange: (text: string, mentionedPersons: PersonMention[]) => void; onFocus: () => void; onDeleteClick: () => void; onRetry: () => void; @@ -35,6 +38,7 @@ let { documentId, blockNumber, text, + mentionedPersons, label = null, active, reviewed, @@ -54,10 +58,10 @@ let { }: Props = $props(); let localText = $state(text); +let localMentions = $state([...mentionedPersons]); let commentOpen = $state(false); let commentCount = $state(0); let selectedQuote = $state(null); -let textareaEl = $state(null); const hasComments = $derived(commentCount > 0); @@ -66,6 +70,7 @@ let prevBlockId = $state(blockId); $effect(() => { if (blockId !== prevBlockId) { localText = text; + localMentions = [...mentionedPersons]; prevBlockId = blockId; } }); @@ -74,29 +79,32 @@ let leftBorderClass = $derived( saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : '' ); -function autoresize(node: HTMLTextAreaElement) { +// Single source of truth for the editor's textarea — stored on attach so +// we can read selection bounds for quote selection without re-querying the DOM. +let textareaEl: HTMLTextAreaElement | null = null; + +function captureTextarea(node: HTMLTextAreaElement) { textareaEl = node; - function resize() { - node.style.height = 'auto'; - node.style.height = `${node.scrollHeight}px`; - } - - resize(); - - return { - update() { - resize(); - }, - destroy() { - textareaEl = null; - } + resizeTextarea(); + return () => { + textareaEl = null; }; } -function handleInput(event: Event) { - const target = event.target as HTMLTextAreaElement; - localText = target.value; - onTextChange(target.value); +function resizeTextarea() { + if (!textareaEl) return; + textareaEl.style.height = 'auto'; + textareaEl.style.height = `${textareaEl.scrollHeight}px`; +} + +$effect(() => { + // Re-run autoresize whenever the bound text changes. + void localText; + resizeTextarea(); +}); + +function emitChange() { + onTextChange(localText, localMentions); } async function handleDelete() { @@ -181,17 +189,24 @@ function handleTextareaMouseUp() { {/if} - - + +
+ localText, + (v) => { + localText = v; + emitChange(); + }} + bind:mentionedPersons={() => localMentions, + (m) => { + localMentions = m; + emitChange(); + }} + placeholder={m.transcription_block_placeholder()} + onfocus={onFocus} + captureTextarea={captureTextarea} + /> +
{#if selectedQuote}

{m.transcription_block_quote_hint()}

diff --git a/frontend/src/lib/components/TranscriptionBlock.test-host.svelte b/frontend/src/lib/components/TranscriptionBlock.test-host.svelte index 9a03b4bf..d2e2055d 100644 --- a/frontend/src/lib/components/TranscriptionBlock.test-host.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.test-host.svelte @@ -1,21 +1,24 @@ - + diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index eae2c825..64e97fef 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -3,7 +3,7 @@ import { m } from '$lib/paraglide/messages.js'; import TranscriptionBlock from './TranscriptionBlock.svelte'; import OcrTrigger from './OcrTrigger.svelte'; import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte'; -import type { TranscriptionBlockData } from '$lib/types'; +import type { PersonMention, TranscriptionBlockData } from '$lib/types'; import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte'; import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte'; @@ -16,7 +16,7 @@ type Props = { storedScriptType?: string; canRunOcr?: boolean; onBlockFocus: (blockId: string) => void; - onSaveBlock: (blockId: string, text: string) => Promise; + onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise; onDeleteBlock: (blockId: string) => Promise; onReviewToggle: (blockId: string) => Promise; onMarkAllReviewed?: () => Promise; @@ -245,16 +245,19 @@ async function handleLabelToggle(label: string) { documentId={documentId} blockNumber={i + 1} text={block.text} + mentionedPersons={block.mentionedPersons ?? []} label={block.label} active={activeBlockId === block.id} reviewed={block.reviewed ?? false} saveState={autoSave.getSaveState(block.id)} canComment={canComment} currentUserId={currentUserId} - onTextChange={(text) => autoSave.handleTextChange(block.id, text)} + onTextChange={(text, mentions) => + autoSave.handleTextChange(block.id, text, mentions)} onFocus={() => handleFocus(block.id)} onDeleteClick={() => handleDelete(block.id)} - onRetry={() => autoSave.handleRetry(block.id, block.text)} + onRetry={() => + autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])} onReviewToggle={() => onReviewToggle(block.id)} onMoveUp={() => handleMoveUp(block.id)} onMoveDown={() => handleMoveDown(block.id)} diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index beeea901..39bf098c 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -15,7 +15,8 @@ const block1 = { sortOrder: 0, version: 0, source: 'MANUAL' as const, - reviewed: false + reviewed: false, + mentionedPersons: [] }; const block2 = { id: 'b2', @@ -26,7 +27,8 @@ const block2 = { sortOrder: 1, version: 0, source: 'OCR' as const, - reviewed: true + reviewed: true, + mentionedPersons: [] }; function renderView(overrides: Record = {}, service = createConfirmService()) { @@ -141,7 +143,28 @@ describe('TranscriptionEditView — auto-save debounce', () => { vi.advanceTimersByTime(1500); await vi.runAllTimersAsync(); - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile'); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []); + vi.useRealTimers(); + }); + + it('passes the block mentionedPersons array as the 3rd save argument', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockResolvedValue(undefined); + const blockWithMention = { + ...block1, + mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] + }; + renderView({ blocks: [blockWithMention], onSaveBlock }); + + const textarea = page.getByRole('textbox').first(); + await textarea.fill('Hallo @Auguste Raddatz'); + + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } + ]); vi.useRealTimers(); }); @@ -165,7 +188,7 @@ describe('TranscriptionEditView — auto-save debounce', () => { // Only one save with the final value expect(onSaveBlock).toHaveBeenCalledTimes(1); - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second'); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []); vi.useRealTimers(); }); }); @@ -220,7 +243,7 @@ describe('TranscriptionEditView — flush on blur', () => { el.dispatchEvent(new FocusEvent('blur', { bubbles: true })); await vi.runAllTimersAsync(); - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text'); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []); vi.useRealTimers(); }); }); diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts index 70823be2..c62eee55 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts +++ b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts @@ -12,7 +12,10 @@ const blocks: TranscriptionBlockData[] = [ text: 'First paragraph text.', label: null, sortOrder: 1, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] }, { id: 'b2', @@ -49,7 +52,10 @@ describe('TranscriptionReadView', () => { text: 'Text before [unleserlich] text after', label: null, sortOrder: 1, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] } ], onParagraphClick: () => {} @@ -71,7 +77,10 @@ describe('TranscriptionReadView', () => { text: 'Some [...] text', label: null, sortOrder: 1, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] } ], onParagraphClick: () => {} diff --git a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts index 739fb432..8e2633e0 100644 --- a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts +++ b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import type { PersonMention } from '$lib/types'; -const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise>(); +const mockSaveFn = + vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise>(); + +const NO_MENTIONS: PersonMention[] = []; const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte'); @@ -22,25 +26,25 @@ describe('createBlockAutoSave', () => { it('debounce coalesces multiple changes — saves once after 1500ms', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text 1'); - as.handleTextChange('block-1', 'text 2'); - as.handleTextChange('block-1', 'text 3'); + as.handleTextChange('block-1', 'text 1', NO_MENTIONS); + as.handleTextChange('block-1', 'text 2', NO_MENTIONS); + as.handleTextChange('block-1', 'text 3', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(mockSaveFn).toHaveBeenCalledTimes(1); - expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3'); + expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS); }); it('handles concurrent blocks independently', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'hello'); - as.handleTextChange('block-2', 'world'); + as.handleTextChange('block-1', 'hello', NO_MENTIONS); + as.handleTextChange('block-2', 'world', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(mockSaveFn).toHaveBeenCalledTimes(2); }); it('sets save state to saving then saved on success', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); vi.advanceTimersByTime(1500); expect(as.getSaveState('block-1')).toBe('saving'); await Promise.resolve(); @@ -50,7 +54,7 @@ describe('createBlockAutoSave', () => { it('sets save state to error on save failure', async () => { mockSaveFn.mockRejectedValue(new Error('save failed')); const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(as.getSaveState('block-1')).toBe('error'); }); @@ -59,24 +63,24 @@ describe('createBlockAutoSave', () => { mockSaveFn.mockRejectedValueOnce(new Error('first fails')); mockSaveFn.mockResolvedValueOnce(undefined); const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'original'); + as.handleTextChange('block-1', 'original', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(as.getSaveState('block-1')).toBe('error'); - await as.handleRetry('block-1', 'original'); + await as.handleRetry('block-1', 'original', NO_MENTIONS); expect(mockSaveFn).toHaveBeenCalledTimes(2); expect(as.getSaveState('block-1')).toBe('saved'); }); it('clearBlock removes all state for a block', () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.clearBlock('block-1'); expect(as.getSaveState('block-1')).toBe('idle'); }); it('destroy clears all pending timers so no save occurs', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.destroy(); await vi.advanceTimersByTimeAsync(2000); expect(mockSaveFn).not.toHaveBeenCalled(); @@ -101,8 +105,8 @@ describe('flushOnUnload', () => { it('sends a PUT request with keepalive:true for each pending block', () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'hello'); - as.handleTextChange('block-2', 'world'); + as.handleTextChange('block-1', 'hello', NO_MENTIONS); + as.handleTextChange('block-2', 'world', NO_MENTIONS); as.flushOnUnload(); expect(mockFetch).toHaveBeenCalledTimes(2); @@ -111,7 +115,7 @@ describe('flushOnUnload', () => { expect.objectContaining({ method: 'PUT', keepalive: true, - body: JSON.stringify({ text: 'hello' }) + body: JSON.stringify({ text: 'hello', mentionedPersons: [] }) }) ); expect(mockFetch).toHaveBeenCalledWith( @@ -119,7 +123,7 @@ describe('flushOnUnload', () => { expect.objectContaining({ method: 'PUT', keepalive: true, - body: JSON.stringify({ text: 'world' }) + body: JSON.stringify({ text: 'world', mentionedPersons: [] }) }) ); }); @@ -127,7 +131,7 @@ describe('flushOnUnload', () => { it('does not call navigator.sendBeacon', () => { const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.flushOnUnload(); expect(sendBeaconSpy).not.toHaveBeenCalled(); @@ -142,7 +146,7 @@ describe('flushOnUnload', () => { it('cancels the debounce timer so saveFn is not also called', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.flushOnUnload(); await vi.advanceTimersByTimeAsync(2000); @@ -151,13 +155,26 @@ describe('flushOnUnload', () => { it('does not send fetch if debounce already fired and pendingTexts is empty', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); - // debounce has fired; pendingTexts should be empty now mockFetch.mockClear(); as.flushOnUnload(); expect(mockFetch).not.toHaveBeenCalled(); }); + + it('flushes the pending mentionedPersons sidecar alongside text', () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }]; + as.handleTextChange('block-1', '@Auguste Raddatz', mentions); + as.flushOnUnload(); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/documents/doc-1/transcription-blocks/block-1', + expect.objectContaining({ + body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions }) + }) + ); + }); }); diff --git a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts index a0c541f3..3e4adaa2 100644 --- a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts +++ b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts @@ -12,7 +12,8 @@ function makeBlock(id: string, sortOrder: number): TranscriptionBlockData { sortOrder, version: 1, source: 'MANUAL', - reviewed: false + reviewed: false, + mentionedPersons: [] }; } diff --git a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts index 07dc5692..fd76f3e8 100644 --- a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts +++ b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts @@ -1,9 +1,10 @@ import { SvelteMap } from 'svelte/reactivity'; +import type { PersonMention } from '$lib/types'; export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; type Options = { - saveFn: (blockId: string, text: string) => Promise; + saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise; documentId: string; }; @@ -11,6 +12,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { const saveStates = new SvelteMap(); const debounceTimers = new SvelteMap>(); const pendingTexts = new SvelteMap(); + const pendingMentions = new SvelteMap(); const fadeTimers: ReturnType[] = []; function getSaveState(blockId: string): SaveState { @@ -21,18 +23,27 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { saveStates.set(blockId, state); } + function getPendingMentions(blockId: string, fallback: PersonMention[]): PersonMention[] { + return pendingMentions.get(blockId) ?? fallback; + } + async function executeSave(blockId: string): Promise { const text = pendingTexts.get(blockId); if (text === undefined) return; + const mentions = pendingMentions.get(blockId) ?? []; pendingTexts.delete(blockId); + pendingMentions.delete(blockId); setSaveState(blockId, 'saving'); try { - await saveFn(blockId, text); + await saveFn(blockId, text, mentions); setSaveState(blockId, 'saved'); scheduleSavedFade(blockId); } catch { + // Preserve in-flight payload so the user can retry without re-typing. + pendingTexts.set(blockId, text); + pendingMentions.set(blockId, mentions); setSaveState(blockId, 'error'); } } @@ -69,11 +80,22 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { } } - function handleTextChange(blockId: string, text: string): void { + function handleTextChange( + blockId: string, + text: string, + mentionedPersons: PersonMention[] + ): void { pendingTexts.set(blockId, text); + pendingMentions.set(blockId, mentionedPersons); scheduleDebounce(blockId); } + function handleMentionsChange(blockId: string, mentionedPersons: PersonMention[]): void { + pendingMentions.set(blockId, mentionedPersons); + // Mentions changes always accompany text changes from the editor, so the + // text-debounce timer covers them too. + } + function handleBlur(): void { for (const [blockId] of [...debounceTimers]) { clearDebounce(blockId); @@ -81,29 +103,37 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { } } - async function handleRetry(blockId: string, currentText: string): Promise { - const pending = pendingTexts.get(blockId); - const text = pending ?? currentText; + async function handleRetry( + blockId: string, + currentText: string, + currentMentions: PersonMention[] + ): Promise { + const text = pendingTexts.get(blockId) ?? currentText; + const mentions = pendingMentions.get(blockId) ?? currentMentions; pendingTexts.set(blockId, text); + pendingMentions.set(blockId, mentions); await executeSave(blockId); } function clearBlock(blockId: string): void { clearDebounce(blockId); pendingTexts.delete(blockId); + pendingMentions.delete(blockId); saveStates.delete(blockId); } function flushOnUnload(): void { for (const [blockId, text] of pendingTexts) { + const mentions = pendingMentions.get(blockId) ?? []; clearDebounce(blockId); void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), + body: JSON.stringify({ text, mentionedPersons: mentions }), keepalive: true }); pendingTexts.delete(blockId); + pendingMentions.delete(blockId); } } @@ -120,7 +150,9 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { return { getSaveState, + getPendingMentions, handleTextChange, + handleMentionsChange, handleBlur, handleRetry, clearBlock, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 2458d35a..f9f810e7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -37,6 +37,11 @@ export type Comment = { export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history'; +export type PersonMention = { + personId: string; + displayName: string; +}; + export type TranscriptionBlockData = { id: string; annotationId: string; @@ -47,6 +52,7 @@ export type TranscriptionBlockData = { version: number; source: 'MANUAL' | 'OCR'; reviewed: boolean; + mentionedPersons: PersonMention[]; updatedAt?: string | null; }; diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 5c780a54..1ea3e10d 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -88,11 +88,15 @@ async function loadTranscriptionBlocks() { } } -async function saveBlock(blockId: string, text: string) { +async function saveBlock( + blockId: string, + text: string, + mentionedPersons: import('$lib/types').PersonMention[] +) { const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }) + body: JSON.stringify({ text, mentionedPersons }) }); if (!res.ok) throw new Error('Save failed'); const updated = await res.json();