@@ -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.
+
+
+
+
+
+

+
+
+
+
+ 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