移除虚拟滚动

This commit is contained in:
kura 2026-05-01 20:30:17 +08:00
parent 0247f7f510
commit 68c1706e4a
2 changed files with 25 additions and 168 deletions

View File

@ -2,21 +2,6 @@
import { computed, ref, nextTick, watch } from 'vue' import { computed, ref, nextTick, watch } from 'vue'
import type { SubtitleSegment, SubtitleTask } from '../lib/types' 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<{ const props = defineProps<{
task: SubtitleTask | null task: SubtitleTask | null
logs: string[] logs: string[]
@ -56,129 +41,14 @@ const sortedSegments = computed(() =>
const logsExpanded = ref(false) const logsExpanded = ref(false)
const logContainerRef = ref<HTMLElement | null>(null) 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, () => { watch(() => props.logs.length, () => {
if (isNearBottom.value) { nextTick(() => {
nextTick(() => { if (logContainerRef.value) {
if (logContainerRef.value) { logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
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)
}
} }
}) })
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) { function formatTime(seconds: number) {
const ms = Math.round(seconds * 1000) const ms = Math.round(seconds * 1000)
@ -227,28 +97,25 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<template v-else>{{ $t('editor.noSegments') }}</template> <template v-else>{{ $t('editor.noSegments') }}</template>
</div> </div>
<div v-else ref="segContainerRef" class="segment-list" @scroll="handleSegScroll"> <div v-else class="segment-list">
<div class="seg-spacer" :style="{ paddingTop: `${visibleSegments.paddingTop}px`, paddingBottom: `${visibleSegments.paddingBottom}px` }"> <article
<article v-for="segment in sortedSegments"
v-for="segment in visibleSegments.items" :key="segment.id"
:key="segment.id" class="segment-item"
: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>
<div class="task-row subtle"> <span class="segment-id">{{ segment.id }}</span>
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span> </div>
<span class="segment-id">{{ segment.id }}</span> <p class="source-text">{{ segment.sourceText || $t('editor.waiting') }}</p>
</div> <textarea
<p class="source-text">{{ segment.sourceText || $t('editor.waiting') }}</p> class="editor-input"
<textarea :value="segment.translatedText ?? ''"
class="editor-input" :placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')"
:value="segment.translatedText ?? ''" :disabled="task.outputMode === 'source'"
:placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')" @change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
:disabled="task.outputMode === 'source'" />
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)" </article>
/>
</article>
</div>
</div> </div>
<div class="log-drawer" :class="{ expanded: logsExpanded }"> <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"> <div v-if="logs.length === 0" class="empty-state">
{{ $t('editor.noLogs') }} {{ $t('editor.noLogs') }}
</div> </div>
<div v-else ref="logContainerRef" class="log-list" @scroll="handleLogScroll"> <div v-else ref="logContainerRef" class="log-list">
<div class="log-spacer" :style="{ paddingTop: `${visibleLogs.paddingTop}px`, paddingBottom: `${visibleLogs.paddingBottom}px` }"> <pre v-for="(line, i) in logs" :key="i" class="log-line">{{ line }}</pre>
<pre v-for="(line, i) in visibleLogs.items" :key="visibleLogs.start + i" class="log-line">{{ line }}</pre>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -394,10 +394,6 @@ textarea {
will-change: scroll-position; will-change: scroll-position;
} }
.seg-spacer {
min-width: 100%;
}
.segment-item + .segment-item { .segment-item + .segment-item {
margin-top: 8px; margin-top: 8px;
} }
@ -713,10 +709,6 @@ textarea {
will-change: scroll-position; will-change: scroll-position;
} }
.log-spacer {
min-width: 100%;
}
.log-line { .log-line {
margin: 0; margin: 0;
white-space: pre-wrap; white-space: pre-wrap;