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 { createCommandRegistry } from "@commands/registry";
|
||||||
import { selectionCommands } from "@commands/selection";
|
import { selectionCommands } from "@commands/selection";
|
||||||
import { toolCommands } from "@commands/tool";
|
import { toolCommands } from "@commands/tool";
|
||||||
|
import { transformCommands } from "@commands/transform";
|
||||||
import { viewportCommands } from "@commands/viewport";
|
import { viewportCommands } from "@commands/viewport";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
import { createAppStore } from "@editor/store";
|
import { createAppStore } from "@editor/store";
|
||||||
@@ -10,7 +11,7 @@ import { createAppStore } from "@editor/store";
|
|||||||
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
||||||
|
|
||||||
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
|
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);
|
const store = createAppStore(createInitialAppState(options?.documentName), registry);
|
||||||
|
|
||||||
if (options?.createDefaultArtboard !== false) {
|
if (options?.createDefaultArtboard !== false) {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export const commandIds = {
|
|||||||
toolSetActive: "tool.setActive",
|
toolSetActive: "tool.setActive",
|
||||||
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
||||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||||
|
transformBegin: "transform.begin",
|
||||||
|
transformUpdate: "transform.update",
|
||||||
|
transformEnd: "transform.end",
|
||||||
viewportPan: "viewport.pan",
|
viewportPan: "viewport.pan",
|
||||||
viewportSetZoom: "viewport.setZoom",
|
viewportSetZoom: "viewport.setZoom",
|
||||||
viewportZoomAroundPoint: "viewport.zoomAroundPoint",
|
viewportZoomAroundPoint: "viewport.zoomAroundPoint",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export { createCommandRegistry } from "./registry";
|
|||||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
|
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 type { ToolSetActivePayload } from "./tool";
|
||||||
export {
|
export {
|
||||||
viewportCommands,
|
viewportCommands,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { commandIds } from "./ids";
|
|||||||
import type { DocumentAddArtboardPayload, DocumentSetArtboardBoundsPayload } from "./document";
|
import type { DocumentAddArtboardPayload, DocumentSetArtboardBoundsPayload } from "./document";
|
||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
import type { ToolSetActivePayload } from "./tool";
|
import type { ToolSetActivePayload } from "./tool";
|
||||||
|
import type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
||||||
import type {
|
import type {
|
||||||
ViewportFitArtboardPayload,
|
ViewportFitArtboardPayload,
|
||||||
ViewportPanPayload,
|
ViewportPanPayload,
|
||||||
@@ -19,6 +20,9 @@ export type CommandPayloads = {
|
|||||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||||
[commandIds.toolEnterTemporaryPan]: void;
|
[commandIds.toolEnterTemporaryPan]: void;
|
||||||
[commandIds.toolExitTemporaryPan]: void;
|
[commandIds.toolExitTemporaryPan]: void;
|
||||||
|
[commandIds.transformBegin]: TransformBeginPayload;
|
||||||
|
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||||
|
[commandIds.transformEnd]: void;
|
||||||
[commandIds.viewportPan]: ViewportPanPayload;
|
[commandIds.viewportPan]: ViewportPanPayload;
|
||||||
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
||||||
[commandIds.viewportZoomAroundPoint]: ViewportZoomAroundPointPayload;
|
[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: [],
|
layerIds: [],
|
||||||
},
|
},
|
||||||
tools: initialToolState,
|
tools: initialToolState,
|
||||||
|
transformSession: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createInitialAppState(name = "Untitled"): AppState {
|
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 { Angle, Size, Vec2D } from "@core/geometry";
|
||||||
import type { ArtboardId, LayerId } from "@core/id";
|
import type { ArtboardId, LayerId } from "@core/id";
|
||||||
import type { ToolState } from "./tools";
|
import type { ToolState } from "./tools";
|
||||||
|
import type { TransformSession } from "./transform";
|
||||||
|
|
||||||
export type ViewportState = {
|
export type ViewportState = {
|
||||||
center: Vec2D;
|
center: Vec2D;
|
||||||
@@ -19,6 +20,7 @@ export type EditorState = {
|
|||||||
viewport: ViewportState;
|
viewport: ViewportState;
|
||||||
selection: SelectionState;
|
selection: SelectionState;
|
||||||
tools: ToolState;
|
tools: ToolState;
|
||||||
|
transformSession?: TransformSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
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