Files
Genarrative/src/components/image-editor/ImageCanvasInteractionModel.test.ts
kdletters b5cbe62b47 抽出图片画布交互模型
新增 ImageCanvasInteractionModel 收口适合视图、缩放、滚轮、框选、拖拽和小地图交互计算

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

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

更新图片画布前端拆分计划和 TRACKING 验证记录
2026-06-17 03:55:46 +08:00

247 lines
6.6 KiB
TypeScript

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