抽出图片画布交互模型
新增 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 生成面板拆分浏览器回归:`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 错误。
|
||||
|
||||
@@ -64,10 +64,17 @@
|
||||
- 主视图继续负责命令触发时机、历史快照、选中态、菜单关闭、元数据清理和导出下载等 UI / 浏览器副作用。
|
||||
- 该模块有独立单测锁定当前右键菜单语义,避免后续调整 UI 时顺手改变图层数据规则。
|
||||
|
||||
## 第六阶段模块
|
||||
|
||||
- `ImageCanvasInteractionModel.ts`
|
||||
- 承载画布交互纯计算:适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。
|
||||
- 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。
|
||||
- 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。
|
||||
|
||||
## 后续阶段
|
||||
|
||||
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
|
||||
- 画布交互模型:拖拽、吸附、框选、小地图和滚轮缩放仍散在主视图内,后续应在保证坐标源一致后继续收口。
|
||||
- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。
|
||||
|
||||
## 验证计划
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>) =>
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<DragState, { kind: 'minimap' }>,
|
||||
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<HTMLDivElement>) => {
|
||||
@@ -4306,7 +4162,6 @@ export function ImageCanvasEditorView() {
|
||||
onCanvasPointerDown={handleCanvasPointerDown}
|
||||
onCanvasPointerMove={handlePointerMove}
|
||||
onCanvasPointerUp={finishDrag}
|
||||
onCanvasWheel={handleWheel}
|
||||
onCanvasDragOver={handleCanvasDragOver}
|
||||
onCanvasDragLeave={handleCanvasDragLeave}
|
||||
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,
|
||||
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<HTMLDivElement | null>;
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
@@ -116,7 +107,6 @@ type ImageCanvasStageViewProps = {
|
||||
onCanvasPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onCanvasPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onCanvasPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onCanvasWheel: (event: ReactWheelEvent<HTMLDivElement>) => void;
|
||||
onCanvasDragOver: (event: ReactDragEvent<HTMLDivElement>) => void;
|
||||
onCanvasDragLeave: (event: ReactDragEvent<HTMLDivElement>) => void;
|
||||
onCanvasDrop: (event: ReactDragEvent<HTMLDivElement>) => 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}
|
||||
|
||||
Reference in New Issue
Block a user