pcb-retrace/docs/schema/interaction.js

557 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ── 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();
}
});
}