Fix undo gaps and add right-click menu items 16-20

Undo fixes:
- Move undo now redraws deviceLayer so devices visually snap back
- Label, reference, gauge, color, and all wire/device property changes are now undoable
- Duplicate and paste are now undoable (deletes cloned devices on Ctrl+Z)

Right-click menu additions (roadmap 16-20):
- Align & distribute: left/right/top/bottom edges, H/V spacing for multi-select
- Bring to front / Send to back via zOrder in device.properties
- Copy/paste wire style (color, gauge, stripe, twisted pair, shielded)
- Select all wires on net highlights every wire sharing the same label
- Lock device: prevents moves, shows padlock badge, persists across refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:19:45 -04:00
parent f958027528
commit f43cd3409c
3 changed files with 252 additions and 30 deletions
+76 -4
View File
@@ -32,6 +32,7 @@ class DiagramCanvas {
this._multiDragWireWaypoints = null; // wireId -> [{x,y}] snapshot at dragstart
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._initStage(containerId);
this._initZoomPan();
@@ -505,7 +506,10 @@ class DiagramCanvas {
setMode(mode) {
this.mode = mode;
this.stage.container().classList.toggle("wire-mode", mode === "wire");
this.deviceNodes.forEach(g => g.draggable(mode === "select"));
this.deviceNodes.forEach((g, id) => {
const d = this.deviceData.get(id);
g.draggable(mode === "select" && !d?.properties?.locked);
});
if (mode !== "wire") this._cancelWire();
}
@@ -532,9 +536,12 @@ class DiagramCanvas {
this._multiDragAnchorId = null; this._multiDragStartPos = null;
this._bundleNodes = []; this._bundleData = [];
const sorted = [...(diagram.devices || [])].sort((a, b) =>
(a.device_type === "group" ? -1 : 0) - (b.device_type === "group" ? -1 : 0)
);
const sorted = [...(diagram.devices || [])].sort((a, b) => {
const aGroup = a.device_type === "group" ? 0 : 1;
const bGroup = b.device_type === "group" ? 0 : 1;
if (aGroup !== bGroup) return aGroup - bGroup;
return (a.properties?.zOrder || 0) - (b.properties?.zOrder || 0);
});
sorted.forEach(d => {
this.deviceData.set(d.id, d);
this._renderDevice(d);
@@ -630,6 +637,8 @@ class DiagramCanvas {
}
clearSelection() {
this._netHighlightIds.forEach(id => this._wireHighlight(id, false));
this._netHighlightIds.clear();
this.selectedDeviceIds.forEach(id => this._highlightDevice(id, false));
this.selectedDeviceIds.clear();
this._clearResizeHandles();
@@ -1163,6 +1172,14 @@ class DiagramCanvas {
(device.pins || []).forEach(pin => this._addPin(group, device, pin));
if (device.properties?.locked) {
group.add(new Konva.Text({
name: "lock-badge", text: "🔒",
x: device.width - 16, y: 3, fontSize: 11, listening: false,
}));
group.draggable(false);
}
group.on("contextmenu", (e) => {
e.evt.preventDefault();
e.cancelBubble = true;
@@ -1213,6 +1230,7 @@ class DiagramCanvas {
});
group.on("dragstart", () => {
if (this.wireStart) { group.stopDrag(); return; }
if (device.properties?.locked) { group.stopDrag(); return; }
if (device.device_type !== "group") group.moveToTop();
this._clearResizeHandles(); // hide during drag
if (this.selectedDeviceIds.size > 1 && this.selectedDeviceIds.has(device.id)) {
@@ -1807,4 +1825,58 @@ class DiagramCanvas {
this._handleNodes = [];
this.uiLayer.batchDraw();
}
// ── Layer order ───────────────────────────────────────────────────────────────
bringToFront(id) {
const g = this.deviceNodes.get(id);
const d = this.deviceData.get(id);
if (!g || !d) return;
g.moveToTop();
(d.device_type === "group" ? this.groupLayer : this.deviceLayer).batchDraw();
}
sendToBack(id) {
const g = this.deviceNodes.get(id);
const d = this.deviceData.get(id);
if (!g || !d) return;
g.moveToBottom();
(d.device_type === "group" ? this.groupLayer : this.deviceLayer).batchDraw();
}
// ── Lock / unlock ─────────────────────────────────────────────────────────────
setDeviceLocked(id, locked) {
const d = this.deviceData.get(id);
const g = this.deviceNodes.get(id);
if (!d || !g) return;
d.properties = { ...(d.properties || {}), locked };
g.draggable(!locked && this.mode === "select");
const existing = g.findOne(".lock-badge");
if (existing) existing.destroy();
if (locked) {
g.add(new Konva.Text({
name: "lock-badge", text: "🔒",
x: d.width - 16, y: 3, fontSize: 11, listening: false,
}));
}
(d.device_type === "group" ? this.groupLayer : this.deviceLayer).batchDraw();
}
// ── Net highlight ─────────────────────────────────────────────────────────────
highlightNet(label) {
this._netHighlightIds.forEach(id => {
if (id !== this.selectedId) this._wireHighlight(id, false);
});
this._netHighlightIds.clear();
if (!label) return;
this.wireData.forEach((wire, id) => {
if (wire.label === label) {
this._netHighlightIds.add(id);
this._wireHighlight(id, true);
}
});
this.wireLayer.batchDraw();
}
}