feat(transform): add transform session commands

This commit is contained in:
syntaxbullet
2026-07-03 16:06:58 +02:00
parent 03d4d4a3b9
commit bf5d9f5371
9 changed files with 186 additions and 1 deletions

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;

View 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
View 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),
};
}

View File

@@ -12,6 +12,7 @@ export const initialEditorState: EditorState = {
layerIds: [],
},
tools: initialToolState,
transformSession: undefined,
};
export function createInitialAppState(name = "Untitled"): AppState {

View File

@@ -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
View 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;
};