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

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