From 15709287dd4f74dccad84b9396e2277bd7948bd1 Mon Sep 17 00:00:00 2001 From: gzz727 Date: Fri, 25 Sep 2026 11:38:00 +0800 Subject: [PATCH 1/2] feat(ux): smart state KeepAlive and completion lifecycle reset across all tools --- src/App.vue | 18 +++++----- src/tools/CompressTool.vue | 11 +++--- src/tools/ImageToPdfTool.vue | 3 ++ src/tools/MergeTool.vue | 12 ++++--- src/tools/OrganizeTool.vue | 23 ++++++++++-- src/tools/PageNumberTool.vue | 6 ++-- src/tools/PdfToImageTool.vue | 26 +++++++++++--- src/tools/PipelineTool.vue | 3 ++ src/tools/ProtectTool.vue | 11 +++--- src/tools/RedactTool.vue | 17 ++++++--- src/tools/SanitizeTool.vue | 11 +++--- src/tools/SignTool.vue | 16 ++++++--- src/tools/SplitTool.vue | 12 ++++--- src/tools/UnlockTool.vue | 11 +++--- src/tools/WatermarkTool.vue | 7 ++-- tests/keepAliveLifecycle.test.js | 62 ++++++++++++++++++++++++++++++++ 16 files changed, 193 insertions(+), 56 deletions(-) create mode 100644 tests/keepAliveLifecycle.test.js diff --git a/src/App.vue b/src/App.vue index 006cc61..eeb8531 100644 --- a/src/App.vue +++ b/src/App.vue @@ -29,14 +29,16 @@ ]" > - + + + diff --git a/src/tools/CompressTool.vue b/src/tools/CompressTool.vue index 3e38018..6ec981f 100644 --- a/src/tools/CompressTool.vue +++ b/src/tools/CompressTool.vue @@ -499,9 +499,6 @@ watch(() => Boolean(docBytes.value), (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(Boolean(docBytes.value)); -}); const filename = ref(''); const totalPages = ref(0); const isDragOver = ref(false); @@ -779,5 +776,11 @@ function checkIncomingFile() { } onMounted(checkIncomingFile); -onActivated(checkIncomingFile); +onActivated(() => { + if (lastExportedFile.value) { + reset(); + } + workspaceState?.setActiveFile(Boolean(docBytes.value)); + checkIncomingFile(); +}); diff --git a/src/tools/ImageToPdfTool.vue b/src/tools/ImageToPdfTool.vue index 3c31f81..7df09c5 100644 --- a/src/tools/ImageToPdfTool.vue +++ b/src/tools/ImageToPdfTool.vue @@ -671,6 +671,9 @@ watch(() => imageList.value.length > 0, (active) => { }, { immediate: true }); onActivated(() => { + if (lastExportedFile.value) { + clearAll(); + } workspaceState?.setActiveFile(imageList.value.length > 0); }); diff --git a/src/tools/MergeTool.vue b/src/tools/MergeTool.vue index 77dbb36..e9abe27 100644 --- a/src/tools/MergeTool.vue +++ b/src/tools/MergeTool.vue @@ -343,10 +343,6 @@ watch(() => files.value.length > 0, (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(files.value.length > 0); -}); - const customOutputBaseName = ref(`${userSettings.defaultExportPrefix || 'PDFSeal'}_Merged_${new Date().toISOString().slice(0, 10)}`); const autoSaveToVault = ref(userSettings.autoSaveToVault !== false); @@ -605,7 +601,13 @@ function checkIncomingFile() { } onMounted(checkIncomingFile); -onActivated(checkIncomingFile); +onActivated(() => { + if (lastExportedFile.value) { + clearAll(); + } + workspaceState?.setActiveFile(files.value.length > 0); + checkIncomingFile(); +}); async function handlePasswordSubmit(pwd) { if (!pendingFileObj) return; diff --git a/src/tools/OrganizeTool.vue b/src/tools/OrganizeTool.vue index 99eec50..a6470bd 100644 --- a/src/tools/OrganizeTool.vue +++ b/src/tools/OrganizeTool.vue @@ -679,9 +679,6 @@ watch(() => Boolean(docBytes.value), (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(Boolean(docBytes.value)); -}); const filename = ref(''); const pages = ref([]); const isDragOver = ref(false); @@ -1223,6 +1220,17 @@ async function downloadPageAsImage(idx) { needDestroy = true; } + if (!targetPdf && docBytes.value) { + const loadingTask = pdfjsLib.getDocument({ + data: new Uint8Array(docBytes.value.slice(0)), + cMapUrl: typeof window !== 'undefined' ? (window.location.origin + '/cmaps/') : '/cmaps/', + cMapPacked: true, + standardFontDataUrl: typeof window !== 'undefined' ? (window.location.origin + '/standard_fonts/') : '/standard_fonts/' + }); + pdfDoc = await loadingTask.promise; + targetPdf = pdfDoc; + } + if (!targetPdf) return; const page = await targetPdf.getPage(item.pageIndex + 1); @@ -1448,8 +1456,17 @@ onMounted(() => { }); onActivated(() => { + if (lastExportedFile.value) { + reset(); + } + workspaceState?.setActiveFile(Boolean(docBytes.value)); checkIncomingFile(); window.addEventListener('keydown', handleKeydown); + if (gridRef.value && !sortableInstance && pages.value.length > 0) { + nextTick(() => { + initSortable(); + }); + } }); onDeactivated(() => { diff --git a/src/tools/PageNumberTool.vue b/src/tools/PageNumberTool.vue index b3240ad..f642e08 100644 --- a/src/tools/PageNumberTool.vue +++ b/src/tools/PageNumberTool.vue @@ -625,9 +625,6 @@ watch(() => Boolean(docBytes.value), (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(Boolean(docBytes.value)); -}); const filename = ref(''); const totalPages = ref(0); const isDragOver = ref(false); @@ -1156,6 +1153,9 @@ function checkIncomingFile() { onMounted(checkIncomingFile); onActivated(() => { + if (lastExportedFile.value) { + reset(); + } workspaceState?.setActiveFile(Boolean(docBytes.value)); checkIncomingFile(); if (docBytes.value && !lastExportedFile.value) { diff --git a/src/tools/PdfToImageTool.vue b/src/tools/PdfToImageTool.vue index cf41437..1fdd30f 100644 --- a/src/tools/PdfToImageTool.vue +++ b/src/tools/PdfToImageTool.vue @@ -375,9 +375,6 @@ watch(() => Boolean(docBytes.value), (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(Boolean(docBytes.value)); -}); const filename = ref(''); const pages = ref([]); const isDragOver = ref(false); @@ -464,6 +461,19 @@ async function destroyPdfDoc() { } } +async function ensurePdfDoc() { + if (!pdfDoc && docBytes.value) { + const loadingTask = pdfjsLib.getDocument({ + data: new Uint8Array(docBytes.value.slice(0)), + cMapUrl: typeof window !== 'undefined' ? (window.location.origin + '/cmaps/') : '/cmaps/', + cMapPacked: true, + standardFontDataUrl: typeof window !== 'undefined' ? (window.location.origin + '/standard_fonts/') : '/standard_fonts/' + }); + pdfDoc = await loadingTask.promise; + } + return pdfDoc; +} + function onFileSelected(e) { const file = e.target.files[0]; if (file) loadFile(file); @@ -598,6 +608,7 @@ function getBaseName() { * (JPEG has no alpha channel; PNG gets consistent output too). */ async function renderPageBlob(pageIndex) { + await ensurePdfDoc(); if (!pdfDoc) throw new Error('No PDF document loaded'); const page = await pdfDoc.getPage(pageIndex + 1); const viewport = page.getViewport({ scale: outputDpi.value / 72 }); @@ -643,6 +654,7 @@ async function downloadPage(idx) { } async function downloadAllAsZip() { + await ensurePdfDoc(); if (!pdfDoc || pages.value.length === 0 || isZipping.value || isRenderingPage.value !== null) return; isZipping.value = true; zipProgress.value = 0; @@ -750,7 +762,13 @@ function checkIncomingFile() { } onMounted(checkIncomingFile); -onActivated(checkIncomingFile); +onActivated(async () => { + if (lastExportedFile.value) { + await reset(); + } + workspaceState?.setActiveFile(Boolean(docBytes.value)); + checkIncomingFile(); +}); onDeactivated(() => { // Free the pdf.js worker resources when navigating away (KeepAlive safe) destroyPdfDoc(); diff --git a/src/tools/PipelineTool.vue b/src/tools/PipelineTool.vue index d148bf3..bbbdab0 100644 --- a/src/tools/PipelineTool.vue +++ b/src/tools/PipelineTool.vue @@ -2834,6 +2834,9 @@ onMounted(() => { }); onActivated(() => { + if (outputResults.value.length > 0) { + resetBatchAndResults(); + } workspaceState?.setActiveFile(inputFiles.value.length > 0); checkIncomingFile(); }); diff --git a/src/tools/ProtectTool.vue b/src/tools/ProtectTool.vue index 9c57530..aad8406 100644 --- a/src/tools/ProtectTool.vue +++ b/src/tools/ProtectTool.vue @@ -717,9 +717,6 @@ watch(() => Boolean(docBytes.value), (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(Boolean(docBytes.value)); -}); const filename = ref(''); const totalPages = ref(0); const originalSizeFormatted = ref('0 B'); @@ -1142,5 +1139,11 @@ function checkIncomingFile() { } onMounted(checkIncomingFile); -onActivated(checkIncomingFile); +onActivated(() => { + if (lastExportedFile.value) { + reset(); + } + workspaceState?.setActiveFile(Boolean(docBytes.value)); + checkIncomingFile(); +}); diff --git a/src/tools/RedactTool.vue b/src/tools/RedactTool.vue index 6d53ca0..56cc43c 100644 --- a/src/tools/RedactTool.vue +++ b/src/tools/RedactTool.vue @@ -920,7 +920,7 @@ diff --git a/src/tools/SignTool.vue b/src/tools/SignTool.vue index 650dba5..dd35ce3 100644 --- a/src/tools/SignTool.vue +++ b/src/tools/SignTool.vue @@ -830,9 +830,6 @@ watch(() => Boolean(docBytes.value), (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(Boolean(docBytes.value)); -}); const filename = ref(''); const totalPages = ref(0); const currentPage = ref(1); @@ -1769,5 +1766,16 @@ onUnmounted(() => { window.removeEventListener('resize', handleWindowResize); reset(); }); -onActivated(checkIncomingFile); +onActivated(() => { + if (lastExportedFile.value) { + reset(); + } + workspaceState?.setActiveFile(Boolean(docBytes.value)); + checkIncomingFile(); + if (docBytes.value && !lastExportedFile.value) { + nextTick(() => { + renderCurrentPage(); + }); + } +}); diff --git a/src/tools/SplitTool.vue b/src/tools/SplitTool.vue index 11607d6..d69b781 100644 --- a/src/tools/SplitTool.vue +++ b/src/tools/SplitTool.vue @@ -695,10 +695,6 @@ watch(() => Boolean(docBytes.value), (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(Boolean(docBytes.value)); -}); - const filename = ref(''); const pages = ref([]); const totalPages = ref(0); @@ -1399,6 +1395,12 @@ function checkIncomingFile() { } onMounted(checkIncomingFile); -onActivated(checkIncomingFile); +onActivated(() => { + if (lastExportedFile.value) { + reset(); + } + workspaceState?.setActiveFile(Boolean(docBytes.value)); + checkIncomingFile(); +}); onUnmounted(destroySplitPdfDoc); diff --git a/src/tools/UnlockTool.vue b/src/tools/UnlockTool.vue index 49cf104..1e2a8fc 100644 --- a/src/tools/UnlockTool.vue +++ b/src/tools/UnlockTool.vue @@ -305,9 +305,6 @@ watch(() => Boolean(docBytes.value), (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(Boolean(docBytes.value)); -}); const filename = ref(''); const totalPages = ref(0); const isDragOver = ref(false); @@ -516,5 +513,11 @@ function checkIncomingFile() { } onMounted(checkIncomingFile); -onActivated(checkIncomingFile); +onActivated(() => { + if (lastExportedFile.value) { + reset(); + } + workspaceState?.setActiveFile(Boolean(docBytes.value)); + checkIncomingFile(); +}); diff --git a/src/tools/WatermarkTool.vue b/src/tools/WatermarkTool.vue index f0ca8c4..280cbc1 100644 --- a/src/tools/WatermarkTool.vue +++ b/src/tools/WatermarkTool.vue @@ -454,9 +454,6 @@ watch(() => Boolean(docBytes.value), (active) => { workspaceState?.setActiveFile(active); }, { immediate: true }); -onActivated(() => { - workspaceState?.setActiveFile(Boolean(docBytes.value)); -}); const filename = ref(''); const totalPages = ref(0); const isDragOver = ref(false); @@ -807,6 +804,10 @@ function checkIncomingFile() { onMounted(checkIncomingFile); onActivated(() => { + if (lastExportedFile.value) { + reset(); + } + workspaceState?.setActiveFile(Boolean(docBytes.value)); checkIncomingFile(); if (docBytes.value && !lastExportedFile.value) { nextTick(() => { diff --git a/tests/keepAliveLifecycle.test.js b/tests/keepAliveLifecycle.test.js new file mode 100644 index 0000000..1210f7b --- /dev/null +++ b/tests/keepAliveLifecycle.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +describe('Smart State KeepAlive & Lifecycle Management', () => { + const rootDir = path.resolve(__dirname, '..'); + const appVuePath = path.join(rootDir, 'src/App.vue'); + const toolsDir = path.join(rootDir, 'src/tools'); + + it('preserves component instances in App.vue using KeepAlive with bounded capacity', () => { + const appContent = fs.readFileSync(appVuePath, 'utf-8'); + expect(appContent).toMatch(//); + expect(appContent).toContain(''); + }); + + it('resets delivered results on activation across all 13 standard processing tools', () => { + const standardTools = [ + 'CompressTool.vue', + 'SplitTool.vue', + 'OrganizeTool.vue', + 'PdfToImageTool.vue', + 'ImageToPdfTool.vue', + 'MergeTool.vue', + 'WatermarkTool.vue', + 'PageNumberTool.vue', + 'ProtectTool.vue', + 'UnlockTool.vue', + 'SignTool.vue', + 'RedactTool.vue', + 'SanitizeTool.vue' + ]; + + for (const toolFile of standardTools) { + const filePath = path.join(toolsDir, toolFile); + const content = fs.readFileSync(filePath, 'utf-8'); + + // 1. Must use onActivated lifecycle hook + expect(content, `${toolFile} must import and use onActivated`).toMatch(/\bonActivated\s*\(/); + + // 2. Must check lastExportedFile on activation before resetting + expect( + content, + `${toolFile} must check lastExportedFile in onActivated to reset completed delivery while preserving draft` + ).toMatch(/if\s*\(\s*lastExportedFile\.value\s*\)\s*\{\s*(await\s+)?(reset|clearAll)\s*\(\s*\);?\s*\}/); + + // 3. Must notify workspaceState of active file status + expect(content, `${toolFile} must update workspaceState on activation`).toContain('workspaceState?.setActiveFile'); + + // 4. Must not contain duplicate onActivated hooks (clean single activation handler) + const onActivatedMatches = content.match(/onActivated\s*\(/g) || []; + expect(onActivatedMatches.length, `${toolFile} should have exactly one onActivated hook`).toBe(1); + } + }); + + it('resets completed batch results on activation in PipelineTool', () => { + const filePath = path.join(toolsDir, 'PipelineTool.vue'); + const content = fs.readFileSync(filePath, 'utf-8'); + + expect(content).toMatch(/if\s*\(\s*outputResults\.value\.length\s*>\s*0\s*\)\s*\{\s*resetBatchAndResults\s*\(\s*\);?\s*\}/); + expect(content).toContain('workspaceState?.setActiveFile'); + }); +}); From 60d9c7da0be2d8fb17100e215f1e83f19f78f306 Mon Sep 17 00:00:00 2001 From: gzz727 Date: Fri, 25 Sep 2026 11:43:39 +0800 Subject: [PATCH 2/2] chore(release): bump version to 1.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/config/siteConfig.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e476fed..5e12aef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pdfseal", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdfseal", - "version": "1.1.0", + "version": "1.2.0", "license": "AGPL-3.0-only", "dependencies": { "@pdfsmaller/pdf-encrypt": "^1.2.0", diff --git a/package.json b/package.json index 083b065..c36cd2e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pdfseal", - "version": "1.1.0", + "version": "1.2.0", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/src/config/siteConfig.js b/src/config/siteConfig.js index 42deb7a..04ad822 100644 --- a/src/config/siteConfig.js +++ b/src/config/siteConfig.js @@ -59,7 +59,7 @@ export const isOfficialMode = ref(checkInitialOfficialMode()); export const siteConfig = reactive({ // Version Identity - version: import.meta.env.VITE_APP_VERSION || '1.1.0', + version: import.meta.env.VITE_APP_VERSION || '1.2.0', // Brand Identity brandName: import.meta.env.VITE_BRAND_NAME || 'PDFSeal',