PageSourceSearch

https://imgmin.pro/script.js?v=20260924a

js imgmin.pro collected 2026-10-02 01:08:17 UTC 114,780 bytes, 2,500 lines download raw bytes

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.