From b5cbe62b473304ac8b27c0b8d5c983978aec8348 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 03:55:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E4=BA=A4=E4=BA=92=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ImageCanvasInteractionModel 收口适合视图、缩放、滚轮、框选、拖拽和小地图交互计算 主视图保留 React 事件、pointer capture、history、生成对象回写和状态更新 补充交互模型单测并修复真实浏览器 passive wheel 阻止默认行为问题 更新图片画布前端拆分计划和 TRACKING 验证记录 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 9 +- .../image-editor/ImageCanvasEditorView.tsx | 397 ++++++---------- .../ImageCanvasInteractionModel.test.ts | 246 ++++++++++ .../ImageCanvasInteractionModel.ts | 425 ++++++++++++++++++ .../image-editor/ImageCanvasStageView.tsx | 14 +- 6 files changed, 807 insertions(+), 285 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasInteractionModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasInteractionModel.ts diff --git a/TRACKING.md b/TRACKING.md index 0c06a903..de01d5e2 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -119,3 +119,4 @@ - 2026-06-17 前端拆分第四阶段:新增 `ImageCanvasGenerationComposerView`,把生成图片、生成规范、生成角色形象、生成图标素材、快速编辑、角色动画和修改图片弹窗从主视图抽出;生成提交、上传 input、引用选择、占位框拖拽、结果回写、历史和画布状态机仍保留在主视图。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`。 - 2026-06-17 生成面板拆分浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空浏览器数据后未登录刷新弹出 `账号入口`;关闭登录后 `画布背景色` 打开 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`;点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 跟随对话框,`AI画布工具栏` 保持可见;使用临时开发账号密码登录后上传素材成功,点击素材可添加到画布,切换 `图层` 面板可看到对应图层。 - 2026-06-17 前端拆分第五阶段:新增 `ImageCanvasLayerCommandModel`,把右键图层目标解析、复制 / 粘贴 / 创建副本、层级移动、分组 / 解组、显隐、锁定、翻转和删除的数据规则从主视图抽出;主视图只保留历史、选中态、菜单关闭、元数据清理和导出下载副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasLayerCommandModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景入口打开完整 `画布背景设置` 面板;登录后上传素材成功,点击素材可加入画布,图片右键打开 `图片功能面板`,创建副本、水平翻转、锁定和隐藏均生效,`AI画布工具栏` 保持可见。 +- 2026-06-17 前端拆分第六阶段:新增 `ImageCanvasInteractionModel`,把适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动的纯规则从主视图抽出;主视图保留事件、pointer capture、history、生成对象回写、选中态和状态更新。验证命令:`npm run test -- src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材、画布图层、小地图和 `AI画布工具栏` 保持可见,Ctrl 滚轮从 110% 缩放到 121%,普通滚轮不改变缩放,浏览器控制台无 passive wheel 错误。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 17ffb915..8ad86288 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -64,10 +64,17 @@ - 主视图继续负责命令触发时机、历史快照、选中态、菜单关闭、元数据清理和导出下载等 UI / 浏览器副作用。 - 该模块有独立单测锁定当前右键菜单语义,避免后续调整 UI 时顺手改变图层数据规则。 +## 第六阶段模块 + +- `ImageCanvasInteractionModel.ts` + - 承载画布交互纯计算:适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。 + - 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。 + - 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。 + ## 后续阶段 - 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。 -- 画布交互模型:拖拽、吸附、框选、小地图和滚轮缩放仍散在主视图内,后续应在保证坐标源一致后继续收口。 +- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。 ## 验证计划 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e0d91d5e..ff56cc3a 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -16,7 +16,6 @@ import { useMemo, useRef, useState, - type WheelEvent as ReactWheelEvent, } from 'react'; import { ApiClientError } from '../../services/apiClient'; @@ -48,6 +47,22 @@ import { UnifiedModal } from '../common/UnifiedModal'; 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 { createCanvasLayerClipboard, duplicateCanvasLayers, @@ -71,20 +86,13 @@ import { DEFAULT_CANVAS_BACKGROUND_COLOR, DEFAULT_CANVAS_SIZE, EDITOR_ASSET_FOLDERS, - FIT_VIEW_PADDING, MAX_HISTORY_STEPS, - MAX_SCALE, - MIN_SCALE, - MINIMAP_DRAG_SENSITIVITY, - MINIMAP_PADDING, - MINIMAP_SIZE, TOOLBAR_HALF_WIDTH, clamp, createLayerFromAsset, escapeCssIdentifier, formatImageSizeValue, getDraggedAssetId, - getLayerBounds, hasDataTransferType, hydrateLayer, isGeneratedLayer, @@ -93,7 +101,6 @@ import { normalizeCanvasBackgroundHex, resolveContextMenuPosition, resolveLayerResolutionSize, - resolveSnappedLayerPosition, serializeLayer, } from './ImageCanvasEditorModel'; import { @@ -895,58 +902,10 @@ export function ImageCanvasEditorView() { [openEditorLoginModal], ); - const minimapModel = useMemo(() => { - const layerBounds = getLayerBounds(layers); - if (!layerBounds) { - return null; - } - - const visibleBounds = { - minX: (0 - viewport.x) / viewport.scale, - minY: (0 - viewport.y) / viewport.scale, - maxX: (canvasSize.width - viewport.x) / viewport.scale, - maxY: (canvasSize.height - viewport.y) / viewport.scale, - }; - const bounds = { - minX: Math.min(layerBounds.minX, visibleBounds.minX), - minY: Math.min(layerBounds.minY, visibleBounds.minY), - maxX: Math.max(layerBounds.maxX, visibleBounds.maxX), - maxY: Math.max(layerBounds.maxY, visibleBounds.maxY), - }; - const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); - const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); - const scale = Math.min( - (MINIMAP_SIZE.width - MINIMAP_PADDING * 2) / boundsWidth, - (MINIMAP_SIZE.height - MINIMAP_PADDING * 2) / boundsHeight, - ); - const projectRect = (rect: { - minX: number; - minY: number; - maxX: number; - maxY: number; - }) => ({ - left: MINIMAP_PADDING + (rect.minX - bounds.minX) * scale, - top: MINIMAP_PADDING + (rect.minY - bounds.minY) * scale, - width: Math.max(2, (rect.maxX - rect.minX) * scale), - height: Math.max(2, (rect.maxY - rect.minY) * scale), - }); - - return { - bounds, - scale, - layers: layers.map((layer) => ({ - id: layer.id, - title: layer.title, - rect: projectRect({ - minX: layer.x, - minY: layer.y, - maxX: layer.x + layer.width, - maxY: layer.y + layer.height, - }), - })), - viewport: projectRect(visibleBounds), - }; - }, [canvasSize.height, canvasSize.width, layers, viewport]); + const minimapModel = useMemo( + () => createMinimapModel({ layers, viewport, canvasSize }), + [canvasSize, layers, viewport], + ); useEffect(() => { let cancelled = false; @@ -1287,104 +1246,67 @@ export function ImageCanvasEditorView() { const fitLayers = useCallback( (targetLayers: CanvasLayer[] = layers) => { - if (targetLayers.length === 0) { - return; - } - - const bounds = getLayerBounds(targetLayers); - if (!bounds) { - return; - } - const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); - const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); - const availableWidth = Math.max( - 1, - canvasSize.width - FIT_VIEW_PADDING * 2, - ); - const availableHeight = Math.max( - 1, - canvasSize.height - FIT_VIEW_PADDING * 2, - ); - const scale = clamp( - Math.min( - 1, - availableWidth / boundsWidth, - availableHeight / boundsHeight, - ), - MIN_SCALE, - MAX_SCALE, - ); - - captureCanvasHistory(); - setViewport({ - x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale, - y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale, - scale, + const nextViewport = fitViewportToLayers({ + layers: targetLayers, + canvasSize, }); - }, - [captureCanvasHistory, canvasSize.height, canvasSize.width, layers], - ); + if (!nextViewport) { + return; + } - const updateScaleFromCenter = (nextScale: number) => { - const viewportElement = canvasViewportRef.current; - if (!viewportElement) { captureCanvasHistory(); - setViewport((currentViewport) => ({ - ...currentViewport, - scale: clamp(nextScale, MIN_SCALE, MAX_SCALE), - })); + setViewport(nextViewport); + }, + [captureCanvasHistory, canvasSize, layers], + ); + + 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 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; + const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2; captureCanvasHistory(); - setViewport((currentViewport) => { - const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE); - const worldX = (centerX - currentViewport.x) / currentViewport.scale; - const worldY = (centerY - currentViewport.y) / currentViewport.scale; - return { - x: centerX - worldX * scale, - y: centerY - worldY * scale, - scale, - }; - }); + setViewport((currentViewport) => + scaleViewportFromScreenPoint({ + viewport: currentViewport, + nextScale, + screenPoint: { x: centerX, y: centerY }, + }), + ); }; const resolveCanvasPoint = (clientX: number, clientY: number) => { - const rect = canvasViewportRef.current?.getBoundingClientRect(); - if (!rect) { - return null; - } - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - return null; - } - return { - x: clientX - rect.left, - y: clientY - rect.top, - }; + const rect = canvasViewportRef.current?.getBoundingClientRect() ?? null; + return resolveCanvasPointFromClient({ clientX, clientY, rect }); }; const getCanvasDropPoint = (event: ReactDragEvent) => - resolveCanvasPoint(event.clientX, event.clientY) ?? { - x: Number.isFinite(canvasSize.width) ? canvasSize.width / 2 : 0, - y: Number.isFinite(canvasSize.height) ? canvasSize.height / 2 : 0, - }; + resolveCanvasDropPoint({ + clientX: event.clientX, + clientY: event.clientY, + rect: canvasViewportRef.current?.getBoundingClientRect() ?? null, + canvasSize, + }); const getCanvasPointFromClient = (clientX: number, clientY: number) => { - const rect = canvasViewportRef.current?.getBoundingClientRect(); - const screenX = clientX - (rect?.left ?? 0); - const screenY = clientY - (rect?.top ?? 0); - return { - x: (screenX - viewport.x) / viewport.scale, - y: (screenY - viewport.y) / viewport.scale, - }; + return getWorldPointFromClient({ + clientX, + clientY, + rect: canvasViewportRef.current?.getBoundingClientRect() ?? null, + viewport, + }); }; const duplicateLayersToPoint = ( @@ -3323,7 +3245,7 @@ export function ImageCanvasEditorView() { } }; - const handleWheel = (event: ReactWheelEvent) => { + const handleNativeWheel = useCallback((event: WheelEvent) => { event.preventDefault(); const viewportElement = canvasViewportRef.current; if (!viewportElement) { @@ -3331,34 +3253,38 @@ export function ImageCanvasEditorView() { } if (!event.ctrlKey && !event.metaKey) { - setViewport((currentViewport) => ({ - ...currentViewport, - y: currentViewport.y - event.deltaY, - })); + setViewport((currentViewport) => + scrollViewportVertically(currentViewport, event.deltaY), + ); return; } const rect = viewportElement.getBoundingClientRect(); - const pointerX = event.clientX - rect.left; - const pointerY = event.clientY - rect.top; - const scaleMultiplier = event.deltaY > 0 ? 0.9 : 1.1; + const screenPoint = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + setViewport((currentViewport) => + zoomViewportFromWheel({ + viewport: currentViewport, + deltaY: event.deltaY, + screenPoint, + }), + ); + }, []); - setViewport((currentViewport) => { - const nextScale = clamp( - currentViewport.scale * scaleMultiplier, - MIN_SCALE, - MAX_SCALE, - ); - const worldX = (pointerX - currentViewport.x) / currentViewport.scale; - const worldY = (pointerY - currentViewport.y) / currentViewport.scale; - - return { - x: pointerX - worldX * nextScale, - y: pointerY - worldY * nextScale, - scale: nextScale, - }; + 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(); @@ -3668,41 +3594,30 @@ export function ImageCanvasEditorView() { if (!rect) { return; } - const localX = clamp(clientX - rect.left, 0, MINIMAP_SIZE.width); - const localY = clamp(clientY - rect.top, 0, MINIMAP_SIZE.height); - const worldX = - minimapModel.bounds.minX + - (localX - MINIMAP_PADDING) / minimapModel.scale; - const worldY = - minimapModel.bounds.minY + - (localY - MINIMAP_PADDING) / minimapModel.scale; - setViewport((currentViewport) => ({ - ...currentViewport, - x: canvasSize.width / 2 - worldX * currentViewport.scale, - y: canvasSize.height / 2 - worldY * currentViewport.scale, - })); + setViewport((currentViewport) => + resolveViewportFromMinimapPointer({ + viewport: currentViewport, + canvasSize, + minimapModel, + pointer: { + x: clientX - rect.left, + y: clientY - rect.top, + }, + }), + ); }; - const moveViewportFromMinimapDrag = ( + const updateViewportFromMinimapDrag = ( dragState: Extract, clientX: number, clientY: number, ) => { - const deltaWorldX = - ((clientX - dragState.startClientX) / dragState.minimapScale) * - MINIMAP_DRAG_SENSITIVITY; - const deltaWorldY = - ((clientY - dragState.startClientY) / dragState.minimapScale) * - MINIMAP_DRAG_SENSITIVITY; - setViewport({ - ...dragState.startViewport, - x: - dragState.startViewport.x - - deltaWorldX * dragState.startViewport.scale, - y: - dragState.startViewport.y - - deltaWorldY * dragState.startViewport.scale, - }); + setViewport( + resolveViewportFromMinimapDrag(dragState, { + x: clientX, + y: clientY, + }), + ); }; const handleMinimapPointerDown = ( @@ -3738,24 +3653,12 @@ export function ImageCanvasEditorView() { } : null, ); - const left = Math.min(canvasMarquee.startX, currentX); - const right = Math.max(canvasMarquee.startX, currentX); - const top = Math.min(canvasMarquee.startY, currentY); - const bottom = Math.max(canvasMarquee.startY, currentY); - const selectedIds = layers - .filter((layer) => { - const layerLeft = viewport.x + layer.x * viewport.scale; - const layerTop = viewport.y + layer.y * viewport.scale; - const layerRight = layerLeft + layer.width * viewport.scale; - const layerBottom = layerTop + layer.height * viewport.scale; - return ( - layerLeft <= right && - layerRight >= left && - layerTop <= bottom && - layerBottom >= top - ); - }) - .map((layer) => layer.id); + const selectedIds = selectLayersInsideMarquee({ + marquee: canvasMarquee, + currentPoint: { x: currentX, y: currentY }, + layers, + viewport, + }); setSelectedLayerIds(selectedIds); setSelectedLayerId(selectedIds[0] ?? null); return; @@ -3774,28 +3677,21 @@ export function ImageCanvasEditorView() { if (dragState.kind === 'pan') { const pointer = getPointerClient(event); - setViewport({ - ...dragState.startViewport, - x: dragState.startViewport.x + pointer.x - dragState.startClientX, - y: dragState.startViewport.y + pointer.y - dragState.startClientY, - }); + setViewport(moveViewportFromPan(dragState, pointer)); return; } if (dragState.kind === 'generation-frame') { const pointer = getPointerClient(event); - const deltaX = - (pointer.x - dragState.startClientX) / dragState.startScale; - const deltaY = - (pointer.y - dragState.startClientY) / dragState.startScale; + const nextFramePoint = moveGenerationFrameFromDrag(dragState, pointer); updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) => currentDialog.placeholder ? { ...currentDialog, placeholder: { ...currentDialog.placeholder, - x: dragState.startFrameX + deltaX, - y: dragState.startFrameY + deltaY, + x: nextFramePoint.x, + y: nextFramePoint.y, }, } : currentDialog, @@ -3811,58 +3707,18 @@ export function ImageCanvasEditorView() { dragState.moved = true; } if (dragState.moved) { - moveViewportFromMinimapDrag(dragState, pointer.x, pointer.y); + updateViewportFromMinimapDrag(dragState, pointer.x, pointer.y); } return; } - const movingLayer = layers.find((layer) => layer.id === dragState.layerId); - if (!movingLayer) { + const pointer = getPointerClient(event); + const movedLayers = moveLayersFromDrag({ dragState, layers, pointer }); + if (!movedLayers) { return; } - const pointer = getPointerClient(event); - const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; - const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; - const snapped = resolveSnappedLayerPosition( - movingLayer, - dragState.startLayerX + deltaX, - dragState.startLayerY + deltaY, - layers, - dragState.startScale, - ); - setSnapGuide(snapped.guide); - setLayers((currentLayers) => - currentLayers.map((layer) => - dragState.layerIds.includes(layer.id) - ? (() => { - const startLayer = dragState.startLayers.find( - (item) => item.id === layer.id, - ); - if (!startLayer) { - return layer; - } - if (layer.id === dragState.layerId) { - return { - ...layer, - x: snapped.x, - y: snapped.y, - }; - } - return { - ...layer, - x: - startLayer.x + - deltaX + - (snapped.x - (dragState.startLayerX + deltaX)), - y: - startLayer.y + - deltaY + - (snapped.y - (dragState.startLayerY + deltaY)), - }; - })() - : layer, - ), - ); + setSnapGuide(movedLayers.snapGuide); + setLayers(movedLayers.layers); }; const finishDrag = (event: ReactPointerEvent) => { @@ -4306,7 +4162,6 @@ export function ImageCanvasEditorView() { onCanvasPointerDown={handleCanvasPointerDown} onCanvasPointerMove={handlePointerMove} onCanvasPointerUp={finishDrag} - onCanvasWheel={handleWheel} onCanvasDragOver={handleCanvasDragOver} onCanvasDragLeave={handleCanvasDragLeave} onCanvasDrop={handleCanvasDrop} diff --git a/src/components/image-editor/ImageCanvasInteractionModel.test.ts b/src/components/image-editor/ImageCanvasInteractionModel.test.ts new file mode 100644 index 00000000..b4b55e3c --- /dev/null +++ b/src/components/image-editor/ImageCanvasInteractionModel.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest'; + +import { + createMinimapModel, + fitViewportToLayers, + getCanvasDropPoint, + getCanvasPointFromClient, + getWorldPointFromClient, + moveGenerationFrameFromDrag, + moveLayersFromDrag, + moveViewportFromMinimapDrag, + moveViewportFromMinimapPointer, + moveViewportFromPan, + scaleViewportFromScreenPoint, + scrollViewportVertically, + selectLayersInsideMarquee, + zoomViewportFromWheel, +} from './ImageCanvasInteractionModel'; +import type { CanvasLayer, DragState } from './ImageCanvasEditorTypes'; + +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, + }; +} + +describe('ImageCanvasInteractionModel', () => { + it('fits layers into the visible canvas without upscaling past 100%', () => { + const viewport = fitViewportToLayers({ + layers: [ + createLayer({ id: 'left', x: 0, y: 0, width: 400, height: 300 }), + createLayer({ id: 'right', x: 600, y: 100, width: 200, height: 200 }), + ], + canvasSize: { width: 900, height: 640 }, + }); + + expect(viewport).toEqual({ + x: 50, + y: 170, + scale: 1, + }); + + expect( + fitViewportToLayers({ + layers: [createLayer({ width: 5000, height: 3000 })], + canvasSize: { width: 900, height: 640 }, + })?.scale, + ).toBe(0.24); + }); + + it('maps client points to canvas and world coordinates with safe fallbacks', () => { + const rect = { left: 100, top: 50, right: 700, bottom: 450 }; + + expect( + getCanvasPointFromClient({ clientX: 260, clientY: 190, rect }), + ).toEqual({ x: 160, y: 140 }); + expect( + getCanvasPointFromClient({ clientX: 80, clientY: 190, rect }), + ).toBeNull(); + expect( + getCanvasDropPoint({ + clientX: 80, + clientY: 190, + rect, + canvasSize: { width: 900, height: 640 }, + }), + ).toEqual({ x: 450, y: 320 }); + expect( + getWorldPointFromClient({ + clientX: 260, + clientY: 190, + rect, + viewport: { x: -40, y: 20, scale: 2 }, + }), + ).toEqual({ x: 100, y: 60 }); + }); + + it('scrolls vertically and zooms around a screen point', () => { + const viewport = { x: 10, y: 20, scale: 1 }; + + expect(scrollViewportVertically(viewport, 120)).toEqual({ + x: 10, + y: -100, + scale: 1, + }); + + expect( + scaleViewportFromScreenPoint({ + viewport, + nextScale: 2, + screenPoint: { x: 210, y: 120 }, + }), + ).toEqual({ x: -190, y: -80, scale: 2 }); + + const zoomedViewport = zoomViewportFromWheel({ + viewport, + deltaY: -120, + screenPoint: { x: 210, y: 120 }, + }); + expect(zoomedViewport.x).toBeCloseTo(-10); + expect(zoomedViewport.y).toBeCloseTo(10); + expect(zoomedViewport.scale).toBe(1.1); + }); + + it('selects layers intersecting the marquee in screen space', () => { + const selectedIds = selectLayersInsideMarquee({ + marquee: { + pointerId: 1, + startX: 90, + startY: 90, + currentX: 90, + currentY: 90, + }, + currentPoint: { x: 360, y: 260 }, + viewport: { x: 20, y: 10, scale: 1 }, + layers: [ + createLayer({ id: 'inside', x: 100, y: 120 }), + createLayer({ id: 'outside', x: 600, y: 600 }), + ], + }); + + expect(selectedIds).toEqual(['inside']); + }); + + it('moves pan, generation frames, and layer groups from drag state', () => { + expect( + moveViewportFromPan( + { + kind: 'pan', + pointerId: 1, + startClientX: 100, + startClientY: 200, + startViewport: { x: 10, y: 20, scale: 1 }, + }, + { x: 140, y: 170 }, + ), + ).toEqual({ x: 50, y: -10, scale: 1 }); + + expect( + moveGenerationFrameFromDrag( + { + kind: 'generation-frame', + dialogId: 'dialog-1', + pointerId: 1, + startClientX: 100, + startClientY: 200, + startFrameX: 300, + startFrameY: 400, + startScale: 2, + }, + { x: 140, y: 170 }, + ), + ).toEqual({ x: 320, y: 385 }); + + const layers = [ + createLayer({ id: 'moving', x: 90, y: 90, width: 100, height: 100 }), + createLayer({ id: 'follower', x: 220, y: 90, width: 100, height: 100 }), + createLayer({ id: 'anchor', x: 300, y: 100, width: 100, height: 100 }), + ]; + const result = moveLayersFromDrag({ + layers, + pointer: { x: 210, y: 100 }, + dragState: { + kind: 'layer', + pointerId: 1, + layerId: 'moving', + layerIds: ['moving', 'follower'], + startClientX: 100, + startClientY: 100, + startLayerX: 90, + startLayerY: 90, + startLayers: [ + { id: 'moving', x: 90, y: 90 }, + { id: 'follower', x: 220, y: 90 }, + ], + startScale: 1, + }, + }); + + expect(result?.layers.find((layer) => layer.id === 'moving')).toMatchObject({ + x: 200, + y: 90, + }); + expect(result?.layers.find((layer) => layer.id === 'follower')).toMatchObject({ + x: 330, + y: 90, + }); + expect(result?.snapGuide).toEqual({ + vertical: 300, + horizontal: 90, + }); + }); + + it('builds minimap projection and moves the viewport from minimap interactions', () => { + const layers = [ + createLayer({ id: 'one', x: 100, y: 100, width: 200, height: 100 }), + createLayer({ id: 'two', x: 700, y: 400, width: 100, height: 100 }), + ]; + const minimap = createMinimapModel({ + layers, + viewport: { x: -100, y: -50, scale: 1 }, + canvasSize: { width: 900, height: 640 }, + }); + + expect(minimap?.layers).toHaveLength(2); + expect(minimap?.viewport.width).toBeGreaterThan(2); + + const nextViewport = moveViewportFromMinimapPointer({ + viewport: { x: -100, y: -50, scale: 1 }, + canvasSize: { width: 900, height: 640 }, + minimapModel: minimap!, + pointer: { x: 100, y: 66 }, + }); + expect(nextViewport.x).not.toBe(-100); + expect(nextViewport.y).not.toBe(-50); + + const dragState: Extract = { + kind: 'minimap', + pointerId: 1, + startClientX: 100, + startClientY: 100, + startViewport: { x: -100, y: -50, scale: 1 }, + minimapScale: minimap!.scale, + moved: true, + }; + const draggedViewport = moveViewportFromMinimapDrag(dragState, { + x: 103, + y: 102, + }); + expect(draggedViewport.x).toBeLessThan(-100); + expect(draggedViewport.y).toBeLessThan(-50); + }); +}); diff --git a/src/components/image-editor/ImageCanvasInteractionModel.ts b/src/components/image-editor/ImageCanvasInteractionModel.ts new file mode 100644 index 00000000..a1c8e65a --- /dev/null +++ b/src/components/image-editor/ImageCanvasInteractionModel.ts @@ -0,0 +1,425 @@ +import type { CSSProperties } from 'react'; + +import type { + CanvasLayer, + CanvasMarqueeState, + CanvasViewport, + DragState, +} from './ImageCanvasEditorTypes'; +import { + DEFAULT_CANVAS_SIZE, + FIT_VIEW_PADDING, + MAX_SCALE, + MIN_SCALE, + MINIMAP_DRAG_SENSITIVITY, + MINIMAP_PADDING, + MINIMAP_SIZE, + clamp, + getLayerBounds, + resolveSnappedLayerPosition, +} from './ImageCanvasEditorModel'; + +export type CanvasSize = { + width: number; + height: number; +}; + +export type CanvasPoint = { + x: number; + y: number; +}; + +export type CanvasRect = { + left: number; + top: number; + right: number; + bottom: number; +}; + +export type CanvasLayerMoveResult = { + layers: CanvasLayer[]; + snapGuide: ReturnType['guide']; +}; + +export type StageMinimapModel = { + bounds: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }; + scale: number; + layers: Array<{ + id: string; + title: string; + rect: CSSProperties; + }>; + viewport: CSSProperties; +}; + +export function getCanvasPointFromClient({ + clientX, + clientY, + rect, +}: { + clientX: number; + clientY: number; + rect: CanvasRect | null; +}): CanvasPoint | null { + if (!rect) { + return null; + } + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null; + } + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; +} + +export function getCanvasDropPoint({ + clientX, + clientY, + rect, + canvasSize, +}: { + clientX: number; + clientY: number; + rect: CanvasRect | null; + canvasSize: CanvasSize; +}) { + return ( + getCanvasPointFromClient({ clientX, clientY, rect }) ?? { + x: Number.isFinite(canvasSize.width) ? canvasSize.width / 2 : 0, + y: Number.isFinite(canvasSize.height) ? canvasSize.height / 2 : 0, + } + ); +} + +export function getWorldPointFromClient({ + clientX, + clientY, + rect, + viewport, +}: { + clientX: number; + clientY: number; + rect: CanvasRect | null; + viewport: CanvasViewport; +}) { + const screenX = clientX - (rect?.left ?? 0); + const screenY = clientY - (rect?.top ?? 0); + return { + x: (screenX - viewport.x) / viewport.scale, + y: (screenY - viewport.y) / viewport.scale, + }; +} + +export function fitViewportToLayers({ + layers, + canvasSize, +}: { + layers: CanvasLayer[]; + canvasSize: CanvasSize; +}): CanvasViewport | null { + const bounds = getLayerBounds(layers); + if (!bounds) { + return null; + } + const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); + const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); + const availableWidth = Math.max(1, canvasSize.width - FIT_VIEW_PADDING * 2); + const availableHeight = Math.max(1, canvasSize.height - FIT_VIEW_PADDING * 2); + const scale = clamp( + Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight), + MIN_SCALE, + MAX_SCALE, + ); + + return { + x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale, + y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale, + scale, + }; +} + +export function scaleViewportFromScreenPoint({ + viewport, + nextScale, + screenPoint, +}: { + viewport: CanvasViewport; + nextScale: number; + screenPoint: CanvasPoint | null; +}) { + const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE); + if (!screenPoint) { + return { + ...viewport, + scale, + }; + } + const worldX = (screenPoint.x - viewport.x) / viewport.scale; + const worldY = (screenPoint.y - viewport.y) / viewport.scale; + return { + x: screenPoint.x - worldX * scale, + y: screenPoint.y - worldY * scale, + scale, + }; +} + +export function scrollViewportVertically( + viewport: CanvasViewport, + deltaY: number, +) { + return { + ...viewport, + y: viewport.y - deltaY, + }; +} + +export function zoomViewportFromWheel({ + viewport, + deltaY, + screenPoint, +}: { + viewport: CanvasViewport; + deltaY: number; + screenPoint: CanvasPoint; +}) { + return scaleViewportFromScreenPoint({ + viewport, + nextScale: viewport.scale * (deltaY > 0 ? 0.9 : 1.1), + screenPoint, + }); +} + +export function moveViewportFromPan( + dragState: Extract, + pointer: CanvasPoint, +) { + return { + ...dragState.startViewport, + x: dragState.startViewport.x + pointer.x - dragState.startClientX, + y: dragState.startViewport.y + pointer.y - dragState.startClientY, + }; +} + +export function moveGenerationFrameFromDrag( + dragState: Extract, + pointer: CanvasPoint, +) { + return { + x: + dragState.startFrameX + + (pointer.x - dragState.startClientX) / dragState.startScale, + y: + dragState.startFrameY + + (pointer.y - dragState.startClientY) / dragState.startScale, + }; +} + +export function selectLayersInsideMarquee({ + marquee, + currentPoint, + layers, + viewport, +}: { + marquee: CanvasMarqueeState; + currentPoint: CanvasPoint; + layers: CanvasLayer[]; + viewport: CanvasViewport; +}) { + const left = Math.min(marquee.startX, currentPoint.x); + const right = Math.max(marquee.startX, currentPoint.x); + const top = Math.min(marquee.startY, currentPoint.y); + const bottom = Math.max(marquee.startY, currentPoint.y); + + return layers + .filter((layer) => { + const layerLeft = viewport.x + layer.x * viewport.scale; + const layerTop = viewport.y + layer.y * viewport.scale; + const layerRight = layerLeft + layer.width * viewport.scale; + const layerBottom = layerTop + layer.height * viewport.scale; + return ( + layerLeft <= right && + layerRight >= left && + layerTop <= bottom && + layerBottom >= top + ); + }) + .map((layer) => layer.id); +} + +export function moveLayersFromDrag({ + dragState, + layers, + pointer, +}: { + dragState: Extract; + layers: CanvasLayer[]; + pointer: CanvasPoint; +}): CanvasLayerMoveResult | null { + const movingLayer = layers.find((layer) => layer.id === dragState.layerId); + if (!movingLayer) { + return null; + } + const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; + const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; + const proposedX = dragState.startLayerX + deltaX; + const proposedY = dragState.startLayerY + deltaY; + const snapped = resolveSnappedLayerPosition( + movingLayer, + proposedX, + proposedY, + layers, + dragState.startScale, + ); + + return { + snapGuide: snapped.guide, + layers: layers.map((layer) => + dragState.layerIds.includes(layer.id) + ? (() => { + const startLayer = dragState.startLayers.find( + (item) => item.id === layer.id, + ); + if (!startLayer) { + return layer; + } + if (layer.id === dragState.layerId) { + return { + ...layer, + x: snapped.x, + y: snapped.y, + }; + } + return { + ...layer, + x: startLayer.x + deltaX + (snapped.x - proposedX), + y: startLayer.y + deltaY + (snapped.y - proposedY), + }; + })() + : layer, + ), + }; +} + +export function createMinimapModel({ + layers, + viewport, + canvasSize, +}: { + layers: CanvasLayer[]; + viewport: CanvasViewport; + canvasSize: CanvasSize; +}): StageMinimapModel | null { + const layerBounds = getLayerBounds(layers); + if (!layerBounds) { + return null; + } + + const visibleBounds = { + minX: (0 - viewport.x) / viewport.scale, + minY: (0 - viewport.y) / viewport.scale, + maxX: (canvasSize.width - viewport.x) / viewport.scale, + maxY: (canvasSize.height - viewport.y) / viewport.scale, + }; + const bounds = { + minX: Math.min(layerBounds.minX, visibleBounds.minX), + minY: Math.min(layerBounds.minY, visibleBounds.minY), + maxX: Math.max(layerBounds.maxX, visibleBounds.maxX), + maxY: Math.max(layerBounds.maxY, visibleBounds.maxY), + }; + const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); + const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); + const scale = Math.min( + (MINIMAP_SIZE.width - MINIMAP_PADDING * 2) / boundsWidth, + (MINIMAP_SIZE.height - MINIMAP_PADDING * 2) / boundsHeight, + ); + const projectRect = (rect: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }) => ({ + left: MINIMAP_PADDING + (rect.minX - bounds.minX) * scale, + top: MINIMAP_PADDING + (rect.minY - bounds.minY) * scale, + width: Math.max(2, (rect.maxX - rect.minX) * scale), + height: Math.max(2, (rect.maxY - rect.minY) * scale), + }); + + return { + bounds, + scale, + layers: layers.map((layer) => ({ + id: layer.id, + title: layer.title, + rect: projectRect({ + minX: layer.x, + minY: layer.y, + maxX: layer.x + layer.width, + maxY: layer.y + layer.height, + }), + })), + viewport: projectRect(visibleBounds), + }; +} + +export function moveViewportFromMinimapPointer({ + viewport, + canvasSize, + minimapModel, + pointer, +}: { + viewport: CanvasViewport; + canvasSize: CanvasSize; + minimapModel: StageMinimapModel; + pointer: CanvasPoint; +}) { + const localX = clamp(pointer.x, 0, MINIMAP_SIZE.width); + const localY = clamp(pointer.y, 0, MINIMAP_SIZE.height); + const worldX = + minimapModel.bounds.minX + + (localX - MINIMAP_PADDING) / minimapModel.scale; + const worldY = + minimapModel.bounds.minY + + (localY - MINIMAP_PADDING) / minimapModel.scale; + + return { + ...viewport, + x: canvasSize.width / 2 - worldX * viewport.scale, + y: canvasSize.height / 2 - worldY * viewport.scale, + }; +} + +export function moveViewportFromMinimapDrag( + dragState: Extract, + pointer: CanvasPoint, +) { + const deltaWorldX = + ((pointer.x - dragState.startClientX) / dragState.minimapScale) * + MINIMAP_DRAG_SENSITIVITY; + const deltaWorldY = + ((pointer.y - dragState.startClientY) / dragState.minimapScale) * + MINIMAP_DRAG_SENSITIVITY; + + return { + ...dragState.startViewport, + x: dragState.startViewport.x - deltaWorldX * dragState.startViewport.scale, + y: dragState.startViewport.y - deltaWorldY * dragState.startViewport.scale, + }; +} + +export function getDefaultCanvasScreenCenter(canvasSize = DEFAULT_CANVAS_SIZE) { + return { + x: canvasSize.width / 2, + y: canvasSize.height / 2, + }; +} diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index ee1a26e3..c4d34f20 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -29,7 +29,6 @@ import type { PointerEvent as ReactPointerEvent, ReactNode, RefObject, - WheelEvent as ReactWheelEvent, } from 'react'; import { PlatformActionButton } from '../common/PlatformActionButton'; @@ -43,6 +42,7 @@ import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import type { StageMinimapModel } from './ImageCanvasInteractionModel'; import { CANVAS_BACKGROUND_OPTIONS, CANVAS_WORLD_SIZE, @@ -70,15 +70,6 @@ import type { SnapGuide, } from './ImageCanvasEditorTypes'; -type StageMinimapModel = { - layers: Array<{ - id: string; - title: string; - rect: CSSProperties; - }>; - viewport: CSSProperties; -}; - type ImageCanvasStageViewProps = { canvasViewportRef: RefObject; specToolWrapRef: RefObject; @@ -116,7 +107,6 @@ type ImageCanvasStageViewProps = { onCanvasPointerDown: (event: ReactPointerEvent) => void; onCanvasPointerMove: (event: ReactPointerEvent) => void; onCanvasPointerUp: (event: ReactPointerEvent) => void; - onCanvasWheel: (event: ReactWheelEvent) => void; onCanvasDragOver: (event: ReactDragEvent) => void; onCanvasDragLeave: (event: ReactDragEvent) => void; onCanvasDrop: (event: ReactDragEvent) => void; @@ -239,7 +229,6 @@ export function ImageCanvasStageView({ onCanvasPointerDown, onCanvasPointerMove, onCanvasPointerUp, - onCanvasWheel, onCanvasDragOver, onCanvasDragLeave, onCanvasDrop, @@ -294,7 +283,6 @@ export function ImageCanvasStageView({ onPointerMove={onCanvasPointerMove} onPointerUp={onCanvasPointerUp} onPointerCancel={onCanvasPointerUp} - onWheel={onCanvasWheel} onDragOver={onCanvasDragOver} onDragLeave={onCanvasDragLeave} onDrop={onCanvasDrop}