拆分图片画布视口控制
新增视口控制 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 前端拆分第十五阶段:新增 `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。
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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>,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -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