From d0025877c4bbb43d0c49ef06daf1d661eafe4bcf Mon Sep 17 00:00:00 2001 From: Jad Date: Mon, 22 Dec 2025 23:49:09 +0800 Subject: [PATCH] feat: Add non-gemini generated image tips, and add a image lightbox (#4) * wip: detect image not be made with gemini * wip: multi image list style * feat: add image lightbox --- package.json | 4 ++- pnpm-lock.yaml | 16 +++++++++ public/index.html | 24 +++++++++---- src/app.js | 84 ++++++++++++++++++++------------------------- src/i18n/en-US.json | 5 ++- src/i18n/zh-CN.json | 5 ++- src/utils.js | 54 +++++++++++++++++++++++++++++ 7 files changed, 137 insertions(+), 55 deletions(-) create mode 100644 src/utils.js diff --git a/package.json b/package.json index e551c55..15ee12b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "serve": "npx serve dist" }, "dependencies": { - "jszip": "^3.10.1" + "exifr": "^7.1.3", + "jszip": "^3.10.1", + "medium-zoom": "^1.1.0" }, "devDependencies": { "esbuild": "^0.24.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33952ec..b908065 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: dependencies: + exifr: + specifier: ^7.1.3 + version: 7.1.3 jszip: specifier: ^3.10.1 version: 3.10.1 + medium-zoom: + specifier: ^1.1.0 + version: 1.1.0 devDependencies: esbuild: specifier: ^0.24.0 @@ -176,6 +182,9 @@ packages: engines: {node: '>=18'} hasBin: true + exifr@7.1.3: + resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==} + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -191,6 +200,9 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + medium-zoom@1.1.0: + resolution: {integrity: sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -319,6 +331,8 @@ snapshots: '@esbuild/win32-ia32': 0.24.2 '@esbuild/win32-x64': 0.24.2 + exifr@7.1.3: {} + immediate@3.0.6: {} inherits@2.0.4: {} @@ -336,6 +350,8 @@ snapshots: dependencies: immediate: 3.0.6 + medium-zoom@1.1.0: {} + pako@1.0.11: {} process-nextick-args@2.0.1: {} diff --git a/public/index.html b/public/index.html index f46edd6..455c18b 100644 --- a/public/index.html +++ b/public/index.html @@ -19,6 +19,10 @@ primary: '#10B981', 'primary-hover': '#059669', dark: '#1F2937', + success: '#10B981', + warn: '#F59E0B', + err: '#EF4444', + info: '#3B82F6', }, boxShadow: { 'soft': '0 4px 20px -2px rgba(16, 185, 129, 0.1)', @@ -65,6 +69,14 @@ } body.loading { opacity: 0; } body { transition: opacity 0s; } + + .medium-zoom-overlay, + .medium-zoom-image--opened { + z-index: 999; + } + .medium-zoom-overlay { + backdrop-filter: blur(4px); + } @@ -103,7 +115,7 @@
- +

点击选择 或 拖拽图片至此

支持 JPG, PNG, WebP

@@ -140,8 +152,8 @@
-
- +
+
@@ -152,8 +164,8 @@
-
- +
+
@@ -174,7 +186,7 @@ -
+
diff --git a/src/app.js b/src/app.js index f16f837..e3883b4 100644 --- a/src/app.js +++ b/src/app.js @@ -1,11 +1,14 @@ import { WatermarkEngine } from './core/watermarkEngine.js'; import i18n from './i18n.js'; +import { loadImage, checkOriginal, getOriginalStatus, setStatusMessage, showLoading, hideLoading } from './utils.js'; import JSZip from 'jszip'; +import mediumZoom from 'medium-zoom'; // global state let engine = null; let imageQueue = []; let processedCount = 0; +let zoom = null; // dom elements references const uploadArea = document.getElementById('uploadArea'); @@ -15,15 +18,13 @@ const multiPreview = document.getElementById('multiPreview'); const imageList = document.getElementById('imageList'); const progressText = document.getElementById('progressText'); const downloadAllBtn = document.getElementById('downloadAllBtn'); -const loadingOverlay = document.getElementById('loadingOverlay'); -const originalCanvas = document.getElementById('originalCanvas'); +const originalImage = document.getElementById('originalImage'); const processedSection = document.getElementById('processedSection'); const processedImage = document.getElementById('processedImage'); const originalInfo = document.getElementById('originalInfo'); const processedInfo = document.getElementById('processedInfo'); const downloadBtn = document.getElementById('downloadBtn'); const resetBtn = document.getElementById('resetBtn'); -const statusMessage = document.getElementById('statusMessage'); /** * initialize the application @@ -38,6 +39,12 @@ async function init() { hideLoading(); setupEventListeners(); + + zoom = mediumZoom('[data-zoomable]', { + margin: 24, + scrollOffset: 0, + background: 'rgba(255, 255, 255, .6)', + }) } catch (error) { hideLoading(); console.error('初始化错误:', error); @@ -136,16 +143,17 @@ async function processSingle(item) { const img = await loadImage(item.file); item.originalImg = img; - originalCanvas.width = img.width; - originalCanvas.height = img.height; - originalCanvas.getContext('2d').drawImage(img, 0, 0); + const { is_google, is_original } = await checkOriginal(item.file); + const status = getOriginalStatus({ is_google, is_original }); + setStatusMessage(status, is_google && is_original ? 'success' : 'warn'); + + originalImage.src = img.src; - // update original image info const watermarkInfo = engine.getWatermarkInfo(img.width, img.height); originalInfo.innerHTML = ` - ${i18n.t('info.size')}:${img.width} × ${img.height} px
- ${i18n.t('info.watermark')}:${watermarkInfo.size}×${watermarkInfo.size} px
- ${i18n.t('info.position')}:(${watermarkInfo.position.x}, ${watermarkInfo.position.y}) +

${i18n.t('info.size')}: ${img.width}×${img.height}

+

${i18n.t('info.watermark')}: ${watermarkInfo.size}×${watermarkInfo.size}

+

${i18n.t('info.position')}: (${watermarkInfo.position.x},${watermarkInfo.position.y})

`; const result = await engine.removeWatermarkFromImage(img); @@ -158,10 +166,13 @@ async function processSingle(item) { downloadBtn.onclick = () => downloadImage(item); processedInfo.innerHTML = ` - ${i18n.t('info.size')}:${img.width} × ${img.height} px
- ${i18n.t('info.status')}:${i18n.t('info.removed')} +

${i18n.t('info.size')}: ${img.width}×${img.height}

+

${i18n.t('info.status')}: ${i18n.t('info.removed')}

`; + zoom.detach(); + zoom.attach('[data-zoomable]'); + processedSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (error) { console.error(error); @@ -171,20 +182,20 @@ async function processSingle(item) { function createImageCard(item) { const card = document.createElement('div'); card.id = `card-${item.id}`; - card.className = 'bg-white md:h-[130px] rounded-xl shadow-card border border-gray-100 overflow-hidden'; + card.className = 'bg-white md:h-[140px] rounded-xl shadow-card border border-gray-100 overflow-hidden'; card.innerHTML = ` -
-
+
+
- +
-

${item.name}

+

${item.name}

${i18n.t('status.pending')}
-
- +
+
`; @@ -196,6 +207,7 @@ async function processQueue() { const img = await loadImage(item.file); item.originalImg = img; document.getElementById(`result-${item.id}`).src = img.src; + zoom.attach(`#result-${item.id}`); } for (const item of imageQueue) { @@ -213,9 +225,13 @@ async function processQueue() { item.status = 'completed'; const watermarkInfo = engine.getWatermarkInfo(item.originalImg.width, item.originalImg.height); - updateStatus(item.id, `${i18n.t('info.size')}:${item.originalImg.width} × ${item.originalImg.height} px
- ${i18n.t('info.watermark')}:${watermarkInfo.size}×${watermarkInfo.size} px
- ${i18n.t('info.position')}:(${watermarkInfo.position.x}, ${watermarkInfo.position.y})`, true); + const { is_google, is_original } = await checkOriginal(item.originalImg); + const originalStatus = getOriginalStatus({ is_google, is_original }); + + updateStatus(item.id, `

${i18n.t('info.size')}: ${item.originalImg.width}×${item.originalImg.height}

+

${i18n.t('info.watermark')}: ${watermarkInfo.size}×${watermarkInfo.size}

+

${i18n.t('info.position')}: (${watermarkInfo.position.x},${watermarkInfo.position.y})

+

${originalStatus}

`, true); const downloadBtn = document.getElementById(`download-${item.id}`); downloadBtn.classList.remove('hidden'); @@ -235,20 +251,6 @@ async function processQueue() { } } -function loadImage(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = e.target.result; - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} - function updateStatus(id, text, isHtml = false) { const el = document.getElementById(`status-${id}`); if (el) el.innerHTML = isHtml ? text : text.replace(/\n/g, '
'); @@ -288,14 +290,4 @@ async function downloadAll() { a.click(); } -function showLoading(text = null) { - loadingOverlay.style.display = 'flex'; - const textEl = loadingOverlay.querySelector('p'); - if (textEl && text) textEl.textContent = text; -} - -function hideLoading() { - loadingOverlay.style.display = 'none'; -} - init(); diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index 26584b0..1709d99 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -39,5 +39,8 @@ "info.watermark": "Detected Watermark", "info.position": "Position", "info.status": "Status", - "info.removed": "Watermark Removed" + "info.removed": "Watermark Removed", + "original.not_gemini": "⚠️ This image may not be made with Gemini. Lossless processing unavailable.", + "original.not_original": "⚠️ This image may not be original size. Lossless processing unavailable.", + "original.pass": "✅ This image made with Gemini. Lossless processing supported." } diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index e302ce5..76b18e3 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -39,5 +39,8 @@ "info.watermark": "检测到的水印", "info.position": "位置", "info.status": "状态", - "info.removed": "水印已移除" + "info.removed": "水印已移除", + "original.not_gemini": "⚠️ 此图片非 Gemini 生成的原始图片,可能无法进行无损去水印", + "original.not_original": "⚠️ 此图片非原始尺寸,可能无法进行无损去水印", + "original.pass": "✅ 此图片为 Gemini 生成的原始图片,可进行无损去水印" } diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..3e4affe --- /dev/null +++ b/src/utils.js @@ -0,0 +1,54 @@ +import exifr from 'exifr'; +import i18n from './i18n.js'; + +export function loadImage(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = e.target.result; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +export async function checkOriginal(file) { + try { + const exif = await exifr.parse(file, { xmp: true }); + return { + is_google: exif?.Credit === 'Made with Google AI', + is_original: ['ImageWidth', 'ImageHeight'].every(key => exif?.[key]) + } + } catch { + return { is_google: false, is_original: false }; + } +} + +export function getOriginalStatus({ is_google, is_original }) { + if (!is_google) return i18n.t('original.not_gemini'); + if (!is_original) return i18n.t('original.not_original'); + return ''; +} + +const statusMessage = document.getElementById('statusMessage'); +export function setStatusMessage(message = '', type = '') { + statusMessage.textContent = message; + statusMessage.style.display = message ? 'block' : 'none'; + const colorMap = { warn: 'text-warn', success: 'text-success' }; + statusMessage.classList.remove(...Object.values(colorMap)); + if (colorMap[type]) statusMessage.classList.add(colorMap[type]); +} + +const loadingOverlay = document.getElementById('loadingOverlay'); +export function showLoading(text = null) { + loadingOverlay.style.display = 'flex'; + const textEl = loadingOverlay.querySelector('p'); + if (textEl && text) textEl.textContent = text; +} + +export function hideLoading() { + loadingOverlay.style.display = 'none'; +}