import type { Rect, Vec2D } from "@core/geometry"; import type { Layer } from "@core/layer"; import { applyTransformTargetBounds } from "./transform-document"; import type { TransformHandle, TransformTarget } from "@editor/transform"; import type { Command } from "./command"; import { commandIds } from "./ids"; import type { AppState } from "@editor/state"; export type TransformBeginPayload = { target: TransformTarget; handle: TransformHandle; point: Vec2D; initialBounds: Rect; }; export type TransformUpdatePayload = { point: Vec2D; shiftKey?: boolean; }; export type TransformSetBoundsPayload = { target: TransformTarget; bounds: Rect; }; export type TransformSetRotationPayload = { target: TransformTarget; rotation: number; }; export const transformBeginCommand: Command = { id: commandIds.transformBegin, name: "Begin transform", history: { mode: "deferred", phase: "begin" }, execute({ state }, payload) { return { ...state, editor: { ...state.editor, transformSession: { target: payload.target, handle: payload.handle, startPoint: payload.point, initialBounds: payload.initialBounds, }, }, }; }, }; export const transformUpdateCommand: Command = { id: commandIds.transformUpdate, name: "Update transform", history: { mode: "deferred", phase: "update" }, execute({ state }, payload) { const session = state.editor.transformSession; if (!session) return state; const nextBounds = transformBounds( session.initialBounds, session.handle, { x: payload.point.x - session.startPoint.x, y: payload.point.y - session.startPoint.y, }, payload.shiftKey === true, ); return { ...state, document: applyTransformTargetBounds(state.document, session.target, nextBounds), }; }, }; export const transformSetBoundsCommand: Command = { id: commandIds.transformSetBounds, name: "Set transform bounds", execute({ state }, payload) { if (isTargetLocked(state, payload.target)) return state; return { ...state, document: applyTransformTargetBounds(state.document, payload.target, normalizeRect(payload.bounds)), }; }, }; export const transformSetRotationCommand: Command = { id: commandIds.transformSetRotation, name: "Set transform rotation", execute({ state }, payload) { if (payload.target.type !== "layer" || !Number.isFinite(payload.rotation) || isTargetLocked(state, payload.target)) return state; const location = state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, payload.target.id)).find(Boolean); if (!location || location.type === "group") return state; const maskId = "layerMask" in location ? location.layerMask?.maskLayerId : undefined; const ids = new Set([payload.target.id, ...(maskId ? [maskId] : [])]); return { ...state, document: { ...state.document, artboards: state.document.artboards.map((artboard) => ({ ...artboard, layers: mapRotation(artboard.layers, ids, payload.rotation) })), }, }; }, }; function isTargetLocked(state: AppState, target: TransformTarget) { if (target.type === "artboard") return state.document.artboards.find((artboard) => artboard.id === target.id)?.locked !== false; return state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, target.id)).find(Boolean)?.locked !== false; } function findLayerInTree(layers: Layer[], id: string): Layer[] { return layers.flatMap((layer) => layer.id === id ? [layer] : layer.type === "group" ? findLayerInTree(layer.children, id) : []); } function mapRotation(layers: Layer[], ids: ReadonlySet, rotation: number): Layer[] { return layers.map((layer) => ({ ...layer, ...(ids.has(layer.id) ? { transform: { ...layer.transform, rotation } } : {}), ...(layer.type === "group" ? { children: mapRotation(layer.children, ids, rotation) } : {}), })) as Layer[]; } export const transformEndCommand: Command = { id: commandIds.transformEnd, name: "End transform", history: { mode: "deferred", phase: "commit" }, execute({ state }) { if (!state.editor.transformSession) return state; return { ...state, editor: { ...state.editor, transformSession: undefined, }, }; }, }; export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformSetRotationCommand, transformEndCommand] satisfies Command[]; function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect { if (handle === "body") { const constrainedDelta = constrained ? constrainMoveDelta(delta) : delta; return { ...bounds, x: bounds.x + constrainedDelta.x, y: bounds.y + constrainedDelta.y }; } let x = bounds.x; let y = bounds.y; let w = bounds.w; let h = bounds.h; if (handle.includes("w")) { x = bounds.x + delta.x; w = bounds.w - delta.x; } if (handle.includes("e")) w = bounds.w + delta.x; if (handle.includes("n")) { y = bounds.y + delta.y; h = bounds.h - delta.y; } if (handle.includes("s")) h = bounds.h + delta.y; const resized = normalizeRect({ x, y, w, h }); if (!constrained || !isCornerHandle(handle)) return resized; return proportionalCornerResize(bounds, handle, resized); } function constrainMoveDelta(delta: Vec2D): Vec2D { return Math.abs(delta.x) >= Math.abs(delta.y) ? { x: delta.x, y: 0 } : { x: 0, y: delta.y }; } function isCornerHandle(handle: TransformHandle) { return handle.length === 2; } function proportionalCornerResize(initial: Rect, handle: TransformHandle, resized: Rect): Rect { const aspectRatio = initial.w / initial.h; const widthScale = resized.w / initial.w; const heightScale = resized.h / initial.h; const scale = Math.abs(widthScale - 1) >= Math.abs(heightScale - 1) ? widthScale : heightScale; const w = Math.max(1, initial.w * scale); const h = Math.max(1, w / aspectRatio); const x = handle.includes("w") ? initial.x + initial.w - w : initial.x; const y = handle.includes("n") ? initial.y + initial.h - h : initial.y; return { x, y, w, h }; } function normalizeRect(rect: Rect): Rect { const minSize = 1; return { x: rect.w < minSize ? rect.x + rect.w - minSize : rect.x, y: rect.h < minSize ? rect.y + rect.h - minSize : rect.y, w: Math.max(minSize, rect.w), h: Math.max(minSize, rect.h), }; }