抽出图片画布交互模型
新增 ImageCanvasInteractionModel 收口适合视图、缩放、滚轮、框选、拖拽和小地图交互计算 主视图保留 React 事件、pointer capture、history、生成对象回写和状态更新 补充交互模型单测并修复真实浏览器 passive wheel 阻止默认行为问题 更新图片画布前端拆分计划和 TRACKING 验证记录
This commit is contained in:
@@ -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 前端拆分第四阶段:新增 `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 生成面板拆分浏览器回归:`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 前端拆分第五阶段:新增 `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 错误。
|
||||||
|
|||||||
@@ -64,10 +64,17 @@
|
|||||||
- 主视图继续负责命令触发时机、历史快照、选中态、菜单关闭、元数据清理和导出下载等 UI / 浏览器副作用。
|
- 主视图继续负责命令触发时机、历史快照、选中态、菜单关闭、元数据清理和导出下载等 UI / 浏览器副作用。
|
||||||
- 该模块有独立单测锁定当前右键菜单语义,避免后续调整 UI 时顺手改变图层数据规则。
|
- 该模块有独立单测锁定当前右键菜单语义,避免后续调整 UI 时顺手改变图层数据规则。
|
||||||
|
|
||||||
|
## 第六阶段模块
|
||||||
|
|
||||||
|
- `ImageCanvasInteractionModel.ts`
|
||||||
|
- 承载画布交互纯计算:适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。
|
||||||
|
- 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。
|
||||||
|
- 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。
|
||||||
|
|
||||||
## 后续阶段
|
## 后续阶段
|
||||||
|
|
||||||
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
|
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
|
||||||
- 画布交互模型:拖拽、吸附、框选、小地图和滚轮缩放仍散在主视图内,后续应在保证坐标源一致后继续收口。
|
- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。
|
||||||
|
|
||||||
## 验证计划
|
## 验证计划
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type WheelEvent as ReactWheelEvent,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { ApiClientError } from '../../services/apiClient';
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
@@ -48,6 +47,22 @@ import { UnifiedModal } from '../common/UnifiedModal';
|
|||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
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 {
|
import {
|
||||||
createCanvasLayerClipboard,
|
createCanvasLayerClipboard,
|
||||||
duplicateCanvasLayers,
|
duplicateCanvasLayers,
|
||||||
@@ -71,20 +86,13 @@ import {
|
|||||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||||
DEFAULT_CANVAS_SIZE,
|
DEFAULT_CANVAS_SIZE,
|
||||||
EDITOR_ASSET_FOLDERS,
|
EDITOR_ASSET_FOLDERS,
|
||||||
FIT_VIEW_PADDING,
|
|
||||||
MAX_HISTORY_STEPS,
|
MAX_HISTORY_STEPS,
|
||||||
MAX_SCALE,
|
|
||||||
MIN_SCALE,
|
|
||||||
MINIMAP_DRAG_SENSITIVITY,
|
|
||||||
MINIMAP_PADDING,
|
|
||||||
MINIMAP_SIZE,
|
|
||||||
TOOLBAR_HALF_WIDTH,
|
TOOLBAR_HALF_WIDTH,
|
||||||
clamp,
|
clamp,
|
||||||
createLayerFromAsset,
|
createLayerFromAsset,
|
||||||
escapeCssIdentifier,
|
escapeCssIdentifier,
|
||||||
formatImageSizeValue,
|
formatImageSizeValue,
|
||||||
getDraggedAssetId,
|
getDraggedAssetId,
|
||||||
getLayerBounds,
|
|
||||||
hasDataTransferType,
|
hasDataTransferType,
|
||||||
hydrateLayer,
|
hydrateLayer,
|
||||||
isGeneratedLayer,
|
isGeneratedLayer,
|
||||||
@@ -93,7 +101,6 @@ import {
|
|||||||
normalizeCanvasBackgroundHex,
|
normalizeCanvasBackgroundHex,
|
||||||
resolveContextMenuPosition,
|
resolveContextMenuPosition,
|
||||||
resolveLayerResolutionSize,
|
resolveLayerResolutionSize,
|
||||||
resolveSnappedLayerPosition,
|
|
||||||
serializeLayer,
|
serializeLayer,
|
||||||
} from './ImageCanvasEditorModel';
|
} from './ImageCanvasEditorModel';
|
||||||
import {
|
import {
|
||||||
@@ -895,58 +902,10 @@ export function ImageCanvasEditorView() {
|
|||||||
[openEditorLoginModal],
|
[openEditorLoginModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const minimapModel = useMemo(() => {
|
const minimapModel = useMemo(
|
||||||
const layerBounds = getLayerBounds(layers);
|
() => createMinimapModel({ layers, viewport, canvasSize }),
|
||||||
if (!layerBounds) {
|
[canvasSize, layers, viewport],
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1287,52 +1246,31 @@ export function ImageCanvasEditorView() {
|
|||||||
|
|
||||||
const fitLayers = useCallback(
|
const fitLayers = useCallback(
|
||||||
(targetLayers: CanvasLayer[] = layers) => {
|
(targetLayers: CanvasLayer[] = layers) => {
|
||||||
if (targetLayers.length === 0) {
|
const nextViewport = fitViewportToLayers({
|
||||||
|
layers: targetLayers,
|
||||||
|
canvasSize,
|
||||||
|
});
|
||||||
|
if (!nextViewport) {
|
||||||
return;
|
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();
|
captureCanvasHistory();
|
||||||
setViewport({
|
setViewport(nextViewport);
|
||||||
x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale,
|
|
||||||
y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale,
|
|
||||||
scale,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[captureCanvasHistory, canvasSize.height, canvasSize.width, layers],
|
[captureCanvasHistory, canvasSize, layers],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateScaleFromCenter = (nextScale: number) => {
|
const updateScaleFromCenter = (nextScale: number) => {
|
||||||
const viewportElement = canvasViewportRef.current;
|
const viewportElement = canvasViewportRef.current;
|
||||||
if (!viewportElement) {
|
if (!viewportElement) {
|
||||||
captureCanvasHistory();
|
captureCanvasHistory();
|
||||||
setViewport((currentViewport) => ({
|
setViewport((currentViewport) =>
|
||||||
...currentViewport,
|
scaleViewportFromScreenPoint({
|
||||||
scale: clamp(nextScale, MIN_SCALE, MAX_SCALE),
|
viewport: currentViewport,
|
||||||
}));
|
nextScale,
|
||||||
|
screenPoint: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1340,51 +1278,35 @@ export function ImageCanvasEditorView() {
|
|||||||
const centerX = rect.width > 0 ? rect.width / 2 : canvasSize.width / 2;
|
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();
|
captureCanvasHistory();
|
||||||
setViewport((currentViewport) => {
|
setViewport((currentViewport) =>
|
||||||
const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
scaleViewportFromScreenPoint({
|
||||||
const worldX = (centerX - currentViewport.x) / currentViewport.scale;
|
viewport: currentViewport,
|
||||||
const worldY = (centerY - currentViewport.y) / currentViewport.scale;
|
nextScale,
|
||||||
return {
|
screenPoint: { x: centerX, y: centerY },
|
||||||
x: centerX - worldX * scale,
|
}),
|
||||||
y: centerY - worldY * scale,
|
);
|
||||||
scale,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveCanvasPoint = (clientX: number, clientY: number) => {
|
const resolveCanvasPoint = (clientX: number, clientY: number) => {
|
||||||
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
const rect = canvasViewportRef.current?.getBoundingClientRect() ?? null;
|
||||||
if (!rect) {
|
return resolveCanvasPointFromClient({ clientX, clientY, 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 getCanvasDropPoint = (event: ReactDragEvent<HTMLDivElement>) =>
|
const getCanvasDropPoint = (event: ReactDragEvent<HTMLDivElement>) =>
|
||||||
resolveCanvasPoint(event.clientX, event.clientY) ?? {
|
resolveCanvasDropPoint({
|
||||||
x: Number.isFinite(canvasSize.width) ? canvasSize.width / 2 : 0,
|
clientX: event.clientX,
|
||||||
y: Number.isFinite(canvasSize.height) ? canvasSize.height / 2 : 0,
|
clientY: event.clientY,
|
||||||
};
|
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
|
||||||
|
canvasSize,
|
||||||
|
});
|
||||||
|
|
||||||
const getCanvasPointFromClient = (clientX: number, clientY: number) => {
|
const getCanvasPointFromClient = (clientX: number, clientY: number) => {
|
||||||
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
return getWorldPointFromClient({
|
||||||
const screenX = clientX - (rect?.left ?? 0);
|
clientX,
|
||||||
const screenY = clientY - (rect?.top ?? 0);
|
clientY,
|
||||||
return {
|
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
|
||||||
x: (screenX - viewport.x) / viewport.scale,
|
viewport,
|
||||||
y: (screenY - viewport.y) / viewport.scale,
|
});
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const duplicateLayersToPoint = (
|
const duplicateLayersToPoint = (
|
||||||
@@ -3323,7 +3245,7 @@ export function ImageCanvasEditorView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
|
const handleNativeWheel = useCallback((event: WheelEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const viewportElement = canvasViewportRef.current;
|
const viewportElement = canvasViewportRef.current;
|
||||||
if (!viewportElement) {
|
if (!viewportElement) {
|
||||||
@@ -3331,34 +3253,38 @@ export function ImageCanvasEditorView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!event.ctrlKey && !event.metaKey) {
|
if (!event.ctrlKey && !event.metaKey) {
|
||||||
setViewport((currentViewport) => ({
|
setViewport((currentViewport) =>
|
||||||
...currentViewport,
|
scrollViewportVertically(currentViewport, event.deltaY),
|
||||||
y: currentViewport.y - event.deltaY,
|
);
|
||||||
}));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = viewportElement.getBoundingClientRect();
|
const rect = viewportElement.getBoundingClientRect();
|
||||||
const pointerX = event.clientX - rect.left;
|
const screenPoint = {
|
||||||
const pointerY = event.clientY - rect.top;
|
x: event.clientX - rect.left,
|
||||||
const scaleMultiplier = event.deltaY > 0 ? 0.9 : 1.1;
|
y: event.clientY - rect.top,
|
||||||
|
};
|
||||||
setViewport((currentViewport) => {
|
setViewport((currentViewport) =>
|
||||||
const nextScale = clamp(
|
zoomViewportFromWheel({
|
||||||
currentViewport.scale * scaleMultiplier,
|
viewport: currentViewport,
|
||||||
MIN_SCALE,
|
deltaY: event.deltaY,
|
||||||
MAX_SCALE,
|
screenPoint,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const worldX = (pointerX - currentViewport.x) / currentViewport.scale;
|
}, []);
|
||||||
const worldY = (pointerY - currentViewport.y) / currentViewport.scale;
|
|
||||||
|
|
||||||
return {
|
useEffect(() => {
|
||||||
x: pointerX - worldX * nextScale,
|
const viewportElement = canvasViewportRef.current;
|
||||||
y: pointerY - worldY * nextScale,
|
if (!viewportElement) {
|
||||||
scale: nextScale,
|
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();
|
||||||
@@ -3668,41 +3594,30 @@ export function ImageCanvasEditorView() {
|
|||||||
if (!rect) {
|
if (!rect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const localX = clamp(clientX - rect.left, 0, MINIMAP_SIZE.width);
|
setViewport((currentViewport) =>
|
||||||
const localY = clamp(clientY - rect.top, 0, MINIMAP_SIZE.height);
|
resolveViewportFromMinimapPointer({
|
||||||
const worldX =
|
viewport: currentViewport,
|
||||||
minimapModel.bounds.minX +
|
canvasSize,
|
||||||
(localX - MINIMAP_PADDING) / minimapModel.scale;
|
minimapModel,
|
||||||
const worldY =
|
pointer: {
|
||||||
minimapModel.bounds.minY +
|
x: clientX - rect.left,
|
||||||
(localY - MINIMAP_PADDING) / minimapModel.scale;
|
y: clientY - rect.top,
|
||||||
setViewport((currentViewport) => ({
|
},
|
||||||
...currentViewport,
|
}),
|
||||||
x: canvasSize.width / 2 - worldX * currentViewport.scale,
|
);
|
||||||
y: canvasSize.height / 2 - worldY * currentViewport.scale,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveViewportFromMinimapDrag = (
|
const updateViewportFromMinimapDrag = (
|
||||||
dragState: Extract<DragState, { kind: 'minimap' }>,
|
dragState: Extract<DragState, { kind: 'minimap' }>,
|
||||||
clientX: number,
|
clientX: number,
|
||||||
clientY: number,
|
clientY: number,
|
||||||
) => {
|
) => {
|
||||||
const deltaWorldX =
|
setViewport(
|
||||||
((clientX - dragState.startClientX) / dragState.minimapScale) *
|
resolveViewportFromMinimapDrag(dragState, {
|
||||||
MINIMAP_DRAG_SENSITIVITY;
|
x: clientX,
|
||||||
const deltaWorldY =
|
y: clientY,
|
||||||
((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,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMinimapPointerDown = (
|
const handleMinimapPointerDown = (
|
||||||
@@ -3738,24 +3653,12 @@ export function ImageCanvasEditorView() {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
const left = Math.min(canvasMarquee.startX, currentX);
|
const selectedIds = selectLayersInsideMarquee({
|
||||||
const right = Math.max(canvasMarquee.startX, currentX);
|
marquee: canvasMarquee,
|
||||||
const top = Math.min(canvasMarquee.startY, currentY);
|
currentPoint: { x: currentX, y: currentY },
|
||||||
const bottom = Math.max(canvasMarquee.startY, currentY);
|
layers,
|
||||||
const selectedIds = layers
|
viewport,
|
||||||
.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);
|
|
||||||
setSelectedLayerIds(selectedIds);
|
setSelectedLayerIds(selectedIds);
|
||||||
setSelectedLayerId(selectedIds[0] ?? null);
|
setSelectedLayerId(selectedIds[0] ?? null);
|
||||||
return;
|
return;
|
||||||
@@ -3774,28 +3677,21 @@ export function ImageCanvasEditorView() {
|
|||||||
|
|
||||||
if (dragState.kind === 'pan') {
|
if (dragState.kind === 'pan') {
|
||||||
const pointer = getPointerClient(event);
|
const pointer = getPointerClient(event);
|
||||||
setViewport({
|
setViewport(moveViewportFromPan(dragState, pointer));
|
||||||
...dragState.startViewport,
|
|
||||||
x: dragState.startViewport.x + pointer.x - dragState.startClientX,
|
|
||||||
y: dragState.startViewport.y + pointer.y - dragState.startClientY,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dragState.kind === 'generation-frame') {
|
if (dragState.kind === 'generation-frame') {
|
||||||
const pointer = getPointerClient(event);
|
const pointer = getPointerClient(event);
|
||||||
const deltaX =
|
const nextFramePoint = moveGenerationFrameFromDrag(dragState, pointer);
|
||||||
(pointer.x - dragState.startClientX) / dragState.startScale;
|
|
||||||
const deltaY =
|
|
||||||
(pointer.y - dragState.startClientY) / dragState.startScale;
|
|
||||||
updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) =>
|
updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) =>
|
||||||
currentDialog.placeholder
|
currentDialog.placeholder
|
||||||
? {
|
? {
|
||||||
...currentDialog,
|
...currentDialog,
|
||||||
placeholder: {
|
placeholder: {
|
||||||
...currentDialog.placeholder,
|
...currentDialog.placeholder,
|
||||||
x: dragState.startFrameX + deltaX,
|
x: nextFramePoint.x,
|
||||||
y: dragState.startFrameY + deltaY,
|
y: nextFramePoint.y,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: currentDialog,
|
: currentDialog,
|
||||||
@@ -3811,58 +3707,18 @@ export function ImageCanvasEditorView() {
|
|||||||
dragState.moved = true;
|
dragState.moved = true;
|
||||||
}
|
}
|
||||||
if (dragState.moved) {
|
if (dragState.moved) {
|
||||||
moveViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
|
updateViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const movingLayer = layers.find((layer) => layer.id === dragState.layerId);
|
const pointer = getPointerClient(event);
|
||||||
if (!movingLayer) {
|
const movedLayers = moveLayersFromDrag({ dragState, layers, pointer });
|
||||||
|
if (!movedLayers) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pointer = getPointerClient(event);
|
setSnapGuide(movedLayers.snapGuide);
|
||||||
const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale;
|
setLayers(movedLayers.layers);
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
@@ -4306,7 +4162,6 @@ export function ImageCanvasEditorView() {
|
|||||||
onCanvasPointerDown={handleCanvasPointerDown}
|
onCanvasPointerDown={handleCanvasPointerDown}
|
||||||
onCanvasPointerMove={handlePointerMove}
|
onCanvasPointerMove={handlePointerMove}
|
||||||
onCanvasPointerUp={finishDrag}
|
onCanvasPointerUp={finishDrag}
|
||||||
onCanvasWheel={handleWheel}
|
|
||||||
onCanvasDragOver={handleCanvasDragOver}
|
onCanvasDragOver={handleCanvasDragOver}
|
||||||
onCanvasDragLeave={handleCanvasDragLeave}
|
onCanvasDragLeave={handleCanvasDragLeave}
|
||||||
onCanvasDrop={handleCanvasDrop}
|
onCanvasDrop={handleCanvasDrop}
|
||||||
|
|||||||
246
src/components/image-editor/ImageCanvasInteractionModel.test.ts
Normal file
246
src/components/image-editor/ImageCanvasInteractionModel.test.ts
Normal file
@@ -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>): 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<DragState, { kind: 'minimap' }> = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
425
src/components/image-editor/ImageCanvasInteractionModel.ts
Normal file
425
src/components/image-editor/ImageCanvasInteractionModel.ts
Normal file
@@ -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<typeof resolveSnappedLayerPosition>['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<DragState, { kind: 'pan' }>,
|
||||||
|
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<DragState, { kind: 'generation-frame' }>,
|
||||||
|
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<DragState, { kind: 'layer' }>;
|
||||||
|
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<DragState, { kind: 'minimap' }>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,7 +29,6 @@ import type {
|
|||||||
PointerEvent as ReactPointerEvent,
|
PointerEvent as ReactPointerEvent,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
WheelEvent as ReactWheelEvent,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
@@ -43,6 +42,7 @@ import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
|||||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
import { PlatformTextField } from '../common/PlatformTextField';
|
import { PlatformTextField } from '../common/PlatformTextField';
|
||||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||||
|
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
||||||
import {
|
import {
|
||||||
CANVAS_BACKGROUND_OPTIONS,
|
CANVAS_BACKGROUND_OPTIONS,
|
||||||
CANVAS_WORLD_SIZE,
|
CANVAS_WORLD_SIZE,
|
||||||
@@ -70,15 +70,6 @@ import type {
|
|||||||
SnapGuide,
|
SnapGuide,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
type StageMinimapModel = {
|
|
||||||
layers: Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
rect: CSSProperties;
|
|
||||||
}>;
|
|
||||||
viewport: CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImageCanvasStageViewProps = {
|
type ImageCanvasStageViewProps = {
|
||||||
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
||||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||||
@@ -116,7 +107,6 @@ type ImageCanvasStageViewProps = {
|
|||||||
onCanvasPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
onCanvasPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
onCanvasPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
onCanvasPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
onCanvasPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
onCanvasPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
onCanvasWheel: (event: ReactWheelEvent<HTMLDivElement>) => void;
|
|
||||||
onCanvasDragOver: (event: ReactDragEvent<HTMLDivElement>) => void;
|
onCanvasDragOver: (event: ReactDragEvent<HTMLDivElement>) => void;
|
||||||
onCanvasDragLeave: (event: ReactDragEvent<HTMLDivElement>) => void;
|
onCanvasDragLeave: (event: ReactDragEvent<HTMLDivElement>) => void;
|
||||||
onCanvasDrop: (event: ReactDragEvent<HTMLDivElement>) => void;
|
onCanvasDrop: (event: ReactDragEvent<HTMLDivElement>) => void;
|
||||||
@@ -239,7 +229,6 @@ export function ImageCanvasStageView({
|
|||||||
onCanvasPointerDown,
|
onCanvasPointerDown,
|
||||||
onCanvasPointerMove,
|
onCanvasPointerMove,
|
||||||
onCanvasPointerUp,
|
onCanvasPointerUp,
|
||||||
onCanvasWheel,
|
|
||||||
onCanvasDragOver,
|
onCanvasDragOver,
|
||||||
onCanvasDragLeave,
|
onCanvasDragLeave,
|
||||||
onCanvasDrop,
|
onCanvasDrop,
|
||||||
@@ -294,7 +283,6 @@ export function ImageCanvasStageView({
|
|||||||
onPointerMove={onCanvasPointerMove}
|
onPointerMove={onCanvasPointerMove}
|
||||||
onPointerUp={onCanvasPointerUp}
|
onPointerUp={onCanvasPointerUp}
|
||||||
onPointerCancel={onCanvasPointerUp}
|
onPointerCancel={onCanvasPointerUp}
|
||||||
onWheel={onCanvasWheel}
|
|
||||||
onDragOver={onCanvasDragOver}
|
onDragOver={onCanvasDragOver}
|
||||||
onDragLeave={onCanvasDragLeave}
|
onDragLeave={onCanvasDragLeave}
|
||||||
onDrop={onCanvasDrop}
|
onDrop={onCanvasDrop}
|
||||||
|
|||||||
Reference in New Issue
Block a user