perf(view): reduce shell rerenders

This commit is contained in:
syntaxbullet
2026-07-04 15:37:54 +02:00
parent dfa2bffeda
commit ef0ce15bb5
9 changed files with 321 additions and 173 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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