移除虚拟滚动

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 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>

View File

@ -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;