Files
bookshelf/static/js/canvas-crop.js
Petr Polezhaev b94f222c96 Add per-request AI logging, DB batch queue, WS entity updates, and UI polish
- 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>
2026-03-11 12:10:54 +03:00

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
}