Files
Genarrative/src/components/image-editor/ImageCanvasInteractionModel.ts
T
k88936 29369e6376
Project CI / Frontend tests (push) Successful in 1m39s
Project CI / Repository checks (push) Successful in 2m38s
Project CI / Native shell tests (push) Successful in 5m25s
Project CI / Backend tests (push) Failing after 5m27s
fix: 画布鼠标/触摸板可以上下滚动,无法左右滚动 (#100)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/100
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-21 20:50:03 +08:00

728 lines
18 KiB
TypeScript

import type { CSSProperties } from 'react';
import {
clamp,
DEFAULT_CANVAS_SIZE,
FIT_VIEW_PADDING,
getLayerBounds,
MAX_SCALE,
MIN_SCALE,
MINIMAP_DRAG_SENSITIVITY,
MINIMAP_PADDING,
MINIMAP_SIZE,
resolveSnappedItemPosition,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasMarqueeState,
CanvasViewport,
DragState,
} from './ImageCanvasEditorTypes';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
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 resolveSnappedItemPosition>['guide'];
snapOffset: CanvasPoint;
};
export type CanvasGenerationFrameMoveResult = {
generationDialogs: CanvasGenerationDialogState[];
snapGuide: ReturnType<typeof resolveSnappedItemPosition>['guide'];
snapOffset: CanvasPoint;
};
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;
}
return fitViewportToBounds({
bounds: {
x: bounds.minX,
y: bounds.minY,
width: bounds.maxX - bounds.minX,
height: bounds.maxY - bounds.minY,
},
canvasSize,
});
}
export function fitViewportToBounds({
bounds,
canvasSize,
insets = {},
padding = FIT_VIEW_PADDING,
}: {
bounds: { x: number; y: number; width: number; height: number };
canvasSize: CanvasSize;
insets?: { top?: number; right?: number; bottom?: number; left?: number };
padding?: number;
}): CanvasViewport {
const boundsWidth = Math.max(1, bounds.width);
const boundsHeight = Math.max(1, bounds.height);
const leftInset = Math.max(0, insets.left ?? 0) + padding;
const rightInset = Math.max(0, insets.right ?? 0) + padding;
const topInset = Math.max(0, insets.top ?? 0) + padding;
const bottomInset = Math.max(0, insets.bottom ?? 0) + padding;
const availableWidth = Math.max(1, canvasSize.width - leftInset - rightInset);
const availableHeight = Math.max(
1,
canvasSize.height - topInset - bottomInset,
);
const scale = clamp(
Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight),
MIN_SCALE,
MAX_SCALE,
);
return {
x: leftInset + availableWidth / 2 - (bounds.x + boundsWidth / 2) * scale,
y: topInset + availableHeight / 2 - (bounds.y + 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 panViewportFromWheel({
viewport,
deltaX,
deltaY,
}: {
viewport: CanvasViewport;
deltaX: number;
deltaY: number;
}) {
return {
...viewport,
x: viewport.x - deltaX,
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,
};
}
function getMarqueeRect({
marquee,
currentPoint,
}: {
marquee: CanvasMarqueeState;
currentPoint: CanvasPoint;
}) {
return {
left: Math.min(marquee.startX, currentPoint.x),
right: Math.max(marquee.startX, currentPoint.x),
top: Math.min(marquee.startY, currentPoint.y),
bottom: Math.max(marquee.startY, currentPoint.y),
};
}
function intersectsScreenRect({
x,
y,
width,
height,
viewport,
rect,
}: {
x: number;
y: number;
width: number;
height: number;
viewport: CanvasViewport;
rect: { left: number; right: number; top: number; bottom: number };
}) {
const itemLeft = viewport.x + x * viewport.scale;
const itemTop = viewport.y + y * viewport.scale;
const itemRight = itemLeft + width * viewport.scale;
const itemBottom = itemTop + height * viewport.scale;
return (
itemLeft <= rect.right &&
itemRight >= rect.left &&
itemTop <= rect.bottom &&
itemBottom >= rect.top
);
}
function getCanvasSnapItems({
layers,
generationDialogs,
excludedLayerIds = [],
excludedGenerationDialogIds = [],
}: {
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
excludedLayerIds?: string[];
excludedGenerationDialogIds?: string[];
}) {
const layerSnapItems = layers
.filter((layer) => !excludedLayerIds.includes(layer.id))
.map((layer) => ({
id: layer.id,
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
hidden: layer.hidden,
}));
const generationSnapItems = generationDialogs.flatMap((dialog) => {
if (
!dialog.placeholder ||
excludedGenerationDialogIds.includes(dialog.id)
) {
return [];
}
return [
{
id: getCanvasGenerationSelectionId(dialog.id),
x: dialog.placeholder.x,
y: dialog.placeholder.y,
width: dialog.placeholder.width,
height: dialog.placeholder.height,
},
];
});
return [...layerSnapItems, ...generationSnapItems];
}
export function selectLayersInsideMarquee({
marquee,
currentPoint,
layers,
viewport,
}: {
marquee: CanvasMarqueeState;
currentPoint: CanvasPoint;
layers: CanvasLayer[];
viewport: CanvasViewport;
}) {
const rect = getMarqueeRect({ marquee, currentPoint });
return layers
.filter((layer) => {
return intersectsScreenRect({
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
viewport,
rect,
});
})
.map((layer) => layer.id);
}
export function selectCanvasObjectsInsideMarquee({
marquee,
currentPoint,
layers,
generationDialogs,
viewport,
}: {
marquee: CanvasMarqueeState;
currentPoint: CanvasPoint;
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport;
}) {
const rect = getMarqueeRect({ marquee, currentPoint });
const layerIds = selectLayersInsideMarquee({
marquee,
currentPoint,
layers,
viewport,
});
const generationDialogIds = generationDialogs
.filter((dialog) => {
const placeholder = dialog.placeholder;
if (!placeholder) {
return false;
}
return intersectsScreenRect({
x: placeholder.x,
y: placeholder.y,
width: placeholder.width,
height: placeholder.height,
viewport,
rect,
});
})
.map((dialog) => getCanvasGenerationSelectionId(dialog.id));
return [...layerIds, ...generationDialogIds];
}
export function moveLayersFromDrag({
dragState,
layers,
generationDialogs = [],
pointer,
}: {
dragState: Extract<DragState, { kind: 'layer' }>;
layers: CanvasLayer[];
generationDialogs?: CanvasGenerationDialogState[];
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 = resolveSnappedItemPosition(
movingLayer,
proposedX,
proposedY,
getCanvasSnapItems({
layers,
generationDialogs,
excludedLayerIds: dragState.layerIds,
excludedGenerationDialogIds: dragState.generationDialogIds ?? [],
}),
dragState.startScale,
);
const snapOffset = {
x: snapped.x - proposedX,
y: snapped.y - proposedY,
};
return {
snapGuide: snapped.guide,
snapOffset,
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 moveGenerationFramesFromDrag({
dragState,
generationDialogs,
layers = [],
snapOffset,
pointer,
}: {
dragState: Extract<DragState, { kind: 'layer' | 'generation-frame' }>;
generationDialogs: CanvasGenerationDialogState[];
layers?: CanvasLayer[];
snapOffset?: CanvasPoint | null;
pointer: CanvasPoint;
}): CanvasGenerationFrameMoveResult {
const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale;
const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale;
const targetDialogIds =
dragState.kind === 'generation-frame'
? dragState.dialogIds
: (dragState.generationDialogIds ?? []);
if (!targetDialogIds.length) {
return {
generationDialogs,
snapGuide: null,
snapOffset: { x: 0, y: 0 },
};
}
const startFrames =
dragState.kind === 'generation-frame'
? dragState.startFrames
: (dragState.startGenerationFrames ?? []);
const movingDialogId =
dragState.kind === 'generation-frame'
? dragState.dialogId
: targetDialogIds[0];
const movingDialog = generationDialogs.find(
(dialog) => dialog.id === movingDialogId,
);
const movingPlaceholder = movingDialog?.placeholder;
const movingStartFrame = startFrames.find(
(frame) => frame.id === movingDialogId,
);
const proposedX = (movingStartFrame?.x ?? 0) + deltaX;
const proposedY = (movingStartFrame?.y ?? 0) + deltaY;
const snapped =
snapOffset || dragState.kind === 'layer'
? {
x: proposedX + (snapOffset?.x ?? 0),
y: proposedY + (snapOffset?.y ?? 0),
guide: null,
}
: movingDialog && movingPlaceholder && movingStartFrame
? resolveSnappedItemPosition(
{
id: getCanvasGenerationSelectionId(movingDialog.id),
x: movingPlaceholder.x,
y: movingPlaceholder.y,
width: movingPlaceholder.width,
height: movingPlaceholder.height,
},
proposedX,
proposedY,
getCanvasSnapItems({
layers,
generationDialogs,
excludedLayerIds: dragState.layerIds,
excludedGenerationDialogIds: targetDialogIds,
}),
dragState.startScale,
)
: { x: proposedX, y: proposedY, guide: null };
const appliedSnapOffset = {
x: snapped.x - proposedX,
y: snapped.y - proposedY,
};
return {
snapGuide: snapped.guide,
snapOffset: appliedSnapOffset,
generationDialogs: generationDialogs.map((dialog) => {
if (!targetDialogIds.includes(dialog.id) || !dialog.placeholder) {
return dialog;
}
const startFrame = startFrames.find((frame) => frame.id === dialog.id);
if (!startFrame) {
return dialog;
}
return {
...dialog,
placeholder: {
...dialog.placeholder,
x: startFrame.x + deltaX + appliedSnapOffset.x,
y: startFrame.y + deltaY + appliedSnapOffset.y,
},
};
}),
};
}
export function moveDragSelectedLayers({
dragState,
layers,
snapOffset = { x: 0, y: 0 },
pointer,
}: {
dragState: Extract<DragState, { kind: 'generation-frame' }>;
layers: CanvasLayer[];
snapOffset?: CanvasPoint;
pointer: CanvasPoint;
}) {
if (!dragState.layerIds.length) {
return layers;
}
const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale;
const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale;
return layers.map((layer) => {
if (!dragState.layerIds.includes(layer.id)) {
return layer;
}
const startLayer = dragState.startLayers.find((item) => item.id === layer.id);
if (!startLayer) {
return layer;
}
return {
...layer,
x: startLayer.x + deltaX + snapOffset.x,
y: startLayer.y + deltaY + snapOffset.y,
};
});
}
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,
};
}