feat: implement document actions for managing artboards, zoom, and history; update UI components to utilize new actions
This commit is contained in:
@@ -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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
34
app/document-actions.test.ts
Normal file
34
app/document-actions.test.ts
Normal 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
58
app/document-actions.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
14
view/App.tsx
14
view/App.tsx
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user