201 lines
6.5 KiB
TypeScript
201 lines
6.5 KiB
TypeScript
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<TransformBeginPayload> = {
|
|
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<TransformUpdatePayload> = {
|
|
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<TransformSetBoundsPayload> = {
|
|
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<TransformSetRotationPayload> = {
|
|
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<string>, 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<unknown>[];
|
|
|
|
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),
|
|
};
|
|
}
|