拆分图片画布视口控制
新增视口控制 hook 管理缩放、滚轮、坐标和小地图 从主视图移除视口尺寸与滚轮绑定逻辑 补充视口控制单测并更新拆分记录
This commit is contained in:
@@ -132,3 +132,4 @@
|
||||
- 2026-06-17 前端拆分第十五阶段:新增 `useImageCanvasGenerationWorkflow`,把生成入口、规范 / 角色 / 图标 / 修改 / 快速编辑 / 角色动画状态机、真实生成提交、结果落图、失败恢复和删除图层后的生成态清理从主视图抽出;主视图保留画布事件、浮层定位、上传、项目资源持久化和历史捕获。验证命令:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,关闭登录后 `画布背景色` 打开完整 `画布背景设置` 面板,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 均可见;登录临时开发账号后上传素材成功,素材数增加,点击素材可加入画布,切换 `图层` 面板可看到对应图层,登录后控制台无前端 error。
|
||||
- 2026-06-17 上传鉴权回归修正:普通素材上传入口在未登录时先打开 `账号入口`,不再先弹系统文件选择器;登录后用户再次点击上传即可打开文件选择器,避免浏览器拦截登录后异步触发的系统选择器。拖拽 / 已选中文件的续传逻辑仍保留,角色 / 图标生成参考图仍作为本地引用上传,不强制登录。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,`画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;未登录点击侧栏上传直接弹登录,不出现文件选择器;登录后再次点击上传可以打开文件选择器并上传成功,素材计数从 4 增至 5,`AI画布工具栏` 保持可见。
|
||||
- 2026-06-17 前端拆分第十六阶段:新增 `useImageCanvasEditorChrome`,把项目标题 / 重命名、侧栏开关、当前工具、缩放菜单、背景设置、小地图和背景 HEX 状态从主视图抽出;主视图继续保留上传 / 生成 / 键盘 Escape 的跨工作流编排。新增 hook 单测覆盖重命名、鉴权登录、背景色输入、面板开关和工具状态;主视图从 2039 行降至 1966 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。
|
||||
- 2026-06-17 前端拆分第十七阶段:新增 `useImageCanvasViewportControls`,把视口状态、画布尺寸、小地图投影、适合视图、中心缩放、滚轮语义、坐标换算和小地图移动从主视图抽出;主视图继续保留图层拖拽、框选、生成占位拖拽、上传 drop 和历史触发时机。验证命令:`npm run test -- src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏可见;普通滚轮不改变缩放,Ctrl 滚轮从 `100%` 到 `110%`;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,登录后控制台无前端 error。
|
||||
|
||||
@@ -146,14 +146,21 @@
|
||||
- 主视图继续负责真正跨工作流的动作编排,例如上传工具触发上传工作流、生成工具触发生成工作流、项目加载后注入标题、键盘 Escape 同时关闭生成 / 快速编辑 / 图片菜单等非 chrome 面板。
|
||||
- 该 hook 用独立单测覆盖项目重命名、鉴权失败登录、背景色合法 / 非法 HEX、侧栏切换、缩放 / 背景面板关闭、小地图和工具状态,避免后续改顶部栏或左下 dock 时把这些状态重新散回主视图。
|
||||
|
||||
## 第十七阶段模块
|
||||
|
||||
- `useImageCanvasViewportControls.ts`
|
||||
- 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。
|
||||
- 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。
|
||||
- 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。
|
||||
|
||||
## 后续阶段
|
||||
|
||||
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
|
||||
- 生成对象定位、画布 pointer 事件、素材入画布、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。
|
||||
- 生成对象定位、图层 / 生成占位 / 框选 pointer 事件、素材入画布、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- `npm run test -- src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
|
||||
- `npm run test -- src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
|
||||
- `npm run typecheck`
|
||||
- `npm run check:encoding`
|
||||
- `git diff --check`
|
||||
|
||||
@@ -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