// Freehand polygon selection tool // User taps to place points, then adjusts nodes before confirming. const CLOSE_THRESHOLD = 30; // px distance to first point to auto-close const NODE_RADIUS = 14; // px, visual + hit area /** * Show the polygon selection overlay on an image. * @param {string} imageURL - object URL of the image to select from * @returns {Promise<{polygon: {x,y}[], imageBlob: Blob} | null>} * polygon coords are normalized 0–1. Returns null if cancelled. */ export function showPolygonSelector(imageURL, initialPoints) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'poly-overlay'; overlay.innerHTML = `
Tap around the item
`; document.body.appendChild(overlay); const img = document.getElementById('poly-img'); const canvas = document.getElementById('poly-canvas'); const ctx = canvas.getContext('2d'); const hint = document.getElementById('poly-hint'); const undoBtn = overlay.querySelector('.poly-undo-btn'); const closeBtn = overlay.querySelector('.poly-close-btn'); let points = []; // {x, y} in canvas pixel coords let closed = false; let draggingIdx = -1; let imgRect = null; function cleanup() { overlay.remove(); } // Wait for image to load to size canvas img.onload = () => { imgRect = img.getBoundingClientRect(); canvas.width = imgRect.width; canvas.height = imgRect.height; canvas.style.width = imgRect.width + 'px'; canvas.style.height = imgRect.height + 'px'; // Pre-populate with initial points if provided if (initialPoints && initialPoints.length >= 3) { points = initialPoints.map(p => ({ x: p.x * canvas.width, y: p.y * canvas.height, })); closePolygon(); } draw(); }; // If already loaded (cached) if (img.complete && img.naturalWidth) { setTimeout(() => img.onload(), 0); } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); if (points.length === 0) return; // Draw filled polygon (semi-transparent) if (closed && points.length >= 3) { ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y); } ctx.closePath(); ctx.fillStyle = 'rgba(240, 180, 41, 0.15)'; ctx.fill(); } // Draw lines ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y); } if (closed) ctx.closePath(); ctx.strokeStyle = '#f0b429'; ctx.lineWidth = 3; ctx.stroke(); // Draw nodes points.forEach((p, i) => { ctx.beginPath(); ctx.arc(p.x, p.y, NODE_RADIUS, 0, Math.PI * 2); ctx.fillStyle = i === 0 ? '#e8738a' : '#f0b429'; ctx.fill(); ctx.strokeStyle = '#2d1b4e'; ctx.lineWidth = 2; ctx.stroke(); }); } function canvasCoords(e) { const rect = canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top, }; } function distPt(a, b) { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); } function findNodeAt(pos) { for (let i = points.length - 1; i >= 0; i--) { if (distPt(pos, points[i]) < NODE_RADIUS + 10) return i; } return -1; } function closePolygon() { if (points.length < 3) return; closed = true; hint.textContent = 'Drag nodes to adjust'; closeBtn.style.display = 'none'; document.getElementById('poly-actions').style.display = ''; document.getElementById('poly-drawing-actions').style.display = 'none'; draw(); } // Pointer events on canvas let pointerDownPos = null; let pointerMoved = false; canvas.addEventListener('pointerdown', (e) => { e.preventDefault(); const pos = canvasCoords(e); pointerDownPos = pos; pointerMoved = false; if (closed) { // Try to drag a node draggingIdx = findNodeAt(pos); if (draggingIdx >= 0) { canvas.setPointerCapture(e.pointerId); } return; } // Check if near first point -> close if (points.length >= 3 && distPt(pos, points[0]) < CLOSE_THRESHOLD) { closePolygon(); return; } draggingIdx = -1; }); canvas.addEventListener('pointermove', (e) => { e.preventDefault(); const pos = canvasCoords(e); if (pointerDownPos && distPt(pos, pointerDownPos) > 5) { pointerMoved = true; } if (closed && draggingIdx >= 0) { // Clamp to canvas points[draggingIdx].x = Math.max(0, Math.min(canvas.width, pos.x)); points[draggingIdx].y = Math.max(0, Math.min(canvas.height, pos.y)); draw(); } }); canvas.addEventListener('pointerup', (e) => { e.preventDefault(); const pos = canvasCoords(e); if (closed) { draggingIdx = -1; return; } // Only add point if it was a tap (not a drag) if (!pointerMoved) { points.push(pos); undoBtn.disabled = false; if (points.length >= 3) closeBtn.style.display = ''; draw(); } pointerDownPos = null; pointerMoved = false; }); // Undo undoBtn.addEventListener('click', () => { if (closed) { // Reopen closed = false; hint.textContent = 'Tap to add more points'; document.getElementById('poly-actions').style.display = 'none'; document.getElementById('poly-drawing-actions').style.display = ''; if (points.length >= 3) closeBtn.style.display = ''; } else { points.pop(); } undoBtn.disabled = points.length === 0; if (points.length < 3) closeBtn.style.display = 'none'; draw(); }); // Close shape button closeBtn.addEventListener('click', closePolygon); // Confirm document.getElementById('poly-confirm').addEventListener('click', async () => { if (!closed || points.length < 3) return; // Normalize coordinates const polygon = points.map(p => ({ x: p.x / canvas.width, y: p.y / canvas.height, })); // Crop and mask the image const croppedBlob = await cropToPolygon(img, polygon); cleanup(); resolve({ polygon, imageBlob: croppedBlob }); }); // Reset document.getElementById('poly-reset').addEventListener('click', () => { points = []; closed = false; draggingIdx = -1; hint.textContent = 'Tap around the item'; undoBtn.disabled = true; closeBtn.style.display = 'none'; document.getElementById('poly-actions').style.display = 'none'; document.getElementById('poly-drawing-actions').style.display = ''; draw(); }); // Cancel const cancelHandler = () => { cleanup(); resolve(null); }; document.getElementById('poly-cancel').addEventListener('click', cancelHandler); document.getElementById('poly-cancel2').addEventListener('click', cancelHandler); }); } /** * Crop image to polygon bounding box with pixels outside the polygon made transparent. */ async function cropToPolygon(imgEl, polygon) { // Draw original image at full resolution const bitmap = await createImageBitmap(imgEl); const fullW = bitmap.width; const fullH = bitmap.height; // Convert normalized polygon to pixel coords const pxPoly = polygon.map(p => ({ x: p.x * fullW, y: p.y * fullH })); // Bounding box let minX = fullW, minY = fullH, maxX = 0, maxY = 0; for (const p of pxPoly) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } // Add small padding const pad = 4; minX = Math.max(0, Math.floor(minX) - pad); minY = Math.max(0, Math.floor(minY) - pad); maxX = Math.min(fullW, Math.ceil(maxX) + pad); maxY = Math.min(fullH, Math.ceil(maxY) + pad); const cropW = maxX - minX; const cropH = maxY - minY; // Draw cropped region const canvas = new OffscreenCanvas(cropW, cropH); const ctx = canvas.getContext('2d'); // Clip to polygon ctx.beginPath(); const first = pxPoly[0]; ctx.moveTo(first.x - minX, first.y - minY); for (let i = 1; i < pxPoly.length; i++) { ctx.lineTo(pxPoly[i].x - minX, pxPoly[i].y - minY); } ctx.closePath(); ctx.clip(); // Draw image ctx.drawImage(bitmap, minX, minY, cropW, cropH, 0, 0, cropW, cropH); return canvas.convertToBlob({ type: 'image/png' }); }