Spaces:
Paused
Paused
| <template> | |
| <aside class="shadowing-transcript flex h-full min-h-0 w-full flex-col border-l border-[var(--border)] bg-[var(--bg-surface)]"> | |
| <div class="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-3"> | |
| <span class="sh-panel-title">Bản chép</span> | |
| <label class="flex cursor-pointer items-center gap-1 text-[10px] font-semibold text-[var(--ink3)]"> | |
| <input | |
| type="checkbox" | |
| class="rounded border-[var(--border)] text-[var(--spotify-green)]" | |
| :checked="showTranslation" | |
| @change="$emit('update:showTranslation', $event.target.checked)" | |
| /> | |
| Trans | |
| </label> | |
| </div> | |
| <div ref="listEl" class="min-h-0 flex-1 overflow-y-auto p-3"> | |
| <div | |
| v-for="(seg, idx) in segments" | |
| :key="seg.id" | |
| :ref="(el) => setCardRef(el, idx)" | |
| class="sh-transcript-card mb-2 w-full rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] px-3 py-3 text-left transition-colors" | |
| :class="{ 'is-active': idx === activeIndex }" | |
| > | |
| <div class="mb-2 flex items-center justify-between"> | |
| <button | |
| type="button" | |
| class="rounded-md px-2 py-0.5 text-[10px] font-bold" | |
| :class="idx === activeIndex ? 'bg-[var(--green-l)] text-black' : 'bg-[var(--bg-interactive)] text-[var(--ink3)]'" | |
| @click="$emit('seek', idx)" | |
| > | |
| #{{ seg.id }} | |
| </button> | |
| <button | |
| type="button" | |
| class="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-amber-50" | |
| :class="flaggedIds.includes(seg.id) ? '!text-amber-600' : ''" | |
| title="Đánh dấu câu khó" | |
| @click.stop="$emit('toggle-flag', seg.id)" | |
| > | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/> | |
| <line x1="4" y1="22" x2="4" y2="15"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div | |
| role="button" | |
| tabindex="0" | |
| class="sh-transcript-body w-full cursor-pointer text-left" | |
| @click="$emit('seek', idx)" | |
| @keydown.enter="$emit('seek', idx)" | |
| > | |
| <p v-if="dictationMode" class="m-0 break-words text-[13px] leading-[1.65] text-black"> | |
| <span | |
| v-for="(w, wi) in wordsOf(seg)" | |
| :key="wi" | |
| class="inline align-baseline" | |
| > | |
| <button | |
| v-if="!isWordRevealed(seg.id, wi)" | |
| type="button" | |
| class="mr-0.5 inline-flex h-4 w-4 align-middle items-center justify-center text-[var(--ink3)] hover:text-[var(--spotify-green)]" | |
| title="Hiện từ" | |
| @click.stop="$emit('reveal-word', seg.id, wi)" | |
| > | |
| <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/> | |
| </svg> | |
| </button> | |
| <span :class="isWordRevealed(seg.id, wi) ? '' : 'text-gray-400'"> | |
| {{ isWordRevealed(seg.id, wi) ? w : dotsFor(w) }} | |
| </span> | |
| <span v-if="wi < wordsOf(seg).length - 1"> </span> | |
| </span> | |
| </p> | |
| <template v-else> | |
| <ShadowingVocabText | |
| v-if="vocabEnabled" | |
| class="sh-transcript-prose" | |
| :text="seg.text" | |
| :vocab-enabled="true" | |
| :source-quiz-id="sourceQuizId" | |
| /> | |
| <p v-else class="m-0 break-words text-[13px] leading-[1.65] text-black">{{ seg.text }}</p> | |
| </template> | |
| <p | |
| v-if="showTranslation && seg.translation" | |
| class="mt-2 break-words text-[12px] leading-[1.55] text-gray-600" | |
| > | |
| {{ seg.translation }} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| </template> | |
| <script setup> | |
| import { ref, watch, nextTick } from 'vue' | |
| import { tokenizeWords } from '@/utils/segmentUtils.js' | |
| import ShadowingVocabText from '@/components/shadowing/ShadowingVocabText.vue' | |
| const props = defineProps({ | |
| segments: { type: Array, default: () => [] }, | |
| activeIndex: { type: Number, default: 0 }, | |
| showTranslation: { type: Boolean, default: true }, | |
| flaggedIds: { type: Array, default: () => [] }, | |
| dictationMode: { type: Boolean, default: false }, | |
| /** @type {Set<string> | string[]} keys `${segId}-${wi}` */ | |
| revealedWordKeys: { type: [Set, Array], default: () => new Set() }, | |
| vocabEnabled: { type: Boolean, default: true }, | |
| sourceQuizId: { type: String, default: '' }, | |
| }) | |
| defineEmits(['seek', 'toggle-flag', 'update:showTranslation', 'reveal-word']) | |
| const listEl = ref(null) | |
| const cardRefs = ref({}) | |
| function setCardRef(el, idx) { | |
| if (el) cardRefs.value[idx] = el | |
| } | |
| function wordsOf(seg) { | |
| return tokenizeWords(seg?.text || '') | |
| } | |
| function wordKey(segId, wi) { | |
| return `${segId}-${wi}` | |
| } | |
| function isWordRevealed(segId, wi) { | |
| const keys = props.revealedWordKeys | |
| if (keys instanceof Set) return keys.has(wordKey(segId, wi)) | |
| return (keys || []).includes(wordKey(segId, wi)) | |
| } | |
| function dotsFor(w) { | |
| return '•'.repeat(Math.max(3, Math.min(8, w.length))) | |
| } | |
| /** Scroll active card to a fixed anchor — keep full card visible below scroll top. */ | |
| const SCROLL_ANCHOR_PX = 132 | |
| function scrollActiveToAnchor(idx) { | |
| const el = cardRefs.value[idx] | |
| const container = listEl.value | |
| if (!el || !container) return | |
| const containerRect = container.getBoundingClientRect() | |
| const elRect = el.getBoundingClientRect() | |
| const delta = elRect.top - containerRect.top - SCROLL_ANCHOR_PX | |
| if (Math.abs(delta) < 2) return | |
| container.scrollBy({ top: delta, behavior: 'smooth' }) | |
| } | |
| watch( | |
| () => props.activeIndex, | |
| async (idx) => { | |
| await nextTick() | |
| scrollActiveToAnchor(idx) | |
| }, | |
| ) | |
| </script> | |