feat(transform): add transform session commands
This commit is contained in:
@@ -3,6 +3,7 @@ import { commandIds } from "@commands/ids";
|
||||
import { createCommandRegistry } from "@commands/registry";
|
||||
import { selectionCommands } from "@commands/selection";
|
||||
import { toolCommands } from "@commands/tool";
|
||||
import { transformCommands } from "@commands/transform";
|
||||
import { viewportCommands } from "@commands/viewport";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { createAppStore } from "@editor/store";
|
||||
@@ -10,7 +11,7 @@ import { createAppStore } from "@editor/store";
|
||||
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
||||
|
||||
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
|
||||
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands]);
|
||||
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...transformCommands]);
|
||||
const store = createAppStore(createInitialAppState(options?.documentName), registry);
|
||||
|
||||
if (options?.createDefaultArtboard !== false) {
|
||||
|
||||
@@ -7,6 +7,9 @@ export const commandIds = {
|
||||
toolSetActive: "tool.setActive",
|
||||
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||
transformBegin: "transform.begin",
|
||||
transformUpdate: "transform.update",
|
||||
transformEnd: "transform.end",
|
||||
viewportPan: "viewport.pan",
|
||||
viewportSetZoom: "viewport.setZoom",
|
||||
viewportZoomAroundPoint: "viewport.zoomAroundPoint",
|
||||
|
||||
@@ -9,6 +9,8 @@ export { createCommandRegistry } from "./registry";
|
||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
|
||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformUpdateCommand } from "./transform";
|
||||
export type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
||||
export type { ToolSetActivePayload } from "./tool";
|
||||
export {
|
||||
viewportCommands,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { commandIds } from "./ids";
|
||||
import type { DocumentAddArtboardPayload, DocumentSetArtboardBoundsPayload } from "./document";
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolSetActivePayload } from "./tool";
|
||||
import type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
||||
import type {
|
||||
ViewportFitArtboardPayload,
|
||||
ViewportPanPayload,
|
||||
@@ -19,6 +20,9 @@ export type CommandPayloads = {
|
||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||
[commandIds.toolEnterTemporaryPan]: void;
|
||||
[commandIds.toolExitTemporaryPan]: void;
|
||||
[commandIds.transformBegin]: TransformBeginPayload;
|
||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||
[commandIds.transformEnd]: void;
|
||||
[commandIds.viewportPan]: ViewportPanPayload;
|
||||
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
||||
[commandIds.viewportZoomAroundPoint]: ViewportZoomAroundPointPayload;
|
||||
|
||||
44
commands/transform.test.ts
Normal file
44
commands/transform.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { documentAddArtboardCommand } from "./document";
|
||||
import { transformBeginCommand, transformEndCommand, transformUpdateCommand } from "./transform";
|
||||
|
||||
function artboardState() {
|
||||
return documentAddArtboardCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
{ id: "a1", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||
);
|
||||
}
|
||||
|
||||
describe("transform commands", () => {
|
||||
test("moves artboard transform target", () => {
|
||||
const started = transformBeginCommand.execute(
|
||||
{ state: artboardState() },
|
||||
{ target: { type: "artboard", id: "a1" }, handle: "body", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||
);
|
||||
const updated = transformUpdateCommand.execute({ state: started }, { point: { x: 10, y: 20 } });
|
||||
|
||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 100, h: 80 });
|
||||
});
|
||||
|
||||
test("resizes artboard from southeast handle", () => {
|
||||
const started = transformBeginCommand.execute(
|
||||
{ state: artboardState() },
|
||||
{ target: { type: "artboard", id: "a1" }, handle: "se", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||
);
|
||||
const updated = transformUpdateCommand.execute({ state: started }, { point: { x: 10, y: 20 } });
|
||||
|
||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 110, h: 100 });
|
||||
});
|
||||
|
||||
test("ends transform session", () => {
|
||||
const started = transformBeginCommand.execute(
|
||||
{ state: artboardState() },
|
||||
{ target: { type: "artboard", id: "a1" }, handle: "body", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||
);
|
||||
|
||||
const ended = transformEndCommand.execute({ state: started }, undefined);
|
||||
|
||||
expect(ended.editor.transformSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
112
commands/transform.ts
Normal file
112
commands/transform.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Rect, Vec2D } from "@core/geometry";
|
||||
import type { TransformHandle, TransformTarget } from "@editor/transform";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export type TransformBeginPayload = {
|
||||
target: TransformTarget;
|
||||
handle: TransformHandle;
|
||||
point: Vec2D;
|
||||
initialBounds: Rect;
|
||||
};
|
||||
|
||||
export type TransformUpdatePayload = {
|
||||
point: Vec2D;
|
||||
};
|
||||
|
||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||
id: commandIds.transformBegin,
|
||||
name: "Begin transform",
|
||||
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",
|
||||
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,
|
||||
});
|
||||
|
||||
if (session.target.type === "artboard") {
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((artboard) =>
|
||||
artboard.id === session.target.id ? { ...artboard, bounds: nextBounds } : artboard,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
};
|
||||
|
||||
export const transformEndCommand: Command = {
|
||||
id: commandIds.transformEnd,
|
||||
name: "End transform",
|
||||
execute({ state }) {
|
||||
if (!state.editor.transformSession) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
transformSession: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||
|
||||
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D): Rect {
|
||||
if (handle === "body") return { ...bounds, x: bounds.x + delta.x, y: bounds.y + delta.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;
|
||||
|
||||
return normalizeRect({ 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),
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const initialEditorState: EditorState = {
|
||||
layerIds: [],
|
||||
},
|
||||
tools: initialToolState,
|
||||
transformSession: undefined,
|
||||
};
|
||||
|
||||
export function createInitialAppState(name = "Untitled"): AppState {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
|
||||
import type { Angle, Size, Vec2D } from "@core/geometry";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { ToolState } from "./tools";
|
||||
import type { TransformSession } from "./transform";
|
||||
|
||||
export type ViewportState = {
|
||||
center: Vec2D;
|
||||
@@ -19,6 +20,7 @@ export type EditorState = {
|
||||
viewport: ViewportState;
|
||||
selection: SelectionState;
|
||||
tools: ToolState;
|
||||
transformSession?: TransformSession;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
|
||||
16
editor/transform.ts
Normal file
16
editor/transform.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Rect, Vec2D } from "@core/geometry";
|
||||
import type { ArtboardId } from "@core/id";
|
||||
|
||||
export type TransformHandle = "body" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||
|
||||
export type TransformTarget = {
|
||||
type: "artboard";
|
||||
id: ArtboardId;
|
||||
};
|
||||
|
||||
export type TransformSession = {
|
||||
target: TransformTarget;
|
||||
handle: TransformHandle;
|
||||
startPoint: Vec2D;
|
||||
initialBounds: Rect;
|
||||
};
|
||||
Reference in New Issue
Block a user