|
| 1 | +<script setup lang="ts"> |
| 2 | +import { computed, nextTick, ref, watch } from 'vue'; |
| 3 | +import { type EafDocument, parseEaf } from '@/composables/useEafParser'; |
| 4 | +
|
| 5 | +const props = defineProps<{ |
| 6 | + src: string; |
| 7 | + currentTime?: number; |
| 8 | + showHeader?: boolean; |
| 9 | +}>(); |
| 10 | +
|
| 11 | +const emit = defineEmits<{ |
| 12 | + seek: [seconds: number]; |
| 13 | +}>(); |
| 14 | +
|
| 15 | +const eafDoc = ref<EafDocument>(); |
| 16 | +const selectedTierIds = ref<string[]>([]); |
| 17 | +const loading = ref(true); |
| 18 | +
|
| 19 | +const tiers = computed(() => eafDoc.value?.tiers.filter((t) => t.annotations.length > 0) ?? []); |
| 20 | +
|
| 21 | +type MergedRow = { |
| 22 | + startMs: number; |
| 23 | + endMs: number; |
| 24 | + texts: { tierId: string; value: string }[]; |
| 25 | +}; |
| 26 | +
|
| 27 | +const selectedTiers = computed(() => tiers.value.filter((t) => selectedTierIds.value.includes(t.tierId))); |
| 28 | +
|
| 29 | +const mergedRows = computed<MergedRow[]>(() => { |
| 30 | + const groups = new Map<string, MergedRow>(); |
| 31 | +
|
| 32 | + for (const tier of selectedTiers.value) { |
| 33 | + for (const ann of tier.annotations) { |
| 34 | + const key = `${ann.startMs}-${ann.endMs}`; |
| 35 | +
|
| 36 | + let group = groups.get(key); |
| 37 | + if (!group) { |
| 38 | + group = { startMs: ann.startMs, endMs: ann.endMs, texts: [] }; |
| 39 | + groups.set(key, group); |
| 40 | + } |
| 41 | +
|
| 42 | + group.texts.push({ tierId: tier.tierId, value: ann.value }); |
| 43 | + } |
| 44 | + } |
| 45 | +
|
| 46 | + return Array.from(groups.values()).sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs); |
| 47 | +}); |
| 48 | +
|
| 49 | +const showTierLabels = computed(() => selectedTierIds.value.length > 1); |
| 50 | +
|
| 51 | +const languageLabelMap = computed(() => { |
| 52 | + const map = new Map<string, string>(); |
| 53 | + if (!eafDoc.value) { |
| 54 | + return map; |
| 55 | + } |
| 56 | + for (const lang of eafDoc.value.languages) { |
| 57 | + map.set(lang.langId, lang.langLabel || lang.langId); |
| 58 | + } |
| 59 | + return map; |
| 60 | +}); |
| 61 | +
|
| 62 | +const fetchAndParse = async (url: string) => { |
| 63 | + loading.value = true; |
| 64 | +
|
| 65 | + try { |
| 66 | + const response = await fetch(url); |
| 67 | + if (!response.ok) { |
| 68 | + return; |
| 69 | + } |
| 70 | +
|
| 71 | + const xml = await response.text(); |
| 72 | + eafDoc.value = parseEaf(xml); |
| 73 | + const nonEmpty = tiers.value; |
| 74 | + if (nonEmpty.length > 0) { |
| 75 | + selectedTierIds.value = [nonEmpty[0].tierId]; |
| 76 | + } |
| 77 | + } finally { |
| 78 | + loading.value = false; |
| 79 | + } |
| 80 | +}; |
| 81 | +
|
| 82 | +watch( |
| 83 | + () => props.src, |
| 84 | + (url) => { |
| 85 | + if (url) { |
| 86 | + fetchAndParse(url); |
| 87 | + } |
| 88 | + }, |
| 89 | + { immediate: true }, |
| 90 | +); |
| 91 | +
|
| 92 | +const currentTimeMs = computed(() => (props.currentTime ?? -1) * 1000); |
| 93 | +
|
| 94 | +const formatTime = (ms: number): string => { |
| 95 | + const totalSeconds = Math.floor(ms / 1000); |
| 96 | + const minutes = Math.floor(totalSeconds / 60); |
| 97 | + const seconds = totalSeconds % 60; |
| 98 | + const millis = Math.floor((ms % 1000) / 10); |
| 99 | + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(2, '0')}`; |
| 100 | +}; |
| 101 | +
|
| 102 | +const isActive = (startMs: number, endMs: number): boolean => { |
| 103 | + if (props.currentTime === undefined) { |
| 104 | + return false; |
| 105 | + } |
| 106 | +
|
| 107 | + return currentTimeMs.value >= startMs && currentTimeMs.value < endMs; |
| 108 | +}; |
| 109 | +
|
| 110 | +const tableRef = ref<InstanceType<typeof import('element-plus')['ElTable']>>(); |
| 111 | +
|
| 112 | +const activeRowIndex = computed(() => { |
| 113 | + if (props.currentTime === undefined) { |
| 114 | + return -1; |
| 115 | + } |
| 116 | +
|
| 117 | + return mergedRows.value.findIndex((r) => isActive(r.startMs, r.endMs)); |
| 118 | +}); |
| 119 | +
|
| 120 | +watch(activeRowIndex, (index) => { |
| 121 | + if (index < 0) { |
| 122 | + return; |
| 123 | + } |
| 124 | +
|
| 125 | + nextTick(() => { |
| 126 | + const tableEl = tableRef.value?.$el as HTMLElement | undefined; |
| 127 | + if (!tableEl) { |
| 128 | + return; |
| 129 | + } |
| 130 | +
|
| 131 | + // Element Plus uses el-scrollbar inside the body wrapper |
| 132 | + const scrollViewport = tableEl.querySelector('.el-table__body-wrapper .el-scrollbar__wrap') as HTMLElement | null; |
| 133 | + const rows = tableEl.querySelectorAll('.el-table__body tbody tr'); |
| 134 | + if (!scrollViewport || !rows?.[index]) { |
| 135 | + return; |
| 136 | + } |
| 137 | +
|
| 138 | + const row = rows[index] as HTMLElement; |
| 139 | + const viewportHeight = scrollViewport.clientHeight; |
| 140 | + const rowTop = row.offsetTop; |
| 141 | + const rowBottom = rowTop + row.offsetHeight; |
| 142 | + const scrollTop = scrollViewport.scrollTop; |
| 143 | + const scrollBottom = scrollTop + viewportHeight; |
| 144 | +
|
| 145 | + // If row is outside visible area, scroll to centre it |
| 146 | + if (rowBottom > scrollBottom || rowTop < scrollTop) { |
| 147 | + scrollViewport.scrollTo({ |
| 148 | + top: rowTop - viewportHeight / 2, |
| 149 | + behavior: 'smooth', |
| 150 | + }); |
| 151 | + } |
| 152 | + }); |
| 153 | +}); |
| 154 | +
|
| 155 | +const handleRowClick = (row: MergedRow) => { |
| 156 | + emit('seek', row.startMs / 1000); |
| 157 | +}; |
| 158 | +
|
| 159 | +const tableRowClassName = ({ rowIndex }: { row: MergedRow; rowIndex: number }) => { |
| 160 | + return rowIndex === activeRowIndex.value ? 'eaf-active-row' : ''; |
| 161 | +}; |
| 162 | +</script> |
| 163 | + |
| 164 | +<template> |
| 165 | + <div v-if="loading" class="p-4 text-center"> |
| 166 | + <el-skeleton :rows="5" animated /> |
| 167 | + </div> |
| 168 | + <div v-else-if="tiers.length > 0" class="w-full"> |
| 169 | + <div v-if="props.showHeader && eafDoc" class="mb-3 flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600"> |
| 170 | + <div v-if="eafDoc.author"><span class="font-semibold">Author:</span> {{ eafDoc.author }}</div> |
| 171 | + <div v-if="eafDoc.date"><span class="font-semibold">Date:</span> {{ new |
| 172 | + Date(eafDoc.date).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) }}</div> |
| 173 | + <div v-if="eafDoc.version"><span class="font-semibold">Version:</span> {{ eafDoc.version }}</div> |
| 174 | + <div v-if="eafDoc.format"><span class="font-semibold">Format:</span> {{ eafDoc.format }}</div> |
| 175 | + <div v-if="eafDoc.languages.length"> |
| 176 | + <span class="font-semibold">Languages:</span> |
| 177 | + {{eafDoc.languages.map((l) => l.langLabel || l.langId).join(', ')}} |
| 178 | + </div> |
| 179 | + </div> |
| 180 | + |
| 181 | + <div class="flex" style="height: 400px;"> |
| 182 | + <div v-if="tiers.length > 1" class="pr-4 shrink-0 overflow-y-auto"> |
| 183 | + <h4 class="text-lg font-semibold mb-2">Tiers ({{ tiers.length }})</h4> |
| 184 | + <el-checkbox :model-value="selectedTierIds.length === tiers.length" |
| 185 | + :indeterminate="selectedTierIds.length > 0 && selectedTierIds.length < tiers.length" class="mb-2" |
| 186 | + @change="(val: boolean) => selectedTierIds = val ? tiers.map((t) => t.tierId) : []"> |
| 187 | + Select all |
| 188 | + </el-checkbox> |
| 189 | + <el-checkbox-group v-model="selectedTierIds" class="flex flex-col gap-2"> |
| 190 | + <div v-for="tier in tiers" :key="tier.tierId"> |
| 191 | + <el-checkbox :label="tier.tierId" :value="tier.tierId" /> |
| 192 | + <div class="ml-6 text-xs text-gray-500"> |
| 193 | + <div v-if="tier.annotations.length">Start: {{ formatTime(tier.annotations[0].startMs) }}</div> |
| 194 | + <div v-if="tier.participant">{{ tier.participant }}</div> |
| 195 | + <div v-if="tier.annotator">Annotator: {{ tier.annotator }}</div> |
| 196 | + <div v-if="tier.langRef && languageLabelMap.get(tier.langRef)">{{ languageLabelMap.get(tier.langRef) }} |
| 197 | + </div> |
| 198 | + </div> |
| 199 | + </div> |
| 200 | + </el-checkbox-group> |
| 201 | + </div> |
| 202 | + |
| 203 | + <div class="flex-1 min-w-0"> |
| 204 | + <el-table ref="tableRef" :data="mergedRows" :row-class-name="tableRowClassName" @row-click="handleRowClick" |
| 205 | + class="cursor-pointer" height="100%"> |
| 206 | + <el-table-column label="Start" width="120"> |
| 207 | + <template #default="{ row }">{{ formatTime(row.startMs) }}</template> |
| 208 | + </el-table-column> |
| 209 | + <el-table-column label="End" width="120"> |
| 210 | + <template #default="{ row }">{{ formatTime(row.endMs) }}</template> |
| 211 | + </el-table-column> |
| 212 | + <el-table-column label="Text"> |
| 213 | + <template #default="{ row }"> |
| 214 | + <div v-for="(text, i) in row.texts" :key="i"> |
| 215 | + <span v-if="showTierLabels" class="text-xs text-gray-400 mr-1">{{ text.tierId }}:</span> |
| 216 | + <span>{{ text.value }}</span> |
| 217 | + </div> |
| 218 | + </template> |
| 219 | + </el-table-column> |
| 220 | + </el-table> |
| 221 | + </div> |
| 222 | + </div> |
| 223 | + </div> |
| 224 | +</template> |
| 225 | + |
| 226 | +<style> |
| 227 | +.eaf-active-row { |
| 228 | + --el-table-tr-bg-color: var(--el-color-primary-light-8); |
| 229 | + font-weight: bold; |
| 230 | +} |
| 231 | +</style> |
0 commit comments