diff --git a/src/App.tsx b/src/App.tsx index 67a2c68c..ce2e23aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,8 @@ import StubLabelContextMenu from "./components/StubLabelContextMenu"; import TextStubContextMenu from "./components/TextStubContextMenu"; import RoomEditor from "./components/RoomEditor"; import AnnotationEditor from "./components/AnnotationEditor"; +import ImagePropertiesEditor from "./components/ImagePropertiesEditor"; +import FloorplanCanvas from "./components/FloorplanCanvas"; import QuickAddDevice from "./components/QuickAddDevice"; import DeviceCreatorPicker from "./components/DeviceCreatorPicker"; import PageTabs from "./components/PageTabs"; @@ -1870,6 +1872,22 @@ export default function App() { return () => window.removeEventListener("keydown", handleKeyDown); }, [undo, redo]); + // Track Shift globally (every page) so image resizing can temporarily lock aspect ratio. + useEffect(() => { + const setShift = useSchematicStore.getState().setShiftHeld; + const down = (e: KeyboardEvent) => { if (e.key === "Shift") setShift(true); }; + const up = (e: KeyboardEvent) => { if (e.key === "Shift") setShift(false); }; + const blur = () => setShift(false); + window.addEventListener("keydown", down); + window.addEventListener("keyup", up); + window.addEventListener("blur", blur); + return () => { + window.removeEventListener("keydown", down); + window.removeEventListener("keyup", up); + window.removeEventListener("blur", blur); + }; + }, []); + return (
@@ -1900,12 +1918,15 @@ export default function App() { ) : activePgType === "patch-panel" ? ( + ) : activePgType === "floorplan" ? ( + ) : ( )} + diff --git a/src/components/FloorplanCanvas.tsx b/src/components/FloorplanCanvas.tsx new file mode 100644 index 00000000..fab55f11 --- /dev/null +++ b/src/components/FloorplanCanvas.tsx @@ -0,0 +1,104 @@ +import { useCallback, useRef } from "react"; +import { + ReactFlow, + ReactFlowProvider, + Background, + BackgroundVariant, + Controls, + MiniMap, + useReactFlow, + type NodeChange, +} from "@xyflow/react"; +import { useSchematicStore } from "../store"; +import { nodeTypes } from "../nodeTypes"; +import type { FloorplanPage } from "../types"; +import { importImageFile, fitImageSize } from "../imageImport"; + +function FloorplanCanvasInner({ page }: { page: FloorplanPage }) { + const onFloorplanNodesChange = useSchematicStore((s) => s.onFloorplanNodesChange); + const addFloorplanImage = useSchematicStore((s) => s.addFloorplanImage); + const fileInputRef = useRef(null); + const wrapperRef = useRef(null); + const { screenToFlowPosition } = useReactFlow(); + + const onNodesChange = useCallback( + (changes: NodeChange[]) => onFloorplanNodesChange(page.id, changes), + [onFloorplanNodesChange, page.id] + ); + + const handleFile = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; // allow re-importing the same file + if (!file) return; + try { + const img = await importImageFile(file); + const size = fitImageSize(img.naturalWidth, img.naturalHeight); + // Drop at the center of the visible canvas. + const rect = wrapperRef.current?.getBoundingClientRect(); + const center = rect + ? screenToFlowPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }) + : { x: 0, y: 0 }; + addFloorplanImage( + page.id, + { x: center.x - size.width / 2, y: center.y - size.height / 2 }, + { src: img.src, naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, opacity: 100, lockAspect: true }, + size + ); + } catch (err) { + alert(err instanceof Error ? err.message : "Could not import image."); + } + }, [addFloorplanImage, page.id, screenToFlowPosition]); + + return ( +
+
+ + +
+ + + + + +
+ ); +} + +/** Standalone canvas for the active floorplan page. Wrapped in its own + * ReactFlowProvider so its viewport is isolated from the schematic canvas. */ +export default function FloorplanCanvas() { + const activePage = useSchematicStore((s) => s.activePage); + const page = useSchematicStore((s) => s.pages.find((p) => p.id === s.activePage)); + + if (!page || page.type !== "floorplan") return null; + + return ( +
+ + {/* key by page id so switching floorplan tabs remounts with a fresh viewport */} + + +
+ ); +} diff --git a/src/components/ImageNode.tsx b/src/components/ImageNode.tsx new file mode 100644 index 00000000..28215ad1 --- /dev/null +++ b/src/components/ImageNode.tsx @@ -0,0 +1,86 @@ +import { memo } from "react"; +import { NodeResizer, type NodeProps } from "@xyflow/react"; +import type { ImageNodeData } from "../types"; +import { useSchematicStore } from "../store"; + +function LockIcon() { + return ( + + + + + ); +} + +function UnlockIcon() { + return ( + + + + + ); +} + +function ImageNode({ id, data, selected }: NodeProps) { + const imageData = data as unknown as ImageNodeData; + const opacity = (imageData.opacity ?? 100) / 100; + const locked = imageData.locked ?? false; + const lockAspect = imageData.lockAspect ?? true; + // Hold Shift during a resize to temporarily constrain to the natural aspect ratio. + const shiftHeld = useSchematicStore((s) => s.shiftHeld); + + const handleDoubleClick = () => { + useSchematicStore.getState().setEditingNodeId(id); + }; + + return ( + <> + +
+ + {/* Lock toggle β€” top-right corner, mirrors RoomNode */} +
+ +
+
+ + ); +} + +export default memo(ImageNode); diff --git a/src/components/ImagePropertiesEditor.tsx b/src/components/ImagePropertiesEditor.tsx new file mode 100644 index 00000000..ddadfa57 --- /dev/null +++ b/src/components/ImagePropertiesEditor.tsx @@ -0,0 +1,288 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useSchematicStore } from "../store"; +import type { ImageNodeData, SchematicNode } from "../types"; + +const UNITS = ["ft", "in", "m", "cm", "mm"]; + +interface Pt { x: number; y: number } + +/** Sub-modal: draw a line across the image and enter its real length to derive scale. */ +function CalibrationModal({ + src, + naturalWidth, + naturalHeight, + initialUnit, + onCancel, + onApply, +}: { + src: string; + naturalWidth: number; + naturalHeight: number; + initialUnit: string; + onCancel: () => void; + onApply: (pxPerUnit: number, unit: string) => void; +}) { + const [a, setA] = useState(null); + const [b, setB] = useState(null); + const [knownLength, setKnownLength] = useState(""); + const [unit, setUnit] = useState(initialUnit || "ft"); + const imgRef = useRef(null); + + // Fit the image into a max display box while preserving aspect ratio. + const maxBox = 460; + const scale = Math.min(maxBox / naturalWidth, maxBox / naturalHeight, 1); + const dispW = Math.max(1, Math.round(naturalWidth * scale)); + const dispH = Math.max(1, Math.round(naturalHeight * scale)); + + const handleClick = (e: React.MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + if (!a || (a && b)) { + setA(pt); + setB(null); + } else { + setB(pt); + } + }; + + // Display-space line length β†’ natural-pixel length. + const dispLen = a && b ? Math.hypot(b.x - a.x, b.y - a.y) : 0; + const naturalLen = dispW > 0 ? dispLen * (naturalWidth / dispW) : 0; + const lengthNum = parseFloat(knownLength); + const valid = a && b && dispLen > 2 && lengthNum > 0; + const pxPerUnit = valid ? naturalLen / lengthNum : 0; + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + > +
+
+

Calibrate Scale

+

+ Click two points spanning a known distance, then enter its real length. +

+
+ +
+
+ + + {a && b && ( + + )} + {a && } + {b && } + +
+ +
+ Known length + setKnownLength(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + placeholder="e.g. 10" + className="w-24 bg-[var(--color-surface)] border border-[var(--color-border)] rounded px-2 py-1 text-xs text-[var(--color-text-heading)] outline-none focus:border-blue-500" + /> + + {valid && ( + + {pxPerUnit.toFixed(1)} px/{unit} + + )} +
+
+ +
+ + +
+
+
+ ); +} + +export default function ImagePropertiesEditor() { + const editingNodeId = useSchematicStore((s) => s.editingNodeId); + const activePage = useSchematicStore((s) => s.activePage); + const nodes = useSchematicStore((s) => s.nodes); + const pages = useSchematicStore((s) => s.pages); + const updateImageNode = useSchematicStore((s) => s.updateImageNode); + const setEditingNodeId = useSchematicStore((s) => s.setEditingNodeId); + + // Resolve the edited node from the schematic (global) or the active floorplan page. + let node: SchematicNode | undefined; + if (activePage === "schematic") { + node = nodes.find((n) => n.id === editingNodeId && n.type === "image"); + } else { + const page = pages.find((p) => p.id === activePage && p.type === "floorplan"); + node = page?.type === "floorplan" ? page.nodes.find((n) => n.id === editingNodeId && n.type === "image") : undefined; + } + const data = node?.data as ImageNodeData | undefined; + + const [opacity, setOpacity] = useState(100); + const [lockAspect, setLockAspect] = useState(true); + const [locked, setLocked] = useState(false); + const [pxPerUnit, setPxPerUnit] = useState(undefined); + const [unitLabel, setUnitLabel] = useState(undefined); + const [showCalibrate, setShowCalibrate] = useState(false); + + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + if (!node) return; + const d = node.data as ImageNodeData; + setOpacity(d.opacity ?? 100); + setLockAspect(d.lockAspect ?? true); + setLocked(d.locked ?? false); + setPxPerUnit(d.pxPerUnit); + setUnitLabel(d.unitLabel); + setShowCalibrate(false); + }, [node]); + /* eslint-enable react-hooks/set-state-in-effect */ + + const close = useCallback(() => setEditingNodeId(null), [setEditingNodeId]); + + const handleSave = useCallback(() => { + if (!editingNodeId) return; + updateImageNode(editingNodeId, { + opacity, + lockAspect, + locked: locked || undefined, + pxPerUnit, + unitLabel, + }); + close(); + }, [editingNodeId, opacity, lockAspect, locked, pxPerUnit, unitLabel, updateImageNode, close]); + + if (!editingNodeId || !node || !data) return null; + + return ( +
{ if (e.target === e.currentTarget) close(); }} + > +
+
+

Image Properties

+ +
+ +
+ {/* Preview */} +
+ +
+ + {/* Opacity */} +
+ +
+ setOpacity(Number(e.target.value))} + className="flex-1 h-1.5 cursor-pointer accent-blue-500" + /> + {opacity}% +
+
+ + {/* Scale calibration */} +
+ +
+ + + {pxPerUnit && unitLabel ? `${pxPerUnit.toFixed(1)} px/${unitLabel}` : "Not set"} + + {pxPerUnit != null && ( + + )} +
+
+ + {/* Toggles */} +
+ + +
+
+ +
+ + +
+
+ + {showCalibrate && ( + setShowCalibrate(false)} + onApply={(ppu, unit) => { setPxPerUnit(ppu); setUnitLabel(unit); setShowCalibrate(false); }} + /> + )} +
+ ); +} diff --git a/src/components/MenuBar.tsx b/src/components/MenuBar.tsx index 2d63b4ad..57dc2efe 100644 --- a/src/components/MenuBar.tsx +++ b/src/components/MenuBar.tsx @@ -7,6 +7,7 @@ import { exportPdf } from "../pdfExport"; import { exportTemplatesToFile, readTemplateFile } from "../templateExport"; import { loadSchematicTemplate } from "../templateApi"; import { getPaperSize } from "../printConfig"; +import { importImageFile, fitImageSize } from "../imageImport"; import type { SchematicFile, SchematicNode, AnnotationData } from "../types"; import ReportsDialog, { type ReportsTab } from "./ReportsDialog"; import TitleBlockDialog from "./TitleBlockDialog"; @@ -135,6 +136,7 @@ export default function MenuBar() { const reactFlowInstance = useReactFlow(); const fileInputRef = useRef(null); const archiveInputRef = useRef(null); + const imageInputRef = useRef(null); const menuBarRef = useRef(null); const { isDark, toggle: toggleTheme } = useTheme(); @@ -553,6 +555,27 @@ export default function MenuBar() { state.saveToLocalStorage(); }, [reactFlowInstance]); + const handleImportImage = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; // allow re-importing the same file + if (!file) return; + try { + const img = await importImageFile(file); + const size = fitImageSize(img.naturalWidth, img.naturalHeight); + const viewport = reactFlowInstance.getViewport(); + // Center of the current viewport, minus half the image so it lands centered. + const cx = (-viewport.x + window.innerWidth / 2) / viewport.zoom; + const cy = (-viewport.y + window.innerHeight / 2) / viewport.zoom; + useSchematicStore.getState().addImageNode( + { x: cx - size.width / 2, y: cy - size.height / 2 }, + { src: img.src, naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, opacity: 100, lockAspect: true }, + size + ); + } catch (err) { + alert(err instanceof Error ? err.message : "Could not import image."); + } + }, [reactFlowInstance]); + const handleNew = useCallback(async () => { if (isLoggedIn && isOnline) { try { @@ -603,6 +626,8 @@ export default function MenuBar() { { type: "item", label: "Add Circle", onClick: () => addAnnotation("circle") }, { type: "item", label: "Add Diamond", onClick: () => addAnnotation("diamond") }, { type: "item", label: "Add Triangle", onClick: () => addAnnotation("triangle") }, + { type: "separator" }, + { type: "item", label: "Add Image...", onClick: () => imageInputRef.current?.click() }, ], View: [ { @@ -1034,6 +1059,13 @@ export default function MenuBar() { className="hidden" onChange={handleImportArchive} /> + {reportsTab && ( setReportsTab(null)} /> diff --git a/src/components/PageTabs.tsx b/src/components/PageTabs.tsx index 7ae8bb86..290b368b 100644 --- a/src/components/PageTabs.tsx +++ b/src/components/PageTabs.tsx @@ -22,6 +22,10 @@ export default function PageTabs() { const addPatchPanelPage = useSchematicStore((s) => s.addPatchPanelPage); const removePatchPanelPage = useSchematicStore((s) => s.removePatchPanelPage); const renamePatchPanelPage = useSchematicStore((s) => s.renamePatchPanelPage); + const addFloorplanPage = useSchematicStore((s) => s.addFloorplanPage); + const removeFloorplanPage = useSchematicStore((s) => s.removeFloorplanPage); + const renameFloorplanPage = useSchematicStore((s) => s.renameFloorplanPage); + const duplicateFloorplanPage = useSchematicStore((s) => s.duplicateFloorplanPage); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); @@ -58,9 +62,10 @@ export default function PageTabs() { if (!page) { setEditingId(null); return; } if (page.type === "print-sheet") renamePrintSheetPage(editingId, editValue.trim()); else if (page.type === "patch-panel") renamePatchPanelPage(editingId, editValue.trim()); + else if (page.type === "floorplan") renameFloorplanPage(editingId, editValue.trim()); else renameRackPage(editingId, editValue.trim()); setEditingId(null); - }, [editingId, editValue, pages, renameRackPage, renamePrintSheetPage, renamePatchPanelPage]); + }, [editingId, editValue, pages, renameRackPage, renamePrintSheetPage, renamePatchPanelPage, renameFloorplanPage]); const handleContextMenu = useCallback((e: React.MouseEvent, pageId: string) => { e.preventDefault(); @@ -81,6 +86,7 @@ export default function PageTabs() { if (!menuPage || menuPage.type === "patch-panel") return; setContextMenu(null); if (menuPage.type === "print-sheet") duplicatePrintSheetPage(menuPage.id); + else if (menuPage.type === "floorplan") duplicateFloorplanPage(menuPage.id); else duplicateRackPage(menuPage.id); }; @@ -93,6 +99,8 @@ export default function PageTabs() { if (confirm(`Delete patch bay page "${menuPage.label}"? Panels and patch assignments are kept β€” only the tab is removed.`)) { removePatchPanelPage(menuPage.id); } + } else if (menuPage.type === "floorplan") { + if (confirm(`Delete floorplan "${menuPage.label}"? This removes its images.`)) removeFloorplanPage(menuPage.id); } else { if (confirm(`Delete rack page "${menuPage.label}"? This will remove all racks and placements on this page.`)) { removeRackPage(menuPage.id); @@ -100,21 +108,16 @@ export default function PageTabs() { } }; - type TabVariant = "rack" | "print" | "patch"; - const tabClass = (isActive: boolean, variant: TabVariant = "rack") => - `px-3 py-1 rounded-t border border-b-0 whitespace-nowrap transition-colors ${ - isActive - ? variant === "print" - ? "bg-white border-violet-400 font-semibold text-violet-900" - : variant === "patch" - ? "bg-white border-sky-400 font-semibold text-sky-900" - : "bg-white border-neutral-300 font-semibold text-neutral-900" - : variant === "print" - ? "bg-violet-50 border-transparent text-violet-600 hover:bg-violet-100" - : variant === "patch" - ? "bg-sky-50 border-transparent text-sky-600 hover:bg-sky-100" - : "bg-neutral-200 border-transparent text-neutral-600 hover:bg-neutral-50" - }`; + type TabVariant = "rack" | "print" | "patch" | "floorplan"; + const tabClass = (isActive: boolean, variant: TabVariant = "rack") => { + const palette = { + print: { active: "bg-white border-violet-400 font-semibold text-violet-900", idle: "bg-violet-50 border-transparent text-violet-600 hover:bg-violet-100" }, + patch: { active: "bg-white border-sky-400 font-semibold text-sky-900", idle: "bg-sky-50 border-transparent text-sky-600 hover:bg-sky-100" }, + floorplan: { active: "bg-white border-teal-400 font-semibold text-teal-900", idle: "bg-teal-50 border-transparent text-teal-600 hover:bg-teal-100" }, + rack: { active: "bg-white border-neutral-300 font-semibold text-neutral-900", idle: "bg-neutral-200 border-transparent text-neutral-600 hover:bg-neutral-50" }, + }[variant]; + return `px-3 py-1 rounded-t border border-b-0 whitespace-nowrap transition-colors ${isActive ? palette.active : palette.idle}`; + }; return ( <> @@ -131,8 +134,10 @@ export default function PageTabs() { {/* Page tabs */} {pages.map((page) => { const variant: TabVariant = - page.type === "print-sheet" ? "print" : page.type === "patch-panel" ? "patch" : "rack"; - const isPrint = page.type === "print-sheet"; + page.type === "print-sheet" ? "print" + : page.type === "patch-panel" ? "patch" + : page.type === "floorplan" ? "floorplan" + : "rack"; return ( ); @@ -191,6 +196,15 @@ export default function PageTabs() { πŸ”Œ+ )} + + {/* Add floorplan page */} +
{/* Context menu */} diff --git a/src/imageImport.ts b/src/imageImport.ts new file mode 100644 index 00000000..a6a01524 --- /dev/null +++ b/src/imageImport.ts @@ -0,0 +1,83 @@ +/** Shared helpers for importing a floorplan/reference image as a data URL. + * Mirrors the FileReader + canvas-resize pattern in TitleBlockDialog, but + * generalized for full-page floorplan images (larger cap) and returns the + * natural dimensions so callers can seed an aspect-correct node size. */ + +/** Max edge (px) the imported image is downscaled to, to keep localStorage/JSON + * payloads reasonable. Floorplans need more detail than a logo, so this is large. */ +const MAX_EDGE_PX = 4000; + +/** Reject images larger than this raw file size before reading. */ +export const MAX_IMAGE_BYTES = 15 * 1024 * 1024; // 15 MB + +export interface ImportedImage { + src: string; + naturalWidth: number; + naturalHeight: number; +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +/** Load a data URL into an Image, downscaling to MAX_EDGE_PX if needed. + * SVGs (which may report 0Γ—0) are passed through untouched. */ +function rasterize(dataUrl: string, isSvg: boolean): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const w0 = img.naturalWidth || img.width; + const h0 = img.naturalHeight || img.height; + if (!w0 || !h0) { + // Unknown intrinsic size (e.g. some SVGs) β€” keep as-is with a sane default. + resolve({ src: dataUrl, naturalWidth: w0 || 1000, naturalHeight: h0 || 1000 }); + return; + } + const scale = Math.min(1, MAX_EDGE_PX / w0, MAX_EDGE_PX / h0); + if (isSvg || scale >= 1) { + resolve({ src: dataUrl, naturalWidth: w0, naturalHeight: h0 }); + return; + } + const w = Math.round(w0 * scale); + const h = Math.round(h0 * scale); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) { resolve({ src: dataUrl, naturalWidth: w0, naturalHeight: h0 }); return; } + ctx.drawImage(img, 0, 0, w, h); + resolve({ src: canvas.toDataURL("image/png"), naturalWidth: w, naturalHeight: h }); + }; + img.onerror = reject; + img.src = dataUrl; + }); +} + +/** Read an image File and return a (possibly downscaled) data URL plus its + * natural dimensions. Throws if the file is too large or not an image. */ +export async function importImageFile(file: File): Promise { + if (!file.type.startsWith("image/")) { + throw new Error("Selected file is not an image."); + } + if (file.size > MAX_IMAGE_BYTES) { + throw new Error(`Image is too large (max ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)} MB).`); + } + const dataUrl = await readFileAsDataUrl(file); + return rasterize(dataUrl, file.type === "image/svg+xml"); +} + +/** Compute the initial on-canvas size for an imported image, fitting it within + * `maxEdge` flow-units while preserving aspect ratio. */ +export function fitImageSize(naturalWidth: number, naturalHeight: number, maxEdge = 600): { width: number; height: number } { + if (!naturalWidth || !naturalHeight) return { width: maxEdge, height: maxEdge }; + const scale = Math.min(1, maxEdge / naturalWidth, maxEdge / naturalHeight); + return { + width: Math.round(naturalWidth * scale), + height: Math.round(naturalHeight * scale), + }; +} diff --git a/src/nodeTypes.ts b/src/nodeTypes.ts index 09138c33..f600cc8d 100644 --- a/src/nodeTypes.ts +++ b/src/nodeTypes.ts @@ -3,6 +3,7 @@ import DeviceNodeComponent from "./components/DeviceNode"; import RoomNodeComponent from "./components/RoomNode"; import NoteNodeComponent from "./components/NoteNode"; import AnnotationNodeComponent from "./components/AnnotationNode"; +import ImageNodeComponent from "./components/ImageNode"; import StubLabelNodeComponent from "./components/StubLabelNode"; import TextStubNodeComponent from "./components/TextStubNode"; import WaypointNodeComponent from "./components/WaypointNode"; @@ -14,6 +15,7 @@ export const nodeTypes: NodeTypes = { room: RoomNodeComponent, note: NoteNodeComponent, annotation: AnnotationNodeComponent, + image: ImageNodeComponent, "stub-label": StubLabelNodeComponent, "text-stub": TextStubNodeComponent, waypoint: WaypointNodeComponent, diff --git a/src/pathfinding.ts b/src/pathfinding.ts index f71c45ef..33d479af 100644 --- a/src/pathfinding.ts +++ b/src/pathfinding.ts @@ -154,7 +154,8 @@ export function buildObstacles( n.type === "note" || n.type === "stub-label" || n.type === "waypoint" || - n.type === "bundle-junction" + n.type === "bundle-junction" || + n.type === "image" ) continue; if (excludeIds.length > 0 && excludeIds.includes(n.id)) continue; const pos = getAbsPos(n); diff --git a/src/store.ts b/src/store.ts index 2e02d2fd..712e6748 100644 --- a/src/store.ts +++ b/src/store.ts @@ -20,6 +20,7 @@ import type { SchematicPage, RackElevationPage, PrintSheetPage, + FloorplanPage, PrintViewport, RackData, RackDevicePlacement, @@ -291,6 +292,9 @@ interface SchematicState { loadSeq: number; editingNodeId: string | null; creatingNodeId: string | null; + /** True while Shift is held β€” used to temporarily lock image aspect ratio during a resize. */ + shiftHeld: boolean; + setShiftHeld: (held: boolean) => void; customTemplates: DeviceTemplate[]; ownedGear: OwnedGearItem[]; showOwnedGearPane: boolean; @@ -366,6 +370,13 @@ interface SchematicState { updateRoomLabel: (nodeId: string, label: string) => void; updateRoom: (nodeId: string, data: import("./types").RoomData) => void; updateAnnotation: (nodeId: string, data: Partial) => void; + /** Add a floorplan/reference image node to the main schematic canvas (global nodes). */ + addImageNode: (position: { x: number; y: number }, data: import("./types").ImageNodeData, size: { width: number; height: number }) => void; + /** Patch an image node's data. Resolves to the schematic (global nodes) or the active + * floorplan page based on `activePage`. */ + updateImageNode: (nodeId: string, patch: Partial) => void; + /** Toggle the locked (pinned) state of an image node (schematic or active floorplan page). */ + toggleImageLock: (nodeId: string) => void; toggleRoomLock: (nodeId: string) => void; toggleEquipmentRack: (nodeId: string) => void; addNote: (position: { x: number; y: number }) => void; @@ -738,6 +749,13 @@ interface SchematicState { addRackPage: (label: string) => string; removeRackPage: (pageId: string) => void; renameRackPage: (pageId: string, label: string) => void; + // Floorplan page CRUD + per-page image nodes + addFloorplanPage: (label?: string) => string; + removeFloorplanPage: (pageId: string) => void; + renameFloorplanPage: (pageId: string, label: string) => void; + duplicateFloorplanPage: (pageId: string) => string; + addFloorplanImage: (pageId: string, position: { x: number; y: number }, data: import("./types").ImageNodeData, size: { width: number; height: number }) => void; + onFloorplanNodesChange: (pageId: string, changes: import("@xyflow/react").NodeChange[]) => void; addRack: (pageId: string, rack: Omit) => string; removeRack: (pageId: string, rackId: string) => void; updateRack: (pageId: string, rackId: string, patch: Partial) => void; @@ -845,6 +863,11 @@ function nextRackPageId(): string { return `rackpage-${++rackPageIdCounter}`; } +let floorplanPageIdCounter = 0; +function nextFloorplanPageId(): string { + return `floorplan-${++floorplanPageIdCounter}`; +} + let rackIdCounter = 0; function nextRackId(): string { return `rack-${++rackIdCounter}`; @@ -875,6 +898,11 @@ function mapElevationPage(pages: SchematicPage[], pageId: string, fn: (p: RackEl return pages.map((p) => (p.id === pageId && p.type === "rack-elevation") ? fn(p) : p); } +/** Apply fn to the floorplan page with the given id; leave other pages untouched. */ +function mapFloorplanPage(pages: SchematicPage[], pageId: string, fn: (p: FloorplanPage) => FloorplanPage): SchematicPage[] { + return pages.map((p) => (p.id === pageId && p.type === "floorplan") ? fn(p) : p); +} + /** Remove patch hops that reference deleted panel nodes. Segment overrides are dropped * alongside (their indices shift when the hop list changes). */ function stripDeadHops(edges: ConnectionEdge[], deadNodeIds: Set): ConnectionEdge[] { @@ -896,6 +924,8 @@ function syncRackCounters(pages: SchematicPage[]) { for (const page of pages) { const pm = page.id.match(/^rackpage-(\d+)$/); if (pm) rackPageIdCounter = Math.max(rackPageIdCounter, Number(pm[1])); + const fm = page.id.match(/^floorplan-(\d+)$/); + if (fm) floorplanPageIdCounter = Math.max(floorplanPageIdCounter, Number(fm[1])); if (page.type === "print-sheet") { // Arrays default to [] β€” an older/partial page missing these would throw // "not iterable" here, AFTER importFromJSON already loaded the schematic, @@ -909,6 +939,7 @@ function syncRackCounters(pages: SchematicPage[]) { continue; } if (page.type === "patch-panel") continue; + if (page.type === "floorplan") continue; for (const rack of page.racks ?? []) { const rm = rack.id.match(/^rack-(\d+)$/); if (rm) rackIdCounter = Math.max(rackIdCounter, Number(rm[1])); @@ -1389,6 +1420,8 @@ export const useSchematicStore = create((set, get) => ({ loadSeq: 0, editingNodeId: null, creatingNodeId: null, + shiftHeld: false, + setShiftHeld: (held) => { if (get().shiftHeld !== held) set({ shiftHeld: held }); }, customTemplates: _initCustomTemplates, ownedGear: [], showOwnedGearPane: false, @@ -1518,16 +1551,28 @@ export const useSchematicStore = create((set, get) => ({ onNodesChange: (changes) => { const updated = applyNodeChanges(changes, get().nodes) as SchematicNode[]; - // Keep room zIndex pinned low (React Flow may reset it) + // Keep room/image zIndex pinned low (React Flow may reset it) const normalized = updated.map((n) => { - if (n.type !== "room") return n; - const locked = (n.data as import("./types").RoomData).locked; - return { - ...n, - zIndex: -1, - selectable: !locked, - className: locked ? "locked" : undefined, - }; + if (n.type === "room") { + const locked = (n.data as import("./types").RoomData).locked; + return { + ...n, + zIndex: -1, + selectable: !locked, + className: locked ? "locked" : undefined, + }; + } + if (n.type === "image") { + const locked = (n.data as import("./types").ImageNodeData).locked; + return { + ...n, + zIndex: -10, + draggable: locked ? false : undefined, + selectable: !locked, + className: locked ? "locked" : undefined, + }; + } + return n; }); // Mirror waypoint node positions back to canonical edge.data.manualWaypoints // so the router and persistence see drag/multi-select-drag results. @@ -3208,6 +3253,79 @@ export const useSchematicStore = create((set, get) => ({ get().saveToLocalStorage(); }, + addImageNode: (position, data, size) => { + const state = get(); + pushUndo({ nodes: state.nodes, edges: state.edges }); + const id = `image-${Date.now()}`; + const newNode = { + id, + type: "image" as const, + position, + data, + style: { width: size.width, height: size.height }, + zIndex: -10, + } as SchematicNode; + set({ nodes: [...state.nodes, newNode], editingNodeId: id }); + get().saveToLocalStorage(); + }, + + updateImageNode: (nodeId, patch) => { + const state = get(); + pushUndo({ nodes: state.nodes, edges: state.edges }); + const patchNode = (n: SchematicNode): SchematicNode => { + if (n.id !== nodeId || n.type !== "image") return n; + const nextData = { ...n.data, ...patch } as import("./types").ImageNodeData; + const locked = nextData.locked; + return { + ...n, + draggable: locked ? false : undefined, + selectable: !locked, + className: locked ? "locked" : undefined, + data: nextData, + } as SchematicNode; + }; + if (state.activePage === "schematic") { + set({ nodes: state.nodes.map(patchNode) }); + } else { + set({ + pages: state.pages.map((p) => + p.id === state.activePage && p.type === "floorplan" + ? { ...p, nodes: p.nodes.map(patchNode) } + : p + ), + }); + } + get().saveToLocalStorage(); + }, + + toggleImageLock: (nodeId) => { + const state = get(); + pushUndo({ nodes: state.nodes, edges: state.edges }); + const toggle = (n: SchematicNode): SchematicNode => { + if (n.id !== nodeId || n.type !== "image") return n; + const locked = !(n.data as import("./types").ImageNodeData).locked; + return { + ...n, + draggable: locked ? false : undefined, + selectable: !locked, + className: locked ? "locked" : undefined, + data: { ...n.data, locked: locked || undefined }, // keep JSON clean + } as SchematicNode; + }; + if (state.activePage === "schematic") { + set({ nodes: state.nodes.map(toggle) }); + } else { + set({ + pages: state.pages.map((p) => + p.id === state.activePage && p.type === "floorplan" + ? { ...p, nodes: p.nodes.map(toggle) } + : p + ), + }); + } + get().saveToLocalStorage(); + }, + toggleRoomLock: (nodeId) => { const state = get(); pushUndo({ nodes: state.nodes, edges: state.edges }); @@ -4476,6 +4594,94 @@ export const useSchematicStore = create((set, get) => ({ get().saveToLocalStorage(); }, + addFloorplanPage: (label) => { + const state = get(); + pushUndo({ nodes: state.nodes, edges: state.edges }); + const id = nextFloorplanPageId(); + const pageLabel = label ?? `Floorplan ${state.pages.filter((p) => p.type === "floorplan").length + 1}`; + const page: FloorplanPage = { id, label: pageLabel, type: "floorplan", nodes: [] }; + set({ pages: [...state.pages, page], activePage: id, undoSize: undoStack.length, redoSize: 0 }); + get().saveToLocalStorage(); + return id; + }, + + removeFloorplanPage: (pageId) => { + const state = get(); + pushUndo({ nodes: state.nodes, edges: state.edges }); + const pages = state.pages.filter((p) => p.id !== pageId); + const activePage = state.activePage === pageId ? "schematic" : state.activePage; + set({ pages, activePage, undoSize: undoStack.length, redoSize: 0 }); + get().saveToLocalStorage(); + }, + + renameFloorplanPage: (pageId, label) => { + const state = get(); + pushUndo({ nodes: state.nodes, edges: state.edges }); + set({ pages: state.pages.map((p) => p.id === pageId ? { ...p, label } : p), undoSize: undoStack.length, redoSize: 0 }); + get().saveToLocalStorage(); + }, + + duplicateFloorplanPage: (pageId) => { + const state = get(); + const src = state.pages.find((p) => p.id === pageId && p.type === "floorplan") as FloorplanPage | undefined; + if (!src) return ""; + pushUndo({ nodes: state.nodes, edges: state.edges }); + const newPageId = nextFloorplanPageId(); + const newPage: FloorplanPage = { + id: newPageId, + label: `${src.label} (copy)`, + type: "floorplan", + nodes: src.nodes.map((n) => ({ ...n, id: `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` })), + }; + const idx = state.pages.findIndex((p) => p.id === pageId); + const pages = [...state.pages.slice(0, idx + 1), newPage, ...state.pages.slice(idx + 1)]; + set({ pages, activePage: newPageId, undoSize: undoStack.length, redoSize: 0 }); + get().saveToLocalStorage(); + return newPageId; + }, + + addFloorplanImage: (pageId, position, data, size) => { + const state = get(); + pushUndo({ nodes: state.nodes, edges: state.edges }); + const id = `image-${Date.now()}`; + const newNode = { + id, + type: "image" as const, + position, + data, + style: { width: size.width, height: size.height }, + zIndex: -10, + } as SchematicNode; + set({ + pages: mapFloorplanPage(state.pages, pageId, (p) => ({ ...p, nodes: [...p.nodes, newNode] })), + editingNodeId: id, + undoSize: undoStack.length, redoSize: 0, + }); + get().saveToLocalStorage(); + }, + + onFloorplanNodesChange: (pageId, changes) => { + const state = get(); + set({ + pages: mapFloorplanPage(state.pages, pageId, (p) => { + const updated = applyNodeChanges(changes, p.nodes) as SchematicNode[]; + const normalized = updated.map((n) => { + if (n.type !== "image") return n; + const locked = (n.data as import("./types").ImageNodeData).locked; + return { + ...n, + zIndex: -10, + draggable: locked ? false : undefined, + selectable: !locked, + className: locked ? "locked" : undefined, + }; + }); + return { ...p, nodes: normalized }; + }), + }); + get().saveToLocalStorage(); + }, + addRack: (pageId, rackData) => { const state = get(); pushUndo({ nodes: state.nodes, edges: state.edges }); @@ -5082,7 +5288,7 @@ export const useSchematicStore = create((set, get) => ({ accessories: p.accessories.filter((a) => a.rackId !== rackId), }; } - if (p.id === dstPageId) { + if (p.id === dstPageId && p.type === "rack-elevation") { return { ...p, racks: [...p.racks, rack], diff --git a/src/types.ts b/src/types.ts index dcfd4db1..18d08eed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -383,6 +383,27 @@ export interface AnnotationData { export type AnnotationNode = Node; +export interface ImageNodeData { + [key: string]: unknown; + /** Image source as a data URL */ + src: string; + /** Natural pixel dimensions of the imported image (for aspect-ratio locking) */ + naturalWidth: number; + naturalHeight: number; + /** Display opacity, 0-100 (default 100) */ + opacity?: number; + /** Calibration: image pixels per real-world unit (from the scale tool) */ + pxPerUnit?: number; + /** Unit label paired with pxPerUnit, e.g. "ft", "m" */ + unitLabel?: string; + /** Pin the image to its coordinate (no drag/resize), mirrors RoomData.locked */ + locked?: boolean; + /** Constrain resizing to the natural aspect ratio (default true) */ + lockAspect?: boolean; +} + +export type ImageNode = Node; + export interface StubLabelData { [key: string]: unknown; /** Signal type β€” controls border color, matches the linked connection */ @@ -465,7 +486,7 @@ export interface BundleJunctionData { export type BundleJunctionNode = Node; -export type SchematicNode = DeviceNode | RoomNode | NoteNode | AnnotationNode | StubLabelNode | TextStubNode | WaypointNode | BundleJunctionNode; +export type SchematicNode = DeviceNode | RoomNode | NoteNode | AnnotationNode | ImageNode | StubLabelNode | TextStubNode | WaypointNode | BundleJunctionNode; /** One intermediate patch-panel hop on a connection's physical path (source β†’ target order). * The panel is a real device node (deviceType "patch-panel"), possibly off-canvas. */ @@ -803,7 +824,18 @@ export interface PatchPanelViewPage { type: "patch-panel"; } -export type SchematicPage = RackElevationPage | PrintSheetPage | PatchPanelViewPage; +/** A standalone canvas (its own ReactFlow surface) for laying a floorplan/reference + * image and, in the future, device/room markers. Stores its own node array so it is + * fully isolated from the global schematic nodes/edges. */ +export interface FloorplanPage { + id: string; + label: string; + type: "floorplan"; + /** Image nodes today; marker node types can be added later. */ + nodes: SchematicNode[]; +} + +export type SchematicPage = RackElevationPage | PrintSheetPage | PatchPanelViewPage | FloorplanPage; /** Per-bundle metadata. Membership is on each connection's `data.bundleId`; this holds * the label, an optional user-dragged trunk override, and collapse state. */