29369e6376
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/100 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
728 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|