From 8965532c3a4c1cbbe6afde89c4c3431a968c3e51 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 11:31:55 +0200 Subject: [PATCH] feat(viewport): add fit artboard command --- app/app.ts | 4 +++- commands/ids.ts | 1 + commands/payloads.ts | 2 ++ commands/viewport.test.ts | 31 +++++++++++++++++++++++++++++ commands/viewport.ts | 41 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/app/app.ts b/app/app.ts index 3e48fdf..79aec5b 100644 --- a/app/app.ts +++ b/app/app.ts @@ -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 { diff --git a/commands/ids.ts b/commands/ids.ts index e55bb2a..de09d74 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -11,4 +11,5 @@ export const commandIds = { viewportZoomAroundPoint: "viewport.zoomAroundPoint", viewportSetSize: "viewport.setSize", viewportReset: "viewport.reset", + viewportFitArtboard: "viewport.fitArtboard", } as const; diff --git a/commands/payloads.ts b/commands/payloads.ts index e5995cb..39036ef 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -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; diff --git a/commands/viewport.test.ts b/commands/viewport.test.ts index fdca045..7747a78 100644 --- a/commands/viewport.test.ts +++ b/commands/viewport.test.ts @@ -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 } }); diff --git a/commands/viewport.ts b/commands/viewport.ts index 1abfef8..f296cdc 100644 --- a/commands/viewport.ts +++ b/commands/viewport.ts @@ -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 = { id: commandIds.viewportPan, name: "Pan viewport", @@ -111,6 +117,40 @@ export const viewportSetSizeCommand: Command = { }, }; +export const viewportFitArtboardCommand: Command = { + 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[];