/* * 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 === 'ArrowUp' || e.key === 'ArrowDown') { const isUp = e.key === 'ArrowUp'; const naturalCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); if (S.selectedComp) { e.preventDefault(); const idx = S.components.findIndex(c => c.id === S.selectedComp); if (idx !== -1) { const nextIdx = isUp ? idx - 1 : idx + 1; if (nextIdx >= 0 && nextIdx < S.components.length) { const nextComp = S.components[nextIdx]; S.selectedComp = nextComp.id; showProperties(nextComp); updateSidePanels(); render(); document.querySelector('.comp-item.selected')?.scrollIntoView({ block: 'nearest' }); } } return; } if (S.selectedNet) { e.preventDefault(); const idx = S.nets.findIndex(n => n.name === S.selectedNet); if (idx !== -1) { const nextIdx = isUp ? idx - 1 : idx + 1; if (nextIdx >= 0 && nextIdx < S.nets.length) { const nextNet = S.nets[nextIdx]; S.selectedNet = nextNet.name; showNetProperties(nextNet); updateSidePanels(); render(); document.querySelector('.net-item.selected')?.scrollIntoView({ block: 'nearest' }); } } return; } } 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(); } }); }