Spaces:
Running
Running
| document.addEventListener("DOMContentLoaded", () => { | |
| // DOM Elements | |
| const video = document.getElementById("camera-stream"); | |
| const canvas = document.getElementById("overlay-canvas"); | |
| const ctx = canvas.getContext("2d", { willReadFrequently: true }); | |
| const sourceImage = document.getElementById("source-image"); | |
| // Controls | |
| const opacitySlider = document.getElementById("opacity-slider"); | |
| const opacityVal = document.getElementById("opacity-val"); | |
| const scaleSlider = document.getElementById("scale-slider"); | |
| const scaleVal = document.getElementById("scale-val"); | |
| const colorPicker = document.getElementById("color-picker"); | |
| const toleranceSlider = document.getElementById("tolerance-slider"); | |
| const toleranceVal = document.getElementById("tolerance-val"); | |
| const imageUpload = document.getElementById("image-upload"); | |
| const lockBtn = document.getElementById("lock-btn"); | |
| const unlockBtn = document.getElementById("unlock-btn"); | |
| const resetBtn = document.getElementById("reset-btn"); | |
| // State | |
| let isLocked = false; | |
| let imgX = 0; | |
| let imgY = 0; | |
| let scale = 1.0; | |
| // Touch & Drag State | |
| let isDragging = false; | |
| let startDragX = 0; | |
| let startDragY = 0; | |
| let initialPinchDistance = null; | |
| let initialScale = 1.0; | |
| let initialPinchCenter = { x: 0, y: 0 }; | |
| // Original image data for processing | |
| let originalImageData = null; | |
| // --- Camera Setup --- | |
| async function setupCamera() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: "environment" }, // Prefer rear camera | |
| }); | |
| video.srcObject = stream; | |
| } catch (err) { | |
| console.error("Error accessing camera:", err); | |
| alert( | |
| "Could not access the camera. Please ensure you have granted permission.", | |
| ); | |
| } | |
| } | |
| // --- Image Processing --- | |
| function hexToRgb(hex) { | |
| const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
| return result | |
| ? { | |
| r: parseInt(result[1], 16), | |
| g: parseInt(result[2], 16), | |
| b: parseInt(result[3], 16), | |
| } | |
| : { r: 255, g: 255, b: 255 }; | |
| } | |
| function processImageColor() { | |
| if (!originalImageData) return; | |
| const targetColor = hexToRgb(colorPicker.value); | |
| const tolerance = parseInt(toleranceSlider.value, 10); | |
| // Create a copy of the original data to modify | |
| const processedData = new ImageData( | |
| new Uint8ClampedArray(originalImageData.data), | |
| originalImageData.width, | |
| originalImageData.height, | |
| ); | |
| const data = processedData.data; | |
| for (let i = 0; i < data.length; i += 4) { | |
| const r = data[i]; | |
| const g = data[i + 1]; | |
| const b = data[i + 2]; | |
| // Calculate color distance (simple Manhattan distance for speed) | |
| const rDiff = Math.abs(r - targetColor.r); | |
| const gDiff = Math.abs(g - targetColor.g); | |
| const bDiff = Math.abs(b - targetColor.b); | |
| // If the pixel is within tolerance of the target color, make it transparent | |
| if (rDiff <= tolerance && gDiff <= tolerance && bDiff <= tolerance) { | |
| data[i + 3] = 0; // Set alpha to 0 | |
| } | |
| } | |
| ctx.putImageData(processedData, 0, 0); | |
| } | |
| function initImage() { | |
| // Set canvas size to match image | |
| canvas.width = sourceImage.naturalWidth; | |
| canvas.height = sourceImage.naturalHeight; | |
| // Draw initial image | |
| ctx.drawImage(sourceImage, 0, 0); | |
| // Store original data for color processing | |
| originalImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| // Center the image initially | |
| resetPosition(); | |
| // Apply initial transparency if needed | |
| processImageColor(); | |
| } | |
| // --- Interactions & Controls --- | |
| function updateTransform() { | |
| canvas.style.transform = `translate(${imgX}px, ${imgY}px) scale(${scale})`; | |
| canvas.style.opacity = opacitySlider.value; | |
| // Sync slider with scale | |
| scaleSlider.value = scale; | |
| scaleVal.textContent = scale.toFixed(1) + "x"; | |
| } | |
| function resetPosition() { | |
| scale = 1.0; | |
| // Center based on window size and canvas size | |
| imgX = (window.innerWidth - canvas.width) / 2; | |
| // Place it slightly higher than center to leave room for controls | |
| imgY = (window.innerHeight - canvas.height) / 3; | |
| updateTransform(); | |
| } | |
| // --- Multi-Touch Gesture Logic --- | |
| function getPinchDistance(touches) { | |
| const dx = touches[0].clientX - touches[1].clientX; | |
| const dy = touches[0].clientY - touches[1].clientY; | |
| return Math.sqrt(dx * dx + dy * dy); | |
| } | |
| function getPinchCenter(touches) { | |
| return { | |
| x: (touches[0].clientX + touches[1].clientX) / 2, | |
| y: (touches[0].clientY + touches[1].clientY) / 2, | |
| }; | |
| } | |
| function startDrag(e) { | |
| if (isLocked) return; | |
| e.preventDefault(); // Prevent default touch actions like scrolling | |
| if (e.touches) { | |
| if (e.touches.length === 1) { | |
| // Single finger: Pan | |
| isDragging = true; | |
| startDragX = e.touches[0].clientX - imgX; | |
| startDragY = e.touches[0].clientY - imgY; | |
| } else if (e.touches.length === 2) { | |
| // Two fingers: Zoom & Pan | |
| isDragging = true; | |
| initialPinchDistance = getPinchDistance(e.touches); | |
| initialScale = scale; | |
| const center = getPinchCenter(e.touches); | |
| startDragX = center.x - imgX; | |
| startDragY = center.y - imgY; | |
| } | |
| } else { | |
| // Mouse drag | |
| isDragging = true; | |
| startDragX = e.clientX - imgX; | |
| startDragY = e.clientY - imgY; | |
| } | |
| } | |
| function doDrag(e) { | |
| if (!isDragging || isLocked) return; | |
| e.preventDefault(); | |
| if (e.touches) { | |
| if (e.touches.length === 1) { | |
| // Single finger: Pan | |
| imgX = e.touches[0].clientX - startDragX; | |
| imgY = e.touches[0].clientY - startDragY; | |
| } else if (e.touches.length === 2) { | |
| // Two fingers: Zoom & Pan | |
| // 1. Handle Pan (using midpoint) | |
| const center = getPinchCenter(e.touches); | |
| imgX = center.x - startDragX; | |
| imgY = center.y - startDragY; | |
| // 2. Handle Zoom | |
| if (initialPinchDistance) { | |
| const currentDistance = getPinchDistance(e.touches); | |
| const distanceRatio = currentDistance / initialPinchDistance; | |
| let newScale = initialScale * distanceRatio; | |
| // Constrain scale to reasonable limits | |
| newScale = Math.max(0.1, Math.min(newScale, 5.0)); | |
| scale = newScale; | |
| } | |
| } | |
| } else { | |
| // Mouse drag | |
| imgX = e.clientX - startDragX; | |
| imgY = e.clientY - startDragY; | |
| } | |
| updateTransform(); | |
| } | |
| function endDrag(e) { | |
| if (e.touches && e.touches.length > 0) { | |
| // If one finger remains after a pinch, reset the drag starting point so it doesn't jump | |
| isDragging = true; | |
| startDragX = e.touches[0].clientX - imgX; | |
| startDragY = e.touches[0].clientY - imgY; | |
| initialPinchDistance = null; | |
| } else { | |
| isDragging = false; | |
| initialPinchDistance = null; | |
| } | |
| } | |
| // Event Listeners for Dragging & Gestures | |
| canvas.addEventListener("mousedown", startDrag); | |
| window.addEventListener("mousemove", doDrag); | |
| window.addEventListener("mouseup", endDrag); | |
| // Use passive: false so we can call e.preventDefault() to stop page scroll | |
| canvas.addEventListener("touchstart", startDrag, { passive: false }); | |
| window.addEventListener("touchmove", doDrag, { passive: false }); | |
| window.addEventListener("touchend", endDrag); | |
| window.addEventListener("touchcancel", endDrag); | |
| // Controls Listeners | |
| opacitySlider.addEventListener("input", (e) => { | |
| opacityVal.textContent = Math.round(e.target.value * 100) + "%"; | |
| canvas.style.opacity = e.target.value; | |
| }); | |
| scaleSlider.addEventListener("input", (e) => { | |
| scale = parseFloat(e.target.value); | |
| updateTransform(); | |
| }); | |
| colorPicker.addEventListener("input", processImageColor); | |
| toleranceSlider.addEventListener("input", (e) => { | |
| toleranceVal.textContent = e.target.value; | |
| processImageColor(); | |
| }); | |
| resetBtn.addEventListener("click", resetPosition); | |
| // Lock logic | |
| lockBtn.addEventListener("click", () => { | |
| isLocked = true; | |
| document.body.classList.add("locked"); | |
| lockBtn.style.display = "none"; | |
| unlockBtn.style.display = "block"; // Explicitly show the unlock button | |
| }); | |
| unlockBtn.addEventListener("click", () => { | |
| isLocked = false; | |
| document.body.classList.remove("locked"); | |
| lockBtn.style.display = "inline-block"; | |
| unlockBtn.style.display = "none"; // Hide unlock button | |
| }); | |
| imageUpload.addEventListener("change", (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| sourceImage.onload = () => { | |
| initImage(); | |
| }; | |
| sourceImage.src = event.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| }); | |
| // --- Initialization --- | |
| // Ensure image loads (especially useful for local dev or cached images) | |
| if (sourceImage.complete) { | |
| initImage(); | |
| } else { | |
| sourceImage.onload = initImage; | |
| } | |
| setupCamera(); | |
| }); | |