From 3592111019f7ecb7491f2fec8585ebbbf14f2882 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 13 Mar 2026 16:42:11 -0700 Subject: [PATCH] Improve 2D (PageDraw) render stability and performance (#1143) Generous use of caching used for react elements. Made a pretty significant difference too. Minor refactor to move helper functions outside instead of within component tree. --- .../app/src/pages/PageDraw/PageDraw.tsx | 356 ++++++++---------- 1 file changed, 167 insertions(+), 189 deletions(-) diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx index 16a3388760..7de1db93ec 100644 --- a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx @@ -63,6 +63,25 @@ import { const PAGE_ID: ModelPage = ModelPage.Canvas2D; +// Pure helpers — module-level so they're not recreated on every render/call +const mapAspectRatio = (ratio?: string): EnqueueEditImageSize | undefined => { + switch (ratio) { + case "auto": return EnqueueEditImageSize.Auto; + case "wide": return EnqueueEditImageSize.Wide; + case "tall": return EnqueueEditImageSize.Tall; + case "square": return EnqueueEditImageSize.Square; + default: return undefined; + } +}; +const mapResolution = (res?: string): EnqueueEditImageResolution | undefined => { + switch (res) { + case "1k": return EnqueueEditImageResolution.OneK; + case "2k": return EnqueueEditImageResolution.TwoK; + case "4k": return EnqueueEditImageResolution.FourK; + default: return undefined; + } +}; + // ─── Edit3DButton ───────────────────────────────────────────────────────────── // Isolated memoized component so Konva-driven position updates (stage pan/zoom, // transformer drag) only re-render this small button — not the entire PageDraw @@ -602,7 +621,7 @@ const PageDraw = () => { [nodes, editing3DNodeId], ); - const handleImageUpload = async (files: File[]): Promise => { + const handleImageUpload = useCallback(async (files: File[]): Promise => { // Determine current canvas dimensions from the store (according to aspect-ratio) const { width: canvasW, height: canvasH } = getAspectRatioDimensions(); @@ -629,7 +648,7 @@ const PageDraw = () => { }; img.src = URL.createObjectURL(file); } - }; + }, [getAspectRatioDimensions, createImageFromFile]); const handleTauriEnqueue = async ( image: ImageBitmap, @@ -754,40 +773,6 @@ const PageDraw = () => { return; } - // Helper to map aspect ratio string to enum - const mapAspectRatio = ( - ratio?: string, - ): EnqueueEditImageSize | undefined => { - switch (ratio) { - case "auto": - return EnqueueEditImageSize.Auto; - case "wide": - return EnqueueEditImageSize.Wide; - case "tall": - return EnqueueEditImageSize.Tall; - case "square": - return EnqueueEditImageSize.Square; - default: - return undefined; - } - }; - - // Helper to map resolution string to enum - const mapResolution = ( - res?: string, - ): EnqueueEditImageResolution | undefined => { - switch (res) { - case "1k": - return EnqueueEditImageResolution.OneK; - case "2k": - return EnqueueEditImageResolution.TwoK; - case "4k": - return EnqueueEditImageResolution.FourK; - default: - return undefined; - } - }; - const { width, height } = getAspectRatioDimensions(); const subscriberId: string = crypto?.randomUUID?.() ?? @@ -910,7 +895,7 @@ const PageDraw = () => { [generationCount, getCompositeCanvasFile, selectedImageModel], ); - const onFitPressed = async () => { + const onFitPressed = useCallback(async () => { // Get the stage and its container dimensions const stage = stageRef.current; if (!stage) return; @@ -920,8 +905,7 @@ const PageDraw = () => { const containerHeight = stage.container().offsetHeight; // Get canvas dimensions from store aspect ratio - const canvasW = getAspectRatioDimensions().width; - const canvasH = getAspectRatioDimensions().height; + const { width: canvasW, height: canvasH } = getAspectRatioDimensions(); // Add padding to ensure canvas doesn't touch the edges const padding = 40; @@ -947,7 +931,7 @@ const PageDraw = () => { // Redraw the stage stage.batchDraw(); - }; + }, [getAspectRatioDimensions]); // When the model inpainting support changes, we need to auto-change the tool so it's not set to inpainting // Note: setActiveTool is a stable Zustand action, so we don't need it in deps @@ -991,6 +975,119 @@ const PageDraw = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [baseImageBitmap]); + // ── Stable callbacks for child components ────────────────────────────────── + const handleSelectTool = useCallback(() => setActiveTool("select"), [setActiveTool]); + + const handleActivateShapeTool = useCallback( + (shape: "rectangle" | "circle" | "triangle") => { + selectNode(null); + setCurrentShape(shape); + setActiveTool("shape"); + selectNode(null); + }, + [selectNode, setCurrentShape, setActiveTool], + ); + + const handlePaintBrush = useCallback( + (hex: string, size: number, opacity: number) => { + setActiveTool("draw"); + setBrushColor(hex); + setBrushSize(size); + setBrushOpacity(opacity); + }, + [setActiveTool, setBrushColor, setBrushSize, setBrushOpacity], + ); + + const handleCanvasBackground = useCallback( + (hex: string) => { setFillColor(hex); }, + [setFillColor], + ); + + const handleUploadImageClick = useCallback(() => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); + input.onchange = (e: Event) => { + const target = e.target as HTMLInputElement; + if (target.files) { + const imageFiles = Array.from(target.files).filter((f) => + f.type.startsWith("image/"), + ); + if (imageFiles.length > 0) handleImageUpload(imageFiles); + } + document.body.removeChild(input); + }; + input.value = ""; + input.click(); + }, [handleImageUpload]); + + const handleAspectRatioChange = useCallback( + async (ratio: string) => { + const ratioToType = (r: string): AspectRatioType => { + switch (r) { + case "tall": return AspectRatioType.PORTRAIT; + case "wide": return AspectRatioType.LANDSCAPE; + case "square": return AspectRatioType.SQUARE; + default: return AspectRatioType.NONE; + } + }; + setAspectRatioType(ratioToType(ratio)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + onFitPressed(); + }, + [setAspectRatioType, onFitPressed], + ); + + const handleMenuAction = useCallback( + async (action: string) => { + switch (action) { + case "LOCK": toggleLock(selectedNodeIds); break; + case "REMOVE_BACKGROUND": await beginRemoveBackground(selectedNodeIds); break; + case "BRING_TO_FRONT": bringToFront(selectedNodeIds); break; + case "BRING_FORWARD": bringForward(selectedNodeIds); break; + case "SEND_BACKWARD": sendBackward(selectedNodeIds); break; + case "SEND_TO_BACK": sendToBack(selectedNodeIds); break; + case "DUPLICATE": copySelectedItems(); pasteItems(); break; + case "DELETE": deleteSelectedItems(); break; + default: break; + } + }, + [ + selectedNodeIds, toggleLock, beginRemoveBackground, bringToFront, + bringForward, sendBackward, sendToBack, copySelectedItems, pasteItems, + deleteSelectedItems, + ], + ); + + const handleCanvasSizeChange = useCallback((width: number, height: number) => { + canvasWidth.current = width; + canvasHeight.current = height; + }, []); + + // ── Memoized derived values ───────────────────────────────────────────────── + const isLocked = useMemo( + () => + selectedNodeIds.some((id) => { + const node = nodes.find((n) => n.id === id); + const lineNode = lineNodes.find((n) => n.id === id); + return (node?.locked || lineNode?.locked) ?? false; + }), + [selectedNodeIds, nodes, lineNodes], + ); + + const selectedNodeWithModel = useMemo(() => { + if (selectedNodeIds.length !== 1) return null; + return nodes.find((n) => n.id === selectedNodeIds[0]) ?? null; + }, [selectedNodeIds, nodes]); + + const editingNode = useMemo( + () => (editing3DNodeId ? (nodes.find((n) => n.id === editing3DNodeId) ?? null) : null), + [editing3DNodeId, nodes], + ); + // Display image selector on launch, otherwise hide it // Also show loading state if info is set but image is loading if (!baseImageInfo || !baseImageBitmap) { @@ -1060,26 +1157,7 @@ const PageDraw = () => { }`} > { - const ratioToType = (ratio: string): AspectRatioType => { - switch (ratio) { - case "tall": - return AspectRatioType.PORTRAIT; - case "wide": - return AspectRatioType.LANDSCAPE; - case "square": - return AspectRatioType.SQUARE; - default: - return AspectRatioType.NONE; - } - }; - - const aspectRatioType = ratioToType(ratio); - setAspectRatioType(aspectRatioType); - - await new Promise((resolve) => requestAnimationFrame(resolve)); - onFitPressed(); - }} + onAspectRatioChange={handleAspectRatioChange} usePrompt2DStore={promptStoreProvider} EncodeImageBitmapToBase64={EncodeImageBitmapToBase64} onGenerateClick={handleGenerate} @@ -1094,66 +1172,11 @@ const PageDraw = () => { { - setActiveTool("select"); - }} - onActivateShapeTool={( - shape: "rectangle" | "circle" | "triangle", - ): void => { - selectNode(null); - setCurrentShape(shape); - setActiveTool("shape"); - selectNode(null); - }} - onPaintBrush={(hex: string, size: number, opacity: number): void => { - setActiveTool("draw"); - setBrushColor(hex); - setBrushSize(size); - setBrushOpacity(opacity); - }} - onCanvasBackground={(hex: string): void => { - console.log("Canvas background activated", { color: hex }); - // Add background change logic here - // TODO: minor bug needs to update the preview panel - // Debounce also causes issues with real time color change. - setFillColor(hex); - }} - onUploadImage={(): void => { - // Create input element dynamically like in PromptEditor - console.log("Upload image activated"); - const input = document.createElement("input"); - input.type = "file"; - input.accept = "image/*"; - input.multiple = true; - input.style.display = "none"; - document.body.appendChild(input); - - input.onchange = (e: Event) => { - console.log("File change event triggered"); - const target = e.target as HTMLInputElement; - if (target.files) { - const files = Array.from(target.files); - console.log("Selected files:", files); - const imageFiles = files.filter((file) => - file.type.startsWith("image/"), - ); - console.log("Filtered image files:", imageFiles); - - if (imageFiles.length > 0) { - console.log("Uploading images:", imageFiles); - handleImageUpload(imageFiles); - } else { - console.log("No valid image files selected"); - } - } else { - console.log("No files selected"); - } - document.body.removeChild(input); - }; - - input.value = ""; - input.click(); - }} + onSelect={handleSelectTool} + onActivateShapeTool={handleActivateShapeTool} + onPaintBrush={handlePaintBrush} + onCanvasBackground={handleCanvasBackground} + onUploadImage={handleUploadImageClick} supportsMaskTool={supportsMaskedInpainting} activeToolId={activeTool} currentShape={currentShape} @@ -1173,53 +1196,14 @@ const PageDraw = () => { } return false; }} - onMenuAction={async (action) => { - switch (action) { - case "LOCK": - toggleLock(selectedNodeIds); - break; - case "REMOVE_BACKGROUND": - await beginRemoveBackground(selectedNodeIds); - break; - case "BRING_TO_FRONT": - bringToFront(selectedNodeIds); - break; - case "BRING_FORWARD": - bringForward(selectedNodeIds); - break; - case "SEND_BACKWARD": - sendBackward(selectedNodeIds); - break; - case "SEND_TO_BACK": - sendToBack(selectedNodeIds); - break; - case "DUPLICATE": - copySelectedItems(); - pasteItems(); - break; - case "DELETE": - deleteSelectedItems(); - break; - default: - // No action - } - }} - isLocked={selectedNodeIds.some((id: string) => { - const node = nodes.find((n: (typeof nodes)[number]) => n.id === id); - const lineNode = lineNodes.find( - (n: (typeof lineNodes)[number]) => n.id === id, - ); - return (node?.locked || lineNode?.locked) ?? false; - })} + onMenuAction={handleMenuAction} + isLocked={isLocked} > { - canvasWidth.current = width; - canvasHeight.current = height; - }} + onCanvasSizeChange={handleCanvasSizeChange} fillColor={fillColor} activeTool={activeTool} brushColor={brushColor} @@ -1250,35 +1234,29 @@ const PageDraw = () => { {/* Floating "Edit 3D" button — shown above the selected 3D-model node when the overlay is not already open. Rendered as an isolated memoized component so Konva-driven position updates don't re-render PageDraw. */} - {!editing3DNodeId && - selectedNodeIds.length === 1 && - nodes.find((n) => n.id === selectedNodeIds[0])?.modelUrl && ( - + )} + {editingNode && ( + <> + - )} - {editing3DNodeId && - (() => { - const editingNode = nodes.find((n) => n.id === editing3DNodeId); - return editingNode ? ( - <> - - setEditing3DNodeId(null)} - /> - - ) : null; - })()} + setEditing3DNodeId(null)} + /> + + )} ); };