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