From 31cc1f0473d0e1906b69e4588947bf4f72e07983 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 09:17:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E8=A7=86=E5=8F=A3=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增视口控制 hook 管理缩放、滚轮、坐标和小地图 从主视图移除视口尺寸与滚轮绑定逻辑 补充视口控制单测并更新拆分记录 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 11 +- .../image-editor/ImageCanvasEditorView.tsx | 228 +++------------- .../useImageCanvasViewportControls.test.tsx | 217 +++++++++++++++ .../useImageCanvasViewportControls.ts | 258 ++++++++++++++++++ 5 files changed, 517 insertions(+), 198 deletions(-) create mode 100644 src/components/image-editor/useImageCanvasViewportControls.test.tsx create mode 100644 src/components/image-editor/useImageCanvasViewportControls.ts diff --git a/TRACKING.md b/TRACKING.md index b45f8ab6..d6f9b659 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -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。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 58245bed..3d8267ed 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -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` diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 0f2fd8ce..eaa19dd5 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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([]); - const viewportRef = useRef({ - x: -260, - y: 70, - scale: 0.82, - }); + const viewportRef = useRef(DEFAULT_IMAGE_CANVAS_VIEWPORT); + const captureCanvasHistoryRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); @@ -166,12 +156,6 @@ export function ImageCanvasEditorView() { () => {}, ); const suppressAssetClickRef = useRef(false); - const [viewport, setViewport] = useState({ - x: -260, - y: 70, - scale: 0.82, - }); - const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE); const [layers, setLayers] = useState([]); const [canvasMarquee, setCanvasMarquee] = useState( 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) => - 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) => { 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, - clientX: number, - clientY: number, - ) => { - setViewport( - resolveViewportFromMinimapDrag(dragState, { - x: clientX, - y: clientY, - }), - ); - }; - const handleMinimapPointerDown = ( event: ReactPointerEvent, ) => { diff --git a/src/components/image-editor/useImageCanvasViewportControls.test.tsx b/src/components/image-editor/useImageCanvasViewportControls.test.tsx new file mode 100644 index 00000000..ad23631a --- /dev/null +++ b/src/components/image-editor/useImageCanvasViewportControls.test.tsx @@ -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 { + 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(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 ( +
+
{ + viewportRef.current = element; + if (element) { + setElementBox(element, { width: 900, height: 640 }); + } + }} + data-testid="viewport-element" + /> +
{ + if (element) { + setElementBox(element, { + left: 20, + top: 30, + width: 160, + height: 120, + }); + } + }} + /> + + {controls.viewport.x.toFixed(2)},{controls.viewport.y.toFixed(2)}, + {controls.viewport.scale.toFixed(2)} + + + {controls.canvasSize.width}x{controls.canvasSize.height} + + + {JSON.stringify(controls.getCanvasDropPoint(260, 190))} + + + {JSON.stringify(controls.getCanvasPointFromClient(260, 190))} + + + {controls.minimapModel?.layers.length ?? 0} + + + + + +
+ ); +} + +describe('useImageCanvasViewportControls', () => { + it('owns canvas size, fit view, center zoom and canvas point helpers', () => { + const captureCanvasHistory = vi.fn(); + render( + , + ); + + 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(); + 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); + }); +}); diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts new file mode 100644 index 00000000..30e97ea0 --- /dev/null +++ b/src/components/image-editor/useImageCanvasViewportControls.ts @@ -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; + layers: CanvasLayer[]; + captureCanvasHistory: () => void; +}; + +export function useImageCanvasViewportControls({ + canvasViewportRef, + layers, + captureCanvasHistory, +}: UseImageCanvasViewportControlsOptions) { + const [viewport, setViewport] = useState( + 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, + 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, + }; +}