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