From 0f7ade6945165fb6e2786263d3c4a2606cd7b902 Mon Sep 17 00:00:00 2001 From: douboer Date: Sat, 7 Feb 2026 15:11:16 +0800 Subject: [PATCH] update at 2026-02-07 15:11:16 --- frontend/src/components/SvgPreview.vue | 329 +++++++++++++++---------- 1 file changed, 198 insertions(+), 131 deletions(-) diff --git a/frontend/src/components/SvgPreview.vue b/frontend/src/components/SvgPreview.vue index ddf4ec2..e2662d1 100644 --- a/frontend/src/components/SvgPreview.vue +++ b/frontend/src/components/SvgPreview.vue @@ -11,35 +11,51 @@ const uiStore = useUiStore() const previewItems = ref([]) const isGenerating = ref(false) +const isBatchGenerating = ref(false) +const activePreviewFonts = ref([]) +const processedFontCount = ref(0) +const renderedPreviewCount = ref(0) +const previewTriggerItemEl = ref(null) const previewFonts = computed(() => fontStore.previewFonts) const inputText = computed(() => uiStore.inputText) const fontSize = computed(() => uiStore.fontSize) const fillColor = computed(() => uiStore.textColor) + const PREVIEW_DEBOUNCE_MS = 240 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 | null = null let previewGenerationToken = 0 let hasTriggeredInitialGenerate = false let previewLazyLoadObserver: IntersectionObserver | null = null + const previewGeometryCache = new Map & { svgTemplate: string }>() -const renderedPreviewCount = ref(0) -const previewTriggerItemEl = ref(null) const isAllPreviewSelected = computed(() => { 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 hasMorePreviewItems = computed(() => { + return ( + renderedPreviewCount.value < previewItems.value.length || + processedFontCount.value < activePreviewFonts.value.length + ) +}) + +const visiblePreviewItems = computed(() => { + return previewItems.value.slice(0, renderedPreviewCount.value) +}) + const previewTriggerIndex = computed(() => { - if (!hasMorePreviewItems.value || renderedPreviewCount.value <= 0) { + if (!hasMorePreviewItems.value || visiblePreviewItems.value.length <= 0) { return -1 } - return Math.max(0, renderedPreviewCount.value - PREVIEW_PREFETCH_OFFSET) + return Math.max(0, visiblePreviewItems.value.length - PREVIEW_PREFETCH_OFFSET) }) function isStaleGeneration(token: number): boolean { @@ -53,13 +69,13 @@ function scheduleGeneratePreviews(withDebounce = true) { } if (!withDebounce) { - void generatePreviews() + void regeneratePreviews() return } previewGenerateTimer = setTimeout(() => { previewGenerateTimer = null - void generatePreviews() + void regeneratePreviews() }, PREVIEW_DEBOUNCE_MS) } @@ -84,16 +100,6 @@ function disconnectPreviewLazyLoadObserver() { previewLazyLoadObserver = null } -function loadNextPreviewBatch() { - if (!hasMorePreviewItems.value) { - return - } - renderedPreviewCount.value = Math.min( - renderedPreviewCount.value + PREVIEW_BATCH_SIZE, - previewItems.value.length, - ) -} - function bindPreviewLazyLoadObserver() { disconnectPreviewLazyLoadObserver() @@ -106,7 +112,7 @@ function bindPreviewLazyLoadObserver() { (entries) => { const isVisible = entries.some(entry => entry.isIntersecting) if (isVisible) { - loadNextPreviewBatch() + void handleLoadMoreByScroll() } }, { @@ -126,12 +132,7 @@ function setPreviewItemRef(el: unknown, index: number) { } function getPreviewGeometryCacheKey(fontInfo: FontInfo): string { - return [ - fontInfo.id, - fontInfo.path, - inputText.value, - fontSize.value, - ].join('::') + return [fontInfo.id, fontInfo.path, inputText.value, fontSize.value].join('::') } function readPreviewGeometryFromCache(key: string) { @@ -140,7 +141,6 @@ function readPreviewGeometryFromCache(key: string) { return null } - // LRU: 读取后刷新顺序 previewGeometryCache.delete(key) previewGeometryCache.set(key, cached) return cached @@ -185,6 +185,7 @@ async function generateSvgWithPreviewCache(fontInfo: FontInfo): Promise { + const endIndex = Math.min(startIndex + batchSize, fonts.length) + const batchFonts = fonts.slice(startIndex, endIndex) + if (batchFonts.length === 0) { + return [] + } + + const selectedFontIdSet = new Set(uiStore.selectedExportItems.map(item => item.fontInfo.id)) + const items = new Array(batchFonts.length).fill(null) + const workerCount = Math.min(PREVIEW_CONCURRENCY, batchFonts.length) + let nextIndex = 0 + + const worker = async () => { + while (true) { + if (isStaleGeneration(generationToken)) { + return + } + + const localIndex = nextIndex + nextIndex += 1 + if (localIndex >= batchFonts.length) { + return + } + + const fontInfo = batchFonts[localIndex] + if (!fontInfo) { + continue + } + + if (!fontInfo.loaded) { + try { + await fontStore.loadFont(fontInfo) + } catch (error) { + console.error(`Failed to load font ${fontInfo.name}:`, error) + continue + } + } + + if (isStaleGeneration(generationToken) || !fontInfo.font) { + continue + } + + try { + const svgResult = await generateSvgWithPreviewCache(fontInfo) + if (!svgResult) { + continue + } + + if (isStaleGeneration(generationToken)) { + return + } + + items[localIndex] = { + fontInfo, + svgResult, + selected: selectedFontIdSet.has(fontInfo.id), + } + } catch (error) { + console.error(`Failed to generate SVG for ${fontInfo.name}:`, error) + } + } + } + + await Promise.all(Array.from({ length: workerCount }, () => worker())) + + if (isStaleGeneration(generationToken)) { + return [] + } + + return items.filter((item): item is PreviewItemType => item !== null) +} + +async function loadNextPreviewBatch(generationToken: number) { + if (isBatchGenerating.value || isStaleGeneration(generationToken)) { + return + } + + const startIndex = processedFontCount.value + if (startIndex >= activePreviewFonts.value.length) { + return + } + + isBatchGenerating.value = true + + try { + const batchItems = await generatePreviewBatch( + activePreviewFonts.value, + startIndex, + PREVIEW_BATCH_SIZE, + generationToken, + ) + + if (isStaleGeneration(generationToken)) { + return + } + + processedFontCount.value = Math.min( + startIndex + PREVIEW_BATCH_SIZE, + activePreviewFonts.value.length, + ) + + previewItems.value = [...previewItems.value, ...batchItems] + renderedPreviewCount.value = Math.min( + renderedPreviewCount.value + PREVIEW_BATCH_SIZE, + previewItems.value.length, + ) + } catch (error) { + console.error('Failed to load preview batch:', error) + } finally { + if (!isStaleGeneration(generationToken)) { + isBatchGenerating.value = false + } + } +} + +async function handleLoadMoreByScroll() { + const generationToken = previewGenerationToken + + if (isBatchGenerating.value || isStaleGeneration(generationToken)) { + return + } + + if (renderedPreviewCount.value < previewItems.value.length) { + renderedPreviewCount.value = Math.min( + renderedPreviewCount.value + PREVIEW_BATCH_SIZE, + previewItems.value.length, + ) + return + } + + if (processedFontCount.value < activePreviewFonts.value.length) { + await loadNextPreviewBatch(generationToken) + } +} + +async function regeneratePreviews() { + const generationToken = ++previewGenerationToken + const nextPreviewFonts = [...previewFonts.value] + const validPreviewFontIds = new Set(nextPreviewFonts.map(font => font.id)) + uiStore.retainExportItemsByFontIds(validPreviewFontIds) + + activePreviewFonts.value = nextPreviewFonts + processedFontCount.value = 0 + previewItems.value = [] + renderedPreviewCount.value = 0 + + if (!inputText.value || inputText.value.trim() === '' || nextPreviewFonts.length === 0) { + isGenerating.value = false + return + } + + isGenerating.value = true + + try { + await loadNextPreviewBatch(generationToken) + } catch (error) { + console.error('Failed to regenerate previews:', error) + } finally { + if (!isStaleGeneration(generationToken)) { + isGenerating.value = false + } + } +} + watch( [previewFonts, inputText, fontSize, fillColor], () => { scheduleGeneratePreviews(hasTriggeredInitialGenerate) hasTriggeredInitialGenerate = true }, - { immediate: true } -) - -watch( - previewItems, - () => { - renderedPreviewCount.value = Math.min(PREVIEW_BATCH_SIZE, previewItems.value.length) - }, { immediate: true }, ) @@ -232,101 +394,6 @@ onBeforeUnmount(() => { previewGenerationToken += 1 }) -async function generatePreviews() { - const generationToken = ++previewGenerationToken - const validPreviewFontIds = new Set(previewFonts.value.map(font => font.id)) - uiStore.retainExportItemsByFontIds(validPreviewFontIds) - - if (!inputText.value || inputText.value.trim() === '') { - if (!isStaleGeneration(generationToken)) { - previewItems.value = [] - } - return - } - - const fonts = previewFonts.value - if (fonts.length === 0) { - if (!isStaleGeneration(generationToken)) { - previewItems.value = [] - } - return - } - - isGenerating.value = true - - try { - const selectedFontIdSet = new Set(uiStore.selectedExportItems.map(item => item.fontInfo.id)) - const items = new Array(fonts.length).fill(null) - const workerCount = Math.min(PREVIEW_CONCURRENCY, fonts.length) - let nextIndex = 0 - - const worker = async () => { - while (true) { - if (isStaleGeneration(generationToken)) { - return - } - - const currentIndex = nextIndex - nextIndex += 1 - if (currentIndex >= fonts.length) { - return - } - - const fontInfo = fonts[currentIndex] - if (!fontInfo) { - continue - } - - if (!fontInfo.loaded) { - try { - await fontStore.loadFont(fontInfo) - } catch (error) { - console.error(`Failed to load font ${fontInfo.name}:`, error) - continue - } - } - - if (isStaleGeneration(generationToken) || !fontInfo.font) { - continue - } - - try { - const svgResult = await generateSvgWithPreviewCache(fontInfo) - if (!svgResult) { - continue - } - - if (isStaleGeneration(generationToken)) { - return - } - - items[currentIndex] = { - fontInfo, - svgResult, - selected: selectedFontIdSet.has(fontInfo.id), - } - } catch (error) { - console.error(`Failed to generate SVG for ${fontInfo.name}:`, error) - } - } - } - - await Promise.all(Array.from({ length: workerCount }, () => worker())) - - if (isStaleGeneration(generationToken)) { - return - } - - previewItems.value = items.filter((item): item is PreviewItemType => item !== null) - } catch (error) { - console.error('Failed to generate previews:', error) - } finally { - if (!isStaleGeneration(generationToken)) { - isGenerating.value = false - } - } -} - function toggleSelectItem(item: PreviewItemType) { item.selected = !item.selected uiStore.toggleExportItem(item) @@ -387,7 +454,7 @@ defineExpose({ -
@@ -396,7 +463,7 @@ defineExpose({
- 继续下滑加载更多({{ visiblePreviewItems.length }}/{{ previewItems.length }}) + {{ isBatchGenerating ? '加载中...' : `继续下滑加载更多(${visiblePreviewItems.length}/${activePreviewFonts.length})` }}