diff --git a/ROADMAP.md b/ROADMAP.md index 8667592..f1a9bd5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,11 +10,11 @@ - [x] 15. **Wire labels on canvas** — wire label + length rendered mid-wire as a small tag ### Right-Click Menu Additions -- [ ] 16. **Align & distribute** — right-click multi-select → Align Left / Align Right / Align Top / Align Bottom / Distribute Horizontally / Distribute Vertically -- [ ] 17. **Send to back / Bring to front** — right-click any device → layer order shortcuts (especially useful for Section Boxes) -- [ ] 18. **Copy wire properties** — right-click a wire → "Copy style"; then right-click another wire → "Paste style" to apply color/gauge/stripe -- [ ] 19. **Select all wires on net** — right-click a wire → "Select all on net" highlights every wire sharing the same wire label/net name -- [ ] 20. **Lock device** — right-click → "Lock position" prevents accidental moves; locked devices show a padlock badge +- [x] 16. **Align & distribute** — right-click multi-select → Align Left / Align Right / Align Top / Align Bottom / Distribute Horizontally / Distribute Vertically +- [x] 17. **Send to back / Bring to front** — right-click any device → layer order shortcuts (especially useful for Section Boxes) +- [x] 18. **Copy wire properties** — right-click a wire → "Copy style"; then right-click another wire → "Paste style" to apply color/gauge/stripe +- [x] 19. **Select all wires on net** — right-click a wire → "Select all on net" highlights every wire sharing the same wire label/net name +- [x] 20. **Lock device** — right-click → "Lock position" prevents accidental moves; locked devices show a padlock badge ### Productivity - [ ] 21. **Find / search** — toolbar search box filters devices by reference/label and highlights matches on canvas; Ctrl+F shortcut diff --git a/frontend/js/app.js b/frontend/js/app.js index b6b68c8..9d6f017 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -11,6 +11,7 @@ class WiringApp { this._octopartReady = null; // null=unknown, true/false after status check this._bundles = []; // wire bundles for current diagram this._updatingBundleDropdown = false; + this._copiedWireStyle = null; // clipboard for wire style copy/paste this.canvas = new DiagramCanvas("canvas-container", { onDeviceSelected: (d) => this._showDeviceProps(d), @@ -83,6 +84,67 @@ class WiringApp { }, 3000); } + // ── Align & Distribute ──────────────────────────────────────────────────────── + + async _alignDevices(edge) { + const ids = [...this.canvas.selectedIds]; + if (ids.length < 2) return; + const boxes = ids.map(id => { + const d = this.canvas.deviceData.get(id); + return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 }; + }); + let target; + if (edge === "left") target = Math.min(...boxes.map(b => b.x)); + if (edge === "right") target = Math.max(...boxes.map(b => b.x + b.w)); + if (edge === "top") target = Math.min(...boxes.map(b => b.y)); + if (edge === "bottom") target = Math.max(...boxes.map(b => b.y + b.h)); + for (const b of boxes) { + const d = this.canvas.deviceData.get(b.id); + if (edge === "left") d.x = target; + if (edge === "right") d.x = target - b.w; + if (edge === "top") d.y = target; + if (edge === "bottom") d.y = target - b.h; + this.canvas.updateDevice(d); + await api.devices.update(b.id, { x: d.x, y: d.y }).catch(console.error); + } + this._flashSaved(); + } + + async _distributeDevices(axis) { + const ids = [...this.canvas.selectedIds]; + if (ids.length < 3) return; + const boxes = ids.map(id => { + const d = this.canvas.deviceData.get(id); + return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 }; + }); + if (axis === "h") { + boxes.sort((a, b) => a.x - b.x); + const totalW = boxes.reduce((s, b) => s + b.w, 0); + const gap = (boxes[boxes.length - 1].x + boxes[boxes.length - 1].w - boxes[0].x - totalW) / (boxes.length - 1); + let cursor = boxes[0].x + boxes[0].w; + for (let i = 1; i < boxes.length - 1; i++) { + const d = this.canvas.deviceData.get(boxes[i].id); + d.x = Math.round(cursor + gap); + this.canvas.updateDevice(d); + await api.devices.update(boxes[i].id, { x: d.x }).catch(console.error); + cursor = d.x + boxes[i].w; + } + } else { + boxes.sort((a, b) => a.y - b.y); + const totalH = boxes.reduce((s, b) => s + b.h, 0); + const gap = (boxes[boxes.length - 1].y + boxes[boxes.length - 1].h - boxes[0].y - totalH) / (boxes.length - 1); + let cursor = boxes[0].y + boxes[0].h; + for (let i = 1; i < boxes.length - 1; i++) { + const d = this.canvas.deviceData.get(boxes[i].id); + d.y = Math.round(cursor + gap); + this.canvas.updateDevice(d); + await api.devices.update(boxes[i].id, { y: d.y }).catch(console.error); + cursor = d.y + boxes[i].h; + } + } + this._flashSaved(); + } + // ── Undo ────────────────────────────────────────────────────────────────────── _pushUndo(fn) { @@ -99,7 +161,6 @@ class WiringApp { if (!this._preDrag) return; const { positions, waypoints } = this._preDrag; this._preDrag = null; - // Snapshot post-drag state for redo (not needed — single-level undo) this._pushUndo(async () => { for (const [id, { x, y }] of positions) { const g = this.canvas.deviceNodes.get(id); @@ -116,6 +177,7 @@ class WiringApp { await api.wires.update(wId, { waypoints: wire.waypoints }).catch(console.error); } } + this.canvas.deviceLayer.batchDraw(); this.canvas.wireLayer.batchDraw(); this._flashSaved(); }); @@ -431,34 +493,61 @@ class WiringApp { _showCtxDevice(id, x, y) { const d = this.canvas.deviceData.get(id); - const isMulti = this.canvas.selectedDeviceIds.size > 1; + const count = this.canvas.selectedDeviceIds.size; + const isMulti = count > 1; const isGroup = d?.device_type === "group"; + const isLocked = !!d?.properties?.locked; + const items = isMulti ? [ - { action: "dup", icon: "⊕", label: `Duplicate ${this.canvas.selectedDeviceIds.size} items` }, - { action: "zoom", icon: "⊞", label: "Zoom to selection" }, + { action: "dup", icon: "⊕", label: `Duplicate ${count} items` }, + { action: "zoom", icon: "⊞", label: "Zoom to selection" }, "---", - { action: "del", icon: "✕", label: `Delete ${this.canvas.selectedDeviceIds.size} items`, danger: true }, - ] : isGroup ? [ - { action: "dup", icon: "⊕", label: "Duplicate" }, + { action: "align-left", icon: "⇤", label: "Align Left" }, + { action: "align-right", icon: "⇥", label: "Align Right" }, + { action: "align-top", icon: "⇡", label: "Align Top" }, + { action: "align-bottom", icon: "⇣", label: "Align Bottom" }, + ...(count >= 3 ? [ + "---", + { action: "dist-h", icon: "↔", label: "Distribute Horizontally" }, + { action: "dist-v", icon: "↕", label: "Distribute Vertically" }, + ] : []), "---", - { action: "del", icon: "✕", label: "Delete", danger: true }, + { action: "del", icon: "✕", label: `Delete ${count} items`, danger: true }, ] : [ - { action: "dup", icon: "⊕", label: "Duplicate" }, - { action: "addpin", icon: "+", label: "Add Pin" }, - { action: "savelb", icon: "⬆", label: "Save to Library" }, + { action: "dup", icon: "⊕", label: "Duplicate" }, + ...(!isGroup ? [{ action: "addpin", icon: "+", label: "Add Pin" }] : []), + ...(!isGroup ? [{ action: "savelb", icon: "⬆", label: "Save to Library" }] : []), "---", - { action: "del", icon: "✕", label: "Delete", danger: true }, + { action: "front", icon: "▲", label: "Bring to Front" }, + { action: "back", icon: "▼", label: "Send to Back" }, + "---", + isLocked + ? { action: "unlock", icon: "🔓", label: "Unlock Position" } + : { action: "lock", icon: "🔒", label: "Lock Position" }, + "---", + { action: "del", icon: "✕", label: "Delete", danger: true }, ]; + this._openCtx(x, y, items); - this._ctxMenu().onclick = (e) => { + this._ctxMenu().onclick = async (e) => { const action = e.target.closest(".ctx-item")?.dataset.action; this._closeCtx(); if (!action) return; - if (action === "dup") this.duplicateSelected(); - if (action === "del") this.deleteSelected(); - if (action === "zoom") this.canvas.zoomToSelection(); - if (action === "addpin" && d) this._addDevicePin(d); - if (action === "savelb" && d) this._saveDeviceAsConnector(d); + if (action === "dup") this.duplicateSelected(); + if (action === "del") this.deleteSelected(); + if (action === "zoom") this.canvas.zoomToSelection(); + if (action === "addpin" && d) this._addDevicePin(d); + if (action === "savelb" && d) this._saveDeviceAsConnector(d); + if (action === "front") { this.canvas.bringToFront(id); const dd = this.canvas.deviceData.get(id); if (dd) { dd.properties = { ...(dd.properties||{}), zOrder: Date.now() }; api.devices.update(id, { properties: dd.properties }).catch(console.error); } } + if (action === "back") { this.canvas.sendToBack(id); const dd = this.canvas.deviceData.get(id); if (dd) { dd.properties = { ...(dd.properties||{}), zOrder: -Date.now() }; api.devices.update(id, { properties: dd.properties }).catch(console.error); } } + if (action === "lock") { this.canvas.setDeviceLocked(id, true); const dd = this.canvas.deviceData.get(id); if (dd) await api.devices.update(id, { properties: dd.properties }).catch(console.error); } + if (action === "unlock") { this.canvas.setDeviceLocked(id, false); const dd = this.canvas.deviceData.get(id); if (dd) await api.devices.update(id, { properties: dd.properties }).catch(console.error); } + if (action === "align-left") this._alignDevices("left"); + if (action === "align-right") this._alignDevices("right"); + if (action === "align-top") this._alignDevices("top"); + if (action === "align-bottom") this._alignDevices("bottom"); + if (action === "dist-h") this._distributeDevices("h"); + if (action === "dist-v") this._distributeDevices("v"); }; } @@ -466,13 +555,17 @@ class WiringApp { const wire = this.canvas.wireData.get(id); const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0; const items = [ + { action: "copystyle", icon: "⎘", label: "Copy Style" }, + ...(this._copiedWireStyle ? [{ action: "pastestyle", icon: "⎗", label: "Paste Style" }] : []), + ...(wire?.label ? [{ action: "selectnet", icon: "⋈", label: `Select net "${wire.label}"` }] : []), + "---", { action: "straighten", icon: "⤢", label: "Straighten wire" }, ...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []), "---", { action: "del", icon: "✕", label: "Delete wire", danger: true }, ]; this._openCtx(x, y, items); - this._ctxMenu().onclick = (e) => { + this._ctxMenu().onclick = async (e) => { const action = e.target.closest(".ctx-item")?.dataset.action; this._closeCtx(); if (!action) return; @@ -483,6 +576,27 @@ class WiringApp { api.wires.update(id, { waypoints: [] }).catch(console.error); this._flashSaved(); } + if (action === "copystyle" && wire) { + this._copiedWireStyle = { + color_primary: wire.color_primary, + color_stripe: wire.color_stripe, + gauge: wire.gauge, + twisted_pair: wire.twisted_pair, + twist_pitch: wire.twist_pitch, + shielded: wire.shielded, + show_size_label: wire.show_size_label, + }; + } + if (action === "pastestyle" && wire && this._copiedWireStyle) { + Object.assign(wire, this._copiedWireStyle); + this.canvas.updateWire(wire); + await api.wires.update(id, this._copiedWireStyle).catch(console.error); + if (this.canvas.selectedId === id) this._showWireProps(wire); + this._flashSaved(); + } + if (action === "selectnet" && wire?.label) { + this.canvas.highlightNet(wire.label); + } }; } @@ -701,9 +815,19 @@ class WiringApp { async _patchDevice(data, redraw = false) { if (this.canvas.selectedType !== "device") return; const id = this.canvas.selectedId; + const d = this.canvas.deviceData.get(id); + const oldData = d ? Object.fromEntries(Object.keys(data).map(k => [k, d[k]])) : {}; + if (d) Object.assign(d, data); await api.devices.update(id, data).catch(console.error); this._flashSaved(); - if (redraw) { const d = this.canvas.deviceData.get(id); if (d) { Object.assign(d, data); this.canvas.updateDevice(d); } } + if (redraw && d) this.canvas.updateDevice(d); + this._pushUndo(async () => { + const cur = this.canvas.deviceData.get(id); + if (cur) Object.assign(cur, oldData); + await api.devices.update(id, oldData).catch(console.error); + if (redraw && cur) this.canvas.updateDevice(cur); + this._flashSaved(); + }); } async _patchDeviceProp(key, value, redraw = false) { @@ -711,16 +835,25 @@ class WiringApp { const id = this.canvas.selectedId; const d = this.canvas.deviceData.get(id); if (!d) return; - d.properties = { ...(d.properties || {}), [key]: value }; + const oldProps = { ...(d.properties || {}) }; + d.properties = { ...oldProps, [key]: value }; await api.devices.update(id, { properties: d.properties }).catch(console.error); this._flashSaved(); if (redraw) { this.canvas.updateDevice(d); this.canvas.selectDevice(id); } + this._pushUndo(async () => { + const cur = this.canvas.deviceData.get(id); + if (cur) { cur.properties = oldProps; } + await api.devices.update(id, { properties: oldProps }).catch(console.error); + if (redraw) { if (cur) this.canvas.updateDevice(cur); this.canvas.selectDevice(id); } + this._flashSaved(); + }); } async _patchWire(data, redraw = false) { if (this.canvas.selectedType !== "wire") return; const id = this.canvas.selectedId; const w = this.canvas.wireData.get(id); + const oldData = w ? Object.fromEntries(Object.keys(data).map(k => [k, w[k]])) : {}; if (w) { Object.assign(w, data); if (redraw) this.canvas.updateWire(w); @@ -728,6 +861,12 @@ class WiringApp { try { await api.wires.update(id, data); this._flashSaved(); + this._pushUndo(async () => { + const cur = this.canvas.wireData.get(id); + if (cur) { Object.assign(cur, oldData); this.canvas.updateWire(cur); } + await api.wires.update(id, oldData).catch(console.error); + this._flashSaved(); + }); } catch (e) { console.error("Wire save failed:", e); this._flashError(); @@ -996,6 +1135,7 @@ class WiringApp { async _cloneDevices(sources, offset) { this.canvas.clearSelection(); + const clonedIds = []; for (const source of sources) { const def = { diagram_id: this.diagramId, @@ -1013,9 +1153,19 @@ class WiringApp { const device = await api.devices.create(def); this.canvas.addDevice(device); this.canvas.addToSelection(device.id); + clonedIds.push(device.id); } catch (e) { console.error("Clone device failed:", e); } } this._flashSaved(); + this._pushUndo(async () => { + this.canvas.clearSelection(); + this._clearProps(); + for (const cid of clonedIds) { + this.canvas.removeDevice(cid); + await api.devices.delete(cid).catch(console.error); + } + this._flashSaved(); + }); if (this.canvas.selectedDeviceIds.size === 1) { const [id] = this.canvas.selectedDeviceIds; this.canvas.selectedId = id; diff --git a/frontend/js/canvas.js b/frontend/js/canvas.js index c74cfc3..6991c59 100644 --- a/frontend/js/canvas.js +++ b/frontend/js/canvas.js @@ -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(); + } }