feat: Split sentence/translation in UI into a component

This commit is contained in:
wilson 2026-04-07 07:09:25 +01:00
parent c65509b53f
commit acadf77e2e
4 changed files with 332 additions and 105 deletions

View file

@ -32,6 +32,11 @@ class SpacyClient:
"""Use SpaCy to get parts of speech for the given text and language, """Use SpaCy to get parts of speech for the given text and language,
broken down by sentences and then by tokens.""" broken down by sentences and then by tokens."""
nlp = self._get_nlp(language) nlp = self._get_nlp(language)
# Recognise line-breaks as always being sentence boundaries, even if the model doesn't.
# This is important for the frontend to be able to show line-breaks in the source text.
nlp.add_pipe("sentencizer", before="parser")
doc = nlp(text) doc = nlp(text)
sentences = [ sentences = [

View file

@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { PartsOfSpeechData } from '$lib/spacy/types'; import type { PartOfSpeechToken, PartsOfSpeechData } from '$lib/spacy/types';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import TargetLanguageBody from './TargetLanguageBody.svelte'; import TargetLanguageBody from './TargetLanguageBody.svelte';
import type { Paragraph, Sentence, SentenceToken, Transcript } from './Transcript'; import type { Paragraph, Sentence, SentenceToken, Transcript } from './Transcript';
import TranslationPanel from './TranslationPanel.svelte'; import TranslationPanel from './TranslationPanel.svelte';
import { translateText } from './translate.remote';
const { data }: PageProps = $props(); const { data }: PageProps = $props();
const { article } = data; const { article } = data;
@ -35,10 +34,10 @@
}; };
const sentenceEndsWithNewLine = s.text.endsWith('\n'); const sentenceEndsWithNewLine = s.text.endsWith('\n');
paragraphs[paragraphs.length - 1].sentences.push(sentence);
if (sentenceEndsWithNewLine) { if (sentenceEndsWithNewLine) {
paragraphs.push({ index: paragraphs.length, sentences: [] }); paragraphs.push({ index: paragraphs.length, sentences: [] });
} else {
paragraphs[paragraphs.length - 1].sentences.push(sentence);
} }
}); });
@ -49,6 +48,16 @@
article.target_body_pos as Record<string, any> as PartsOfSpeechData article.target_body_pos as Record<string, any> as PartsOfSpeechData
); );
// Flat source-sentence list, aligned by sentence index to the target sentences.
// Used by TranslationPanel to show the source-language context for guessing.
const sourceSentences: Array<{ text: string; tokens: PartOfSpeechToken[] }> = (() => {
try {
return (article.source_body_pos as Record<string, any> as PartsOfSpeechData).sentences ?? [];
} catch {
return [];
}
})();
// Flat sentence list for O(n) audio-time lookup // Flat sentence list for O(n) audio-time lookup
const allSentences: Array<{ idx: number; startWordIdx: number; endWordIdx: number }> = []; const allSentences: Array<{ idx: number; startWordIdx: number; endWordIdx: number }> = [];
for (const para of paragraphs) { for (const para of paragraphs) {
@ -88,10 +97,10 @@
let audioEl: HTMLAudioElement | null = $state(null); let audioEl: HTMLAudioElement | null = $state(null);
let activeSentenceIdx = $state(-1); let activeSentenceIdx = $state(-1);
let selectedSentenceToken: SentenceToken | null = $state(null); let selectedTokens: SentenceToken[] = $state([]);
let selectedSentence: Sentence | null = $state(null); let selectedSentence: Sentence | null = $state(null);
let translatedText: string | null = $state(null);
let translating = $state(false); const selectedTokenIndices = $derived(new Set(selectedTokens.map((t) => t.idx)));
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Audio: sentence highlighting // Audio: sentence highlighting
@ -125,35 +134,18 @@
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Word click: fetch translation // Word selection: open panel with sentence context
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
async function handleWordClick(token: SentenceToken, sentence: Sentence) { function handleSelection(tokens: SentenceToken[], sentence: Sentence) {
selectedSentenceToken = token; selectedTokens = tokens;
selectedSentence = sentence;
activeSentenceIdx = sentence.idx; activeSentenceIdx = sentence.idx;
translatedText = null;
translating = true;
try {
const result = await translateText({
fromLanguage: article.target_language,
toLanguage: article.source_language,
sentenceText: sentence.text,
text: token.text
});
translatedText = result.text;
console.log({ result });
} catch {
translatedText = null;
} finally {
translating = false;
}
} }
function closePanel() { function closePanel() {
selectedSentenceToken = null; selectedTokens = [];
translatedText = null; selectedSentence = null;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -216,12 +208,19 @@
lang={article.source_language} lang={article.source_language}
{paragraphs} {paragraphs}
{activeSentenceIdx} {activeSentenceIdx}
onWordClick={handleWordClick} onSelection={handleSelection}
{selectedSentenceToken} {selectedTokenIndices}
/> />
</div> </div>
<TranslationPanel {closePanel} {selectedSentenceToken} {translatedText} {translating} /> <TranslationPanel
{closePanel}
{selectedTokens}
targetSentence={selectedSentence}
sourceTokens={selectedSentence !== null
? (sourceSentences[selectedSentence.idx]?.tokens ?? null)
: null}
/>
</div> </div>
</div> </div>
@ -229,7 +228,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="drawer-backdrop" class="drawer-backdrop"
class:is-visible={selectedSentenceToken !== null} class:is-visible={selectedTokens.length > 0}
onclick={closePanel} onclick={closePanel}
aria-hidden="true" aria-hidden="true"
></div> ></div>

View file

@ -4,16 +4,156 @@
interface Props { interface Props {
paragraphs: Paragraph[]; paragraphs: Paragraph[];
activeSentenceIdx: number; activeSentenceIdx: number;
selectedSentenceToken: SentenceToken | null; selectedTokenIndices: Set<number>;
onWordClick: (token: SentenceToken, sentence: Sentence) => void; onSelection: (tokens: SentenceToken[], sentence: Sentence) => void;
lang: string; lang: string;
} }
const { paragraphs, activeSentenceIdx, selectedSentenceToken, onWordClick, lang }: Props = const { paragraphs, activeSentenceIdx, selectedTokenIndices, onSelection, lang }: Props =
$props(); $props();
// Flat map: token.idx → { token, sentence } used during drag finalisation
const tokenMap = new Map<number, { token: SentenceToken; sentence: Sentence }>();
for (const para of paragraphs) {
for (const sentence of para.sentences) {
for (const token of sentence.tokens) {
tokenMap.set(token.idx, { token, sentence });
}
}
}
// -------------------------------------------------------------------------
// Drag-selection state
// -------------------------------------------------------------------------
type DragState = {
startTokenIdx: number;
endTokenIdx: number;
sentenceIdx: number;
startX: number;
startY: number;
pointerId: number;
committed: boolean; // true once we've locked in as a drag (not a tap)
};
let drag = $state<DragState | null>(null);
let containerEl: HTMLDivElement;
// The token-index range highlighted during an active drag
const dragRange = $derived(
drag?.committed
? {
min: Math.min(drag.startTokenIdx, drag.endTokenIdx),
max: Math.max(drag.startTokenIdx, drag.endTokenIdx)
}
: null
);
// After a committed drag, suppress the click event the browser fires next
let suppressNextClick = false;
function wordElAt(target: EventTarget | null) {
return (target as Element | null)?.closest<HTMLElement>('[data-token-idx]') ?? null;
}
function handlePointerDown(e: PointerEvent) {
const wordEl = wordElAt(e.target);
if (!wordEl) return;
drag = {
startTokenIdx: parseInt(wordEl.dataset.tokenIdx!),
endTokenIdx: parseInt(wordEl.dataset.tokenIdx!),
sentenceIdx: parseInt(wordEl.dataset.sentenceIdx!),
startX: e.clientX,
startY: e.clientY,
pointerId: e.pointerId,
committed: false
};
}
function handlePointerMove(e: PointerEvent) {
if (!drag || e.pointerId !== drag.pointerId) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
if (!drag.committed) {
// Commit to drag only when horizontal movement clearly dominates
if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy) * 1.5) {
drag.committed = true;
containerEl.setPointerCapture(e.pointerId);
} else if (Math.abs(dy) > 12) {
// Vertical scroll intent — abandon without selection
drag = null;
}
return;
}
// Drag is committed: find the word under the pointer and update end token.
// document.elementFromPoint is unaffected by pointer capture and returns
// the visually topmost element at (x, y).
const el = document.elementFromPoint(e.clientX, e.clientY);
const wordEl = el?.closest<HTMLElement>('[data-token-idx]');
if (wordEl) {
const newSentenceIdx = parseInt(wordEl.dataset.sentenceIdx!);
// Constrain selection to the sentence the drag started in
if (newSentenceIdx === drag.sentenceIdx) {
drag.endTokenIdx = parseInt(wordEl.dataset.tokenIdx!);
}
}
}
function handlePointerUp(e: PointerEvent) {
if (!drag || e.pointerId !== drag.pointerId) return;
const { startTokenIdx, endTokenIdx, committed } = drag;
drag = null;
const entry = tokenMap.get(startTokenIdx);
if (!entry) return;
if (committed) {
suppressNextClick = true;
const sentence = entry.sentence;
const min = Math.min(startTokenIdx, endTokenIdx);
const max = Math.max(startTokenIdx, endTokenIdx);
const tokens = sentence.tokens.filter((t) => !t.is_punct && t.idx >= min && t.idx <= max);
if (tokens.length > 0) onSelection(tokens, sentence);
}
// Simple tap falls through to the click event handled below
}
function handlePointerCancel(e: PointerEvent) {
if (drag?.pointerId === e.pointerId) drag = null;
}
// Handles both mouse clicks and keyboard activation (Enter/Space on a focused word)
function handleClick(e: MouseEvent) {
if (suppressNextClick) {
suppressNextClick = false;
return;
}
const wordEl = wordElAt(e.target);
if (!wordEl) return;
const entry = tokenMap.get(parseInt(wordEl.dataset.tokenIdx!));
if (entry && !entry.token.is_punct) {
onSelection([entry.token], entry.sentence);
}
}
</script> </script>
<div class="article-body" {lang}> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="article-body"
{lang}
bind:this={containerEl}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerCancel}
onclick={handleClick}
role="none"
>
{#each paragraphs as para (para.index)} {#each paragraphs as para (para.index)}
<p class="paragraph"> <p class="paragraph">
{#each para.sentences as sentence (sentence.idx)} {#each para.sentences as sentence (sentence.idx)}
@ -22,8 +162,13 @@
{#if !token.is_punct} {#if !token.is_punct}
<button <button
class="word" class="word"
class:word--selected={selectedSentenceToken?.idx === token.idx} class:word--selected={selectedTokenIndices.has(token.idx) ||
onclick={() => onWordClick(token, sentence)} (dragRange !== null &&
token.idx >= dragRange.min &&
token.idx <= dragRange.max)}
data-token-idx={token.idx}
data-sentence-idx={sentence.idx}
tabindex="0"
> >
{token.text} {token.text}
</button> </button>
@ -43,6 +188,9 @@
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
font-family: var(--font-body); font-family: var(--font-body);
/* Allow vertical scroll gestures to pass through; horizontal drags are
intercepted by our pointer handlers for word selection. */
touch-action: pan-y;
} }
.paragraph { .paragraph {
@ -76,6 +224,9 @@
transition: transition:
background-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard); color var(--duration-fast) var(--ease-standard);
/* Prevent text selection highlight during drag */
user-select: none;
-webkit-user-select: none;
} }
.word:hover { .word:hover {

View file

@ -1,58 +1,101 @@
<script lang="ts"> <script lang="ts">
import type { SentenceToken } from './Transcript'; import type { PartOfSpeechToken } from '$lib/spacy/types';
import type { Sentence, SentenceToken } from './Transcript';
let { let {
selectedSentenceToken = null, selectedTokens = [],
translating = false, targetSentence = null,
translatedText = '', sourceTokens = null,
closePanel closePanel
}: { }: {
selectedSentenceToken?: SentenceToken | null; selectedTokens?: SentenceToken[];
translating?: boolean; targetSentence?: Sentence | null;
translatedText: string | null; sourceTokens?: PartOfSpeechToken[] | null;
closePanel: () => void; closePanel: () => void;
} = $props(); } = $props();
// Which source-language word indices the user has tapped as their guess
let guessedIndices = $state(new Set<number>());
// Reset guess whenever the selection changes
$effect(() => {
selectedTokens; // reactive dependency — any new selection clears the guess
guessedIndices = new Set();
});
const isOpen = $derived(selectedTokens.length > 0);
// Title: the selected word(s) joined by space
const selectionLabel = $derived(selectedTokens.map((t) => t.text).join(' '));
// Set of target token indices for fast lookup in the sentence display
const selectedTargetIndices = $derived(new Set(selectedTokens.map((t) => t.idx)));
function toggleGuess(i: number) {
const next = new Set(guessedIndices);
if (next.has(i)) {
next.delete(i);
} else {
next.add(i);
}
guessedIndices = next;
}
</script> </script>
<!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) --> <!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) -->
<aside <aside class="translation-panel" class:is-open={isOpen} aria-label="Word translation">
class="translation-panel" {#if isOpen}
class:is-open={selectedSentenceToken !== null}
aria-label="Word translation"
>
{#if selectedSentenceToken}
<div class="panel-header"> <div class="panel-header">
<p class="panel-word">{selectedSentenceToken.text}</p> <p class="panel-word">{selectionLabel}</p>
<button class="btn btn-ghost panel-close" onclick={closePanel} aria-label="Close panel"> <button class="btn btn-ghost panel-close" onclick={closePanel} aria-label="Close panel">
</button> </button>
</div> </div>
{#if translating} {#if targetSentence}
<div class="panel-loading"> <!-- Target-language sentence with the selection highlighted -->
<div class="spinner" aria-hidden="true"></div> <p class="panel-sentence panel-sentence--target" lang="und">
<span>Translating…</span> {#each targetSentence.tokens as token (token.idx)}
</div> {#if !token.is_punct}
{:else if translatedText} <span
<p class="panel-translation">{translatedText}</p> class="sentence-word"
<button class="btn btn-secondary panel-save" disabled aria-disabled="true"> class:sentence-word--selected={selectedTargetIndices.has(token.idx)}
Add to flashcard >
</button> {token.text}
{:else} </span>
<p class="panel-error">Could not load translation.</p> {:else}
<span class="sentence-punct">{token.text}</span>
{/if}
{/each}
</p>
{/if}
{#if sourceTokens && sourceTokens.length > 0}
<p class="panel-prompt">Which word(s) do you think it means?</p>
<!-- Source-language sentence — tap words to mark your guess -->
<p class="panel-sentence panel-sentence--source" lang="und">
{#each sourceTokens as token, i (i)}
{#if !token.is_punct}
<button
class="source-word"
class:source-word--guessed={guessedIndices.has(i)}
onclick={() => toggleGuess(i)}
>
{token.text}
</button>
{:else}
<span class="sentence-punct">{token.text}</span>
{/if}
{/each}
</p>
{/if} {/if}
{:else} {:else}
<p class="panel-hint">Tap any word for a translation</p> <p class="panel-hint">Select a word — or drag across several — to see it in context</p>
{/if} {/if}
</aside> </aside>
<style> <style>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.panel-header { .panel-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -76,19 +119,72 @@
line-height: 1; line-height: 1;
} }
.panel-translation { /* --- Sentence displays --- */
.panel-sentence {
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--text-body-lg); font-size: var(--text-body-lg);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
color: var(--color-on-surface-variant); color: var(--color-on-surface);
font-style: italic; }
.panel-sentence--target {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
.panel-save { .panel-sentence--source {
width: 100%; margin-top: var(--space-2);
padding-block: var(--space-2); }
opacity: 0.6;
.sentence-word {
/* inline, non-interactive — just for highlighting */
}
.sentence-word--selected {
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
color: var(--color-primary);
font-weight: var(--weight-semibold);
border-radius: var(--radius-xs);
padding: 0 0.1em;
}
.sentence-punct {
color: var(--color-on-surface-variant);
}
/* --- Prompt --- */
.panel-prompt {
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
margin-bottom: var(--space-2);
}
/* --- Source-language word buttons (guess targets) --- */
.source-word {
display: inline;
background: none;
border: none;
padding: 0 0.1em;
margin: 0;
font: inherit;
font-size: var(--text-body-lg);
color: inherit;
cursor: pointer;
border-radius: var(--radius-xs);
transition:
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
.source-word:hover {
background-color: color-mix(in srgb, var(--color-secondary) 12%, transparent);
color: var(--color-secondary);
}
.source-word--guessed {
background-color: color-mix(in srgb, var(--color-secondary) 20%, transparent);
color: var(--color-secondary);
font-weight: var(--weight-medium);
} }
.panel-hint { .panel-hint {
@ -99,30 +195,6 @@
padding: var(--space-4) 0; padding: var(--space-4) 0;
} }
.panel-error {
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
}
.panel-loading {
display: flex;
align-items: center;
gap: var(--space-3);
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
}
.spinner {
flex-shrink: 0;
width: 1rem;
height: 1rem;
border: 2px solid var(--color-outline-variant);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
/* --- Translation panel: Desktop (sticky sidebar) --- */ /* --- Translation panel: Desktop (sticky sidebar) --- */
@media (min-width: 768px) { @media (min-width: 768px) {
.translation-panel { .translation-panel {
@ -147,7 +219,7 @@
background-color: var(--color-surface-container-lowest); background-color: var(--color-surface-container-lowest);
border-radius: var(--radius-xl) var(--radius-xl) 0 0; border-radius: var(--radius-xl) var(--radius-xl) 0 0;
padding: var(--space-5) var(--space-5) calc(var(--space-5) + env(safe-area-inset-bottom)); padding: var(--space-5) var(--space-5) calc(var(--space-5) + env(safe-area-inset-bottom));
max-height: 55vh; max-height: 70vh;
overflow-y: auto; overflow-y: auto;
transform: translateY(100%); transform: translateY(100%);
transition: transform var(--duration-slow) var(--ease-standard); transition: transform var(--duration-slow) var(--ease-standard);