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';
+}