/**
* @file Grid toolbox component
*/
import * as React from "react";
import { useNodeCanvas, useNodeCanvasActions } from "../../contexts/composed/canvas/viewport/context";
import { useNodeEditor } from "../../contexts/composed/node-editor/context";
import { useEditorActionState } from "../../contexts/composed/EditorActionStateContext";
import { applyZoomDelta, clampZoomScale } from "../../utils/zoomUtils";
import styles from "./GridToolbox.module.css";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type GridToolboxProps = {
// No props needed - layout is handled by GridLayout
};
const ZOOM_PRESETS = [5, 10, 25, 50, 100, 200, 400, 800, 1000] as const;
// Memoized zoom button component
const ZoomButton = React.memo<{
onClick: () => void;
title: string;
ariaLabel: string;
disabled?: boolean;
children: React.ReactNode;
}>(({ onClick, title, ariaLabel, disabled, children }) => (
));
ZoomButton.displayName = "ZoomButton";
// Memoized toggle button component
const ToggleButton = React.memo<{
active: boolean;
onClick: () => void;
title: string;
ariaLabel: string;
children: React.ReactNode;
}>(({ active, onClick, title, ariaLabel, children }) => (
));
ToggleButton.displayName = "ToggleButton";
// Memoized status indicator component
const StatusIndicator = React.memo<{
label: string;
value: number;
}>(({ label, value }) => (
{label}:
{value}
));
StatusIndicator.displayName = "StatusIndicator";
export const GridToolbox: React.FC = React.memo(() => {
const { state: canvasState } = useNodeCanvas();
const { actions: canvasActions } = useNodeCanvasActions();
const { state: editorState } = useNodeEditor();
const { state: actionState } = useEditorActionState();
const zoomPercentage = React.useMemo(
() => Math.round(canvasState.viewport.scale * 100),
[canvasState.viewport.scale],
);
const nodeCount = React.useMemo(() => Object.keys(editorState.nodes).length, [editorState.nodes]);
const connectionCount = React.useMemo(() => Object.keys(editorState.connections).length, [editorState.connections]);
const selectedNodeCount = React.useMemo(() => actionState.selectedNodeIds.length, [actionState.selectedNodeIds]);
const handleZoomIn = React.useCallback(() => {
const newScale = applyZoomDelta(canvasState.viewport.scale, 1);
canvasActions.zoomViewport(newScale);
}, [canvasState.viewport.scale, canvasActions]);
const handleZoomOut = React.useCallback(() => {
const newScale = applyZoomDelta(canvasState.viewport.scale, -1);
canvasActions.zoomViewport(newScale);
}, [canvasState.viewport.scale, canvasActions]);
const handleZoomReset = React.useCallback(() => {
canvasActions.setViewport({
...canvasState.viewport,
scale: 1,
});
}, [canvasState.viewport, canvasActions]);
const handleZoomToFit = React.useCallback(() => {
const nodes = Object.values(editorState.nodes);
if (nodes.length === 0) {
return;
}
// Calculate bounding box of all nodes
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
nodes.forEach((node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + (node.size?.width ?? 150));
maxY = Math.max(maxY, node.position.y + (node.size?.height ?? 50));
});
const padding = 50;
const contentWidth = maxX - minX + padding * 2;
const contentHeight = maxY - minY + padding * 2;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scaleX = viewportWidth / contentWidth;
const scaleY = viewportHeight / contentHeight;
const scale = clampZoomScale(Math.min(scaleX, scaleY, 1));
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
canvasActions.setViewport({
scale,
offset: {
x: -(centerX * scale - viewportWidth / 2),
y: -(centerY * scale - viewportHeight / 2),
},
});
}, [editorState.nodes, canvasActions]);
const handleZoomToSelection = React.useCallback(() => {
if (selectedNodeCount === 0) {
return;
}
const selectedNodes = actionState.selectedNodeIds
.map((id) => editorState.nodes[id])
.filter((node): node is NonNullable => node != null);
if (selectedNodes.length === 0) {
return;
}
// Calculate bounding box of selected nodes
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
selectedNodes.forEach((node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + (node.size?.width ?? 150));
maxY = Math.max(maxY, node.position.y + (node.size?.height ?? 50));
});
const padding = 50;
const contentWidth = maxX - minX + padding * 2;
const contentHeight = maxY - minY + padding * 2;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scaleX = viewportWidth / contentWidth;
const scaleY = viewportHeight / contentHeight;
const scale = clampZoomScale(Math.min(scaleX, scaleY, 2));
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
canvasActions.setViewport({
scale,
offset: {
x: -(centerX * scale - viewportWidth / 2),
y: -(centerY * scale - viewportHeight / 2),
},
});
}, [editorState.nodes, actionState.selectedNodeIds, selectedNodeCount, canvasActions]);
const handlePresetZoom = React.useCallback(
(preset: number) => {
canvasActions.setViewport({
...canvasState.viewport,
scale: preset / 100,
});
},
[canvasState.viewport, canvasActions],
);
const handleToggleGrid = React.useCallback(() => {
canvasActions.updateGridSettings({
showGrid: !canvasState.gridSettings.showGrid,
});
}, [canvasState.gridSettings.showGrid, canvasActions]);
const handleToggleSnapToGrid = React.useCallback(() => {
canvasActions.updateGridSettings({
snapToGrid: !canvasState.gridSettings.snapToGrid,
});
}, [canvasState.gridSettings.snapToGrid, canvasActions]);
// Memoized zoom select options
const zoomOptions = React.useMemo(() => {
const options = ZOOM_PRESETS.map((preset) => (
));
if (!ZOOM_PRESETS.includes(zoomPercentage as (typeof ZOOM_PRESETS)[number])) {
options.push(
,
);
}
return options;
}, [zoomPercentage]);
return (
{/* Zoom controls */}
{/* Grid toggle */}
{/* Snap to grid toggle */}
{/* Node count indicator */}
{/* Connection count indicator */}
);
});
GridToolbox.displayName = "GridToolbox";
/**
* Debug notes:
* - Reviewed src/components/canvas/CanvasBase.tsx to mirror toolbar zoom controls with canvas handlers.
* - Reviewed src/contexts/NodeCanvasContext.tsx to confirm shared clamping semantics for toolbar adjustments.
*/