Spaces:
Sleeping
Sleeping
| // 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 = ` | |
| <div class="poly-toolbar"> | |
| <button class="pill poly-undo-btn" disabled>Undo</button> | |
| <span class="poly-hint" id="poly-hint">Tap around the item</span> | |
| <button class="pill poly-close-btn" style="display:none">Close shape</button> | |
| </div> | |
| <div class="poly-canvas-wrap"> | |
| <img src="${imageURL}" class="poly-img" id="poly-img" draggable="false"/> | |
| <canvas id="poly-canvas" class="poly-canvas"></canvas> | |
| </div> | |
| <div class="poly-actions" id="poly-actions" style="display:none"> | |
| <button class="big-button" id="poly-confirm">\u2705 Use this selection</button> | |
| <button class="pill" id="poly-reset">Start over</button> | |
| <button class="pill" id="poly-cancel">Cancel</button> | |
| </div> | |
| <div class="poly-actions" id="poly-drawing-actions"> | |
| <button class="pill" id="poly-cancel2">Cancel</button> | |
| </div> | |
| `; | |
| 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' }); | |
| } | |