抽出图片画布交互模型
新增 ImageCanvasInteractionModel 收口适合视图、缩放、滚轮、框选、拖拽和小地图交互计算 主视图保留 React 事件、pointer capture、history、生成对象回写和状态更新 补充交互模型单测并修复真实浏览器 passive wheel 阻止默认行为问题 更新图片画布前端拆分计划和 TRACKING 验证记录
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user