移除虚拟滚动
This commit is contained in:
parent
0247f7f510
commit
68c1706e4a
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user