Add views, import/duplicate, DRC, jumps toggle, multi-wire select, auto-route, delete view tabs

- Roadmap #22: File→Import JSON to restore a diagram (drag-drop or file picker)
- Roadmap #23: Duplicate diagram from diagram list
- Roadmap #24: DRC panel with collapsible categories (unconnected pins, dup refs, no length, no P/N)
- Multi-view tabs: named views per diagram with independent device/wire layouts; snapshot-on-entry prevents bleed
- Keyboard arrow nudge for selected device(s)
- Undo for move, duplicate, label/property changes
- Wire jump arcs toggle (⌒ Jumps button in toolbar)
- Shift+click multi-wire selection; right-click bulk clear waypoints or auto-route
- Auto-route: obstacle-avoiding ortho routing for single or bulk selected wires
- Delete view tab with × button; right-click context menu for rename/delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 19:18:39 -04:00
parent f43cd3409c
commit e4a80dc0e6
11 changed files with 823 additions and 25 deletions
+66 -6
View File
@@ -33,6 +33,8 @@ class DiagramCanvas {
this._bundleData = []; // array of bundle objects
this._bundleNodes = []; // Konva shapes for bundle overlays (on groupLayer)
this._netHighlightIds = new Set(); // wire IDs currently net-highlighted
this.selectedWireIds = new Set(); // wire IDs in multi-wire selection
this.jumpsEnabled = true; // wire jump arcs at crossings
this._initStage(containerId);
this._initZoomPan();
@@ -639,6 +641,8 @@ class DiagramCanvas {
clearSelection() {
this._netHighlightIds.forEach(id => this._wireHighlight(id, false));
this._netHighlightIds.clear();
this.selectedWireIds.forEach(id => this._wireHighlight(id, false));
this.selectedWireIds.clear();
this.selectedDeviceIds.forEach(id => this._highlightDevice(id, false));
this.selectedDeviceIds.clear();
this._clearResizeHandles();
@@ -966,7 +970,38 @@ class DiagramCanvas {
_makeSvgPath(pts, crossings) {
if (this.routeMode === "curved") return this._ptsToSvgPathCurved(pts);
return this._ptsToSvgPath(pts, crossings);
return this._ptsToSvgPath(pts, this.jumpsEnabled ? crossings : null);
}
setJumpsEnabled(enabled) {
this.jumpsEnabled = enabled;
this._recomputeJumps();
}
autoRouteWire(wireId) {
const wire = this.wireData.get(wireId);
if (!wire) return null;
const fd = this.deviceData.get(wire.from_device_id);
const td = this.deviceData.get(wire.to_device_id);
if (!fd || !td) return null;
const fp = (fd.pins || []).find(p => p.id === wire.from_pin);
const tp = (td.pins || []).find(p => p.id === wire.to_pin);
if (!fp || !tp) return null;
const S = 24;
const ex1 = fd.x + fp.x_offset + (fp.side === "right" ? S : fp.side === "left" ? -S : 0);
const ey1 = fd.y + fp.y_offset + (fp.side === "bottom" ? S : fp.side === "top" ? -S : 0);
const ex2 = td.x + tp.x_offset + (tp.side === "right" ? S : tp.side === "left" ? -S : 0);
const ey2 = td.y + tp.y_offset + (tp.side === "bottom" ? S : tp.side === "top" ? -S : 0);
const excludeIds = new Set([wire.from_device_id, wire.to_device_id]);
const inner = this._autoOrtho(ex1, ey1, ex2, ey2, excludeIds);
// Strip the two exit points (_autoOrtho includes ex1,ey1 and ex2,ey2); store inner pairs as waypoints
const mid = inner.slice(2, inner.length - 2);
wire.waypoints = [];
for (let i = 0; i < mid.length; i += 2) wire.waypoints.push({ x: mid[i], y: mid[i + 1] });
this._destroyWireVisuals(wireId);
this._renderWire(wire);
this._recomputeJumps();
return wire.waypoints;
}
_recomputeJumps() {
@@ -1630,24 +1665,49 @@ class DiagramCanvas {
hit.on("click", (e) => {
if (this.mode !== "select") return;
e.cancelBubble = true;
this.selectWire(wire.id);
if (e.evt.shiftKey) {
// Shift+click: toggle this wire in multi-wire selection
if (this.selectedWireIds.has(wire.id)) {
this.selectedWireIds.delete(wire.id);
this._wireHighlight(wire.id, false);
} else {
// Add current single-selected wire to set first
if (this.selectedId && this.selectedType === "wire" && !this.selectedWireIds.has(this.selectedId)) {
this.selectedWireIds.add(this.selectedId);
}
this.selectedWireIds.add(wire.id);
this._wireHighlight(wire.id, true);
this.selectedId = wire.id;
this.selectedType = "wire";
}
this.cb.onMultiWireSelected?.(this.selectedWireIds);
} else {
this.selectWire(wire.id);
}
});
hit.on("mouseenter", () => {
if (this.mode === "select" && this.selectedId !== wire.id) {
if (this.mode === "select" && this.selectedId !== wire.id && !this.selectedWireIds.has(wire.id)) {
this._wireHighlight(wire.id, true);
this.stage.container().style.cursor = "pointer";
}
});
hit.on("mouseleave", () => {
if (this.selectedId !== wire.id) this._wireHighlight(wire.id, false);
if (this.selectedId !== wire.id && !this.selectedWireIds.has(wire.id)) {
this._wireHighlight(wire.id, false);
}
this.stage.container().style.cursor = "";
});
hit.on("contextmenu", (e) => {
e.evt.preventDefault();
e.cancelBubble = true;
if (this.selectedId !== wire.id) this.selectWire(wire.id);
this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY);
if (this.selectedWireIds.size > 1 && this.selectedWireIds.has(wire.id)) {
// Multi-wire context menu
this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY);
} else {
if (this.selectedId !== wire.id) this.selectWire(wire.id);
this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY);
}
});
// Wire body drag (selected wire only) → inserts a waypoint at drag start