Files
voice-to-notes/src/lib/components/TranscriptEditor.svelte
Claude 727107323c Fix transcript text edit not showing after Enter
The display renders segment.words (not segment.text), so editing the text
field alone had no visible effect. Now finishEditing() rebuilds the words
array from the edited text so the change is immediately visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:20:20 -07:00

286 lines
8.1 KiB
Svelte

<script lang="ts">
import { segments, speakers } from '$lib/stores/transcript';
import { currentTimeMs, isPlaying } from '$lib/stores/playback';
import type { Segment, Word, Speaker } from '$lib/types/transcript';
interface Props {
onWordClick?: (timeMs: number) => void;
onTextEdit?: (segmentId: string, newText: string) => void;
}
let { onWordClick, onTextEdit }: Props = $props();
let transcriptContainer: HTMLDivElement;
let autoScroll = $state(true);
let lastActiveSegmentId = $state('');
let userScrollTimeout: ReturnType<typeof setTimeout> | null = null;
function getSpeakerName(speakerId: string | null, speakerList: Speaker[]): string {
if (!speakerId) return 'Unknown';
const speaker = speakerList.find(s => s.id === speakerId);
return speaker?.display_name || speaker?.label || 'Unknown';
}
function getSpeakerColor(speakerId: string | null, speakerList: Speaker[]): string {
if (!speakerId) return '#888';
const speaker = speakerList.find(s => s.id === speakerId);
return speaker?.color || '#888';
}
function formatTimestamp(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function isWordActive(word: Word, currentMs: number): boolean {
return currentMs >= word.start_ms && currentMs <= word.end_ms;
}
function isSegmentActive(segment: Segment, currentMs: number): boolean {
return currentMs >= segment.start_ms && currentMs <= segment.end_ms;
}
let editingSegmentId = $state<string | null>(null);
let editText = $state('');
function handleWordClick(word: Word) {
onWordClick?.(word.start_ms);
}
function startEditing(segment: Segment) {
editingSegmentId = segment.id;
// Combine word texts or fall back to segment text
editText = segment.words.length > 0
? segment.words.map(w => w.word).join(' ')
: segment.text;
}
function finishEditing(segmentId: string) {
const trimmed = editText.trim();
if (trimmed) {
// Update the segment text and rebuild words from the edited text.
// The display renders segment.words, so we must update them too.
segments.update(segs => segs.map(s => {
if (s.id !== segmentId) return s;
// Rebuild words from the edited text, preserving timing from the
// original segment boundaries (individual word timing is lost on edit)
const newWords = trimmed.split(/\s+/).map((word, widx) => ({
id: `${s.id}-word-${widx}`,
segment_id: s.id,
word,
start_ms: s.start_ms,
end_ms: s.end_ms,
confidence: 1.0,
word_index: widx,
}));
return {
...s,
text: trimmed,
words: newWords,
original_text: s.original_text ?? s.text,
is_edited: true,
edited_at: new Date().toISOString(),
};
}));
onTextEdit?.(segmentId, trimmed);
}
editingSegmentId = null;
}
function handleEditKeydown(e: KeyboardEvent, segmentId: string) {
if (e.key === 'Escape') {
editingSegmentId = null;
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
finishEditing(segmentId);
}
}
// Pause auto-scroll when user manually scrolls, resume after 3 seconds
function handleScroll() {
if (!$isPlaying) return;
autoScroll = false;
if (userScrollTimeout) clearTimeout(userScrollTimeout);
userScrollTimeout = setTimeout(() => {
autoScroll = true;
}, 3000);
}
// Auto-scroll to the active segment during playback
$effect(() => {
if (!$isPlaying || !autoScroll || !transcriptContainer) return;
const currentMs = $currentTimeMs;
const activeSegment = $segments.find(s => isSegmentActive(s, currentMs));
if (!activeSegment || activeSegment.id === lastActiveSegmentId) return;
lastActiveSegmentId = activeSegment.id;
const el = transcriptContainer.querySelector(`[data-segment-id="${activeSegment.id}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
</script>
<div class="transcript-editor" bind:this={transcriptContainer} onscroll={handleScroll}>
{#if $segments.length === 0}
<div class="empty-state">
<p>No transcript yet</p>
<p class="hint">Import an audio file and run transcription to get started</p>
</div>
{:else}
{#each $segments as segment (segment.id)}
<div
class="segment"
class:active={isSegmentActive(segment, $currentTimeMs)}
data-segment-id={segment.id}
>
<div class="segment-header">
<span
class="speaker-label"
style="border-left-color: {getSpeakerColor(segment.speaker_id, $speakers)}"
>
{getSpeakerName(segment.speaker_id, $speakers)}
</span>
<span class="timestamp">{formatTimestamp(segment.start_ms)}</span>
</div>
{#if editingSegmentId === segment.id}
<div class="segment-edit">
<textarea
class="edit-textarea"
bind:value={editText}
onblur={() => finishEditing(segment.id)}
onkeydown={(e) => handleEditKeydown(e, segment.id)}
></textarea>
<span class="edit-hint">Enter to save, Esc to cancel</span>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="segment-text" ondblclick={() => startEditing(segment)}>
{#each segment.words as word (word.id)}
<span
class="word"
class:word-active={isWordActive(word, $currentTimeMs)}
onclick={() => handleWordClick(word)}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') handleWordClick(word); }}
>{word.word} </span>
{:else}
<span class="segment-plain-text">{segment.text}</span>
{/each}
{#if segment.is_edited}
<span class="edited-badge">edited</span>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
</div>
<style>
.transcript-editor {
flex: 1;
overflow-y: auto;
padding: 1rem;
background: #16213e;
border-radius: 8px;
color: #e0e0e0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
}
.hint {
font-size: 0.875rem;
color: #555;
}
.segment {
margin-bottom: 1rem;
padding: 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.segment.active {
background: rgba(233, 69, 96, 0.1);
}
.segment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.speaker-label {
font-weight: 600;
font-size: 0.875rem;
border-left: 3px solid;
padding-left: 0.5rem;
}
.timestamp {
color: #666;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.segment-text {
line-height: 1.6;
padding-left: 0.75rem;
word-wrap: break-word;
overflow-wrap: break-word;
}
.word {
cursor: pointer;
border-radius: 2px;
padding: 0 1px;
transition: background-color 0.15s;
}
.word:hover {
background: rgba(233, 69, 96, 0.2);
}
.word-active {
background: rgba(233, 69, 96, 0.35);
color: #fff;
}
.segment-plain-text {
color: #ccc;
}
.segment-edit {
padding-left: 0.75rem;
}
.edit-textarea {
width: 100%;
min-height: 3rem;
background: #1a1a2e;
color: #e0e0e0;
border: 1px solid #e94560;
border-radius: 4px;
padding: 0.5rem;
font-family: inherit;
font-size: inherit;
line-height: 1.6;
resize: vertical;
}
.edit-textarea:focus {
outline: none;
border-color: #ff6b81;
}
.edit-hint {
font-size: 0.7rem;
color: #666;
}
.edited-badge {
font-size: 0.65rem;
color: #e94560;
background: rgba(233, 69, 96, 0.15);
padding: 0.1rem 0.3rem;
border-radius: 3px;
margin-left: 0.5rem;
vertical-align: middle;
}
</style>