feat(viewport): add fit artboard command
This commit is contained in:
@@ -14,11 +14,13 @@ export function createImageStudioApp(options?: { documentName?: string; createDe
|
||||
const store = createAppStore(createInitialAppState(options?.documentName), registry);
|
||||
|
||||
if (options?.createDefaultArtboard !== false) {
|
||||
const artboardId = crypto.randomUUID();
|
||||
store.dispatch(commandIds.documentAddArtboard, {
|
||||
id: crypto.randomUUID(),
|
||||
id: artboardId,
|
||||
name: "Artboard 1",
|
||||
bounds: { x: -400, y: -300, w: 800, h: 600 },
|
||||
});
|
||||
store.dispatch(commandIds.viewportFitArtboard, { artboardId });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,4 +11,5 @@ export const commandIds = {
|
||||
viewportZoomAroundPoint: "viewport.zoomAroundPoint",
|
||||
viewportSetSize: "viewport.setSize",
|
||||
viewportReset: "viewport.reset",
|
||||
viewportFitArtboard: "viewport.fitArtboard",
|
||||
} as const;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DocumentAddArtboardPayload } from "./document";
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolSetActivePayload } from "./tool";
|
||||
import type {
|
||||
ViewportFitArtboardPayload,
|
||||
ViewportPanPayload,
|
||||
ViewportSetSizePayload,
|
||||
ViewportSetZoomPayload,
|
||||
@@ -22,6 +23,7 @@ export type CommandPayloads = {
|
||||
[commandIds.viewportZoomAroundPoint]: ViewportZoomAroundPointPayload;
|
||||
[commandIds.viewportSetSize]: ViewportSetSizePayload;
|
||||
[commandIds.viewportReset]: void;
|
||||
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
|
||||
};
|
||||
|
||||
export type CommandId = keyof CommandPayloads;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import {
|
||||
viewportFitArtboardCommand,
|
||||
viewportPanCommand,
|
||||
viewportResetCommand,
|
||||
viewportSetSizeCommand,
|
||||
@@ -34,6 +35,36 @@ describe("viewport commands", () => {
|
||||
expect(next.editor.viewport.center).toEqual({ x: 25, y: 0 });
|
||||
});
|
||||
|
||||
test("fits an artboard in the viewport", () => {
|
||||
const state = {
|
||||
...viewportSetSizeCommand.execute(context(), { w: 1000, h: 800 }),
|
||||
document: {
|
||||
...context().state.document,
|
||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: -400, y: -300, w: 800, h: 600 }, backgroundColor: "transparent", layers: [] }],
|
||||
},
|
||||
};
|
||||
|
||||
const next = viewportFitArtboardCommand.execute({ state }, { artboardId: "artboard-1", padding: 100 });
|
||||
|
||||
expect(next.editor.viewport.center).toEqual({ x: 0, y: 0 });
|
||||
expect(next.editor.viewport.zoom).toBe(1);
|
||||
});
|
||||
|
||||
test("fit artboard centers even before viewport size is known", () => {
|
||||
const state = {
|
||||
...context().state,
|
||||
document: {
|
||||
...context().state.document,
|
||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: 10, y: 20, w: 100, h: 200 }, backgroundColor: "transparent", layers: [] }],
|
||||
},
|
||||
};
|
||||
|
||||
const next = viewportFitArtboardCommand.execute({ state }, { artboardId: "artboard-1" });
|
||||
|
||||
expect(next.editor.viewport.center).toEqual({ x: 60, y: 120 });
|
||||
expect(next.editor.viewport.zoom).toBe(1);
|
||||
});
|
||||
|
||||
test("resets viewport but preserves size", () => {
|
||||
const sized = viewportSetSizeCommand.execute(context(), { w: 640, h: 480 });
|
||||
const panned = viewportPanCommand.execute({ state: sized }, { delta: { x: 4, y: 8 } });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ArtboardId } from "@core/id";
|
||||
import type { Vec2D } from "@core/geometry";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
@@ -20,6 +21,11 @@ export type ViewportSetSizePayload = {
|
||||
h: number;
|
||||
};
|
||||
|
||||
export type ViewportFitArtboardPayload = {
|
||||
artboardId?: ArtboardId;
|
||||
padding?: number;
|
||||
};
|
||||
|
||||
export const viewportPanCommand: Command<ViewportPanPayload> = {
|
||||
id: commandIds.viewportPan,
|
||||
name: "Pan viewport",
|
||||
@@ -111,6 +117,40 @@ export const viewportSetSizeCommand: Command<ViewportSetSizePayload> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const viewportFitArtboardCommand: Command<ViewportFitArtboardPayload | undefined> = {
|
||||
id: commandIds.viewportFitArtboard,
|
||||
name: "Fit artboard in viewport",
|
||||
execute({ state }, payload) {
|
||||
const artboardId = payload?.artboardId ?? state.editor.selection.artboardId;
|
||||
const artboard = artboardId
|
||||
? state.document.artboards.find((candidate) => candidate.id === artboardId)
|
||||
: state.document.artboards[0];
|
||||
|
||||
if (!artboard) return state;
|
||||
|
||||
const padding = Math.max(0, payload?.padding ?? 48);
|
||||
const availableWidth = Math.max(0, state.editor.viewport.size.w - padding * 2);
|
||||
const availableHeight = Math.max(0, state.editor.viewport.size.h - padding * 2);
|
||||
const canFitZoom = availableWidth > 0 && availableHeight > 0 && artboard.bounds.w > 0 && artboard.bounds.h > 0;
|
||||
const zoom = canFitZoom ? Math.max(0.01, Math.min(availableWidth / artboard.bounds.w, availableHeight / artboard.bounds.h)) : state.editor.viewport.zoom;
|
||||
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
viewport: {
|
||||
...state.editor.viewport,
|
||||
center: {
|
||||
x: artboard.bounds.x + artboard.bounds.w / 2,
|
||||
y: artboard.bounds.y + artboard.bounds.h / 2,
|
||||
},
|
||||
zoom,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const viewportResetCommand: Command = {
|
||||
id: commandIds.viewportReset,
|
||||
name: "Reset viewport",
|
||||
@@ -135,5 +175,6 @@ export const viewportCommands = [
|
||||
viewportSetZoomCommand,
|
||||
viewportZoomAroundPointCommand,
|
||||
viewportSetSizeCommand,
|
||||
viewportFitArtboardCommand,
|
||||
viewportResetCommand,
|
||||
] satisfies Command<unknown>[];
|
||||
|
||||
Reference in New Issue
Block a user