Spaces:
Paused
Paused
File size: 6,081 Bytes
02f8344 3092643 02f8344 3092643 02f8344 3092643 02f8344 3092643 02f8344 3092643 02f8344 3092643 02f8344 b3e4ba6 02f8344 b3e4ba6 02f8344 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | <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>
|