拆分图片画布视口控制
新增视口控制 hook 管理缩放、滚轮、坐标和小地图 从主视图移除视口尺寸与滚轮绑定逻辑 补充视口控制单测并更新拆分记录
This commit is contained in:
@@ -18,20 +18,10 @@ import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||
import {
|
||||
createMinimapModel,
|
||||
fitViewportToLayers,
|
||||
getCanvasDropPoint as resolveCanvasDropPoint,
|
||||
getCanvasPointFromClient as resolveCanvasPointFromClient,
|
||||
getWorldPointFromClient,
|
||||
moveGenerationFrameFromDrag,
|
||||
moveLayersFromDrag,
|
||||
moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag,
|
||||
moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer,
|
||||
moveViewportFromPan,
|
||||
scaleViewportFromScreenPoint,
|
||||
scrollViewportVertically,
|
||||
selectLayersInsideMarquee,
|
||||
zoomViewportFromWheel,
|
||||
} from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
getCanvasLayersByIds,
|
||||
@@ -41,7 +31,6 @@ import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
||||
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
||||
import {
|
||||
ASSET_DRAG_MIME_TYPE,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
TOOLBAR_HALF_WIDTH,
|
||||
clamp,
|
||||
createLayerFromAsset,
|
||||
@@ -83,6 +72,10 @@ import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWork
|
||||
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
||||
import {
|
||||
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
||||
useImageCanvasViewportControls,
|
||||
} from './useImageCanvasViewportControls';
|
||||
|
||||
function isEditableTarget(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
@@ -152,11 +145,8 @@ export function ImageCanvasEditorView() {
|
||||
const isShiftPressedRef = useRef(false);
|
||||
const layerCounterRef = useRef(0);
|
||||
const layersRef = useRef<CanvasLayer[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>({
|
||||
x: -260,
|
||||
y: 70,
|
||||
scale: 0.82,
|
||||
});
|
||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -166,12 +156,6 @@ export function ImageCanvasEditorView() {
|
||||
() => {},
|
||||
);
|
||||
const suppressAssetClickRef = useRef(false);
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: -260,
|
||||
y: 70,
|
||||
scale: 0.82,
|
||||
});
|
||||
const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE);
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
||||
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
|
||||
null,
|
||||
@@ -191,6 +175,26 @@ export function ImageCanvasEditorView() {
|
||||
const [uploadDropTarget, setUploadDropTarget] = useState<
|
||||
'canvas' | 'assets' | null
|
||||
>(null);
|
||||
const captureViewportHistory = useCallback(() => {
|
||||
captureCanvasHistoryRef.current();
|
||||
}, []);
|
||||
const {
|
||||
viewport,
|
||||
setViewport,
|
||||
canvasSize,
|
||||
minimapModel,
|
||||
updateScaleFromCenter,
|
||||
fitLayers,
|
||||
resolveCanvasPoint,
|
||||
getCanvasDropPoint,
|
||||
getCanvasPointFromClient,
|
||||
moveViewportFromMinimapPointer,
|
||||
updateViewportFromMinimapDrag,
|
||||
} = useImageCanvasViewportControls({
|
||||
canvasViewportRef,
|
||||
layers,
|
||||
captureCanvasHistory: captureViewportHistory,
|
||||
});
|
||||
|
||||
selectedLayerIdRef.current = selectedLayerId;
|
||||
selectedLayerIdsRef.current = selectedLayerIds;
|
||||
@@ -465,6 +469,7 @@ export function ImageCanvasEditorView() {
|
||||
setters: canvasHistorySetters,
|
||||
resetters: canvasHistoryResetters,
|
||||
});
|
||||
captureCanvasHistoryRef.current = captureCanvasHistory;
|
||||
const selectSingleLayer = useCallback((layerId: string | null) => {
|
||||
setSelectedLayerId(layerId);
|
||||
setSelectedLayerIds(layerId ? [layerId] : []);
|
||||
@@ -482,21 +487,6 @@ export function ImageCanvasEditorView() {
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
const fitLayers = useCallback(
|
||||
(targetLayers: CanvasLayer[] = layers) => {
|
||||
const nextViewport = fitViewportToLayers({
|
||||
layers: targetLayers,
|
||||
canvasSize,
|
||||
});
|
||||
if (!nextViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
captureCanvasHistory();
|
||||
setViewport(nextViewport);
|
||||
},
|
||||
[captureCanvasHistory, canvasSize, layers],
|
||||
);
|
||||
const projectPersistenceRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
@@ -715,36 +705,6 @@ export function ImageCanvasEditorView() {
|
||||
setContextMenu(null);
|
||||
}, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -942,55 +902,6 @@ export function ImageCanvasEditorView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateScaleFromCenter = (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 },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const resolveCanvasPoint = (clientX: number, clientY: number) => {
|
||||
const rect = canvasViewportRef.current?.getBoundingClientRect() ?? null;
|
||||
return resolveCanvasPointFromClient({ clientX, clientY, rect });
|
||||
};
|
||||
|
||||
const getCanvasDropPoint = (event: ReactDragEvent<HTMLDivElement>) =>
|
||||
resolveCanvasDropPoint({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
|
||||
canvasSize,
|
||||
});
|
||||
|
||||
const getCanvasPointFromClient = (clientX: number, clientY: number) => {
|
||||
return getWorldPointFromClient({
|
||||
clientX,
|
||||
clientY,
|
||||
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
|
||||
viewport,
|
||||
});
|
||||
};
|
||||
|
||||
const addAssetLayer = (
|
||||
asset: EditorAsset,
|
||||
position?: { x: number; y: number },
|
||||
@@ -1017,47 +928,6 @@ export function ImageCanvasEditorView() {
|
||||
|
||||
deleteLayerByIdRef.current = deleteLayerById;
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewportElement = canvasViewportRef.current;
|
||||
if (!viewportElement) {
|
||||
return undefined;
|
||||
}
|
||||
viewportElement.addEventListener('wheel', handleNativeWheel, {
|
||||
passive: false,
|
||||
});
|
||||
return () => {
|
||||
viewportElement.removeEventListener('wheel', handleNativeWheel);
|
||||
};
|
||||
}, [handleNativeWheel]);
|
||||
|
||||
const startPan = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const pointer = getPointerClient(event);
|
||||
@@ -1140,7 +1010,10 @@ export function ImageCanvasEditorView() {
|
||||
event.preventDefault();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
addAssetLayer(draggedAsset, getCanvasDropPoint(event));
|
||||
addAssetLayer(
|
||||
draggedAsset,
|
||||
getCanvasDropPoint(event.clientX, event.clientY),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const files = event.dataTransfer.files;
|
||||
@@ -1150,7 +1023,7 @@ export function ImageCanvasEditorView() {
|
||||
event.preventDefault();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
const canvasPoint = getCanvasDropPoint(event);
|
||||
const canvasPoint = getCanvasDropPoint(event.clientX, event.clientY);
|
||||
const defaultFolder =
|
||||
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0];
|
||||
addUploadedFiles(files, {
|
||||
@@ -1344,43 +1217,6 @@ export function ImageCanvasEditorView() {
|
||||
};
|
||||
};
|
||||
|
||||
const moveViewportFromMinimapPointer = (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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const updateViewportFromMinimapDrag = (
|
||||
dragState: Extract<DragState, { kind: 'minimap' }>,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
setViewport(
|
||||
resolveViewportFromMinimapDrag(dragState, {
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleMinimapPointerDown = (
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useRef } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { useImageCanvasViewportControls } from './useImageCanvasViewportControls';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer>): CanvasLayer {
|
||||
const id = overrides.id ?? 'layer-a';
|
||||
return {
|
||||
id,
|
||||
resourceId: `resource-${id}`,
|
||||
title: id,
|
||||
src: `data:image/png;base64,${id}`,
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 200,
|
||||
height: 100,
|
||||
originalWidth: 200,
|
||||
originalHeight: 100,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setElementBox(
|
||||
element: HTMLElement,
|
||||
box: { width: number; height: number; left?: number; top?: number },
|
||||
) {
|
||||
Object.defineProperty(element, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: box.width,
|
||||
});
|
||||
Object.defineProperty(element, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: box.height,
|
||||
});
|
||||
element.getBoundingClientRect = () =>
|
||||
({
|
||||
x: box.left ?? 0,
|
||||
y: box.top ?? 0,
|
||||
left: box.left ?? 0,
|
||||
top: box.top ?? 0,
|
||||
right: (box.left ?? 0) + box.width,
|
||||
bottom: (box.top ?? 0) + box.height,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
}
|
||||
|
||||
function ViewportHarness({
|
||||
captureCanvasHistory = vi.fn(),
|
||||
}: {
|
||||
captureCanvasHistory?: () => void;
|
||||
}) {
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const layers = [
|
||||
createLayer({ id: 'one', x: 0, y: 0, width: 400, height: 300 }),
|
||||
createLayer({ id: 'two', x: 600, y: 100, width: 200, height: 200 }),
|
||||
];
|
||||
const controls = useImageCanvasViewportControls({
|
||||
canvasViewportRef: viewportRef,
|
||||
layers,
|
||||
captureCanvasHistory,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={(element) => {
|
||||
viewportRef.current = element;
|
||||
if (element) {
|
||||
setElementBox(element, { width: 900, height: 640 });
|
||||
}
|
||||
}}
|
||||
data-testid="viewport-element"
|
||||
/>
|
||||
<div
|
||||
className="image-canvas-editor__minimap"
|
||||
data-testid="minimap"
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
setElementBox(element, {
|
||||
left: 20,
|
||||
top: 30,
|
||||
width: 160,
|
||||
height: 120,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span data-testid="viewport">
|
||||
{controls.viewport.x.toFixed(2)},{controls.viewport.y.toFixed(2)},
|
||||
{controls.viewport.scale.toFixed(2)}
|
||||
</span>
|
||||
<span data-testid="canvas-size">
|
||||
{controls.canvasSize.width}x{controls.canvasSize.height}
|
||||
</span>
|
||||
<span data-testid="drop-point">
|
||||
{JSON.stringify(controls.getCanvasDropPoint(260, 190))}
|
||||
</span>
|
||||
<span data-testid="world-point">
|
||||
{JSON.stringify(controls.getCanvasPointFromClient(260, 190))}
|
||||
</span>
|
||||
<span data-testid="minimap-count">
|
||||
{controls.minimapModel?.layers.length ?? 0}
|
||||
</span>
|
||||
<button type="button" onClick={() => controls.fitLayers()}>
|
||||
fit
|
||||
</button>
|
||||
<button type="button" onClick={() => controls.updateScaleFromCenter(2)}>
|
||||
zoom center
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
controls.updateViewportFromMinimapDrag(
|
||||
{
|
||||
kind: 'minimap',
|
||||
pointerId: 1,
|
||||
startClientX: 100,
|
||||
startClientY: 100,
|
||||
startViewport: { x: -100, y: -50, scale: 1 },
|
||||
minimapScale: controls.minimapModel?.scale ?? 1,
|
||||
moved: true,
|
||||
},
|
||||
104,
|
||||
103,
|
||||
);
|
||||
}}
|
||||
>
|
||||
minimap drag
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => controls.moveViewportFromMinimapPointer(100, 90)}
|
||||
>
|
||||
minimap click
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useImageCanvasViewportControls', () => {
|
||||
it('owns canvas size, fit view, center zoom and canvas point helpers', () => {
|
||||
const captureCanvasHistory = vi.fn();
|
||||
render(
|
||||
<ViewportHarness captureCanvasHistory={captureCanvasHistory} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('canvas-size').textContent).toBe('900x640');
|
||||
expect(screen.getByTestId('minimap-count').textContent).toBe('2');
|
||||
expect(screen.getByTestId('drop-point').textContent).toBe(
|
||||
'{"x":260,"y":190}',
|
||||
);
|
||||
const worldPoint = JSON.parse(
|
||||
screen.getByTestId('world-point').textContent ?? '{}',
|
||||
) as { x: number; y: number };
|
||||
expect(worldPoint.x).toBeCloseTo(634.1463);
|
||||
expect(worldPoint.y).toBeCloseTo(146.3415);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'fit' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(
|
||||
'50.00,170.00,1.00',
|
||||
);
|
||||
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'zoom center' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(
|
||||
'-350.00,20.00,2.00',
|
||||
);
|
||||
expect(captureCanvasHistory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles vertical wheel scroll, ctrl wheel zoom and minimap movement', () => {
|
||||
render(<ViewportHarness />);
|
||||
const viewportElement = screen.getByTestId('viewport-element');
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(viewportElement, { deltaY: 120, clientX: 260, clientY: 190 });
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(
|
||||
'-260.00,-50.00,0.82',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(viewportElement, {
|
||||
ctrlKey: true,
|
||||
deltaY: -120,
|
||||
clientX: 260,
|
||||
clientY: 190,
|
||||
});
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(
|
||||
'-312.00,-74.00,0.90',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap drag' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toContain('-');
|
||||
|
||||
const beforeClick = screen.getByTestId('viewport').textContent;
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap click' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).not.toBe(beforeClick);
|
||||
});
|
||||
});
|
||||
258
src/components/image-editor/useImageCanvasViewportControls.ts
Normal file
258
src/components/image-editor/useImageCanvasViewportControls.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user