- log_thread.py: thread-safe ContextVar bridge so executor threads can log
individual LLM calls and archive searches back to the event loop
- ai_log.py: init_thread_logging(), notify_entity_update(); WS now pushes
entity_update messages when book data changes after any plugin or batch run
- batch.py: replace batch_pending.json with batch_queue SQLite table;
run_batch_consumer() reads queue dynamically so new books can be added
while batch is running; add_to_queue() deduplicates
- migrate.py: fix _migrate_v1 (clear-on-startup bug); add _migrate_v2 for
batch_queue table
- _client.py / archive.py / identification.py: wrap each LLM API call and
archive search with log_thread start/finish entries
- api.py: POST /api/batch returns {already_running, added}; notify_entity_update
after identify pipeline
- models.default.yaml: strengthen ai_identify confidence-scoring instructions;
warn against placeholder data
- detail-render.js: book log entries show clickable ID + spine thumbnail;
book spine/title images open full-screen popup
- events.js: batch-start handles already_running+added; open-img-popup action
- init.js: entity_update WS handler; image popup close listeners
- overlays.css / index.html: full-screen image popup overlay
- eslint.config.js: add new globals; fix no-redeclare/no-unused-vars for
multi-file global architecture; all lint errors resolved
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
211 lines
7.9 KiB
JavaScript
211 lines
7.9 KiB
JavaScript
/*
|
|
* 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 =
|
|
'<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
|
|
}
|