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

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

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

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

426 lines
10 KiB
TypeScript

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