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 <PersonMentionEditor>, 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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 00:32:09 +02:00
parent c4ee2c666b
commit 02d3e2ab61
11 changed files with 207 additions and 78 deletions

View File

@@ -2,6 +2,8 @@
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import CommentThread from './CommentThread.svelte';
import PersonMentionEditor from './PersonMentionEditor.svelte';
import type { PersonMention } from '$lib/types';
const { confirm } = getConfirmService();
@@ -12,13 +14,14 @@ type Props = {
documentId: string;
blockNumber: number;
text: string;
mentionedPersons: PersonMention[];
label: string | null;
active: boolean;
reviewed: boolean;
saveState: SaveState;
canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string) => void;
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
@@ -35,6 +38,7 @@ let {
documentId,
blockNumber,
text,
mentionedPersons,
label = null,
active,
reviewed,
@@ -54,10 +58,10 @@ let {
}: Props = $props();
let localText = $state(text);
let localMentions = $state<PersonMention[]>([...mentionedPersons]);
let commentOpen = $state(false);
let commentCount = $state(0);
let selectedQuote = $state<string | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(null);
const hasComments = $derived(commentCount > 0);
@@ -66,6 +70,7 @@ let prevBlockId = $state(blockId);
$effect(() => {
if (blockId !== prevBlockId) {
localText = text;
localMentions = [...mentionedPersons];
prevBlockId = blockId;
}
});
@@ -74,29 +79,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}
</div>
<!-- Textarea -->
<textarea
use:autoresize={localText}
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
placeholder={m.transcription_block_placeholder()}
rows={1}
value={localText}
oninput={handleInput}
onfocus={onFocus}
onmouseup={handleTextareaMouseUp}
></textarea>
<!-- Textarea (now powered by PersonMentionEditor for @-mention typeahead) -->
<div onmouseup={handleTextareaMouseUp} role="presentation">
<PersonMentionEditor
bind:value={() => localText,
(v) => {
localText = v;
emitChange();
}}
bind:mentionedPersons={() => localMentions,
(m) => {
localMentions = m;
emitChange();
}}
placeholder={m.transcription_block_placeholder()}
onfocus={onFocus}
captureTextarea={captureTextarea}
/>
</div>
{#if selectedQuote}
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>