Files
Genarrative/src/components/image-editor/useImageCanvasViewportControls.ts
kdletters 31cc1f0473 拆分图片画布视口控制
新增视口控制 hook 管理缩放、滚轮、坐标和小地图

从主视图移除视口尺寸与滚轮绑定逻辑

补充视口控制单测并更新拆分记录
2026-06-17 09:17:04 +08:00

259 lines
6.7 KiB
TypeScript

import {
type RefObject,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
DEFAULT_CANVAS_SIZE,
} from './ImageCanvasEditorModel';
import type {
CanvasLayer,
CanvasViewport,
DragState,
} from './ImageCanvasEditorTypes';
import {
createMinimapModel,
fitViewportToLayers,
getCanvasDropPoint as resolveCanvasDropPoint,
getCanvasPointFromClient as resolveCanvasPointFromClient,
getWorldPointFromClient,
moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag,
moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer,
scaleViewportFromScreenPoint,
scrollViewportVertically,
zoomViewportFromWheel,
} from './ImageCanvasInteractionModel';
export const DEFAULT_IMAGE_CANVAS_VIEWPORT: CanvasViewport = {
x: -260,
y: 70,
scale: 0.82,
};
type UseImageCanvasViewportControlsOptions = {
canvasViewportRef: RefObject<HTMLDivElement | null>;
layers: CanvasLayer[];
captureCanvasHistory: () => void;
};
export function useImageCanvasViewportControls({
canvasViewportRef,
layers,
captureCanvasHistory,
}: UseImageCanvasViewportControlsOptions) {
const [viewport, setViewport] = useState<CanvasViewport>(
DEFAULT_IMAGE_CANVAS_VIEWPORT,
);
const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE);
const minimapModel = useMemo(
() => createMinimapModel({ layers, viewport, canvasSize }),
[canvasSize, layers, viewport],
);
useEffect(() => {
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
return undefined;
}
const updateCanvasSize = () => {
setCanvasSize({
width: viewportElement.clientWidth || DEFAULT_CANVAS_SIZE.width,
height: viewportElement.clientHeight || DEFAULT_CANVAS_SIZE.height,
});
};
updateCanvasSize();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateCanvasSize);
return () => window.removeEventListener('resize', updateCanvasSize);
}
const observer = new ResizeObserver(updateCanvasSize);
observer.observe(viewportElement);
return () => observer.disconnect();
}, [canvasViewportRef]);
const updateScaleFromCenter = useCallback(
(nextScale: number) => {
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
captureCanvasHistory();
setViewport((currentViewport) =>
scaleViewportFromScreenPoint({
viewport: currentViewport,
nextScale,
screenPoint: null,
}),
);
return;
}
const rect = viewportElement.getBoundingClientRect();
const centerX = rect.width > 0 ? rect.width / 2 : canvasSize.width / 2;
const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2;
captureCanvasHistory();
setViewport((currentViewport) =>
scaleViewportFromScreenPoint({
viewport: currentViewport,
nextScale,
screenPoint: { x: centerX, y: centerY },
}),
);
},
[canvasSize.height, canvasSize.width, canvasViewportRef, captureCanvasHistory],
);
const fitLayers = useCallback(
(targetLayers: CanvasLayer[] = layers) => {
const nextViewport = fitViewportToLayers({
layers: targetLayers,
canvasSize,
});
if (!nextViewport) {
return;
}
captureCanvasHistory();
setViewport(nextViewport);
},
[captureCanvasHistory, canvasSize, layers],
);
const resolveCanvasPoint = useCallback(
(clientX: number, clientY: number) => {
const rect = canvasViewportRef.current?.getBoundingClientRect() ?? null;
return resolveCanvasPointFromClient({ clientX, clientY, rect });
},
[canvasViewportRef],
);
const getCanvasDropPoint = useCallback(
(clientX: number, clientY: number) =>
resolveCanvasDropPoint({
clientX,
clientY,
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
canvasSize,
}),
[canvasSize, canvasViewportRef],
);
const getCanvasPointFromClient = useCallback(
(clientX: number, clientY: number) =>
getWorldPointFromClient({
clientX,
clientY,
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
viewport,
}),
[canvasViewportRef, viewport],
);
const moveViewportFromMinimapPointer = useCallback(
(clientX: number, clientY: number) => {
if (!minimapModel) {
return;
}
const minimapElement = document.querySelector(
'.image-canvas-editor__minimap',
) as HTMLElement | null;
const rect = minimapElement?.getBoundingClientRect();
if (!rect) {
return;
}
setViewport((currentViewport) =>
resolveViewportFromMinimapPointer({
viewport: currentViewport,
canvasSize,
minimapModel,
pointer: {
x: clientX - rect.left,
y: clientY - rect.top,
},
}),
);
},
[canvasSize, minimapModel],
);
const updateViewportFromMinimapDrag = useCallback(
(
dragState: Extract<DragState, { kind: 'minimap' }>,
clientX: number,
clientY: number,
) => {
setViewport(
resolveViewportFromMinimapDrag(dragState, {
x: clientX,
y: clientY,
}),
);
},
[],
);
const handleNativeWheel = useCallback(
(event: WheelEvent) => {
event.preventDefault();
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
return;
}
if (!event.ctrlKey && !event.metaKey) {
setViewport((currentViewport) =>
scrollViewportVertically(currentViewport, event.deltaY),
);
return;
}
const rect = viewportElement.getBoundingClientRect();
const screenPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
setViewport((currentViewport) =>
zoomViewportFromWheel({
viewport: currentViewport,
deltaY: event.deltaY,
screenPoint,
}),
);
},
[canvasViewportRef],
);
useEffect(() => {
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
return undefined;
}
viewportElement.addEventListener('wheel', handleNativeWheel, {
passive: false,
});
return () => {
viewportElement.removeEventListener('wheel', handleNativeWheel);
};
}, [canvasViewportRef, handleNativeWheel]);
return {
viewport,
setViewport,
canvasSize,
minimapModel,
updateScaleFromCenter,
fitLayers,
resolveCanvasPoint,
getCanvasDropPoint,
getCanvasPointFromClient,
moveViewportFromMinimapPointer,
updateViewportFromMinimapDrag,
};
}