Replaces captureTextarea + handleTextareaMouseUp (which read selection bounds off a real <textarea>) with an onSelectionChange callback prop on PersonMentionEditor, wired to Tiptap's selectionUpdate event. The editor emits the selected text directly so the parent no longer needs DOM access. Tests are updated to drive the contenteditable via the Selection API instead of the now-deleted textarea. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
293 lines
8.4 KiB
Svelte
293 lines
8.4 KiB
Svelte
<script lang="ts">
|
|
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();
|
|
|
|
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
|
|
|
type Props = {
|
|
blockId: string;
|
|
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, mentionedPersons: PersonMention[]) => void;
|
|
onFocus: () => void;
|
|
onDeleteClick: () => void;
|
|
onRetry: () => void;
|
|
onReviewToggle: () => void;
|
|
onMoveUp?: () => void;
|
|
onMoveDown?: () => void;
|
|
isFirst?: boolean;
|
|
isLast?: boolean;
|
|
source?: 'MANUAL' | 'OCR';
|
|
};
|
|
|
|
let {
|
|
blockId,
|
|
documentId,
|
|
blockNumber,
|
|
text,
|
|
mentionedPersons,
|
|
label = null,
|
|
active,
|
|
reviewed,
|
|
saveState,
|
|
canComment,
|
|
currentUserId,
|
|
onTextChange,
|
|
onFocus,
|
|
onDeleteClick,
|
|
onRetry,
|
|
onReviewToggle,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
isFirst = false,
|
|
isLast = false,
|
|
source = 'MANUAL'
|
|
}: 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);
|
|
|
|
const hasComments = $derived(commentCount > 0);
|
|
|
|
// Sync from prop only when switching to a different block (not on save responses)
|
|
let prevBlockId = $state(blockId);
|
|
$effect(() => {
|
|
if (blockId !== prevBlockId) {
|
|
localText = text;
|
|
localMentions = [...mentionedPersons];
|
|
prevBlockId = blockId;
|
|
}
|
|
});
|
|
|
|
let leftBorderClass = $derived(
|
|
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
|
|
);
|
|
|
|
function emitChange() {
|
|
onTextChange(localText, localMentions);
|
|
}
|
|
|
|
async function handleDelete() {
|
|
const confirmed = await confirm({
|
|
title: m.transcription_block_delete_confirm(),
|
|
destructive: true
|
|
});
|
|
if (confirmed) onDeleteClick();
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="relative flex overflow-visible rounded border border-line {leftBorderClass}"
|
|
data-block-id={blockId}
|
|
>
|
|
<!-- Turquoise numbered badge — overlaps top-left of card -->
|
|
<span
|
|
class="absolute -top-2 -left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-turquoise text-xs font-bold text-turquoise-fg shadow-sm"
|
|
>
|
|
{blockNumber}
|
|
</span>
|
|
|
|
<!-- Drag handle (desktop) / Arrow buttons (mobile) -->
|
|
<div class="flex shrink-0 flex-col items-center justify-center border-r border-line px-1">
|
|
<!-- Mobile: arrow buttons -->
|
|
<button
|
|
type="button"
|
|
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
|
|
disabled={isFirst}
|
|
aria-label="Nach oben"
|
|
onclick={() => onMoveUp?.()}
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
|
</svg>
|
|
</button>
|
|
<!-- Desktop: grip handle (drag target) -->
|
|
<div
|
|
class="hidden cursor-grab text-ink-3 transition-colors select-none hover:text-ink active:cursor-grabbing md:block"
|
|
data-drag-handle
|
|
aria-label="Ziehen zum Sortieren"
|
|
>
|
|
⠿
|
|
</div>
|
|
<!-- Mobile: arrow down -->
|
|
<button
|
|
type="button"
|
|
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
|
|
disabled={isLast}
|
|
aria-label="Nach unten"
|
|
onclick={() => onMoveDown?.()}
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="min-w-0 flex-1 p-4 pl-3">
|
|
<!-- Header -->
|
|
<div class="mb-2 flex items-center gap-2">
|
|
{#if label}
|
|
<span class="text-xs font-medium tracking-wide text-ink-2 uppercase">
|
|
{label}
|
|
</span>
|
|
{/if}
|
|
{#if (!text || text.trim() === '') && source === 'MANUAL'}
|
|
<span class="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-ink-3"
|
|
>{m.transcription_block_segmentation_only()}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Editor powered by PersonMentionEditor (Tiptap) for @-mention typeahead -->
|
|
<PersonMentionEditor
|
|
bind:value={() => localText,
|
|
(v) => {
|
|
localText = v;
|
|
emitChange();
|
|
}}
|
|
bind:mentionedPersons={() => localMentions,
|
|
(next) => {
|
|
localMentions = next;
|
|
emitChange();
|
|
}}
|
|
placeholder={m.transcription_block_placeholder()}
|
|
onfocus={onFocus}
|
|
onSelectionChange={(text) => (selectedQuote = text)}
|
|
/>
|
|
|
|
{#if selectedQuote}
|
|
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>
|
|
{/if}
|
|
|
|
<!-- Footer -->
|
|
<div class="flex items-center justify-between border-t border-line pt-2">
|
|
<div>
|
|
{#if !hasComments}
|
|
<button
|
|
type="button"
|
|
class="flex cursor-pointer items-center gap-1 text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
|
onclick={() => (commentOpen = true)}
|
|
>
|
|
<svg
|
|
class="h-3 w-3"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
|
/>
|
|
</svg>
|
|
{m.transcription_block_comment_btn()}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<!-- Save state indicator -->
|
|
{#if saveState === 'saving'}
|
|
<span class="animate-pulse text-xs text-ink-3">
|
|
{m.transcription_block_save_saving()}
|
|
</span>
|
|
{:else if saveState === 'saved' || saveState === 'fading'}
|
|
<span
|
|
class="text-xs text-green-600 transition-opacity duration-300 {saveState === 'fading' ? 'opacity-0' : 'opacity-100'}"
|
|
>
|
|
{m.transcription_block_save_saved()} <span class="inline-block">✓</span>
|
|
</span>
|
|
{:else if saveState === 'error'}
|
|
<span class="text-error text-xs">
|
|
{m.transcription_block_save_error()}
|
|
<span class="mx-1">—</span>
|
|
<button
|
|
type="button"
|
|
class="underline transition-colors hover:text-ink"
|
|
onclick={onRetry}
|
|
>
|
|
{m.transcription_block_save_retry()}
|
|
</button>
|
|
</span>
|
|
{/if}
|
|
|
|
<!-- Review toggle -->
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer transition-colors {reviewed ? 'text-turquoise hover:text-turquoise/70' : 'text-ink-3 hover:text-turquoise'}"
|
|
aria-label={reviewed ? m.transcription_block_unreview() : m.transcription_block_review()}
|
|
title={reviewed ? m.transcription_block_unreview() : m.transcription_block_review()}
|
|
onclick={onReviewToggle}
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
fill={reviewed ? 'currentColor' : 'none'}
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Delete button -->
|
|
<button
|
|
type="button"
|
|
class="hover:text-error cursor-pointer text-ink-3 transition-colors"
|
|
aria-label={m.btn_delete()}
|
|
onclick={handleDelete}
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Comment thread — list always visible, compose toggled by Kommentieren -->
|
|
<div class="mt-3">
|
|
<CommentThread
|
|
documentId={documentId}
|
|
blockId={blockId}
|
|
loadOnMount={true}
|
|
canComment={canComment}
|
|
currentUserId={currentUserId}
|
|
quotedText={selectedQuote}
|
|
showCompose={commentOpen}
|
|
onCountChange={(count) => (commentCount = count)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|