diff --git a/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx b/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx index 0af0958b24..f5590d9609 100644 --- a/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx +++ b/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx @@ -404,17 +404,6 @@ const PageDraw = ({ adapter }: PageDrawProps) => { const generateInFlightRef = useRef(false); const [isGenerating, setIsGenerating] = useState(false); - // Latest-ref mirrors of base-image state. Read from `*.current` inside any - // useCallback whose deps would otherwise have to list these and cascade - // recreation through the bake/generate chain. Updated after every render - // (no deps) so callbacks always see fresh values. - const baseImageBitmapRef = useRef(baseImageBitmap); - const baseImageInfoRef = useRef(baseImageInfo); - useEffect(() => { - baseImageBitmapRef.current = baseImageBitmap; - baseImageInfoRef.current = baseImageInfo; - }); - const selectedImageModel: ImageModel | undefined = useSelectedImageModel(PAGE_ID); @@ -606,12 +595,15 @@ const PageDraw = ({ adapter }: PageDrawProps) => { [getAspectRatioDimensions, createImageFromFile], ); - // Stable: reads base state via latest-refs so deps stay empty and the bake - // chain (runCompositeBake → getCompositeFile / scheduleCompositeBake → - // handleGenerate) doesn't recreate on every base-image swap. + // Stable: reads base state imperatively from the Zustand store so deps stay + // empty and the bake chain (runCompositeBake → getCompositeFile / + // scheduleCompositeBake → handleGenerate) doesn't recreate on every + // base-image swap. `useSceneStore.getState()` always returns the latest + // store snapshot, so deferred bakes pick up the current bitmap at run time + // (not the one captured at schedule time). const getCompositeCanvasFile = useCallback(async (): Promise => { - const baseBitmapNow = baseImageBitmapRef.current; - const baseInfoNow = baseImageInfoRef.current; + const { baseImageBitmap: baseBitmapNow, baseImageInfo: baseInfoNow } = + useSceneStore.getState(); if (!stageRef.current || !baseImageKonvaRef.current) return null; if (!baseInfoNow?.isBlankCanvas && !baseBitmapNow) return null; @@ -1179,7 +1171,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => { EncodeImageBitmapToBase64={EncodeImageBitmapToBase64} onGenerateClick={handleGenerate} onFitPressed={onFitPressed} - isDisabled={false} + isDisabled={!baseImageBitmap && !baseImageInfo?.isBlankCanvas} isEnqueueing={isGenerating} generationCount={generationCount} onGenerationCountChange={setGenerationCount} diff --git a/frontend/libs/components/pagedraw/src/lib/stores/SceneState.ts b/frontend/libs/components/pagedraw/src/lib/stores/SceneState.ts index 1012e92f53..1f6e18d0dd 100644 --- a/frontend/libs/components/pagedraw/src/lib/stores/SceneState.ts +++ b/frontend/libs/components/pagedraw/src/lib/stores/SceneState.ts @@ -3,6 +3,14 @@ import { Node, NodeType } from "../Node"; import { ImageBundle, BaseSelectorImage } from "../types"; import { Model3DParams } from "../utilities/render3DModel"; +// Generation counter for in-flight base-image loads. Incremented on every +// setBaseImageInfo call so that a stale `.onload` from a superseded +// selection (e.g. rapid HistoryStack navigation while the previous image is +// still in flight) becomes a no-op. Without this guard, A's onload could fire +// after B was selected and overwrite baseImageBitmap (and via applyImageNodes, +// even drawNodes) with A's content. +let baseImageLoadGen = 0; + // LineNode type — ordering in PageDraw is by position in drawNodes array (zIndex ignored there) export type LineNode = { id: string; @@ -1372,6 +1380,9 @@ export const useSceneStore = create((set, get, store) => ({ }, setBaseImageInfo: (image: BaseSelectorImage | null) => { + // Bump first so any in-flight load from a previous call invalidates itself + // on completion (see imgBitmap.onload / onerror below). + const myGen = ++baseImageLoadGen; if (!image) { set({ baseImageInfo: null }); return; @@ -1442,9 +1453,11 @@ export const useSceneStore = create((set, get, store) => ({ const imgBitmap = new Image(); imgBitmap.onload = () => { + if (myGen !== baseImageLoadGen) return; // superseded by a newer setBaseImageInfo applyImageNodes(imgBitmap); }; imgBitmap.onerror = (event) => { + if (myGen !== baseImageLoadGen) return; console.error("Failed to load base image, discarding", event); set({ baseImageInfo: null, baseImageBitmap: null }); imgBitmap.onload = null; diff --git a/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx b/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx index 0580e59688..e12e9861f3 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx @@ -509,7 +509,7 @@ export const PromptBox2D = ({ className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50" icon={undefined} onClick={handleGenerate} - disabled={isEnqueueing || !prompt.trim()} + disabled={isEnqueueing || isDisabled || !prompt.trim()} loading={isEnqueueing} > Generate