/* * canvas-crop.js * In-place crop tool for cabinet and shelf photos. * Renders a draggable crop rectangle on the boundary canvas overlay, * then POSTs pixel coordinates to the server to permanently crop the image. * * Entry point: startCropMode(type, id) — called from events.js 'crop-start'. * Disables boundary drag events while active (checked via S._cropMode). * * Depends on: S (state.js); req, toast (api.js / helpers.js); * drawBnd (canvas-boundary.js) — called in cancelCrop to restore * the boundary overlay after the crop UI is dismissed * Provides: startCropMode(), cancelCrop(), confirmCrop() */ /* exported startCropMode */ // ── Crop state ─────────────────────────────────────────────────────────────── let _cropState = null; // {x1,y1,x2,y2} fractions; null = not in crop mode let _cropDragPart = null; // 'tl','tr','bl','br','t','b','l','r','move' | null let _cropDragStart = null; // {fx,fy,x1,y1,x2,y2} snapshot at drag start // ── Public entry point ─────────────────────────────────────────────────────── function startCropMode(type, id) { const canvas = document.getElementById('bnd-canvas'); const wrap = document.getElementById('bnd-wrap'); if (!canvas || !wrap) return; S._cropMode = { type, id }; _cropState = { x1: 0.05, y1: 0.05, x2: 0.95, y2: 0.95 }; canvas.addEventListener('pointerdown', cropPointerDown); canvas.addEventListener('pointermove', cropPointerMove); canvas.addEventListener('pointerup', cropPointerUp); document.getElementById('crop-bar')?.remove(); const bar = document.createElement('div'); bar.id = 'crop-bar'; bar.style.cssText = 'margin-top:10px;display:flex;gap:8px'; bar.innerHTML = ''; wrap.after(bar); document.getElementById('crop-ok').addEventListener('click', confirmCrop); document.getElementById('crop-cancel').addEventListener('click', cancelCrop); drawCropOverlay(); } // ── Drawing ────────────────────────────────────────────────────────────────── function drawCropOverlay() { const canvas = document.getElementById('bnd-canvas'); if (!canvas || !_cropState) return; const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; const { x1, y1, x2, y2 } = _cropState; const px1 = x1 * W, py1 = y1 * H, px2 = x2 * W, py2 = y2 * H; ctx.clearRect(0, 0, W, H); // Dark shadow outside crop rect ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, 0, W, H); ctx.clearRect(px1, py1, px2 - px1, py2 - py1); // Bright border ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2; ctx.setLineDash([]); ctx.strokeRect(px1, py1, px2 - px1, py2 - py1); // Corner handles const hs = 9; ctx.fillStyle = '#38bdf8'; [ [px1, py1], [px2, py1], [px1, py2], [px2, py2], ].forEach(([x, y]) => ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs)); } // ── Hit testing ────────────────────────────────────────────────────────────── function _cropFracFromEvt(e) { const canvas = document.getElementById('bnd-canvas'); const r = canvas.getBoundingClientRect(); return { fx: (e.clientX - r.left) / r.width, fy: (e.clientY - r.top) / r.height }; } function _getCropPart(fx, fy) { if (!_cropState) return null; const { x1, y1, x2, y2 } = _cropState; const th = 0.05; const inX = fx >= x1 && fx <= x2, inY = fy >= y1 && fy <= y2; const nX1 = Math.abs(fx - x1) < th, nX2 = Math.abs(fx - x2) < th; const nY1 = Math.abs(fy - y1) < th, nY2 = Math.abs(fy - y2) < th; if (nX1 && nY1) return 'tl'; if (nX2 && nY1) return 'tr'; if (nX1 && nY2) return 'bl'; if (nX2 && nY2) return 'br'; if (nY1 && inX) return 't'; if (nY2 && inX) return 'b'; if (nX1 && inY) return 'l'; if (nX2 && inY) return 'r'; if (inX && inY) return 'move'; return null; } function _cropPartCursor(part) { if (!part) return 'crosshair'; if (part === 'move') return 'move'; if (part === 'tl' || part === 'br') return 'nwse-resize'; if (part === 'tr' || part === 'bl') return 'nesw-resize'; if (part === 't' || part === 'b') return 'ns-resize'; return 'ew-resize'; } // ── Pointer events ─────────────────────────────────────────────────────────── function cropPointerDown(e) { if (!_cropState) return; const { fx, fy } = _cropFracFromEvt(e); const part = _getCropPart(fx, fy); if (part) { _cropDragPart = part; _cropDragStart = { fx, fy, ..._cropState }; document.getElementById('bnd-canvas').setPointerCapture(e.pointerId); } } function cropPointerMove(e) { if (!_cropState) return; const canvas = document.getElementById('bnd-canvas'); const { fx, fy } = _cropFracFromEvt(e); if (_cropDragPart && _cropDragStart) { const dx = fx - _cropDragStart.fx, dy = fy - _cropDragStart.fy; const s = { ..._cropState }; if (_cropDragPart === 'move') { const w = _cropDragStart.x2 - _cropDragStart.x1, h = _cropDragStart.y2 - _cropDragStart.y1; s.x1 = Math.max(0, Math.min(1 - w, _cropDragStart.x1 + dx)); s.y1 = Math.max(0, Math.min(1 - h, _cropDragStart.y1 + dy)); s.x2 = s.x1 + w; s.y2 = s.y1 + h; } else { if (_cropDragPart.includes('l')) s.x1 = Math.max(0, Math.min(_cropDragStart.x2 - 0.05, _cropDragStart.x1 + dx)); if (_cropDragPart.includes('r')) s.x2 = Math.min(1, Math.max(_cropDragStart.x1 + 0.05, _cropDragStart.x2 + dx)); if (_cropDragPart.includes('t')) s.y1 = Math.max(0, Math.min(_cropDragStart.y2 - 0.05, _cropDragStart.y1 + dy)); if (_cropDragPart.includes('b')) s.y2 = Math.min(1, Math.max(_cropDragStart.y1 + 0.05, _cropDragStart.y2 + dy)); } _cropState = s; drawCropOverlay(); canvas.style.cursor = _cropPartCursor(_cropDragPart); } else { canvas.style.cursor = _cropPartCursor(_getCropPart(fx, fy)); } } function cropPointerUp() { _cropDragPart = null; _cropDragStart = null; } // ── Confirm / cancel ───────────────────────────────────────────────────────── async function confirmCrop() { if (!_cropState || !S._cropMode) return; const img = document.getElementById('bnd-img'); if (!img) return; const { x1, y1, x2, y2 } = _cropState; const W = img.naturalWidth, H = img.naturalHeight; const px = { x: Math.round(x1 * W), y: Math.round(y1 * H), w: Math.round((x2 - x1) * W), h: Math.round((y2 - y1) * H), }; if (px.w < 10 || px.h < 10) { toast('Selection too small'); return; } const { type, id } = S._cropMode; const url = type === 'cabinet' ? `/api/cabinets/${id}/crop` : `/api/shelves/${id}/crop`; try { await req('POST', url, px); toast('Cropped'); cancelCrop(); render(); } catch (err) { toast('Crop failed: ' + err.message); } } function cancelCrop() { S._cropMode = null; _cropState = null; _cropDragPart = null; _cropDragStart = null; document.getElementById('crop-bar')?.remove(); const canvas = document.getElementById('bnd-canvas'); if (canvas) { canvas.removeEventListener('pointerdown', cropPointerDown); canvas.removeEventListener('pointermove', cropPointerMove); canvas.removeEventListener('pointerup', cropPointerUp); canvas.style.cursor = ''; } drawBnd(); // restore boundary overlay }