feat: implement document actions for managing artboards, zoom, and history; update UI components to utilize new actions

This commit is contained in:
syntaxbullet
2026-07-11 11:26:27 +02:00
parent 1236b6dd2f
commit 53cf25c132
12 changed files with 168 additions and 59 deletions

View File

@@ -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<typeof createImageStudioApp>;
@@ -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 },
};
}

View File

@@ -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");
});
});

58
app/document-actions.ts Normal file
View File

@@ -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<typeof createDocumentActions>;
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;
},
};
}

View File

@@ -1,2 +1,3 @@
export type { ImageStudioApp } from "./app";
export { createImageStudioApp } from "./app";
export type { DocumentActions } from "./document-actions";

View File

@@ -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;
}

View File

@@ -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}
/>
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
<div className="pointer-events-auto flex items-center gap-2">
@@ -174,7 +174,7 @@ export function App({ app }: AppProps) {
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
</button>
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, document.assets)}>
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => void app.actions.document.exportArtboard(activeArtboard?.id)}>
<DownloadSimple size={24} />
</button>
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={toggleLayers}>
@@ -202,6 +202,7 @@ export function App({ app }: AppProps) {
maskEdit={maskEdit}
open={layersOpen}
dispatch={app.store.dispatch}
documentActions={app.actions.document}
/>
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<BottomControlsIsland
@@ -224,6 +225,7 @@ export function App({ app }: AppProps) {
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
dispatch={app.store.dispatch}
generationWorkflow={app.workflows.generation}
documentActions={app.actions.document}
/>
</div>
<div className="absolute bottom-4 left-4 z-10">

View File

@@ -12,6 +12,7 @@ import { ZoomControls } from "./bottom-controls/ZoomControls";
import type { Rect } from "@core/geometry";
import type { TransformTarget } from "@editor/transform";
import type { GenerationWorkflow } from "@operations/generation/workflow";
import type { DocumentActions } from "@app/document-actions";
export type BottomControlsAction = "pan" | "zoom";
export type BottomControlsIslandProps = {
@@ -34,9 +35,10 @@ export type BottomControlsIslandProps = {
brushHint?: string;
dispatch: AppStore["dispatch"];
generationWorkflow: GenerationWorkflow;
documentActions: DocumentActions;
};
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow }: BottomControlsIslandProps) {
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
const zoomPercent = Math.round(viewport.zoom * 100);
const x = Math.round(viewport.center.x);
const y = Math.round(viewport.center.y);
@@ -49,7 +51,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
}`}
>
{operation === "generate" ? (
<GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} />
<GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} generate={documentActions.generate} />
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
<BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? (
@@ -63,7 +65,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
) : action === "pan" ? (
<PanControls x={x} y={y} />
) : (
<ZoomControls zoom={viewport.zoom} zoomPercent={zoomPercent} dispatch={dispatch} />
<ZoomControls zoomPercent={zoomPercent} actions={documentActions} />
)}
</div>
);

View File

@@ -8,6 +8,7 @@ import type { AppStore } from "@editor/store";
import type { ToolState } from "@editor/tools";
import { createDocumentReadIndex } from "@editor/document-indexes";
import { createPaletteItems, type PaletteItem } from "./paletteItems";
import type { DocumentActions } from "@app/document-actions";
export type CommandPaletteProps = {
state: CommandPaletteState;
@@ -23,6 +24,7 @@ export type CommandPaletteProps = {
openGenerate: () => void;
openLayers: () => void;
closeLayers: () => void;
documentActions: DocumentActions;
};
export function CommandPalette({
@@ -39,6 +41,7 @@ export function CommandPalette({
openGenerate,
openLayers,
closeLayers,
documentActions,
}: CommandPaletteProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
@@ -67,6 +70,7 @@ export function CommandPalette({
openGenerate,
openLayers,
closeLayers,
documentActions,
}),
[
activeArtboard,
@@ -77,6 +81,7 @@ export function CommandPalette({
dispatch,
document,
documentIndex,
documentActions,
generation,
layersOpen,
openFilePicker,

View File

@@ -9,7 +9,7 @@ import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/documen
import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { resolveLayerDrop } from "@input/index";
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import type { DocumentActions } from "@app/document-actions";
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
@@ -19,9 +19,10 @@ export type LayersSheetProps = {
maskEdit?: MaskEditState;
open: boolean;
dispatch: AppStore["dispatch"];
documentActions: DocumentActions;
};
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) {
export function LayersSheet({ document, selection, maskEdit, open, dispatch, documentActions }: LayersSheetProps) {
const draggedLayerId = useRef<string | undefined>(undefined);
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
@@ -41,6 +42,7 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
editingTitle={editingTitle}
setEditingTitle={setEditingTitle}
dispatch={dispatch}
documentActions={documentActions}
/>
) : null}
</aside>
@@ -55,6 +57,7 @@ function LayersSheetBody({
editingTitle,
setEditingTitle,
dispatch,
documentActions,
}: Omit<LayersSheetProps, "open"> & {
draggedLayerId: MutableRefObject<string | undefined>;
editingTitle: EditingTitle | undefined;
@@ -152,7 +155,7 @@ function LayersSheetBody({
title="Export PNG"
onClick={(event) => {
event.stopPropagation();
void downloadArtboardPng(artboard, document.assets);
void documentActions.exportArtboard(artboard.id);
}}
>
<DownloadSimple size={24} weight="regular" />

View File

@@ -10,9 +10,10 @@ export type GenerateActionControlsProps = {
generation: GenerationState;
dispatch: AppStore["dispatch"];
workflow: GenerationWorkflow;
generate: () => Promise<void>;
};
export function GenerateActionControls({ settings, generation, dispatch, workflow }: GenerateActionControlsProps) {
export function GenerateActionControls({ settings, generation, dispatch, workflow, generate }: GenerateActionControlsProps) {
const job = currentGenerationJob(generation);
const busy = job?.status === "running";
const candidate = selectedCandidate(generation);
@@ -28,7 +29,7 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
className="h-12 rounded-full bg-white px-7 text-base font-semibold !text-black transition hover:bg-white/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:pointer-events-none disabled:opacity-35"
title={job?.status === "failed" ? job.error : preconditionMessage ?? "Generate with ComfyUI"}
onClick={() => {
void workflow.generate();
void generate();
}}
>
{busy && job?.kind === "generate" ? "Generating..." : "Generate"}

View File

@@ -1,27 +1,25 @@
import { Minus, CornersOut, Plus } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { DocumentActions } from "@app/document-actions";
import { BottomControlDivider } from "./Divider";
import { bottomControlButtonClass, bottomControlMenuClass, bottomControlValueClass } from "./styles";
export type ZoomControlsProps = {
zoom: number;
zoomPercent: number;
dispatch: AppStore["dispatch"];
actions: DocumentActions;
};
export function ZoomControls({ zoom, zoomPercent, dispatch }: ZoomControlsProps) {
export function ZoomControls({ zoomPercent, actions }: ZoomControlsProps) {
return (
<div className={bottomControlMenuClass()}>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom out" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom / 1.2 })}>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom out" onClick={() => actions.zoomBy(1 / 1.2)}>
<Minus size={24} weight="regular" />
</button>
<span className={bottomControlValueClass()}>{zoomPercent}%</span>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom in" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom * 1.2 })}>
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom in" onClick={() => actions.zoomBy(1.2)}>
<Plus size={24} weight="regular" />
</button>
<BottomControlDivider />
<button type="button" className={bottomControlButtonClass()} aria-label="Fit artboard" title="Fit artboard" onClick={() => dispatch(commandIds.viewportFitArtboard, undefined)}>
<button type="button" className={bottomControlButtonClass()} aria-label="Fit artboard" title="Fit artboard" onClick={() => actions.fitArtboard()}>
<CornersOut size={24} weight="regular" />
</button>
</div>

View File

@@ -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: <DownloadSimple size={20} />,
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: <ArrowCounterClockwise size={20} />,
run: documentActions.undo,
},
{
id: "redo",
section: "Edit",
title: "Redo",
subtitle: "Cmd/Ctrl+Shift+Z",
disabled: !documentActions.canRedo(),
icon: <ArrowCounterClockwise size={20} className="scale-x-[-1]" />,
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: <Plus size={20} />,
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: <Minus size={20} />,
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: <CornersOut size={20} />,
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: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportFitArtboard, undefined),
},
);
items.push(
{
id: "debug-reset-viewport",
section: "Debug",
title: "Reset viewport",
icon: <CornersOut size={20} />,
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: <Cursor size={20} />,
run: () => dispatch(commandIds.selectionClear, undefined),
},
{
id: "debug-clear-candidates",
section: "Debug",
title: "Clear generation state",
disabled: !hasCandidates,
icon: <Trash size={20} />,
run: () => dispatch(commandIds.generationClearCandidates, undefined),
run: () => documentActions.fitArtboard(activeArtboard?.id),
},
);