From bf5d9f5371a067adad4c3063f03d2f4d0cd0ba67 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 16:06:58 +0200 Subject: [PATCH] feat(transform): add transform session commands --- app/app.ts | 3 +- commands/ids.ts | 3 + commands/index.ts | 2 + commands/payloads.ts | 4 ++ commands/transform.test.ts | 44 +++++++++++++++ commands/transform.ts | 112 +++++++++++++++++++++++++++++++++++++ editor/initial-state.ts | 1 + editor/state.ts | 2 + editor/transform.ts | 16 ++++++ 9 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 commands/transform.test.ts create mode 100644 commands/transform.ts create mode 100644 editor/transform.ts diff --git a/app/app.ts b/app/app.ts index 79aec5b..18e0e2b 100644 --- a/app/app.ts +++ b/app/app.ts @@ -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; 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) { diff --git a/commands/ids.ts b/commands/ids.ts index 3c84355..245eef9 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -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", diff --git a/commands/index.ts b/commands/index.ts index 2f29ae9..618cf7f 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -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, diff --git a/commands/payloads.ts b/commands/payloads.ts index 4e0bead..af77e57 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -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; diff --git a/commands/transform.test.ts b/commands/transform.test.ts new file mode 100644 index 0000000..b411822 --- /dev/null +++ b/commands/transform.test.ts @@ -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(); + }); +}); diff --git a/commands/transform.ts b/commands/transform.ts new file mode 100644 index 0000000..bf633d3 --- /dev/null +++ b/commands/transform.ts @@ -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 = { + 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 = { + 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[]; + +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), + }; +} diff --git a/editor/initial-state.ts b/editor/initial-state.ts index 199cadc..e91dba1 100644 --- a/editor/initial-state.ts +++ b/editor/initial-state.ts @@ -12,6 +12,7 @@ export const initialEditorState: EditorState = { layerIds: [], }, tools: initialToolState, + transformSession: undefined, }; export function createInitialAppState(name = "Untitled"): AppState { diff --git a/editor/state.ts b/editor/state.ts index 0b9cfab..830bae7 100644 --- a/editor/state.ts +++ b/editor/state.ts @@ -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 = { diff --git a/editor/transform.ts b/editor/transform.ts new file mode 100644 index 0000000..4ad77cc --- /dev/null +++ b/editor/transform.ts @@ -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; +};