拆分图片画布视口控制

新增视口控制 hook 管理缩放、滚轮、坐标和小地图

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

补充视口控制单测并更新拆分记录
This commit is contained in:
2026-06-17 09:17:04 +08:00
parent e67e921c67
commit 31cc1f0473
5 changed files with 517 additions and 198 deletions

View File

@@ -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>,
) => {

View File

@@ -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);
});
});

View 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,
};
}