抽出图片画布交互模型

新增 ImageCanvasInteractionModel 收口适合视图、缩放、滚轮、框选、拖拽和小地图交互计算

主视图保留 React 事件、pointer capture、history、生成对象回写和状态更新

补充交互模型单测并修复真实浏览器 passive wheel 阻止默认行为问题

更新图片画布前端拆分计划和 TRACKING 验证记录
This commit is contained in:
2026-06-17 03:55:46 +08:00
parent 7b5d74037a
commit b5cbe62b47
6 changed files with 807 additions and 285 deletions

View File

@@ -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 错误。

View File

@@ -64,10 +64,17 @@
- 主视图继续负责命令触发时机、历史快照、选中态、菜单关闭、元数据清理和导出下载等 UI / 浏览器副作用。
- 该模块有独立单测锁定当前右键菜单语义,避免后续调整 UI 时顺手改变图层数据规则。
## 第六阶段模块
- `ImageCanvasInteractionModel.ts`
- 承载画布交互纯计算适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。
- 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`
- 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。
## 后续阶段
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
- 画布交互模型:拖拽、吸附、框选、小地图和滚轮缩放仍散在主视图内,后续应在保证坐标源一致后继续收口。
- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。
## 验证计划

View File

@@ -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}

View 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);
});
});

View 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,
};
}

View File

@@ -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}