Initial commit
Photo-based book cataloger with AI identification. Room → Cabinet → Shelf → Book hierarchy; FastAPI + SQLite backend; vanilla JS SPA; OpenAI-compatible plugin system for boundary detection, text recognition, and archive search.
This commit is contained in:
166
static/js/canvas-crop.js
Normal file
166
static/js/canvas-crop.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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()
|
||||
*/
|
||||
|
||||
// ── 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 = '<button class="btn btn-p" id="crop-ok">Confirm crop</button><button class="btn btn-s" id="crop-cancel">Cancel</button>';
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user