1document.addEventListener('DOMContentLoaded', function () { 2 const dropZone = document.getElementById('dropZone'); 3 const fileInput = document.getElementById('fileInput'); 4 const uploadBtn = document.querySelector('.upload-btn'); 5 const comparisonModal = document.getElementById('comparisonModal'); 6 const closeComparisonModal = document.getElementById('closeComparisonModal'); 7 const fileList = document.getElementById('fileList'); 8 const batchActions = document.getElementById('batchActions'); 9 const clearAllBtn = document.getElementById('clearAllBtn'); 10 const downloadAllBtn = document.getElementById('downloadAllBtn'); 11 const shareRuleBtn = document.getElementById('shareRuleBtn'); 12 const batchStatusText = document.getElementById('batchStatusText'); 13 14 // Details View Elements 15 const originalImage = document.getElementById('originalImage'); 16 const compressedImage = document.getElementById('compressedImage'); 17 const originalWrapper = document.getElementById('originalWrapper'); 18 const compressedWrapper = document.getElementById('compressedWrapper'); 19 const originalSize = document.getElementById('originalSize'); 20 const compressedSize = document.getElementById('compressedSize'); 21 const originalResolution = document.getElementById('originalResolution'); 22 const compressedResolution = document.getElementById('compressedResolution'); 23 const cropControls = document.getElementById('cropControls'); 24 const cropApply = document.getElementById('cropApply'); 25 const cropCancel = document.getElementById('cropCancel'); 26 const downloadBtn = document.getElementById('downloadBtn'); 27 const advancedSummaryText = document.getElementById('advancedSummaryText'); 28 const controlsContainer = document.querySelector('.controls'); 29 const taskToolbar = document.querySelector('.task-toolbar'); 30 // Settings belong to the task toolbar; opening them expands in place instead of adding a second row. 31 if (controlsContainer && taskToolbar) taskToolbar.appendChild(controlsContainer); 32 33 // Mobile modal elements 34 const mobileOrigSize = document.getElementById('mobileOrigSize'); 35 const mobileCompSize = document.getElementById('mobileCompSize'); 36 const mobileSavePct = document.getElementById('mobileSavePct'); 37 const mobileResolution = document.getElementById('mobileResolution'); 38 const mobileOriginalTab = document.getElementById('mobileOriginalTab'); 39 const mobileCompressedTab = document.getElementById('mobileCompressedTab'); 40 const modalMobileDownloadBtn = document.getElementById('modalMobileDownloadBtn'); 41 42 const isMobile = () => window.innerWidth <= 768; 43 44 // Stats Elements 45 const statsReport = document.getElementById('statsReport'); 46 const statsText = document.getElementById('statsText'); 47 48 // --- Persistence Logic (IndexedDB) --- 49 const DB_CONFIG = { 50 name: 'ImgMinDB', 51 version: 1, 52 storeName: 'files' 53 }; 54 55 const ImagePersistence = { 56 db: null, 57 58 async init() { 59 return new Promise((resolve, reject) => { 60 const request = indexedDB.open(DB_CONFIG.name, DB_CONFIG.version); 61 62 request.onerror = (event) => { 63 console.error('IndexedDB error:', event.target.error); 64 reject(event.target.error); 65 }; 66 67 request.onsuccess = (event) => { 68 this.db = event.target.result; 69 resolve(this.db); 70 }; 71 72 request.onupgradeneeded = (event) => { 73 const db = event.target.result; 74 if (!db.objectStoreNames.contains(DB_CONFIG.storeName)) { 75 db.createObjectStore(DB_CONFIG.storeName, { keyPath: 'id' }); 76 } 77 }; 78 }); 79 }, 80 81 async saveFile(item) { 82 if (!this.db) await this.init(); 83 return new Promise((resolve, reject) => { 84 const transaction = this.db.transaction([DB_CONFIG.storeName], 'readwrite'); 85 const store = transaction.objectStore(DB_CONFIG.storeName); 86 87 // Only store necessary data 88 const record = { 89 id: item.id, 90 file: item.file, 91 compressedBlob: item.compressedBlob, 92 status: item.status, 93 timestamp: item.timestamp || Date.now(), 94 taskSettings: item.taskSettings || null, 95 targetMet: item.targetMet ?? null, 96 errorReason: item.errorReason || null, 97 originalWidth: item.originalWidth || null, 98 originalHeight: item.originalHeight || null, 99 compressedWidth: item.compressedWidth || null, 100 compressedHeight: item.compressedHeight || null, 101 cropPosition: item.cropPosition || { x: 0.5, y: 0.5 } 102 }; 103 104 const request = store.put(record); 105 request.onsuccess = () => resolve(); 106 request.onerror = () => reject(request.error); 107 }); 108 }, 109 110 async getAllFiles() { 111 if (!this.db) await this.init(); 112 return new Promise((resolve, reject) => { 113 const transaction = this.db.transaction([DB_CONFIG.storeName], 'readonly'); 114 const store = transaction.objectStore(DB_CONFIG.storeName); 115 const request = store.getAll(); 116 117 request.onsuccess = () => resolve(request.result); 118 request.onerror = () => reject(request.error); 119 }); 120 }, 121 122 async clearAll() { 123 if (!this.db) await this.init(); 124 return new Promise((resolve, reject) => { 125 const transaction = this.db.transaction([DB_CONFIG.storeName], 'readwrite'); 126 const store = transaction.objectStore(DB_CONFIG.storeName); 127 const request = store.clear(); 128 request.onsuccess = () => resolve(); 129 request.onerror = () => reject(request.error); 130 }); 131 }, 132 133 async removeFile(id) { 134 if (!this.db) await this.init(); 135 return new Promise((resolve, reject) => { 136 const transaction = this.db.transaction([DB_CONFIG.storeName], 'readwrite'); 137 const store = transaction.objectStore(DB_CONFIG.storeName); 138 const request = store.delete(id); 139 request.onsuccess = () => resolve(); 140 request.onerror = () => reject(request.error); 141 }); 142 } 143 }; 144 145 let fileQueue = []; // Array of file objects with state 146 let activeFileId = null; // ID of file currently in detail view
147 let compressionMode = 'balanced'; // 'balanced', 'quality', 'size' 148 const COMPRESSION_PRESETS = { 149 balanced: { quality: 80, minQuality: 50, maxDimension: 2048, keepSmallerOriginal: true }, 150 quality: { quality: 90, minQuality: 70, maxDimension: null, keepSmallerOriginal: false }, 151 size: { quality: 60, minQuality: 35, maxDimension: 1600, keepSmallerOriginal: false } 152 }; 153 154 const RESIZE_PRESETS = { 155 taobao: { w: 800, h: 800, fit: 'cover', group: 'ecommerce' }, 156 jd: { w: 800, h: 800, fit: 'cover', group: 'ecommerce' }, 157 amazon: { w: 2000, h: 2000, fit: 'cover', group: 'ecommerce' }, 158 shopify: { w: 2048, h: 2048, fit: 'cover', group: 'ecommerce' }, 159 wechat_cover: { w: 900, h: 383, fit: 'cover', group: 'social' }, 160 xiaohongshu: { w: 1242, h: 1656, fit: 'cover', group: 'social' }, 161 ig_post: { w: 1080, h: 1080, fit: 'cover', group: 'social' }, 162 ig_story: { w: 1080, h: 1920, fit: 'cover', group: 'social' }, 163 twitter: { w: 1600, h: 900, fit: 'cover', group: 'social' }, 164 youtube: { w: 1280, h: 720, fit: 'cover', group: 'social' } 165 }; 166 let resizeSettings = { enabled: true, preset: null, width: null, height: null, fit: 'cover' }; 167 let renameSettings = { enabled: false, prefix: '', suffix: '', keepOriginal: true, seq: false, seqStart: 1, seqPad: 2 }; 168 let watermarkSettings = { enabled: false, text: '', image: null, imageEl: null, position: 'bottom-right', opacity: 0.5 }; 169 170 const outputSettings = { format: 'auto', targetKB: 0, background: '#ffffff' }; 171 const outputFormatEl = document.getElementById('outputFormat'); 172 const targetKBEl = document.getElementById('targetKB'); 173 const jpegBackgroundEl = document.getElementById('jpegBackground'); 174 // Landing pages (e.g. /compress-to-100kb/) embed the same tool and declare 175 // their defaults via window.IMGMIN_TOOL_PRESET. URL params still win so 176 // shared deep-links behave identically everywhere. 177 const toolPreset = window.IMGMIN_TOOL_PRESET || {}; 178 if (['jpeg', 'png', 'webp'].includes(toolPreset.format)) outputSettings.format = toolPreset.format; 179 if (Number.isInteger(toolPreset.targetKB) && toolPreset.targetKB >= 1 && toolPreset.targetKB <= 100000) outputSettings.targetKB = toolPreset.targetKB; 180 const outputParams = new URLSearchParams(location.search); 181 if (['balanced', 'quality', 'size'].includes(outputParams.get('mode'))) { 182 compressionMode = outputParams.get('mode'); 183 document.querySelectorAll('.mode-btn[data-mode]').forEach(button => button.classList.toggle('active', button.dataset.mode === compressionMode)); 184 } 185 if (['jpeg', 'png', 'webp'].includes(outputParams.get('format'))) outputSettings.format = outputParams.get('format'); 186 const urlKB = Number(outputParams.get('targetKB')); 187 if (Number.isInteger(urlKB) && urlKB >= 1 && urlKB <= 100000) outputSettings.targetKB = urlKB; 188 outputFormatEl.value = outputSettings.format; 189 targetKBEl.value = outputSettings.targetKB || ''; 190 function syncOutputControls() { 191 document.getElementById('jpegBackgroundControl').hidden = outputSettings.format !== 'jpeg'; 192 document.querySelectorAll('.target-shortcut').forEach(button => { 193 button.classList.toggle('active', Number(button.dataset.targetKb) === outputSettings.targetKB); 194 }); 195 } 196 syncOutputControls(); 197 [outputFormatEl, targetKBEl, jpegBackgroundEl].forEach(control => control.addEventListener('change', () => { 198 if (!targetKBEl.checkValidity()) { targetKBEl.reportValidity(); return; } 199 outputSettings.format = outputFormatEl.value; 200 outputSettings.targetKB = Number(targetKBEl.value) || 0; 201 outputSettings.background = jpegBackgroundEl.value; 202 syncOutputControls(); 203 recompressAll(); 204 })); 205 document.querySelectorAll('.target-shortcut').forEach(button => button.addEventListener('click', () => { 206 outputSettings.targetKB = Number(button.dataset.targetKb); 207 targetKBEl.value = outputSettings.targetKB; 208 syncOutputControls(); 209 recompressAll(); 210 }));
211 let currentZoom = 1; // Zoom level for comparison modal 212 let cropDraft = { x: 0.5, y: 0.5 }; 213 let cropDrag = null; 214 let cropApplying = false; 215 216 function getCompressionPreset() { 217 return COMPRESSION_PRESETS[compressionMode] || COMPRESSION_PRESETS.balanced; 218 } 219 220 function getCompressionQuality() { 221 return getCompressionPreset().quality / 100; 222 } 223 224 function normalizeMimeType(file, format) { 225 if (format !== 'auto') return `image/${format}`; 226 const type = String(file?.type || '').toLowerCase(); 227 const name = String(file?.name || '').toLowerCase(); 228 if (type === 'image/jpg' || type === 'image/pjpeg' || /\.jpe?g$/.test(name)) return 'image/jpeg'; 229 return ['image/jpeg', 'image/png', 'image/webp'].includes(type) ? type : (type || 'image/jpeg'); 230 } 231 232 // These counters describe work in this browser only. Never imply sitewide usage. 233 function formatSavedMegabytes(bytes) { 234 const locale = window.imgminI18n?.lang || document.documentElement.lang || 'en-US'; 235 return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(Math.max(0, bytes) / 1000000); 236 } 237 238 function loadGlobalStats() { 239 // The cumulative-stats section only exists on the homepage; embedded 240 // tool pages omit it, so bail out instead of throwing on null. 241 const statImagesEl = document.getElementById('statImages'); 242 const statSavedEl = document.getElementById('statSaved'); 243 if (!statImagesEl || !statSavedEl) return; 244 let localImages = 0; 245 let localSavedBytes = 0; 246 try { 247 localImages = Number.parseInt(localStorage.getItem('imgmin_stats_images') || '0', 10) || 0; 248 localSavedBytes = Number.parseInt(localStorage.getItem('imgmin_stats_saved') || '0', 10) || 0; 249 } catch (_) { /* Browser storage may be unavailable; processing still works. */ } 250 statImagesEl.textContent = localImages.toLocaleString(window.imgminI18n?.lang || document.documentElement.lang || 'en-US'); 251 statSavedEl.textContent = formatSavedMegabytes(localSavedBytes); 252 } 253 function incrementGlobalStats(savedBytes) { 254 try { 255 const images = Number.parseInt(localStorage.getItem('imgmin_stats_images') || '0', 10) || 0; 256 const saved = Number.parseInt(localStorage.getItem('imgmin_stats_saved') || '0', 10) || 0; 257 localStorage.setItem('imgmin_stats_images', String(images + 1)); 258 localStorage.setItem('imgmin_stats_saved', String(saved + Math.max(0, Math.round(savedBytes) || 0))); 259 } catch (_) { return; } 260 loadGlobalStats(); 261 } 262 263 function trackTask(event, item, extra = {}) { 264 // Only bounded categories: never send image bytes, names, URLs or file IDs. 265 const settings = item?.taskSettings || outputSettings; 266 const data = { 267 task_type: item?.taskType || (settings.targetKB ? 'target_size' : settings.format !== 'auto' ? 'convert' 268 : item?.originalWidth && item?.compressedWidth && (item.originalWidth !== item.compressedWidth || item.originalHeight !== item.compressedHeight) 269 ? 'resize' : fileQueue.length > 1 ? 'batch' : 'compress'), 270 output_format: settings.format, 271 target_bucket: [50, 100, 200].includes(settings.targetKB) ? String(settings.targetKB) : settings.targetKB ? 'custom' : 'none', 272 language: window.imgminI18n.lang, 273 ...extra 274 }; 275 try { window.gtag('event', event, data); } catch (_) { /* analytics cannot break processing */ } 276 try { 277 if (!window.imgminAnalytics) return; 278 if (['advanced_settings_opened', 'settings_adjusted', 'rule_saved', 'rule_reused', 'rule_shared'].includes(event)) window.imgminAnalytics.track(event, {}); 279 if (['task_start', 'process_success', 'target_met'].includes(event)) { 280 window.imgminAnalytics.track(event, { 281 task_type: data.task_type, 282 output_format: data.output_format, 283 target_bucket: data.target_bucket, 284 duration_bucket: data.duration_bucket 285 }); 286 } 287 if (event === 'process_fail') { 288 window.imgminAnalytics.track(event, { 289 task_type: data.task_type, 290 output_format: data.output_format, 291 target_bucket: data.target_bucket, 292 reason: data.reason 293 }); 294 } 295 if (event === 'export_triggered') { 296 window.imgminAnalytics.track(event, { 297 task_type: data.task_type, 298 output_format: data.output_format, 299 target_bucket: data.target_bucket, 300 method: data.method, 301 export_format: data.export_format, 302 target_met: data.target_met 303 }); 304 } 305 } catch (_) { /* analytics cannot break processing */ } 306 } 307 function trackExport(blob, method, exportedBlob = blob) { 308 const item = fileQueue.find(entry => entry.compressedBlob === blob); 309 if (!item || item.exportRecorded) return; 310 item.exportRecorded = true; 311 trackTask('export_triggered', item, { method, target_met: !!item.taskSettings?.targetKB && exportedBlob.size <= item.taskSettings.targetKB * 1000, export_format: ['image/png', 'image/jpeg', 'image/webp'].includes(exportedBlob.type) ? exportedBlob.type.slice(6) : 'other' }); 312 if (shareRuleBtn) shareRuleBtn.hidden = false;
313 } 314 315 function formatResolution(width, height) { 316 if (!width || !height) return ''; 317 return `${width} à ${height}`; 318 } 319 320 function getResolutionValue(width, height, unchanged = false) { 321 if (!width || !height) return ''; 322 if (unchanged) { 323 return window.imgminI18n.t('file.resolution_unchanged', { width, height }); 324 } 325 return formatResolution(width, height); 326 } 327 328 function getDimensionsSummary(item) { 329 if (!item.originalWidth || !item.originalHeight || !item.compressedWidth || !item.compressedHeight) { 330 return ''; 331 } 332 333 const unchanged = item.originalWidth === item.compressedWidth 334 && item.originalHeight === item.compressedHeight; 335 336 if (unchanged) { 337 return window.imgminI18n.t('file.dimensions_unchanged', { 338 width: item.originalWidth, 339 height: item.originalHeight 340 }); 341 } 342 343 return window.imgminI18n.t('compression.result_dimensions', { 344 originalWidth: item.originalWidth, 345 originalHeight: item.originalHeight, 346 compressedWidth: item.compressedWidth, 347 compressedHeight: item.compressedHeight 348 }); 349 } 350 351 function getCompressedItems() { 352 return fileQueue.filter(item => item.status === 'done' && item.compressedBlob); 353 } 354 355 function getDownloadLabel() { 356 const key = getCompressedItems().length <= 1 ? 'download.single' : 'batch.download'; 357 return window.imgminI18n.t(key); 358 } 359 360 function updateDownloadLabels() { 361 const completed = getCompressedItems().length; 362 const label = getDownloadLabel(); 363 [downloadAllBtn, downloadBtn, document.getElementById('floatingDownload')].forEach(btn => { 364 if (!btn) return; 365 btn.textContent = label; 366 btn.setAttribute('aria-label', label); 367 }); 368 const noDownload = fileQueue.length > 0 && completed === 0; 369 if (downloadAllBtn) downloadAllBtn.hidden = completed === 0; 370 if (shareRuleBtn) shareRuleBtn.hidden = !fileQueue.some(item => item.exportRecorded); 371 batchActions.classList.toggle('no-download', noDownload); 372 if (batchStatusText) { 373 batchStatusText.textContent = completed > 0 && completed < fileQueue.length ? `${completed} / ${fileQueue.length}` : ''; 374 } 375 } 376 377 function updateWorkspaceState() { 378 const hasFiles = fileQueue.length > 0; 379 document.body.classList.toggle('has-files', hasFiles); 380 fileList.classList.toggle('single-item', fileQueue.length === 1); 381 if (hasFiles) { 382 fileList.style.display = 'flex'; 383 batchActions.style.display = 'flex'; 384 } 385 if (!hasFiles && typeof updateFloatingBtn === 'function') updateFloatingBtn(); 386 } 387 388 function updateAdvancedSummary() { 389 if (!advancedSummaryText) return; 390 let summary = window.imgminI18n.t('settings.summary', { 391 mode: window.imgminI18n.t(`mode.${compressionMode}`) 392 }); 393 if (resizeSettings.enabled && resizeSettings.width && resizeSettings.height) { 394 summary += window.imgminI18n.t('settings.summary_resize', { 395 w: resizeSettings.width, 396 h: resizeSettings.height 397 }); 398 } 399 if (renameSettings.enabled) { 400 summary += window.imgminI18n.t('settings.summary_rename'); 401 } 402 if (watermarkSettings.enabled) { 403 summary += window.imgminI18n.t('settings.summary_watermark'); 404 } 405 if (outputSettings.targetKB) summary += window.imgminI18n.t('settings.summary_target', { size: outputSettings.targetKB }); 406 if (outputSettings.format !== 'auto') summary += ` · ${outputSettings.format.toUpperCase()}`; 407 advancedSummaryText.textContent = summary; 408 } 409 410 function getAspectInfo(width, height) { 411 if (!width || !height) return { className: 'ratio-unknown', label: '' }; 412 413 const ratio = width / height; 414 const candidates = [ 415 { label: '1:1', value: 1 }, 416 { label: '4:3', value: 4 / 3 }, 417 { label: '3:2', value: 3 / 2 }, 418 { label: '16:9', value: 16 / 9 }, 419 { label: '5:2', value: 5 / 2 }, 420 { label: '3:4', value: 3 / 4 }, 421 { label: '9:16', value: 9 / 16 } 422 ]; 423 const nearest = candidates.reduce((best, candidate) => { 424 return Math.abs(candidate.value - ratio) < Math.abs(best.value - ratio) ? candidate : best; 425 }, candidates[0]); 426 427 let className = 'ratio-square'; 428 if (ratio >= 2.1) className = 'ratio-ultrawide'; 429 else if (ratio >= 1.35) className = 'ratio-wide'; 430 else if (ratio <= 0.78) className = 'ratio-portrait'; 431 432 return { className, label: nearest.label }; 433 } 434 435 function updateFileItemAspect(item) { 436 const el = document.getElementById(`item-${item.id}`); 437 if (!el) return; 438 439 const info = getAspectInfo(item.originalWidth, item.originalHeight); 440 el.classList.remove('ratio-unknown', 'ratio-square', 'ratio-wide', 'ratio-ultrawide', 'ratio-portrait'); 441 el.classList.add(info.className); 442 443 const aspectLabel = el.querySelector('.file-aspect-label'); 444 if (aspectLabel) { 445 aspectLabel.textContent = info.label; 446 aspectLabel.style.display = info.label ? 'inline-flex' : 'none'; 447 } 448 } 449 450 function updateFileItemActionLabels(item, itemEl) { 451 const el = itemEl || document.getElementById(`item-${item.id}`); 452 if (!el) return; 453 454 const previewLabel = window.imgminI18n.t('file.action.preview'); 455 const deleteLabel = window.imgminI18n.t('file.action.delete'); 456 const previewBtn = el.querySelector('.preview-item-btn'); 457 const deleteBtn = el.querySelector('.delete-item-btn'); 458 459 if (previewBtn) { 460 previewBtn.title = previewLabel; 461 previewBtn.setAttribute('aria-label', previewLabel); 462 } 463 if (deleteBtn) { 464 deleteBtn.title = deleteLabel; 465 deleteBtn.setAttribute('aria-label', deleteLabel); 466 } 467 } 468 469 function renderResolutionInfo(item) { 470 if (!item) return; 471 472 originalResolution.textContent = getResolutionValue(item.originalWidth, item.originalHeight); 473 474 if (!item.compressedBlob) { 475 compressedResolution.textContent = ''; 476 return; 477 } 478 479 const unchanged = item.originalWidth === item.compressedWidth 480 && item.originalHeight === item.compressedHeight; 481 482 compressedResolution.textContent = getResolutionValue( 483 item.compressedWidth, 484 item.compressedHeight, 485 unchanged 486 ); 487 } 488 489 function getImageDimensions(blob) { 490 return new Promise((resolve, reject) => { 491 const url = URL.createObjectURL(blob); 492 const img = new Image(); 493 494 img.onload = () => { 495 const dimensions = {
496 width: img.naturalWidth || img.width, 497 height: img.naturalHeight || img.height 498 }; 499 URL.revokeObjectURL(url); 500 resolve(dimensions); 501 }; 502 503 img.onerror = (error) => { 504 URL.revokeObjectURL(url); 505 reject(error); 506 }; 507 508 img.src = url; 509 }); 510 } 511 512 async function ensureItemDimensions(item) { 513 let changed = false; 514 515 if ((!item.originalWidth || !item.originalHeight) && item.file) { 516 const originalDimensions = await getImageDimensions(item.file); 517 item.originalWidth = originalDimensions.width; 518 item.originalHeight = originalDimensions.height; 519 changed = true; 520 } 521 522 if (item.compressedBlob && (!item.compressedWidth || !item.compressedHeight)) { 523 if (item.compressedBlob === item.file) { 524 item.compressedWidth = item.originalWidth; 525 item.compressedHeight = item.originalHeight; 526 } else { 527 const compressedDimensions = await getImageDimensions(item.compressedBlob); 528 item.compressedWidth = compressedDimensions.width; 529 item.compressedHeight = compressedDimensions.height; 530 } 531 changed = true; 532 } 533 534 if (changed) {
535 ImagePersistence.saveFile(item).catch(console.error); 536 } 537 538 return item; 539 } 540 541 function rerenderDynamicI18n() { 542 fileQueue.forEach(item => { 543 updateFileItemActionLabels(item); 544 if (item.status === 'done' || item.status === 'error') { 545 updateFileItemUI(item); 546 } else { 547 const statusBadge = document.querySelector(`#item-${item.id} .file-status-badge`); 548 if (statusBadge) statusBadge.textContent = window.imgminI18n.t('file.status.processing'); 549 } 550 }); 551 552 updateDownloadLabels(); 553 updateAdvancedSummary(); 554 buildResizePresetOptions(); 555 buildRenamePreview(); 556 557 if (activeFileId !== null && comparisonModal.style.display === 'flex') { 558 const activeItem = fileQueue.find(f => f.id === activeFileId); 559 if (activeItem) { 560 setActiveFile(activeItem); 561 } 562 } 563 } 564 565 // --- Resize element refs (must be declared before the init calls below) --- 566 const resizeEnableEl = document.getElementById('resizeEnable'); 567 const resizeOptionsEl = document.getElementById('resizeOptions'); 568 const resizePresetEl = document.getElementById('resizePreset'); 569 const resizeWidthEl = document.getElementById('resizeWidth'); 570 const resizeHeightEl = document.getElementById('resizeHeight'); 571 const resizeChipEl = document.getElementById('resizeChip'); 572 const resizeChipTextEl = document.getElementById('resizeChipText'); 573 const resizeChipDismissEl = document.getElementById('resizeChipDismiss'); 574 575 // --- Rename element refs (must be before init calls) --- 576 const renameEnableEl = document.getElementById('renameEnable'); 577 const renameOptionsEl = document.getElementById('renameOptions'); 578 const renamePrefixEl = document.getElementById('renamePrefix'); 579 const renameSuffixEl = document.getElementById('renameSuffix'); 580 const renameKeepOriginalEl = document.getElementById('renameKeepOriginal'); 581 const renameSeqEl = document.getElementById('renameSeq'); 582 const renameSeqOptionsEl = document.getElementById('renameSeqOptions'); 583 const renameSeqStartEl = document.getElementById('renameSeqStart'); 584 const renameSeqPadEl = document.getElementById('renameSeqPad'); 585 const renamePreviewEl = document.getElementById('renamePreview'); 586 const renamePreviewTextEl = document.getElementById('renamePreviewText'); 587 588 // --- Watermark element refs (must be before init calls) --- 589 const watermarkEnableEl = document.getElementById('watermarkEnable'); 590 const watermarkOptionsEl = document.getElementById('watermarkOptions'); 591 const watermarkTextEl = document.getElementById('watermarkText'); 592 const watermarkImageInputEl = document.getElementById('watermarkImageInput'); 593 const watermarkImageLabelEl = document.getElementById('watermarkImageLabel'); 594 const watermarkImageClearEl = document.getElementById('watermarkImageClear'); 595 const watermarkOpacityEl = document.getElementById('watermarkOpacity'); 596 const watermarkOpacityValEl = document.getElementById('watermarkOpacityVal'); 597 const watermarkGridEl = document.getElementById('watermarkGrid'); 598 599 // Initialize stats 600 loadGlobalStats(); 601 updateDownloadLabels(); 602 updateAdvancedSummary(); 603 buildResizePresetOptions(); 604 applyUrlResizeParams(); 605 const LOCAL_RULE_KEY = 'imgmin_local_rule_v1'; 606 const saveRuleBtn = document.getElementById('saveRuleBtn'); 607 const reuseRuleBtn = document.getElementById('reuseRuleBtn'); 608 function readLocalRule() { 609 try { 610 const value = JSON.parse(localStorage.getItem(LOCAL_RULE_KEY)); 611 if (!value || !['auto', 'jpeg', 'png', 'webp'].includes(value.format) || 612 !Number.isInteger(value.targetKB) || value.targetKB < 0 || value.targetKB > 100000 || 613 !['balanced', 'quality', 'size'].includes(value.mode)) return null; 614 if (value.resize !== null && (!Number.isInteger(value.resize?.w) || value.resize.w < 1 || value.resize.w > 10000 || 615 !Number.isInteger(value.resize?.h) || value.resize.h < 1 || value.resize.h > 10000 || 616 !['cover', 'contain'].includes(value.resize?.fit))) return null; 617 return value; 618 } catch (_) { return null; } 619 } 620 if (reuseRuleBtn) reuseRuleBtn.hidden = !readLocalRule(); 621 saveRuleBtn?.addEventListener('click', () => { 622 const rule = { 623 mode: compressionMode, format: outputSettings.format, targetKB: outputSettings.targetKB, 624 background: outputSettings.background, 625 resize: resizeSettings.enabled && resizeSettings.width && resizeSettings.height 626 ? { w: resizeSettings.width, h: resizeSettings.height, fit: resizeSettings.fit } : null 627 }; 628 try { 629 localStorage.setItem(LOCAL_RULE_KEY, JSON.stringify(rule)); 630 reuseRuleBtn.hidden = false;
631 trackTask('rule_saved', null); 632 showToast(window.imgminI18n.t('rule.saved'), 'success'); 633 } catch (_) { showToast(window.imgminI18n.t('rule.unavailable'), 'error'); } 634 }); 635 reuseRuleBtn?.addEventListener('click', () => { 636 const rule = readLocalRule(); 637 if (!rule) { reuseRuleBtn.hidden = true; return; } 638 compressionMode = rule.mode; 639 document.querySelectorAll('.mode-btn[data-mode]').forEach(button => button.classList.toggle('active', button.dataset.mode === rule.mode)); 640 outputSettings.format = rule.format; 641 outputSettings.targetKB = rule.targetKB; 642 outputSettings.background = /^#[0-9a-f]{6}$/i.test(rule.background) ? rule.background : '#ffffff'; 643 outputFormatEl.value = rule.format; 644 targetKBEl.value = rule.targetKB || ''; 645 jpegBackgroundEl.value = outputSettings.background; 646 resizeSettings = rule.resize 647 ? { enabled: true, preset: null, width: rule.resize.w, height: rule.resize.h, fit: rule.resize.fit } 648 : { enabled: false, preset: null, width: null, height: null, fit: 'cover' }; 649 syncOutputControls(); 650 buildResizePresetOptions(); 651 syncResizeUI(); 652 updateAdvancedSummary(); 653 trackTask('rule_reused', null); 654 recompressAll(); 655 }); 656 shareRuleBtn?.addEventListener('click', async () => { 657 const url = new URL('https://imgmin.pro/'); 658 if (compressionMode !== 'balanced') url.searchParams.set('mode', compressionMode); 659 if (outputSettings.targetKB) url.searchParams.set('targetKB', String(outputSettings.targetKB)); 660 if (outputSettings.format !== 'auto') url.searchParams.set('format', outputSettings.format); 661 if (resizeSettings.enabled && resizeSettings.width && resizeSettings.height) { 662 url.searchParams.set('w', String(resizeSettings.width)); 663 url.searchParams.set('h', String(resizeSettings.height)); 664 url.searchParams.set('fit', resizeSettings.fit); 665 } 666 try { 667 if (navigator.share) await navigator.share({ title: window.imgminI18n.t('share.title'), url: url.href }); 668 else { await navigator.clipboard.writeText(url.href); showToast(window.imgminI18n.t('toast.copied'), 'success'); } 669 trackTask('rule_shared', null); 670 } catch (error) { 671 if (error.name !== 'AbortError') showToast(window.imgminI18n.t('share.api_error'), 'error'); 672 } 673 }); 674 675 const advancedSettings = document.querySelector('.advanced-settings'); 676 window.imgminAnalytics?.track('tool_view', {}); 677 document.getElementById('extBanner')?.addEventListener('click', () => { 678 window.imgminAnalytics?.track('extension_cta_clicked', {}); 679 }); 680 // Embedded landing pages declare presets via IMGMIN_TOOL_PRESET â open the 681 // settings panel once on load so the active target/format is visible. 682 if (advancedSettings && (toolPreset.targetKB || toolPreset.format || toolPreset.resizePreset || toolPreset.resizeWidth)) { 683 advancedSettings.open = true; 684 } 685 let advancedOpenRecorded = false; 686 advancedSettings?.addEventListener('toggle', () => { 687 if (advancedSettings.open && !advancedOpenRecorded) { 688 advancedOpenRecorded = true; 689 trackTask('advanced_settings_opened', null); 690 } 691 }); 692 document.addEventListener('pointerdown', event => { 693 if (advancedSettings?.open && !advancedSettings.contains(event.target)) advancedSettings.open = false; 694 }); 695 696 window.addEventListener('imgmin:languagechange', rerenderDynamicI18n); 697 698 // --- Persistence Init --- 699 ImagePersistence.getAllFiles().then(savedItems => { 700 if (savedItems && savedItems.length > 0) { 701 702 // Filter out items with invalid files just in case 703 const validItems = savedItems.filter(item => item.file && (item.file instanceof Blob || item.file instanceof File)); 704 705 if (validItems.length > 0) { 706 fileList.style.display = 'flex'; // Fix: Use flex for correct horizontal layout 707 batchActions.style.display = 'flex'; 708 709 validItems.sort((a, b) =>
709 a.timestamp - b.timestamp).forEach(item => { 710 item.restored = true; 711 item.exportRecorded = true; 712 item.cropPosition ||= { x: 0.5, y: 0.5 }; 713 fileQueue.push(item); 714 // FIX: Function name was wrong (createFileElement -> createFileItemUI) 715 createFileItemUI(item); 716 717 // If displaying existing compressed item, update UI state 718 if (['targetKB', 'format', 'preset', 'w', 'h', 'fit'].some(key => outputParams.has(key))) { 719 item.status = 'pending'; 720 compressImage(item, getCompressionQuality()); 721 } else if (item.status === 'done') { 722 ensureItemDimensions(item) 723 .then(() => updateFileItemUI(item)) 724 .catch(console.error); 725 } else if (item.status === 'error') { 726 if (item.errorReason === 'encode_failed') { 727 item.status = 'pending'; 728 compressImage(item, getCompressionQuality()); 729 } else { 730 updateFileItemUI(item); 731 } 732 } else { 733 compressImage(item, getCompressionQuality()); 734 } 735 }); 736 737 updateWorkspaceState(); 738 updateDownloadLabels(); 739 } 740 } 741 }).catch(console.error); 742 743 // --- Check Share API Support --- 744 745 746 747 748 dropZone.addEventListener('dragover', (e) => { 749 e.preventDefault(); 750 dropZone.style.borderColor = '#6C5CE7'; 751 }); 752 753 dropZone.addEventListener('dragleave', (e) => { 754 e.preventDefault(); 755 dropZone.style.borderColor = '#DEDEDE'; 756 }); 757 758 dropZone.addEventListener('drop', (e) => { 759 e.preventDefault(); 760 dropZone.style.borderColor = '#DEDEDE'; 761 const files = e.dataTransfer.files; 762 if (files.length > 0) { 763 window.imgminAnalytics?.track('upload_intent', { method: 'drop' }); 764 handleFiles(files, 'drop'); 765 } 766 }); 767 768 // ä»»å¡1ï¼ç¹å»æ´ä¸ªåºå触åä¸ä¼ (鲿¢ç¹å°æé®éå¤è§¦å) 769 dropZone.addEventListener('click', (e) => { 770 if (e.target !== fileInput && !e.target.closest('.upload-btn')) { 771 window.imgminAnalytics?.track('upload_intent', { method: 'select' }); 772 fileInput.click(); 773 } 774 }); 775 776 clearAllBtn.addEventListener('click', () => { 777 ImagePersistence.clearAll().then(() => { 778 fileQueue = []; 779 fileList.innerHTML = ''; 780 fileList.style.display = 'none'; 781 batchActions.style.display = 'none'; 782 comparisonModal.style.display = 'none'; 783 activeFileId = null; 784 fileInput.value = ''; // Reset input 785 updateWorkspaceState(); 786 updateDownloadLabels(); 787 if (typeof updateFloatingBtn === 'function') updateFloatingBtn(); 788 }); 789 }); 790 791 downloadAllBtn.addEventListener('click', () => downloadCompressed()); 792 793 if (downloadBtn) { 794 downloadBtn.addEventListener('click', () => downloadCompressed()); 795 } 796 797 if (modalMobileDownloadBtn) { 798 modalMobileDownloadBtn.addEventListener('click', () => { 799 if (activeFileId !== null) { 800 const item = fileQueue.find(f => f.id === activeFileId); 801 if (item && item.compressedBlob) { 802 downloadFile(item.file.name, item.compressedBlob); 803 } 804 } 805 }); 806 } 807 808 809 // --- File Handling --- 810 811 // --- Core Logic --- 812 813 function handleFileSelect(e) { 814 handleFiles(e.target.files, 'select'); 815 } 816 817 function handleFiles(files, method = 'select') { 818 const remainingSlots = 20 - fileQueue.length; 819 if (remainingSlots <= 0) { 820 alert(window.imgminI18n.t('batch.upload.limit')); 821 return; 822 } 823 824 const filesToProcess = Array.from(files).slice(0, remainingSlots); 825 if (files.length > remainingSlots) { 826 alert(window.imgminI18n.t('batch.upload.limit')); // Warn if some files were skipped 827 } 828 829 const accepted = filesToProcess.filter(file => /\.(heic|heif)$/i.test(file.name) || file.type === 'image/heic' || file.type === 'image/heif' || file.type.startsWith('image/')); 830 if (accepted.length) { 831 const count_bucket = accepted.length === 1 ? '1' : accepted.length <= 5 ? '2_5' : '6_20'; 832 window.imgminAnalytics?.track('files_accepted', { method, count_bucket }); 833 } 834 accepted.forEach(file => { 835 const isHeic = /\.(heic|heif)$/i.test(file.name) || file.type === 'image/heic' || file.type === 'image/heif'; 836 if (isHeic) { 837 convertHeicFile(file); 838 return; 839 } 840 if (!file.type.startsWith('image/')) return; 841 processNewFile(file); 842 }); 843 } 844 845 function processNewFile(file) { 846 const id = Date.now() + Math.random().toString(36).substr(2, 9); 847 const item = { 848 id: id, 849 file: file, 850 originalUrl: URL.createObjectURL(file), // For preview 851 compressedBlob: null, 852 status: 'pending', // pending, processing, done, error 853 timestamp: Date.now(), 854 originalWidth: null, 855 originalHeight: null, 856 compressedWidth: null, 857 compressedHeight: null, 858 cropPosition: { x: 0.5, y: 0.5 } 859 }; 860 861 fileQueue.push(item); 862 createFileItemUI(item); 863 864 // Initial Save
865 ImagePersistence.saveFile(item).catch(console.error); 866 867 // Immediate processing 868 compressImage(item, getCompressionQuality()); 869 870 // Show containers 871 fileList.style.display = 'flex'; // Fix: Use flex to respect CSS layout 872 batchActions.style.display = 'flex'; 873 updateWorkspaceState(); 874 updateDownloadLabels(); 875 876 // Don't auto-show comparison, user should click eye icon 877 } 878 879 function createFileItemUI(item) { 880 // Create URL if needed (for restored items) 881 if (!item.originalUrl && item.file) { 882 item.originalUrl = URL.createObjectURL(item.file); 883 } 884 885 const div = document.createElement('div'); 886 div.className = 'file-item'; 887 div.id = `item-${item.id}`; 888 889 div.innerHTML = ` 890 <div class="file-preview-wrapper"> 891 <img src="${item.originalUrl}" class="file-preview" alt="Preview"> 892 <span class="file-aspect-label" style="display: none;"></span> 893 <span class="file-status-badge status-processing">${window.imgminI18n.t('file.status.processing')}</span> 894 </div> 895 <div class="file-details"> 896 <div class="file-name">${item.file.name}</div> 897 <div class="file-meta"> 898 <div class="file-meta-primary"> 899 <span class="file-orig-size">${formatFileSize(item.file.size)}</span> 900 </div> 901 </div> 902 </div> 903 <div class="file-actions"> 904 <button class="icon-btn preview-item-btn" type="button"> 905 <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg> 906 </button> 907 <button class="icon-btn delete-item-btn" type="button"> 908 <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg> 909 </button> 910 </div> 911 `; 912 div.classList.add('ratio-unknown'); 913 updateFileItemActionLabels(item, div); 914 updateFileItemAspect(item); 915 916 // Event listeners for item buttons 917 918 919 div.querySelector('.preview-item-btn').addEventListener('click', (e) => { 920 e.stopPropagation();
921 setActiveFile(item); 922 }); 923 924 div.querySelector('.delete-item-btn').addEventListener('click', (e) => { 925 e.stopPropagation(); 926 removeFile(item.id); 927 }); 928 929 div.addEventListener('click', () => setActiveFile(item)); 930 931 fileList.appendChild(div); 932 } 933 934 function removeFile(id) { 935 const index = fileQueue.findIndex(i => i.id === id); 936 if (index > -1) { 937 const item = fileQueue[index]; 938 if (item.originalUrl) URL.revokeObjectURL(item.originalUrl); 939 fileQueue.splice(index, 1); 940 document.getElementById(`item-${id}`).remove(); 941 942 // Remove from DB 943 ImagePersistence.removeFile(id).catch(console.error); 944 945 if (activeFileId === id) { 946 comparisonModal.style.display = 'none'; 947 activeFileId = null; 948 } 949 950 if (fileQueue.length === 0) { 951 fileList.style.display = 'none'; // Revert to none if empty 952 batchActions.style.display = 'none'; 953 } 954 955 updateWorkspaceState(); 956 updateDownloadLabels(); 957 if (typeof updateFloatingBtn === 'function') updateFloatingBtn(); 958 } 959 } 960 961 function isCropAvailable(item) { 962 if (!resizeSettings.enabled || resizeSettings.fit === 'contain' || !resizeSettings.width || !resizeSettings.height) return false; 963 if (!item.originalWidth || !item.originalHeight) return false; 964 return Math.abs(item.originalWidth / item.originalHeight - resizeSettings.width / resizeSettings.height) > 0.001; 965 } 966 967 function renderCropDraft() { 968 if (!compressedWrapper.classList.contains('crop-enabled')) return; 969 compressedImage.style.objectPosition = `${cropDraft.x * 100}% ${cropDraft.y * 100}%`; 970 } 971 972 function updateCropFrame() { 973 if (!compressedWrapper.classList.contains('crop-enabled')) return; 974 const ratio = resizeSettings.width / resizeSettings.height; 975 const availableWidth = compressedWrapper.parentElement.clientWidth; 976 const maxHeight = Math.min(window.innerHeight * 0.3, 300); 977 const width = Math.min(availableWidth, maxHeight * ratio); 978 compressedWrapper.style.width = `${width}px`; 979 compressedWrapper.style.height = `${width / ratio}px`; 980 compressedWrapper.style.marginInline = 'auto'; 981 } 982 983 function setupCropEditor(item) { 984 compressedWrapper.classList.remove('crop-enabled'); 985 compressedWrapper.style.removeProperty('aspect-ratio'); 986 compressedWrapper.style.removeProperty('width'); 987 compressedWrapper.style.removeProperty('height'); 988 compressedWrapper.style.removeProperty('margin-inline'); 989 compressedImage.style.removeProperty('object-position'); 990 if (cropControls) cropControls.hidden = true; 991 if (!isCropAvailable(item)) return; 992 993 const previous = compressedImage.src; 994 if (previous && previous.startsWith('blob:') && !fileQueue.some(entry => entry.originalUrl === previous)) URL.revokeObjectURL(previous); 995 item.cropPosition ||= { x: 0.5, y: 0.5 }; 996 cropDraft = { ...item.cropPosition }; 997 compressedImage.src = item.originalUrl; 998 compressedImage.style.display = 'block'; 999 compressedWrapper.classList.add('crop-enabled'); 1000 compressedWrapper.style.aspectRatio = `${resizeSettings.width} / ${resizeSettings.height}`; 1001 if (cropControls) cropControls.hidden = false; 1002 renderCropDraft(); 1003 updateCropFrame(); 1004 } 1005 1006 function setMobilePreview(mode) { 1007 const showOriginal = mode === 'original'; 1008 comparisonModal.dataset.mobilePreview = showOriginal ? 'original' : 'compressed'; 1009 mobileOriginalTab?.classList.toggle('active', showOriginal); 1010 mobileCompressedTab?.classList.toggle('active', !showOriginal); 1011 mobileOriginalTab?.setAttribute('aria-selected', String(showOriginal)); 1012 mobileCompressedTab?.setAttribute('aria-selected', String(!showOriginal)); 1013 } 1014 1015 mobileOriginalTab?.addEventListener('click', () => setMobilePreview('original')); 1016 mobileCompressedTab?.addEventListener('click', () => setMobilePreview('compressed')); 1017 1018 window.addEventListener('resize', updateCropFrame); 1019 1020 async function setActiveFile(item) { 1021 activeFileId = item.id; 1022 1023 // Ensure URLs exist (for restored items) 1024 if (!item.originalUrl && item.file) { 1025 item.originalUrl = URL.createObjectURL(item.file); 1026 } 1027 1028 // Highlight in list 1029 document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active')); 1030 const el = document.getElementById(`item-${item.id}`); 1031 if (el) el.classList.add('active'); 1032 1033 // Show comparison modal 1034 comparisonModal.style.display = 'flex'; 1035 if (typeof updateFloatingBtn === 'function') updateFloatingBtn(); 1036 setMobilePreview('compressed'); 1037 originalImage.src = item.originalUrl; 1038 originalSize.textContent = formatFileSize(item.file.size); 1039 await ensureItemDimensions(item); 1040 renderResolutionInfo(item); 1041 1042 // Reset image styles 1043 originalImage.style.width = 'auto'; 1044 originalImage.style.maxWidth = '100%'; 1045 compressedImage.style.width = 'auto'; 1046 compressedImage.style.maxWidth = '100%'; 1047 1048 if (item.compressedBlob) { 1049 const prevSrc = compressedImage.src; 1050 if (prevSrc && prevSrc.startsWith('blob:') && !fileQueue.some(entry => entry.originalUrl === prevSrc)) URL.revokeObjectURL(prevSrc); 1051 compressedImage.src = URL.createObjectURL(item.compressedBlob); 1052 compressedImage.style.display = 'block'; 1053 compressedSize.textContent = formatFileSize(item.compressedBlob.size); 1054 renderResolutionInfo(item); 1055 updateStats(item.file.size, item.compressedBlob.size); 1056 updateMobileModalSummary(item); 1057 } else { 1058 const prevSrc = compressedImage.src; 1059 if (prevSrc && prevSrc.startsWith('blob:') && !fileQueue.some(entry => entry.originalUrl === prevSrc)) URL.revokeObjectURL(prevSrc); 1060 compressedImage.removeAttribute('src'); 1061 compressedImage.style.display = 'none'; 1062 compressedSize.textContent = 'â'; 1063 compressedResolution.textContent = ''; 1064 statsReport.style.display = 'none'; 1065 // Clear mobile summary when no compressed result yet 1066 if (mobileOrigSize) mobileOrigSize.textContent = ''; 1067 if (mobileCompSize) mobileCompSize.textContent = ''; 1068 if (mobileSavePct) mobileSavePct.textContent = ''; 1069 if (mobileResolution) mobileResolution.textContent = ''; 1070 } 1071 setupCropEditor(item); 1072 } 1073 1074 compressedWrapper.addEventListener('pointerdown', event => { 1075 if (!compressedWrapper.classList.contains('crop-enabled')) return; 1076 event.preventDefault(); 1077 cropDrag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, position: { ...cropDraft } }; 1078 compressedWrapper.setPointerCapture?.(event.pointerId); 1079 compressedWrapper.classList.add('is-dragging'); 1080 }); 1081 compressedWrapper.addEventListener('pointermove', event => { 1082 if (!cropDrag || cropDrag.pointerId !== event.pointerId) return; 1083 const rect = compressedWrapper.getBoundingClientRect(); 1084 cropDraft.x = Math.min(1, Math.max(0, cropDrag.position.x - (event.clientX - cropDrag.startX) / rect.width)); 1085 cropDraft.y = Math.min(1, Math.max(0, cropDrag.position.y - (event.clientY - cropDrag.startY) / rect.height)); 1086 renderCropDraft(); 1087 }); 1088 const finishCropDrag = event => { 1089 if (!cropDrag || cropDrag.pointerId !== event.pointerId) return; 1090 compressedWrapper.releasePointerCapture?.(event.pointerId); 1091 cropDrag = null; 1092 compressedWrapper.classList.remove('is-dragging'); 1093 }; 1094 compressedWrapper.addEventListener('pointerup', finishCropDrag); 1095 compressedWrapper.addEventListener('pointercancel', finishCropDrag); 1096 cropCancel?.addEventListener('click', () => { 1097 const item = fileQueue.find(entry => entry.id === activeFileId); 1098 if (!item) return; 1099 cropDraft = { ...(item.cropPosition || { x: 0.5, y: 0.5 }) }; 1100 renderCropDraft(); 1101 }); 1102 cropApply?.addEventListener('click', () => { 1103 const item = fileQueue.find(entry => entry.id === activeFileId); 1104 if (!item || cropApplying) return; 1105 cropApplying = true; 1106 cropApply.disabled = true; 1107 cropApply.setAttribute('aria-busy', 'true'); 1108 cropApply.textContent = window.imgminI18n.t('crop.applying'); 1109 const previousBlob = item.compressedBlob; 1110 const previousStatus = item.status; 1111 item.cropPosition = { ...cropDraft }; 1112 item.status = 'pending'; 1113 item.compressedBlob = null;
1114 ImagePersistence.saveFile(item).catch(console.error); 1115 compressImage(item, getCompressionQuality(), { 1116 onSuccess: () => { 1117 cropApplying = false; 1118 cropApply.disabled = false; 1119 cropApply.removeAttribute('aria-busy'); 1120 cropApply.textContent = window.imgminI18n.t('crop.apply'); 1121 showToast(window.imgminI18n.t('crop.applied'), 'success'); 1122 }, 1123 onError: () => { 1124 if (previousBlob) { 1125 item.compressedBlob = previousBlob; 1126 item.status = previousStatus === 'done' ? 'done' : 'error'; 1127 ImagePersistence.saveFile(item).catch(console.error); 1128 updateFileItemUI(item); 1129 if (activeFileId === item.id && comparisonModal.style.display === 'flex') setActiveFile(item); 1130 } 1131 cropApplying = false; 1132 cropApply.disabled = false; 1133 cropApply.removeAttribute('aria-busy'); 1134 cropApply.textContent = window.imgminI18n.t('crop.apply'); 1135 showToast(window.imgminI18n.t('crop.applyFailed'), 'error'); 1136 } 1137 }); 1138 }); 1139 1140 function updateMobileModalSummary(item) { 1141 if (!item.compressedBlob) return; 1142 const savedPct = Math.round((1 - item.compressedBlob.size / item.file.size) * 100); 1143 if (mobileOrigSize) mobileOrigSize.textContent = formatFileSize(item.file.size); 1144 if (mobileCompSize) mobileCompSize.textContent = formatFileSize(item.compressedBlob.size); 1145 if (mobileSavePct) mobileSavePct.textContent = `â${savedPct}%`; 1146 if (mobileResolution) mobileResolution.textContent = getDimensionsSummary(item); 1147 } 1148 1149 function updateStats(origSize, compSize) { 1150 if (!compSize || compSize >= origSize) { 1151 statsReport.style.display = 'none'; 1152 return; 1153 } 1154 1155 const savedBytes = origSize - compSize; 1156 const savedStr = formatFileSize(savedBytes); 1157 1158 // ä»»å¡3ï¼ç²¾ç®ç»è®¡ææ¡ï¼åªæ¾ç¤ºèççå¤§å° 1159 const text = window.imgminI18n.t('compression.stats', { 1160 size: savedStr 1161 }); 1162 1163 statsText.textContent = text; 1164 statsReport.style.display = 'flex'; 1165 } 1166 1167 function compressImage(item, quality, callbacks = {}) { 1168 // If restored item is already done, skip compression 1169 if (item.status === 'done' && item.compressedBlob) { 1170 // Just ensure stats recorded if needed? Or assume done. 1171 return; 1172 } 1173 1174 const generation = item.generation = (item.generation || 0) + 1; 1175 const current = () => item.generation === generation && fileQueue.includes(item); 1176 const settings = { ...outputSettings }; 1177 item.processStartedAt = Date.now(); 1178 item.exportRecorded = false; 1179 item.taskSettings = settings; 1180 item.taskType = settings.targetKB ? 'target_size' : settings.format !== 'auto' ? 'convert' 1181 : resizeSettings.enabled && resizeSettings.width && resizeSettings.height ? 'resize' 1182 : fileQueue.length > 1 ? 'batch' : 'compress'; 1183 item.targetMet = null; 1184 item.compressedBlob = null; 1185 item.status = 'processing'; 1186 updateFileItemUI(item); 1187 if (!item.taskStarted && !item.restored) { item.taskStarted = true; trackTask('task_start', item); } 1188 const fail = reason => { 1189 if (!current()) return; 1190 item.status = 'error'; 1191 item.errorReason = reason;
1192 ImagePersistence.saveFile(item).catch(console.error); 1193 updateFileItemUI(item); 1194 item.failureReasons ||= new Set(); 1195 if (!item.restored && !item.failureReasons.has(reason)) { 1196 item.failureReasons.add(reason); 1197 trackTask('process_fail', item, { reason }); 1198 } 1199 callbacks.onError?.(reason); 1200 }; 1201 const img = new Image(); 1202 img.onerror = () => fail('decode_failed'); 1203 // Ensure URL 1204 if (!item.originalUrl) item.originalUrl = URL.createObjectURL(item.file); 1205 img.src = item.originalUrl; 1206 1207 img.onload = async function () { 1208 if (!current()) return; 1209 try { 1210 await window.ImgMinTargetEncoder.assertStatic(item.file); 1211 if (!current()) return; 1212 const canvas = document.createElement('canvas'); 1213 const ctx = canvas.getContext('2d'); 1214 1215 // Calculate dimensions based on mode 1216 let targetWidth = img.width; 1217 let targetHeight = img.height; 1218 let targetQuality = quality; 1219 const compressionPreset = getCompressionPreset(); 1220 1221 // Explicit user resize overrides mode-based dimension caps 1222 let resizeActive = false; 1223 let coverCrop = null; 1224 if (resizeSettings.enabled && resizeSettings.width > 0 && resizeSettings.height > 0) { 1225 resizeActive = true; 1226 const tw = resizeSettings.width; 1227 const th = resizeSettings.height; 1228 if (resizeSettings.fit === 'cover') { 1229 // Scale to fill, center-crop to exact twÃth (upscale allowed for platform requirements) 1230 const scale = Math.max(tw / img.width, th / img.height); 1231 const sw = tw / scale; 1232 const sh = th / scale; 1233 const focal = item.cropPosition || { x: 0.5, y: 0.5 }; 1234 const sx = Math.max(0, Math.min(img.width - sw, focal.x * img.width - sw / 2)); 1235 const sy = Math.max(0, Math.min(img.height - sh, focal.y * img.height - sh / 2)); 1236 coverCrop = { sx, sy, sw, sh }; 1237 targetWidth = tw; 1238 targetHeight = th; 1239 } else { 1240 // contain: fit within twÃth, never upscale 1241 const ratio = Math.min( 1242 Math.min(tw, img.width) / img.width, 1243 Math.min(th, img.height) / img.height 1244 ); 1245 targetWidth = Math.floor(img.width * ratio); 1246 targetHeight = Math.floor(img.height * ratio); 1247 } 1248 } 1249 1250 if (!resizeActive && compressionPreset.maxDimension && (img.width > compressionPreset.maxDimension || img.height > compressionPreset.maxDimension)) { 1251 const ratio = Math.min(compressionPreset.maxDimension / img.width, compressionPreset.maxDimension / img.height); 1252 targetWidth = Math.floor(img.width * ratio); 1253 targetHeight = Math.floor(img.height * ratio); 1254 } 1255 1256 item.originalWidth = img.width; 1257 item.originalHeight = img.height; 1258 item.compressedWidth = targetWidth; 1259 item.compressedHeight = targetHeight; 1260 updateFileItemAspect(item); 1261 1262 canvas.width = targetWidth; 1263 canvas.height = targetHeight; 1264 if (!ctx) throw new Error('encode_failed'); 1265 if (settings.format === 'jpeg') { 1266 ctx.fillStyle = settings.background; 1267 ctx.fillRect(0, 0, targetWidth, targetHeight); 1268 } 1269 ctx.imageSmoothingEnabled = true; 1270 ctx.imageSmoothingQuality = 'high'; 1271 if (coverCrop) { 1272 ctx.drawImage(img, coverCrop.sx, coverCrop.sy, coverCrop.sw, coverCrop.sh, 0, 0, targetWidth, targetHeight); 1273 } else { 1274 ctx.drawImage(img, 0, 0, targetWidth, targetHeight); 1275 } 1276 1277 // Watermark: draw after image, before encoding 1278 if (watermarkSettings.enabled && (watermarkSettings.text || watermarkSettings.imageEl)) { 1279 const wm = watermarkSettings; 1280 ctx.save(); 1281 ctx.globalAlpha = wm.opacity; 1282 const shortSide = Math.min(targetWidth, targetHeight); 1283 const pad = Math.round(shortSide * 0.03); 1284 1285 // Position offsets 1286 const posMap = { 1287 'top-left': [pad, pad, 'left', 'top'], 1288 'top-center': [targetWidth / 2, pad, 'center', 'top'], 1289 'top-right': [targetWidth - pad, pad, 'right', 'top'], 1290 'center-left': [pad, targetHeight / 2, 'left', 'middle'], 1291 'center': [targetWidth / 2, targetHeight / 2, 'center', 'middle'], 1292 'center-right': [targetWidth - pad, targetHeight / 2, 'right', 'middle'], 1293 'bottom-left': [pad, targetHeight - pad, 'left', 'bottom'], 1294 'bottom-center': [targetWidth / 2, targetHeight - pad, 'center', 'bottom'], 1295 'bottom-right': [targetWidth - pad, targetHeight - pad, 'right', 'bottom'], 1296 }; 1297 const [wx, wy, hAlign, vAlign] = posMap[wm.position] || posMap['bottom-right']; 1298 ctx.textAlign = hAlign === 'center' ? 'center' : hAlign === 'right' ? 'right' : 'left'; 1299 1300 if (wm.imageEl) { 1301 const maxW = Math.round(targetWidth * 0.2); 1302 const scale = Math.min(maxW / wm.imageEl.naturalWi
1302dth, 1); 1303 const iw = Math.round(wm.imageEl.naturalWidth * scale); 1304 const ih = Math.round(wm.imageEl.naturalHeight * scale); 1305 const ix = hAlign === 'right' ? wx - iw : hAlign === 'center' ? wx - iw / 2 : wx; 1306 const iy = vAlign === 'bottom' ? wy - ih : vAlign === 'middle' ? wy - ih / 2 : wy; 1307 ctx.drawImage(wm.imageEl, ix, iy, iw, ih); 1308 } 1309 1310 if (wm.text) { 1311 const fontSize = Math.max(14, Math.round(shortSide * 0.04)); 1312 ctx.font = `600 ${fontSize}px -apple-system, sans-serif`; 1313 const textY = vAlign === 'bottom' ? wy - (wm.imageEl ? Math.round(wm.imageEl.naturalHeight * Math.min(targetWidth * 0.2 / wm.imageEl.naturalWidth, 1)) + 4 : 0) : wy + (vAlign === 'top' ? fontSize : fontSize / 2); 1314 ctx.shadowColor = 'rgba(0,0,0,0.55)'; 1315 ctx.shadowBlur = 4; 1316 ctx.fillStyle = 'white'; 1317 ctx.fillText(wm.text, wx, textY); 1318 ctx.shadowColor = 'transparent'; 1319 } 1320 ctx.restore(); 1321 } 1322 1323 // Auto mode keeps the input format. PNG stays PNG (lossless, preserves 1324 // transparency) â never silently downgraded to JPEG. Matches the Chrome 1325 // extension's policy and avoids handing back a .png that is actually JPEG. 1326 const mimeType = normalizeMimeType(item.file, settings.format); 1327 1328 item.outputWidth = targetWidth; 1329 item.outputHeight = targetHeight; 1330 item.inputWidth = img.width; 1331 item.inputHeight = img.height; 1332 1333 const blob = await window.ImgMinTargetEncoder.encode({ 1334 type: mimeType, quality: targetQuality, minQuality: compressionPreset.minQuality / 100, 1335 targetBytes: settings.targetKB * 1000, 1336 isCurrent: current, 1337 encodeBlob: q => new Promise(resolve => canvas.toBlob(resolve, mimeType, q)) 1338 }); 1339 if (!current()) return; 1340 const dimsChanged = targetWidth !== img.width || targetHeight !== img.height; 1341 const mustTransform = (resizeActive && dimsChanged) || watermarkSettings.enabled || settings.format !== 'auto'; 1342 const keepOriginal = compressionPreset.keepSmallerOriginal && !mustTransform && blob.size >= item.file.size; 1343 item.compressedBlob = keepOriginal 1344 ? (item.file.type === mimeType ? item.file : new Blob([item.file], { type: mimeType })) 1345 : blob; 1346 if (keepOriginal) { 1347 item.compressedWidth = item.originalWidth; 1348 item.compressedHeight = item.originalHeight; 1349 } 1350 item.outputWidth = item.compressedWidth; 1351 item.outputHeight = item.compressedHeight; 1352 item.targetMet = settings.targetKB ? item.compressedBlob.size <= settings.targetKB * 1000 : null; 1353 item.status = 'done'; 1354 updateFileItemUI(item); 1355 if (typeof updateFloatingBtn === 'function') updateFloatingBtn();
1356 ImagePersistence.saveFile(item).catch(console.error); 1357 if (!item.globalStatsRecorded && !item.restored) { 1358 incrementGlobalStats(item.file.size - item.compressedBlob.size); 1359 item.globalStatsRecorded = true; 1360 } 1361 if (!item.successRecorded && !item.restored) { 1362 const seconds = (Date.now() - item.processStartedAt) / 1000; 1363 const duration_bucket = seconds < 1 ? 'under_1s' : seconds < 3 ? '1_3s' : seconds < 10 ? '3_10s' : 'over_10s'; 1364 trackTask('process_success', item, { duration_bucket }); 1365 item.successRecorded = true; 1366 } 1367 if (item.targetMet && !item.targetRecorded && !item.restored) { 1368 trackTask('target_met', item); 1369 item.targetRecorded = true; 1370 } 1371 if (activeFileId === item.id && comparisonModal.style.display === 'flex') setActiveFile(item); 1372 callbacks.onSuccess?.(item); 1373 } catch (error) { 1374 if (error.message !== 'cancelled') fail(['unsupported_output', 'unsupported_input'].includes(error.message) ? error.message : 'encode_failed'); 1375 } 1376 1377 }; 1378 } 1379 1380 function updateFileItemUI(item) { 1381 const el = document.getElementById(`item-${item.id}`); 1382 if (!el) return; 1383 1384 const statusBadge = el.querySelector('.file-status-badge'); 1385 const metaEl = el.querySelector('.file-meta'); 1386 1387 if (item.status !== 'done') { 1388 if (statusBadge) { 1389 statusBadge.style.display = 'inline-flex'; 1390 statusBadge.className = 'file-status-badge ' + (item.status === 'error' ? 'status-warning' : 'status-processing'); 1391 statusBadge.textContent = window.imgminI18n.t(item.status === 'error' ? 'output.error' : 'file.status.processing'); 1392 } 1393 el.querySelectorAll('.file-stats, .file-dims, .target-result, .target-guidance').forEach(node => node.remove()); 1394 if (item.status === 'error') { 1395 const detail = document.createElement('div'); 1396 detail.className = 'target-result'; 1397 detail.textContent = window.imgminI18n.t(item.errorReason === 'unsupported_input' ? 'output.animation' : 'output.errorHint'); 1398 metaEl.appendChild(detail); 1399 } 1400 updateDownloadLabels(); 1401 return; 1402 } 1403 if (item.status === 'done' && item.compressedBlob) { 1404 const savedPercentage = Math.round((1 - item.compressedBlob.size / item.file.size) * 100); 1405 updateFileItemAspect(item); 1406 updateDownloadLabels(); 1407 1408 if (statusBadge) { 1409 statusBadge.style.display = 'none'; 1410 } 1411 1412 let savingsBadge = el.querySelector('.file-savings-badge'); 1413 if (!savingsBadge) { 1414 savingsBadge = document.createElement('span'); 1415 savingsBadge.className = 'file-savings-badge'; 1416 el.querySelector('.file-preview-wrapper')?.appendChild(savingsBadge); 1417 } 1418 savingsBadge.classList.toggle('size-increased', savedPercentage < 0); 1419 savingsBadge.textContent = `${savedPercentage >= 0 ? 'â' : 'â'}${Math.abs(savedPercentage)}%`; 1420 1421 // Update original size label (may have changed if re-compressing) 1422 const origSizeEl = metaEl.querySelector('.file-orig-size'); 1423 if (origSizeEl) origSizeEl.textContent = `${window.imgminI18n.t('file.originalShort')} ${formatFileSize(item.file.size)}`; 1424 1425 // Compressed result line 1426 let statsSpan = metaEl.querySelector('.file-stats'); 1427 if (!statsSpan) { 1428 statsSpan = document.createElement('div'); 1429 statsSpan.className = 'file-stats'; 1430 metaEl.querySelector('.file-meta-primary').appendChild(statsSpan); 1431 } 1432 statsSpan.classList.toggle('size-increased', savedPercentage < 0); 1433 const outputParts = [ 1434 ['file-output-arrow', 'â'], 1435 ['file-output-size', formatFileSize(item.compressedBlob.size)], 1436 ['file-output-separator file-output-separator-format', '·'], 1437 ['file-output-format', item.compressedBlob.type.replace('image/', '').toUpperCase()], 1438 ['file-output-separator file-output-separator-saving', '·'], 1439 ['file-output-saving', `${savedPercentage >= 0 ? 'â' : 'â'}${Math.abs(savedPercentage)}%`] 1440 ]; 1441 statsSpan.replaceChildren(...outputParts.map(([className, text]) => { 1442 const span = document.createElement('span'); 1443 span.className = className; 1444 span.textContent = text; 1445 return span; 1446 })); 1447 let targetLine = metaEl.querySelector('.target-result'); 1448 if (!targetLine) { targetLine = document.createElement('div'); targetLine.className = 'target-result'; metaEl.appendChild(targetLine); } 1449 targetLine.textContent = item.targetMet === true 1450 ? window.imgminI18n.t('output.targetMet', { size: item.taskSettings.targetKB }) 1451 : item.targetMet === false 1452 ? window.imgminI18n.t('output.targetUnmet', { size: item.taskSettings.targetKB }) 1453 : ''; 1454 targetLine.classList.toggle('status-done', item.targetMet === true); 1455 targetLine.classList.toggle('status-warning', item.targetMet === false); 1456 metaEl.querySelector('.target-guidance')?.remove(); 1457 if (item.targetMet === false) { 1458 const guidance = document.createElement('div'); 1459 guidance.className = 'target-guidance'; 1460 const isPng = item.compressedBlob.type === 'image/png'; 1461 const message = document.createElement('p'); 1462 message.textContent = window.imgminI18n.t(isPng ? 'output.unmetGuidePng' : 'output.unmetGuide'); 1463 const actions = document.createElement('div'); 1464 actions.className = 'target-guidance-actions'; 1465 const addAction = (key, handler) => { 1466 const button = document.createElement('button'); 1467 button.type = 'button'; 1468 button.className = 'target-guidance-btn'; 1469 button.textContent = window.imgminI18n.t(key); 1470 button.addEventListener('click', event => { 1471 event.stopPropagation(); 1472 handler(); 1473 }); 1474 actions.appendChild(button); 1475 }; 1476 1477 if (isPng) {
1478 addAction('output.convertWebp', () => { 1479 outputSettings.format = 'webp'; 1480 outputFormatEl.value = 'webp'; 1481 syncOutputControls(); 1482 updateAdvancedSummary(); 1483 recompressAll(); 1484 }); 1485 } else if (compressionMode !== 'size') { 1486 addAction('output.trySmaller', () => { 1487 compressionMode = 'size'; 1488 document.querySelectorAll('.mode-btn[data-mode]').forEach(button => { 1489 button.classList.toggle('active', button.dataset.mode === 'size'); 1490 }); 1491 updateAdvancedSummary(); 1492 recompressAll(); 1493 }); 1494 } 1495 addAction('output.adjustDimensions', () => { 1496 const settings = document.querySelector('.advanced-settings'); 1497 if (settings) { 1498 settings.open = true; 1499 settings.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 1500 window.setTimeout(() => resizePresetEl?.focus(), 180); 1501 } 1502 }); 1503 guidance.append(message, actions); 1504 metaEl.appendChild(guidance); 1505 } else { 1506 targetLine.onclick = null; 1507 targetLine.removeAttribute('tabindex'); 1508 } 1509 1510 1511 // Always show the actual output dimensions beside size and format. 1512 let dimsSpan = metaEl.querySelector('.file-dims'); 1513 if (item.outputWidth && item.outputHeight) { 1514 if (!dimsSpan) { 1515 dimsSpan = document.createElement('div'); 1516 dimsSpan.className = 'file-dims'; 1517 metaEl.querySelector('.file-meta-primary').appendChild(dimsSpan); 1518 } 1519 dimsSpan.textContent = `${item.outputWidth} Ã ${item.outputHeight}`; 1520 } else if (dimsSpan) { 1521 dimsSpan.remove(); 1522 } 1523 1524 // HEIC badge 1525 const nameEl = el.querySelector('.file-name'); 1526 if (nameEl && item.convertedFromHeic && !nameEl.querySelector('.heic-badge')) { 1527 const badge = document.createElement('span'); 1528 badge.className = 'heic-badge'; 1529 badge.textContent = window.imgminI18n.t('heic.badge'); 1530 nameEl.appendChild(badge); 1531 } 1532 1533 1534 } 1535 } 1536 1537 let heic2anyLoaded = false; 1538 async function convertHeicFile(file) { 1539 // Create a placeholder item first so user sees progress immediately 1540 const placeholderName = file.name.replace(/\.(heic|heif)$/i, '.jpg'); 1541 const id = Date.now() + Math.random().toString(36).substr(2, 9); 1542 const item = { 1543 id, file, 1544 originalUrl: null, 1545 compressedBlob: null, 1546 status: 'processing', 1547 timestamp: Date.now(), 1548 originalWidth: null, originalHeight: null, 1549 compressedWidth: null, compressedHeight: null, 1550 convertedFromHeic: true, 1551 convertingHeic: true, 1552 heicDisplayName: placeholderName, 1553 }; 1554 fileQueue.push(item); 1555 1556 // Minimal placeholder UI while converting 1557 const div = document.createElement('div'); 1558 div.className = 'file-item';
1559 div.id = `item-${item.id}`; 1560 div.innerHTML = ` 1561 <div class="file-preview-wrapper"> 1562 <div class="file-preview" style="background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:1.4rem;">ð¼</div> 1563 <span class="file-status-badge status-processing">${window.imgminI18n.t('heic.converting')}</span> 1564 </div> 1565 <div class="file-details"> 1566 <div class="file-name">${placeholderName}<span class="heic-badge">${window.imgminI18n.t('heic.badge')}</span></div> 1567 <div class="file-meta"><div class="file-meta-primary"><span class="file-orig-size">${formatFileSize(file.size)}</span></div></div> 1568 </div> 1569 <div class="file-actions"></div>`; 1570 fileList.appendChild(div); 1571 fileList.style.display = 'flex'; 1572 batchActions.style.display = 'flex'; 1573 updateDownloadLabels(); 1574 1575 item.taskStarted = true; 1576 trackTask('task_start', item); 1577 try { 1578 if (!heic2anyLoaded) { 1579 await new Promise((resolve, reject) => { 1580 const s = document.createElement('script'); 1581 s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/heic2any.min.js'; 1582 s.onload = () => { heic2anyLoaded = true; resolve(); }; 1583 s.onerror = reject; 1584 document.head.appendChild(s); 1585 }); 1586 } 1587 const jpegBlob = await window.heic2any({ blob: file, toType: 'image/jpeg', quality: 0.92 }); 1588 if (!fileQueue.includes(item)) return; 1589 item.convertingHeic = false; 1590 const jpegFile = new File([jpegBlob], placeholderName, { type: 'image/jpeg' }); 1591 item.file = jpegFile; 1592 item.originalUrl = URL.createObjectURL(jpegFile); 1593 1594 // Replace placeholder with real preview 1595 const previewWrapper = div.querySelector('.file-preview-wrapper'); 1596 if (previewWrapper) { 1597 previewWrapper.innerHTML = ` 1598 <img src="${item.originalUrl}" class="file-preview" alt="Preview"> 1599 <span class="file-aspect-label" style="display:none;"></span> 1600 <span class="file-status-badge status-processing">${window.imgminI18n.t('file.status.processing')}</span>`; 1601 } 1602 const actionsEl = div.querySelector('.file-actions'); 1603 if (actionsEl) { 1604 actionsEl.innerHTML = ` 1605 <button class="icon-btn preview-item-btn" type="button"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg></button> 1606 <button class="icon-btn delete-item-btn" type="button"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>`; 1607 actionsEl.querySelector('.preview-item-btn').addEventListener('click', e => { e.stopPropagation(); setActiveFile(item); }); 1608 actionsEl.querySelector('.delete-item-btn').addEventListener('click', e => { e.stopPropagation(); deleteFileItem(item); }); 1609 } 1610 1611 item.status = 'pending';
1612 ImagePersistence.saveFile(item).catch(console.error); 1613 item.cropPosition ||= { x: 0.5, y: 0.5 }; 1614 compressImage(item, getCompressionQuality()); 1615 } catch (err) { 1616 console.error('HEIC conversion failed:', err); 1617 const badge = div.querySelector('.file-status-badge'); 1618 item.convertingHeic = false; 1619 item.status = 'error'; 1620 if (badge) { badge.className = 'file-status-badge status-warning'; badge.textContent = window.imgminI18n.t('output.error'); } 1621 trackTask('process_fail', item, { reason: 'heic_decode_failed' }); 1622 } 1623 } 1624 1625 async function downloadCompressed() { 1626 if (fileQueue.length === 0) { 1627 showToast(window.imgminI18n.t('toast.upload_first'), 'error'); 1628 window.scrollTo({ top: 0, behavior: 'smooth' }); 1629 return; 1630 } 1631 1632 const compressedItems = getCompressedItems(); 1633 if (compressedItems.length === 0) return; 1634 1635 if (compressedItems.length > 2) { 1636 const zip = new JSZip(); 1637 const usedNames = new Set(); 1638 compressedItems.forEach((item, idx) => { 1639 const requested = buildDownloadName(item.file.name, item.compressedBlob, idx); 1640 const dot = requested.lastIndexOf('.'); 1641 const base = dot > 0 ? requested.slice(0, dot) : requested; 1642 const ext = dot > 0 ? requested.slice(dot) : ''; 1643 let name = requested; 1644 let suffix = 2; 1645 while (usedNames.has(name.toLowerCase())) name = `${base}-${suffix++}${ext}`; 1646 usedNames.add(name.toLowerCase()); 1647 zip.file(name, item.compressedBlob); 1648 }); 1649 const zipBlob = await zip.generateAsync({ type: 'blob' }); 1650 const link = document.createElement('a'); 1651 link.download = 'imgmin_compressed.zip'; 1652 const zipUrl = URL.createObjectURL(zipBlob); 1653 link.href = zipUrl; 1654 link.click(); 1655 compressedItems.forEach(item => trackExport(item.compressedBlob, 'zip')); 1656 setTimeout(() => URL.revokeObjectURL(zipUrl), 1000); 1657 } else { 1658 compressedItems.forEach((item, idx) => { 1659 downloadFile(item.file.name, item.compressedBlob, idx); 1660 }); 1661 } 1662 } 1663 1664 // Build a download name whose extension matches the ACTUAL blob content. 1665 // Guarantees we never hand back a .png that is really a JPEG (or vice versa). 1666 // index (0-based) is used for sequence numbering when rename is active. 1667 function buildDownloadName(filename, blob, index = 0) { 1668 const extMap = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/avif': 'avif' }; 1669 const ext = extMap[blob && blob.type]; 1670 const base = filename.replace(/\.[^.]+$/, ''); 1671 1672 if (renameSettings.enabled) { 1673 const p = renameSettings.prefix; 1674 const s = renameSettings.suffix; 1675 const keepBase = renameSettings.keepOriginal ? base : ''; 1676 const seq = renameSettings.seq 1677 ? String(renameSettings.seqStart + index).padStart(renameSettings.seqPad, '0') 1678 : ''; 1679 const raw = (p + keepBase + s + seq).replace(/[/\\:*?"<>|]/g, '_'); 1680 const name = raw || 'image'; 1681 return ext ? `${name}.${ext}` : `${name}.${filename.split('.').pop()}`; 1682 } 1683 1684 if (!ext) return 'min_' + filename; 1685 return `min_${base}.${ext}`; 1686 } 1687 1688 async function downloadFile(filename, blob, index = 0) { 1689 const downloadName = buildDownloadName(filename, blob, index); 1690 1691 // On mobile, prefer Web Share API (triggers iOS native save / share sheet) 1692 if (isMobile() && navigator.share && navigator.canShare) { 1693 const file = new File([blob], downloadName, { type: blob.type }); 1694 if (navigator.canShare({ files: [file] })) { 1695 try { 1696 await navigator.share({ files: [file], title: downloadName }); 1697 trackExport(blob, 'share'); 1698 return; 1699 } catch (err) { 1700 if (err.name === 'AbortError') return; // user cancelled â don't fall through 1701 // Other errors: fall through to anchor download 1702 } 1703 } 1704 } 1705 1706 const link = document.createElement('a'); 1707 link.download = downloadName; 1708 const url = URL.createObjectURL(blob); 1709 link.href = url; 1710 link.click(); 1711 trackExport(blob, 'download'); 1712 setTimeout(() => URL.revokeObjectURL(url), 1000); 1713 } 1714 1715 function formatFileSize(bytes) { 1716 if (bytes === 0) return '0 Bytes';
1717 const k = 1000; 1718 const sizes = ['Bytes', 'KB', 'MB', 'GB']; 1719 const i = Math.floor(Math.log(bytes) / Math.log(k)); 1720 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 1721 } 1722 1723 // New: Compression Mode Switcher Logic 1724 document.querySelectorAll('.mode-btn[data-mode]').forEach(btn => { 1725 btn.addEventListener('click', function () { 1726 document.querySelectorAll('.mode-btn[data-mode]').forEach(b => b.classList.remove('active')); 1727 this.classList.add('active'); 1728 compressionMode = this.dataset.mode; 1729 updateAdvancedSummary(); 1730 1731 recompressAll(); 1732 }); 1733 }); 1734 1735 // --- Resize Controls --- 1736 1737 function buildResizePresetOptions() { 1738 if (!resizePresetEl) return; 1739 const selected = resizeSettings.preset || 'custom'; 1740 const groups = { 1741 ecommerce: document.createElement('optgroup'), 1742 social: document.createElement('optgroup') 1743 }; 1744 groups.ecommerce.label = window.imgminI18n.t('resize.group.ecommerce'); 1745 groups.social.label = window.imgminI18n.t('resize.group.social'); 1746 1747 resizePresetEl.innerHTML = ''; 1748 const customOpt = document.createElement('option'); 1749 customOpt.value = 'custom'; 1750 customOpt.textContent = window.imgminI18n.t('resize.preset.custom'); 1751 resizePresetEl.appendChild(customOpt); 1752 1753 const zhOrder = ['taobao', 'jd', 'amazon', 'shopify', 'wechat_cover', 'xiaohongshu', 'ig_post', 'ig_story', 'twitter', 'youtube']; 1754 const globalOrder = ['amazon', 'shopify', 'taobao', 'jd', 'ig_post', 'ig_story', 'youtube', 'twitter', 'xiaohongshu', 'wechat_cover']; 1755 const order = window.imgminI18n.lang.startsWith('zh') ? zhOrder : globalOrder; 1756 for (const key of order) { 1757 const preset = RESIZE_PRESETS[key]; 1758 const opt = document.createElement('option'); 1759 opt.value = key; 1760 opt.textContent = `${window.imgminI18n.t(`resize.preset.${key}`)} ${preset.w}Ã${preset.h}`; 1761 groups[preset.group].appendChild(opt); 1762 } 1763 resizePresetEl.appendChild(groups.ecommerce); 1764 resizePresetEl.appendChild(groups.social); 1765 1766 // Restore current selection 1767 resizePresetEl.value = selected; 1768 } 1769 1770 function syncResizeUI() { 1771 if (!resizeOptionsEl) return; 1772 resizeOptionsEl.style.display = resizeSettings.enabled ? 'flex' : 'none'; 1773 if (resizeEnableEl) resizeEnableEl.checked = resizeSettings.enabled; 1774 if (resizeWidthEl && resizeSettings.width) resizeWidthEl.value = resizeSettings.width; 1775 if (resizeHeightEl && resizeSettings.height) resizeHeightEl.value = resizeSettings.height; 1776 updateAdvancedSummary(); 1777 } 1778 1779 function recompressAll() { 1780 if (fileQueue.length) trackTask('settings_adjusted', null); 1781 const quality = getCompressionQuality(); 1782 if (activeFileId !== null && comparisonModal.style.display === 'flex') { 1783 const activeItem = fileQueue.find(entry => entry.id === activeFileId); 1784 const prevSrc = compressedImage.src; 1785 if (prevSrc && prevSrc.startsWith('blob:') && prevSrc !== activeItem?.originalUrl) URL.revokeObjectURL(prevSrc); 1786 compressedImage.removeAttribute('src'); 1787 compressedImage.style.display = 'none'; 1788 compressedSize.textContent = 'â'; 1789 compressedResolution.textContent = ''; 1790 statsReport.style.display = 'none'; 1791 } 1792 fileQueue.forEach(item => { 1793 if (item.convertingHeic) return; 1794 item.status = 'pending'; 1795 item.compressedBlob = null; 1796 compressImage(item, quality); 1797 }); 1798 } 1799 1800 if (resizeEnableEl) { 1801 resizeEnableEl.addEventListener('change', function () { 1802 resizeSettings.enabled = this.checked; 1803 syncResizeUI(); 1804 if (resizeSettings.enabled && resizeSettings.width && resizeSettings.height) { 1805 recompressAll(); 1806 } else if (!resizeSettings.enabled) { 1807 recompressAll(); 1808 } 1809 }); 1810 } 1811 1812 if (resizePresetEl) { 1813 resizePresetEl.addEventListener('change', function () {
1814 const key = this.value; 1815 if (key === 'custom') { 1816 resizeSettings.preset = null; 1817 // Keep existing width/height 1818 } else { 1819 const preset = RESIZE_PRESETS[key]; 1820 resizeSettings.preset = key; 1821 resizeSettings.width = preset.w; 1822 resizeSettings.height = preset.h; 1823 resizeSettings.fit = 'cover'; 1824 syncResizeUI(); 1825 } 1826 if (resizeSettings.enabled) recompressAll(); 1827 }); 1828 } 1829 1830 if (resizeWidthEl) { 1831 resizeWidthEl.addEventListener('input', function () { 1832 const v = parseInt(this.value); 1833 if (v > 0 && v <= 10000) { 1834 resizeSettings.width = v; 1835 resizeSettings.preset = null; 1836 resizeSettings.fit = 'cover'; 1837 if (resizePresetEl) resizePresetEl.value = 'custom'; 1838 updateAdvancedSummary(); 1839 if (resizeSettings.enabled && resizeSettings.height) recompressAll(); 1840 } 1841 }); 1842 } 1843 1844 if (resizeHeightEl) { 1845 resizeHeightEl.addEventListener('input', function () { 1846 const v = parseInt(this.value); 1847 if (v > 0 && v <= 10000) { 1848 resizeSettings.height = v; 1849 resizeSettings.preset = null; 1850 resizeSettings.fit = 'cover'; 1851 if (resizePresetEl) resizePresetEl.value = 'custom'; 1852 updateAdvancedSummary(); 1853 if (resizeSettings.enabled && resizeSettings.width) recompressAll(); 1854 } 1855 }); 1856 } 1857 1858 if (resizeChipDismissEl) { 1859 resizeChipDismissEl.addEventListener('click', () => { 1860 resizeSettings.enabled = false; 1861 if (resizeEnableEl) resizeEnableEl.checked = false; 1862 if (resizeChipEl) resizeChipEl.style.display = 'none'; 1863 syncResizeUI(); 1864 recompressAll(); 1865 }); 1866 } 1867 1868 function applyUrlResizeParams() { 1869 const params = new URLSearchParams(window.location.search); 1870 // Fall back to the embedding page's declared preset (IMGMIN_TOOL_PRESET); 1871 // explicit URL params always win. 1872 const presetKey = params.get('preset') || toolPreset.resizePreset; 1873 const w = parseInt(params.get('w')) || toolPreset.resizeWidth; 1874 const h = parseInt(params.get('h')) || toolPreset.resizeHeight; 1875 const fit = params.get('fit') || toolPreset.resizeFit; 1876 1877 let activated = false; 1878 let chipName = ''; 1879 1880 if (presetKey && RESIZE_PRESETS[presetKey]) { 1881 const preset = RESIZE_PRESETS[presetKey]; 1882 resizeSettings.enabled = true; 1883 resizeSettings.preset = presetKey; 1884 resizeSettings.width = preset.w; 1885 resizeSettings.height = preset.h; 1886 resizeSettings.fit = fit === 'contain' ? 'contain' : 'cover'; 1887 activated = true; 1888 chipName = window.imgminI18n.t(`resize.preset.${presetKey}`); 1889 } else if (w > 0 && w <= 10000 && h > 0 && h <= 10000) { 1890 resizeSettings.enabled = true; 1891 resizeSettings.preset = null; 1892 resizeSettings.width = w; 1893 resizeSettings.height = h; 1894 resizeSettings.fit = fit === 'contain' ? 'contain' : 'cover'; 1895 activated = true; 1896 chipName = `${w}Ã${h}`; 1897 } 1898 1899 if (activated) { 1900 const advDetails = document.querySelector('.advanced-settings'); 1901 if (advDetails) advDetails.open = true; 1902 buildResizePresetOptions(); 1903 syncResizeUI(); 1904 if (resizeChipEl && resizeChipTextEl) { 1905 resizeChipTextEl.textContent = window.imgminI18n.t('resize.chip.active', { name: chipName }); 1906 if (resizeChipDismissEl) resizeChipDismissEl.setAttribute('aria-label', window.imgminI18n.t('resize.chip.dismiss')); 1907 resizeChipEl.style.display = 'flex'; 1908 } 1909 } 1910 } 1911 1912 // --- Rename Controls --- 1913 1914 function buildRenamePreview() { 1915 if (!renameSettings.enabled || !renamePreviewTextEl) return; 1916 const firstName = fileQueue.length > 0 ? fileQueue[0].file.name : 'photo.jpg'; 1917 const firstBlob = fileQueue.length > 0 ? fileQueue[0].compressedBlob : null; 1918 const previewName = buildDownloadName(firstName, firstBlob || { type: 'image/jpeg' }, 0); 1919 renamePreviewTextEl.textContent = previewName; 1920 if (renamePreviewEl) renamePreviewEl.style.display = 'flex'; 1921 } 1922 1923 function syncRenameUI() { 1924 if (!renameEnableEl) return; 1925 if (renameOptionsEl) renameOptionsEl.style.display = renameSettings.enabled ? 'flex' : 'none'; 1926 if (renameEnableEl) renameEnableEl.checked = renameSettings.enabled; 1927 if (renamePrefixEl) renamePrefixEl.value = renameSettings.prefix; 1928 if (renameSuffixEl) renameSuffixEl.value = renameSettings.suffix; 1929 if (renameKeepOriginalEl) renameKeepOriginalEl.checked = renameSettings.keepOriginal; 1930 if (renameSeqEl) renameSeqEl.checked = renameSettings.seq; 1931 if (renameSeqOptionsEl) renameSeqOptionsEl.style.display = renameSettings.seq ? 'flex' : 'none'; 1932 if (renameSeqStartEl) renameSeqStartEl.value = renameSettings.seqStart; 1933 if (renameSeqPadEl) renameSeqPadEl.value = renameSettings.seqPad; 1934 if (renamePreviewEl) renamePreviewEl.style.display = renameSettings.enabled ? 'flex' : 'none'; 1935 buildRenamePreview(); 1936 } 1937 1938 if (renameEnableEl) { 1939 renameEnableEl.addEventListener('change', () => { 1940 renameSettings.enabled = renameEnableEl.checked; 1941 syncRenameUI(); 1942 updateAdvancedSummary(); 1943 }); 1944 } 1945 1946 if (renamePrefixEl) renamePrefixEl.addEventListener('input', () => { renameSettings.prefix = renamePrefixEl.value; buildRenamePreview(); }); 1947 if (renameSuffixEl) renameSuffixEl.addEventListener('input', () => { renameSettings.suffix = renameSuffixEl.value; buildRenamePreview(); }); 1948 1949 if (renameKeepOriginalEl) { 1950 renameKeepOriginalEl.addEventListener('change', () => { 1951 renameSettings.keepOriginal = renameKeepOriginalEl.checked; 1952 // When keep-original is off, force sequence on to avoid duplicate names in ZIP 1953 if (!renameSettings.keepOriginal && !renameSettings.seq) { 1954 renameSettings.seq = true; 1955 if (renameSeqEl) renameSeqEl.checked = true; 1956 if (renameSeqOptionsEl) renameSeqOptionsEl.style.display = 'flex'; 1957 } 1958 buildRenamePreview(); 1959 }); 1960 } 1961 1962 if (renameSeqEl) { 1963 renameSeqEl.addEventListener('change', () => { 1964 renameSettings.seq = renameSeqEl.checked; 1965 if (renameSeqOptionsEl) renameSeqOptionsEl.style.display = renameSettings.seq ? 'flex' : 'none'; 1966 buildRenamePreview(); 1967 }); 1968 } 1969 1970 if (renameSeqStartEl) renameSeqStartEl.addEventListener('input', () => { renameSettings.seqStart = parseInt(renameSeqStartEl.value) || 1; buildRenamePreview(); }); 1971 if (renameSeqPadEl) renameSeqPadEl.addEventListener('input', () => { renameSettings.seqPad = Math.max(1, Math.min(4, parseInt(renameSeqPadEl.value) || 2)); buildRenamePreview(); }); 1972 1973 // --- Watermark Controls --- 1974 function recompressAllWatermark() { 1975 recompressAll(); 1976 } 1977 1978 if (watermarkEnableEl) { 1979 watermarkEnableEl.addEventListener('change', () => { 1980 watermarkSettings.enabled = watermarkEnableEl.checked; 1981 if (watermarkOptionsEl) watermarkOptionsEl.style.display = watermarkSettings.enabled ? 'flex' : 'none'; 1982 updateAdvancedSummary(); 1983 recompressAllWatermark(); 1984 }); 1985 } 1986 1987 if (watermarkTextEl) { 1988 watermarkTextEl.addEventListener('input', () => { 1989 watermarkSettings.text = watermarkTextEl.value; 1990 if (watermarkSettings.enabled) recompressAllWatermark(); 1991 }); 1992 } 1993 1994 if (watermarkImageInputEl) { 1995 watermarkImageInputEl.addEventListener('change', () => { 1996 const file = watermarkImageInputEl.files[0]; 1997 if (!file) return; 1998 const url = URL.createObjectURL(file); 1999 const img = new Image(); 2000 img.onload = () => { 2001 watermarkSettings.imageEl = img; 2002 watermarkSettings.image = url; 2003 if (watermarkImageLabelEl) watermarkImageLabelEl.textContent = file.name.slice(0, 20); 2004 if (watermarkImageClearEl) watermarkImageClearEl.style.display = 'inline'; 2005 if (watermarkSettings.enabled) recompressAllWatermark(); 2006 }; 2007 img.src = url; 2008 }); 2009 } 2010 2011 if (watermarkImageClearEl) { 2012 watermarkImageClearEl.addEventListener('click', () => { 2013 watermarkSettings.imageEl = null; 2014 watermarkSettings.image = null; 2015 if (watermarkImageInputEl) watermarkImageInputEl.value = ''; 2016 if (watermarkImageLabelEl) watermarkImageLabelEl.textContent = window.imgminI18n.t('watermark.image.upload'); 2017 watermarkImageClearEl.style.display = 'none'; 2018 if (watermarkSettings.enabled) recompressAllWatermark(); 2019 }); 2020 } 2021 2022 if (watermarkOpacityEl) { 2023 watermarkOpacityEl.addEventListener('input', () => { 2024 watermarkSettings.opacity = watermarkOpacityEl.value / 100; 2025 if (watermarkOpacityValEl) watermarkOpacityValEl.textContent = watermarkOpacityEl.value + '%'; 2026 if (watermarkSettings.enabled) recompressAllWatermark(); 2027 }); 2028 } 2029 2030 if (watermarkGridEl) { 2031 watermarkGridEl.querySelectorAll('.wm-cell').forEach(cell => { 2032 cell.addEventListener('click', () => { 2033 watermarkGridEl.querySelectorAll('.wm-cell').forEach(c => c.classList.remove('active')); 2034 cell.classList.add('active'); 2035 watermarkSettings.position = cell.dataset.pos; 2036 if (watermarkSettings.enabled) recompressAllWatermark(); 2037 }); 2038 }); 2039 } 2040 2041 // Modal logic (Same as before) 2042 originalImage.addEventListener('click', () => showModal(originalImage.src)); 2043 compressedImage.addEventListener('click', () => { 2044 if (!compressedWrapper.classList.contains('crop-enabled')) showModal(compressedImage.src); 2045 }); 2046 2047 const modal = document.getElementById('imageModal'); 2048 const modalImage = document.getElementById('modalImage'); 2049 // --- Event Listeners --- 2050 2051 uploadBtn.addEventListener('click', () => { 2052 window.imgminAnalytics?.track('upload_intent', { method: 'select' }); 2053 fileInput.click(); 2054 }); 2055 2056 fileInput.addEventListener('change', handleFileSelect); 2057 2058 // Modal close event 2059 closeComparisonModal.addEventListener('click', () => { 2060 comparisonModal.style.display = 'none'; 2061 if (typeof updateFloatingBtn === 'function') updateFloatingBtn(); 2062 }); 2063 2064 // Close modal on background click 2065 comparisonModal.addEventListener('click', (e) => { 2066 if (e.target === comparisonModal) { 2067 comparisonModal.style.display = 'none'; 2068 if (typeof updateFloatingBtn === 'function') updateFloatingBtn(); 2069 } 2070 }); 2071 const modalClose = document.querySelector('.modal-close'); 2072 2073 modalClose.addEventListener('click', (e) => { 2074 e.stopPropagation(); 2075 modal.style.display = 'none'; 2076 document.body.style.overflow = ''; 2077 }); 2078 2079 modal.addEventListener('click', (e) => { 2080 if (e.target === modal) { 2081 modal.style.display = 'none'; 2082 document.body.style.overflow = ''; 2083 } 2084 }); 2085 2086 function applyZoom() { 2087 const zoomValue = currentZoom === 'fit' ? '100%' : `${currentZoom * 100}%`; 2088 if (currentZoom === 'fit') { 2089 originalImage.style.width = 'auto'; 2090 originalImage.style.maxWidth = '100%'; 2091 compressedImage.style.width = 'auto'; 2092 compressedImage.style.maxWidth = '100%'; 2093 } else { 2094 originalImage.style.width = zoomValue; 2095 originalImage.style.maxWidth = 'none'; 2096 compressedImage.style.width = zoomValue; 2097 compressedImage.style.maxWidth = 'none'; 2098 } 2099 } 2100 2101 function showModal(src) { 2102 modalImage.src = src; 2103 modal.style.display = 'flex'; 2104 document.body.style.overflow = 'hidden'; 2105 } 2106 2107 // Keyboard accessibility 2108 document.addEventListener('keydown', (e) => { 2109 if (e.key === 'Escape') { 2110 if (advancedSettings?.open) advancedSettings.open = false;
2111 if (comparisonModal.style.display === 'flex') { 2112 comparisonModal.style.display = 'none'; 2113 if (typeof updateFloatingBtn === 'function') updateFloatingBtn(); 2114 } 2115 if (modal.style.display === 'flex') { 2116 modal.style.display = 'none'; 2117 document.body.style.overflow = ''; 2118 } 2119 } 2120 }); 2121 2122 // ä»»å¡4ï¼åªè´´æ¿ç²è´´åè½ 2123 // çå¬é¡µé¢ç²è´´äºä»¶ 2124 document.addEventListener('paste', async (e) => { 2125 const items = e.clipboardData?.items; 2126 if (!items) return; 2127 2128 // æ¥æ¾å¾ç项 2129 for (let i = 0; i < items.length; i++) { 2130 if (items[i].type.indexOf('image') !== -1) { 2131 e.preventDefault(); 2132 const blob = items[i].getAsFile(); 2133 2134 if (blob) { 2135 window.imgminAnalytics?.track('upload_intent', { method: 'paste' }); 2136 // å建ä¸ä¸ªå¸¦èªå®ä¹åç§°çFile对象 2137 const file = new File([blob], `clipboard-${Date.now()}.${blob.type.split('/')[1]}`, { 2138 type: blob.type, 2139 lastModified: Date.now() 2140 }); 2141 2142 handleFiles([file], 'paste'); 2143 2144 // æ¾ç¤ºç²è´´æåæç¤º 2145 showToast(window.imgminI18n.t('clipboard.pasted')); 2146 } 2147 break; 2148 } 2149 } 2150 }); 2151 2152 // Toastæç¤ºå½æ°ï¼æ¯æç±»ååæ° 2153 function showToast(message, typeOrDuration = 'info') { 2154 // æ¯ææ§ç¾å showToast(msg, duration) çå ¼å®¹ 2155 let type = 'info'; 2156 let duration = 2000; 2157 if (typeof typeOrDuration === 'number') { 2158 duration = typeOrDuration; 2159 } else { 2160 type = typeOrDuration; 2161 duration = type === 'error' ? 3000 : 2000; 2162 } 2163 2164 const typeStyles = { 2165 info: 'background: rgba(0, 0, 0, 0.8)', 2166 success: 'background: rgba(46, 204, 113, 0.9)', 2167 error: 'background: rgba(231, 76, 60, 0.9)' 2168 }; 2169 2170 // ç§»é¤å·²åå¨çtoast 2171 const existingToast = document.querySelector('.clipboard-toast'); 2172 if (existingToast) existingToast.remove(); 2173 2174 const toast = document.createElement('div'); 2175 toast.className = 'clipboard-toast'; 2176 toast.textContent = message; 2177 toast.style.cssText = ` 2178 position: fixed; 2179 bottom: 30px; 2180 left: 50%; 2181 transform: translateX(-50%); 2182 ${typeStyles[type] || typeStyles.info}; 2183 color: white; 2184 padding: 12px 24px; 2185 border-radius: 8px; 2186 font-size: 14px; 2187 z-index: 10000; 2188 animation: slideUp 0.3s ease-out; 2189 `; 2190 2191 document.body.appendChild(toast); 2192 2193 setTimeout(() => { 2194 toast.style.animation = 'fadeOut 0.3s ease-out'; 2195 setTimeout(() => toast.remove(), 300); 2196 }, duration); 2197 } 2198 2199 // æ·»å CSSå¨ç» 2200 if (!document.querySelector('#clipboard-toast-styles')) { 2201 const style = document.createElement('style'); 2202 style.id = 'clipboard-toast-styles'; 2203 style.textContent = ` 2204 @keyframes slideUp { 2205 from { 2206 opacity: 0; 2207 transform: translateX(-50%) translateY(20px); 2208 } 2209 to { 2210 opacity: 1; 2211 transform: translateX(-50%) translateY(0); 2212 } 2213 } 2214 @keyframes fadeOut { 2215 from { 2216 opacity: 1; 2217 } 2218 to { 2219 opacity: 0; 2220 } 2221 } 2222 `; 2223 document.head.appendChild(style); 2224 } 2225 2226 // ===== å³é®èååè½ ===== 2227 const contextMenu = document.createElement('div'); 2228 contextMenu.id = 'customContextMenu'; 2229 contextMenu.style.cssText = ` 2230 position: fixed; 2231 background: white; 2232 border-radius: 8px; 2233 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 2234 padding: 0.5rem 0; 2235 z-index: 10000; 2236 display: none; 2237 min-width: 160px; 2238 `; 2239 document.body.appendChild(contextMenu); 2240 2241 function hideContextMenu() { 2242 contextMenu.style.display = 'none'; 2243 } 2244 2245 document.addEventListener('click', hideContextMenu); 2246 2247 // ä¸ä¼ åºå³é®ç²è´´ 2248 dropZone.addEventListener('contextmenu', (e) => { 2249 e.preventDefault(); 2250 hideContextMenu(); 2251 contextMenu.innerHTML = ` 2252 <div class="context-menu-item" data-action="paste"> 2253 <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> 2254 <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/> 2255 </svg>
2256 <span>${window.imgminI18n.t('contextmenu.paste')}</span> 2257 </div> 2258 `; 2259 2260 // æºè½å®ä½ï¼ç¡®ä¿èåå¨è§å£å 2261 contextMenu.style.display = 'block'; 2262 const menuWidth = contextMenu.offsetWidth; 2263 const menuHeight = contextMenu.offsetHeight; 2264 const windowWidth = window.innerWidth; 2265 const windowHeight = window.innerHeight; 2266 2267 let left = e.clientX; 2268 let top = e.clientY; 2269 2270 // æ£æ¥å³è¾¹ç 2271 if (left + menuWidth > windowWidth - 10) { 2272 left = windowWidth - menuWidth - 10; 2273 } 2274 2275 // æ£æ¥åºé¨è¾¹ç 2276 if (top + menuHeight > windowHeight - 10) { 2277 top = windowHeight - menuHeight - 10; 2278 } 2279 2280 // ç¡®ä¿ä¸è¶ åºå·¦è¾¹çåé¡¶é¨ 2281 left = Math.max(10, left); 2282 top = Math.max(10, top); 2283 2284 contextMenu.style.left = left + 'px'; 2285 contextMenu.style.top = top + 'px'; 2286 2287 setTimeout(() => { 2288 const pasteItem = contextMenu.querySelector('[data-action="paste"]'); 2289 if (pasteItem) { 2290 pasteItem.onclick = async () => { 2291 try { 2292 const items = await navigator.clipboard.read(); 2293 for (const item of items) { 2294 const imageType = item.types.find(t => t.startsWith('image/')); 2295 if (imageType) { 2296 const blob = await item.getType(imageType); 2297 const file = new File([blob], `clipboard-${Date.now()}.${imageType.split('/')[1]}`, { type: imageType }); 2298 handleFiles([file]); 2299 showToast(window.imgminI18n.t('clipboard.pasted')); 2300 break; 2301 } 2302 } 2303 } catch (err) { console.warn('Clipboard read failed:', err); } 2304 hideContextMenu(); 2305 }; 2306 } 2307 }, 0); 2308 }); 2309 2310 // å缩å¾çå³é®èå - ä¿®å¤ï¼åªå¨file-itemå é¨è§¦å 2311 fileList.addEventListener('contextmenu', (e) => { 2312 // æ£æ¥æ¯å¦ç¹å»å¨æé®ä¸ï¼å¦ææ¯å䏿¾ç¤ºèå 2313 if (e.target.closest('.icon-btn') || e.target.closest('button')) { 2314 return; 2315 } 2316 2317 const fileItem = e.target.closest('.file-item'); 2318 if (fileItem) { 2319 e.preventDefault(); 2320 hideContextMenu(); 2321 const itemId = fileItem.id.replace('item-', ''); 2322 const item = fileQueue.find(i => i.id === itemId); 2323 if (!item || !item.compressedBlob) return; 2324 2325 contextMenu.innerHTML = ` 2326 <div class="context-menu-item" data-action="copy"> 2327 <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> 2328 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/> 2329 <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/> 2330 </svg> 2331 <span>${window.imgminI18n.t('contextmenu.copy')}</span> 2332 </div> 2333 <div class="context-menu-item" data-action="download"> 2334 <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> 2335 <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/> 2336 </svg> 2337 <span>${window.imgminI18n.t('contextmenu.download')}</span> 2338 </div> 2339 `; 2340 2341 // æºè½å®ä½ï¼ç¡®ä¿èåå¨è§å£å 2342 contextMenu.style.display = 'block'; // å æ¾ç¤ºä»¥è·å尺寸 2343 const menuWidth = contextMenu.offsetWidth; 2344 const menuHeight = contextMenu.offsetHeight; 2345 const windowWidth = window.innerWidth; 2346 const windowHeight = window.innerHeight; 2347 2348 let left = e.clientX; 2349 let top = e.clientY; 2350 2351 // æ£æ¥å³è¾¹ç 2352 if (left + menuWidth > windowWidth - 10) { 2353 left = windowWidth - menuWidth - 10; 2354 } 2355 2356 // æ£æ¥åºé¨è¾¹ç 2357 if (top + menuHeight > windowHeight - 10) { 2358 top = windowHeight - menuHeight - 10; 2359 } 2360 2361 // ç¡®ä¿ä¸è¶ åºå·¦è¾¹çåé¡¶é¨ 2362 left = Math.max(10, left); 2363 top = Math.max(10, top); 2364 2365 contextMenu.style.left = left + 'px'; 2366 contextMenu.style.top = top + 'px'; 2367 2368 setTimeout(() => { 2369 const copyBtn = contextMenu.querySelector('[data-action="copy"]'); 2370 const downloadBtn = contextMenu.querySelector('[data-action="download"]'); 2371 if (copyBtn) { 2372 copyBtn.onclick = async () => { 2373 try { 2374 const blob = item.compressedBlob; 2375 let blobToWrite = blob; 2376 2377 // å ¼å®¹æ§å¤çï¼å¦æä¸æ¯PNGï¼è½¬æ¢ä¸ºPNG以确ä¿å ¼å®¹æ§
2378 if (blob.type !== 'image/png') { 2379 const img = new Image(); 2380 img.src = URL.createObjectURL(blob); 2381 await new Promise((resolve, reject) => { 2382 img.onload = resolve; 2383 img.onerror = reject; 2384 }); 2385 2386 const canvas = document.createElement('canvas'); 2387 canvas.width = img.width; 2388 canvas.height = img.height; 2389 const ctx = canvas.getContext('2d'); 2390 ctx.drawImage(img, 0, 0); 2391 2392 blobToWrite = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); 2393 URL.revokeObjectURL(img.src); 2394 } 2395 2396 await navigator.clipboard.write([ 2397 new ClipboardItem({ 2398 [blobToWrite.type]: blobToWrite 2399 }) 2400 ]); 2401 trackExport(item.compressedBlob, 'copy', blobToWrite); 2402 showToast(window.imgminI18n.t('toast.copied')); 2403 } catch (err) { 2404 console.error('Copy failed:', err); 2405 showToast(window.imgminI18n.t('toast.copy_failed')); 2406 } 2407 hideContextMenu(); 2408 }; 2409 } 2410 if (downloadBtn) { 2411 downloadBtn.onclick = () => { 2412 downloadFile(item.file.name, item.compressedBlob); 2413 hideContextMenu(); 2414 }; 2415 } 2416 }, 0); 2417 } 2418 }); 2419 2420 // ä»»å¡ï¼æç½å½©èåè½ 2421 window.addEventListener('offline', () => { 2422 showToast(window.imgminI18n.t('toast.offline'), 3500); 2423 }); 2424 2425 // å³é®èåæ ·å¼ 2426 if (!document.querySelector('#context-menu-styles')) { 2427 const style = document.createElement('style'); 2428 style.id = 'context-menu-styles'; 2429 style.textContent = ` 2430 .context-menu-item { 2431 padding: 0.6rem 1rem; 2432 cursor: pointer; 2433 display: flex; 2434 align-items: center; 2435 gap: 0.8rem; 2436 color: #333; 2437 font-size: 0.9rem; 2438 transition: all 0.2s; 2439 } 2440 .context-menu-item:hover { 2441 background: #f5f5f7; 2442 } 2443 .context-menu-item svg { 2444 color: #666; 2445 } 2446 `; 2447 document.head.appendChild(style); 2448 } 2449 2450 // --- Floating Download Button --- 2451 const floatingDownload = document.getElementById('floatingDownload'); 2452 2453 function hasCompressedFiles() { 2454 return fileQueue.some(f => f.compressedBlob); 2455 } 2456 2457 function updateFloatingBtn() { 2458 if (!floatingDownload) return; // embedded tool pages omit the floating button 2459 const isMobile = window.matchMedia('(max-width: 768px)').matches; 2460 const navHeight = document.querySelector('.navbar')?.getBoundingClientRect().height || 0; 2461 const batchRect = batchActions.getBoundingClientRect(); 2462 const footerRect = document.querySelector('.footer')?.getBoundingClientRect(); 2463 const batchActionsPassed = batchRect.height > 0 && batchRect.bottom <= navHeight; 2464 const footerVisible = !!footerRect && footerRect.top < window.innerHeight && footerRect.bottom > 0; 2465 const comparisonOpen = comparisonModal.style.display === 'flex'; 2466 const shouldShow = hasCompressedFiles() 2467 && !comparisonOpen 2468 && (isMobile ? batchActionsPassed && !footerVisible : !batchActionsVisible); 2469 2470 if (shouldShow) { 2471 floatingDownload.classList.add('visible'); 2472 } else { 2473 floatingDownload.classList.remove('visible'); 2474 } 2475 floatingDownload.setAttribute('aria-hidden', String(!shouldShow)); 2476 floatingDownload.tabIndex = shouldShow ? 0 : -1; 2477 } 2478 2479 let batchActionsVisible = true; 2480 if (floatingDownload && batchActions) { 2481 const batchObserver = new IntersectionObserver((entries) => { 2482 batchActionsVisible = entries[0].isIntersecting; 2483 updateFloatingBtn(); 2484 }, { threshold: 0 }); 2485 batchObserver.observe(batchActions); 2486 2487 floatingDownload.addEventListener('click', () => downloadCompressed()); 2488 2489 let floatingFrame = 0; 2490 const scheduleFloatingUpdate = () => { 2491 if (floatingFrame) return; 2492 floatingFrame = window.requestAnimationFrame(() => { 2493 floatingFrame = 0; 2494 updateFloatingBtn(); 2495 }); 2496 }; 2497 window.addEventListener('scroll', scheduleFloatingUpdate, { passive: true }); 2498 window.addEventListener('resize', scheduleFloatingUpdate); 2499 } 2500});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.