update at 2026-02-07 15:05:34

This commit is contained in:
douboer
2026-02-07 15:05:34 +08:00
parent be510798ff
commit d5cfaa4605

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue' import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { useFontStore } from '../stores/fontStore' import { useFontStore } from '../stores/fontStore'
import { useUiStore } from '../stores/uiStore' import { useUiStore } from '../stores/uiStore'
import { generateSvg } from '../utils/svg-builder' import { generateSvg } from '../utils/svg-builder'
import type { PreviewItem as PreviewItemType } from '../types/font' import type { PreviewItem as PreviewItemType } from '../types/font'
import type { SvgGenerateResult, FontInfo } from '../types/font'
const fontStore = useFontStore() const fontStore = useFontStore()
const uiStore = useUiStore() const uiStore = useUiStore()
@@ -17,13 +18,29 @@ const fontSize = computed(() => uiStore.fontSize)
const fillColor = computed(() => uiStore.textColor) const fillColor = computed(() => uiStore.textColor)
const PREVIEW_DEBOUNCE_MS = 240 const PREVIEW_DEBOUNCE_MS = 240
const PREVIEW_CONCURRENCY = 4 const PREVIEW_CONCURRENCY = 4
const PREVIEW_GEOMETRY_CACHE_LIMIT = 600
const PREVIEW_COLOR_TOKEN = '__FONT2SVG_FILL__'
const PREVIEW_BATCH_SIZE = 20
const PREVIEW_PREFETCH_OFFSET = 10
let previewGenerateTimer: ReturnType<typeof setTimeout> | null = null let previewGenerateTimer: ReturnType<typeof setTimeout> | null = null
let previewGenerationToken = 0 let previewGenerationToken = 0
let hasTriggeredInitialGenerate = false let hasTriggeredInitialGenerate = false
let previewLazyLoadObserver: IntersectionObserver | null = null
const previewGeometryCache = new Map<string, Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string }>()
const renderedPreviewCount = ref(0)
const previewTriggerItemEl = ref<HTMLElement | null>(null)
const isAllPreviewSelected = computed(() => { const isAllPreviewSelected = computed(() => {
return previewItems.value.length > 0 && previewItems.value.every(item => item.selected) return previewItems.value.length > 0 && previewItems.value.every(item => item.selected)
}) })
const hasMorePreviewItems = computed(() => renderedPreviewCount.value < previewItems.value.length)
const visiblePreviewItems = computed(() => previewItems.value.slice(0, renderedPreviewCount.value))
const previewTriggerIndex = computed(() => {
if (!hasMorePreviewItems.value || renderedPreviewCount.value <= 0) {
return -1
}
return Math.max(0, renderedPreviewCount.value - PREVIEW_PREFETCH_OFFSET)
})
function isStaleGeneration(token: number): boolean { function isStaleGeneration(token: number): boolean {
return token !== previewGenerationToken return token !== previewGenerationToken
@@ -46,6 +63,139 @@ function scheduleGeneratePreviews(withDebounce = true) {
}, PREVIEW_DEBOUNCE_MS) }, PREVIEW_DEBOUNCE_MS)
} }
function findScrollableParent(el: HTMLElement): HTMLElement | null {
let current: HTMLElement | null = el.parentElement
while (current) {
const style = window.getComputedStyle(current)
const overflowY = style.overflowY
if ((overflowY === 'auto' || overflowY === 'scroll') && current.scrollHeight > current.clientHeight) {
return current
}
current = current.parentElement
}
return null
}
function disconnectPreviewLazyLoadObserver() {
if (!previewLazyLoadObserver) {
return
}
previewLazyLoadObserver.disconnect()
previewLazyLoadObserver = null
}
function loadNextPreviewBatch() {
if (!hasMorePreviewItems.value) {
return
}
renderedPreviewCount.value = Math.min(
renderedPreviewCount.value + PREVIEW_BATCH_SIZE,
previewItems.value.length,
)
}
function bindPreviewLazyLoadObserver() {
disconnectPreviewLazyLoadObserver()
if (!hasMorePreviewItems.value || !previewTriggerItemEl.value) {
return
}
const root = findScrollableParent(previewTriggerItemEl.value)
previewLazyLoadObserver = new IntersectionObserver(
(entries) => {
const isVisible = entries.some(entry => entry.isIntersecting)
if (isVisible) {
loadNextPreviewBatch()
}
},
{
root,
threshold: 0.01,
},
)
previewLazyLoadObserver.observe(previewTriggerItemEl.value)
}
function setPreviewItemRef(el: unknown, index: number) {
if (index !== previewTriggerIndex.value) {
return
}
previewTriggerItemEl.value = el instanceof HTMLElement ? el : null
}
function getPreviewGeometryCacheKey(fontInfo: FontInfo): string {
return [
fontInfo.id,
fontInfo.path,
inputText.value,
fontSize.value,
].join('::')
}
function readPreviewGeometryFromCache(key: string) {
const cached = previewGeometryCache.get(key)
if (!cached) {
return null
}
// LRU: 读取后刷新顺序
previewGeometryCache.delete(key)
previewGeometryCache.set(key, cached)
return cached
}
function writePreviewGeometryToCache(
key: string,
geometry: Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string },
) {
if (previewGeometryCache.has(key)) {
previewGeometryCache.delete(key)
}
previewGeometryCache.set(key, geometry)
while (previewGeometryCache.size > PREVIEW_GEOMETRY_CACHE_LIMIT) {
const oldestKey = previewGeometryCache.keys().next().value
if (oldestKey === undefined) {
break
}
previewGeometryCache.delete(oldestKey)
}
}
async function generateSvgWithPreviewCache(fontInfo: FontInfo): Promise<SvgGenerateResult | null> {
if (!fontInfo.font) {
return null
}
const cacheKey = getPreviewGeometryCacheKey(fontInfo)
let geometry = readPreviewGeometryFromCache(cacheKey)
if (!geometry) {
const baseResult = await generateSvg({
text: inputText.value,
font: fontInfo.font,
fontSize: fontSize.value,
fillColor: PREVIEW_COLOR_TOKEN,
})
geometry = {
width: baseResult.width,
height: baseResult.height,
fontName: baseResult.fontName,
svgTemplate: baseResult.svg,
}
writePreviewGeometryToCache(cacheKey, geometry)
}
return {
width: geometry.width,
height: geometry.height,
fontName: geometry.fontName,
svg: geometry.svgTemplate.split(PREVIEW_COLOR_TOKEN).join(fillColor.value),
}
}
watch( watch(
[previewFonts, inputText, fontSize, fillColor], [previewFonts, inputText, fontSize, fillColor],
() => { () => {
@@ -55,11 +205,30 @@ watch(
{ immediate: true } { immediate: true }
) )
watch(
previewItems,
() => {
renderedPreviewCount.value = Math.min(PREVIEW_BATCH_SIZE, previewItems.value.length)
},
{ immediate: true },
)
watch(
[previewTriggerIndex, hasMorePreviewItems],
async () => {
previewTriggerItemEl.value = null
await nextTick()
bindPreviewLazyLoadObserver()
},
{ immediate: true },
)
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (previewGenerateTimer !== null) { if (previewGenerateTimer !== null) {
clearTimeout(previewGenerateTimer) clearTimeout(previewGenerateTimer)
previewGenerateTimer = null previewGenerateTimer = null
} }
disconnectPreviewLazyLoadObserver()
previewGenerationToken += 1 previewGenerationToken += 1
}) })
@@ -122,12 +291,10 @@ async function generatePreviews() {
} }
try { try {
const svgResult = await generateSvg({ const svgResult = await generateSvgWithPreviewCache(fontInfo)
text: inputText.value, if (!svgResult) {
font: fontInfo.font, continue
fontSize: fontSize.value, }
fillColor: fillColor.value,
})
if (isStaleGeneration(generationToken)) { if (isStaleGeneration(generationToken)) {
return return
@@ -197,8 +364,9 @@ defineExpose({
<div v-else class="flex flex-col gap-2"> <div v-else class="flex flex-col gap-2">
<div <div
v-for="item in previewItems" v-for="(item, index) in visiblePreviewItems"
:key="item.fontInfo.id" :key="item.fontInfo.id"
:ref="(el) => setPreviewItemRef(el, index)"
class="flex flex-col gap-2" class="flex flex-col gap-2"
> >
<div class="flex items-center gap-[8px] border-b border-[#c9cdd4] pb-[8px] pr-[8px]"> <div class="flex items-center gap-[8px] border-b border-[#c9cdd4] pb-[8px] pr-[8px]">
@@ -226,6 +394,10 @@ defineExpose({
<div v-html="item.svgResult.svg" class="svg-preview-container"></div> <div v-html="item.svgResult.svg" class="svg-preview-container"></div>
</div> </div>
</div> </div>
<div v-if="hasMorePreviewItems" class="text-xs text-[#86909c] text-center py-2">
继续下滑加载更多{{ visiblePreviewItems.length }}/{{ previewItems.length }}
</div>
</div> </div>
</div> </div>
</template> </template>