perf(view): reduce shell rerenders
This commit is contained in:
76
view/App.tsx
76
view/App.tsx
@@ -10,9 +10,10 @@ import { LayersSheet } from "./LayersSheet";
|
|||||||
import { ShortcutsDisplay } from "./ShortcutsDisplay";
|
import { ShortcutsDisplay } from "./ShortcutsDisplay";
|
||||||
import { ToolOverlay } from "./ToolOverlay";
|
import { ToolOverlay } from "./ToolOverlay";
|
||||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||||
|
import type { AppState } from "@editor/state";
|
||||||
import type { ToolId } from "@editor/tools";
|
import type { ToolId } from "@editor/tools";
|
||||||
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
|
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
|
||||||
import { useAppState } from "./useAppState";
|
import { shallowEqual, useAppState } from "./useAppState";
|
||||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
||||||
import { useImageImport } from "./useImageImport";
|
import { useImageImport } from "./useImageImport";
|
||||||
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
||||||
@@ -23,14 +24,15 @@ export type AppProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function App({ app }: AppProps) {
|
export function App({ app }: AppProps) {
|
||||||
const state = useAppState(app.store);
|
const shellState = useAppState(app.store, selectAppShellState, shallowEqual);
|
||||||
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
const { document, selection, viewport, tools, transformSession, maskEdit } = shellState;
|
||||||
|
const viewportActivityIsland = useViewportActivityIsland(viewport);
|
||||||
const imageImport = useImageImport(app.store);
|
const imageImport = useImageImport(app.store);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
const [generateOpen, setGenerateOpen] = useState(false);
|
const [generateOpen, setGenerateOpen] = useState(false);
|
||||||
const previousGenerateTool = useRef<ToolId>("select");
|
const previousGenerateTool = useRef<ToolId>("select");
|
||||||
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
|
const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection);
|
||||||
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
|
const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0];
|
||||||
|
|
||||||
const openGenerate = useCallback(() => {
|
const openGenerate = useCallback(() => {
|
||||||
const activeTool = app.store.getState().editor.tools.activeTool;
|
const activeTool = app.store.getState().editor.tools.activeTool;
|
||||||
@@ -108,8 +110,8 @@ 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, closeGenerate, toggleGenerate]);
|
}, [app.store, closeGenerate, toggleGenerate]);
|
||||||
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
|
const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined;
|
||||||
const brushHint = brushUnavailableHint(state.document, state.editor);
|
const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
|
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
|
||||||
@@ -122,7 +124,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, state.document.assets)}>
|
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, document.assets)}>
|
||||||
<DownloadSimple size={24} />
|
<DownloadSimple size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => { closeGenerate(); setLayersOpen((open) => !open); }}>
|
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => { closeGenerate(); setLayersOpen((open) => !open); }}>
|
||||||
@@ -132,41 +134,37 @@ export function App({ app }: AppProps) {
|
|||||||
</header>
|
</header>
|
||||||
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||||
<ToolOverlay
|
<ToolOverlay
|
||||||
activeTool={state.editor.tools.activeTool}
|
activeTool={tools.activeTool}
|
||||||
interactionMode={state.editor.tools.interactionMode}
|
interactionMode={tools.interactionMode}
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<GenerateSheet
|
<GenerateSheet
|
||||||
document={state.document}
|
settings={tools.generate}
|
||||||
selection={state.editor.selection}
|
|
||||||
viewport={state.editor.viewport}
|
|
||||||
settings={state.editor.tools.generate}
|
|
||||||
open={generateOpen}
|
open={generateOpen}
|
||||||
onOpenChange={(open) => open ? openGenerate() : closeGenerate()}
|
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
<LayersSheet
|
<LayersSheet
|
||||||
document={state.document}
|
document={document}
|
||||||
selection={state.editor.selection}
|
selection={selection}
|
||||||
maskEdit={state.editor.maskEdit}
|
maskEdit={maskEdit}
|
||||||
open={layersOpen}
|
open={layersOpen}
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
<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
|
||||||
document={state.document}
|
document={document}
|
||||||
selection={state.editor.selection}
|
selection={selection}
|
||||||
viewport={state.editor.viewport}
|
viewport={viewport}
|
||||||
visible={state.editor.tools.activeTool === "generate" || state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || state.editor.tools.activeTool === "chromaKey" || state.editor.tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
visible={tools.activeTool === "generate" || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "chromaKey" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||||
action={viewportActivityIsland.action}
|
action={viewportActivityIsland.action}
|
||||||
activeTool={state.editor.tools.activeTool}
|
activeTool={tools.activeTool}
|
||||||
brushSettings={state.editor.tools.brush}
|
brushSettings={tools.brush}
|
||||||
generateSettings={state.editor.tools.generate}
|
generateSettings={tools.generate}
|
||||||
chromaKeySettings={state.editor.tools.chromaKey}
|
chromaKeySettings={tools.chromaKey}
|
||||||
magicWandSettings={state.editor.tools.magicWand}
|
magicWandSettings={tools.magicWand}
|
||||||
editingMask={Boolean(state.editor.maskEdit)}
|
editingMask={Boolean(maskEdit)}
|
||||||
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
|
maskViewMode={maskEdit?.viewMode ?? "composite"}
|
||||||
brushHint={brushHint}
|
brushHint={brushHint}
|
||||||
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
||||||
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
||||||
@@ -186,4 +184,24 @@ function topBarButtonClass(active = false) {
|
|||||||
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
|
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AppShellState = {
|
||||||
|
document: AppState["document"];
|
||||||
|
selection: AppState["editor"]["selection"];
|
||||||
|
viewport: AppState["editor"]["viewport"];
|
||||||
|
tools: AppState["editor"]["tools"];
|
||||||
|
transformSession: AppState["editor"]["transformSession"];
|
||||||
|
maskEdit: AppState["editor"]["maskEdit"];
|
||||||
|
};
|
||||||
|
|
||||||
|
function selectAppShellState(state: AppState): AppShellState {
|
||||||
|
return {
|
||||||
|
document: state.document,
|
||||||
|
selection: state.editor.selection,
|
||||||
|
viewport: state.editor.viewport,
|
||||||
|
tools: state.editor.tools,
|
||||||
|
transformSession: state.editor.transformSession,
|
||||||
|
maskEdit: state.editor.maskEdit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { AppState, MaskEditState } from "@editor/state";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { InteractionMode } from "@editor/tools";
|
||||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
||||||
import { brushUnavailableHint, canPreviewBrush } from "./canvas/brush";
|
import { brushUnavailableHint, canPreviewBrush, type BrushTargetEditorState } from "./canvas/brush";
|
||||||
import { canvasCursorClass } from "./canvas/cursor";
|
import { canvasCursorClass } from "./canvas/cursor";
|
||||||
import { useCanvasInput } from "./canvas/useCanvasInput";
|
import { useCanvasInput } from "./canvas/useCanvasInput";
|
||||||
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
|
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
|
||||||
@@ -26,7 +29,7 @@ export function CanvasViewport({
|
|||||||
globalWheelConsumer = ignoreGlobalWheel,
|
globalWheelConsumer = ignoreGlobalWheel,
|
||||||
}: CanvasViewportProps) {
|
}: CanvasViewportProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const state = useAppState(store);
|
const cursorState = useAppState(store, selectCanvasCursorState, canvasCursorStatesEqual);
|
||||||
const inputOptions = useMemo(
|
const inputOptions = useMemo(
|
||||||
() => ({ globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer }),
|
() => ({ globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer }),
|
||||||
[globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer],
|
[globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer],
|
||||||
@@ -35,9 +38,53 @@ export function CanvasViewport({
|
|||||||
useCanvasRenderer(canvasRef, store);
|
useCanvasRenderer(canvasRef, store);
|
||||||
useCanvasResize(canvasRef, store.dispatch);
|
useCanvasResize(canvasRef, store.dispatch);
|
||||||
const input = useCanvasInput(canvasRef, store, inputOptions);
|
const input = useCanvasInput(canvasRef, store, inputOptions);
|
||||||
const brushHint = brushUnavailableHint(state.document, state.editor);
|
const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor);
|
||||||
const hasBrushPreview = Boolean(state.editor.brushPreview && !brushHint && canPreviewBrush(state.document, state.editor));
|
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
|
||||||
const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
|
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
|
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CanvasCursorState = {
|
||||||
|
document: ImageDocument;
|
||||||
|
editor: BrushTargetEditorState;
|
||||||
|
hasBrushPreview: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function selectCanvasCursorState(state: AppState): CanvasCursorState {
|
||||||
|
return {
|
||||||
|
document: state.document,
|
||||||
|
editor: {
|
||||||
|
selection: state.editor.selection,
|
||||||
|
tools: {
|
||||||
|
activeTool: state.editor.tools.activeTool,
|
||||||
|
interactionMode: state.editor.tools.interactionMode,
|
||||||
|
},
|
||||||
|
maskEdit: state.editor.maskEdit,
|
||||||
|
},
|
||||||
|
hasBrushPreview: Boolean(state.editor.brushPreview),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasCursorStatesEqual(a: CanvasCursorState, b: CanvasCursorState): boolean {
|
||||||
|
return (
|
||||||
|
a.document === b.document &&
|
||||||
|
a.hasBrushPreview === b.hasBrushPreview &&
|
||||||
|
a.editor.selection === b.editor.selection &&
|
||||||
|
a.editor.tools.activeTool === b.editor.tools.activeTool &&
|
||||||
|
interactionModesEqual(a.editor.tools.interactionMode, b.editor.tools.interactionMode) &&
|
||||||
|
maskEditStatesEqual(a.editor.maskEdit, b.editor.maskEdit)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean {
|
||||||
|
if (a.type !== b.type) return false;
|
||||||
|
if (a.type === "temporary-pan") return b.type === "temporary-pan" && a.previousTool === b.previousTool;
|
||||||
|
return b.type === "tool" && a.tool === b.tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
|
||||||
import type { SelectionState, ViewportState } from "@editor/state";
|
|
||||||
import type { GenerateSettings } from "@editor/tools";
|
import type { GenerateSettings } from "@editor/tools";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { GenerateControls } from "./bottom-controls/GenerateControls";
|
import { GenerateControls } from "./bottom-controls/GenerateControls";
|
||||||
|
|
||||||
export type GenerateSheetProps = {
|
export type GenerateSheetProps = {
|
||||||
document: ImageDocument;
|
|
||||||
selection: SelectionState;
|
|
||||||
viewport: ViewportState;
|
|
||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GenerateSheet({ document, selection, viewport, settings, open, onOpenChange, dispatch }: GenerateSheetProps) {
|
export function GenerateSheet({ settings, open, dispatch }: GenerateSheetProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
@@ -23,10 +17,11 @@ export function GenerateSheet({ document, selection, viewport, settings, open, o
|
|||||||
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
|
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
|
{open ? (
|
||||||
<GenerateControls document={document} selection={selection} viewport={viewport} settings={settings} dispatch={dispatch} />
|
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
|
||||||
</div>
|
<GenerateControls settings={settings} dispatch={dispatch} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ export type LayersSheetProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) {
|
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) {
|
||||||
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
|
|
||||||
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
|
|
||||||
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
|
|
||||||
const canUngroup = selectedLayer?.layer.type === "group";
|
|
||||||
const maskLayerIds = collectDocumentMaskLayerIds(document);
|
|
||||||
const draggedLayerId = useRef<string>();
|
const draggedLayerId = useRef<string>();
|
||||||
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
||||||
|
|
||||||
@@ -34,6 +29,42 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
|
|||||||
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
|
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{open ? (
|
||||||
|
<LayersSheetBody
|
||||||
|
document={document}
|
||||||
|
selection={selection}
|
||||||
|
maskEdit={maskEdit}
|
||||||
|
draggedLayerId={draggedLayerId}
|
||||||
|
editingTitle={editingTitle}
|
||||||
|
setEditingTitle={setEditingTitle}
|
||||||
|
dispatch={dispatch}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayersSheetBody({
|
||||||
|
document,
|
||||||
|
selection,
|
||||||
|
maskEdit,
|
||||||
|
draggedLayerId,
|
||||||
|
editingTitle,
|
||||||
|
setEditingTitle,
|
||||||
|
dispatch,
|
||||||
|
}: Omit<LayersSheetProps, "open"> & {
|
||||||
|
draggedLayerId: MutableRefObject<string | undefined>;
|
||||||
|
editingTitle: EditingTitle | undefined;
|
||||||
|
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
||||||
|
}) {
|
||||||
|
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
|
||||||
|
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
|
||||||
|
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
|
||||||
|
const canUngroup = selectedLayer?.layer.type === "group";
|
||||||
|
const maskLayerIds = collectDocumentMaskLayerIds(document);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<header className="flex h-20 items-center">
|
<header className="flex h-20 items-center">
|
||||||
<div className="flex w-full items-center gap-2">
|
<div className="flex w-full items-center gap-2">
|
||||||
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add artboard" title="Add artboard" onClick={() => addArtboard(document, dispatch)}>
|
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add artboard" title="Add artboard" onClick={() => addArtboard(document, dispatch)}>
|
||||||
@@ -145,7 +176,7 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
|
|||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
|
||||||
import type { Layer } from "@core/layer";
|
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { SelectionState, ViewportState } from "@editor/state";
|
|
||||||
import type { GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
|
import type { GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
|
||||||
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
||||||
import { BottomControlSlider } from "./Slider";
|
import { BottomControlSlider } from "./Slider";
|
||||||
@@ -25,9 +22,6 @@ const sizePresets = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type GenerateControlsProps = {
|
export type GenerateControlsProps = {
|
||||||
document: ImageDocument;
|
|
||||||
selection: SelectionState;
|
|
||||||
viewport: ViewportState;
|
|
||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
@@ -226,109 +220,3 @@ function panelLabelClass() {
|
|||||||
function panelTextAreaClass(extra = "") {
|
function panelTextAreaClass(extra = "") {
|
||||||
return `${extra} resize-none rounded-[1.25rem] bg-white/5 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:bg-white/[0.07] focus:ring-2 focus:ring-white/30`;
|
return `${extra} resize-none rounded-[1.25rem] bg-white/5 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:bg-white/[0.07] focus:ring-2 focus:ring-white/30`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateImage(options: GenerateControlsProps & { setBusy: (busy: boolean) => void; setError: (error: string | undefined) => void }) {
|
|
||||||
const { document, selection, viewport, settings, dispatch, setBusy, setError } = options;
|
|
||||||
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
|
|
||||||
if (!artboard) return;
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
setError(undefined);
|
|
||||||
try {
|
|
||||||
const target = resolveSelectedImage(document, selection);
|
|
||||||
const inputImage = target && settings.mode !== "text-to-image" ? await imageSourceToDataUrl(target.asset.source) : undefined;
|
|
||||||
const maskImage = target?.maskAsset && settings.mode === "inpaint" ? await imageSourceToDataUrl(target.maskAsset.source) : undefined;
|
|
||||||
const response = await fetch("/api/comfy/generate", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
mode: settings.mode,
|
|
||||||
model: settings.model,
|
|
||||||
prompt: settings.prompt,
|
|
||||||
negativePrompt: settings.negativePrompt,
|
|
||||||
strength: settings.strength,
|
|
||||||
steps: settings.steps,
|
|
||||||
cfg: settings.cfg,
|
|
||||||
seed: settings.seed,
|
|
||||||
sampler: settings.sampler,
|
|
||||||
scheduler: settings.scheduler,
|
|
||||||
width: settings.width,
|
|
||||||
height: settings.height,
|
|
||||||
outpaint: settings.outpaint,
|
|
||||||
inputImage,
|
|
||||||
maskImage,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(await response.text());
|
|
||||||
const generated = await response.json() as { source: string; mimeType: string };
|
|
||||||
const intrinsicSize = await loadImageSize(generated.source);
|
|
||||||
const assetId = crypto.randomUUID();
|
|
||||||
const layerId = crypto.randomUUID();
|
|
||||||
dispatch(commandIds.documentAddAsset, { asset: { id: assetId, name: "Generated image", mimeType: generated.mimeType, source: generated.source, intrinsicSize } });
|
|
||||||
dispatch(commandIds.documentAddImageLayer, {
|
|
||||||
artboardId: artboard.id,
|
|
||||||
layer: {
|
|
||||||
id: layerId,
|
|
||||||
type: "image",
|
|
||||||
name: "Generated image",
|
|
||||||
visible: true,
|
|
||||||
locked: false,
|
|
||||||
opacity: 1,
|
|
||||||
assetId,
|
|
||||||
transform: { position: { x: viewport.center.x - intrinsicSize.w / 2, y: viewport.center.y - intrinsicSize.h / 2 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] });
|
|
||||||
} catch (reason) {
|
|
||||||
setError(reason instanceof Error ? reason.message : "Generation failed");
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSelectedImage(document: ImageDocument, selection: SelectionState) {
|
|
||||||
const layerId = selection.layerIds[0];
|
|
||||||
if (!layerId) return undefined;
|
|
||||||
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
|
|
||||||
if (!layer || layer.type === "group") return undefined;
|
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
|
||||||
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined;
|
|
||||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
|
||||||
return asset ? { layer, asset, maskAsset } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.id === layerId) return layer;
|
|
||||||
if (layer.type === "group") {
|
|
||||||
const found = findLayer(layer.children, layerId);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function imageSourceToDataUrl(source: string) {
|
|
||||||
if (source.startsWith("data:")) return source;
|
|
||||||
const image = await loadImage(source);
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = image.naturalWidth;
|
|
||||||
canvas.height = image.naturalHeight;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) throw new Error("Unable to read selected image");
|
|
||||||
context.drawImage(image, 0, 0);
|
|
||||||
return canvas.toDataURL("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImageSize(source: string): Promise<{ w: number; h: number }> {
|
|
||||||
return loadImage(source).then((image) => ({ w: image.naturalWidth, h: image.naturalHeight }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImage(source: string): Promise<HTMLImageElement> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
image.onload = () => resolve(image);
|
|
||||||
image.onerror = () => reject(new Error("Failed to load image"));
|
|
||||||
image.src = source;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { ImageDocument } from "@core/document";
|
|||||||
import type { Vec2D } from "@core/geometry";
|
import type { Vec2D } from "@core/geometry";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import type { RasterLayer } from "@core/raster-layer";
|
import type { RasterLayer } from "@core/raster-layer";
|
||||||
import type { EditorState } from "@editor/state";
|
import type { MaskEditState, SelectionState } from "@editor/state";
|
||||||
import { isPanInteractionMode } from "@editor/tools";
|
import { isPanInteractionMode, type ToolState } from "@editor/tools";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
|
|
||||||
export type BrushSession = {
|
export type BrushSession = {
|
||||||
@@ -17,17 +17,23 @@ export type BrushSession = {
|
|||||||
cancelled?: boolean;
|
cancelled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined {
|
export type BrushTargetEditorState = {
|
||||||
|
selection: SelectionState;
|
||||||
|
tools: Pick<ToolState, "activeTool" | "interactionMode">;
|
||||||
|
maskEdit?: MaskEditState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
|
||||||
const layer = resolveBrushTargetLayer(document, editor);
|
const layer = resolveBrushTargetLayer(document, editor);
|
||||||
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||||
return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool };
|
return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canPreviewBrush(document: ImageDocument, editor: EditorState): boolean {
|
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
|
||||||
return Boolean(resolveBrushTargetLayer(document, editor));
|
return Boolean(resolveBrushTargetLayer(document, editor));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function brushUnavailableHint(document: ImageDocument, editor: EditorState): string | undefined {
|
export function brushUnavailableHint(document: ImageDocument, editor: BrushTargetEditorState): string | undefined {
|
||||||
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||||
if (resolveBrushTargetLayer(document, editor)) return undefined;
|
if (resolveBrushTargetLayer(document, editor)) return undefined;
|
||||||
|
|
||||||
@@ -46,7 +52,7 @@ export function brushUnavailableHint(document: ImageDocument, editor: EditorStat
|
|||||||
return "Select a raster layer or layer mask to paint.";
|
return "Select a raster layer or layer mask to paint.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): RasterLayer | undefined {
|
function resolveBrushTargetLayer(document: ImageDocument, editor: BrushTargetEditorState): RasterLayer | undefined {
|
||||||
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||||
const editingMask = Boolean(editor.maskEdit);
|
const editingMask = Boolean(editor.maskEdit);
|
||||||
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function runGenerate(options: {
|
|||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
}) {
|
}) {
|
||||||
const { document, selection, viewport, settings, dispatch } = options;
|
const { document, selection, settings, dispatch } = options;
|
||||||
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
|
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
|
||||||
if (!artboard) return;
|
if (!artboard) return;
|
||||||
|
|
||||||
|
|||||||
68
view/useAppState.test.ts
Normal file
68
view/useAppState.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
|
import type { AppState } from "@editor/state";
|
||||||
|
import { createAppStateSelectorSnapshot, shallowEqual } from "./useAppState";
|
||||||
|
|
||||||
|
describe("app state selector snapshot", () => {
|
||||||
|
test("reuses selected snapshots when equality reports no relevant changes", () => {
|
||||||
|
const state = createInitialAppState("Test");
|
||||||
|
const snapshot = createAppStateSelectorSnapshot(selectShellLikeState, shallowEqual);
|
||||||
|
const selected = snapshot.getSnapshot(state);
|
||||||
|
const previewOnlyUpdate: AppState = {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
brushStrokePreview: { layerId: "layer", assetId: "asset", source: "preview" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(snapshot.updateSnapshot(previewOnlyUpdate)).toBe(false);
|
||||||
|
expect(snapshot.getSnapshot(previewOnlyUpdate)).toBe(selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can collapse brush preview updates to presence instead of position", () => {
|
||||||
|
const state = createInitialAppState("Test");
|
||||||
|
const snapshot = createAppStateSelectorSnapshot((next: AppState) => ({ hasBrushPreview: Boolean(next.editor.brushPreview) }), shallowEqual);
|
||||||
|
const withoutPreview = snapshot.getSnapshot(state);
|
||||||
|
const withPreview: AppState = {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
brushPreview: { position: { x: 10, y: 20 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(snapshot.updateSnapshot(withPreview)).toBe(true);
|
||||||
|
const previewPresent = snapshot.getSnapshot(withPreview);
|
||||||
|
expect(previewPresent).not.toBe(withoutPreview);
|
||||||
|
expect(previewPresent.hasBrushPreview).toBe(true);
|
||||||
|
|
||||||
|
const movedPreview: AppState = {
|
||||||
|
...withPreview,
|
||||||
|
editor: {
|
||||||
|
...withPreview.editor,
|
||||||
|
brushPreview: { position: { x: 30, y: 40 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(snapshot.updateSnapshot(movedPreview)).toBe(false);
|
||||||
|
expect(snapshot.getSnapshot(movedPreview)).toBe(previewPresent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shallow equality uses object keys and Object.is value checks", () => {
|
||||||
|
expect(shallowEqual({ id: "a", value: Number.NaN }, { id: "a", value: Number.NaN })).toBe(true);
|
||||||
|
expect(shallowEqual({ id: "a", value: 0 }, { id: "a", value: -0 })).toBe(false);
|
||||||
|
expect(shallowEqual({ id: "a" }, { id: "a", extra: true })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectShellLikeState(state: AppState) {
|
||||||
|
return {
|
||||||
|
document: state.document,
|
||||||
|
selection: state.editor.selection,
|
||||||
|
viewport: state.editor.viewport,
|
||||||
|
tools: state.editor.tools,
|
||||||
|
transformSession: state.editor.transformSession,
|
||||||
|
maskEdit: state.editor.maskEdit,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,102 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useCallback, useRef, useSyncExternalStore } from "react";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { AppState } from "@editor/state";
|
import type { AppState } from "@editor/state";
|
||||||
|
|
||||||
export function useAppState(store: AppStore): AppState {
|
export type AppStateSelector<TSelection> = (state: AppState) => TSelection;
|
||||||
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
export type AppStateEquality<TSelection> = (a: TSelection, b: TSelection) => boolean;
|
||||||
|
|
||||||
|
type AppStateSelectorSnapshot<TSelection> = {
|
||||||
|
getSnapshot(state: AppState): TSelection;
|
||||||
|
updateSnapshot(state: AppState): boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppStateSelectorRef<TSelection> = {
|
||||||
|
selector: AppStateSelector<TSelection>;
|
||||||
|
isEqual: AppStateEquality<TSelection>;
|
||||||
|
snapshot: AppStateSelectorSnapshot<TSelection>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAppState = (state: AppState) => state;
|
||||||
|
|
||||||
|
export function useAppState(store: AppStore): AppState;
|
||||||
|
export function useAppState<TSelection>(
|
||||||
|
store: AppStore,
|
||||||
|
selector: AppStateSelector<TSelection>,
|
||||||
|
isEqual?: AppStateEquality<TSelection>,
|
||||||
|
): TSelection;
|
||||||
|
export function useAppState<TSelection>(
|
||||||
|
store: AppStore,
|
||||||
|
selector: AppStateSelector<TSelection> = selectAppState as AppStateSelector<TSelection>,
|
||||||
|
isEqual: AppStateEquality<TSelection> = Object.is,
|
||||||
|
): TSelection {
|
||||||
|
const selectorRef = useRef<AppStateSelectorRef<TSelection> | undefined>(undefined);
|
||||||
|
|
||||||
|
if (!selectorRef.current || selectorRef.current.selector !== selector || selectorRef.current.isEqual !== isEqual) {
|
||||||
|
selectorRef.current = {
|
||||||
|
selector,
|
||||||
|
isEqual,
|
||||||
|
snapshot: createAppStateSelectorSnapshot(selector, isEqual),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSnapshot = useCallback(() => selectorRef.current!.snapshot.getSnapshot(store.getState()), [store]);
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(onStoreChange: () => void) =>
|
||||||
|
store.subscribe((state) => {
|
||||||
|
if (selectorRef.current!.snapshot.updateSnapshot(state)) onStoreChange();
|
||||||
|
}),
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppStateSelectorSnapshot<TSelection>(
|
||||||
|
selector: AppStateSelector<TSelection>,
|
||||||
|
isEqual: AppStateEquality<TSelection> = Object.is,
|
||||||
|
): AppStateSelectorSnapshot<TSelection> {
|
||||||
|
let stateSnapshot: AppState | undefined;
|
||||||
|
let selectedSnapshot: TSelection | undefined;
|
||||||
|
let hasSnapshot = false;
|
||||||
|
|
||||||
|
const setSnapshot = (state: AppState, selected: TSelection) => {
|
||||||
|
stateSnapshot = state;
|
||||||
|
selectedSnapshot = selected;
|
||||||
|
hasSnapshot = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSnapshot(state) {
|
||||||
|
if (hasSnapshot && stateSnapshot === state) return selectedSnapshot as TSelection;
|
||||||
|
|
||||||
|
const selected = selector(state);
|
||||||
|
if (hasSnapshot && isEqual(selectedSnapshot as TSelection, selected)) {
|
||||||
|
stateSnapshot = state;
|
||||||
|
return selectedSnapshot as TSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapshot(state, selected);
|
||||||
|
return selected;
|
||||||
|
},
|
||||||
|
updateSnapshot(state) {
|
||||||
|
const selected = selector(state);
|
||||||
|
if (hasSnapshot && isEqual(selectedSnapshot as TSelection, selected)) {
|
||||||
|
stateSnapshot = state;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapshot(state, selected);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shallowEqual<TObject extends object>(a: TObject, b: TObject): boolean {
|
||||||
|
if (Object.is(a, b)) return true;
|
||||||
|
|
||||||
|
const aKeys = Object.keys(a) as Array<keyof TObject>;
|
||||||
|
const bKeys = Object.keys(b) as Array<keyof TObject>;
|
||||||
|
if (aKeys.length !== bKeys.length) return false;
|
||||||
|
|
||||||
|
return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && Object.is(a[key], b[key]));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user