拆分图片画布视口控制

新增视口控制 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

@@ -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 前端拆分第十五阶段:新增 `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 上传鉴权回归修正:普通素材上传入口在未登录时先打开 `账号入口`,不再先弹系统文件选择器;登录后用户再次点击上传即可打开文件选择器,避免浏览器拦截登录后异步触发的系统选择器。拖拽 / 已选中文件的续传逻辑仍保留,角色 / 图标生成参考图仍作为本地引用上传,不强制登录。验证命令:`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 前端拆分第十六阶段:新增 `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。

View File

@@ -146,14 +146,21 @@
- 主视图继续负责真正跨工作流的动作编排,例如上传工具触发上传工作流、生成工具触发生成工作流、项目加载后注入标题、键盘 Escape 同时关闭生成 / 快速编辑 / 图片菜单等非 chrome 面板。 - 主视图继续负责真正跨工作流的动作编排,例如上传工具触发上传工作流、生成工具触发生成工作流、项目加载后注入标题、键盘 Escape 同时关闭生成 / 快速编辑 / 图片菜单等非 chrome 面板。
- 该 hook 用独立单测覆盖项目重命名、鉴权失败登录、背景色合法 / 非法 HEX、侧栏切换、缩放 / 背景面板关闭、小地图和工具状态,避免后续改顶部栏或左下 dock 时把这些状态重新散回主视图。 - 该 hook 用独立单测覆盖项目重命名、鉴权失败登录、背景色合法 / 非法 HEX、侧栏切换、缩放 / 背景面板关闭、小地图和工具状态,避免后续改顶部栏或左下 dock 时把这些状态重新散回主视图。
## 第十七阶段模块
- `useImageCanvasViewportControls.ts`
- 承载画布视口控制:`viewport``canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。
- 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。
- 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。
## 后续阶段 ## 后续阶段
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。 - 后续可继续选择更高内聚的交互 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 typecheck`
- `npm run check:encoding` - `npm run check:encoding`
- `git diff --check` - `git diff --check`

View File

@@ -18,20 +18,10 @@ import { useAuthUi } from '../auth/AuthUiContext';
import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView'; import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
import { import {
createMinimapModel,
fitViewportToLayers,
getCanvasDropPoint as resolveCanvasDropPoint,
getCanvasPointFromClient as resolveCanvasPointFromClient,
getWorldPointFromClient,
moveGenerationFrameFromDrag, moveGenerationFrameFromDrag,
moveLayersFromDrag, moveLayersFromDrag,
moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag,
moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer,
moveViewportFromPan, moveViewportFromPan,
scaleViewportFromScreenPoint,
scrollViewportVertically,
selectLayersInsideMarquee, selectLayersInsideMarquee,
zoomViewportFromWheel,
} from './ImageCanvasInteractionModel'; } from './ImageCanvasInteractionModel';
import { import {
getCanvasLayersByIds, getCanvasLayersByIds,
@@ -41,7 +31,6 @@ import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView'; import { ImageCanvasStageView } from './ImageCanvasStageView';
import { import {
ASSET_DRAG_MIME_TYPE, ASSET_DRAG_MIME_TYPE,
DEFAULT_CANVAS_SIZE,
TOOLBAR_HALF_WIDTH, TOOLBAR_HALF_WIDTH,
clamp, clamp,
createLayerFromAsset, createLayerFromAsset,
@@ -83,6 +72,10 @@ import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWork
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
import {
DEFAULT_IMAGE_CANVAS_VIEWPORT,
useImageCanvasViewportControls,
} from './useImageCanvasViewportControls';
function isEditableTarget(event: KeyboardEvent) { function isEditableTarget(event: KeyboardEvent) {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
@@ -152,11 +145,8 @@ export function ImageCanvasEditorView() {
const isShiftPressedRef = useRef(false); const isShiftPressedRef = useRef(false);
const layerCounterRef = useRef(0); const layerCounterRef = useRef(0);
const layersRef = useRef<CanvasLayer[]>([]); const layersRef = useRef<CanvasLayer[]>([]);
const viewportRef = useRef<CanvasViewport>({ const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
x: -260, const captureCanvasHistoryRef = useRef<() => void>(() => {});
y: 70,
scale: 0.82,
});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null); const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null); const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null); const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
@@ -166,12 +156,6 @@ export function ImageCanvasEditorView() {
() => {}, () => {},
); );
const suppressAssetClickRef = useRef(false); 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 [layers, setLayers] = useState<CanvasLayer[]>([]);
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>( const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
null, null,
@@ -191,6 +175,26 @@ export function ImageCanvasEditorView() {
const [uploadDropTarget, setUploadDropTarget] = useState< const [uploadDropTarget, setUploadDropTarget] = useState<
'canvas' | 'assets' | null 'canvas' | 'assets' | null
>(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; selectedLayerIdRef.current = selectedLayerId;
selectedLayerIdsRef.current = selectedLayerIds; selectedLayerIdsRef.current = selectedLayerIds;
@@ -465,6 +469,7 @@ export function ImageCanvasEditorView() {
setters: canvasHistorySetters, setters: canvasHistorySetters,
resetters: canvasHistoryResetters, resetters: canvasHistoryResetters,
}); });
captureCanvasHistoryRef.current = captureCanvasHistory;
const selectSingleLayer = useCallback((layerId: string | null) => { const selectSingleLayer = useCallback((layerId: string | null) => {
setSelectedLayerId(layerId); setSelectedLayerId(layerId);
setSelectedLayerIds(layerId ? [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( const projectPersistenceRefs = useMemo(
() => ({ () => ({
layersRef, layersRef,
@@ -715,36 +705,6 @@ export function ImageCanvasEditorView() {
setContextMenu(null); setContextMenu(null);
}, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]); }, [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(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ( 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 = ( const addAssetLayer = (
asset: EditorAsset, asset: EditorAsset,
position?: { x: number; y: number }, position?: { x: number; y: number },
@@ -1017,47 +928,6 @@ export function ImageCanvasEditorView() {
deleteLayerByIdRef.current = deleteLayerById; 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>) => { const startPan = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
const pointer = getPointerClient(event); const pointer = getPointerClient(event);
@@ -1140,7 +1010,10 @@ export function ImageCanvasEditorView() {
event.preventDefault(); event.preventDefault();
setUploadDropTarget(null); setUploadDropTarget(null);
updateAssetMoveDropFolder(null); updateAssetMoveDropFolder(null);
addAssetLayer(draggedAsset, getCanvasDropPoint(event)); addAssetLayer(
draggedAsset,
getCanvasDropPoint(event.clientX, event.clientY),
);
return; return;
} }
const files = event.dataTransfer.files; const files = event.dataTransfer.files;
@@ -1150,7 +1023,7 @@ export function ImageCanvasEditorView() {
event.preventDefault(); event.preventDefault();
setUploadDropTarget(null); setUploadDropTarget(null);
updateAssetMoveDropFolder(null); updateAssetMoveDropFolder(null);
const canvasPoint = getCanvasDropPoint(event); const canvasPoint = getCanvasDropPoint(event.clientX, event.clientY);
const defaultFolder = const defaultFolder =
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0];
addUploadedFiles(files, { 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 = ( const handleMinimapPointerDown = (
event: ReactPointerEvent<HTMLButtonElement>, 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,
};
}