feat(viewport): add fit artboard command

This commit is contained in:
syntaxbullet
2026-07-03 11:31:55 +02:00
parent 84a610f019
commit 8965532c3a
5 changed files with 78 additions and 1 deletions

View File

@@ -14,11 +14,13 @@ export function createImageStudioApp(options?: { documentName?: string; createDe
const store = createAppStore(createInitialAppState(options?.documentName), registry); const store = createAppStore(createInitialAppState(options?.documentName), registry);
if (options?.createDefaultArtboard !== false) { if (options?.createDefaultArtboard !== false) {
const artboardId = crypto.randomUUID();
store.dispatch(commandIds.documentAddArtboard, { store.dispatch(commandIds.documentAddArtboard, {
id: crypto.randomUUID(), id: artboardId,
name: "Artboard 1", name: "Artboard 1",
bounds: { x: -400, y: -300, w: 800, h: 600 }, bounds: { x: -400, y: -300, w: 800, h: 600 },
}); });
store.dispatch(commandIds.viewportFitArtboard, { artboardId });
} }
return { return {

View File

@@ -11,4 +11,5 @@ export const commandIds = {
viewportZoomAroundPoint: "viewport.zoomAroundPoint", viewportZoomAroundPoint: "viewport.zoomAroundPoint",
viewportSetSize: "viewport.setSize", viewportSetSize: "viewport.setSize",
viewportReset: "viewport.reset", viewportReset: "viewport.reset",
viewportFitArtboard: "viewport.fitArtboard",
} as const; } as const;

View File

@@ -3,6 +3,7 @@ import type { DocumentAddArtboardPayload } 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 { import type {
ViewportFitArtboardPayload,
ViewportPanPayload, ViewportPanPayload,
ViewportSetSizePayload, ViewportSetSizePayload,
ViewportSetZoomPayload, ViewportSetZoomPayload,
@@ -22,6 +23,7 @@ export type CommandPayloads = {
[commandIds.viewportZoomAroundPoint]: ViewportZoomAroundPointPayload; [commandIds.viewportZoomAroundPoint]: ViewportZoomAroundPointPayload;
[commandIds.viewportSetSize]: ViewportSetSizePayload; [commandIds.viewportSetSize]: ViewportSetSizePayload;
[commandIds.viewportReset]: void; [commandIds.viewportReset]: void;
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
}; };
export type CommandId = keyof CommandPayloads; export type CommandId = keyof CommandPayloads;

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state"; import { createInitialAppState } from "@editor/initial-state";
import { import {
viewportFitArtboardCommand,
viewportPanCommand, viewportPanCommand,
viewportResetCommand, viewportResetCommand,
viewportSetSizeCommand, viewportSetSizeCommand,
@@ -34,6 +35,36 @@ describe("viewport commands", () => {
expect(next.editor.viewport.center).toEqual({ x: 25, y: 0 }); 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", () => { test("resets viewport but preserves size", () => {
const sized = viewportSetSizeCommand.execute(context(), { w: 640, h: 480 }); const sized = viewportSetSizeCommand.execute(context(), { w: 640, h: 480 });
const panned = viewportPanCommand.execute({ state: sized }, { delta: { x: 4, y: 8 } }); const panned = viewportPanCommand.execute({ state: sized }, { delta: { x: 4, y: 8 } });

View File

@@ -1,3 +1,4 @@
import type { ArtboardId } from "@core/id";
import type { Vec2D } from "@core/geometry"; import type { Vec2D } from "@core/geometry";
import type { Command } from "./command"; import type { Command } from "./command";
import { commandIds } from "./ids"; import { commandIds } from "./ids";
@@ -20,6 +21,11 @@ export type ViewportSetSizePayload = {
h: number; h: number;
}; };
export type ViewportFitArtboardPayload = {
artboardId?: ArtboardId;
padding?: number;
};
export const viewportPanCommand: Command<ViewportPanPayload> = { export const viewportPanCommand: Command<ViewportPanPayload> = {
id: commandIds.viewportPan, id: commandIds.viewportPan,
name: "Pan viewport", 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 = { export const viewportResetCommand: Command = {
id: commandIds.viewportReset, id: commandIds.viewportReset,
name: "Reset viewport", name: "Reset viewport",
@@ -135,5 +175,6 @@ export const viewportCommands = [
viewportSetZoomCommand, viewportSetZoomCommand,
viewportZoomAroundPointCommand, viewportZoomAroundPointCommand,
viewportSetSizeCommand, viewportSetSizeCommand,
viewportFitArtboardCommand,
viewportResetCommand, viewportResetCommand,
] satisfies Command<unknown>[]; ] satisfies Command<unknown>[];