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