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 { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store"; import { createAppStore } from "@editor/store";
import { createGenerationWorkflow } from "@operations/generation/workflow"; import { createGenerationWorkflow } from "@operations/generation/workflow";
import { createDocumentActions } from "./document-actions";
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>; 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 registry = createCommandRegistry([...projectCommands, ...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands, ...workspaceCommands, ...editorCommands]);
const store = createAppStore(createInitialAppState(options?.documentName), registry); const store = createAppStore(createInitialAppState(options?.documentName), registry);
const generation = createGenerationWorkflow(store); const generation = createGenerationWorkflow(store);
const documentActions = createDocumentActions(store, generation);
if (options?.createDefaultArtboard !== false) { if (options?.createDefaultArtboard !== false) {
const artboardId = crypto.randomUUID(); const artboardId = crypto.randomUUID();
@@ -29,12 +31,13 @@ export function createImageStudioApp(options?: { documentName?: string; createDe
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 }); documentActions.fitArtboard(artboardId);
} }
return { return {
registry, registry,
store, store,
actions: { document: documentActions },
workflows: { generation }, 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 type { ImageStudioApp } from "./app";
export { createImageStudioApp } 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 { Dispatch } from "@commands/dispatcher";
import type { KeybindEvent } from "./keyboard"; 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; if (options.event.altKey) return false;
const modifier = options.event.metaKey || options.event.ctrlKey; const modifier = options.event.metaKey || options.event.ctrlKey;
if (!modifier || options.event.key.toLowerCase() !== "z") return false; 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; return true;
} }

View File

@@ -15,7 +15,6 @@ import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/t
import type { AppState } from "@editor/state"; import type { AppState } from "@editor/state";
import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleOperationKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index"; import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleOperationKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { shallowEqual, useAppState } from "./useAppState"; import { shallowEqual, useAppState } from "./useAppState";
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import { useImageImport } from "./useImageImport"; import { useImageImport } from "./useImageImport";
import { useViewportActivityIsland } from "./useViewportActivityIsland"; import { useViewportActivityIsland } from "./useViewportActivityIsland";
import { useProjectLifecycle } from "./useProjectLifecycle"; import { useProjectLifecycle } from "./useProjectLifecycle";
@@ -42,8 +41,8 @@ export function App({ app }: AppProps) {
}, [app.workflows.generation, generateOpen]); }, [app.workflows.generation, generateOpen]);
const openGenerate = useCallback(() => { const openGenerate = useCallback(() => {
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "generate" }); app.actions.document.openGenerate();
}, [app.store]); }, [app.actions.document]);
const closeGenerate = useCallback(() => { const closeGenerate = useCallback(() => {
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" }); app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" });
@@ -90,7 +89,7 @@ export function App({ app }: AppProps) {
if (editableTarget) return; if (editableTarget) return;
const historyConsumed = handleHistoryKey({ event: keybindEvent, dispatch: app.store.dispatch }); const historyConsumed = handleHistoryKey({ event: keybindEvent, actions: app.actions.document });
if (historyConsumed) { if (historyConsumed) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -134,7 +133,7 @@ export function App({ app }: AppProps) {
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [app.store, toggleLayers]); }, [app.actions.document, app.store, toggleLayers]);
const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined; const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined;
const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit }); const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit });
@@ -156,6 +155,7 @@ export function App({ app }: AppProps) {
openGenerate={openGenerate} openGenerate={openGenerate}
openLayers={openLayers} openLayers={openLayers}
closeLayers={closeLayers} 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"> <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"> <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}> <button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} /> <Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
</button> </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} /> <DownloadSimple size={24} />
</button> </button>
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={toggleLayers}> <button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={toggleLayers}>
@@ -202,6 +202,7 @@ export function App({ app }: AppProps) {
maskEdit={maskEdit} maskEdit={maskEdit}
open={layersOpen} open={layersOpen}
dispatch={app.store.dispatch} dispatch={app.store.dispatch}
documentActions={app.actions.document}
/> />
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center"> <div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<BottomControlsIsland <BottomControlsIsland
@@ -224,6 +225,7 @@ export function App({ app }: AppProps) {
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget} transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
dispatch={app.store.dispatch} dispatch={app.store.dispatch}
generationWorkflow={app.workflows.generation} generationWorkflow={app.workflows.generation}
documentActions={app.actions.document}
/> />
</div> </div>
<div className="absolute bottom-4 left-4 z-10"> <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 { Rect } from "@core/geometry";
import type { TransformTarget } from "@editor/transform"; import type { TransformTarget } from "@editor/transform";
import type { GenerationWorkflow } from "@operations/generation/workflow"; import type { GenerationWorkflow } from "@operations/generation/workflow";
import type { DocumentActions } from "@app/document-actions";
export type BottomControlsAction = "pan" | "zoom"; export type BottomControlsAction = "pan" | "zoom";
export type BottomControlsIslandProps = { export type BottomControlsIslandProps = {
@@ -34,9 +35,10 @@ export type BottomControlsIslandProps = {
brushHint?: string; brushHint?: string;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
generationWorkflow: GenerationWorkflow; 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 zoomPercent = Math.round(viewport.zoom * 100);
const x = Math.round(viewport.center.x); const x = Math.round(viewport.center.x);
const y = Math.round(viewport.center.y); const y = Math.round(viewport.center.y);
@@ -49,7 +51,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
}`} }`}
> >
{operation === "generate" ? ( {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 ? ( ) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
<BrushHint tool={activeTool} hint={brushHint} /> <BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? ( ) : activeTool === "brush" || activeTool === "eraser" ? (
@@ -63,7 +65,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
) : action === "pan" ? ( ) : action === "pan" ? (
<PanControls x={x} y={y} /> <PanControls x={x} y={y} />
) : ( ) : (
<ZoomControls zoom={viewport.zoom} zoomPercent={zoomPercent} dispatch={dispatch} /> <ZoomControls zoomPercent={zoomPercent} actions={documentActions} />
)} )}
</div> </div>
); );

View File

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

View File

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

View File

@@ -10,9 +10,10 @@ export type GenerateActionControlsProps = {
generation: GenerationState; generation: GenerationState;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
workflow: GenerationWorkflow; 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 job = currentGenerationJob(generation);
const busy = job?.status === "running"; const busy = job?.status === "running";
const candidate = selectedCandidate(generation); 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" 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"} title={job?.status === "failed" ? job.error : preconditionMessage ?? "Generate with ComfyUI"}
onClick={() => { onClick={() => {
void workflow.generate(); void generate();
}} }}
> >
{busy && job?.kind === "generate" ? "Generating..." : "Generate"} {busy && job?.kind === "generate" ? "Generating..." : "Generate"}

View File

@@ -1,27 +1,25 @@
import { Minus, CornersOut, Plus } from "@phosphor-icons/react"; import { Minus, CornersOut, Plus } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids"; import type { DocumentActions } from "@app/document-actions";
import type { AppStore } from "@editor/store";
import { BottomControlDivider } from "./Divider"; import { BottomControlDivider } from "./Divider";
import { bottomControlButtonClass, bottomControlMenuClass, bottomControlValueClass } from "./styles"; import { bottomControlButtonClass, bottomControlMenuClass, bottomControlValueClass } from "./styles";
export type ZoomControlsProps = { export type ZoomControlsProps = {
zoom: number;
zoomPercent: number; zoomPercent: number;
dispatch: AppStore["dispatch"]; actions: DocumentActions;
}; };
export function ZoomControls({ zoom, zoomPercent, dispatch }: ZoomControlsProps) { export function ZoomControls({ zoomPercent, actions }: ZoomControlsProps) {
return ( return (
<div className={bottomControlMenuClass()}> <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" /> <Minus size={24} weight="regular" />
</button> </button>
<span className={bottomControlValueClass()}>{zoomPercent}%</span> <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" /> <Plus size={24} weight="regular" />
</button> </button>
<BottomControlDivider /> <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" /> <CornersOut size={24} weight="regular" />
</button> </button>
</div> </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 type { ReactNode } from "react";
import { commandIds } from "@commands/ids"; import { commandIds } from "@commands/ids";
import type { Artboard } from "@core/artboard"; import type { Artboard } from "@core/artboard";
@@ -7,7 +7,7 @@ import type { GenerationCompareMode, GenerationState, SelectionState, ViewportSt
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools"; import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools";
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes"; 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 { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { labelForTool } from "./toolLabels"; import { labelForTool } from "./toolLabels";
@@ -31,6 +31,7 @@ export function createPaletteItems(options: {
openGenerate: () => void; openGenerate: () => void;
openLayers: () => void; openLayers: () => void;
closeLayers: () => void; closeLayers: () => void;
documentActions: DocumentActions;
}): PaletteItem[] { }): PaletteItem[] {
const { const {
document, document,
@@ -50,6 +51,7 @@ export function createPaletteItems(options: {
openGenerate, openGenerate,
openLayers, openLayers,
closeLayers, closeLayers,
documentActions,
} = options; } = options;
const hasCandidates = generation.candidates.length > 0; const hasCandidates = generation.candidates.length > 0;
const items: PaletteItem[] = []; const items: PaletteItem[] = [];
@@ -85,12 +87,30 @@ export function createPaletteItems(options: {
disabled: !activeArtboard, disabled: !activeArtboard,
icon: <DownloadSimple size={20} />, icon: <DownloadSimple size={20} />,
run: () => { run: () => {
if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets); if (activeArtboard) void documentActions.exportArtboard(activeArtboard.id);
}, },
}, },
); );
items.push( 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", id: layersOpen ? "close-layers" : "open-layers",
section: "Layers", section: "Layers",
@@ -271,7 +291,7 @@ export function createPaletteItems(options: {
title: "Zoom in", title: "Zoom in",
subtitle: `${Math.round(viewport.zoom * 100)}%`, subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Plus size={20} />, icon: <Plus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }), run: () => documentActions.zoomBy(1.2),
}, },
{ {
id: "zoom-out", id: "zoom-out",
@@ -279,14 +299,14 @@ export function createPaletteItems(options: {
title: "Zoom out", title: "Zoom out",
subtitle: `${Math.round(viewport.zoom * 100)}%`, subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Minus size={20} />, icon: <Minus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }), run: () => documentActions.zoomBy(1 / 1.2),
}, },
{ {
id: "zoom-100", id: "zoom-100",
section: "Zoom", section: "Zoom",
title: "Zoom to 100%", title: "Zoom to 100%",
icon: <CornersOut size={20} />, icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }), run: () => documentActions.zoomTo(1),
}, },
{ {
id: "fit-artboard", id: "fit-artboard",
@@ -295,34 +315,7 @@ export function createPaletteItems(options: {
subtitle: activeArtboard?.name, subtitle: activeArtboard?.name,
disabled: !activeArtboard, disabled: !activeArtboard,
icon: <CornersOut size={20} />, icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportFitArtboard, undefined), run: () => documentActions.fitArtboard(activeArtboard?.id),
},
);
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),
}, },
); );