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 { ToolOverlay } from "./ToolOverlay";
|
||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||
import type { AppState } from "@editor/state";
|
||||
import type { ToolId } from "@editor/tools";
|
||||
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
|
||||
import { useAppState } from "./useAppState";
|
||||
import { shallowEqual, useAppState } from "./useAppState";
|
||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
||||
import { useImageImport } from "./useImageImport";
|
||||
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
||||
@@ -23,14 +24,15 @@ export type AppProps = {
|
||||
};
|
||||
|
||||
export function App({ app }: AppProps) {
|
||||
const state = useAppState(app.store);
|
||||
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
||||
const shellState = useAppState(app.store, selectAppShellState, shallowEqual);
|
||||
const { document, selection, viewport, tools, transformSession, maskEdit } = shellState;
|
||||
const viewportActivityIsland = useViewportActivityIsland(viewport);
|
||||
const imageImport = useImageImport(app.store);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const previousGenerateTool = useRef<ToolId>("select");
|
||||
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
|
||||
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
|
||||
const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection);
|
||||
const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0];
|
||||
|
||||
const openGenerate = useCallback(() => {
|
||||
const activeTool = app.store.getState().editor.tools.activeTool;
|
||||
@@ -108,8 +110,8 @@ export function App({ app }: AppProps) {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [app.store, closeGenerate, toggleGenerate]);
|
||||
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
|
||||
const brushHint = brushUnavailableHint(state.document, state.editor);
|
||||
const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined;
|
||||
const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit });
|
||||
|
||||
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">
|
||||
@@ -122,7 +124,7 @@ export function App({ app }: AppProps) {
|
||||
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
|
||||
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
|
||||
</button>
|
||||
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, state.document.assets)}>
|
||||
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, document.assets)}>
|
||||
<DownloadSimple size={24} />
|
||||
</button>
|
||||
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => { closeGenerate(); setLayersOpen((open) => !open); }}>
|
||||
@@ -132,41 +134,37 @@ export function App({ app }: AppProps) {
|
||||
</header>
|
||||
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||
<ToolOverlay
|
||||
activeTool={state.editor.tools.activeTool}
|
||||
interactionMode={state.editor.tools.interactionMode}
|
||||
activeTool={tools.activeTool}
|
||||
interactionMode={tools.interactionMode}
|
||||
dispatch={app.store.dispatch}
|
||||
/>
|
||||
</div>
|
||||
<GenerateSheet
|
||||
document={state.document}
|
||||
selection={state.editor.selection}
|
||||
viewport={state.editor.viewport}
|
||||
settings={state.editor.tools.generate}
|
||||
settings={tools.generate}
|
||||
open={generateOpen}
|
||||
onOpenChange={(open) => open ? openGenerate() : closeGenerate()}
|
||||
dispatch={app.store.dispatch}
|
||||
/>
|
||||
<LayersSheet
|
||||
document={state.document}
|
||||
selection={state.editor.selection}
|
||||
maskEdit={state.editor.maskEdit}
|
||||
document={document}
|
||||
selection={selection}
|
||||
maskEdit={maskEdit}
|
||||
open={layersOpen}
|
||||
dispatch={app.store.dispatch}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
||||
<BottomControlsIsland
|
||||
document={state.document}
|
||||
selection={state.editor.selection}
|
||||
viewport={state.editor.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}
|
||||
document={document}
|
||||
selection={selection}
|
||||
viewport={viewport}
|
||||
visible={tools.activeTool === "generate" || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "chromaKey" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||
action={viewportActivityIsland.action}
|
||||
activeTool={state.editor.tools.activeTool}
|
||||
brushSettings={state.editor.tools.brush}
|
||||
generateSettings={state.editor.tools.generate}
|
||||
chromaKeySettings={state.editor.tools.chromaKey}
|
||||
magicWandSettings={state.editor.tools.magicWand}
|
||||
editingMask={Boolean(state.editor.maskEdit)}
|
||||
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
|
||||
activeTool={tools.activeTool}
|
||||
brushSettings={tools.brush}
|
||||
generateSettings={tools.generate}
|
||||
chromaKeySettings={tools.chromaKey}
|
||||
magicWandSettings={tools.magicWand}
|
||||
editingMask={Boolean(maskEdit)}
|
||||
maskViewMode={maskEdit?.viewMode ?? "composite"}
|
||||
brushHint={brushHint}
|
||||
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
||||
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`;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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 { InteractionMode } from "@editor/tools";
|
||||
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 { useCanvasInput } from "./canvas/useCanvasInput";
|
||||
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
|
||||
@@ -26,7 +29,7 @@ export function CanvasViewport({
|
||||
globalWheelConsumer = ignoreGlobalWheel,
|
||||
}: CanvasViewportProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const state = useAppState(store);
|
||||
const cursorState = useAppState(store, selectCanvasCursorState, canvasCursorStatesEqual);
|
||||
const inputOptions = useMemo(
|
||||
() => ({ globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer }),
|
||||
[globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer],
|
||||
@@ -35,9 +38,53 @@ export function CanvasViewport({
|
||||
useCanvasRenderer(canvasRef, store);
|
||||
useCanvasResize(canvasRef, store.dispatch);
|
||||
const input = useCanvasInput(canvasRef, store, inputOptions);
|
||||
const brushHint = brushUnavailableHint(state.document, state.editor);
|
||||
const hasBrushPreview = Boolean(state.editor.brushPreview && !brushHint && canPreviewBrush(state.document, state.editor));
|
||||
const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
|
||||
const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor);
|
||||
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
|
||||
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
|
||||
|
||||
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 { AppStore } from "@editor/store";
|
||||
import { GenerateControls } from "./bottom-controls/GenerateControls";
|
||||
|
||||
export type GenerateSheetProps = {
|
||||
document: ImageDocument;
|
||||
selection: SelectionState;
|
||||
viewport: ViewportState;
|
||||
settings: GenerateSettings;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
|
||||
export function GenerateSheet({ document, selection, viewport, settings, open, onOpenChange, dispatch }: GenerateSheetProps) {
|
||||
export function GenerateSheet({ settings, open, dispatch }: GenerateSheetProps) {
|
||||
return (
|
||||
<aside
|
||||
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 ? (
|
||||
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
|
||||
<GenerateControls document={document} selection={selection} viewport={viewport} settings={settings} dispatch={dispatch} />
|
||||
<GenerateControls settings={settings} dispatch={dispatch} />
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,6 @@ export type 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 [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 ? (
|
||||
<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">
|
||||
<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)}>
|
||||
@@ -145,7 +176,7 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
||||
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 { SelectionState, ViewportState } from "@editor/state";
|
||||
import type { GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
|
||||
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
||||
import { BottomControlSlider } from "./Slider";
|
||||
@@ -25,9 +22,6 @@ const sizePresets = [
|
||||
] as const;
|
||||
|
||||
export type GenerateControlsProps = {
|
||||
document: ImageDocument;
|
||||
selection: SelectionState;
|
||||
viewport: ViewportState;
|
||||
settings: GenerateSettings;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
@@ -226,109 +220,3 @@ function panelLabelClass() {
|
||||
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`;
|
||||
}
|
||||
|
||||
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 { Layer } from "@core/layer";
|
||||
import type { RasterLayer } from "@core/raster-layer";
|
||||
import type { EditorState } from "@editor/state";
|
||||
import { isPanInteractionMode } from "@editor/tools";
|
||||
import type { MaskEditState, SelectionState } from "@editor/state";
|
||||
import { isPanInteractionMode, type ToolState } from "@editor/tools";
|
||||
import type { AppStore } from "@editor/store";
|
||||
|
||||
export type BrushSession = {
|
||||
@@ -17,17 +17,23 @@ export type BrushSession = {
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
|
||||
export function canPreviewBrush(document: ImageDocument, editor: EditorState): boolean {
|
||||
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
|
||||
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 (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.";
|
||||
}
|
||||
|
||||
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;
|
||||
const editingMask = Boolean(editor.maskEdit);
|
||||
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function runGenerate(options: {
|
||||
settings: GenerateSettings;
|
||||
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];
|
||||
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 { AppState } from "@editor/state";
|
||||
|
||||
export function useAppState(store: AppStore): AppState {
|
||||
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
||||
export type AppStateSelector<TSelection> = (state: AppState) => TSelection;
|
||||
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