2026-05-07 14:47:37 +00:00
|
|
|
/*
|
|
|
|
|
* Copyright (c) 2025-2026 Taras Greben
|
|
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
|
|
|
|
|
* See LICENSE file for details.
|
|
|
|
|
*/
|
|
|
|
|
|
2025-12-21 19:59:28 +02:00
|
|
|
/* canvas-ui.js - Shared Canvas Logic (Mobile/Touch Ready + Resize Hook) */
|
2025-12-21 00:31:26 +02:00
|
|
|
|
|
|
|
|
class PanZoomCanvas {
|
|
|
|
|
constructor(id, onDraw, onClick, onDragPt) {
|
|
|
|
|
this.canvas = document.getElementById(id);
|
|
|
|
|
this.container = this.canvas ? this.canvas.parentElement : null;
|
|
|
|
|
this.ctx = this.canvas ? this.canvas.getContext('2d') : null;
|
|
|
|
|
this.bmp = null;
|
2025-12-21 18:45:40 +02:00
|
|
|
|
|
|
|
|
this.t = {x:0, y:0, k:1};
|
|
|
|
|
|
2025-12-21 00:31:26 +02:00
|
|
|
this.lm = {x:0, y:0};
|
|
|
|
|
this.activePtIdx = -1;
|
|
|
|
|
this.isMirrored = false;
|
2025-12-21 18:45:40 +02:00
|
|
|
|
|
|
|
|
this.onDraw = onDraw;
|
|
|
|
|
this.onClick = onClick;
|
2025-12-21 00:31:26 +02:00
|
|
|
this.onDragPt = onDragPt;
|
2025-12-21 18:45:40 +02:00
|
|
|
this.onMouseMove = null;
|
2025-12-21 19:59:28 +02:00
|
|
|
this.onPointerDown = null;
|
|
|
|
|
|
|
|
|
|
// New: Resize Callback hook
|
|
|
|
|
this.onResize = null;
|
2025-12-21 18:45:40 +02:00
|
|
|
|
|
|
|
|
this.evCache = [];
|
|
|
|
|
this.prevDiff = -1;
|
|
|
|
|
this.isDragging = false;
|
|
|
|
|
this.totalDragDist = 0;
|
2025-12-21 00:31:26 +02:00
|
|
|
|
|
|
|
|
if(this.container) {
|
|
|
|
|
new ResizeObserver(() => {
|
|
|
|
|
if(this.container && this.container.clientWidth > 0) {
|
|
|
|
|
this.canvas.width = this.container.clientWidth;
|
|
|
|
|
this.canvas.height = this.container.clientHeight;
|
|
|
|
|
this.draw();
|
2025-12-21 19:59:28 +02:00
|
|
|
// Trigger Hook
|
|
|
|
|
if (this.onResize) this.onResize(this.canvas.width, this.canvas.height);
|
2025-12-21 00:31:26 +02:00
|
|
|
}
|
|
|
|
|
}).observe(this.container);
|
|
|
|
|
this.initEvents();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initEvents() {
|
2025-12-21 18:45:40 +02:00
|
|
|
this.canvas.style.touchAction = 'none';
|
|
|
|
|
|
|
|
|
|
this.canvas.addEventListener('wheel', e => {
|
2025-12-21 00:31:26 +02:00
|
|
|
e.preventDefault();
|
|
|
|
|
const f = Math.exp(-e.deltaY * 0.001);
|
2025-12-21 18:45:40 +02:00
|
|
|
this.zoomAt(e.clientX, e.clientY, f);
|
|
|
|
|
}, { passive: false });
|
2025-12-21 00:31:26 +02:00
|
|
|
|
2025-12-21 18:45:40 +02:00
|
|
|
this.canvas.addEventListener('pointerdown', e => this.handlePointerDown(e));
|
|
|
|
|
this.canvas.addEventListener('pointermove', e => this.handlePointerMove(e));
|
|
|
|
|
this.canvas.addEventListener('pointerup', e => this.handlePointerUp(e));
|
|
|
|
|
this.canvas.addEventListener('pointercancel', e => this.handlePointerUp(e));
|
|
|
|
|
this.canvas.addEventListener('pointerout', e => this.handlePointerUp(e));
|
|
|
|
|
this.canvas.addEventListener('pointerleave', e => this.handlePointerUp(e));
|
|
|
|
|
|
|
|
|
|
this.canvas.addEventListener('contextmenu', e => {
|
2025-12-21 00:31:26 +02:00
|
|
|
const coords = this.getImgCoords(e.clientX, e.clientY);
|
|
|
|
|
if (this.onDragPt) this.onDragPt(coords.x, coords.y, 'delete');
|
2025-12-21 18:45:40 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
zoomAt(clientX, clientY, factor) {
|
|
|
|
|
const r = this.canvas.getBoundingClientRect();
|
|
|
|
|
const mx = clientX - r.left;
|
|
|
|
|
const my = clientY - r.top;
|
|
|
|
|
const wx = (mx - this.t.x) / this.t.k;
|
|
|
|
|
const wy = (my - this.t.y) / this.t.k;
|
|
|
|
|
this.t.k = Math.max(0.01, Math.min(20, this.t.k * factor));
|
|
|
|
|
this.t.x = mx - wx * this.t.k;
|
|
|
|
|
this.t.y = my - wy * this.t.k;
|
|
|
|
|
this.draw();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handlePointerDown(e) {
|
|
|
|
|
this.canvas.setPointerCapture(e.pointerId);
|
|
|
|
|
this.evCache.push(e);
|
|
|
|
|
this.totalDragDist = 0;
|
|
|
|
|
|
|
|
|
|
if (this.onPointerDown) this.onPointerDown(e);
|
|
|
|
|
|
|
|
|
|
const coords = this.getImgCoords(e.clientX, e.clientY);
|
|
|
|
|
|
|
|
|
|
if (this.onDragPt) {
|
|
|
|
|
const idx = this.onDragPt(coords.x, coords.y, 'check');
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
this.activePtIdx = idx;
|
|
|
|
|
this.isDragging = true;
|
|
|
|
|
this.lm = { x: e.clientX, y: e.clientY };
|
|
|
|
|
return;
|
2025-12-21 00:31:26 +02:00
|
|
|
}
|
2025-12-21 18:45:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.isDragging = true;
|
|
|
|
|
this.lm = { x: e.clientX, y: e.clientY };
|
|
|
|
|
this.activePtIdx = -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handlePointerMove(e) {
|
|
|
|
|
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
|
|
|
|
|
if (index > -1) this.evCache[index] = e;
|
|
|
|
|
|
|
|
|
|
if (this.evCache.length === 2) {
|
|
|
|
|
const dx = this.evCache[0].clientX - this.evCache[1].clientX;
|
|
|
|
|
const dy = this.evCache[0].clientY - this.evCache[1].clientY;
|
|
|
|
|
const curDiff = Math.hypot(dx, dy);
|
|
|
|
|
|
|
|
|
|
if (this.prevDiff > 0) {
|
|
|
|
|
const factor = 1 + ((curDiff - this.prevDiff) * 0.01);
|
|
|
|
|
const cx = (this.evCache[0].clientX + this.evCache[1].clientX) / 2;
|
|
|
|
|
const cy = (this.evCache[0].clientY + this.evCache[1].clientY) / 2;
|
|
|
|
|
this.zoomAt(cx, cy, factor);
|
2025-12-21 00:31:26 +02:00
|
|
|
}
|
2025-12-21 18:45:40 +02:00
|
|
|
this.prevDiff = curDiff;
|
2025-12-21 19:59:28 +02:00
|
|
|
this.totalDragDist += 20;
|
2025-12-21 18:45:40 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2025-12-21 00:31:26 +02:00
|
|
|
|
2025-12-21 18:45:40 +02:00
|
|
|
if (this.isDragging && this.evCache.length === 1) {
|
|
|
|
|
const dx = e.clientX - this.lm.x;
|
|
|
|
|
const dy = e.clientY - this.lm.y;
|
|
|
|
|
this.totalDragDist += Math.hypot(dx, dy);
|
|
|
|
|
|
|
|
|
|
if (this.activePtIdx !== -1) {
|
|
|
|
|
const curr = this.getImgCoords(e.clientX, e.clientY);
|
|
|
|
|
const prev = this.getImgCoords(this.lm.x, this.lm.y);
|
|
|
|
|
this.onDragPt(curr.x - prev.x, curr.y - prev.y, 'move', this.activePtIdx);
|
|
|
|
|
} else {
|
|
|
|
|
this.t.x += dx;
|
|
|
|
|
this.t.y += dy;
|
|
|
|
|
this.draw();
|
2025-12-21 00:31:26 +02:00
|
|
|
}
|
2025-12-21 18:45:40 +02:00
|
|
|
this.lm = { x: e.clientX, y: e.clientY };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if(this.onMouseMove) {
|
|
|
|
|
const coords = this.getImgCoords(e.clientX, e.clientY);
|
|
|
|
|
this.onMouseMove(coords.x, coords.y);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handlePointerUp(e) {
|
|
|
|
|
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
|
|
|
|
|
if (index > -1) this.evCache.splice(index, 1);
|
|
|
|
|
if (this.evCache.length < 2) this.prevDiff = -1;
|
|
|
|
|
|
|
|
|
|
if (this.evCache.length === 0) {
|
|
|
|
|
if (this.totalDragDist < 20) {
|
|
|
|
|
if (this.activePtIdx === -1 && this.onClick) {
|
|
|
|
|
const coords = this.getImgCoords(e.clientX, e.clientY);
|
|
|
|
|
this.onClick(coords.x, coords.y, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.isDragging = false;
|
2025-12-21 00:31:26 +02:00
|
|
|
this.activePtIdx = -1;
|
2025-12-21 18:45:40 +02:00
|
|
|
}
|
2025-12-21 00:31:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getImgCoords(screenX, screenY) {
|
|
|
|
|
const r = this.canvas.getBoundingClientRect();
|
|
|
|
|
const mx = (screenX - r.left - this.t.x) / this.t.k;
|
|
|
|
|
const my = (screenY - r.top - this.t.y) / this.t.k;
|
|
|
|
|
if (this.isMirrored && this.bmp) { return { x: this.bmp.width - mx, y: my }; }
|
|
|
|
|
return { x: mx, y: my };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setMirror(val) { this.isMirrored = val; this.draw(); }
|
|
|
|
|
setImage(b) { this.bmp = b; this.draw(); }
|
|
|
|
|
setDimmed(isDimmed) {
|
2025-12-21 18:45:40 +02:00
|
|
|
this.canvas.style.filter = isDimmed ? "brightness(0.4) grayscale(100%)" : "none";
|
2025-12-21 00:31:26 +02:00
|
|
|
}
|
|
|
|
|
fit() {
|
|
|
|
|
if(!this.bmp || !this.canvas) return;
|
|
|
|
|
const vw = this.canvas.width;
|
|
|
|
|
const vh = this.canvas.height;
|
|
|
|
|
if (vw === 0 || vh === 0) return;
|
|
|
|
|
const iw = this.bmp.width;
|
|
|
|
|
const ih = this.bmp.height;
|
|
|
|
|
const scale = Math.min(vw / iw, vh / ih);
|
|
|
|
|
const cx = (vw - iw * scale) / 2;
|
|
|
|
|
const cy = (vh - ih * scale) / 2;
|
|
|
|
|
this.t = { x: cx, y: cy, k: scale };
|
|
|
|
|
this.draw();
|
|
|
|
|
}
|
|
|
|
|
draw() {
|
|
|
|
|
if(!this.ctx) return;
|
|
|
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
|
this.ctx.save();
|
|
|
|
|
this.ctx.translate(this.t.x, this.t.y);
|
|
|
|
|
this.ctx.scale(this.t.k, this.t.k);
|
|
|
|
|
if (this.bmp) {
|
|
|
|
|
this.ctx.save();
|
|
|
|
|
if (this.isMirrored) {
|
|
|
|
|
this.ctx.translate(this.bmp.width, 0);
|
|
|
|
|
this.ctx.scale(-1, 1);
|
|
|
|
|
}
|
|
|
|
|
this.ctx.drawImage(this.bmp, 0, 0);
|
|
|
|
|
this.ctx.restore();
|
|
|
|
|
}
|
|
|
|
|
if (this.onDraw) this.onDraw(this.ctx, this.t.k);
|
|
|
|
|
this.ctx.restore();
|
|
|
|
|
}
|
|
|
|
|
}
|