235 lines
6.3 KiB
JavaScript
235 lines
6.3 KiB
JavaScript
/*
|
|
* Copyright (c) 2025-2026 Taras Greben
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
|
|
* See LICENSE file for details.
|
|
*/
|
|
|
|
/**
|
|
* spyglass.js
|
|
* Handles zooming, panning, and coordinate adjustment for PCB previews.
|
|
* v2.1 - Added Pointer Events for Mobile Support (Pinch & Touch-Drag)
|
|
*/
|
|
class PcbSpyglass {
|
|
/**
|
|
* @param {string} canvasId
|
|
* @param {string} overlayId
|
|
* @param {function} onUpdateCallback - Optional. Called with (x, y) when user drags.
|
|
*/
|
|
constructor(canvasId, overlayId, onUpdateCallback = null) {
|
|
this.canvas = document.getElementById(canvasId);
|
|
this.overlay = document.getElementById(overlayId);
|
|
this.container = this.canvas ? this.canvas.parentElement : null;
|
|
this.onUpdate = onUpdateCallback;
|
|
|
|
if (!this.canvas) {
|
|
console.error("Spyglass: Canvas element not found");
|
|
return;
|
|
}
|
|
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.zoom = 2;
|
|
this.img = null;
|
|
this.x = 0;
|
|
this.y = 0;
|
|
this.enabled = false;
|
|
|
|
// Pointer State
|
|
this.isDragging = false;
|
|
this.lastMouseX = 0;
|
|
this.lastMouseY = 0;
|
|
this.evCache = []; // Cache for multi-touch
|
|
this.prevDiff = -1; // For pinch zoom
|
|
|
|
// Bind Events
|
|
this.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
|
|
|
// Unified Pointer Events (Mouse + Touch)
|
|
this.canvas.addEventListener('pointerdown', this.handlePointerDown.bind(this));
|
|
this.canvas.addEventListener('pointermove', this.handlePointerMove.bind(this));
|
|
this.canvas.addEventListener('pointerup', this.handlePointerUp.bind(this));
|
|
this.canvas.addEventListener('pointercancel', this.handlePointerUp.bind(this));
|
|
this.canvas.addEventListener('pointerout', this.handlePointerUp.bind(this));
|
|
this.canvas.addEventListener('pointerleave', this.handlePointerUp.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Sets the image and coordinates.
|
|
* @param {ImageBitmap|HTMLImageElement} imgSource
|
|
* @param {number} x
|
|
* @param {number} y
|
|
* @param {boolean} autoCloseOld - If true, .close() is called on the old bitmap (Use false for galleries)
|
|
*/
|
|
setTarget(imgSource, x, y, autoCloseOld = true) {
|
|
if (!imgSource) return;
|
|
|
|
// MEMORY CLEANUP: Only if requested
|
|
if (autoCloseOld && this.img && this.img !== imgSource && typeof this.img.close === 'function') {
|
|
try { this.img.close(); } catch(e) {}
|
|
}
|
|
|
|
this.img = imgSource;
|
|
this.x = x;
|
|
this.y = y;
|
|
this.enabled = true;
|
|
|
|
if (this.container) {
|
|
this.container.classList.remove('empty');
|
|
this.container.classList.add('active');
|
|
}
|
|
|
|
this.render();
|
|
this.updateOverlay();
|
|
}
|
|
|
|
clear() {
|
|
this.enabled = false;
|
|
this.img = null;
|
|
|
|
if (this.container) {
|
|
this.container.classList.remove('active');
|
|
this.container.classList.add('empty');
|
|
}
|
|
|
|
this.ctx.fillStyle = '#ffffff';
|
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
if (this.overlay) this.overlay.style.display = 'none';
|
|
}
|
|
|
|
handleWheel(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const d = e.deltaY > 0 ? -0.5 : 0.5;
|
|
this.zoom = Math.max(0.5, Math.min(10, this.zoom + d));
|
|
this.render();
|
|
this.updateOverlay();
|
|
}
|
|
|
|
// --- POINTER HANDLING (Desktop + Mobile) ---
|
|
|
|
handlePointerDown(e) {
|
|
if (!this.enabled) return;
|
|
this.canvas.setPointerCapture(e.pointerId);
|
|
this.evCache.push(e);
|
|
|
|
if (this.evCache.length === 1) {
|
|
this.isDragging = true;
|
|
this.lastMouseX = e.clientX;
|
|
this.lastMouseY = e.clientY;
|
|
this.canvas.style.cursor = 'grabbing';
|
|
}
|
|
}
|
|
|
|
handlePointerMove(e) {
|
|
if (!this.enabled) return;
|
|
|
|
// Update cache
|
|
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
|
|
if (index > -1) this.evCache[index] = e;
|
|
|
|
// 1. PINCH ZOOM (2 Fingers)
|
|
if (this.evCache.length === 2) {
|
|
e.preventDefault();
|
|
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 diff = curDiff - this.prevDiff;
|
|
// PATCH 1: Increased Zoom Speed (0.01 -> 0.1)
|
|
this.zoom = Math.max(0.5, Math.min(10, this.zoom + (diff * 0.1)));
|
|
this.render();
|
|
this.updateOverlay();
|
|
}
|
|
this.prevDiff = curDiff;
|
|
return;
|
|
}
|
|
|
|
// 2. PANNING (1 Finger / Mouse)
|
|
if (this.isDragging && this.evCache.length === 1) {
|
|
e.preventDefault();
|
|
const dx = e.clientX - this.lastMouseX;
|
|
const dy = e.clientY - this.lastMouseY;
|
|
|
|
this.lastMouseX = e.clientX;
|
|
this.lastMouseY = e.clientY;
|
|
|
|
// PATCH 2: Speed Multiplier for Touch
|
|
// Mouse stays 1:1 for precision, Touch gets 5x speed to traverse faster
|
|
const speed = (e.pointerType === 'touch') ? 5 : 1.0;
|
|
|
|
this.x -= (dx * speed) / this.zoom;
|
|
this.y -= (dy * speed) / this.zoom;
|
|
|
|
this.render();
|
|
|
|
if (this.onUpdate) {
|
|
this.onUpdate(Math.round(this.x), Math.round(this.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) {
|
|
this.isDragging = false;
|
|
if(this.canvas) this.canvas.style.cursor = 'crosshair';
|
|
} else if (this.evCache.length === 1) {
|
|
// Reset reference point when switching from pinch to pan
|
|
this.lastMouseX = this.evCache[0].clientX;
|
|
this.lastMouseY = this.evCache[0].clientY;
|
|
}
|
|
}
|
|
|
|
updateOverlay() {
|
|
if (this.overlay) {
|
|
this.overlay.innerText = this.zoom.toFixed(1) + 'x';
|
|
this.overlay.style.display = this.enabled ? 'block' : 'none';
|
|
}
|
|
}
|
|
|
|
render() {
|
|
if (!this.enabled || !this.img) return;
|
|
|
|
const w = this.canvas.width;
|
|
const h = this.canvas.height;
|
|
|
|
const srcW = w / this.zoom;
|
|
const srcH = h / this.zoom;
|
|
|
|
this.ctx.fillStyle = '#ffffff';
|
|
this.ctx.fillRect(0, 0, w, h);
|
|
|
|
try {
|
|
this.ctx.drawImage(
|
|
this.img,
|
|
this.x - srcW / 2,
|
|
this.y - srcH / 2,
|
|
srcW, srcH,
|
|
0, 0, w, h
|
|
);
|
|
|
|
// Crosshair (Red)
|
|
const cx = Math.floor(w / 2) + 0.5;
|
|
const cy = Math.floor(h / 2) + 0.5;
|
|
|
|
this.ctx.strokeStyle = '#ff0000';
|
|
this.ctx.lineWidth = 2;
|
|
this.ctx.lineCap = 'butt';
|
|
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(0, cy); this.ctx.lineTo(w, cy);
|
|
this.ctx.moveTo(cx, 0); this.ctx.lineTo(cx, h);
|
|
this.ctx.stroke();
|
|
|
|
} catch (e) {
|
|
if (e.name !== 'InvalidStateError' && e.name !== 'DOMException') {
|
|
console.error("Spyglass Render Error:", e);
|
|
}
|
|
}
|
|
}
|
|
}
|