2026-05-23 09:09:46 +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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-04-24 17:28:59 +03:00
|
|
|
|
// ── Interaction ───────────────────────────────────────────────
|
|
|
|
|
|
import { db } from './db.js';
|
|
|
|
|
|
import { S, canvas, s2w, snap } from './state.js';
|
|
|
|
|
|
import { compGeometry } from './components.js';
|
|
|
|
|
|
import { routeWires, fitView, moveComponentRoute } from './layout.js';
|
|
|
|
|
|
import { render } from './draw.js';
|
|
|
|
|
|
import { pushHistory, undo } from './history.js';
|
|
|
|
|
|
import {
|
|
|
|
|
|
showProperties, showNetProperties,
|
|
|
|
|
|
showDefaultProps, updateSidePanels, toast,
|
|
|
|
|
|
} from './ui.js';
|
|
|
|
|
|
|
|
|
|
|
|
// Autosave callbacks injected by app.js
|
|
|
|
|
|
let _saveComponentLayout = null;
|
|
|
|
|
|
let _saveViewport = null;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Hit testing ───────────────────────────────────────────────
|
|
|
|
|
|
export function hitTestComp(wx, wy) {
|
|
|
|
|
|
for (const c of [...S.components].reverse()) {
|
|
|
|
|
|
const g = compGeometry(c);
|
|
|
|
|
|
const hw = g.w / 2 + 8, hh = g.h / 2 + 8;
|
|
|
|
|
|
const rot = -(c.rotation || 0) * Math.PI / 180;
|
|
|
|
|
|
const dx = wx - c.x, dy = wy - c.y;
|
|
|
|
|
|
const lx = dx * Math.cos(rot) - dy * Math.sin(rot);
|
|
|
|
|
|
const ly = dx * Math.sin(rot) + dy * Math.cos(rot);
|
|
|
|
|
|
if (Math.abs(lx) <= hw && Math.abs(ly) <= hh) return c;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function hitTestWire(wx, wy) {
|
|
|
|
|
|
// Returns the net name of the first wire hit, or null.
|
|
|
|
|
|
const THRESH = 6 / S.zoom;
|
|
|
|
|
|
for (const wire of [...S.wires].reverse()) {
|
|
|
|
|
|
for (let i = 0; i < wire.points.length - 1; i++) {
|
|
|
|
|
|
const a = wire.points[i], b = wire.points[i + 1];
|
|
|
|
|
|
const isH = Math.abs(a.y - b.y) < 1;
|
|
|
|
|
|
const isV = Math.abs(a.x - b.x) < 1;
|
|
|
|
|
|
if (isH) {
|
|
|
|
|
|
const lo = Math.min(a.x, b.x), hi = Math.max(a.x, b.x);
|
|
|
|
|
|
if (Math.abs(wy - a.y) < THRESH && wx >= lo - THRESH && wx <= hi + THRESH)
|
|
|
|
|
|
return wire.net;
|
|
|
|
|
|
} else if (isV) {
|
|
|
|
|
|
const lo = Math.min(a.y, b.y), hi = Math.max(a.y, b.y);
|
|
|
|
|
|
if (Math.abs(wx - a.x) < THRESH && wy >= lo - THRESH && wy <= hi + THRESH)
|
|
|
|
|
|
return wire.net;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Context menu ──────────────────────────────────────────────
|
|
|
|
|
|
// A lightweight DOM context menu that works on both desktop
|
|
|
|
|
|
// (right-click) and touch (long-press, handled below).
|
|
|
|
|
|
let _ctxMenu = null;
|
|
|
|
|
|
|
|
|
|
|
|
function _hideContextMenu() {
|
|
|
|
|
|
if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _showContextMenu(screenX, screenY, comp) {
|
|
|
|
|
|
_hideContextMenu();
|
|
|
|
|
|
|
|
|
|
|
|
const menu = document.createElement('div');
|
|
|
|
|
|
menu.id = 'ctx-menu';
|
|
|
|
|
|
menu.style.cssText = `
|
|
|
|
|
|
position:fixed; left:${screenX}px; top:${screenY}px;
|
|
|
|
|
|
background:var(--surface); border:1px solid var(--border);
|
|
|
|
|
|
border-radius:6px; padding:4px 0; z-index:500; min-width:160px;
|
|
|
|
|
|
font-family:'Space Mono',monospace; font-size:11px;
|
|
|
|
|
|
box-shadow:0 4px 20px rgba(0,0,0,.5);
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
const items = [
|
|
|
|
|
|
{ label: '↺ Rotate −90°', action: () => _rotateComp(comp, { rotation: 270, flipX: false }) },
|
|
|
|
|
|
{ label: '↻ Rotate 90°', action: () => _rotateComp(comp, { rotation: 90, flipX: false }) },
|
|
|
|
|
|
{ label: '↕ Rotate 180°', action: () => _rotateComp(comp, { rotation: 180, flipX: false }) },
|
|
|
|
|
|
{ label: '↕ Mirror Vertically', action: () => _rotateComp(comp, { rotation: 180, flipX: true }) },
|
|
|
|
|
|
{ label: '↔ Mirror Horizontally', action: () => _rotateComp(comp, { rotation: 0, flipX: true }) },
|
|
|
|
|
|
{ label: '⟳ Reset rotation', action: () => _rotateComp(comp, d4delta({ rotation: comp.rotation, flipX: comp.flipX }, { rotation: 0, flipX: 0 })) },
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '✨ Replace Symbol...', action: () => {
|
|
|
|
|
|
import('./ui.js').then(m => m.showReplaceSymbolModal(comp));
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
if (comp.type === 'KICAD') {
|
|
|
|
|
|
items.push({
|
|
|
|
|
|
label: '↺ Reset to Default Symbol', action: () => {
|
|
|
|
|
|
pushHistory();
|
|
|
|
|
|
import('./components.js').then(async ({ classifyComp }) => {
|
|
|
|
|
|
comp.type = classifyComp(comp.ref);
|
|
|
|
|
|
comp.componentTypeId = null;
|
|
|
|
|
|
comp.kicadData = null;
|
|
|
|
|
|
// Reset pin names to standard sequential numbers
|
|
|
|
|
|
comp.pins.forEach((p, i) => { p.name = String(i + 1); });
|
|
|
|
|
|
|
|
|
|
|
|
await routeWires();
|
|
|
|
|
|
if (_saveComponentLayout) _saveComponentLayout(comp.id);
|
|
|
|
|
|
import('./ui.js').then(m => {
|
|
|
|
|
|
m.toast('Reset to default symbol');
|
|
|
|
|
|
m.showProperties(comp);
|
|
|
|
|
|
m.updateSidePanels();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
items.forEach(({ label, action }) => {
|
|
|
|
|
|
const item = document.createElement('div');
|
|
|
|
|
|
item.textContent = label;
|
|
|
|
|
|
item.style.cssText = `
|
|
|
|
|
|
padding:7px 14px; cursor:pointer; color:var(--text);
|
|
|
|
|
|
transition:background .1s;
|
|
|
|
|
|
`;
|
|
|
|
|
|
item.onmouseenter = () => { item.style.background = 'var(--surface2)'; item.style.color = 'var(--accent)'; };
|
|
|
|
|
|
item.onmouseleave = () => { item.style.background = ''; item.style.color = 'var(--text)'; };
|
|
|
|
|
|
item.onclick = () => { _hideContextMenu(); action(); };
|
|
|
|
|
|
menu.appendChild(item);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(menu);
|
|
|
|
|
|
_ctxMenu = menu;
|
|
|
|
|
|
|
|
|
|
|
|
// Clamp to viewport
|
|
|
|
|
|
const r = menu.getBoundingClientRect();
|
|
|
|
|
|
if (r.right > window.innerWidth) menu.style.left = (screenX - r.width) + 'px';
|
|
|
|
|
|
if (r.bottom > window.innerHeight) menu.style.top = (screenY - r.height) + 'px';
|
|
|
|
|
|
|
|
|
|
|
|
// Close on next click anywhere
|
|
|
|
|
|
setTimeout(() => document.addEventListener('click', _hideContextMenu, { once: true }), 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function d4inverse(t) {
|
|
|
|
|
|
const r = t.rotation || 0;
|
|
|
|
|
|
if (!t.flipX)
|
|
|
|
|
|
return { rotation: (360 - r) % 360, flipX: false };
|
|
|
|
|
|
else
|
|
|
|
|
|
return { rotation: r, flipX: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function d4compose(a, b) {
|
|
|
|
|
|
const rA = a.rotation || 0;
|
|
|
|
|
|
const rB = b.rotation || 0;
|
|
|
|
|
|
const fB = b.flipX || false;
|
|
|
|
|
|
|
|
|
|
|
|
if (!a.flipX)
|
|
|
|
|
|
return { rotation: (rA + rB) % 360, flipX: fB };
|
|
|
|
|
|
else
|
|
|
|
|
|
return { rotation: (rA + (360 - rB)) % 360, flipX: !fB };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function d4delta(current, target) {
|
|
|
|
|
|
// Delta to reach target is solved via extrinsic composition: Δ * C = T => Δ = T * C^-1
|
|
|
|
|
|
return d4compose(target, d4inverse(current));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _rotateComp(comp, delta) {
|
|
|
|
|
|
pushHistory();
|
|
|
|
|
|
const prev = { rotation: comp.rotation || 0, flipX: comp.flipX || false };
|
|
|
|
|
|
// Swap compose order: Δ * prev applies transformations extrinsically (in world space)
|
|
|
|
|
|
const next = d4compose(delta, prev);
|
|
|
|
|
|
comp.rotation = next.rotation;
|
|
|
|
|
|
comp.flipX = next.flipX;
|
|
|
|
|
|
showProperties(comp);
|
|
|
|
|
|
updateSidePanels();
|
|
|
|
|
|
moveComponentRoute(comp.id).then(() => {
|
|
|
|
|
|
if (_saveComponentLayout) _saveComponentLayout(comp.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Touch long-press for context menu ─────────────────────────
|
|
|
|
|
|
let _longPressTimer = null;
|
|
|
|
|
|
let _longPressComp = null;
|
|
|
|
|
|
|
|
|
|
|
|
function _cancelLongPress() {
|
|
|
|
|
|
if (_longPressTimer) { clearTimeout(_longPressTimer); _longPressTimer = null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Mouse event listeners ─────────────────────────────────────
|
|
|
|
|
|
let _mouseDown = false;
|
|
|
|
|
|
let _didDrag = false; // distinguish click from drag on mouseup
|
|
|
|
|
|
|
|
|
|
|
|
export function initInteraction(saveComponentLayout, saveViewport) {
|
|
|
|
|
|
_saveComponentLayout = saveComponentLayout || null;
|
|
|
|
|
|
_saveViewport = saveViewport || null;
|
|
|
|
|
|
|
|
|
|
|
|
// ── mousedown ──────────────────────────────────────────────
|
|
|
|
|
|
canvas.addEventListener('mousedown', e => {
|
|
|
|
|
|
document.getElementById('left-panel')?.classList.remove('open');
|
|
|
|
|
|
// {
|
|
|
|
|
|
// const w = s2w(e.offsetX, e.offsetY);
|
|
|
|
|
|
// console.log('[schema] mousedown world:', w.x, w.y,
|
|
|
|
|
|
// 'components:', S.components.map(c => `${c.ref}(${Math.round(c.x)},${Math.round(c.y)})`).join(' '));
|
|
|
|
|
|
// }
|
|
|
|
|
|
_hideContextMenu();
|
|
|
|
|
|
_mouseDown = true;
|
|
|
|
|
|
_didDrag = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Middle-button or alt+left → pan
|
|
|
|
|
|
if (e.button === 1 || e.button === 2 || e.altKey) {
|
|
|
|
|
|
S.panning = true;
|
|
|
|
|
|
S.panStart = { x: e.offsetX, y: e.offsetY, vx: S.viewX, vy: S.viewY };
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const w = s2w(e.offsetX, e.offsetY);
|
|
|
|
|
|
const hC = hitTestComp(w.x, w.y);
|
|
|
|
|
|
if (hC) {
|
|
|
|
|
|
S.selectedComp = hC.id; S.selectedNet = null; S.selectedWire = null;
|
|
|
|
|
|
pushHistory();
|
|
|
|
|
|
S.dragging = { compId: hC.id, ox: w.x - hC.x, oy: w.y - hC.y };
|
|
|
|
|
|
showProperties(hC); updateSidePanels(); render();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Click on wire → select whole net
|
|
|
|
|
|
const hitNet = hitTestWire(w.x, w.y);
|
|
|
|
|
|
if (hitNet) {
|
|
|
|
|
|
const net = S.nets.find(n => n.name === hitNet);
|
|
|
|
|
|
S.selectedNet = hitNet;
|
|
|
|
|
|
S.selectedComp = null; S.selectedWire = null;
|
|
|
|
|
|
if (net) showNetProperties(net);
|
|
|
|
|
|
updateSidePanels(); render();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Click on empty canvas → pan (left-button drag on empty space)
|
|
|
|
|
|
S.panning = true;
|
|
|
|
|
|
S.panStart = { x: e.offsetX, y: e.offsetY, vx: S.viewX, vy: S.viewY };
|
|
|
|
|
|
S.selectedComp = null; S.selectedNet = null; S.selectedWire = null;
|
|
|
|
|
|
showDefaultProps(); updateSidePanels(); render();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── mousemove ─────────────────────────────────────────────
|
|
|
|
|
|
canvas.addEventListener('mousemove', e => {
|
|
|
|
|
|
const w = s2w(e.offsetX, e.offsetY);
|
|
|
|
|
|
document.getElementById('coord-readout').textContent =
|
|
|
|
|
|
`X: ${Math.round(w.x)} Y: ${Math.round(w.y)}`;
|
|
|
|
|
|
|
|
|
|
|
|
if (S.panning && _mouseDown) {
|
|
|
|
|
|
const dx = e.offsetX - S.panStart.x;
|
|
|
|
|
|
const dy = e.offsetY - S.panStart.y;
|
|
|
|
|
|
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) _didDrag = true;
|
|
|
|
|
|
S.viewX = S.panStart.vx + dx;
|
|
|
|
|
|
S.viewY = S.panStart.vy + dy;
|
|
|
|
|
|
canvas.style.cursor = 'grabbing';
|
|
|
|
|
|
render(); return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (S.dragging && _mouseDown) {
|
|
|
|
|
|
_didDrag = true;
|
|
|
|
|
|
const c = S.components.find(x => x.id === S.dragging.compId);
|
|
|
|
|
|
if (!c) return;
|
|
|
|
|
|
c.x = snap(w.x - S.dragging.ox);
|
|
|
|
|
|
c.y = snap(w.y - S.dragging.oy);
|
|
|
|
|
|
render(); return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Hover — cursor only, no hint bar for wires (they're not draggable)
|
|
|
|
|
|
const hC = hitTestComp(w.x, w.y);
|
|
|
|
|
|
const hW = hC ? null : hitTestWire(w.x, w.y);
|
|
|
|
|
|
|
|
|
|
|
|
const prevHoverComp = S.hoverComp;
|
|
|
|
|
|
S.hoverComp = hC ? hC.id : null;
|
|
|
|
|
|
S.hoverWireSeg = null; // no longer tracking individual segments
|
|
|
|
|
|
|
|
|
|
|
|
const newCursor = hC ? 'move' : hW ? 'pointer' : 'default';
|
|
|
|
|
|
if (canvas.style.cursor !== newCursor) canvas.style.cursor = newCursor;
|
|
|
|
|
|
|
|
|
|
|
|
if (S.hoverComp !== prevHoverComp) render();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── mouseup ───────────────────────────────────────────────
|
|
|
|
|
|
canvas.addEventListener('mouseup', e => {
|
|
|
|
|
|
_mouseDown = false;
|
|
|
|
|
|
if (S.dragging) {
|
|
|
|
|
|
const movedId = S.dragging.compId;
|
|
|
|
|
|
S.dragging = null;
|
|
|
|
|
|
moveComponentRoute(movedId).then(() => {
|
|
|
|
|
|
if (_saveComponentLayout) _saveComponentLayout(movedId);
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (S.panning) {
|
|
|
|
|
|
canvas.style.cursor = 'default';
|
|
|
|
|
|
S.panning = false;
|
|
|
|
|
|
S.panStart = null;
|
|
|
|
|
|
if (_saveViewport) _saveViewport();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── mouseleave ────────────────────────────────────────────
|
|
|
|
|
|
canvas.addEventListener('mouseleave', () => {
|
|
|
|
|
|
if (S.dragging) {
|
|
|
|
|
|
const movedId = S.dragging.compId;
|
|
|
|
|
|
S.dragging = null;
|
|
|
|
|
|
moveComponentRoute(movedId).then(() => {
|
|
|
|
|
|
if (_saveComponentLayout) _saveComponentLayout(movedId);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (S.panning) {
|
|
|
|
|
|
S.panning = false; S.panStart = null;
|
|
|
|
|
|
canvas.style.cursor = 'default';
|
|
|
|
|
|
if (_saveViewport) _saveViewport();
|
|
|
|
|
|
}
|
|
|
|
|
|
_mouseDown = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Right-click context menu ──────────────────────────────
|
|
|
|
|
|
canvas.addEventListener('contextmenu', e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const w = s2w(e.offsetX, e.offsetY);
|
|
|
|
|
|
const hC = hitTestComp(w.x, w.y);
|
|
|
|
|
|
if (hC) {
|
|
|
|
|
|
S.selectedComp = hC.id;
|
|
|
|
|
|
showProperties(hC); updateSidePanels(); render();
|
|
|
|
|
|
_showContextMenu(e.clientX, e.clientY, hC);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Touch interaction (Pan, Zoom, Tap, Drag) ─────────────────
|
|
|
|
|
|
let _touchDist = null;
|
|
|
|
|
|
let _touchStartPt = null;
|
|
|
|
|
|
let _touchDragType = null; // 'pan', 'comp', or 'zoom'
|
|
|
|
|
|
let _touchMoved = false;
|
|
|
|
|
|
|
|
|
|
|
|
function getPinchDist(e) {
|
|
|
|
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
|
|
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
|
|
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
canvas.addEventListener('touchstart', e => {
|
|
|
|
|
|
e.preventDefault(); // Stop simulated mouse events & browser scrolling
|
|
|
|
|
|
document.getElementById('left-panel')?.classList.remove('open');
|
|
|
|
|
|
_hideContextMenu();
|
|
|
|
|
|
|
|
|
|
|
|
if (e.touches.length === 2) {
|
|
|
|
|
|
_cancelLongPress();
|
|
|
|
|
|
S.dragging = null;
|
|
|
|
|
|
_touchDist = getPinchDist(e);
|
|
|
|
|
|
_touchDragType = 'zoom';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (e.touches.length === 1) {
|
|
|
|
|
|
_touchMoved = false;
|
|
|
|
|
|
const t = e.touches[0];
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
|
const cx = t.clientX - rect.left;
|
|
|
|
|
|
const cy = t.clientY - rect.top;
|
|
|
|
|
|
_touchStartPt = { cx, cy, vx: S.viewX, vy: S.viewY };
|
|
|
|
|
|
|
|
|
|
|
|
const w = s2w(cx, cy);
|
|
|
|
|
|
const hC = hitTestComp(w.x, w.y);
|
|
|
|
|
|
|
|
|
|
|
|
if (hC) {
|
|
|
|
|
|
_touchDragType = 'comp';
|
|
|
|
|
|
pushHistory();
|
|
|
|
|
|
S.dragging = { compId: hC.id, ox: w.x - hC.x, oy: w.y - hC.y };
|
|
|
|
|
|
_longPressComp = hC;
|
|
|
|
|
|
_longPressTimer = setTimeout(() => {
|
|
|
|
|
|
_longPressComp = null;
|
|
|
|
|
|
_touchDragType = null; // Cancel drag if long press activates
|
|
|
|
|
|
S.dragging = null;
|
|
|
|
|
|
S.selectedComp = hC.id;
|
|
|
|
|
|
showProperties(hC); updateSidePanels(); render();
|
|
|
|
|
|
_showContextMenu(t.clientX, t.clientY, hC);
|
|
|
|
|
|
}, 600);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_touchDragType = 'pan';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { passive: false });
|
|
|
|
|
|
|
|
|
|
|
|
canvas.addEventListener('touchmove', e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
if (e.touches.length === 2 && _touchDragType === 'zoom') {
|
|
|
|
|
|
const newDist = getPinchDist(e);
|
|
|
|
|
|
const factor = newDist / _touchDist;
|
|
|
|
|
|
_touchDist = newDist;
|
|
|
|
|
|
|
|
|
|
|
|
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - canvas.getBoundingClientRect().left;
|
|
|
|
|
|
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - canvas.getBoundingClientRect().top;
|
|
|
|
|
|
const w = s2w(cx, cy);
|
|
|
|
|
|
|
|
|
|
|
|
S.zoom = Math.max(0.1, Math.min(8, S.zoom * factor));
|
|
|
|
|
|
const ns = {
|
|
|
|
|
|
x: w.x * S.zoom + canvas.width / 2 + S.viewX,
|
|
|
|
|
|
y: w.y * S.zoom + canvas.height / 2 + S.viewY,
|
|
|
|
|
|
};
|
|
|
|
|
|
S.viewX += cx - ns.x;
|
|
|
|
|
|
S.viewY += cy - ns.y;
|
|
|
|
|
|
|
|
|
|
|
|
render();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (e.touches.length === 1 && _touchStartPt) {
|
|
|
|
|
|
const t = e.touches[0];
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
|
const cx = t.clientX - rect.left;
|
|
|
|
|
|
const cy = t.clientY - rect.top;
|
|
|
|
|
|
const dx = cx - _touchStartPt.cx;
|
|
|
|
|
|
const dy = cy - _touchStartPt.cy;
|
|
|
|
|
|
|
|
|
|
|
|
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
|
|
|
|
|
_touchMoved = true;
|
|
|
|
|
|
_cancelLongPress();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (_touchDragType === 'pan') {
|
|
|
|
|
|
S.viewX = _touchStartPt.vx + dx;
|
|
|
|
|
|
S.viewY = _touchStartPt.vy + dy;
|
|
|
|
|
|
render();
|
|
|
|
|
|
} else if (_touchDragType === 'comp' && S.dragging) {
|
|
|
|
|
|
const w = s2w(cx, cy);
|
|
|
|
|
|
const c = S.components.find(x => x.id === S.dragging.compId);
|
|
|
|
|
|
if (c) {
|
|
|
|
|
|
c.x = snap(w.x - S.dragging.ox);
|
|
|
|
|
|
c.y = snap(w.y - S.dragging.oy);
|
|
|
|
|
|
render();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { passive: false });
|
|
|
|
|
|
|
|
|
|
|
|
canvas.addEventListener('touchend', e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
_cancelLongPress();
|
|
|
|
|
|
|
|
|
|
|
|
if (e.touches.length === 0) {
|
|
|
|
|
|
if (!_touchMoved && _touchDragType) {
|
|
|
|
|
|
// Finger was lifted without dragging -> Tap
|
|
|
|
|
|
const w = s2w(_touchStartPt.cx, _touchStartPt.cy);
|
|
|
|
|
|
const hC = hitTestComp(w.x, w.y);
|
|
|
|
|
|
if (hC) {
|
|
|
|
|
|
S.selectedComp = hC.id; S.selectedNet = null; S.selectedWire = null;
|
|
|
|
|
|
showProperties(hC); updateSidePanels(); render();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const hitNet = hitTestWire(w.x, w.y);
|
|
|
|
|
|
if (hitNet) {
|
|
|
|
|
|
const net = S.nets.find(n => n.name === hitNet);
|
|
|
|
|
|
S.selectedNet = hitNet;
|
|
|
|
|
|
S.selectedComp = null; S.selectedWire = null;
|
|
|
|
|
|
if (net) showNetProperties(net);
|
|
|
|
|
|
updateSidePanels(); render();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
S.selectedComp = null; S.selectedNet = null; S.selectedWire = null;
|
|
|
|
|
|
showDefaultProps(); updateSidePanels(); render();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
S.dragging = null; // Prevent backend routing on simple tap
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Drag finished
|
|
|
|
|
|
if (_touchDragType === 'comp' && S.dragging) {
|
|
|
|
|
|
const movedId = S.dragging.compId;
|
|
|
|
|
|
moveComponentRoute(movedId).then(() => {
|
|
|
|
|
|
if (_saveComponentLayout) _saveComponentLayout(movedId);
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if ((_touchDragType === 'pan' || _touchDragType === 'zoom') && _saveViewport) {
|
|
|
|
|
|
_saveViewport();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
S.dragging = null;
|
|
|
|
|
|
_touchDragType = null;
|
|
|
|
|
|
_touchStartPt = null;
|
|
|
|
|
|
_touchDist = null;
|
|
|
|
|
|
} else if (e.touches.length === 1) {
|
|
|
|
|
|
// Dropped from 2 fingers to 1 (finished pinching)
|
|
|
|
|
|
_touchDragType = null;
|
|
|
|
|
|
S.dragging = null;
|
|
|
|
|
|
if (_saveViewport) _saveViewport();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { passive: false });
|
|
|
|
|
|
|
|
|
|
|
|
canvas.addEventListener('touchcancel', e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
_cancelLongPress();
|
|
|
|
|
|
S.dragging = null;
|
|
|
|
|
|
_touchDragType = null;
|
|
|
|
|
|
_touchStartPt = null;
|
|
|
|
|
|
_touchDist = null;
|
|
|
|
|
|
}, { passive: false });
|
|
|
|
|
|
|
|
|
|
|
|
// ── Wheel — zoom toward pointer, pan with trackpad scroll ──
|
|
|
|
|
|
canvas.addEventListener('wheel', e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
// Two-finger trackpad pan: small deltas without ctrlKey = scroll, not pinch
|
|
|
|
|
|
if (!e.ctrlKey && e.deltaMode === 0 &&
|
|
|
|
|
|
(Math.abs(e.deltaX) > 2 || Math.abs(e.deltaY) < 50)) {
|
|
|
|
|
|
S.viewX -= e.deltaX;
|
|
|
|
|
|
S.viewY -= e.deltaY;
|
|
|
|
|
|
render();
|
|
|
|
|
|
if (_saveViewport) _saveViewport();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Zoom toward the pointer position (standard for diagram editors)
|
|
|
|
|
|
const factor = (e.deltaY < 0 || (e.ctrlKey && e.deltaY < 0)) ? 1.12 : 1 / 1.12;
|
|
|
|
|
|
const w = s2w(e.offsetX, e.offsetY);
|
|
|
|
|
|
S.zoom = Math.max(0.1, Math.min(8, S.zoom * factor));
|
|
|
|
|
|
const ns = {
|
|
|
|
|
|
x: w.x * S.zoom + canvas.width / 2 + S.viewX,
|
|
|
|
|
|
y: w.y * S.zoom + canvas.height / 2 + S.viewY,
|
|
|
|
|
|
};
|
|
|
|
|
|
S.viewX += e.offsetX - ns.x;
|
|
|
|
|
|
S.viewY += e.offsetY - ns.y;
|
|
|
|
|
|
render();
|
|
|
|
|
|
if (_saveViewport) _saveViewport();
|
|
|
|
|
|
}, { passive: false });
|
|
|
|
|
|
|
|
|
|
|
|
// ── Keyboard shortcuts ─────────────────────────────────────
|
|
|
|
|
|
document.addEventListener('keydown', e => {
|
|
|
|
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
|
|
|
|
_hideContextMenu();
|
|
|
|
|
|
|
|
|
|
|
|
if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) {
|
|
|
|
|
|
undo(); return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (e.key === 'f' || e.key === 'F') {
|
|
|
|
|
|
fitView(); return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((e.key === 'r' || e.key === 'R') && S.selectedComp) {
|
|
|
|
|
|
const c = S.components.find(x => x.id === S.selectedComp);
|
|
|
|
|
|
if (c) _rotateComp(c, { rotation: 270, flipX: false });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((e.key === 'y' || e.key === 'Y') && S.selectedComp) {
|
|
|
|
|
|
const c = S.components.find(x => x.id === S.selectedComp);
|
|
|
|
|
|
if (c) _rotateComp(c, { rotation: 180, flipX: true });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((e.key === 'x' || e.key === 'X') && S.selectedComp) {
|
|
|
|
|
|
const c = S.components.find(x => x.id === S.selectedComp);
|
|
|
|
|
|
if (c) _rotateComp(c, { rotation: 0, flipX: true });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
|
_hideContextMenu();
|
|
|
|
|
|
S.selectedComp = null; S.selectedWire = null; S.selectedNet = null;
|
|
|
|
|
|
showDefaultProps(); updateSidePanels(); render();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|