summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/components/editor/panels/preview/preview-viewport.tsx
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/components/editor/panels/preview/preview-viewport.tsx')
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-viewport.tsx576
1 files changed, 0 insertions, 576 deletions
diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
deleted file mode 100644
index 2ac6bd92..00000000
--- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
+++ /dev/null
@@ -1,576 +0,0 @@
-"use client";
-
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import {
- canvasToOverlay,
- getDisplayScale,
- positionToOverlay,
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import { PREVIEW_ZOOM } from "@/lib/preview/zoom";
-
-const MIDDLE_MOUSE_BUTTON = 1;
-const IS_AT_FIT_EPSILON = 0.001;
-const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
-
-interface PreviewViewportContextValue {
- canPan: boolean;
- isAtFit: boolean;
- isAtActualSize: boolean;
- isPanning: boolean;
- sceneHeight: number;
- sceneLeft: number;
- sceneTop: number;
- sceneWidth: number;
- zoomPercent: number;
- canvasToOverlay: ({
- canvasX,
- canvasY,
- }: {
- canvasX: number;
- canvasY: number;
- }) => { x: number; y: number };
- fitToScreen: () => void;
- getDisplayScale: () => { x: number; y: number };
- handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
- handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
- handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
- positionToOverlay: ({
- positionX,
- positionY,
- }: {
- positionX: number;
- positionY: number;
- }) => { x: number; y: number };
- panByScreenDelta: ({
- deltaX,
- deltaY,
- }: {
- deltaX: number;
- deltaY: number;
- }) => void;
- resetPan: () => void;
- scaleZoom: ({ factor }: { factor: number }) => void;
- screenPixelsToLogicalThreshold: ({
- screenPixels,
- }: {
- screenPixels: number;
- }) => { x: number; y: number };
- screenToCanvas: ({
- clientX,
- clientY,
- }: {
- clientX: number;
- clientY: number;
- }) => { x: number; y: number } | null;
- setActualSize: () => void;
- setViewportPercent: ({ percent }: { percent: number }) => void;
- zoomIn: () => void;
- zoomOut: () => void;
-}
-
-const PreviewViewportContext =
- createContext<PreviewViewportContextValue | null>(null);
-
-interface PreviewViewportProviderProps {
- children: React.ReactNode;
- value: PreviewViewportContextValue;
-}
-
-interface PreviewViewportStateOptions {
- canvasHeight: number;
- canvasWidth: number;
- viewportHeight: number;
- viewportRef: React.RefObject<HTMLDivElement | null>;
- viewportWidth: number;
-}
-
-interface PanSession {
- centerX: number;
- centerY: number;
- clientX: number;
- clientY: number;
- pointerId: number;
-}
-
-function getClampedCenterAxis({
- axisSize,
- center,
- scale,
- viewportSize,
-}: {
- axisSize: number;
- center: number;
- scale: number;
- viewportSize: number;
-}): number {
- if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
- return axisSize / 2;
- }
-
- const visibleHalfSpan = viewportSize / (2 * scale);
- if (visibleHalfSpan >= axisSize / 2) {
- return axisSize / 2;
- }
-
- return clamp({
- value: center,
- min: visibleHalfSpan,
- max: axisSize - visibleHalfSpan,
- });
-}
-
-function clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX,
- centerY,
- scale,
- viewportHeight,
- viewportWidth,
-}: {
- canvasHeight: number;
- canvasWidth: number;
- centerX: number;
- centerY: number;
- scale: number;
- viewportHeight: number;
- viewportWidth: number;
-}): { x: number; y: number } {
- return {
- x: getClampedCenterAxis({
- center: centerX,
- axisSize: canvasWidth,
- scale,
- viewportSize: viewportWidth,
- }),
- y: getClampedCenterAxis({
- center: centerY,
- axisSize: canvasHeight,
- scale,
- viewportSize: viewportHeight,
- }),
- };
-}
-
-function getFitScale({
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportWidth,
-}: {
- canvasHeight: number;
- canvasWidth: number;
- viewportHeight: number;
- viewportWidth: number;
-}): number {
- if (
- canvasHeight <= 0 ||
- canvasWidth <= 0 ||
- viewportHeight <= 0 ||
- viewportWidth <= 0
- ) {
- return 1;
- }
-
- return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
-}
-
-function getClampedZoom({ zoom }: { zoom: number }): number {
- return clamp({
- value: zoom,
- min: PREVIEW_ZOOM.min,
- max: PREVIEW_ZOOM.max,
- });
-}
-
-export function PreviewViewportProvider({
- children,
- value,
-}: PreviewViewportProviderProps) {
- return (
- <PreviewViewportContext.Provider value={value}>
- {children}
- </PreviewViewportContext.Provider>
- );
-}
-
-export function usePreviewViewportState({
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportRef,
- viewportWidth,
-}: PreviewViewportStateOptions): PreviewViewportContextValue {
- const [zoom, setZoomState] = useState(1);
- const [center, setCenter] = useState(() => ({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- }));
- const [isPanning, setIsPanning] = useState(false);
- const panSessionRef = useRef<PanSession | null>(null);
- const centerRef = useRef(center);
- centerRef.current = center;
-
- const fitScale = useMemo(
- () =>
- getFitScale({
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportWidth,
- }),
- [canvasHeight, canvasWidth, viewportHeight, viewportWidth],
- );
- const viewportScale = fitScale * zoom;
- const geometry = useMemo(
- () => ({
- canvasHeight,
- canvasWidth,
- centerX: center.x,
- centerY: center.y,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- }),
- [
- canvasHeight,
- canvasWidth,
- center.x,
- center.y,
- viewportHeight,
- viewportScale,
- viewportWidth,
- ],
- );
-
- const scaleZoom = useCallback(({ factor }: { factor: number }) => {
- setZoomState((previousZoom) =>
- getClampedZoom({
- zoom: previousZoom * factor,
- }),
- );
- }, []);
-
- const panByScreenDelta = useCallback(
- ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
- if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
- return;
- }
-
- setCenter((previousCenter) =>
- clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX: previousCenter.x + deltaX / viewportScale,
- centerY: previousCenter.y + deltaY / viewportScale,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- }),
- );
- },
- [
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportScale,
- viewportWidth,
- zoom,
- ],
- );
-
- const resetPan = useCallback(() => {
- setCenter({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- });
- }, [canvasHeight, canvasWidth]);
-
- const fitToScreen = useCallback(() => {
- setZoomState(1);
- setCenter({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- });
- }, [canvasHeight, canvasWidth]);
-
- const zoomIn = useCallback(() => {
- setZoomState((previousZoom) =>
- getClampedZoom({
- zoom: previousZoom * PREVIEW_ZOOM.step,
- }),
- );
- }, []);
-
- const zoomOut = useCallback(() => {
- setZoomState((previousZoom) =>
- getClampedZoom({
- zoom: previousZoom / PREVIEW_ZOOM.step,
- }),
- );
- }, []);
-
- const setActualSize = useCallback(() => {
- const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
- setZoomState(
- getClampedZoom({
- zoom: actualSizeZoom,
- }),
- );
- }, [fitScale]);
-
- const setViewportPercent = useCallback(
- ({ percent }: { percent: number }) => {
- const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
- setZoomState(getClampedZoom({ zoom: targetZoom }));
- },
- [fitScale],
- );
-
- const getViewportRect = useCallback(
- () => viewportRef.current?.getBoundingClientRect() ?? null,
- [viewportRef],
- );
-
- const screenToCanvasWithViewport = useCallback(
- ({ clientX, clientY }: { clientX: number; clientY: number }) => {
- const viewportRect = getViewportRect();
- if (!viewportRect) return null;
-
- return screenToCanvas({
- clientX,
- clientY,
- geometry,
- viewportRect,
- });
- },
- [getViewportRect, geometry],
- );
-
- const canvasToOverlayWithViewport = useCallback(
- ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
- canvasToOverlay({
- canvasX,
- canvasY,
- geometry,
- }),
- [geometry],
- );
-
- const positionToOverlayWithViewport = useCallback(
- ({ positionX, positionY }: { positionX: number; positionY: number }) =>
- positionToOverlay({
- geometry,
- positionX,
- positionY,
- }),
- [geometry],
- );
-
- const getDisplayScaleWithViewport = useCallback(
- () => getDisplayScale({ geometry }),
- [geometry],
- );
-
- const getLogicalThresholdForScreenPixels = useCallback(
- ({ screenPixels }: { screenPixels: number }) =>
- screenPixelsToLogicalThreshold({
- geometry,
- screenPixels,
- }),
- [geometry],
- );
-
- const handlePanPointerDown = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
- return false;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- panSessionRef.current = {
- centerX: centerRef.current.x,
- centerY: centerRef.current.y,
- clientX: event.clientX,
- clientY: event.clientY,
- pointerId: event.pointerId,
- };
- setIsPanning(true);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
- return true;
- },
- [zoom],
- );
-
- const handlePanPointerMove = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- const panSession = panSessionRef.current;
- if (!panSession) {
- return false;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- const deltaX = event.clientX - panSession.clientX;
- const deltaY = event.clientY - panSession.clientY;
- const nextCenter = clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX: panSession.centerX - deltaX / viewportScale,
- centerY: panSession.centerY - deltaY / viewportScale,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- });
-
- setCenter(nextCenter);
- return true;
- },
- [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
- );
-
- const handlePanPointerUp = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- const panSession = panSessionRef.current;
- if (!panSession) {
- return false;
- }
-
- if (
- (event.currentTarget as HTMLElement).hasPointerCapture(
- panSession.pointerId,
- )
- ) {
- (event.currentTarget as HTMLElement).releasePointerCapture(
- panSession.pointerId,
- );
- }
-
- panSessionRef.current = null;
- setIsPanning(false);
- return true;
- },
- [],
- );
-
- useEffect(() => {
- setZoomState(1);
- setCenter({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- });
- panSessionRef.current = null;
- setIsPanning(false);
- }, [canvasHeight, canvasWidth]);
-
- useEffect(() => {
- setCenter((previousCenter) =>
- clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX: previousCenter.x,
- centerY: previousCenter.y,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- }),
- );
- }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
-
- const sceneWidth = canvasWidth * viewportScale;
- const sceneHeight = canvasHeight * viewportScale;
- const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
- const sceneTop = viewportHeight / 2 - center.y * viewportScale;
- const canPan = zoom > 1;
- const zoomPercent = Math.round(viewportScale * 100);
-
- return useMemo(
- () => ({
- canPan,
- isAtActualSize: isNearlyEqual({
- leftValue: viewportScale,
- rightValue: 1,
- epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
- }),
- isAtFit: isNearlyEqual({
- leftValue: zoom,
- rightValue: 1,
- epsilon: IS_AT_FIT_EPSILON,
- }),
- isPanning,
- sceneHeight,
- sceneLeft,
- sceneTop,
- sceneWidth,
- zoomPercent,
- canvasToOverlay: canvasToOverlayWithViewport,
- fitToScreen,
- getDisplayScale: getDisplayScaleWithViewport,
- handlePanPointerDown,
- handlePanPointerMove,
- handlePanPointerUp,
- panByScreenDelta,
- positionToOverlay: positionToOverlayWithViewport,
- resetPan,
- scaleZoom,
- screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
- screenToCanvas: screenToCanvasWithViewport,
- setActualSize,
- setViewportPercent,
- zoomIn,
- zoomOut,
- }),
- [
- canPan,
- viewportScale,
- zoom,
- isPanning,
- sceneHeight,
- sceneLeft,
- sceneTop,
- sceneWidth,
- zoomPercent,
- canvasToOverlayWithViewport,
- fitToScreen,
- getDisplayScaleWithViewport,
- handlePanPointerDown,
- handlePanPointerMove,
- handlePanPointerUp,
- panByScreenDelta,
- positionToOverlayWithViewport,
- resetPan,
- scaleZoom,
- getLogicalThresholdForScreenPixels,
- screenToCanvasWithViewport,
- setActualSize,
- setViewportPercent,
- zoomIn,
- zoomOut,
- ],
- );
-}
-
-export function usePreviewViewport() {
- const context = useContext(PreviewViewportContext);
- if (!context) {
- throw new Error(
- "usePreviewViewport must be used within PreviewViewportProvider",
- );
- }
-
- return context;
-}