lightweight image tools

feat: implement non-destructive layer system

Major refactor to use operation stack instead of destructive edits:

- All edits are now non-destructive (crop, bg color, bg removal)
- Operations stack: removeBg, crop, bgColor
- Undo button to remove last operation
- Optimize preserves transparency (uses PNG for transparent images)
- Background color is preview-only until export
- Crop can be adjusted without quality loss
- Original image preserved for re-processing

Fixes:
- Optimize no longer converts transparency to black background
- Background color changes don't bake into image permanently
- Can undo any operation

💘 Generated with Crush

Assisted-by: Claude Sonnet 4.5 via Crush <crush@charm.land>

dunkirk.sh 0da9bc90 8d4bbb41

verified
+181 -103
+181 -103
public/index.html
··· 246 246 <button onclick="copyImage()">Copy</button> 247 247 </div> 248 248 <div class="toolbar-group"> 249 + <button onclick="undoOperation()" id="undoBtn" disabled>Undo</button> 249 250 <button onclick="resetAll()">New Image</button> 250 251 </div> 251 252 </div> ··· 290 291 const HANDLE_SIZE = 10; 291 292 const SNAP_THRESHOLD = 8; 292 293 293 - // State 294 - let currentImage = null; // The current working image (Image object) 294 + // State - Layer system 295 + let originalImage = null; // Never modified 296 + let operations = []; // Stack of operations to apply 295 297 let displayScale = 1; 296 298 let crop = { x: 0, y: 0, w: 0, h: 0 }; 297 299 let dragMode = null; ··· 383 385 reader.onload = e => { 384 386 const img = new Image(); 385 387 img.onload = () => { 386 - currentImage = img; 388 + originalImage = img; 389 + operations = []; // Reset operations 387 390 dropZone.classList.add('hidden'); 388 391 editor.classList.remove('hidden'); 389 - updatePreview(); 390 - updateFileInfo(); 392 + renderLayers(); 391 393 }; 392 394 img.src = e.target.result; 393 395 }; ··· 403 405 }); 404 406 } 405 407 406 - function updatePreview() { 407 - if (!currentImage) return; 408 + // Render pipeline: apply all operations to get final image 409 + async function renderLayers() { 410 + if (!originalImage) return; 411 + 412 + let sourceImage = originalImage; 413 + let width = originalImage.width; 414 + let height = originalImage.height; 415 + 416 + // Apply operations in order 417 + for (const op of operations) { 418 + if (op.type === 'removeBg') { 419 + sourceImage = op.image; 420 + width = sourceImage.width; 421 + height = sourceImage.height; 422 + } else if (op.type === 'crop') { 423 + // Crop will be applied during render 424 + width = op.w; 425 + height = op.h; 426 + } 427 + } 428 + 429 + // Find active operations 430 + const bgRemoveOp = operations.find(op => op.type === 'removeBg'); 431 + const cropOp = operations.find(op => op.type === 'crop'); 432 + const bgColorOp = operations.find(op => op.type === 'bgColor'); 433 + 434 + // Set canvas dimensions 435 + const ctx = previewCanvas.getContext('2d'); 436 + previewCanvas.width = width; 437 + previewCanvas.height = height; 408 438 409 - // Calculate dimensions to fit the screen while maintaining aspect ratio 439 + // Calculate display size 410 440 const maxDisplayWidth = Math.min(700, window.innerWidth - 80); 411 441 const maxDisplayHeight = 500; 412 - const imgAspect = currentImage.width / currentImage.height; 442 + const imgAspect = width / height; 413 443 const maxAspect = maxDisplayWidth / maxDisplayHeight; 414 444 415 445 let displayWidth, displayHeight; 416 446 if (imgAspect > maxAspect) { 417 - displayWidth = Math.min(currentImage.width, maxDisplayWidth); 447 + displayWidth = Math.min(width, maxDisplayWidth); 418 448 displayHeight = displayWidth / imgAspect; 419 449 } else { 420 - displayHeight = Math.min(currentImage.height, maxDisplayHeight); 450 + displayHeight = Math.min(height, maxDisplayHeight); 421 451 displayWidth = displayHeight * imgAspect; 422 452 } 423 453 424 - const ctx = previewCanvas.getContext('2d'); 425 - previewCanvas.width = currentImage.width; 426 - previewCanvas.height = currentImage.height; 427 454 previewCanvas.style.width = displayWidth + 'px'; 428 455 previewCanvas.style.height = displayHeight + 'px'; 429 456 430 - ctx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); 457 + ctx.clearRect(0, 0, width, height); 431 458 432 - if (!showTransparent.checked) { 433 - ctx.fillStyle = bgColor.value; 434 - ctx.fillRect(0, 0, previewCanvas.width, previewCanvas.height); 459 + // Apply background color if not showing transparent 460 + if (!showTransparent.checked && (bgColorOp || bgRemoveOp)) { 461 + ctx.fillStyle = bgColorOp ? bgColorOp.color : bgColor.value; 462 + ctx.fillRect(0, 0, width, height); 435 463 } 436 464 437 - ctx.drawImage(currentImage, 0, 0); 438 - updateFileInfo(); 465 + // Draw the image (with crop if applicable) 466 + const imgToDraw = bgRemoveOp ? bgRemoveOp.image : originalImage; 467 + 468 + if (cropOp) { 469 + // Draw cropped portion 470 + ctx.drawImage(imgToDraw, cropOp.x, cropOp.y, cropOp.w, cropOp.h, 0, 0, width, height); 471 + } else { 472 + // Draw full image 473 + ctx.drawImage(imgToDraw, 0, 0); 474 + } 475 + 476 + // Update undo button state 477 + document.getElementById('undoBtn').disabled = operations.length === 0; 478 + 479 + await updateFileInfo(); 480 + } 481 + 482 + function updatePreview() { 483 + renderLayers(); 439 484 } 440 485 441 486 function formatFileSize(bytes) { ··· 451 496 } 452 497 453 498 async function updateFileInfo() { 454 - if (!currentImage) { 499 + if (!originalImage) { 455 500 fileInfo.classList.add('hidden'); 456 501 return; 457 502 } 458 503 459 504 const currentSize = await getCanvasSize(); 460 - const dims = `${currentImage.width}×${currentImage.height}px`; 505 + const dims = `${previewCanvas.width}×${previewCanvas.height}px`; 461 506 462 507 let info = `<strong>Dimensions:</strong> ${dims} | <strong>Current size:</strong> ${formatFileSize(currentSize)}`; 463 508 ··· 479 524 480 525 // Crop functionality 481 526 function startCrop() { 482 - if (!currentImage) return; 527 + if (!originalImage) return; 483 528 inCropMode = true; 529 + 530 + // Get current rendered image 531 + const currentImg = getCurrentImage(); 484 532 485 533 const maxW = Math.min(600, window.innerWidth - 40); 486 - displayScale = Math.min(1, maxW / currentImage.width); 487 - const w = currentImage.width * displayScale; 488 - const h = currentImage.height * displayScale; 534 + displayScale = Math.min(1, maxW / currentImg.width); 535 + const w = currentImg.width * displayScale; 536 + const h = currentImg.height * displayScale; 489 537 490 538 cropCanvas.width = overlayCanvas.width = w; 491 539 cropCanvas.height = overlayCanvas.height = h; 492 - cropCanvas.getContext('2d').drawImage(currentImage, 0, 0, w, h); 540 + cropCanvas.getContext('2d').drawImage(currentImg, 0, 0, w, h); 493 541 494 - crop = { x: 0, y: 0, w: 0, h: 0 }; 542 + // Find existing crop if any 543 + const existingCrop = operations.find(op => op.type === 'crop'); 544 + if (existingCrop) { 545 + crop = { 546 + x: existingCrop.x * displayScale, 547 + y: existingCrop.y * displayScale, 548 + w: existingCrop.w * displayScale, 549 + h: existingCrop.h * displayScale 550 + }; 551 + } else { 552 + crop = { x: 0, y: 0, w: 0, h: 0 }; 553 + } 554 + 495 555 drawOverlay(); 496 556 497 557 previewArea.classList.add('hidden'); 498 558 cropArea.classList.remove('hidden'); 499 559 } 500 560 561 + function getCurrentImage() { 562 + // Return the current state of the image (with bg removal if applied) 563 + const bgRemoveOp = operations.find(op => op.type === 'removeBg'); 564 + return bgRemoveOp ? bgRemoveOp.image : originalImage; 565 + } 566 + 501 567 function cancelCrop() { 502 568 inCropMode = false; 503 569 cropArea.classList.add('hidden'); ··· 516 582 const sw = crop.w / displayScale; 517 583 const sh = crop.h / displayScale; 518 584 519 - const tempCanvas = document.createElement('canvas'); 520 - tempCanvas.width = sw; 521 - tempCanvas.height = sh; 522 - tempCanvas.getContext('2d').drawImage(currentImage, sx, sy, sw, sh, 0, 0, sw, sh); 585 + // Remove existing crop operation if any 586 + operations = operations.filter(op => op.type !== 'crop'); 587 + 588 + // Add new crop operation 589 + operations.push({ 590 + type: 'crop', 591 + x: sx, 592 + y: sy, 593 + w: sw, 594 + h: sh 595 + }); 523 596 524 - const img = new Image(); 525 - img.onload = async () => { 526 - currentImage = img; 527 - // Update file size after crop 528 - currentFileSize = await new Promise(resolve => { 529 - tempCanvas.toBlob(blob => resolve(blob.size), 'image/png'); 530 - }); 531 - inCropMode = false; 532 - cropArea.classList.add('hidden'); 533 - previewArea.classList.remove('hidden'); 534 - updatePreview(); 535 - }; 536 - img.src = tempCanvas.toDataURL('image/png'); 597 + inCropMode = false; 598 + cropArea.classList.add('hidden'); 599 + previewArea.classList.remove('hidden'); 600 + renderLayers(); 537 601 } 538 602 539 603 // Crop overlay logic ··· 684 748 } 685 749 } 686 750 687 - // Remove background with polling 751 + // Remove background 688 752 async function removeBg() { 689 753 const tk = localStorage.getItem('hc_token') || tokenInput.value.trim(); 690 754 if (!tk) { showError('Enter token first'); return; } ··· 696 760 return; 697 761 } 698 762 699 - if (!currentImage) return; 763 + if (!originalImage) return; 700 764 701 765 hideError(); 702 766 showStatus('Removing background...'); 703 767 document.getElementById('removeBgBtn').disabled = true; 704 768 705 - // Get current image as data URL 769 + // Always work with the original image for background removal 706 770 const tempCanvas = document.createElement('canvas'); 707 - tempCanvas.width = currentImage.width; 708 - tempCanvas.height = currentImage.height; 709 - tempCanvas.getContext('2d').drawImage(currentImage, 0, 0); 771 + tempCanvas.width = originalImage.width; 772 + tempCanvas.height = originalImage.height; 773 + tempCanvas.getContext('2d').drawImage(originalImage, 0, 0); 710 774 const dataUrl = tempCanvas.toDataURL('image/png'); 711 775 712 776 // Get selected model ··· 723 787 724 788 const img = new Image(); 725 789 img.crossOrigin = 'anonymous'; 726 - img.onload = async () => { 727 - currentImage = img; 728 - // Reset currentFileSize to the new processed image size 729 - currentFileSize = await new Promise(resolve => { 730 - const tempCanvas = document.createElement('canvas'); 731 - tempCanvas.width = img.width; 732 - tempCanvas.height = img.height; 733 - tempCanvas.getContext('2d').drawImage(img, 0, 0); 734 - tempCanvas.toBlob(blob => resolve(blob.size), 'image/png'); 790 + img.onload = () => { 791 + // Remove existing bg removal operation if any 792 + operations = operations.filter(op => op.type !== 'removeBg'); 793 + 794 + // Add new bg removal operation 795 + operations.push({ 796 + type: 'removeBg', 797 + image: img 735 798 }); 799 + 736 800 hideStatus(); 737 801 document.getElementById('removeBgBtn').disabled = false; 738 - updatePreview(); 802 + renderLayers(); 739 803 }; 740 804 img.onerror = () => { 741 805 hideStatus(); ··· 750 814 } 751 815 } 752 816 753 - // Apply background color (bakes it into the image) 817 + // Apply background color (non-destructive) 754 818 function applyBgColor() { 755 - if (!currentImage) return; 819 + if (!originalImage) return; 756 820 757 - const tempCanvas = document.createElement('canvas'); 758 - tempCanvas.width = currentImage.width; 759 - tempCanvas.height = currentImage.height; 760 - const ctx = tempCanvas.getContext('2d'); 821 + // Remove existing bg color operation if any 822 + operations = operations.filter(op => op.type !== 'bgColor'); 823 + 824 + // Add new bg color operation 825 + operations.push({ 826 + type: 'bgColor', 827 + color: bgColor.value 828 + }); 761 829 762 - ctx.fillStyle = bgColor.value; 763 - ctx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); 764 - ctx.drawImage(currentImage, 0, 0); 765 - 766 - const img = new Image(); 767 - img.onload = async () => { 768 - currentImage = img; 769 - // Update file size after applying bg color 770 - currentFileSize = await new Promise(resolve => { 771 - tempCanvas.toBlob(blob => resolve(blob.size), 'image/png'); 772 - }); 773 - updatePreview(); 774 - }; 775 - img.src = tempCanvas.toDataURL('image/png'); 830 + renderLayers(); 776 831 } 777 832 778 833 // Download ··· 793 848 } 794 849 } 795 850 851 + // Undo last operation 852 + function undoOperation() { 853 + if (operations.length === 0) return; 854 + operations.pop(); 855 + renderLayers(); 856 + } 857 + 796 858 // Reset 797 859 function resetAll() { 798 - currentImage = null; 860 + originalImage = null; 861 + operations = []; 799 862 currentFileSize = 0; 800 863 inCropMode = false; 801 864 editor.classList.add('hidden'); ··· 814 877 }; 815 878 816 879 function optimizeImage() { 817 - if (!currentImage) return; 880 + if (!originalImage) return; 818 881 819 882 const quality = document.getElementById('optimizeQuality').value / 100; 820 883 const maxDim = parseInt(document.getElementById('maxDimension').value) || 2048; 821 884 822 - // Calculate new dimensions (only scale down if larger than maxDim) 823 - let newW = currentImage.width; 824 - let newH = currentImage.height; 885 + // Check if image has transparency (bg removed) 886 + const hasTransparency = operations.some(op => op.type === 'removeBg'); 887 + 888 + // Get current canvas dimensions 889 + let newW = previewCanvas.width; 890 + let newH = previewCanvas.height; 825 891 826 892 // Only resize if image exceeds maxDim 827 893 if (newW > maxDim || newH > maxDim) { ··· 833 899 newH = maxDim; 834 900 } 835 901 } 836 - // If image is already smaller than maxDim, keep original dimensions 837 902 838 903 // Create optimized canvas 839 904 const tempCanvas = document.createElement('canvas'); 840 905 tempCanvas.width = newW; 841 906 tempCanvas.height = newH; 842 907 const ctx = tempCanvas.getContext('2d'); 843 - ctx.drawImage(currentImage, 0, 0, newW, newH); 844 - 845 - // Convert to JPEG with quality setting 846 - const optimized = tempCanvas.toDataURL('image/jpeg', quality); 847 - 848 - // Load back as current image 849 - const img = new Image(); 850 - img.onload = async () => { 851 - currentImage = img; 852 - // Update file size after optimization 853 - currentFileSize = await new Promise(resolve => { 854 - tempCanvas.toBlob(blob => resolve(blob.size), 'image/jpeg', quality); 855 - }); 856 - updatePreview(); 857 - }; 858 - img.src = optimized; 908 + 909 + // If has transparency, use PNG; otherwise use JPEG 910 + if (hasTransparency) { 911 + // Scale down the preview canvas 912 + ctx.drawImage(previewCanvas, 0, 0, newW, newH); 913 + const optimized = tempCanvas.toDataURL('image/png'); 914 + 915 + // Load back as original image (destructive - replaces original) 916 + const img = new Image(); 917 + img.onload = () => { 918 + originalImage = img; 919 + operations = []; // Clear all operations since we're baking them in 920 + renderLayers(); 921 + }; 922 + img.src = optimized; 923 + } else { 924 + // No transparency, safe to use JPEG 925 + ctx.drawImage(previewCanvas, 0, 0, newW, newH); 926 + const optimized = tempCanvas.toDataURL('image/jpeg', quality); 927 + 928 + // Load back as original image 929 + const img = new Image(); 930 + img.onload = () => { 931 + originalImage = img; 932 + operations = []; // Clear all operations 933 + renderLayers(); 934 + }; 935 + img.src = optimized; 936 + } 859 937 } 860 938 </script> 861 939 </body>