diff --git a/app/app.ts b/app/app.ts index 40b5120..9b3bbc9 100644 --- a/app/app.ts +++ b/app/app.ts @@ -14,6 +14,7 @@ import { projectCommands } from "@commands/project"; import { createInitialAppState } from "@editor/initial-state"; import { createAppStore } from "@editor/store"; import { createGenerationWorkflow } from "@operations/generation/workflow"; +import { createDocumentActions } from "./document-actions"; export type ImageStudioApp = ReturnType; @@ -21,6 +22,7 @@ export function createImageStudioApp(options?: { documentName?: string; createDe const registry = createCommandRegistry([...projectCommands, ...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands, ...workspaceCommands, ...editorCommands]); const store = createAppStore(createInitialAppState(options?.documentName), registry); const generation = createGenerationWorkflow(store); + const documentActions = createDocumentActions(store, generation); if (options?.createDefaultArtboard !== false) { const artboardId = crypto.randomUUID(); @@ -29,12 +31,13 @@ export function createImageStudioApp(options?: { documentName?: string; createDe name: "Artboard 1", bounds: { x: -400, y: -300, w: 800, h: 600 }, }); - store.dispatch(commandIds.viewportFitArtboard, { artboardId }); + documentActions.fitArtboard(artboardId); } return { registry, store, + actions: { document: documentActions }, workflows: { generation }, }; } diff --git a/app/document-actions.test.ts b/app/document-actions.test.ts new file mode 100644 index 0000000..bf3f8d4 --- /dev/null +++ b/app/document-actions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { commandIds } from "@commands/ids"; +import { createImageStudioApp } from "./app"; + +describe("document actions", () => { + test("share zoom, fit, and history behavior across UI entry points", () => { + const app = createImageStudioApp(); + const artboard = app.store.getState().document.artboards[0]; + if (!artboard) throw new Error("Expected the default artboard."); + + app.actions.document.zoomTo(2); + app.actions.document.zoomBy(0.5); + expect(app.store.getState().editor.viewport.zoom).toBe(1); + + app.actions.document.fitArtboard(artboard.id); + expect(app.store.getState().editor.viewport.center).toEqual({ x: 0, y: 0 }); + + expect(app.actions.document.canUndo()).toBe(true); + app.actions.document.undo(); + expect(app.store.getState().document.artboards).toHaveLength(0); + expect(app.actions.document.canRedo()).toBe(true); + app.actions.document.redo(); + expect(app.store.getState().document.artboards).toHaveLength(1); + }); + + test("opens Generate through the canonical workspace command", () => { + const app = createImageStudioApp({ createDefaultArtboard: false }); + app.actions.document.openGenerate(); + expect(app.store.getState().editor.workspace.panel).toBe("generate"); + + app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" }); + expect(app.store.getState().editor.workspace.panel).toBe("none"); + }); +}); diff --git a/app/document-actions.ts b/app/document-actions.ts new file mode 100644 index 0000000..645a489 --- /dev/null +++ b/app/document-actions.ts @@ -0,0 +1,58 @@ +import { commandIds } from "@commands/ids"; +import type { ArtboardId } from "@core/id"; +import type { AppStore } from "@editor/store"; +import { downloadArtboardPng } from "@operations/export/downloadArtboard"; +import type { GenerationWorkflow } from "@operations/generation/workflow"; + +export type DocumentActions = ReturnType; + +export function createDocumentActions(store: AppStore, generation: GenerationWorkflow) { + return { + openGenerate() { + store.dispatch(commandIds.workspaceSetPanel, { panel: "generate" }); + }, + + generate() { + return generation.generate(); + }, + + exportArtboard(artboardId?: ArtboardId) { + const state = store.getState(); + const resolvedId = artboardId ?? state.editor.selection.artboardId; + const artboard = resolvedId + ? state.document.artboards.find((candidate) => candidate.id === resolvedId) + : state.document.artboards[0]; + if (!artboard) return Promise.resolve(); + return downloadArtboardPng(artboard, state.document.assets); + }, + + fitArtboard(artboardId?: ArtboardId) { + store.dispatch(commandIds.viewportFitArtboard, artboardId ? { artboardId } : undefined); + }, + + zoomBy(factor: number) { + const zoom = store.getState().editor.viewport.zoom; + store.dispatch(commandIds.viewportSetZoom, { zoom: zoom * factor }); + }, + + zoomTo(zoom: number) { + store.dispatch(commandIds.viewportSetZoom, { zoom }); + }, + + undo() { + store.dispatch(commandIds.historyUndo, undefined); + }, + + canUndo() { + return store.getState().history.past.length > 0; + }, + + redo() { + store.dispatch(commandIds.historyRedo, undefined); + }, + + canRedo() { + return store.getState().history.future.length > 0; + }, + }; +} diff --git a/app/index.ts b/app/index.ts index a410163..8d95b34 100644 --- a/app/index.ts +++ b/app/index.ts @@ -1,2 +1,3 @@ export type { ImageStudioApp } from "./app"; export { createImageStudioApp } from "./app"; +export type { DocumentActions } from "./document-actions"; diff --git a/input/history.ts b/input/history.ts index ca319c7..7307147 100644 --- a/input/history.ts +++ b/input/history.ts @@ -2,11 +2,20 @@ import { commandIds } from "@commands/ids"; import type { Dispatch } from "@commands/dispatcher"; import type { KeybindEvent } from "./keyboard"; -export function handleHistoryKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean { +export type HistoryActions = { undo(): void; redo(): void }; + +export function handleHistoryKey(options: { event: KeybindEvent; dispatch?: Dispatch; actions?: HistoryActions }): boolean { if (options.event.altKey) return false; const modifier = options.event.metaKey || options.event.ctrlKey; if (!modifier || options.event.key.toLowerCase() !== "z") return false; - options.dispatch(options.event.shiftKey ? commandIds.historyRedo : commandIds.historyUndo, undefined); + if (options.actions) { + if (options.event.shiftKey) options.actions.redo(); + else options.actions.undo(); + } else if (options.dispatch) { + options.dispatch(options.event.shiftKey ? commandIds.historyRedo : commandIds.historyUndo, undefined); + } else { + return false; + } return true; } diff --git a/view/App.tsx b/view/App.tsx index 91989b1..72ec449 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -15,7 +15,6 @@ import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/t import type { AppState } from "@editor/state"; import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleOperationKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index"; import { shallowEqual, useAppState } from "./useAppState"; -import { downloadArtboardPng } from "@operations/export/downloadArtboard"; import { useImageImport } from "./useImageImport"; import { useViewportActivityIsland } from "./useViewportActivityIsland"; import { useProjectLifecycle } from "./useProjectLifecycle"; @@ -42,8 +41,8 @@ export function App({ app }: AppProps) { }, [app.workflows.generation, generateOpen]); const openGenerate = useCallback(() => { - app.store.dispatch(commandIds.workspaceSetPanel, { panel: "generate" }); - }, [app.store]); + app.actions.document.openGenerate(); + }, [app.actions.document]); const closeGenerate = useCallback(() => { app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" }); @@ -90,7 +89,7 @@ export function App({ app }: AppProps) { if (editableTarget) return; - const historyConsumed = handleHistoryKey({ event: keybindEvent, dispatch: app.store.dispatch }); + const historyConsumed = handleHistoryKey({ event: keybindEvent, actions: app.actions.document }); if (historyConsumed) { event.preventDefault(); return; @@ -134,7 +133,7 @@ export function App({ app }: AppProps) { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [app.store, toggleLayers]); + }, [app.actions.document, app.store, toggleLayers]); const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined; const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit }); @@ -156,6 +155,7 @@ export function App({ app }: AppProps) { openGenerate={openGenerate} openLayers={openLayers} closeLayers={closeLayers} + documentActions={app.actions.document} />
@@ -174,7 +174,7 @@ export function App({ app }: AppProps) { - {zoomPercent}% - -
diff --git a/view/paletteItems.tsx b/view/paletteItems.tsx index b09d861..7aee3a1 100644 --- a/view/paletteItems.tsx +++ b/view/paletteItems.tsx @@ -1,4 +1,4 @@ -import { ArrowDown, ArrowUp, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, Minus, PaintBrush, Plus, Sparkle, Stack, Trash } from "@phosphor-icons/react"; +import { ArrowCounterClockwise, ArrowDown, ArrowUp, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, Minus, PaintBrush, Plus, Sparkle, Stack, Trash } from "@phosphor-icons/react"; import type { ReactNode } from "react"; import { commandIds } from "@commands/ids"; import type { Artboard } from "@core/artboard"; @@ -7,7 +7,7 @@ import type { GenerationCompareMode, GenerationState, SelectionState, ViewportSt import type { AppStore } from "@editor/store"; import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools"; import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes"; -import { downloadArtboardPng } from "@operations/export/downloadArtboard"; +import type { DocumentActions } from "@app/document-actions"; import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions"; import { labelForTool } from "./toolLabels"; @@ -31,6 +31,7 @@ export function createPaletteItems(options: { openGenerate: () => void; openLayers: () => void; closeLayers: () => void; + documentActions: DocumentActions; }): PaletteItem[] { const { document, @@ -50,6 +51,7 @@ export function createPaletteItems(options: { openGenerate, openLayers, closeLayers, + documentActions, } = options; const hasCandidates = generation.candidates.length > 0; const items: PaletteItem[] = []; @@ -85,12 +87,30 @@ export function createPaletteItems(options: { disabled: !activeArtboard, icon: , run: () => { - if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets); + if (activeArtboard) void documentActions.exportArtboard(activeArtboard.id); }, }, ); items.push( + { + id: "undo", + section: "Edit", + title: "Undo", + subtitle: "Cmd/Ctrl+Z", + disabled: !documentActions.canUndo(), + icon: , + run: documentActions.undo, + }, + { + id: "redo", + section: "Edit", + title: "Redo", + subtitle: "Cmd/Ctrl+Shift+Z", + disabled: !documentActions.canRedo(), + icon: , + run: documentActions.redo, + }, { id: layersOpen ? "close-layers" : "open-layers", section: "Layers", @@ -271,7 +291,7 @@ export function createPaletteItems(options: { title: "Zoom in", subtitle: `${Math.round(viewport.zoom * 100)}%`, icon: , - run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }), + run: () => documentActions.zoomBy(1.2), }, { id: "zoom-out", @@ -279,14 +299,14 @@ export function createPaletteItems(options: { title: "Zoom out", subtitle: `${Math.round(viewport.zoom * 100)}%`, icon: , - run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }), + run: () => documentActions.zoomBy(1 / 1.2), }, { id: "zoom-100", section: "Zoom", title: "Zoom to 100%", icon: , - run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }), + run: () => documentActions.zoomTo(1), }, { id: "fit-artboard", @@ -295,34 +315,7 @@ export function createPaletteItems(options: { subtitle: activeArtboard?.name, disabled: !activeArtboard, icon: , - run: () => dispatch(commandIds.viewportFitArtboard, undefined), - }, - ); - - items.push( - { - id: "debug-reset-viewport", - section: "Debug", - title: "Reset viewport", - icon: , - run: () => dispatch(commandIds.viewportReset, undefined), - }, - { - id: "debug-clear-selection", - section: "Debug", - title: "Clear selection", - subtitle: selection.layerIds.length > 0 || selection.artboardId ? undefined : "Nothing selected", - disabled: selection.layerIds.length === 0 && !selection.artboardId, - icon: , - run: () => dispatch(commandIds.selectionClear, undefined), - }, - { - id: "debug-clear-candidates", - section: "Debug", - title: "Clear generation state", - disabled: !hasCandidates, - icon: , - run: () => dispatch(commandIds.generationClearCandidates, undefined), + run: () => documentActions.fitArtboard(activeArtboard?.id), }, );