移除虚拟滚动
This commit is contained in:
parent
0247f7f510
commit
68c1706e4a
@ -2,21 +2,6 @@
|
||||
import { computed, ref, nextTick, watch } from 'vue'
|
||||
import type { SubtitleSegment, SubtitleTask } from '../lib/types'
|
||||
|
||||
const LOG_ROW_HEIGHT = 20
|
||||
const LOG_OVERSCAN = 10
|
||||
|
||||
const SEGMENT_TIMESTAMP_HEIGHT = 20
|
||||
const SEGMENT_TEXTAREA_HEIGHT = 60
|
||||
const SEGMENT_PADDING_GAP = 34
|
||||
const SEGMENT_CHARS_PER_LINE = 40
|
||||
const SEGMENT_LINE_HEIGHT = 20
|
||||
const SEGMENT_OVERSCAN = 5
|
||||
|
||||
function estimateSegmentHeight(seg: SubtitleSegment): number {
|
||||
const textLines = Math.max(1, Math.ceil((seg.sourceText?.length || 1) / SEGMENT_CHARS_PER_LINE))
|
||||
return SEGMENT_TIMESTAMP_HEIGHT + textLines * SEGMENT_LINE_HEIGHT + SEGMENT_TEXTAREA_HEIGHT + SEGMENT_PADDING_GAP
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
task: SubtitleTask | null
|
||||
logs: string[]
|
||||
@ -56,129 +41,14 @@ const sortedSegments = computed(() =>
|
||||
const logsExpanded = ref(false)
|
||||
|
||||
const logContainerRef = ref<HTMLElement | null>(null)
|
||||
const logScrollTop = ref(0)
|
||||
const isNearBottom = ref(true)
|
||||
|
||||
const visibleLogs = computed(() => {
|
||||
const total = props.logs.length
|
||||
if (total === 0) return { items: [] as string[], start: 0, paddingTop: 0, paddingBottom: 0 }
|
||||
|
||||
const containerHeight = logContainerRef.value?.clientHeight ?? 220
|
||||
const startIndex = Math.max(0, Math.floor(logScrollTop.value / LOG_ROW_HEIGHT) - LOG_OVERSCAN)
|
||||
const visibleCount = Math.ceil(containerHeight / LOG_ROW_HEIGHT) + LOG_OVERSCAN * 2
|
||||
const endIndex = Math.min(total, startIndex + visibleCount)
|
||||
|
||||
return {
|
||||
items: props.logs.slice(startIndex, endIndex),
|
||||
start: startIndex,
|
||||
paddingTop: startIndex * LOG_ROW_HEIGHT,
|
||||
paddingBottom: Math.max(0, total * LOG_ROW_HEIGHT - endIndex * LOG_ROW_HEIGHT),
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogScroll(event: Event) {
|
||||
const el = event.target as HTMLElement
|
||||
logScrollTop.value = el.scrollTop
|
||||
isNearBottom.value = el.scrollTop + el.clientHeight >= el.scrollHeight - 40
|
||||
}
|
||||
|
||||
watch(() => props.logs.length, () => {
|
||||
if (isNearBottom.value) {
|
||||
nextTick(() => {
|
||||
if (logContainerRef.value) {
|
||||
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
|
||||
logScrollTop.value = logContainerRef.value.scrollTop
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const segContainerRef = ref<HTMLElement | null>(null)
|
||||
const segScrollTop = ref(0)
|
||||
const segHeights = ref(new Map<string, number>())
|
||||
const segObservers = new Map<string, ResizeObserver>()
|
||||
|
||||
const segOffsetCache = computed(() => {
|
||||
const segments = sortedSegments.value
|
||||
const cache = new Map<string, { height: number; offset: number }>()
|
||||
let offset = 0
|
||||
for (const seg of segments) {
|
||||
const height = segHeights.value.get(seg.id) ?? estimateSegmentHeight(seg)
|
||||
cache.set(seg.id, { height, offset })
|
||||
offset += height + 8
|
||||
}
|
||||
return { cache, totalHeight: Math.max(0, offset - 8) }
|
||||
})
|
||||
|
||||
const visibleSegments = computed(() => {
|
||||
const segments = sortedSegments.value
|
||||
if (segments.length === 0) return { items: [] as SubtitleSegment[], paddingTop: 0, paddingBottom: 0 }
|
||||
|
||||
const containerHeight = segContainerRef.value?.clientHeight ?? 600
|
||||
const { cache, totalHeight } = segOffsetCache.value
|
||||
|
||||
let startIndex = segments.length - 1
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const info = cache.get(segments[i].id)!
|
||||
if (info.offset + info.height > segScrollTop.value) {
|
||||
startIndex = Math.max(0, i - SEGMENT_OVERSCAN)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let endIndex = segments.length
|
||||
for (let i = startIndex; i < segments.length; i++) {
|
||||
const info = cache.get(segments[i].id)!
|
||||
if (info.offset > segScrollTop.value + containerHeight) {
|
||||
endIndex = Math.min(segments.length, i + SEGMENT_OVERSCAN)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const startOffset = cache.get(segments[startIndex].id)?.offset ?? 0
|
||||
const endInfo = cache.get(segments[Math.min(endIndex - 1, segments.length - 1)].id)
|
||||
const endOffset = endInfo ? endInfo.offset + endInfo.height : totalHeight
|
||||
const paddingBottom = Math.max(0, totalHeight - endOffset)
|
||||
|
||||
return {
|
||||
items: segments.slice(startIndex, endIndex),
|
||||
paddingTop: startOffset,
|
||||
paddingBottom,
|
||||
}
|
||||
})
|
||||
|
||||
function handleSegScroll(event: Event) {
|
||||
const el = event.target as HTMLElement
|
||||
segScrollTop.value = el.scrollTop
|
||||
}
|
||||
|
||||
function onSegMount(el: HTMLElement, segmentId: string) {
|
||||
const existing = segObservers.get(segmentId)
|
||||
if (existing) existing.disconnect()
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const actualHeight = entry.contentBoxSize
|
||||
? entry.contentBoxSize[0].blockSize
|
||||
: entry.borderBoxSize
|
||||
? entry.borderBoxSize[0].blockSize
|
||||
: (entry.target as HTMLElement).offsetHeight
|
||||
if (actualHeight > 0) {
|
||||
segHeights.value = new Map(segHeights.value).set(segmentId, actualHeight)
|
||||
}
|
||||
nextTick(() => {
|
||||
if (logContainerRef.value) {
|
||||
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
segObservers.set(segmentId, ro)
|
||||
}
|
||||
|
||||
function onSegUnmount(segmentId: string) {
|
||||
const ro = segObservers.get(segmentId)
|
||||
if (ro) {
|
||||
ro.disconnect()
|
||||
segObservers.delete(segmentId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const ms = Math.round(seconds * 1000)
|
||||
@ -227,28 +97,25 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
||||
<template v-else>{{ $t('editor.noSegments') }}</template>
|
||||
</div>
|
||||
|
||||
<div v-else ref="segContainerRef" class="segment-list" @scroll="handleSegScroll">
|
||||
<div class="seg-spacer" :style="{ paddingTop: `${visibleSegments.paddingTop}px`, paddingBottom: `${visibleSegments.paddingBottom}px` }">
|
||||
<article
|
||||
v-for="segment in visibleSegments.items"
|
||||
:key="segment.id"
|
||||
:ref="(el: any) => { if (el) onSegMount(el as HTMLElement, segment.id); else onSegUnmount(segment.id) }"
|
||||
class="segment-item"
|
||||
>
|
||||
<div class="task-row subtle">
|
||||
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span>
|
||||
<span class="segment-id">{{ segment.id }}</span>
|
||||
</div>
|
||||
<p class="source-text">{{ segment.sourceText || $t('editor.waiting') }}</p>
|
||||
<textarea
|
||||
class="editor-input"
|
||||
:value="segment.translatedText ?? ''"
|
||||
:placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')"
|
||||
:disabled="task.outputMode === 'source'"
|
||||
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="segment-list">
|
||||
<article
|
||||
v-for="segment in sortedSegments"
|
||||
:key="segment.id"
|
||||
class="segment-item"
|
||||
>
|
||||
<div class="task-row subtle">
|
||||
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span>
|
||||
<span class="segment-id">{{ segment.id }}</span>
|
||||
</div>
|
||||
<p class="source-text">{{ segment.sourceText || $t('editor.waiting') }}</p>
|
||||
<textarea
|
||||
class="editor-input"
|
||||
:value="segment.translatedText ?? ''"
|
||||
:placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')"
|
||||
:disabled="task.outputMode === 'source'"
|
||||
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="log-drawer" :class="{ expanded: logsExpanded }">
|
||||
@ -261,10 +128,8 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
||||
<div v-if="logs.length === 0" class="empty-state">
|
||||
{{ $t('editor.noLogs') }}
|
||||
</div>
|
||||
<div v-else ref="logContainerRef" class="log-list" @scroll="handleLogScroll">
|
||||
<div class="log-spacer" :style="{ paddingTop: `${visibleLogs.paddingTop}px`, paddingBottom: `${visibleLogs.paddingBottom}px` }">
|
||||
<pre v-for="(line, i) in visibleLogs.items" :key="visibleLogs.start + i" class="log-line">{{ line }}</pre>
|
||||
</div>
|
||||
<div v-else ref="logContainerRef" class="log-list">
|
||||
<pre v-for="(line, i) in logs" :key="i" class="log-line">{{ line }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -394,10 +394,6 @@ textarea {
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
.seg-spacer {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.segment-item + .segment-item {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@ -713,10 +709,6 @@ textarea {
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
.log-spacer {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user