respira/src/hooks/ui/useCanvasViewport.ts
Jan-Henrik Bruhn e1aadc9e1f feature: Create comprehensive custom hooks library (WIP)
- Extract 5 new custom hooks:
  * useAutoScroll - Auto-scroll element into view
  * useClickOutside - Detect outside clicks with exclusions
  * useMachinePolling - Dynamic machine status polling
  * useErrorPopoverState - Error popover state management
  * useBluetoothDeviceListener - Bluetooth device discovery

- Reorganize all hooks into categorized folders:
  * utility/ - Generic reusable patterns
  * domain/ - Business logic for embroidery/patterns
  * ui/ - Library integration (Konva)
  * platform/ - Electron/Pyodide specific

- Create barrel exports for clean imports (@/hooks)

- Update components to use new hooks:
  * AppHeader uses useErrorPopoverState
  * ProgressMonitor uses useAutoScroll
  * FileUpload, PatternCanvas use barrel exports

Part 1: Hooks extraction and reorganization
Still TODO: Update remaining components, add tests, add documentation

Related to #40

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 12:19:12 +01:00

170 lines
4.8 KiB
TypeScript

/**
* useCanvasViewport Hook
*
* Manages canvas viewport state including zoom, pan, and container size
* Handles wheel zoom and button zoom operations
*/
import { useState, useEffect, useCallback, type RefObject } from "react";
import type Konva from "konva";
import type { PesPatternData } from "../formats/import/pesImporter";
import type { MachineInfo } from "../types/machine";
import { calculateInitialScale } from "../utils/konvaRenderers";
import { calculateZoomToPoint } from "../components/PatternCanvas/patternCanvasHelpers";
interface UseCanvasViewportOptions {
containerRef: RefObject<HTMLDivElement | null>;
pesData: PesPatternData | null;
uploadedPesData: PesPatternData | null;
machineInfo: MachineInfo | null;
}
export function useCanvasViewport({
containerRef,
pesData,
uploadedPesData,
machineInfo,
}: UseCanvasViewportOptions) {
const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
const [stageScale, setStageScale] = useState(1);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [initialScale, setInitialScale] = useState(1);
// Track the last processed pattern to detect changes during render
const [lastProcessedPattern, setLastProcessedPattern] =
useState<PesPatternData | null>(null);
// Track container size with ResizeObserver
useEffect(() => {
if (!containerRef.current) return;
const updateSize = () => {
if (containerRef.current) {
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
setContainerSize({ width, height });
}
};
// Initial size
updateSize();
// Watch for resize
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, [containerRef]);
// Reset viewport when pattern changes (during render, not in effect)
// This follows the React-recommended pattern for deriving state from props
const currentPattern = uploadedPesData || pesData;
if (
currentPattern &&
currentPattern !== lastProcessedPattern &&
containerSize.width > 0
) {
const { bounds } = currentPattern;
const viewWidth = machineInfo
? machineInfo.maxWidth
: bounds.maxX - bounds.minX;
const viewHeight = machineInfo
? machineInfo.maxHeight
: bounds.maxY - bounds.minY;
const newInitialScale = calculateInitialScale(
containerSize.width,
containerSize.height,
viewWidth,
viewHeight,
);
// Update state during render when pattern changes
// This is the recommended React pattern for resetting state based on props
setLastProcessedPattern(currentPattern);
setInitialScale(newInitialScale);
setStageScale(newInitialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}
// Wheel zoom handler
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const scaleBy = 1.1;
const direction = e.evt.deltaY > 0 ? -1 : 1;
setStageScale((oldScale) => {
const newScale = Math.max(
0.1,
Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
);
// Zoom towards pointer
setStagePos((prevPos) =>
calculateZoomToPoint(oldScale, newScale, pointer, prevPos),
);
return newScale;
});
}, []);
// Zoom control handlers
const handleZoomIn = useCallback(() => {
setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
// Zoom towards center of viewport
const center = {
x: containerSize.width / 2,
y: containerSize.height / 2,
};
setStagePos((prevPos) =>
calculateZoomToPoint(oldScale, newScale, center, prevPos),
);
return newScale;
});
}, [containerSize]);
const handleZoomOut = useCallback(() => {
setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
// Zoom towards center of viewport
const center = {
x: containerSize.width / 2,
y: containerSize.height / 2,
};
setStagePos((prevPos) =>
calculateZoomToPoint(oldScale, newScale, center, prevPos),
);
return newScale;
});
}, [containerSize]);
const handleZoomReset = useCallback(() => {
setStageScale(initialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}, [initialScale, containerSize]);
return {
// State
stagePos,
stageScale,
containerSize,
// Handlers
handleWheel,
handleZoomIn,
handleZoomOut,
handleZoomReset,
};
}