Files
OpenCut/apps/web/src/preview/components/preview-viewport.tsx
T

577 lines
13 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
canvasToOverlay,
getDisplayScale,
positionToOverlay,
screenPixelsToLogicalThreshold,
screenToCanvas,
} from "@/preview/preview-coords";
import { clamp, isNearlyEqual } from "@/utils/math";
import { PREVIEW_ZOOM } from "@/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;
}