diff --git a/frontend/src/components/panels/RackToolbarActions.tsx b/frontend/src/components/panels/RackToolbarActions.tsx index 4bcb1f4..a3cb6a8 100644 --- a/frontend/src/components/panels/RackToolbarActions.tsx +++ b/frontend/src/components/panels/RackToolbarActions.tsx @@ -6,7 +6,7 @@ * much of the cabling is on screen. */ import { useState } from 'react' -import { Cable, Link2, Plus, X } from 'lucide-react' +import { Cable, Link2, Plus, Trash2, X } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { useRackStore } from '@/rack/store' @@ -34,6 +34,8 @@ export function RackToolbarActions() { const setCableTypeFilter = useRackStore((s) => s.setCableTypeFilter) const cableDraft = useRackStore((s) => s.cableDraft) const cancelCableDraft = useRackStore((s) => s.cancelCableDraft) + const selectedCableId = useRackStore((s) => s.selectedCableId) + const removeSelectedCable = useRackStore((s) => s.removeSelectedCable) const importCables = useRackStore((s) => s.importCablesFromNetwork) const networkImportDone = useRackStore((s) => s.networkImportDone) const designs = useDesignStore((s) => s.designs) @@ -65,7 +67,7 @@ export function RackToolbarActions() { variant="ghost" className={`${ghost} ${cableMode ? 'text-[#00d4ff]' : ''}`} onClick={toggleCableMode} - title="Click a port, then another, to patch. Click a cable to remove it." + title="Drag from one port to another to patch, or click both in turn. Click a cable to select it, then Delete to unplug." > {cableMode ? 'Exit patching' : 'Patch'} @@ -81,6 +83,18 @@ export function RackToolbarActions() { )} + {cableMode && selectedCableId && ( + + )} + + , + ) + fireEvent.keyDown(screen.getByTestId('typing'), { key: 'Delete' }) + expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(true) + }) + + it('drops a half-drawn patch on Escape', () => { + const store = useRackStore.getState() + store.loadDemo() + store.toggleCableMode() + const device = useRackStore.getState().devices[0] + useRackStore.getState().startCableDrag(device.id, device.ports[0].id) + render() + fireEvent.keyDown(window, { key: 'Escape' }) + expect(useRackStore.getState().cableDraft).toBeNull() + }) + + it('does nothing on Delete with no cable selected', () => { + useRackStore.getState().loadDemo() + const before = useRackStore.getState().cables.length + render() + fireEvent.keyDown(window, { key: 'Delete' }) + expect(useRackStore.getState().cables).toHaveLength(before) + }) +}) + +describe('RackCanvas cable dragging', () => { + const armPort = () => { + const store = useRackStore.getState() + store.loadDemo() + store.toggleCableMode() + const device = useRackStore.getState().devices[0] + useRackStore.getState().startCableDrag(device.id, device.ports[0].id) + } + + it('tracks the pointer while a patch is being dragged', () => { + armPort() + render() + fireEvent.pointerMove(window, { clientX: 64, clientY: 128 }) + // The mocked screenToFlowPosition is the identity. + expect(useRackStore.getState().cableDrag).toEqual({ + pointer: { x: 64, y: 128 }, + moved: true, + }) + }) + + it('cancels the draft when the drag is released on nothing', () => { + armPort() + render() + fireEvent.pointerMove(window, { clientX: 64, clientY: 128 }) + fireEvent.pointerUp(window) + expect(useRackStore.getState().cableDraft).toBeNull() + expect(useRackStore.getState().cableDrag).toBeNull() + }) + + it('keeps the port armed when the press never moved', () => { + armPort() + const armed = useRackStore.getState().cableDraft + render() + fireEvent.pointerUp(window) + expect(useRackStore.getState().cableDraft).toEqual(armed) + expect(useRackStore.getState().cableDrag).toBeNull() + }) +}) diff --git a/frontend/src/rack/__tests__/RackFlowNode.test.tsx b/frontend/src/rack/__tests__/RackFlowNode.test.tsx index a743ed6..a2eb976 100644 --- a/frontend/src/rack/__tests__/RackFlowNode.test.tsx +++ b/frontend/src/rack/__tests__/RackFlowNode.test.tsx @@ -3,7 +3,7 @@ * its LED from the Device Inventory's live `node_status`. */ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import { RackFlowNode } from '../components/RackFlowNode' import { useRackStore } from '../store' import { rackPalette } from '../rackTheme' @@ -73,3 +73,66 @@ describe('RackFlowNode status', () => { expect(ledFill(device.label)).toBe(palette().status.online) }) }) + +describe('RackFlowNode patching', () => { + /** Two mounted ports in this rack that nothing is plugged into yet. */ + function freePorts() { + const patched = new Set( + store().cables.flatMap((c) => [c.from.portId, c.to.portId]), + ) + const found = store() + .devices.filter((d) => d.rackId === store().racks[0].id) + .flatMap((d) => d.ports.filter((p) => !patched.has(p.id)).map((p) => ({ device: d, port: p }))) + return { a: found[0], b: found[found.length - 1] } + } + + /** The port's group, found through the the plate gives it. */ + function portGroup(label: string, portLabel: string, portType: string) { + const plate = screen.getByTitle(new RegExp(`^${label} ·`)) + const title = Array.from(plate.querySelectorAll('title')).find( + (t) => t.textContent === `${portLabel} · ${portType}`, + ) + if (!title) throw new Error(`no port ${portLabel} on ${label}`) + return title.parentElement! + } + + it('ignores a press on a port outside patch mode', () => { + renderRack() + const { a } = freePorts() + fireEvent.pointerDown(portGroup(a.device.label, a.port.label, a.port.type)) + expect(store().cableDraft).toBeNull() + expect(store().cableDrag).toBeNull() + }) + + it('patches a cable dragged from one port to another', () => { + store().toggleCableMode() + renderRack() + const { a, b } = freePorts() + const before = store().cables.length + + fireEvent.pointerDown(portGroup(a.device.label, a.port.label, a.port.type)) + expect(store().cableDraft).toEqual({ deviceId: a.device.id, portId: a.port.id }) + + fireEvent.pointerUp(portGroup(b.device.label, b.port.label, b.port.type)) + expect(store().cables).toHaveLength(before + 1) + const created = store().cables[store().cables.length - 1] + expect(created.from.portId).toBe(a.port.id) + expect(created.to.portId).toBe(b.port.id) + }) + + it('still patches with two separate clicks', () => { + store().toggleCableMode() + renderRack() + const { a, b } = freePorts() + const before = store().cables.length + + const portA = portGroup(a.device.label, a.port.label, a.port.type) + fireEvent.pointerDown(portA) + fireEvent.pointerUp(portA) + expect(store().cableDraft).toEqual({ deviceId: a.device.id, portId: a.port.id }) + expect(store().cables).toHaveLength(before) + + fireEvent.pointerDown(portGroup(b.device.label, b.port.label, b.port.type)) + expect(store().cables).toHaveLength(before + 1) + }) +}) diff --git a/frontend/src/rack/__tests__/store.test.ts b/frontend/src/rack/__tests__/store.test.ts index d8626f6..a25634b 100644 --- a/frontend/src/rack/__tests__/store.test.ts +++ b/frontend/src/rack/__tests__/store.test.ts @@ -273,6 +273,120 @@ describe('cables', () => { expect(store().cables).toHaveLength(before + 1) }) + it('patches by dragging from one port to another', () => { + const { a, b } = freePorts() + const before = store().cables.length + store().startCableDrag(a.deviceId, a.portId) + expect(store().cableDraft).toEqual(a) + store().moveCableDrag({ x: 120, y: 80 }) + expect(store().cableDrag).toEqual({ pointer: { x: 120, y: 80 }, moved: true }) + store().endCableDrag(b) + expect(store().cables).toHaveLength(before + 1) + expect(store().cableDraft).toBeNull() + expect(store().cableDrag).toBeNull() + }) + + it('drops the draft when a drag is released on nothing', () => { + const { a } = freePorts() + const before = store().cables.length + store().startCableDrag(a.deviceId, a.portId) + store().moveCableDrag({ x: 10, y: 10 }) + store().endCableDrag(null) + expect(store().cables).toHaveLength(before) + expect(store().cableDraft).toBeNull() + expect(store().cableDrag).toBeNull() + }) + + it('keeps the port armed when a press never moved, so click-then-click works', () => { + const { a, b } = freePorts() + const before = store().cables.length + store().startCableDrag(a.deviceId, a.portId) + store().endCableDrag(null) + expect(store().cableDraft).toEqual(a) + expect(store().cableDrag).toBeNull() + + store().startCableDrag(b.deviceId, b.portId) + expect(store().cables).toHaveLength(before + 1) + expect(store().cableDraft).toBeNull() + }) + + it('disarms when the armed port is pressed again', () => { + const { a } = freePorts() + const before = store().cables.length + store().startCableDrag(a.deviceId, a.portId) + store().startCableDrag(a.deviceId, a.portId) + expect(store().cableDraft).toBeNull() + expect(store().cables).toHaveLength(before) + }) + + it('ignores a release with no drag in flight', () => { + const { a, b } = freePorts() + const before = store().cables.length + store().endCableDrag(b) + expect(store().cables).toHaveLength(before) + expect(store().cableDraft).toBeNull() + // And a move without a drag changes nothing. + store().moveCableDrag({ x: 1, y: 1 }) + expect(store().cableDrag).toBeNull() + expect(a).toBeTruthy() + }) + + it('cancels a draft and its drag together', () => { + const { a } = freePorts() + store().startCableDrag(a.deviceId, a.portId) + store().moveCableDrag({ x: 5, y: 5 }) + store().cancelCableDraft() + expect(store().cableDraft).toBeNull() + expect(store().cableDrag).toBeNull() + }) + + it('selects a cable and unplugs it with removeSelectedCable', () => { + const cable = store().cables[0] + store().selectCable(cable.id) + expect(store().selectedCableId).toBe(cable.id) + store().removeSelectedCable() + expect(store().cables.some((c) => c.id === cable.id)).toBe(false) + expect(store().selectedCableId).toBeNull() + }) + + it('removeSelectedCable is a no-op with nothing selected', () => { + const before = store().cables.length + store().removeSelectedCable() + expect(store().cables).toHaveLength(before) + }) + + it('clears the cable selection when the cable is removed by id', () => { + const cable = store().cables[0] + store().selectCable(cable.id) + store().removeCable(cable.id) + expect(store().selectedCableId).toBeNull() + }) + + it('keeps the cable selection when another cable is removed', () => { + const [first, second] = store().cables + store().selectCable(first.id) + store().removeCable(second.id) + expect(store().selectedCableId).toBe(first.id) + }) + + it('drops the cable selection when a device or rack is selected', () => { + store().selectCable(store().cables[0].id) + store().selectDevice(store().devices[0].id) + expect(store().selectedCableId).toBeNull() + + store().selectCable(store().cables[0].id) + store().selectRack('rack-main') + expect(store().selectedCableId).toBeNull() + }) + + it('drops the cable selection when leaving patch mode', () => { + store().toggleCableMode() + store().selectCable(store().cables[0].id) + store().toggleCableMode() + expect(store().cableMode).toBe(false) + expect(store().selectedCableId).toBeNull() + }) + it('turns cables on when entering patch mode', () => { expect(store().cableVisibility).toBe('hover') store().toggleCableMode() @@ -280,6 +394,27 @@ describe('cables', () => { expect(store().cableVisibility).toBe('always') }) + it('restores the previous cable visibility when leaving patch mode', () => { + store().toggleCableMode() + store().toggleCableMode() + expect(store().cableMode).toBe(false) + expect(store().cableVisibility).toBe('hover') + }) + + it('keeps a visibility the user picked while patching', () => { + store().toggleCableMode() + store().setCableVisibility('hidden') + store().toggleCableMode() + expect(store().cableVisibility).toBe('hidden') + }) + + it('leaves an always-on canvas alone across patch mode', () => { + store().setCableVisibility('always') + store().toggleCableMode() + store().toggleCableMode() + expect(store().cableVisibility).toBe('always') + }) + it('imports links from the network canvas once', () => { // Mount the inventory item the hints reference; the other end is racked already. store().mountFromInventory('inv-sw8', 'rack-main', { uStart: 4 }) diff --git a/frontend/src/rack/components/CableLayer.tsx b/frontend/src/rack/components/CableLayer.tsx index 1d61483..1d766e5 100644 --- a/frontend/src/rack/components/CableLayer.tsx +++ b/frontend/src/rack/components/CableLayer.tsx @@ -7,6 +7,7 @@ */ import { ViewportPortal } from '@xyflow/react' import { portPosition } from '../layout' +import { useRackPalette } from '../rackTheme' import { useRackStore } from '../store' import type { Cable, Port, Rack, RackDevice } from '@/types' @@ -32,7 +33,11 @@ export function CableLayer() { const hoveredDeviceId = useRackStore((s) => s.hoveredDeviceId) const selectedDeviceId = useRackStore((s) => s.selectedDeviceId) const cableMode = useRackStore((s) => s.cableMode) - const removeCable = useRackStore((s) => s.removeCable) + const selectedCableId = useRackStore((s) => s.selectedCableId) + const selectCable = useRackStore((s) => s.selectCable) + const cableDraft = useRackStore((s) => s.cableDraft) + const cableDrag = useRackStore((s) => s.cableDrag) + const palette = useRackPalette() if (visibility === 'hidden' && !cableMode) return null @@ -63,7 +68,12 @@ export function CableLayer() { visible.push({ cable, from, to }) } - if (visible.length === 0) return null + // Rubber band: the patch being dragged out of its source port. + const dragFrom = cableDraft && resolve(cableDraft.deviceId, cableDraft.portId) + const dragTo = cableDrag?.pointer ?? null + const draftLine = dragFrom && dragTo ? { from: dragFrom, to: dragTo } : null + + if (visible.length === 0 && !draftLine) return null return ( <ViewportPortal> @@ -80,35 +90,86 @@ export function CableLayer() { }} > {visible.map(({ cable, from, to }) => { - const dimmed = focus != null && cable.from.deviceId !== focus && cable.to.deviceId !== focus + const selected = cableMode && cable.id === selectedCableId + const dimmed = + !selected && focus != null && cable.from.deviceId !== focus && cable.to.deviceId !== focus + const d = cablePath(from, to) return ( <g key={cable.id} opacity={dimmed ? 0.28 : 1}> + {selected && ( + <path + d={d} + fill="none" + stroke={palette.accent} + strokeWidth={7} + strokeLinecap="round" + opacity={0.45} + style={{ pointerEvents: 'none' }} + /> + )} <path - d={cablePath(from, to)} + d={d} fill="none" stroke="#0d1117" strokeWidth={3.5} strokeLinecap="round" /> <path - d={cablePath(from, to)} + d={d} fill="none" - stroke={cable.color} - strokeWidth={1.8} + stroke={selected ? palette.accent : cable.color} + strokeWidth={selected ? 2.6 : 1.8} strokeLinecap="round" - style={{ pointerEvents: cableMode ? 'stroke' : 'none', cursor: cableMode ? 'pointer' : undefined }} - onClick={() => cableMode && removeCable(cable.id)} - > - <title> - {cable.label ? `${cable.label} — ${cable.type}` : cable.type} - {cableMode ? ' (click to remove)' : ''} - - - - + style={{ pointerEvents: 'none' }} + /> + {/* Fat invisible hit area — a 1.8px path is hard to hit at low zoom. */} + {cableMode && ( + { + // The pane click behind would clear the selection again. + e.stopPropagation() + selectCable(selected ? null : cable.id) + }} + > + + {cable.label ? `${cable.label} — ${cable.type}` : cable.type} + {' (click to select, Delete to remove)'} + + + )} + + ) })} + + {draftLine && ( + + + + + + )} ) diff --git a/frontend/src/rack/components/Faceplate.tsx b/frontend/src/rack/components/Faceplate.tsx index c303b33..43b6ffa 100644 --- a/frontend/src/rack/components/Faceplate.tsx +++ b/frontend/src/rack/components/Faceplate.tsx @@ -7,6 +7,7 @@ */ import { memo, useId } from 'react' import { getFaceplate } from '../faceplates' +import { U_PX } from '../layout' import { useRackPalette } from '../rackTheme' import type { DeviceStatus, FaceplateElement, Port, PortType } from '@/types' @@ -18,13 +19,17 @@ interface PortShapeSize { h: number } -/** Fixed-ish port artwork, nudged by plate height so 1U panels stay legible. */ -function portShapeSize(size: 'sm' | 'md', height: number, type: PortType): PortShapeSize { - const w = - size === 'sm' - ? Math.max(5, Math.min(8, height * 0.3)) - : Math.max(7, Math.min(12, height * 0.42)) - return type === 'rj45' ? { w, h: w * 0.92 } : { w: w * 1.15, h: w * 0.62 } +/** + * Port artwork is the same size on every plate — a socket is a socket, whether + * it sits on a 1U switch or a 4U NAS. The reference is a 1U patch panel, the + * densest thing that has to stay legible. + */ +const PORT_W = U_PX * 0.3 + +function portShapeSize(type: PortType): PortShapeSize { + return type === 'rj45' + ? { w: PORT_W, h: PORT_W * 0.92 } + : { w: PORT_W * 1.15, h: PORT_W * 0.62 } } /** RJ45 silhouette: body with the latch slot cut into the top, centred on (0,0). */ @@ -160,12 +165,14 @@ interface Props { revealed?: boolean /** Fade the plate so cables behind it stay readable. */ transparent?: boolean - /** Ports are clickable (patch mode). */ + /** Ports take the pointer (patch mode). */ interactivePorts?: boolean /** Port id -> colour of the cable plugged into it. */ patchedPorts?: Map draftPortId?: string | null - onPortClick?: (portId: string) => void + /** Press arms the port; release over another one closes the patch. */ + onPortPointerDown?: (portId: string) => void + onPortPointerUp?: (portId: string) => void } export const Faceplate = memo(function Faceplate({ @@ -182,7 +189,8 @@ export const Faceplate = memo(function Faceplate({ interactivePorts, patchedPorts, draftPortId, - onPortClick, + onPortPointerDown, + onPortPointerUp, }: Props) { const template = getFaceplate(faceplateId) const palette = useRackPalette() @@ -253,7 +261,7 @@ export const Faceplate = memo(function Faceplate({ const cy = port.y * height const patchColor = patchedPorts?.get(port.id) const isDraft = draftPortId === port.id - const { w, h } = portShapeSize(template.portSize ?? 'md', height, port.type) + const { w, h } = portShapeSize(port.type) const stroke = isDraft ? palette.accent : patchColor ?? palette.portBezel return ( @@ -264,15 +272,28 @@ export const Faceplate = memo(function Faceplate({ cursor: interactivePorts ? 'crosshair' : undefined, pointerEvents: interactivePorts ? 'auto' : 'none', }} - onClick={ + onPointerDown={ + interactivePorts + ? (e) => { + // Keep the press off the plate: it would start an HTML5 + // drag of the mount instead of a cable. + e.stopPropagation() + e.preventDefault() + onPortPointerDown?.(port.id) + } + : undefined + } + onPointerUp={ interactivePorts ? (e) => { e.stopPropagation() - onPortClick?.(port.id) + onPortPointerUp?.(port.id) } : undefined } > + {/* Grab area: a 6px socket is too small to aim a cable at. */} + {interactivePorts && } {port.type === 'rj45' ? ( s.setViewport) const addRack = useRackStore((s) => s.addRack) const loadDemo = useRackStore((s) => s.loadDemo) + const selectCable = useRackStore((s) => s.selectCable) + const removeSelectedCable = useRackStore((s) => s.removeSelectedCable) + const selectedCableId = useRackStore((s) => s.selectedCableId) + const dragging = useRackStore((s) => s.cableDrag != null) + const moveCableDrag = useRackStore((s) => s.moveCableDrag) + const endCableDrag = useRackStore((s) => s.endCableDrag) + const cableDraft = useRackStore((s) => s.cableDraft) + const cancelCableDraft = useRackStore((s) => s.cancelCableDraft) const palette = useRackPalette() - const { setViewport: applyViewport } = useReactFlow() + const { setViewport: applyViewport, screenToFlowPosition } = useReactFlow() // Mounts set to "check device" read their LED from the inventory's // `node_status`, which only a refetch moves. @@ -55,6 +63,44 @@ export function RackCanvas() { if (storedViewport.zoom > 0) void applyViewport(storedViewport) }, [designId, loading, storedViewport, applyViewport]) + // A patch dragged out of a port follows the pointer until it is released. + // Both listeners are on the window: the drop target is often a port on + // another rack, and a release outside the canvas must still end the drag. + useEffect(() => { + if (!dragging) return + const move = (e: PointerEvent) => + moveCableDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY })) + // The port's own pointerup runs first and clears the drag, so reaching here + // means the cable was released on nothing. + const up = () => endCableDrag(null) + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + return () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + } + }, [dragging, moveCableDrag, endCableDrag, screenToFlowPosition]) + + // Delete/Backspace unplugs the selected cable; Escape deselects it, or drops + // a half-drawn patch. + useEffect(() => { + if (!selectedCableId && !cableDraft) return + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null + const tag = target?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return + if (selectedCableId && (e.key === 'Delete' || e.key === 'Backspace')) { + e.preventDefault() + removeSelectedCable() + } else if (e.key === 'Escape') { + selectCable(null) + cancelCableDraft() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [selectedCableId, cableDraft, removeSelectedCable, selectCable, cancelCableDraft]) + const nodes: Node[] = useMemo( () => racks.map((rack) => ({ diff --git a/frontend/src/rack/components/RackFlowNode.tsx b/frontend/src/rack/components/RackFlowNode.tsx index 1d359d4..96dbd64 100644 --- a/frontend/src/rack/components/RackFlowNode.tsx +++ b/frontend/src/rack/components/RackFlowNode.tsx @@ -50,7 +50,8 @@ export function RackFlowNode({ id }: NodeProps) { const selectDevice = useRackStore((s) => s.selectDevice) const selectRack = useRackStore((s) => s.selectRack) const hoverDevice = useRackStore((s) => s.hoverDevice) - const pickPort = useRackStore((s) => s.pickPort) + const startCableDrag = useRackStore((s) => s.startCableDrag) + const endCableDrag = useRackStore((s) => s.endCableDrag) const openDeviceEditor = useRackStore((s) => s.openDeviceEditor) const openRackEditor = useRackStore((s) => s.openRackEditor) const mountFromInventory = useRackStore((s) => s.mountFromInventory) @@ -302,7 +303,8 @@ export function RackFlowNode({ id }: NodeProps) { interactivePorts={cableMode} patchedPorts={patchedPorts} draftPortId={isDraftDevice ? cableDraft.portId : null} - onPortClick={(portId) => pickPort(device.id, portId)} + onPortPointerDown={(portId) => startCableDrag(device.id, portId)} + onPortPointerUp={(portId) => endCableDrag({ deviceId: device.id, portId })} /> ) diff --git a/frontend/src/rack/faceplates.ts b/frontend/src/rack/faceplates.ts index d4f5710..414eea6 100644 --- a/frontend/src/rack/faceplates.ts +++ b/frontend/src/rack/faceplates.ts @@ -121,7 +121,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS, statusLed: true, labelBox: LABEL_LEFT, - portSize: 'md', elements: [ { kind: 'panel', fill: STEEL, stroke: '#0d1117' }, { kind: 'vents', x: 0.32, y: 0.22, w: 0.42, h: 0.56, cols: 16, rows: 2, fill: VENT }, @@ -137,7 +136,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS, statusLed: true, labelBox: LABEL_LEFT, - portSize: 'md', elements: [ { kind: 'panel', fill: STEEL, stroke: '#0d1117' }, { kind: 'bays', x: 0.32, y: 0.18, w: 0.42, h: 0.64, cols: 4, rows: 1, fill: BLACK_BOX }, @@ -153,7 +151,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS, statusLed: true, labelBox: LABEL_LEFT, - portSize: 'md', elements: [ { kind: 'panel', fill: STEEL, stroke: '#0d1117' }, { kind: 'bays', x: 0.32, y: 0.12, w: 0.42, h: 0.76, cols: 4, rows: 2, fill: BLACK_BOX }, @@ -172,7 +169,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS, statusLed: true, labelBox: { x: 0.055, w: 0.18 }, - portSize: 'md', elements: [ { kind: 'panel', fill: STEEL_LIGHT, stroke: '#0d1117' }, { kind: 'bays', x: 0.26, y: 0.1, w: 0.5, h: 0.8, cols: 4, rows: 3, fill: BLACK_BOX }, @@ -188,7 +184,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS / 2, statusLed: true, labelBox: LABEL_SMALL, - portSize: 'md', elements: [ { kind: 'panel', fill: STEEL_LIGHT, stroke: '#0d1117' }, { kind: 'vents', x: 0.54, y: 0.25, w: 0.24, h: 0.5, cols: 8, rows: 2, fill: VENT }, @@ -204,7 +199,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS / 3, statusLed: true, labelBox: { x: 0.14, w: 0.42 }, - portSize: 'md', elements: [ { kind: 'panel', fill: STEEL_LIGHT, stroke: '#0d1117' }, { kind: 'vents', x: 0.6, y: 0.3, w: 0.16, h: 0.4, cols: 5, rows: 2, fill: VENT }, @@ -223,7 +217,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ statusLed: true, alwaysShowPorts: true, labelBox: LABEL_LEFT, - portSize: 'md', elements: [ { kind: 'panel', fill: BLACK_BOX, stroke: '#0d1117' }, { kind: 'strip', x: 0.32, y: 0.16, w: 0.42, h: 0.68, fill: '#11161d' }, @@ -243,7 +236,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ statusLed: true, alwaysShowPorts: true, labelBox: LABEL_TIGHT, - portSize: 'sm', elements: [ { kind: 'panel', fill: BLACK_BOX, stroke: '#0d1117' }, { kind: 'strip', x: 0.22, y: 0.12, w: 0.55, h: 0.76, fill: '#11161d' }, @@ -263,7 +255,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ statusLed: true, alwaysShowPorts: true, labelBox: LABEL_TIGHT, - portSize: 'sm', elements: [ { kind: 'panel', fill: BLACK_BOX, stroke: '#0d1117' }, { kind: 'strip', x: 0.21, y: 0.12, w: 0.56, h: 0.76, fill: '#11161d' }, @@ -282,7 +273,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS, statusLed: true, labelBox: LABEL_LEFT, - portSize: 'md', elements: [ { kind: 'panel', fill: '#232a34', stroke: '#0d1117' }, { kind: 'strip', x: 0.34, y: 0.16, w: 0.4, h: 0.68, fill: '#11161d' }, @@ -301,7 +291,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS, alwaysShowPorts: true, labelBox: { x: 0.02, w: 0.16 }, - portSize: 'sm', elements: [ { kind: 'panel', fill: '#3a3f47', stroke: '#0d1117' }, { kind: 'strip', x: 0.2, y: 0.14, w: 0.78, h: 0.72, fill: '#22262c' }, @@ -317,7 +306,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS, alwaysShowPorts: true, labelBox: { x: 0.02, w: 0.16 }, - portSize: 'sm', elements: [ { kind: 'panel', fill: '#3a3f47', stroke: '#0d1117' }, { kind: 'strip', x: 0.2, y: 0.16, w: 0.6, h: 0.68, fill: '#22262c' }, @@ -335,7 +323,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS, statusLed: true, labelBox: LABEL_LEFT, - portSize: 'md', elements: [ { kind: 'panel', fill: '#262c35', stroke: '#0d1117' }, { kind: 'bays', x: 0.32, y: 0.12, w: 0.42, h: 0.76, cols: 4, rows: 2, fill: BLACK_BOX }, @@ -359,7 +346,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS / 6, statusLed: true, labelBox: { x: 0.26, w: 0.4, y: NAS_BAND }, - portSize: 'md', elements: [ { kind: 'panel', fill: '#262c35', stroke: '#0d1117' }, nasBays(2, RACK_COLUMNS / 6), @@ -375,7 +361,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS / 3, statusLed: true, labelBox: { x: 0.13, w: 0.42, y: NAS_BAND }, - portSize: 'md', elements: [ { kind: 'panel', fill: '#262c35', stroke: '#0d1117' }, nasBays(4), @@ -391,7 +376,6 @@ export const FACEPLATES: FaceplateTemplate[] = [ colSpan: RACK_COLUMNS / 3, statusLed: true, labelBox: { x: 0.13, w: 0.42, y: NAS_BAND }, - portSize: 'md', elements: [ { kind: 'panel', fill: '#262c35', stroke: '#0d1117' }, nasBays(5), diff --git a/frontend/src/rack/store.ts b/frontend/src/rack/store.ts index 26d4646..addfd0e 100644 --- a/frontend/src/rack/store.ts +++ b/frontend/src/rack/store.ts @@ -93,11 +93,25 @@ interface RackState { // UI selectedDeviceId: string | null selectedRackId: string | null + /** Cable picked in patch mode, so Delete/Backspace can unplug it. */ + selectedCableId: string | null hoveredDeviceId: string | null cableVisibility: CableVisibility - /** Port-picking mode: click a port, then another, to create a cable. */ + /** + * What the visibility was before patch mode forced cables on, so leaving + * patch mode puts the canvas back the way the user had it. Cleared as soon + * as the user picks a visibility by hand — their choice outlives the mode. + */ + cableVisibilityBeforePatch: CableVisibility | null + /** Port-picking mode: drag port to port, or click one then another. */ cableMode: boolean cableDraft: CableDraft | null + /** + * A patch being dragged out of `cableDraft`'s port. `pointer` is in flow + * coordinates, so the rubber band lives in the same space as the cables. + * `moved` separates a drag from a plain click on the same port. + */ + cableDrag: { pointer: { x: number; y: number } | null; moved: boolean } | null cableTypeFilter: CableType | 'all' /** * Open editor dialogs. Device and rack settings live in modals, so any @@ -170,11 +184,22 @@ interface RackState { setViewport: (v: Viewport) => void selectDevice: (id: string | null) => void selectRack: (id: string | null) => void + selectCable: (id: string | null) => void + /** Unplug whatever is selected. No-op when nothing is. */ + removeSelectedCable: () => void hoverDevice: (id: string | null) => void setCableVisibility: (v: CableVisibility) => void setCableTypeFilter: (t: CableType | 'all') => void toggleCableMode: () => void pickPort: (deviceId: string, portId: string) => void + /** + * Press on a port: arms it as the cable source, or — when another port is + * already armed — closes the patch, so click-then-click still works. + */ + startCableDrag: (deviceId: string, portId: string) => void + moveCableDrag: (pointer: { x: number; y: number }) => void + /** Release: patch onto `target`, or drop the draft if the pointer travelled. */ + endCableDrag: (target: CableDraft | null) => void cancelCableDraft: () => void openDeviceEditor: (deviceId?: string | null) => void closeDeviceEditor: () => void @@ -203,10 +228,13 @@ function emptyState() { editSeq: 0, selectedDeviceId: null, selectedRackId: null, + selectedCableId: null, hoveredDeviceId: null, cableVisibility: 'hover' as CableVisibility, + cableVisibilityBeforePatch: null, cableMode: false, cableDraft: null, + cableDrag: null, cableTypeFilter: 'all' as const, networkImportDone: false, deviceEditor: null, @@ -615,7 +643,11 @@ export const useRackStore = create((set, get) => { updateCable: (id, patch) => edit((s) => ({ cables: s.cables.map((c) => (c.id === id ? { ...c, ...patch } : c)) })), - removeCable: (id) => edit((s) => ({ cables: s.cables.filter((c) => c.id !== id) })), + removeCable: (id) => + edit((s) => ({ + cables: s.cables.filter((c) => c.id !== id), + selectedCableId: s.selectedCableId === id ? null : s.selectedCableId, + })), importCablesFromNetwork: (hints) => { const { devices, networkImportDone } = get() @@ -652,18 +684,35 @@ export const useRackStore = create((set, get) => { // Pan/zoom is persisted, but nudging the view is not an edit worth a Save // badge — the logical canvas treats the viewport the same way. setViewport: (v) => set({ viewport: v }), - selectDevice: (id) => set({ selectedDeviceId: id, selectedRackId: null }), - selectRack: (id) => set({ selectedRackId: id, selectedDeviceId: null }), + selectDevice: (id) => set({ selectedDeviceId: id, selectedRackId: null, selectedCableId: null }), + selectRack: (id) => set({ selectedRackId: id, selectedDeviceId: null, selectedCableId: null }), + selectCable: (id) => set({ selectedCableId: id }), + + removeSelectedCable: () => { + const id = get().selectedCableId + if (!id) return + get().removeCable(id) + }, hoverDevice: (id) => set({ hoveredDeviceId: id }), - setCableVisibility: (v) => set({ cableVisibility: v }), + setCableVisibility: (v) => set({ cableVisibility: v, cableVisibilityBeforePatch: null }), setCableTypeFilter: (t) => set({ cableTypeFilter: t }), toggleCableMode: () => - set((s) => ({ - cableMode: !s.cableMode, - cableDraft: null, - cableVisibility: !s.cableMode ? 'always' : s.cableVisibility, - })), + set((s) => { + const entering = !s.cableMode + return { + cableMode: entering, + cableDraft: null, + cableDrag: null, + selectedCableId: null, + // Patch mode forces every cable on; leaving it hands the canvas back + // to whatever the user was looking at before. + cableVisibility: entering + ? 'always' + : (s.cableVisibilityBeforePatch ?? s.cableVisibility), + cableVisibilityBeforePatch: entering ? s.cableVisibility : null, + } + }), pickPort: (deviceId, portId) => { const { cableDraft } = get() @@ -675,7 +724,42 @@ export const useRackStore = create((set, get) => { set({ cableDraft: null }) }, - cancelCableDraft: () => set({ cableDraft: null }), + startCableDrag: (deviceId, portId) => { + const { cableDraft } = get() + if (cableDraft) { + const samePort = cableDraft.deviceId === deviceId && cableDraft.portId === portId + // Pressing the armed port again disarms it; pressing another closes the + // patch — that is the click-then-click flow, kept alongside dragging. + if (!samePort) get().addCable(cableDraft, { deviceId, portId }) + set({ cableDraft: null, cableDrag: null }) + return + } + set({ cableDraft: { deviceId, portId }, cableDrag: { pointer: null, moved: false } }) + }, + + moveCableDrag: (pointer) => { + if (!get().cableDrag) return + set({ cableDrag: { pointer, moved: true } }) + }, + + endCableDrag: (target) => { + const { cableDrag, cableDraft } = get() + if (!cableDrag) return + const onAnotherPort = + target && + cableDraft && + (target.deviceId !== cableDraft.deviceId || target.portId !== cableDraft.portId) + if (onAnotherPort) { + get().addCable(cableDraft, target) + set({ cableDraft: null, cableDrag: null }) + return + } + // A drag released on nothing is a cancel; a press that never moved is the + // first half of a click-then-click patch, so the source stays armed. + set(cableDrag.moved ? { cableDraft: null, cableDrag: null } : { cableDrag: null }) + }, + + cancelCableDraft: () => set({ cableDraft: null, cableDrag: null }), openDeviceEditor: (deviceId = null) => set({ deviceEditor: { deviceId }, selectedDeviceId: deviceId }), diff --git a/frontend/src/types/rack.ts b/frontend/src/types/rack.ts index d7a486d..ae81776 100644 --- a/frontend/src/types/rack.ts +++ b/frontend/src/types/rack.ts @@ -112,8 +112,6 @@ export interface FaceplateTemplate { alwaysShowPorts?: boolean /** Name band. Ports and artwork must stay clear of it. */ labelBox: LabelBox - /** Port artwork size. Dense panels use 'sm'. */ - portSize?: 'sm' | 'md' /** Devices get a status LED; accessories do not. */ statusLed?: boolean }