1(function() { 2 'use strict'; 3 4 let svgoOptimize = null; 5 let svgoLoading = null; 6 7 async function loadSvgo() { 8 if (svgoOptimize) return svgoOptimize; 9 if (svgoLoading) return svgoLoading; 10 11 svgoLoading = import('/shared/js/svgo.browser.js') 12 .then(module => { 13 svgoOptimize = module.optimize; 14 return svgoOptimize; 15 }); 16 17 return svgoLoading; 18 } 19 20 const CONFIG = window.APP_CONFIG || { 21 maxSize: 50 * 1024 * 1024, 22 maxFiles: 20, 23 inputFormats: ['jpg', 'jpeg', 'png', 'webp', 'gif'], 24 texts: {}, 25 errors: {} 26 }; 27 28 const fileInput = document.getElementById('fileInput'); 29 const dropZone = document.getElementById('dropZone'); 30 const dropMessage = document.getElementById('dropMessage'); 31 const filesScroll = document.getElementById('filesScroll'); 32 const filesContainer = document.getElementById('filesContainer'); 33 const prevBtn = document.getElementById('prevBtn'); 34 const nextBtn = document.getElementById('nextBtn'); 35 const clearBtn = document.getElementById('clearBtn'); 36 const convertBtn = document.getElementById('convertBtn'); 37 const convertCounter = document.getElementById('convertCounter'); 38 const downloadAllBtn = document.getElementById('downloadAllBtn'); 39 const downloadCounter = document.getElementById('downloadCounter'); 40 const hasSeparateDownloadBtn = !!downloadAllBtn; 41 const fileCardTemplate = document.getElementById('fileCardTemplate'); 42 43 const settingsPanel = document.getElementById('comparisonPanel'); 44 const settingsFileName = document.getElementById('settingsFileName'); 45 const settingsLoader = document.getElementById('settingsLoader'); 46 const comparisonCanvas = document.getElementById('comparisonCanvas'); 47 const comparisonThumb = document.getElementById('comparisonThumb'); 48 const zoomIndicator = document.getElementById('zoomIndicator'); 49 const qualityInput = document.getElementById('qualityInput'); 50 const qualitySlider = document.getElementById('qualitySlider'); 51 const qualityLabel = document.getElementById('qualityLabel'); 52 const applyQualityBtn = document.getElementById('applyQualityBtn'); 53 54 const SLIDER_CONFIG = { 55 quality: { min: 10, max: 100, default: 80, labelKey: 'quality' }, 56 colors: { min: 2, max: 256, default: 256, labelKey: 'colors' }, 57 precision: { min: 0, max: 5, default: 3, labelKey: 'precision' } 58 }; 59 60 function getSliderType(format) { 61 const fmt = format?.toLowerCase(); 62 if (fmt === 'png' || fmt === 'gif') return 'colors'; 63 if (fmt === 'svg') return 'precision'; 64 return 'quality'; 65 } 66 67 function updateSliderForFormat(format, currentValue, maxValue = null) { 68 const type = getSliderType(format); 69 const config = SLIDER_CONFIG[type]; 70 const effectiveMax = maxValue != null ? Math.min(maxValue, config.max) : config.max; 71 72 if (qualitySlider) { 73 qualitySlider.min = config.min; 74 qualitySlider.max = effectiveMax; 75 } 76 if (qualityLabel) { 77 78 qualityLabel.textContent = CONFIG.texts[config.labelKey] || config.labelKey; 79 } 80 81 let value = currentValue ?? config.default; 82 value = Math.max(config.min, Math.min(effectiveMax, value)); 83 84 if (qualityInput) qualityInput.value = value; 85 if (qualitySlider) qualitySlider.value = value; 86 87 return value; 88 } 89 const originalSizeEl = document.getElementById('originalSize'); 90 const compressedSizeEl = document.getElementById('compressedSize'); 91 const savingsPercentEl = document.getElementById('savingsPercent'); 92 93 const frameCounterEl = document.getElementById('frameCounter'); 94 const framePrev10Btn = document.getElementById('framePrev10'); 95 const framePrev1Btn = document.getElementById('framePrev1'); 96 const frameNext1Btn = document.getElementById('frameNext1'); 97 const frameNext10Btn = document.getElementById('frameNext10'); 98 const frameInput = document.getElementById('frameInput'); 99 const frameTotalLabel = document.getElementById('frameTotalLabel'); 100 101 let currentFrameIndex = 0; 102 let currentTotalFrames = 0; 103 let currentAnimatedFileData = null; 104 105 let files = []; 106 window.converterFiles = files; 107 let isConverting = false; 108 let isDownloading = false;
109 let hasDownloaded = false; 110 let selectedFileId = null; 111 let comparisonPanelInstance = null; 112 let autoSelectFirst = true; 113 function getDefaultValue(format) { 114 const type = getSliderType(format); 115 return SLIDER_CONFIG[type].default; 116 } 117 let previewDebounceTimer = null; 118 let isPreviewCompressing = false; 119 let hasQualityChanged = false; 120 121 let worker = null; 122 const pendingThumbnails = new Map(); 123 const pendingCompressions = new Map(); 124 125 function classifyError(msg) { 126 return window.SharedUtils.classifyError(msg, CONFIG.errors); 127 } 128 129 function isWasmCrashError(errorMessage) { 130 return window.SharedUtils.isWasmCrashError(errorMessage); 131 } 132 133 function recreateWorker() { 134 reportError(new Error('WASM worker crashed, recreating'), { op: 'wasm-recovery' }); 135 if (worker) { 136 worker.terminate(); 137 } 138 worker = null; 139 140 const allPendingWithReject = [ 141 pendingThumbnails, 142 pendingCompressions, 143 pendingFrameReads, 144 pendingAnimatedWebpCompressions, 145 pendingGifCompressions 146 ]; 147 const error = new Error('Worker crashed, recreating'); 148 allPendingWithReject.forEach(map => { 149 map.forEach(pending => pending.reject(error)); 150 map.clear(); 151 }); 152 pendingFrameCounts.forEach(pending => pending.resolve(1)); 153 pendingFrameCounts.clear(); 154 155 } 156 const abortedFileIds = new Set(); 157 const shimmeredElements = new Set(); 158 159 const PREVIEW_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']; 160 161 function extractQuality(result, fallback) { 162 163 return result.optimalColors || result.compressedColors || 164 result.detectedColors || fallback; 165 } 166 167 function shimmerHint(element) { 168 window.SharedUtils.shimmerHint(element, shimmeredElements); 169 } 170 171 const pendingFrameCounts = new Map(); 172 const pendingFrameReads = new Map(); 173 const pendingAnimatedWebpCompressions = new Map(); 174 const pendingGifCompressions = new Map(); 175 176 function countFramesInWorker(file) { 177 return new Promise(async (resolve) => { 178 initWorker(); 179 const id = generateId(); 180 const arrayBuffer = await file.arrayBuffer(); 181 182 pendingFrameCounts.set(id, { resolve }); 183 184 worker.postMessage({ 185 type: 'count_frames', 186 id, 187 data: arrayBuffer 188 }, [arrayBuffer]); 189 }); 190 } 191 192 function readFrameInWorker(blob, frameIndex, cacheType = 'original', cacheKey = null) { 193 return new Promise(async (resolve, reject) => { 194 initWorker(); 195 const id = generateId(); 196 const arrayBuffer = await blob.arrayBuffer(); 197 198 pendingFrameReads.set(id, { resolve, reject }); 199 200 worker.postMessage({ 201 type: 'read_frame', 202 id, 203 data: arrayBuffer, 204 frameIndex, 205 cacheType, 206 cacheKey: cacheKey || blob.size.toString() 207 }, [arrayBuffer]); 208 }); 209 } 210 211 function compressAnimatedWebpInWorker(file, quality) { 212 return new Promise(async (resolve, reject) => { 213 initWorker(); 214 const id = generateId(); 215 const arrayBuffer = await file.arrayBuffer(); 216 217 pendingAnimatedWebpCompressions.set(id, { resolve, reject }); 218 219 worker.postMessage({ 220 type: 'compress_animated_webp', 221 id, 222 data: arrayBuffer, 223 quality 224 }, [arrayBuffer]); 225 }); 226 } 227 228 function compressGifInWorker(file, quality, autoMode = false, maxQuality = 99, optimizationLevel = 3) { 229 return new Promise(async (resolve, reject) => { 230 initWorker(); 231 const id = generateId(); 232 const arrayBuffer = await file.arrayBuffer(); 233 234 pendingGifCompressions.set(id, { resolve, reject }); 235 236 worker.postMessage({ 237 type: 'compress_gif', 238 id, 239 data: arrayBuffer, 240 quality, 241 autoMode, 242 maxQuality, 243 optimizationLevel 244 }, [arrayBuffer]); 245 }); 246 } 247 248 function initWorker() { 249 if (worker) return; 250 251 worker = new Worker(`/shared/js/compress-worker.js?v=${window.APP_CONFIG.v}`, { type: 'module' }); 252 253 worker.onmessage = function(e) { 254 const { type, id, success, data, mimeType, error } = e.data; 255 256 if (type === 'thumbnail_result') { 257 const pending = pendingThumbnails.get(id); 258 if (pending) { 259 pendingThumbnails.delete(id); 260 if (success) { 261 const blob = new Blob([data], { type: mimeType }); 262 const url = URL.createObjectURL(blob); 263 pending.resolve({ url, width: e.data.width, height: e.data.height }); 264 } else { 265 pending.reject(new Error(error)); 266 if (isWasmCrashError(error)) { 267 recreateWorker(); 268 } 269 } 270 } 271 } 272 273 if (type === 'frame_count_result') { 274 const pending = pendingFrameCounts.get(id); 275 if (pending) { 276 pendingFrameCounts.delete(id); 277 if (success === false && error) { 278 pending.resolve(1); 279 if (isWasmCrashError(error)) { 280 recreateWorker(); 281 } 282 } else { 283 pending.resolve(e.data.frameCount || 1); 284 } 285 } 286 } 287 288 if (type === 'frame_result') { 289 const pending = pendingFrameReads.get(id); 290 if (pending) { 291 pendingFrameReads.delete(id); 292 if (success) { 293 294 const pixelData = new Uint8ClampedArray(e.data.data); 295 pending.resolve({ 296 width: e.data.width, 297 height: e.data.height, 298 data: pixelData 299 }); 300 } else { 301 pending.reject(new Error(error)); 302 if (isWasmCrashError(error)) { 303 recreateWorker(); 304 } 305 } 306 } 307 } 308 309 if (type === 'compress_animated_webp_result') { 310 const pending = pendingAnimatedWebpCompressions.get(id); 311 if (pending) { 312 pendingAnimatedWebpCompressions.delete(id); 313 if (success) { 314 pending.resolve({ 315 data: new Uint8Array(data), 316 mimeType, 317 originalSize: e.data.originalSize, 318 compressedSize: e.data.compressedSize, 319 detectedQuality: e.data.detectedQuality 320 }); 321 } else { 322 pending.reject(new Error(error)); 323 if (isWasmCrashError(error)) { 324 recreateWorker(); 325 } 326 } 327 } 328 } 329 330 if (type === 'compress_gif_result') { 331 const pending = pendingGifCompressions.get(id); 332 if (pending) { 333 pendingGifCompressions.delete(id); 334 if (success) { 335 pending.resolve({ 336 data: new Uint8Array(data), 337 mimeType, 338 originalSize: e.data.originalSize, 339 compressedSize: e.data.compressedSize, 340 detectedColors: e.data.detectedColors, 341 optimalColors: e.data.optimalColors 342 }); 343 } else { 344 pending.reject(new Error(error)); 345 if (isWasmCrashError(error)) { 346 recreateWorker(); 347 } 348 } 349 } 350 } 351 352 if (type === 'compress_result') { 353 const pending = pendingCompressions.get(id); 354 if (pending) { 355 pendingCompressions.delete(id); 356 if (success) { 357 pending.resolve({ 358 data: new Uint8Array(data), 359 mimeType, 360 originalSize: e.data.originalSize, 361 compressedSize: e.data.compressedSize, 362 actualCompressedSize: e.data.actualCompressedSize, 363 compressedColors: e.data.compressedColors, 364 originalColors: e.data.originalColors, 365 oxipngRatio: e.data.oxipngRatio, 366 detectedQuality: e.data.detectedQuality, 367 usedOriginal: e.data.usedOriginal, 368 imageWidth: e.data.imageWidth, 369 imageHeight: e.data.imageHeight 370 }); 371 } else { 372 373 const err = new Error(error); 374 if (typeof e.data.imageWidth === 'number') { 375 err.imageWidth = e.data.imageWidth; 376 err.imageHeight = e.data.imageHeight; 377 } 378 pending.reject(err); 379 if (isWasmCrashError(error)) { 380 recreateWorker(); 381 } 382 } 383 } 384 } 385 386 }; 387 388 worker.onerror = function(err) { 389 const errorMsg = err.message || 'Worker error'; 390 const allPending = [ 391 pendingThumbnails, 392 pendingCompressions, 393 pendingFrameReads, 394 pendingAnimatedWebpCompressions, 395 pendingGifCompressions 396 ]; 397 allPending.forEach(map => {
398 map.forEach(pending => pending.reject(new Error(errorMsg))); 399 map.clear(); 400 }); 401 402 pendingFrameCounts.forEach(pending => pending.resolve(1)); 403 pendingFrameCounts.clear(); 404 worker = null; 405 }; 406 } 407 408 function generateThumbnailInWorker(file, inputFormat) { 409 return new Promise(async (resolve, reject) => { 410 initWorker(); 411 const id = generateId(); 412 const arrayBuffer = await file.arrayBuffer(); 413 414 pendingThumbnails.set(id, { resolve, reject }); 415 416 worker.postMessage({ 417 type: 'thumbnail', 418 id, 419 data: arrayBuffer, 420 inputFormat 421 }, [arrayBuffer]); 422 }); 423 } 424 425 function isSvgSprite(svgContent) { 426 const parser = new DOMParser(); 427 const doc = parser.parseFromString(svgContent, 'image/svg+xml'); 428 const svg = doc.documentElement; 429 430 for (const child of svg.children) { 431 const tag = child.tagName.toLowerCase(); 432 if (tag !== 'defs' && tag !== 'style' && tag !== 'metadata') { 433 return false; 434 } 435 } 436 437 return svg.querySelector('symbol') !== null; 438 } 439 440 function detectSvgPrecision(svgContent) { 441 let maxPrecision = 0; 442 const matches = svgContent.match(/\d+\.\d+/g); 443 if (matches) { 444 for (const match of matches) { 445 const decimals = match.split('.')[1]; 446 if (decimals && decimals.length > maxPrecision) { 447 maxPrecision = decimals.length; 448 } 449 } 450 } 451 return maxPrecision; 452 } 453 454 function fixSvgDimensions(svgContent) { 455 return svgContent.replace(/<svg([^>]*)>/i, function(match, attrs) { 456 const hasWidth = /\swidth\s*=/i.test(attrs); 457 const hasHeight = /\sheight\s*=/i.test(attrs); 458 459 if (!hasWidth || !hasHeight) { 460 const viewBoxMatch = attrs.match(/viewBox\s*=\s*["']([^"']+)["']/i); 461 if (viewBoxMatch) { 462 const parts = viewBoxMatch[1].trim().split(/\s+/); 463 if (parts.length === 4) { 464 const vbWidth = parts[2]; 465 const vbHeight = parts[3]; 466 let newAttrs = attrs; 467 if (!hasWidth) newAttrs += ` width="${vbWidth}"`; 468 if (!hasHeight) newAttrs += ` height="${vbHeight}"`; 469 return '<svg' + newAttrs + '>'; 470 } 471 } 472 } 473 return match; 474 }); 475 } 476 477 function svgDeclaredEncoding(bytes) { 478 let head = ''; 479 const limit = Math.min(bytes.length, 256); 480 for (let i = 0; i < limit; i++) head += String.fromCharCode(bytes[i]); 481 const m = /encoding\s*=\s*["']([\w.:-]+)["']/i.exec(head); 482 return m ? m[1] : null; 483 } 484 485 function decodeSvgBytes(bytes) { 486 if (bytes.length > 1) { 487 if (bytes[0] === 0xFF && bytes[1] === 0xFE) return new TextDecoder('utf-16le').decode(bytes); 488 if (bytes[0] === 0xFE && bytes[1] === 0xFF) return new TextDecoder('utf-16be').decode(bytes); 489 } 490 const label = svgDeclaredEncoding(bytes); 491 if (label) { 492 try { 493 return new TextDecoder(label, { fatal: true }).decode(bytes); 494 } catch (e) { 495 } 496 } 497 return new TextDecoder().decode(bytes); 498 } 499 500 async function readSvgText(file) { 501 let bytes = new Uint8Array(await file.arrayBuffer()); 502 if (bytes.length > 2 && bytes[0] === 0x1F && bytes[1] === 0x8B) { 503 if (typeof DecompressionStream !== 'function') throw new Error('Invalid SVG file: compressed SVG is not supported by this browser'); 504 try { 505 const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); 506 bytes = new Uint8Array(await new Response(stream).arrayBuffer()); 507 } catch (e) { 508 throw new Error('Invalid SVG file: broken gzip stream'); 509 } 510 } 511 const text = decodeSvgBytes(bytes); 512 if (!/<svg[\s>\/]/i.test(text)) throw new Error('Invalid SVG file'); 513 return text; 514 } 515 516 async function isGzipFile(file) { 517 const head = new Uint8Array(await file.slice(0, 2).arrayBuffer()); 518 return head.length === 2 && head[0] === 0x1F && head[1] === 0x8B; 519 } 520 521 async function gzipBytes(bytes) { 522 const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip')); 523 return new Uint8Array(await new Response(stream).arrayBuffer()); 524 } 525 526 async function optimizeSvgFile(fileData, precision, multipass) { 527 const svgContent = await readSvgText(fileData.file); 528 const optimized = await compressSvg(svgContent, precision, multipass); 529 if (optimized === null) { 530 const data = new Uint8Array(await fileData.file.arrayBuffer()); 531 return { data, compressedSize: data.byteLength, mimeType: 'image/svg+xml', usedOriginal: true }; 532 } 533 const text = new TextEncoder().encode(optimized); 534 if (await isGzipFile(fileData.file) && typeof CompressionStream === 'function') { 535 const data = await gzipBytes(text); 536 return { data, compressedSize: data.byteLength, mimeType: 'image/svg+xml', viewData: text, outputExt: 'svgz' }; 537 } 538 return { data: text, compressedSize: text.byteLength, mimeType: 'image/svg+xml', outputExt: 'svg' }; 539 } 540 541 function outputViewBlob(fileData) { 542 if (fileData.outputBlob === fileData.file && fileData.previewFile) return fileData.previewFile; 543 return fileData.outputView || fileData.outputBlob; 544 } 545 546 function svgResultBlobs(result) { 547 const output = new Blob([result.data], { type: result.mimeType }); 548 const view = result.viewData ? new Blob([result.viewData], { type: result.mimeType }) : output; 549 return { output, view }; 550 } 551 552 async function compressSvg(svgContent, precision, multipass = true) { 553 try { 554 const optimize = await loadSvgo(); 555 const result = optimize(svgContent, { 556 multipass: multipass, 557 floatPrecision: precision,
558 plugins: [ 559 { 560 name: 'preset-default', 561 params: { 562 overrides: { 563 564 removeHiddenElems: false, 565 removeUselessDefs: false, 566 cleanupIds: false, 567 cleanupNumericValues: { 568 floatPrecision: precision 569 }, 570 convertPathData: { 571 floatPrecision: precision 572 }, 573 convertTransform: { 574 floatPrecision: precision 575 } 576 } 577 } 578 } 579 ] 580 }); 581 return fixSvgDimensions(result.data); 582 } catch (error) { 583 console.error('SVGO optimization error:', error); 584 reportError(error, { op: 'svgo' }); 585 return null; 586 } 587 } 588 589 function compressImageInWorker(file, format, quality, optimizationLevel = 2, autoMode = false, maxQuality = 99) { 590 return new Promise(async (resolve, reject) => { 591 initWorker(); 592 const id = generateId(); 593 const arrayBuffer = await file.arrayBuffer(); 594 595 pendingCompressions.set(id, { resolve, reject }); 596 597 worker.postMessage({ 598 type: 'compress', 599 id, 600 data: arrayBuffer, 601 format, 602 quality, 603 optimizationLevel, 604 autoMode, 605 maxQuality 606 }, [arrayBuffer]); 607 }); 608 } 609 610 async function compressFile(fileData, quality) { 611 const format = fileData.inputFormat.toLowerCase(); 612 613 if (!['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg'].includes(format)) { 614 return; 615 } 616 617 if (abortedFileIds.has(fileData.id)) { 618 abortedFileIds.delete(fileData.id); 619 return; 620 } 621 622 try { 623 fileData.status = 'converting'; 624 updateFileCard(fileData); 625 626 const isAnimated = fileData.frameCount > 1; 627 let actualQuality = quality; 628 629 if (abortedFileIds.has(fileData.id)) { 630 abortedFileIds.delete(fileData.id); 631 return; 632 } 633 634 let result; 635 const useAutoMode = format === 'png' || format === 'gif'; 636 637 if (format === 'svg') { 638 result = await optimizeSvgFile(fileData, actualQuality, true); 639 } else if (format === 'gif') { 640 result = await compressGifInWorker(fileData.file, actualQuality, useAutoMode, 99, 3); 641 } else if (format === 'webp' && isAnimated) { 642 result = await compressAnimatedWebpInWorker(fileData.file, actualQuality); 643 } else { 644 result = await compressImageInWorker(fileData.file, format, actualQuality, 3, useAutoMode, 99); 645 } 646 647 if (abortedFileIds.has(fileData.id)) { 648 abortedFileIds.delete(fileData.id); 649 return; 650 } 651 652 if (typeof result.imageWidth === 'number') { 653 fileData.imageWidth = result.imageWidth; 654 fileData.imageHeight = result.imageHeight; 655 } 656 657 if (fileData.compressedUrl) { 658 URL.revokeObjectURL(fileData.compressedUrl); 659 } 660 661 if (result.compressedSize >= fileData.originalSize) { 662 const originalUrl = URL.createObjectURL(fileData.file); 663 const viewBlob = fileData.previewFile || fileData.file; 664 fileData.compressedBlob = viewBlob; 665 fileData.compressedUrl = viewBlob === fileData.file ? originalUrl : URL.createObjectURL(viewBlob); 666 fileData.compressedSize = fileData.originalSize; 667 fileData.outputBlob = fileData.file; 668 fileData.outputUrl = originalUrl; 669 fileData.outputSize = fileData.originalSize; 670 fileData.outputView = null; 671 if (format === 'svg') fileData.outputFormat = fileData.originalExt || 'svg'; 672 } else { 673 const { output, view } = svgResultBlobs(result); 674 fileData.compressedBlob = view; 675 fileData.compressedUrl = URL.createObjectURL(view); 676 fileData.compressedSize = result.compressedSize; 677 fileData.outputBlob = output; 678 fileData.outputUrl = view === output ? fileData.compressedUrl : URL.createObjectURL(output); 679 fileData.outputSize = result.compressedSize; 680 fileData.outputView = view === output ? null : view; 681 if (format === 'svg') fileData.outputFormat = result.outputExt || 'svg'; 682 } 683 actualQuality = extractQuality(result, actualQuality); 684 fileData.quality = actualQuality; 685 fileData.previewQuality = actualQuality; 686 687 if (result.detectedColors || result.originalColors) { 688 fileData.maxColors = result.detectedColors || result.originalColors; 689 if (qualitySlider && selectedFileId === fileData.id) { 690 qualitySlider.max = fileData.maxColors; 691 } 692 } 693 694 if (result.detectedQuality) { 695 fileData.maxQuality = result.detectedQuality; 696 if (qualitySlider && selectedFileId === fileData.id) { 697 qualitySlider.max = fileData.maxQuality; 698 } 699 } 700 701 if (result.oxipngRatio) { 702 fileData.oxipngRatio = result.oxipngRatio; 703 } 704 fileData.status = 'done'; 705 reportSuccess({ op: 'compress', format, inputSize: fileData.originalSize, outputSize: fileData.compressedSize, ...imageDims(fileData) }); 706 707 updateFileCard(fileData); 708 updateUI(); 709 710 if (!fileData.isSprite && (autoSelectFirst || !selectedFileId)) { 711 autoSelectFirst = false;
712 showComparisonPanel(fileData); 713 } 714 715 if (selectedFileId === fileData.id && comparisonPanelInstance) { 716 717 if (currentAnimatedFileData && currentAnimatedFileData.id === fileData.id) { 718 comparisonPanelInstance.updateStats(fileData.originalSize, fileData.compressedSize, false); 719 reloadCurrentFrame(fileData); 720 } else { 721 comparisonPanelInstance.updateCompressedImage(fileData.compressedUrl, fileData.compressedSize, false); 722 } 723 } 724 725 } catch (error) { 726 console.error('processFile error:', error); 727 reportError(error, { op: 'compress', format, fileSize: fileData.originalSize, ...imageDims(fileData) }); 728 if (abortedFileIds.has(fileData.id)) { 729 abortedFileIds.delete(fileData.id); 730 return; 731 } 732 fileData.status = 'error'; 733 fileData.errorMessage = error.message || 'Compression failed'; 734 updateFileCard(fileData); 735 } 736 } 737 738 async function compressPreviewOnly(fileData, quality) { 739 const format = fileData.inputFormat.toLowerCase(); 740 if (!['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg'].includes(format)) { 741 return; 742 } 743 744 try { 745 isPreviewCompressing = true; 746 setLoading(true); 747 748 const isAnimated = fileData.frameCount > 1; 749 750 let result; 751 if (format === 'svg') { 752 result = await optimizeSvgFile(fileData, quality, false); 753 } else if (format === 'gif') { 754 755 result = await compressGifInWorker(fileData.file, quality, false, 99, 2); 756 } else if (format === 'webp' && isAnimated) { 757 result = await compressAnimatedWebpInWorker(fileData.file, quality); 758 } else { 759 result = await compressImageInWorker(fileData.file, format, quality, 2); 760 } 761 762 if (fileData.compressedUrl && fileData.compressedUrl !== fileData.outputUrl) { 763 URL.revokeObjectURL(fileData.compressedUrl); 764 } 765 766 const blob = svgResultBlobs(result).view; 767 fileData.compressedBlob = blob; 768 fileData.compressedUrl = URL.createObjectURL(blob); 769 fileData.compressedSize = result.compressedSize; 770 771 const actualQuality = extractQuality(result, quality); 772 fileData.previewQuality = actualQuality; 773 774 if (result.originalColors) { 775 fileData.maxColors = result.originalColors; 776 if (qualitySlider) { 777 qualitySlider.max = result.originalColors; 778 } 779 } 780 781 const estimatedSize = result.usedOriginal 782 ? result.compressedSize 783 : (result.actualCompressedSize || result.compressedSize); 784 785 if (selectedFileId === fileData.id && comparisonPanelInstance) { 786 787 if (currentAnimatedFileData && currentAnimatedFileData.id === fileData.id) { 788 comparisonPanelInstance.updateStats(fileData.originalSize, estimatedSize, true); 789 reloadCurrentFrame(fileData); 790 } else { 791 comparisonPanelInstance.updateCompressedImage(fileData.compressedUrl, estimatedSize, true); 792 } 793 } 794 795 } catch (error) { 796 console.error('compressPreviewOnly error:', error); 797 reportError(error, { op: 'preview', format }); 798 } finally { 799 isPreviewCompressing = false; 800 setLoading(false); 801 } 802 } 803 804 async function compressAllFiles() { 805 if (isConverting) return; 806 807 const pendingFiles = files.filter(f => f.status === 'pending'); 808 if (pendingFiles.length === 0) return; 809 810 isConverting = true; 811 updateUI(); 812 813 for (const fileData of pendingFiles) { 814 await compressFile(fileData, fileData.quality); 815 } 816 817 isConverting = false; 818 updateUI(); 819 } 820 821 async function addFiles(newFiles) { 822 const remaining = CONFIG.maxFiles - files.length; 823 if (remaining <= 0) return; 824 825 const filesToAdd = Array.from(newFiles).slice(0, remaining); 826 const needsThumbnail = []; 827 const needsDimensions = []; 828 829 for (const file of filesToAdd) { 830 let ext = getFileExtension(file.name); 831 832 const realFormat = await detectFormat(file); 833 if (realFormat && CONFIG.inputFormats.includes(realFormat)) { 834 ext = realFormat; 835 } 836 if (ext === 'svgz') ext = 'svg'; 837 838 if (!CONFIG.inputFormats.includes(ext)) { 839 continue; 840 } 841 842 if (file.size > CONFIG.maxSize) { 843 continue; 844 } 845 846 const canPreview = PREVIEW_FORMATS.includes(ext); 847 const baseName = file.name.replace(/\.[^.]+$/, ''); 848 849 const fileData = { 850 id: generateId(), 851 file: file, 852 name: baseName, 853 inputFormat: ext.toUpperCase(), 854 status: 'pending', 855 progress: 0, 856 localThumbUrl: (canPreview && ext !== 'svg') ? URL.createObjectURL(file) : null, 857 previewFile: null, 858 outputView: null, 859 imageWidth: null, 860 imageHeight: null, 861 frameCount: 1, 862 outputFormat: ext, 863 originalExt: getFileExtension(file.name), 864 outputBlob: null, 865 outputUrl: null, 866 outputSize: null, 867 errorMessage: null, 868 isZip: false, 869 870 originalSize: file.size, 871 compressedSize: null, 872 compressedBlob: null, 873 compressedUrl: null, 874 quality: getDefaultValue(ext), 875 oxipngRatio: null, 876 downloaded: false 877 }; 878 879 files.push(fileData); 880 renderFileCard(fileData); 881 882 if (!canPreview) { 883 needsThumbnail.push(fileData); 884 } else if (ext !== 'svg') { 885 needsDimensions.push(fileData); 886 } 887 888 if (ext === 'gif' || ext === 'webp' || ext === 'tiff' || ext === 'tif') { 889 fileData.frameCountPromise = countFramesInWorker(file).then(count => { 890 fileData.frameCount = count; 891 return count; 892 }); 893 } 894 895 if (ext === 'svg') { 896 readSvgText(file).then(svgContent => { 897 const detectedPrecision = detectSvgPrecision(svgContent); 898 fileData.maxPrecision = Math.min(detectedPrecision, SLIDER_CONFIG.precision.max); 899 fileData.quality = Math.min(fileData.quality, fileData.maxPrecision); 900 901 fileData.isSprite = isSvgSprite(svgContent); 902 903 const fixedSvg = fixSvgDimensions(svgContent); 904 const fixedBlob = new Blob([fixedSvg], { type: 'image/svg+xml' }); 905 fileData.previewFile = fixedBlob; 906 907 if (fileData.localThumbUrl) { 908 URL.revokeObjectURL(fileData.localThumbUrl); 909 } 910 fileData.localThumbUrl = URL.createObjectURL(fixedBlob); 911 912 const card = filesContainer.querySelector(`[data-id="${fileData.id}"]`); 913 if (card) { 914 const img = card.querySelector('.file__image'); 915 if (img) { 916 img.onload = () => img.classList.remove('file__image_hidden'); 917 img.src = fileData.localThumbUrl; 918 } 919 } 920 }).catch(() => {}); 921 } 922 } 923 924 updateUI(); 925 scrollToEnd(); 926 927 if (needsThumbnail.length > 0) { 928 generateThumbnails(needsThumbnail); 929 } 930 931 if (needsDimensions.length > 0) { 932 getDimensions(needsDimensions); 933 } 934 935 } 936 937 async function getDimensions(fileDataList) { 938 for (const fileData of fileDataList) {
939 try { 940 const bitmap = await createImageBitmap(fileData.file); 941 fileData.imageWidth = bitmap.width; 942 fileData.imageHeight = bitmap.height; 943 bitmap.close(); 944 } catch (error) { 945 fileData.status = 'error'; 946 fileData.errorMessage = 'The source image could not be decoded'; 947 updateFileCard(fileData); 948 reportError(error, { op: 'corrupt', fileSize: fileData.file.size, ...imageDims(fileData) }); 949 } 950 } 951 } 952 953 async function generateThumbnails(fileDataList) { 954 for (const fileData of fileDataList) { 955 try { 956 const thumbTimeout = new Promise((_, reject) => 957 setTimeout(() => reject(new Error('The source image could not be decoded')), 5000) 958 ); 959 const result = await Promise.race([ 960 generateThumbnailInWorker(fileData.file, fileData.inputFormat.toLowerCase()), 961 thumbTimeout 962 ]); 963 if (!result.width || !result.height) { 964 throw new Error('The source image could not be decoded'); 965 } 966 if (fileData.localThumbUrl) { 967 URL.revokeObjectURL(fileData.localThumbUrl); 968 } 969 fileData.localThumbUrl = result.url; 970 fileData.imageWidth = result.width; 971 fileData.imageHeight = result.height; 972 973 const card = filesContainer.querySelector(`[data-id="${fileData.id}"]`); 974 if (card) { 975 const img = card.querySelector('.file__image'); 976 if (img) { 977 img.src = result.url; 978 img.onload = () => img.classList.remove('file__image_hidden'); 979 img.onerror = () => { 980 fileData.status = 'error'; 981 fileData.errorMessage = 'The source image could not be decoded'; 982 updateFileCard(fileData); 983 }; 984 } 985 } 986 } catch (error) { 987 fileData.status = 'error'; 988 fileData.errorMessage = error.message || 'Failed to read file'; 989 updateFileCard(fileData); 990 } 991 } 992 } 993 994 function removeFile(id) { 995 const index = files.findIndex(f => f.id === id); 996 if (index === -1) return; 997 998 const fileData = files[index]; 999 1000 if (fileData.status === 'converting') { 1001 abortedFileIds.add(id); 1002 } 1003 1004 if (previewDebounceTimer) { 1005 clearTimeout(previewDebounceTimer); 1006 previewDebounceTimer = null; 1007 } 1008 1009 if (fileData.localThumbUrl) { 1010 URL.revokeObjectURL(fileData.localThumbUrl); 1011 } 1012 if (fileData.outputUrl) { 1013 URL.revokeObjectURL(fileData.outputUrl); 1014 } 1015 1016 if (fileData.compressedUrl && fileData.compressedUrl !== fileData.outputUrl) { 1017 URL.revokeObjectURL(fileData.compressedUrl); 1018 } 1019 1020 if (selectedFileId === id) { 1021 selectedFileId = null; 1022 hideComparisonPanel(); 1023 } 1024 1025 files.splice(index, 1); 1026 1027 const card = filesContainer.querySelector(`[data-id="${id}"]`); 1028 if (card) { 1029 shimmeredElements.delete(card); 1030 card.remove(); 1031 } 1032 1033 updateUI(); 1034 } 1035 1036 function clearAll() { 1037 1038 files.forEach(f => { 1039 if (f.status === 'converting' || f.status === 'pending') { 1040 abortedFileIds.add(f.id); 1041 } 1042 }); 1043 isConverting = false; 1044 1045 if (previewDebounceTimer) { 1046 clearTimeout(previewDebounceTimer); 1047 previewDebounceTimer = null; 1048 } 1049 1050 files.forEach(f => { 1051 if (f.localThumbUrl) URL.revokeObjectURL(f.localThumbUrl); 1052 if (f.outputUrl) URL.revokeObjectURL(f.outputUrl); 1053 1054 if (f.compressedUrl && f.compressedUrl !== f.outputUrl) { 1055 URL.revokeObjectURL(f.compressedUrl); 1056 } 1057 }); 1058 1059 files = []; 1060 filesContainer.innerHTML = ''; 1061 shimmeredElements.clear(); 1062 hideComparisonPanel(); 1063 autoSelectFirst = true; 1064 updateUI(); 1065 } 1066 1067 function downloadFile(fileData) { 1068 if (!fileData.outputUrl || !fileData.outputBlob) return; 1069 1070 hasDownloaded = true; 1071 fileData.downloaded = true; 1072 1073 const ext = fileData.isZip ? 'zip' : fileData.outputFormat; 1074 const filename = `${fileData.name}.${ext}`; 1075 const a = document.createElement('a'); 1076 a.href = fileData.outputUrl; 1077 a.download = filename; 1078 document.body.appendChild(a); 1079 a.click(); 1080 document.body.removeChild(a); 1081 1082 updateFileCard(fileData); 1083 updateUI(); 1084 } 1085 1086 function downloadAll() { 1087 const doneFiles = files.filter(f => f.status === 'done' && f.outputBlob && !f.downloaded); 1088 if (doneFiles.length === 0 || isDownloading) return; 1089 1090 isDownloading = true; 1091 convertBtn.disabled = true; 1092
1093 let completed = 0; 1094 const total = doneFiles.length; 1095 1096 doneFiles.forEach((fileData, index) => { 1097 setTimeout(() => { 1098 downloadFile(fileData); 1099 completed++; 1100 if (completed >= total) { 1101 isDownloading = false; 1102 updateUI(); 1103 } 1104 }, index * 100); 1105 }); 1106 } 1107 1108 function showComparisonPanel(fileData) { 1109 selectedFileId = fileData.id; 1110 1111 hasQualityChanged = false; 1112 updateApplyButtonState(); 1113 1114 const allCards = filesContainer.querySelectorAll('.file'); 1115 allCards.forEach(card => { 1116 card.classList.toggle('file_active', card.dataset.id === fileData.id); 1117 }); 1118 1119 if (settingsFileName) { 1120 settingsFileName.textContent = fileData.name + '.' + fileData.inputFormat.toLowerCase(); 1121 } 1122 1123 const maxValue = fileData.maxColors ?? fileData.maxQuality ?? fileData.maxPrecision; 1124 updateSliderForFormat(fileData.inputFormat, fileData.quality, maxValue); 1125 1126 if (fileData.compressedUrl && fileData.compressedUrl !== fileData.outputUrl && fileData.compressedBlob !== outputViewBlob(fileData)) { 1127 URL.revokeObjectURL(fileData.compressedUrl); 1128 const viewBlob = outputViewBlob(fileData); 1129 fileData.compressedBlob = viewBlob; 1130 fileData.compressedUrl = viewBlob === fileData.outputBlob ? fileData.outputUrl : URL.createObjectURL(viewBlob); 1131 fileData.compressedSize = fileData.outputSize; 1132 fileData.previewQuality = fileData.quality; 1133 } 1134 1135 hideFrameCounter(); 1136 1137 if (comparisonPanelInstance) { 1138 comparisonPanelInstance.show({ 1139 originalUrl: fileData.localThumbUrl, 1140 compressedUrl: fileData.outputUrl, 1141 originalSize: fileData.originalSize, 1142 compressedSize: fileData.outputSize, 1143 quality: fileData.quality 1144 }); 1145 1146 requestAnimationFrame(() => { 1147 if (window.resizeQualitySlider) window.resizeQualitySlider(); 1148 }); 1149 } 1150 1151 const format = fileData.inputFormat.toLowerCase(); 1152 const isAnimatable = format === 'gif' || format === 'webp'; 1153 if (isAnimatable && fileData.frameCount > 1) { 1154 initAnimationReader(fileData); 1155 } 1156 } 1157 1158 function hideComparisonPanel() { 1159 selectedFileId = null; 1160 1161 const allCards = filesContainer.querySelectorAll('.file'); 1162 allCards.forEach(card => card.classList.remove('file_active')); 1163 1164 if (comparisonPanelInstance) { 1165 comparisonPanelInstance.hide(); 1166 } 1167 1168 hideFrameCounter(); 1169 clearFrameCache('all'); 1170 currentAnimatedFileData = null; 1171 } 1172 1173 function showFrameCounter(totalFrames) { 1174 if (!frameCounterEl) return; 1175 currentTotalFrames = totalFrames; 1176 currentFrameIndex = 0; 1177 1178 frameCounterEl.classList.remove('settings__frame-counter_hidden'); 1179 updateFrameCounterUI(); 1180 } 1181 1182 function hideFrameCounter() { 1183 if (!frameCounterEl) return; 1184 frameCounterEl.classList.add('settings__frame-counter_hidden'); 1185 currentTotalFrames = 0; 1186 currentFrameIndex = 0; 1187 } 1188 1189 function updateFrameCounterUI() { 1190 if (!frameInput || !frameTotalLabel) return; 1191 1192 frameInput.value = currentFrameIndex + 1; 1193 frameTotalLabel.textContent = currentTotalFrames; 1194 1195 const atStart = currentFrameIndex === 0; 1196 const atEnd = currentFrameIndex >= currentTotalFrames - 1; 1197 1198 if (framePrev10Btn) framePrev10Btn.disabled = atStart || currentTotalFrames <= 10; 1199 if (framePrev1Btn) framePrev1Btn.disabled = atStart; 1200 if (frameNext1Btn) frameNext1Btn.disabled = atEnd; 1201 if (frameNext10Btn) frameNext10Btn.disabled = atEnd || currentTotalFrames <= 10; 1202 1203 const maxDigits = String(currentTotalFrames).length; 1204 const currentDigits = String(currentFrameIndex + 1).length; 1205 frameInput.maxLength = maxDigits; 1206 frameInput.style.width = currentDigits + 'ch'; 1207 } 1208 1209 let frameChangeTimer = null; 1210 let frameLoadVersion = 0; 1211 const FRAME_CHANGE_DELAY = 50; 1212 1213 function requestFrameChange(index, force = false) { 1214 1215 if (!currentAnimatedFileData || index < 0 || index >= currentTotalFrames) { 1216 1217 return; 1218 } 1219 if (!force && index === currentFrameIndex) { 1220 1221 return; 1222 } 1223 1224 currentFrameIndex = index; 1225 updateFrameCounterUI(); 1226 1227 if (frameChangeTimer) { 1228 clearTimeout(frameChangeTimer); 1229 } 1230 frameChangeTimer = setTimeout(() => { 1231 frameChangeTimer = null; 1232 loadCurrentFrame(); 1233 }, FRAME_CHANGE_DELAY); 1234 } 1235 1236 async function loadCurrentFrame() { 1237 1238 if (!currentAnimatedFileData) return; 1239 1240 const version = ++frameLoadVersion; 1241 const index = currentFrameIndex; 1242 const fileData = currentAnimatedFileData; 1243 1244 try { 1245 1246 const loadFrame = async (blob, cacheType, cacheKey) => { 1247 const result = await readFrameInWorker(blob, index, cacheType, cacheKey); 1248 1249 const imageData = new ImageData(result.data, result.width, result.height); 1250 const canvas = document.createElement('canvas'); 1251 canvas.width = result.width; 1252 canvas.height = result.height; 1253 canvas.getContext('2d').putImageData(imageData, 0, 0); 1254 return canvas.toDataURL('image/png'); 1255 }; 1256 1257 const originalKey = fileData.id + '-original'; 1258 const compressedKey = fileData.id + '-compressed-q' + (fileData.quality || 80); 1259 1260 const [originalDataUrl, compressedDataUrl] = await Promise.all([ 1261 loadFrame(fileData.file, 'original', originalKey), 1262 loadFrame(fileData.compressedBlob || fileData.file, 'compressed', compressedKey) 1263 ]); 1264 1265 if (version === frameLoadVersion && comparisonPanelInstance) { 1266 const originalImg = new Image(); 1267 const compressedImg = new Image(); 1268 1269 await Promise.all([ 1270 new Promise(r => { originalImg.onload = r; originalImg.src = originalDataUrl; }), 1271 new Promise(r => { compressedImg.onload = r; compressedImg.src = compressedDataUrl; }) 1272 ]); 1273 1274 if (version === frameLoadVersion) { 1275 comparisonPanelInstance.setImage('original', originalImg); 1276 comparisonPanelInstance.setImage('compressed', compressedImg); 1277 } 1278 } 1279 } catch (error) { 1280 console.error('showComparisonPanel error:', error); 1281 } 1282 } 1283 1284 async function goToFrame(index, force = false) { 1285 requestFrameChange(index, force); 1286 } 1287 1288 function setFrameButtonsDisabled(disabled) { 1289 if (framePrev10Btn) framePrev10Btn.disabled = disabled; 1290 if (framePrev1Btn) framePrev1Btn.disabled = disabled; 1291 if (frameNext1Btn) frameNext1Btn.disabled = disabled; 1292 if (frameNext10Btn) frameNext10Btn.disabled = disabled; 1293 if (frameInput) frameInput.disabled = disabled; 1294 } 1295 1296 function frameStep(delta) { 1297 1298 const newIndex = Math.max(0, Math.min(currentTotalFrames - 1, currentFrameIndex + delta)); 1299 1300 goToFrame(newIndex); 1301 } 1302 1303 function handleFrameInputChange(e) { 1304 const val = parseInt(e.target.value, 10); 1305 if (!isNaN(val) && val >= 1 && val <= currentTotalFrames) { 1306 goToFrame(val - 1); 1307 } else { 1308 e.target.value = currentFrameIndex + 1; 1309 } 1310 } 1311 1312 async function initAnimationReader(fileData) { 1313 currentAnimatedFileData = fileData; 1314 1315 try { 1316 1317 if (fileData.frameCount > 1) { 1318 showFrameCounter(fileData.frameCount); 1319 1320 goToFrame(0, true); 1321 } 1322 } catch (error) { 1323 1324 hideFrameCounter(); 1325 } 1326 } 1327 1328 function clearFrameCache(cacheType = 'all') { 1329 if (worker) { 1330 worker.postMessage({ type: 'clear_frame_cache', cacheType }); 1331 } 1332 } 1333 1334 async function reloadCurrentFrame(fileData) { 1335 if (!currentAnimatedFileData || currentAnimatedFileData.id !== fileData.id) return; 1336 if (!fileData.compressedBlob) return; 1337 1338 currentAnimatedFileData = fileData; 1339 1340 clearFrameCache('compressed'); 1341 1342 await goToFrame(currentFrameIndex, true); 1343 } 1344 1345 let isDraggingSlider = false;
1346 1347 function setLoading(loading) { 1348 if (settingsLoader) { 1349 settingsLoader.classList.toggle('loader_hidden', !loading); 1350 } 1351 if (!isDraggingSlider) { 1352 if (qualityInput) qualityInput.disabled = loading; 1353 if (qualitySlider) qualitySlider.disabled = loading; 1354 } 1355 1356 if (applyQualityBtn) applyQualityBtn.disabled = loading || !hasQualityChanged; 1357 } 1358 1359 function updateApplyButtonState() { 1360 if (applyQualityBtn) { 1361 applyQualityBtn.disabled = !hasQualityChanged; 1362 } 1363 } 1364 1365 function handleQualityChange(forcePreview = false) { 1366 if (!selectedFileId) return; 1367 1368 const fileData = files.find(f => f.id === selectedFileId); 1369 if (!fileData || fileData.status !== 'done') return; 1370 1371 const type = getSliderType(fileData.inputFormat); 1372 const config = SLIDER_CONFIG[type]; 1373 const quality = parseInt(qualityInput?.value || qualitySlider?.value || config.default, 10); 1374 if (quality < config.min || quality > config.max) return; 1375 1376 hasQualityChanged = quality !== fileData.quality; 1377 updateApplyButtonState(); 1378 1379 if (quality === fileData.previewQuality) return; 1380 if (isDraggingSlider && !forcePreview) return; 1381 1382 if (previewDebounceTimer) { 1383 clearTimeout(previewDebounceTimer); 1384 } 1385 1386 previewDebounceTimer = setTimeout(() => { 1387 compressPreviewOnly(fileData, quality); 1388 }, 300); 1389 } 1390 1391 async function handleApplyQuality() { 1392 if (!selectedFileId) return; 1393 1394 const fileData = files.find(f => f.id === selectedFileId); 1395 if (!fileData) return; 1396 1397 const format = fileData.inputFormat.toLowerCase(); 1398 const defaultVal = getDefaultValue(format); 1399 const quality = fileData.previewQuality || parseInt(qualityInput?.value || defaultVal, 10); 1400 1401 if (quality === fileData.quality) return; 1402 1403 setLoading(true); 1404 1405 try { 1406 const isAnimated = fileData.frameCount > 1; 1407 1408 let result; 1409 if (format === 'svg') { 1410 result = await optimizeSvgFile(fileData, quality, true); 1411 } else if (format === 'gif') { 1412 1413 result = await compressGifInWorker(fileData.file, quality, false, 99, 3); 1414 } else if (format === 'webp' && isAnimated) { 1415 result = await compressAnimatedWebpInWorker(fileData.file, quality); 1416 } else { 1417 result = await compressImageInWorker(fileData.file, format, quality, 3); 1418 } 1419 1420 if (fileData.outputUrl && fileData.outputUrl !== fileData.compressedUrl) { 1421 URL.revokeObjectURL(fileData.outputUrl); 1422 } 1423 if (fileData.compressedUrl) { 1424 URL.revokeObjectURL(fileData.compressedUrl); 1425 } 1426 1427 const { output, view } = svgResultBlobs(result); 1428 fileData.compressedBlob = view; 1429 fileData.compressedUrl = URL.createObjectURL(view); 1430 fileData.compressedSize = result.compressedSize; 1431 fileData.outputBlob = output; 1432 fileData.outputUrl = view === output ? fileData.compressedUrl : URL.createObjectURL(output); 1433 fileData.outputSize = result.compressedSize; 1434 fileData.outputView = view === output ? null : view; 1435 if (format === 'svg') fileData.outputFormat = result.usedOriginal ? (fileData.originalExt || 'svg') : (result.outputExt || 'svg'); 1436 1437 const actualQuality = extractQuality(result, quality); 1438 fileData.quality = actualQuality; 1439 fileData.previewQuality = actualQuality; 1440 1441 if (result.originalColors) { 1442 fileData.maxColors = result.originalColors; 1443 if (qualitySlider) { 1444 qualitySlider.max = result.originalColors; 1445 } 1446 } 1447 1448 if (result.oxipngRatio) { 1449 fileData.oxipngRatio = result.oxipngRatio; 1450 } 1451 1452 if (extractQuality(result, null) && qualityInput && qualitySlider) { 1453 qualityInput.value = actualQuality; 1454 qualitySlider.value = actualQuality; 1455 } 1456 1457 if (comparisonPanelInstance) { 1458 1459 if (currentAnimatedFileData && currentAnimatedFileData.id === fileData.id) { 1460 comparisonPanelInstance.updateStats(fileData.originalSize, fileData.compressedSize, false); 1461 reloadCurrentFrame(fileData); 1462 } else { 1463 comparisonPanelInstance.updateCompressedImage(fileData.compressedUrl, fileData.compressedSize, false); 1464 } 1465 } 1466 1467 fileData.downloaded = false;
1468 updateFileCard(fileData); 1469 updateUI(); 1470 1471 hasQualityChanged = false; 1472 1473 } catch (error) { 1474 console.error('handleApplyQuality error:', error); 1475 reportError(error, { op: 'quality-change' }); 1476 } finally { 1477 setLoading(false); 1478 } 1479 } 1480 1481 function renderFileCard(fileData) { 1482 const template = fileCardTemplate.content.cloneNode(true); 1483 const card = template.querySelector('.file'); 1484 1485 card.dataset.id = fileData.id; 1486 card.querySelector('.file__title').textContent = truncateName(fileData.name + '.' + fileData.inputFormat.toLowerCase()); 1487 1488 const img = card.querySelector('.file__image'); 1489 if (fileData.localThumbUrl) { 1490 img.src = fileData.localThumbUrl; 1491 img.onload = () => img.classList.remove('file__image_hidden'); 1492 img.onerror = () => { 1493 fileData.status = 'error'; 1494 fileData.errorMessage = 'The source image could not be decoded'; 1495 updateFileCard(fileData); 1496 }; 1497 } 1498 1499 const pendingFormatText = card.querySelector('.file__state_pending .file__state-text_format'); 1500 if (pendingFormatText) { 1501 pendingFormatText.textContent = fileData.inputFormat; 1502 } 1503 1504 const removeBtn = card.querySelector('.remove-btn'); 1505 removeBtn.addEventListener('click', (e) => { 1506 e.stopPropagation(); 1507 removeFile(fileData.id); 1508 }); 1509 1510 const downloadBtn = card.querySelector('.file-button'); 1511 downloadBtn.addEventListener('click', (e) => { 1512 e.stopPropagation(); 1513 const currentFile = files.find(f => f.id === fileData.id); 1514 if (currentFile && currentFile.status === 'done') { 1515 downloadFile(currentFile); 1516 } 1517 }); 1518 1519 card.addEventListener('click', () => { 1520 const currentFile = files.find(f => f.id === fileData.id); 1521 1522 if (currentFile && currentFile.status === 'done' && !currentFile.isSprite) { 1523 showComparisonPanel(currentFile); 1524 } 1525 }); 1526 1527 filesContainer.appendChild(card); 1528 updateScrollButtons(); 1529 } 1530 1531 function updateFileCard(fileData) { 1532 const card = filesContainer.querySelector(`[data-id="${fileData.id}"]`); 1533 if (!card) return; 1534 1535 const fileButton = card.querySelector('.file-button'); 1536 const progressBar = card.querySelector('.file-button__progress'); 1537 const buttonText = card.querySelector('.file-button__text'); 1538 1539 const statePending = card.querySelector('.file__state_pending'); 1540 const stateConverting = card.querySelector('.file__state_converting'); 1541 const stateError = card.querySelector('.file__state_error'); 1542 const stateSaving = card.querySelector('.file__state_saving'); 1543 1544 [statePending, stateConverting, stateError, stateSaving].forEach(s => { 1545 if (s) s.classList.remove('file__state_visible'); 1546 }); 1547 1548 switch (fileData.status) { 1549 case 'pending': 1550 statePending.classList.add('file__state_visible'); 1551 fileButton.classList.add('file-button_hidden'); 1552 fileButton.disabled = true; 1553 progressBar.style.width = '0%'; 1554 buttonText.textContent = ''; 1555 break; 1556 1557 case 'converting': 1558 stateConverting.classList.add('file__state_visible'); 1559 fileButton.classList.remove('file-button_hidden'); 1560 fileButton.disabled = true; 1561 progressBar.classList.remove('file-button__progress_pause', 'file-button__progress_hidden'); 1562 void progressBar.offsetHeight; 1563 progressBar.style.width = '100%'; 1564 buttonText.textContent = ''; 1565 break; 1566 1567 case 'done': 1568 stateSaving.classList.add('file__state_visible'); 1569 1570 const savingText = stateSaving.querySelector('.file__state-text_saving'); 1571 if (savingText && fileData.originalSize && fileData.outputSize) { 1572 const savings = Math.round((1 - fileData.outputSize / fileData.originalSize) * 100); 1573 const savingsText = savings >= 0 ? `-${savings}%` : `+${Math.abs(savings)}%`; 1574 savingText.textContent = fileData.isSprite ? `Sprite ${savingsText}` : savingsText; 1575 } 1576 progressBar.classList.add('file-button__progress_hidden'); 1577 if (fileData.downloaded) { 1578 fileButton.classList.add('file-button_hidden'); 1579 fileButton.disabled = true; 1580 buttonText.textContent = ''; 1581 } else { 1582 fileButton.classList.remove('file-button_hidden'); 1583 fileButton.disabled = false;
1584 buttonText.textContent = CONFIG.texts.save || 'SAVE'; 1585 } 1586 break; 1587 1588 case 'error': 1589 stateError.classList.add('file__state_visible'); 1590 const detail = stateError.querySelector('.file__state-detail'); 1591 if (detail) detail.textContent = classifyError(fileData.errorMessage); 1592 fileButton.classList.add('file-button_hidden'); 1593 fileButton.disabled = true; 1594 progressBar.style.width = '0%'; 1595 buttonText.textContent = ''; 1596 updateUI(); 1597 break; 1598 } 1599 1600 card.classList.toggle('file_can-setting', fileData.status === 'done' && !fileData.isSprite); 1601 card.classList.toggle('file_sprite', !!fileData.isSprite); 1602 } 1603 1604 function updateUI() { 1605 const hasFiles = files.length > 0; 1606 const pendingCount = files.filter(f => f.status === 'pending').length; 1607 const downloadableCount = files.filter(f => f.status === 'done' && !f.downloaded).length; 1608 1609 clearBtn.disabled = !hasFiles; 1610 1611 if (hasSeparateDownloadBtn) { 1612 1613 if (pendingCount > 0) { 1614 convertBtn.disabled = isConverting; 1615 convertCounter.textContent = pendingCount; 1616 convertCounter.style.display = ''; 1617 1618 if (!convertBtn.disabled) { 1619 setTimeout(() => { if (!convertBtn.disabled) shimmerHint(convertBtn); }, 500); 1620 } 1621 } else { 1622 convertBtn.disabled = true; 1623 convertCounter.style.display = 'none'; 1624 } 1625 1626 if (downloadableCount > 0) { 1627 downloadAllBtn.disabled = isConverting || isDownloading; 1628 downloadCounter.textContent = downloadableCount; 1629 downloadCounter.style.display = ''; 1630 } else { 1631 downloadAllBtn.disabled = true; 1632 downloadCounter.style.display = 'none'; 1633 } 1634 } else { 1635 1636 const convertBtnText = convertBtn.querySelector('.button__text'); 1637 const convertIconConvert = convertBtn.querySelector('.button__icon_convert'); 1638 const convertIconDownload = convertBtn.querySelector('.button__icon_download'); 1639 1640 if (pendingCount > 0) { 1641 convertBtn.disabled = isConverting; 1642 convertBtn.classList.add('button_dark'); 1643 convertBtn.classList.remove('button_success'); 1644 convertBtnText.textContent = CONFIG.texts.convert || 'COMPRESS'; 1645 if (convertIconConvert) convertIconConvert.style.display = ''; 1646 if (convertIconDownload) convertIconDownload.style.display = 'none'; 1647 convertCounter.textContent = pendingCount; 1648 convertCounter.style.display = ''; 1649 1650 if (!convertBtn.disabled) { 1651 setTimeout(() => { if (!convertBtn.disabled) shimmerHint(convertBtn); }, 500); 1652 } 1653 } else if (downloadableCount > 0) { 1654 convertBtn.disabled = isDownloading; 1655 convertBtn.classList.remove('button_dark'); 1656 convertBtn.classList.add('button_success'); 1657 convertBtnText.textContent = CONFIG.texts.download || 'DOWNLOAD'; 1658 if (convertIconConvert) convertIconConvert.style.display = 'none'; 1659 if (convertIconDownload) convertIconDownload.style.display = ''; 1660 convertCounter.textContent = downloadableCount; 1661 convertCounter.style.display = ''; 1662 } else { 1663 convertBtn.disabled = true; 1664 convertBtn.classList.add('button_dark'); 1665 convertBtn.classList.remove('button_success'); 1666 convertBtnText.textContent = CONFIG.texts.convert || 'COMPRESS'; 1667 if (convertIconConvert) convertIconConvert.style.display = ''; 1668 if (convertIconDownload) convertIconDownload.style.display = 'none'; 1669 convertCounter.style.display = 'none'; 1670 } 1671 } 1672 1673 dropMessage.classList.toggle('drop-caption_hidden', hasFiles); 1674 updateScrollButtons(); 1675 } 1676 1677 function updateScrollButtons() { 1678 if (!filesScroll) return; 1679 1680 const { scrollLeft, scrollWidth, clientWidth } = filesScroll; 1681 const canScrollLeft = scrollLeft > 0; 1682 const canScrollRight = scrollLeft + clientWidth < scrollWidth - 1; 1683 1684 prevBtn.disabled = !canScrollLeft; 1685 nextBtn.disabled = !canScrollRight; 1686 } 1687 1688 function scrollToEnd() { 1689 if (!filesScroll) return; 1690 setTimeout(() => { 1691 filesScroll.scrollTo({ 1692 left: filesScroll.scrollWidth, 1693 behavior: 'smooth' 1694 }); 1695 }, 100); 1696 } 1697 1698 function scrollBy(amount) { 1699 if (!filesScroll) return; 1700 1701 const maxScroll = filesScroll.scrollWidth - filesScroll.clientWidth; 1702 1703 if (amount < 0 && filesScroll.scrollLeft < Math.abs(amount)) { 1704 filesScroll.scrollTo({ left: 0, behavior: 'smooth' }); 1705 return; 1706 } 1707 1708 if (amount > 0 && filesScroll.scrollLeft + amount > maxScroll - 10) { 1709 filesScroll.scrollTo({ left: maxScroll, behavior: 'smooth' }); 1710 return; 1711 } 1712 1713 filesScroll.scrollBy({ 1714 left: amount, 1715 behavior: 'smooth' 1716 }); 1717 } 1718 1719 function handleFileSelect(e) { 1720 const selectedFiles = e.target.files; 1721 if (selectedFiles && selectedFiles.length > 0) { 1722 addFiles(selectedFiles); 1723 e.target.value = ''; 1724 } 1725 } 1726 1727 function handleDragOver(e) { 1728 e.preventDefault(); 1729 e.stopPropagation(); 1730 dropZone.classList.add('files__list-outer_dragover'); 1731 } 1732 1733 function handleDragLeave(e) { 1734 e.preventDefault(); 1735 e.stopPropagation(); 1736 dropZone.classList.remove('files__list-outer_dragover'); 1737 } 1738 1739 function handleDrop(e) { 1740 e.preventDefault(); 1741 e.stopPropagation(); 1742 dropZone.classList.remove('files__list-outer_dragover'); 1743 1744 const droppedFiles = e.dataTransfer.files; 1745 if (droppedFiles && droppedFiles.length > 0) { 1746 addFiles(droppedFiles); 1747 } 1748 } 1749 1750 function handleCompress() { 1751 const pendingCount = files.filter(f => f.status === 'pending').length; 1752 const convertingCount = files.filter(f =>
1752 f.status === 'converting').length; 1753 1754 if (hasSeparateDownloadBtn) { 1755 1756 if (pendingCount > 0 && convertingCount === 0) { 1757 compressAllFiles(); 1758 } 1759 } else { 1760 1761 if (pendingCount > 0 || convertingCount > 0) { 1762 if (pendingCount > 0 && convertingCount === 0) { 1763 compressAllFiles(); 1764 } 1765 } else { 1766 downloadAll(); 1767 } 1768 } 1769 } 1770 1771 function init() { 1772 if (typeof OffscreenCanvas === 'undefined') { 1773 const msg = dropMessage.querySelector('.drop-caption__text'); 1774 if (msg) { 1775 msg.textContent = (CONFIG.errors.browserUnsupported || 'Please update your browser').toUpperCase(); 1776 msg.style.color = '#e74c3c'; 1777 } 1778 fileInput.disabled = true; 1779 fileInput.parentElement.classList.add('button_disabled'); 1780 return; 1781 } 1782 1783 fileInput.addEventListener('change', handleFileSelect); 1784 1785 dropZone.addEventListener('dragover', handleDragOver); 1786 dropZone.addEventListener('dragleave', handleDragLeave); 1787 dropZone.addEventListener('drop', handleDrop); 1788 1789 document.addEventListener('dragover', e => e.preventDefault()); 1790 document.addEventListener('drop', e => e.preventDefault()); 1791 1792 clearBtn.addEventListener('click', clearAll); 1793 convertBtn.addEventListener('click', handleCompress); 1794 downloadAllBtn.addEventListener('click', downloadAll); 1795 1796 prevBtn.addEventListener('click', () => scrollBy(-300)); 1797 nextBtn.addEventListener('click', () => scrollBy(300)); 1798 filesScroll.addEventListener('scroll', updateScrollButtons); 1799 window.addEventListener('resize', updateScrollButtons); 1800 1801 if (settingsPanel && comparisonCanvas && comparisonThumb) { 1802 comparisonPanelInstance = window.ComparisonPanel({ 1803 container: settingsPanel, 1804 canvas: comparisonCanvas, 1805 thumbElement: comparisonThumb, 1806 originalSizeElement: originalSizeEl, 1807 compressedSizeElement: compressedSizeEl, 1808 savingsElement: savingsPercentEl, 1809 zoomIndicatorElement: zoomIndicator 1810 }); 1811 } 1812 1813 if (qualityInput && qualitySlider) { 1814 qualityInput.addEventListener('blur', () => { 1815 const min = parseInt(qualitySlider.min, 10); 1816 const max = parseInt(qualitySlider.max, 10); 1817 let val = parseInt(qualityInput.value, 10); 1818 if (isNaN(val) || val < min) val = min; 1819 if (val > max) val = max; 1820 qualityInput.value = val; 1821 qualitySlider.value = val; 1822 handleQualityChange(); 1823 }); 1824 1825 qualityInput.addEventListener('keydown', (e) => { 1826 if (e.key === 'Enter') { 1827 e.target.blur(); 1828 } 1829 }); 1830 1831 qualitySlider.addEventListener('input', () => { 1832 qualityInput.value = qualitySlider.value; 1833 handleQualityChange(); 1834 }); 1835 1836 const rangeOuter = qualitySlider.parentNode; 1837 1838 function updateSliderFromPointer(e) { 1839 const rect = rangeOuter.getBoundingClientRect(); 1840 const isVertical = rect.height > rect.width; 1841 let ratio; 1842 if (isVertical) { 1843 ratio = 1 - (e.clientY - rect.top) / rect.height; 1844 } else { 1845 ratio = (e.clientX - rect.left) / rect.width; 1846 } 1847 ratio = Math.max(0, Math.min(1, ratio)); 1848 const min = parseInt(qualitySlider.min, 10); 1849 const max = parseInt(qualitySlider.max, 10); 1850 const value = Math.round(min + ratio * (max - min)); 1851 qualitySlider.value = value; 1852 qualityInput.value = value; 1853 handleQualityChange(); 1854 } 1855 1856 function onSliderMove(e) { 1857 if (!isDraggingSlider) return; 1858 e.preventDefault(); 1859 updateSliderFromPointer(e); 1860 } 1861 1862 function onSliderUp() { 1863 if (!isDraggingSlider) return; 1864 isDraggingSlider = false;
1865 document.removeEventListener('pointermove', onSliderMove); 1866 document.removeEventListener('pointerup', onSliderUp); 1867 document.removeEventListener('pointercancel', onSliderUp); 1868 handleQualityChange(true); 1869 } 1870 1871 rangeOuter.addEventListener('pointerdown', (e) => { 1872 e.preventDefault(); 1873 isDraggingSlider = true; 1874 updateSliderFromPointer(e); 1875 document.addEventListener('pointermove', onSliderMove); 1876 document.addEventListener('pointerup', onSliderUp); 1877 document.addEventListener('pointercancel', onSliderUp); 1878 }); 1879 1880 const mobileQuery = window.matchMedia('(max-width: 555px)'); 1881 window.resizeQualitySlider = function() { 1882 const parent = qualitySlider.parentNode; 1883 if (mobileQuery.matches) { 1884 qualitySlider.style.removeProperty('left'); 1885 qualitySlider.style.removeProperty('width'); 1886 } else { 1887 qualitySlider.style.left = parent.offsetWidth / 2 + 'px'; 1888 qualitySlider.style.width = parent.offsetHeight + 'px'; 1889 } 1890 }; 1891 window.addEventListener('resize', window.resizeQualitySlider); 1892 } 1893 1894 if (applyQualityBtn) { 1895 applyQualityBtn.addEventListener('click', handleApplyQuality); 1896 } 1897 1898 if (framePrev10Btn) { 1899 framePrev10Btn.addEventListener('click', () => frameStep(-10)); 1900 } 1901 if (framePrev1Btn) { 1902 framePrev1Btn.addEventListener('click', () => frameStep(-1)); 1903 } 1904 if (frameNext1Btn) { 1905 frameNext1Btn.addEventListener('click', () => frameStep(1)); 1906 } 1907 if (frameNext10Btn) { 1908 1909 frameNext10Btn.addEventListener('click', () => { 1910 1911 frameStep(10); 1912 }); 1913 } 1914 if (frameInput) { 1915 frameInput.addEventListener('blur', handleFrameInputChange); 1916 frameInput.addEventListener('keydown', (e) => { 1917 if (e.key === 'Enter') { 1918 e.target.blur(); 1919 } 1920 }); 1921 1922 frameInput.addEventListener('input', (e) => { 1923 const len = e.target.value.length || 1; 1924 e.target.style.width = len + 'ch'; 1925 }); 1926 } 1927 1928 window.addEventListener('beforeunload', () => { 1929 files.forEach(f => { 1930 if (f.localThumbUrl) URL.revokeObjectURL(f.localThumbUrl); 1931 if (f.outputUrl) URL.revokeObjectURL(f.outputUrl); 1932 if (f.compressedUrl) URL.revokeObjectURL(f.compressedUrl); 1933 }); 1934 if (worker) worker.terminate(); 1935 }); 1936 1937 updateUI(); 1938 } 1939 1940 if (document.readyState === 'loading') { 1941 document.addEventListener('DOMContentLoaded', init); 1942 } else { 1943 init(); 1944 } 1945 1946})();
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.