29d9c9ff5b
新增画布生成器选择 ID 模型,支持图层与生成器混合选择 补齐框选、Shift 点选、拖拽和删除对生成器占位的批量操作 修复音频控件焦点删除和手动素材标签资源持久化 更新图片画布编辑器多选语义文档
575 lines
14 KiB
TypeScript
575 lines
14 KiB
TypeScript
import type { CSSProperties } from 'react';
|
|
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
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';
|
|
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 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,
|
|
};
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
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,
|
|
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 moveGenerationFramesFromDrag({
|
|
dragState,
|
|
generationDialogs,
|
|
pointer,
|
|
}: {
|
|
dragState: Extract<DragState, { kind: 'layer' | 'generation-frame' }>;
|
|
generationDialogs: CanvasGenerationDialogState[];
|
|
pointer: CanvasPoint;
|
|
}) {
|
|
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;
|
|
}
|
|
const startFrames =
|
|
dragState.kind === 'generation-frame'
|
|
? dragState.startFrames
|
|
: (dragState.startGenerationFrames ?? []);
|
|
return 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,
|
|
y: startFrame.y + deltaY,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
export function moveDragSelectedLayers({
|
|
dragState,
|
|
layers,
|
|
pointer,
|
|
}: {
|
|
dragState: Extract<DragState, { kind: 'generation-frame' }>;
|
|
layers: CanvasLayer[];
|
|
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,
|
|
y: startLayer.y + deltaY,
|
|
};
|
|
});
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|