Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="flex flex-col h-full">
<div data-print-hide>
Expand Down Expand Up @@ -1900,12 +1918,15 @@ export default function App() {
<PrintSheetPage />
) : activePgType === "patch-panel" ? (
<PatchPanelPage />
) : activePgType === "floorplan" ? (
<FloorplanCanvas />
) : (
<RackPage />
)}
<DeviceEditor />
<RoomEditor />
<AnnotationEditor />
<ImagePropertiesEditor />
<EdgeContextMenu />
<RoomContextMenu />
<DeviceContextMenu />
Expand Down
104 changes: 104 additions & 0 deletions src/components/FloorplanCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const { screenToFlowPosition } = useReactFlow();

const onNodesChange = useCallback(
(changes: NodeChange[]) => onFloorplanNodesChange(page.id, changes),
[onFloorplanNodesChange, page.id]
);

const handleFile = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div ref={wrapperRef} className="relative flex-1 h-full">
<div className="absolute top-2 left-2 z-10">
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-xs rounded border border-[var(--color-border)] bg-white shadow text-[var(--color-text)] hover:text-[var(--color-text-heading)] cursor-pointer"
>
+ Add Image
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFile}
/>
</div>
<ReactFlow
nodes={page.nodes}
edges={[]}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
deleteKeyCode={["Delete", "Backspace"]}
proOptions={{ hideAttribution: true }}
fitView
minZoom={0.05}
elevateNodesOnSelect={false}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#d4d4d4" />
<Controls position="bottom-right" />
<MiniMap position="bottom-left" pannable zoomable />
</ReactFlow>
</div>
);
}

/** 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 (
<div className="flex flex-1 overflow-hidden">
<ReactFlowProvider>
{/* key by page id so switching floorplan tabs remounts with a fresh viewport */}
<FloorplanCanvasInner key={activePage} page={page} />
</ReactFlowProvider>
</div>
);
}
86 changes: 86 additions & 0 deletions src/components/ImageNode.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}

function UnlockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
</svg>
);
}

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 (
<>
<NodeResizer
isVisible={!!selected && !locked}
keepAspectRatio={lockAspect || shiftHeld}
minWidth={40}
minHeight={40}
lineStyle={{ borderColor: "#3b82f6" }}
handleStyle={{ width: 8, height: 8, borderRadius: 2, backgroundColor: "#3b82f6" }}
/>
<div
style={{ position: "relative", width: "100%", height: "100%" }}
onDoubleClick={handleDoubleClick}
>
<img
src={imageData.src}
draggable={false}
style={{
display: "block",
width: "100%",
height: "100%",
objectFit: "fill",
opacity,
pointerEvents: "none",
userSelect: "none",
border: selected ? "1px solid #3b82f6" : "none",
}}
alt=""
/>
{/* Lock toggle — top-right corner, mirrors RoomNode */}
<div
className="absolute top-0 right-0 px-1.5 py-1 transition-opacity"
style={{ pointerEvents: "auto", opacity: locked ? 1 : selected ? 0.6 : 0 }}
>
<button
className="text-white drop-shadow cursor-pointer bg-black/30 rounded p-0.5 hover:bg-black/50"
onClick={(e) => {
e.stopPropagation();
useSchematicStore.getState().toggleImageLock(id);
}}
title={locked ? "Unlock image" : "Lock image"}
>
{locked ? <LockIcon /> : <UnlockIcon />}
</button>
</div>
</div>
</>
);
}

export default memo(ImageNode);
Loading
Loading