From c98aca330b8a8131487bb3d13faf7e6d234fd7b2 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 20 Feb 2026 16:37:38 -0800 Subject: [PATCH] Map history stack and corresponding edits (#1041) Event based, so only updates on creation, selection and deletion of history images. --- .../app/src/pages/PageDraw/PageDraw.tsx | 376 +++-- .../app/src/pages/PageDraw/PaintSurface.tsx | 1383 +++++++++-------- .../pages/PageDraw/hooks/useGlobalMouseUp.ts | 22 +- .../src/pages/PageDraw/stores/SceneState.ts | 49 +- .../src/pages/PageEdit/EditPaintSurface.tsx | 32 +- 5 files changed, 1057 insertions(+), 805 deletions(-) diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx index cba265e72c..5f5b6125dd 100644 --- a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx @@ -1,9 +1,14 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; import { DRAW_LAYER_ID, INPAINT_LAYER_ID, PaintSurface } from "./PaintSurface"; import "./App.css"; import PromptEditor from "./PromptEditor/PromptEditor"; import SideToolbar from "./components/ui/SideToolbar"; -import { AspectRatioType, useSceneStore } from "./stores/SceneState"; +import { + AspectRatioType, + type SceneState, + useSceneStore, +} from "./stores/SceneState"; import { useUndoRedoHotkeys } from "./hooks/useUndoRedoHotkeys"; import { useDeleteHotkeys } from "./hooks/useDeleteHotkeys"; import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys"; @@ -18,16 +23,26 @@ import { useSelectedImageModel, useSelectedProviderForModel, } from "@storyteller/ui-model-selector"; -import { EnqueueEditImage, EnqueueEditImageRequest, EnqueueEditImageResolution, EnqueueEditImageSize, EnqueueImageInpaint, EnqueueImageInpaintRequest, useCanvasBgRemovedEvent } from "@storyteller/tauri-api"; +import { + EnqueueEditImage, + EnqueueEditImageRequest, + EnqueueEditImageResolution, + EnqueueEditImageSize, + EnqueueImageInpaint, + EnqueueImageInpaintRequest, + useCanvasBgRemovedEvent, +} from "@storyteller/tauri-api"; import { HelpMenuButton } from "@storyteller/ui-help-menu"; import { GenerationProvider } from "@storyteller/api-enums"; import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack"; -import { BaseImageSelector, BaseSelectorImage } from "../PageEdit/BaseImageSelector"; +import { + BaseImageSelector, + BaseSelectorImage, +} from "../PageEdit/BaseImageSelector"; import { normalizeCanvas } from "~/Helpers/CanvasHelpers"; import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64"; import { RefImage, usePrompt2DStore } from "@storyteller/ui-promptbox"; import { PromptsApi } from "@storyteller/api"; -import toast from "react-hot-toast"; const PAGE_ID: ModelPage = ModelPage.Canvas2D; @@ -62,26 +77,123 @@ const PageDraw = () => { const [isSelecting, setIsSelecting] = useState(false); const stageRef = useRef({} as Konva.Stage); const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({}); - const store = useSceneStore(); + + /* + * Scene store: use a selector + useShallow to avoid re-rendering on every store change. + * Without this, useSceneStore() subscribes to the whole store (e.g. moveNode during + * drag updates state constantly) and PageDraw would re-render every frame. + * + * useShallow compares the selected object by top-level keys: we only re-render when + * one of these values actually changes. Do NOT add: + * - cursorPosition / cursorVisible: updated every mouse move → would re-render constantly. + * - historyImageNodeMap: mutated in place in the store (reference never changes), + * so shallow would never see updates; fix the store to replace the Map if you need it. + * When adding new store fields here, prefer primitive/array/object refs that the store + * replaces (e.g. new array from .map()), not in-place mutations. + * + * IMPORTANT: The selector function is memoized to prevent infinite loops. If you modify + * this selector, ensure the function reference stays stable (use useMemo if needed). + */ + const selector = useMemo( + () => (state: SceneState) => ({ + baseImageInfo: state.baseImageInfo, + baseImageBitmap: state.baseImageBitmap, + nodes: state.nodes, + selectedNodeIds: state.selectedNodeIds, + lineNodes: state.lineNodes, + activeTool: state.activeTool, + currentShape: state.currentShape, + fillColor: state.fillColor, + brushColor: state.brushColor, + brushSize: state.brushSize, + historyImageBundles: state.historyImageBundles, + getAspectRatioDimensions: state.getAspectRatioDimensions, + finishRemoveBackground: state.finishRemoveBackground, + createImageFromUrl: state.createImageFromUrl, + createImageFromFile: state.createImageFromFile, + setBaseImageInfo: state.setBaseImageInfo, + RESET: state.RESET, + clearLineNodes: state.clearLineNodes, + setNodes: state.setNodes, + removeHistoryImage: state.removeHistoryImage, + addHistoryImageBundle: state.addHistoryImageBundle, + setAspectRatioType: state.setAspectRatioType, + setActiveTool: state.setActiveTool, + selectNode: state.selectNode, + setCurrentShape: state.setCurrentShape, + setBrushColor: state.setBrushColor, + setBrushSize: state.setBrushSize, + setBrushOpacity: state.setBrushOpacity, + setFillColor: state.setFillColor, + toggleLock: state.toggleLock, + beginRemoveBackground: state.beginRemoveBackground, + bringToFront: state.bringToFront, + bringForward: state.bringForward, + sendBackward: state.sendBackward, + sendToBack: state.sendToBack, + copySelectedItems: state.copySelectedItems, + pasteItems: state.pasteItems, + deleteSelectedItems: state.deleteSelectedItems, + undo: state.undo, + redo: state.redo, + }), + [], + ); + + const { + baseImageInfo, + baseImageBitmap, + nodes, + selectedNodeIds, + lineNodes, + activeTool, + currentShape, + fillColor, + brushColor, + brushSize, + historyImageBundles, + getAspectRatioDimensions, + finishRemoveBackground, + createImageFromUrl, + createImageFromFile, + setBaseImageInfo, + RESET, + clearLineNodes, + setNodes, + removeHistoryImage, + addHistoryImageBundle, + setAspectRatioType, + setActiveTool, + selectNode, + setCurrentShape, + setBrushColor, + setBrushSize, + setBrushOpacity, + setFillColor, + toggleLock, + beginRemoveBackground, + bringToFront, + bringForward, + sendBackward, + sendToBack, + copySelectedItems, + pasteItems, + deleteSelectedItems, + undo, + redo, + } = useSceneStore(useShallow(selector)); + const promptStoreProvider = usePrompt2DStore; const generationCount = promptStoreProvider((state) => state.generationCount); - const setGenerationCount = promptStoreProvider((state) => state.setGenerationCount); - const useSystemPrompt = promptStoreProvider((state) => state.useSystemPrompt); - const referenceImages = promptStoreProvider((state) => state.referenceImages); - const prompt = promptStoreProvider((state) => state.prompt); + const setGenerationCount = promptStoreProvider( + (state) => state.setGenerationCount, + ); const baseImageKonvaRef = useRef({} as Konva.Image); - const baseImageUrl = store.baseImageInfo?.url; + const baseImageUrl = baseImageInfo?.url; const [pendingGenerations, setPendingGenerations] = useState< { id: string; count: number }[] >([]); - const addHistoryImageBundle = useSceneStore( - (state) => state.addHistoryImageBundle, - ); - const removeHistoryImage = useSceneStore((state) => state.removeHistoryImage); - const historyImageBundles = useSceneStore( - (state) => state.historyImageBundles, - ); const selectedImageModel: ImageModel | undefined = useSelectedImageModel(PAGE_ID); @@ -92,11 +204,11 @@ const PageDraw = () => { const supportsMaskedInpainting = selectedImageModel?.usesInpaintingMask ?? false; - useDeleteHotkeys({ onDelete: store.deleteSelectedItems }); - useUndoRedoHotkeys({ undo: store.undo, redo: store.redo }); + useDeleteHotkeys({ onDelete: deleteSelectedItems }); + useUndoRedoHotkeys({ undo, redo }); useCopyPasteHotkeys({ - onCopy: store.copySelectedItems, - onPaste: store.pasteItems, + onCopy: copySelectedItems, + onPaste: pasteItems, }); useCanvasBgRemovedEvent(async (event) => { @@ -113,24 +225,19 @@ const PageDraw = () => { // const file = new File([blob], "generated_image.png", { // type: blob.type, // }); - store.finishRemoveBackground( - nodeId, - event.media_token, - event.image_cdn_url, - ); + finishRemoveBackground(nodeId, event.media_token, event.image_cdn_url); }); // Create a function to use the left layer ref and download the bitmap from it const getMaskArrayBuffer = async (): Promise => { - if ( - !stageRef.current || - !baseImageKonvaRef.current - ) { + if (!stageRef.current || !baseImageKonvaRef.current) { console.error("Stage or left panel ref is not available"); throw new Error("Stage or left panel or base image ref is not available"); } - const layer = stageRef.current.getLayers().find((l) => l.id() === INPAINT_LAYER_ID)!; + const layer = stageRef.current + .getLayers() + .find((l) => l.id() === INPAINT_LAYER_ID)!; // Get the canvas area that's covered by the image/rectangle const rect = baseImageKonvaRef.current; @@ -195,7 +302,7 @@ const PageDraw = () => { console.log("Creating image from URL:", imageUrl); - store.createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl); + createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl); console.log( `Created image "${item.label}" at stage position:`, @@ -214,12 +321,12 @@ const PageDraw = () => { handleGallery2DDrop as EventListener, ); }; - }, [store]); + // Stable action ref; no need to depend on full store. + }, [createImageFromUrl]); const handleImageUpload = async (files: File[]): Promise => { // Determine current canvas dimensions from the store (according to aspect-ratio) - const { width: canvasW, height: canvasH } = - store.getAspectRatioDimensions(); + const { width: canvasW, height: canvasH } = getAspectRatioDimensions(); // Target maximum size – 85 % of the canvas in each direction const maxW = canvasW * 0.85; @@ -240,80 +347,20 @@ const PageDraw = () => { const x = (canvasW - finalW) / 2; const y = (canvasH - finalH) / 2; - store.createImageFromFile(x, y, file, finalW, finalH); + createImageFromFile(x, y, file, finalW, finalH); }; img.src = URL.createObjectURL(file); } }; - const handleTauriEnqueue = async (image: ImageBitmap, aspectRatio: EnqueueEditImageSize | undefined, resolution: EnqueueEditImageResolution | undefined, subscriberId: string) => { - if (image === undefined) { - console.log("image is undefined"); - return; - } - - const api = new PromptsApi(); - const base64Bitmap = await EncodeImageBitmapToBase64(image); - - const byteString = atob(base64Bitmap); - const mimeString = "image/png"; - - const ab = new ArrayBuffer(byteString.length); - const ia = new Uint8Array(ab); - - for (let i = 0; i < byteString.length; i++) { - ia[i] = byteString.charCodeAt(i); - } - - const uuid = crypto.randomUUID(); // Generate a new UUID - const file = new File([ab], `${uuid}.png`, { type: mimeString }); - - const snapshotMediaToken = await api.uploadSceneSnapshot({ - screenshot: file, - }); - - if (snapshotMediaToken.data === undefined) { - toast.error("Error: Unable to upload scene snapshot Please try again."); - return; - } - - console.log("useSystemPrompt", useSystemPrompt); - console.log("Snapshot media token:", snapshotMediaToken.data); - - const request: EnqueueEditImageRequest = { - model: selectedImageModel, - scene_image_media_token: snapshotMediaToken.data!, - image_media_tokens: referenceImages - .map((image) => image.mediaToken) - .filter((t) => t.length > 0), - disable_system_prompt: !useSystemPrompt, - prompt: prompt, - image_count: generationCount, - aspect_ratio: aspectRatio, - image_resolution: resolution, - frontend_caller: "image_editor", - frontend_subscriber_id: subscriberId, - }; - - if (selectedProvider) { - request.provider = selectedProvider; - } - - const generateResponse = await EnqueueEditImage(request); - console.log("generateResponse", generateResponse); - return generateResponse; - } - const getCompositeCanvasFile = useCallback(async (): Promise => { - if ( - !stageRef.current || - !baseImageKonvaRef.current || - !store.baseImageBitmap - ) { + if (!stageRef.current || !baseImageKonvaRef.current || !baseImageBitmap) { return null; } - const editsLayer = stageRef.current.getLayers().find((l) => l.id() === DRAW_LAYER_ID); + const editsLayer = stageRef.current + .getLayers() + .find((l) => l.id() === DRAW_LAYER_ID); if (!editsLayer) { console.error("Edits layer not found"); @@ -328,7 +375,7 @@ const PageDraw = () => { const ctx = canvas.getContext("2d"); if (!ctx) return null; - ctx.drawImage(store.baseImageBitmap, 0, 0, width, height); + ctx.drawImage(baseImageBitmap, 0, 0, width, height); const markerLayerCanvas = editsLayer.toCanvas({ x: stageRef.current.x(), @@ -347,7 +394,7 @@ const PageDraw = () => { const blob = await canvas.convertToBlob({ type: "image/png" }); const uuid = crypto.randomUUID(); return new File([blob], `${uuid}.png`, { type: "image/png" }); - }, [store.baseImageBitmap]); + }, [baseImageBitmap]); const handleGenerate = useCallback( async ( @@ -359,7 +406,7 @@ const PageDraw = () => { selectedProvider?: GenerationProvider; }, ) => { - const editedImageToken = store.baseImageInfo?.mediaToken; + const editedImageToken = baseImageInfo?.mediaToken; if (!editedImageToken) { console.error("Base image is not available"); @@ -400,7 +447,7 @@ const PageDraw = () => { } }; - const { width, height } = store.getAspectRatioDimensions(); + const { width, height } = getAspectRatioDimensions(); const subscriberId: string = crypto?.randomUUID?.() ?? `inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -518,7 +565,9 @@ const PageDraw = () => { ); throw error; } - }, [generationCount, getCompositeCanvasFile, selectedImageModel, store]); + }, + [generationCount, getCompositeCanvasFile, selectedImageModel], + ); const onFitPressed = async () => { // Get the stage and its container dimensions @@ -530,8 +579,8 @@ const PageDraw = () => { const containerHeight = stage.container().offsetHeight; // Get canvas dimensions from store aspect ratio - const canvasW = store.getAspectRatioDimensions().width; - const canvasH = store.getAspectRatioDimensions().height; + const canvasW = getAspectRatioDimensions().width; + const canvasH = getAspectRatioDimensions().height; // Add padding to ensure canvas doesn't touch the edges const padding = 40; @@ -560,15 +609,17 @@ const PageDraw = () => { }; // 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 useEffect(() => { - if (!supportsMaskedInpainting && store.activeTool === "inpaint") { - store.setActiveTool("select"); + if (!supportsMaskedInpainting && activeTool === "inpaint") { + setActiveTool("select"); } - }, [store, supportsMaskedInpainting]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTool, supportsMaskedInpainting]); // Auto-fit canvas to screen on initial load useEffect(() => { - if (!store.baseImageBitmap) { + if (!baseImageBitmap) { return; } @@ -597,11 +648,11 @@ const PageDraw = () => { autoFitCanvas(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [store.baseImageBitmap]); + }, [baseImageBitmap]); // Display image selector on launch, otherwise hide it // Also show loading state if info is set but image is loading - if (!store.baseImageInfo || !store.baseImageBitmap) { + if (!baseImageInfo || !baseImageBitmap) { return (
{ { addHistoryImageBundle({ images: [image] }); - store.setBaseImageInfo(image); + setBaseImageInfo(image); }} - showLoading={ - store.baseImageInfo !== null && store.baseImageInfo === null - } + showLoading={baseImageInfo !== null && baseImageBitmap === null} />
@@ -629,29 +678,28 @@ const PageDraw = () => { <>
{ - store.RESET(); + RESET(); setPendingGenerations([]); }} imageBundles={historyImageBundles} pendingPlaceholders={pendingGenerations} blurredBackgroundUrl={baseImageUrl} onImageSelect={(baseImage) => { - store.clearLineNodes(); - store.setNodes([]); - store.setBaseImageInfo(baseImage); + setBaseImageInfo(baseImage); }} onImageRemove={(baseImage) => { if ( pendingGenerations.length === 0 && - store.historyImageBundles.length === 1 && - store.historyImageBundles[0].images.length <= 1 + historyImageBundles.length === 1 && + historyImageBundles[0].images.length <= 1 ) { - store.RESET(); + RESET(); } else { removeHistoryImage(baseImage); } @@ -662,12 +710,13 @@ const PageDraw = () => { onResolvePending={(id: string) => setPendingGenerations((prev) => prev.filter((p) => p.id !== id)) } - selectedImageToken={store.baseImageInfo?.mediaToken} + selectedImageToken={baseImageInfo?.mediaToken} />
{ @@ -685,7 +734,7 @@ const PageDraw = () => { }; const aspectRatioType = ratioToType(ratio); - store.setAspectRatioType(aspectRatioType); + setAspectRatioType(aspectRatioType); await new Promise((resolve) => requestAnimationFrame(resolve)); onFitPressed(); @@ -705,28 +754,28 @@ const PageDraw = () => { { - store.setActiveTool("select"); + setActiveTool("select"); }} onActivateShapeTool={( shape: "rectangle" | "circle" | "triangle", ): void => { - store.selectNode(null); - store.setCurrentShape(shape); - store.setActiveTool("shape"); - store.selectNode(null); + selectNode(null); + setCurrentShape(shape); + setActiveTool("shape"); + selectNode(null); }} onPaintBrush={(hex: string, size: number, opacity: number): void => { - store.setActiveTool("draw"); - store.setBrushColor(hex); - store.setBrushSize(size); - store.setBrushOpacity(opacity); + 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. - store.setFillColor(hex); + setFillColor(hex); }} onUploadImage={(): void => { // Create input element dynamically like in PromptEditor @@ -765,14 +814,14 @@ const PageDraw = () => { input.click(); }} supportsMaskTool={supportsMaskedInpainting} - activeToolId={store.activeTool} - currentShape={store.currentShape} + activeToolId={activeTool} + currentShape={currentShape} />
{ if (action === "contextMenu") { - const hasSelection = store.selectedNodeIds.length > 0; + const hasSelection = selectedNodeIds.length > 0; if (hasSelection) { console.log("An item is selected."); return true; @@ -786,51 +835,54 @@ const PageDraw = () => { onMenuAction={async (action) => { switch (action) { case "LOCK": - store.toggleLock(store.selectedNodeIds); + toggleLock(selectedNodeIds); break; case "REMOVE_BACKGROUND": - await store.beginRemoveBackground(store.selectedNodeIds); + await beginRemoveBackground(selectedNodeIds); break; case "BRING_TO_FRONT": - store.bringToFront(store.selectedNodeIds); + bringToFront(selectedNodeIds); break; case "BRING_FORWARD": - store.bringForward(store.selectedNodeIds); + bringForward(selectedNodeIds); break; case "SEND_BACKWARD": - store.sendBackward(store.selectedNodeIds); + sendBackward(selectedNodeIds); break; case "SEND_TO_BACK": - store.sendToBack(store.selectedNodeIds); + sendToBack(selectedNodeIds); break; case "DUPLICATE": - store.copySelectedItems(); - store.pasteItems(); + copySelectedItems(); + pasteItems(); break; case "DELETE": - store.deleteSelectedItems(); + deleteSelectedItems(); break; default: // No action } }} - isLocked={store.selectedNodeIds.some((id) => { - const node = store.nodes.find((n) => n.id === id); - const lineNode = store.lineNodes.find((n) => n.id === id); + 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; })} > { canvasWidth.current = width; canvasHeight.current = height; }} - fillColor={store.fillColor} - activeTool={store.activeTool} - brushColor={store.brushColor} - brushSize={store.brushSize} + fillColor={fillColor} + activeTool={activeTool} + brushColor={brushColor} + brushSize={brushSize} onSelectionChange={setIsSelecting} stageRef={stageRef} transformerRefs={transformerRefs} diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/PaintSurface.tsx b/frontend/apps/artcraft/app/src/pages/PageDraw/PaintSurface.tsx index 202f39ee9c..84cf839bb7 100644 --- a/frontend/apps/artcraft/app/src/pages/PageDraw/PaintSurface.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageDraw/PaintSurface.tsx @@ -1,4 +1,11 @@ -import React, { useState, useEffect, useLayoutEffect, useMemo } from "react"; +import React, { + useState, + useEffect, + useLayoutEffect, + useMemo, + useCallback, +} from "react"; +import { useShallow } from "zustand/react/shallow"; import { Stage, Layer, @@ -11,7 +18,7 @@ import { Group, } from "react-konva"; import Konva from "konva"; -import { LineNode, useSceneStore } from "./stores/SceneState"; +import { LineNode, SceneState, useSceneStore } from "./stores/SceneState"; import { Node } from "./Node"; import { useStageSnapshot } from "./hooks/useUpdateSnapshot"; import "./App.css"; @@ -25,10 +32,17 @@ import { DragState } from "../PageEdit/EditPaintSurface"; export type MiraiProps = { nodes: Node[]; + lineNodes: LineNode[]; selectedNodeIds: string[]; onCanvasSizeChange?: (width: number, height: number) => void; fillColor?: string; - activeTool?: "select" | "draw" | "eraser" | "backgroundColor" | "shape" | "inpaint"; + activeTool?: + | "select" + | "draw" + | "eraser" + | "backgroundColor" + | "shape" + | "inpaint"; brushColor?: string; brushSize?: number; onSelectionChange?: (isSelecting: boolean) => void; @@ -39,12 +53,14 @@ export type MiraiProps = { }; const InpaintingColor = "rgba(39, 187, 245, 0.54)"; +const MOUSE_POLL_THROTTLE_MS = 1000 / 60; export const INPAINT_LAYER_ID = "invis-mask-layer"; export const BG_LAYER_ID = "bg-layer"; export const DRAW_LAYER_ID = "draw-layer"; export const PaintSurface = ({ nodes, + lineNodes, selectedNodeIds, onCanvasSizeChange, fillColor, @@ -59,12 +75,61 @@ export const PaintSurface = ({ }: MiraiProps) => { const singlePaneMode = true; - const store = useSceneStore(); + /* + * Subscribe only to the slice PaintSurface needs. We do NOT subscribe to nodes/lineNodes + * here — they come as props from PageDraw. That way during drawing we don't re-render + * on every point; we only flush the current stroke to the store on mouse up. + */ + const selector = useMemo( + () => (s: SceneState) => ({ + baseImageBitmap: s.baseImageBitmap, + brushOpacity: s.brushOpacity, + currentShape: s.currentShape, + shapeColor: s.shapeColor, + getAspectRatioDimensions: s.getAspectRatioDimensions, + selectNode: s.selectNode, + addLineNode: s.addLineNode, + addNode: s.addNode, + updateLineNode: s.updateLineNode, + updateNode: s.updateNode, + moveNode: s.moveNode, + moveLineNode: s.moveLineNode, + saveState: s.saveState, + setActiveTool: s.setActiveTool, + }), + [], + ); + + const { + baseImageBitmap, + brushOpacity, + currentShape, + shapeColor, + getAspectRatioDimensions, + selectNode, + addLineNode, + addNode, + updateLineNode, + updateNode, + moveNode, + moveLineNode, + saveState, + setActiveTool, + } = useSceneStore(useShallow(selector)); + + const mouseMoveThrottle = React.useRef(-1); const imageRef = React.useRef(null); const leftPanelRef = React.useRef(null); const rightContainerRef = React.useRef(null); const cursorLayerRef = React.useRef(null); const cursorShapeRef = React.useRef(null); + const cursorPositionRef = React.useRef<{ x: number; y: number } | null>(null); + const [cursorVisibleLocal, setCursorVisibleLocal] = useState(false); + const currentLinePointsRef = React.useRef([]); + const currentLinePendingRef = React.useRef | null>( + null, + ); + const currentLineKonvaRef = React.useRef(null); // TODO: Polish this by using canvas graphics instead of a bitmap // Or at least manually do the pattern fill to avoid image interpolation const [checkerImage, setCheckerImage] = useState( @@ -84,7 +149,6 @@ export const PaintSurface = ({ const [leftPct, setLeftPct] = useState(singlePaneMode ? 100 : 50); const [isDrawing, setIsDrawing] = useState(false); - const [, setLastPoint] = useState<{ x: number; y: number } | null>(null); const selectionRectRef = React.useRef(null); const [selectionRect, setSelectionRect] = useState<{ @@ -136,7 +200,7 @@ export const PaintSurface = ({ if (minX === Infinity) return null; return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedNodeIds, store.nodes, store.lineNodes, stageRef]); + }, [selectedNodeIds, nodes, lineNodes, stageRef]); useLayoutEffect(() => { const updateDimensions = () => { @@ -173,7 +237,7 @@ export const PaintSurface = ({ const multiSelectTransformerRef = React.useRef(null); - useStageSnapshot(stageRef, imageRef, isSelectingRef, transformerRefs); + // useStageSnapshot(stageRef, imageRef, isSelectingRef, transformerRefs); const previewScale = useRightPanelLayoutManagement( rightContainerRef, @@ -183,6 +247,16 @@ export const PaintSurface = ({ onCanvasSizeChange, ); + const flushCurrentLineToStore = useCallback(() => { + const pending = currentLinePendingRef.current; + const points = currentLinePointsRef.current; + if (pending && points.length >= 4) { + addLineNode({ ...pending, points }, true); + } + currentLinePendingRef.current = null; + currentLinePointsRef.current = []; + }, [addLineNode]); + useGlobalMouseUp( setIsDragging, setIsDrawing, @@ -190,8 +264,8 @@ export const PaintSurface = ({ setIsSelecting, isSelectingRef, setSelectionRect, - setLastPoint, onSelectionChange, + flushCurrentLineToStore, ); const clampToLeftPanel = (point: { @@ -199,20 +273,17 @@ export const PaintSurface = ({ y: number; }): { x: number; y: number } => { return { - x: Math.max(0, Math.min(point.x, store.getAspectRatioDimensions().width)), - y: Math.max( - 0, - Math.min(point.y, store.getAspectRatioDimensions().height), - ), + x: Math.max(0, Math.min(point.x, getAspectRatioDimensions().width)), + y: Math.max(0, Math.min(point.y, getAspectRatioDimensions().height)), }; }; const isWithinLeftPanel = (point: { x: number; y: number }): boolean => { return ( point.x >= 0 && - point.x <= store.getAspectRatioDimensions().width && + point.x <= getAspectRatioDimensions().width && point.y >= 0 && - point.y <= store.getAspectRatioDimensions().height + point.y <= getAspectRatioDimensions().height ); }; @@ -260,6 +331,7 @@ export const PaintSurface = ({ if ((e.evt as MouseEvent).button === 1) { setIsMiddleMousePressed(true); setIsDragging({ ...point, anchorX: stage.x(), anchorY: stage.y() }); + console.log("setting middle mouse"); return; } if (!isTransformerTarget) { @@ -269,11 +341,13 @@ export const PaintSurface = ({ } if ( - (activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint") && + (activeTool === "draw" || + activeTool === "eraser" || + activeTool === "inpaint") && isWithinLeftPanel(stagePoint) ) { const lineId = `line-${activeTool}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const opacity = activeTool === "draw" ? store.brushOpacity : 1; + const opacity = activeTool === "draw" ? brushOpacity : 1; const strokeColor = activeTool === "eraser" ? "#FFFFFF" @@ -282,16 +356,12 @@ export const PaintSurface = ({ : InpaintingColor; const composite = - activeTool === "eraser" - ? "destination-out" - : "source-over"; + activeTool === "eraser" ? "destination-out" : "source-over"; console.log("Starting new line with composite:", composite); const zindex = - activeTool === "eraser" ? - 999 : activeTool === "inpaint" ? - 998 : 1; + activeTool === "eraser" ? 999 : activeTool === "inpaint" ? 998 : 1; const newLineNode: LineNode = { id: lineId, @@ -308,11 +378,23 @@ export const PaintSurface = ({ y: 0, }; - store.selectNode(null); - store.addLineNode(newLineNode, false); + selectNode(null); + currentLinePointsRef.current = [stagePoint.x, stagePoint.y]; + currentLinePendingRef.current = { + id: lineId, + type: "line", + stroke: strokeColor, + strokeWidth: newLineNode.strokeWidth, + draggable: true, + opacity: opacity, + locked: false, + zIndex: zindex, + globalCompositeOperation: composite, + x: 0, + y: 0, + }; setCurrentLineId(lineId); setIsDrawing(true); - setLastPoint(stagePoint); return; } @@ -342,7 +424,7 @@ export const PaintSurface = ({ if ( activeTool === "shape" && - store.currentShape && + currentShape && isWithinLeftPanel(stagePoint) && e.target === e.target.getStage() ) { @@ -353,14 +435,14 @@ export const PaintSurface = ({ y: stagePoint.y, width: 1, height: 1, - fill: store.shapeColor, + fill: shapeColor, stroke: "#444", strokeWidth: 2, draggable: true, } as const; let newNode: Node; - switch (store.currentShape) { + switch (currentShape) { case "circle": newNode = new Node({ ...commonProps, type: "circle" }); break; @@ -373,7 +455,7 @@ export const PaintSurface = ({ break; } - store.addNode(newNode); + addNode(newNode); setIsDrawingShape(true); setCurrentShapeId(id); setShapeStartPoint(stagePoint); @@ -387,175 +469,207 @@ export const PaintSurface = ({ } }; - const handleStageMouseMove = (e: Konva.KonvaEventObject) => { - const stage = stageRef.current; - if (!stage) return; - - const pointer = stage.getPointerPosition(); - if (pointer) { - const stagePoint = { - x: (pointer.x - stage.x()) / stage.scaleX(), - y: (pointer.y - stage.y()) / stage.scaleY(), - }; - - const isWithinCanvas = isWithinLeftPanel(stagePoint); - - if (activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint") { - if (isWithinCanvas || isDrawing) { - stage.container().style.cursor = "none"; - store.setCursorPosition(pointer); - store.setCursorVisible(true); - } else { - stage.container().style.cursor = "grab"; - store.setCursorVisible(false); - } - } else if (activeTool === "shape") { - if (isWithinCanvas) { - stage.container().style.cursor = "crosshair"; - } else { - stage.container().style.cursor = "grab"; - } - store.setCursorVisible(false); - } else { - if (isWithinCanvas) { - stage.container().style.cursor = "default"; - } else { - stage.container().style.cursor = "grab"; - } - store.setCursorVisible(false); - } - - if (isDragging) { - const currentStage = e.target.getStage(); - if (!currentStage) return; - - currentStage.container().style.cursor = "grabbing"; - - const displacement = { - x: pointer.x - isDragging.x, - y: pointer.y - isDragging.y, - }; - const newPos = { - x: isDragging.anchorX + displacement.x, - y: isDragging.anchorY + displacement.y, - }; - currentStage.position(newPos); - + const handleStageMouseMove = useCallback( + (e: Konva.KonvaEventObject) => { + // This function causes potential re-renders and can fire hundreds of times + // Throttling this should help keep everything running efficiently. + // Don't even bother entertaining this event if it's too early + const eventTime = performance.now(); + if (eventTime - mouseMoveThrottle.current < MOUSE_POLL_THROTTLE_MS) { + mouseMoveThrottle.current = eventTime; return; } - } - if ( - isDrawing && - currentLineId && - (activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint") - ) { - const point = stage.getPointerPosition(); - if (!point) return; + const stage = stageRef.current; + if (!stage) return; - const stagePoint = { - x: (point.x - stage.x()) / stage.scaleX(), - y: (point.y - stage.y()) / stage.scaleY(), - }; + const pointer = stage.getPointerPosition(); + if (pointer) { + const stagePoint = { + x: (pointer.x - stage.x()) / stage.scaleX(), + y: (pointer.y - stage.y()) / stage.scaleY(), + }; - // Continue drawing regardless of canvas boundaries - const currentLine = store.lineNodes.find( - (line) => line.id === currentLineId, - ); + const isWithinCanvas = isWithinLeftPanel(stagePoint); - if (currentLine) { - const updatedPoints = [ - ...currentLine.points, - stagePoint.x, - stagePoint.y, - ]; - store.updateLineNode(currentLineId, { points: updatedPoints }, false); - } - setLastPoint(stagePoint); - } + if ( + activeTool === "draw" || + activeTool === "eraser" || + activeTool === "inpaint" + ) { + if (isWithinCanvas || isDrawing) { + stage.container().style.cursor = "none"; + cursorPositionRef.current = pointer; + setCursorVisibleLocal((v) => (v ? v : true)); + const cursorNode = cursorShapeRef.current; + const cursorLayer = cursorLayerRef.current; + if (cursorNode && cursorLayer) { + cursorNode.position(pointer); + cursorLayer.batchDraw(); + } + } else { + stage.container().style.cursor = "grab"; + setCursorVisibleLocal(false); + } + } else if (activeTool === "shape") { + if (isWithinCanvas) { + stage.container().style.cursor = "crosshair"; + } else { + stage.container().style.cursor = "grab"; + } + setCursorVisibleLocal(false); + } else { + if (isWithinCanvas) { + stage.container().style.cursor = "default"; + } else { + stage.container().style.cursor = "grab"; + } + setCursorVisibleLocal(false); + } - if (isDrawingShape && currentShapeId && shapeStartPoint) { - const point = stage.getPointerPosition(); - if (!point) return; + if (isDragging) { + console.log("isdragging"); + const currentStage = e.target.getStage(); + if (!currentStage) return; - const stagePoint = { - x: (point.x - stage.x()) / stage.scaleX(), - y: (point.y - stage.y()) / stage.scaleY(), - }; + currentStage.container().style.cursor = "grabbing"; - const clamped = clampToLeftPanel(stagePoint); + const displacement = { + x: pointer.x - isDragging.x, + y: pointer.y - isDragging.y, + }; + const newPos = { + x: isDragging.anchorX + displacement.x, + y: isDragging.anchorY + displacement.y, + }; + currentStage.position(newPos); - const start = shapeStartPoint; - - let dx = clamped.x - start.x; - let dy = clamped.y - start.y; - - const shiftHeld = (e.evt as MouseEvent).shiftKey; - const keepSquare = shiftHeld; - if (keepSquare) { - const side = Math.max(Math.abs(dx), Math.abs(dy)); - dx = dx < 0 ? -side : side; - dy = dy < 0 ? -side : side; + return; + } } - const newX = dx < 0 ? start.x + dx : start.x; - const newY = dy < 0 ? start.y + dy : start.y; + if ( + isDrawing && + currentLineId && + (activeTool === "draw" || + activeTool === "eraser" || + activeTool === "inpaint") + ) { + const point = stage.getPointerPosition(); + if (!point) return; - const newWidth = Math.abs(dx); - const newHeight = Math.abs(dy); + const stagePoint = { + x: (point.x - stage.x()) / stage.scaleX(), + y: (point.y - stage.y()) / stage.scaleY(), + }; - const updateProps = { - x: newX, - y: newY, - width: newWidth, - height: newHeight, - }; + currentLinePointsRef.current.push(stagePoint.x, stagePoint.y); + const lineNode = currentLineKonvaRef.current; + const layer = lineNode?.getLayer(); + if (lineNode && layer) { + lineNode.points(currentLinePointsRef.current); + layer.batchDraw(); + } + } - store.updateNode(currentShapeId, updateProps, false); + if (isDrawingShape && currentShapeId && shapeStartPoint) { + const point = stage.getPointerPosition(); + if (!point) return; - setShapePreview({ x: newX, y: newY, width: newWidth, height: newHeight }); - } + const stagePoint = { + x: (point.x - stage.x()) / stage.scaleX(), + y: (point.y - stage.y()) / stage.scaleY(), + }; - if (isSelecting && selectionRectRef.current) { - const point = stage.getPointerPosition(); - if (!point) return; - const stagePoint = { - x: (point.x - stage.x()) / stage.scaleX(), - y: (point.y - stage.y()) / stage.scaleY(), - }; + const clamped = clampToLeftPanel(stagePoint); - const clampedPoint = clampToLeftPanel(stagePoint); + const start = shapeStartPoint; - if (selectionRect) { - selectionRectRef.current.setAttrs({ - x: Math.min(selectionRect.startX, clampedPoint.x), - y: Math.min(selectionRect.startY, clampedPoint.y), - width: Math.abs(clampedPoint.x - selectionRect.startX), - height: Math.abs(clampedPoint.y - selectionRect.startY), + let dx = clamped.x - start.x; + let dy = clamped.y - start.y; + + const shiftHeld = (e.evt as MouseEvent).shiftKey; + const keepSquare = shiftHeld; + if (keepSquare) { + const side = Math.max(Math.abs(dx), Math.abs(dy)); + dx = dx < 0 ? -side : side; + dy = dy < 0 ? -side : side; + } + + const newX = dx < 0 ? start.x + dx : start.x; + const newY = dy < 0 ? start.y + dy : start.y; + + const newWidth = Math.abs(dx); + const newHeight = Math.abs(dy); + + const updateProps = { + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }; + + updateNode(currentShapeId, updateProps, false); + + setShapePreview({ + x: newX, + y: newY, + width: newWidth, + height: newHeight, }); } - setSelectionRect((prev) => - prev - ? { - ...prev, - endX: clampedPoint.x, - endY: clampedPoint.y, - } - : null, - ); - } - }; + if (isSelecting && selectionRectRef.current) { + const point = stage.getPointerPosition(); + if (!point) return; + const stagePoint = { + x: (point.x - stage.x()) / stage.scaleX(), + y: (point.y - stage.y()) / stage.scaleY(), + }; + + const clampedPoint = clampToLeftPanel(stagePoint); + + if (selectionRect) { + selectionRectRef.current.setAttrs({ + x: Math.min(selectionRect.startX, clampedPoint.x), + y: Math.min(selectionRect.startY, clampedPoint.y), + width: Math.abs(clampedPoint.x - selectionRect.startX), + height: Math.abs(clampedPoint.y - selectionRect.startY), + }); + } + + setSelectionRect((prev) => + prev + ? { + ...prev, + endX: clampedPoint.x, + endY: clampedPoint.y, + } + : null, + ); + } + }, + [ + activeTool, + isDragging, + isDrawing, + isDrawingShape, + isSelecting, + selectionRect, + nodes, + lineNodes, + ], + ); const handleStageMouseUp = () => { if (isDrawing) { - store.saveState(); + saveState(); } if (isDrawingShape && currentShapeId) { - store.saveState(); - store.setActiveTool("select"); - store.selectNode(currentShapeId); + saveState(); + setActiveTool("select"); + selectNode(currentShapeId); } if (isSelecting && selectionRect) { @@ -577,7 +691,7 @@ export const PaintSurface = ({ } }); - store.lineNodes.forEach((node) => { + lineNodes.forEach((node) => { const isInSelection = node.points.some((point, index) => { if (index % 2 === 0) { const x = point; @@ -593,9 +707,9 @@ export const PaintSurface = ({ }); if (selectedIds.length > 0) { - store.selectNode(selectedIds[0], true); + selectNode(selectedIds[0], true); selectedIds.slice(1).forEach((id) => { - store.selectNode(id, true); + selectNode(id, true); }); } } @@ -607,7 +721,6 @@ export const PaintSurface = ({ isSelectingRef.current = false; onSelectionChange?.(false); setSelectionRect(null); - setLastPoint(null); setIsDrawingShape(false); setCurrentShapeId(null); setShapeStartPoint(null); @@ -626,7 +739,7 @@ export const PaintSurface = ({ const touch2 = touches[1]; return Math.sqrt( Math.pow(touch2.clientX - touch1.clientX, 2) + - Math.pow(touch2.clientY - touch1.clientY, 2), + Math.pow(touch2.clientY - touch1.clientY, 2), ); }; @@ -743,7 +856,7 @@ export const PaintSurface = ({ e: Konva.KonvaEventObject, ) => { if (e.target === e.target.getStage()) { - store.selectNode(null); + selectNode(null); } }; @@ -758,7 +871,9 @@ export const PaintSurface = ({ const container = e.target.getStage()?.container(); if (!container) return; const defaultCursor = - activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint" + activeTool === "draw" || + activeTool === "eraser" || + activeTool === "inpaint" ? "none" : activeTool === "select" ? "grab" @@ -773,16 +888,26 @@ export const PaintSurface = ({ return; } - if (activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint") { + if ( + activeTool === "draw" || + activeTool === "eraser" || + activeTool === "inpaint" + ) { stage.container().style.cursor = "none"; const pointer = stage.getPointerPosition(); if (pointer) { - store.setCursorPosition(pointer); - store.setCursorVisible(true); + cursorPositionRef.current = pointer; + setCursorVisibleLocal((v) => (v ? v : true)); + const cursorNode = cursorShapeRef.current; + const cursorLayer = cursorLayerRef.current; + if (cursorNode && cursorLayer) { + cursorNode.position(pointer); + cursorLayer.batchDraw(); + } } } else { stage.container().style.cursor = "default"; - store.setCursorVisible(false); + setCursorVisibleLocal(false); } }; @@ -791,8 +916,8 @@ export const PaintSurface = ({ if (stage) { stage.container().style.cursor = "default"; } - store.setCursorVisible(false); - store.setCursorPosition(null); + setCursorVisibleLocal(false); + cursorPositionRef.current = null; }; useLayoutEffect(() => { @@ -802,9 +927,11 @@ export const PaintSurface = ({ if (!cursorNode || !cursorLayer || !stage) return; if ( - store.cursorVisible && - store.cursorPosition && - (activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint") + cursorVisibleLocal && + cursorPositionRef.current && + (activeTool === "draw" || + activeTool === "eraser" || + activeTool === "inpaint") ) { const stageX = stage.x(); const stageY = stage.y(); @@ -817,7 +944,7 @@ export const PaintSurface = ({ cursorLayer.scaleY(1 / scaleY); cursorNode.visible(true); - cursorNode.position(store.cursorPosition); + cursorNode.position(cursorPositionRef.current); cursorNode.radius(brushSize / 2); if (activeTool === "draw") { @@ -838,165 +965,91 @@ export const PaintSurface = ({ } cursorLayer.batchDraw(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - store.cursorVisible, - store.cursorPosition, - activeTool, - brushColor, - brushSize, - ]); + }, [cursorVisibleLocal, activeTool, brushColor, brushSize]); - const renderNode = (node: Node | LineNode) => { - const handleNodeMouseDown = ( - e: Konva.KonvaEventObject, - nodeId: string, - ) => { - if ( - activeTool === "draw" || - activeTool === "eraser" || - activeTool === "inpaint" || - activeTool === "shape" - ) { - return; - } - - // Prevent middle mouse button from interacting with nodes (canvas panning only) - if ("button" in e.evt && (e.evt as MouseEvent).button === 1) { - setIsMiddleMousePressed(true); - return; - } - - if ("button" in e.evt && (e.evt as MouseEvent).button === 2) { - const node = store.nodes.find((n) => n.id === nodeId); - const lineNode = store.lineNodes.find((n) => n.id === nodeId); - const isLocked = (node?.locked || lineNode?.locked) ?? false; - - if (isLocked) { - store.selectNode(nodeId); + const renderNode = useCallback( + (node: Node | LineNode) => { + const handleNodeMouseDown = ( + e: Konva.KonvaEventObject, + nodeId: string, + ) => { + if ( + activeTool === "draw" || + activeTool === "eraser" || + activeTool === "inpaint" || + activeTool === "shape" + ) { return; } - } - const draggedNode = - (nodes.find((n) => n.id === nodeId) as Node | LineNode | undefined) || - store.lineNodes.find((ln) => ln.id === nodeId); - if (draggedNode?.locked) { - return; - } + // Prevent middle mouse button from interacting with nodes (canvas panning only) + if ("button" in e.evt && (e.evt as MouseEvent).button === 1) { + setIsMiddleMousePressed(true); + return; + } - const isMultiSelect = - (e.evt as MouseEvent).ctrlKey || (e.evt as MouseEvent).metaKey; + if ("button" in e.evt && (e.evt as MouseEvent).button === 2) { + const node = nodes.find((n) => n.id === nodeId); + const lineNode = lineNodes.find((n) => n.id === nodeId); + const isLocked = (node?.locked || lineNode?.locked) ?? false; - if (e.target === e.target.getStage()) { - store.selectNode(null); - return; - } + if (isLocked) { + selectNode(nodeId); + return; + } + } - // If the node is already selected and we're not multi-selecting, preserve the current selection - // This allows dragging multiple selected nodes without changing the selection - const isAlreadySelected = selectedNodeIds.includes(nodeId); + const draggedNode = + (nodes.find((n) => n.id === nodeId) as Node | LineNode | undefined) || + lineNodes.find((ln) => ln.id === nodeId); + if (draggedNode?.locked) { + return; + } - if (isAlreadySelected && !isMultiSelect && selectedNodeIds.length > 1) { - // Don't change selection - preserve multi-select for dragging - return; - } + const isMultiSelect = + (e.evt as MouseEvent).ctrlKey || (e.evt as MouseEvent).metaKey; - store.selectNode(nodeId, isMultiSelect); - }; + if (e.target === e.target.getStage()) { + selectNode(null); + return; + } - const handleNodeDragStart = ( - e: Konva.KonvaEventObject, - nodeId: string, - ) => { - const targetNode = e.target as Konva.Node & { - lastX?: number; - lastY?: number; - }; - targetNode.lastX = targetNode.x(); - targetNode.lastY = targetNode.y(); + // If the node is already selected and we're not multi-selecting, preserve the current selection + // This allows dragging multiple selected nodes without changing the selection + const isAlreadySelected = selectedNodeIds.includes(nodeId); - let newX = targetNode.x(); - let newY = targetNode.y(); - const draggedNode = - (nodes.find((n) => n.id === nodeId) as Node | LineNode | undefined) || - store.lineNodes.find((ln) => ln.id === nodeId); - if (draggedNode && draggedNode.type === "circle") { - newX = targetNode.x() - draggedNode.width / 2; - newY = targetNode.y() - draggedNode.height / 2; - } - store.moveNode(nodeId, newX, newY, 0, 0, false); - }; + if (isAlreadySelected && !isMultiSelect && selectedNodeIds.length > 1) { + // Don't change selection - preserve multi-select for dragging + return; + } - const handleNodeDragMove = ( - e: Konva.KonvaEventObject, - nodeId: string, - ) => { - const targetNode = e.target as Konva.Node & { - lastX?: number; - lastY?: number; + selectNode(nodeId, isMultiSelect); }; - const dx = targetNode.x() - (targetNode.lastX || targetNode.x()); - const dy = targetNode.y() - (targetNode.lastY || targetNode.y()); - - targetNode.lastX = targetNode.x(); - targetNode.lastY = targetNode.y(); - - let newXMove = targetNode.x(); - let newYMove = targetNode.y(); - const movingNode = - (nodes.find((n) => n.id === nodeId) as Node | LineNode | undefined) || - store.lineNodes.find((ln) => ln.id === nodeId); - if (movingNode && movingNode.type === "circle") { - newXMove = targetNode.x() - movingNode.width / 2; - newYMove = targetNode.y() - movingNode.height / 2; - } - - store.moveNode(nodeId, newXMove, newYMove, dx, dy, false); - }; - - const handleNodeDragEnd = ( - e: Konva.KonvaEventObject, - nodeId: string, - ) => { - const targetNode = e.target as Konva.Node & { - lastX?: number; - lastY?: number; - }; - targetNode.lastX = targetNode.x(); - targetNode.lastY = targetNode.y(); - - let endX = targetNode.x(); - let endY = targetNode.y(); - const endNode = - (nodes.find((n) => n.id === nodeId) as Node | LineNode | undefined) || - store.lineNodes.find((ln) => ln.id === nodeId); - if (endNode && endNode.type === "circle") { - endX = targetNode.x() - endNode.width / 2; - endY = targetNode.y() - endNode.height / 2; - } - store.moveNode(nodeId, endX, endY, 0, 0, true); - }; - - const isSelected = selectedNodeIds.includes(node.id); - const listeningEnabled = activeTool !== "shape"; - - if (node.type === "line") { - const lineNode = node as LineNode; - - const handleLineDragStart = ( + const handleNodeDragStart = ( e: Konva.KonvaEventObject, - _nodeId: string, + nodeId: string, ) => { - void _nodeId; const targetNode = e.target as Konva.Node & { lastX?: number; lastY?: number; }; targetNode.lastX = targetNode.x(); targetNode.lastY = targetNode.y(); + + let newX = targetNode.x(); + let newY = targetNode.y(); + const draggedNode = + (nodes.find((n) => n.id === nodeId) as Node | LineNode | undefined) || + lineNodes.find((ln) => ln.id === nodeId); + if (draggedNode && draggedNode.type === "circle") { + newX = targetNode.x() - draggedNode.width / 2; + newY = targetNode.y() - draggedNode.height / 2; + } + moveNode(nodeId, newX, newY, 0, 0, false); }; - const handleLineDragMove = ( + + const handleNodeDragMove = ( e: Konva.KonvaEventObject, nodeId: string, ) => { @@ -1011,249 +1064,180 @@ export const PaintSurface = ({ targetNode.lastX = targetNode.x(); targetNode.lastY = targetNode.y(); - // Move all selected nodes if multiple are selected - if (selectedNodeIds.length > 1 && selectedNodeIds.includes(nodeId)) { - selectedNodeIds.forEach((id) => { - if (id !== nodeId) { - const isLineNode = store.lineNodes.find((ln) => ln.id === id); - if (isLineNode) { - store.moveLineNode(id, dx, dy); - } else { - const regularNode = nodes.find((n) => n.id === id); - if (regularNode) { - store.moveNode( - id, - regularNode.x + dx, - regularNode.y + dy, - dx, - dy, - false, - ); - } - } - } - }); + let newXMove = targetNode.x(); + let newYMove = targetNode.y(); + const movingNode = + (nodes.find((n) => n.id === nodeId) as Node | LineNode | undefined) || + lineNodes.find((ln) => ln.id === nodeId); + if (movingNode && movingNode.type === "circle") { + newXMove = targetNode.x() - movingNode.width / 2; + newYMove = targetNode.y() - movingNode.height / 2; } + + moveNode(nodeId, newXMove, newYMove, dx, dy, false); }; - const handleLineDragEnd = ( + + const handleNodeDragEnd = ( e: Konva.KonvaEventObject, nodeId: string, ) => { - const targetNode = e.target as Konva.Node; - const finalX = targetNode.x(); - const finalY = targetNode.y(); - const finalOffsetX = targetNode.offsetX(); - const finalOffsetY = targetNode.offsetY(); + const targetNode = e.target as Konva.Node & { + lastX?: number; + lastY?: number; + }; + targetNode.lastX = targetNode.x(); + targetNode.lastY = targetNode.y(); - store.updateLineNode( - nodeId, - { - x: finalX, - y: finalY, - offsetX: finalOffsetX, - offsetY: finalOffsetY, - }, - true, - ); + let endX = targetNode.x(); + let endY = targetNode.y(); + const endNode = + (nodes.find((n) => n.id === nodeId) as Node | LineNode | undefined) || + lineNodes.find((ln) => ln.id === nodeId); + if (endNode && endNode.type === "circle") { + endX = targetNode.x() - endNode.width / 2; + endY = targetNode.y() - endNode.height / 2; + } + moveNode(nodeId, endX, endY, 0, 0, true); }; - return ( - handleNodeMouseDown(e, lineNode.id)} - onTap={(e) => - handleNodeMouseDown( - e as Konva.KonvaEventObject, - lineNode.id, - ) - } - onMouseEnter={handleNodeMouseEnter} - onMouseLeave={handleNodeMouseLeave} - draggable={draggableIfToolsNotActive( - activeTool, - lineNode.draggable && lineNode.locked == false, - )} - onDragMove={(e) => handleLineDragMove(e, lineNode.id)} - onDragStart={(e) => handleLineDragStart(e, lineNode.id)} - onDragEnd={(e) => handleLineDragEnd(e, lineNode.id)} - x={lineNode.x || 0} - y={lineNode.y || 0} - scaleX={lineNode.scaleX || 1} - scaleY={lineNode.scaleY || 1} - rotation={lineNode.rotation || 0} - offsetX={lineNode.offsetX || 0} - offsetY={lineNode.offsetY || 0} - listening={listeningEnabled} - // @ts-expect-error: We don't have globalCompositeOperation in the type definition - globalCompositeOperation={ - lineNode.globalCompositeOperation || "source-over" - } - /> - ); - } + const isSelected = selectedNodeIds.includes(node.id); + const listeningEnabled = activeTool !== "shape"; - if (node.type === "circle") { - return ( - handleNodeMouseDown(e, node.id)} - onTap={(e) => - handleNodeMouseDown( - e as Konva.KonvaEventObject, - node.id, - ) + if (node.type === "line") { + const lineNode = node as LineNode; + + const handleLineDragStart = ( + e: Konva.KonvaEventObject, + _nodeId: string, + ) => { + void _nodeId; + const targetNode = e.target as Konva.Node & { + lastX?: number; + lastY?: number; + }; + targetNode.lastX = targetNode.x(); + targetNode.lastY = targetNode.y(); + }; + const handleLineDragMove = ( + e: Konva.KonvaEventObject, + nodeId: string, + ) => { + const targetNode = e.target as Konva.Node & { + lastX?: number; + lastY?: number; + }; + + const dx = targetNode.x() - (targetNode.lastX || targetNode.x()); + const dy = targetNode.y() - (targetNode.lastY || targetNode.y()); + + targetNode.lastX = targetNode.x(); + targetNode.lastY = targetNode.y(); + + // Move all selected nodes if multiple are selected + if (selectedNodeIds.length > 1 && selectedNodeIds.includes(nodeId)) { + selectedNodeIds.forEach((id) => { + if (id !== nodeId) { + const isLineNode = lineNodes.find((ln) => ln.id === id); + if (isLineNode) { + moveLineNode(id, dx, dy); + } else { + const regularNode = nodes.find((n) => n.id === id); + if (regularNode) { + moveNode( + id, + regularNode.x + dx, + regularNode.y + dy, + dx, + dy, + false, + ); + } + } + } + }); } - onMouseEnter={handleNodeMouseEnter} - onMouseLeave={handleNodeMouseLeave} - onDragMove={(e) => handleNodeDragMove(e, node.id)} - onDragStart={(e) => handleNodeDragStart(e, node.id)} - onDragEnd={(e) => handleNodeDragEnd(e, node.id)} - listening={listeningEnabled} - /> - ); - } + }; + const handleLineDragEnd = ( + e: Konva.KonvaEventObject, + nodeId: string, + ) => { + const targetNode = e.target as Konva.Node; + const finalX = targetNode.x(); + const finalY = targetNode.y(); + const finalOffsetX = targetNode.offsetX(); + const finalOffsetY = targetNode.offsetY(); - if (node.type === "rectangle") { - return ( - handleNodeMouseDown(e, node.id)} - onTap={(e) => - handleNodeMouseDown( - e as Konva.KonvaEventObject, - node.id, - ) - } - onMouseEnter={handleNodeMouseEnter} - onMouseLeave={handleNodeMouseLeave} - onDragMove={(e) => handleNodeDragMove(e, node.id)} - onDragStart={(e) => handleNodeDragStart(e, node.id)} - onDragEnd={(e) => handleNodeDragEnd(e, node.id)} - listening={listeningEnabled} - /> - ); - } + updateLineNode( + nodeId, + { + x: finalX, + y: finalY, + offsetX: finalOffsetX, + offsetY: finalOffsetY, + }, + true, + ); + }; - if (node.type === "triangle") { - const points = [ - node.width / 2, - 0, - 0, - node.height, - node.width, - node.height, - node.width / 2, - 0, - ]; + return ( + handleNodeMouseDown(e, lineNode.id)} + onTap={(e) => + handleNodeMouseDown( + e as Konva.KonvaEventObject, + lineNode.id, + ) + } + onMouseEnter={handleNodeMouseEnter} + onMouseLeave={handleNodeMouseLeave} + draggable={draggableIfToolsNotActive( + activeTool, + lineNode.draggable && lineNode.locked == false, + )} + onDragMove={(e) => handleLineDragMove(e, lineNode.id)} + onDragStart={(e) => handleLineDragStart(e, lineNode.id)} + onDragEnd={(e) => handleLineDragEnd(e, lineNode.id)} + x={lineNode.x || 0} + y={lineNode.y || 0} + scaleX={lineNode.scaleX || 1} + scaleY={lineNode.scaleY || 1} + rotation={lineNode.rotation || 0} + offsetX={lineNode.offsetX || 0} + offsetY={lineNode.offsetY || 0} + listening={listeningEnabled} + // @ts-expect-error: We don't have globalCompositeOperation in the type definition + globalCompositeOperation={ + lineNode.globalCompositeOperation || "source-over" + } + /> + ); + } - return ( - handleNodeMouseDown(e, node.id)} - onTap={(e) => - handleNodeMouseDown( - e as Konva.KonvaEventObject, - node.id, - ) - } - onMouseEnter={handleNodeMouseEnter} - onMouseLeave={handleNodeMouseLeave} - onDragMove={(e) => handleNodeDragMove(e, node.id)} - onDragStart={(e) => handleNodeDragStart(e, node.id)} - onDragEnd={(e) => handleNodeDragEnd(e, node.id)} - listening={listeningEnabled} - /> - ); - } - - if (node.type === "image") { - return ( - - {node.backgroundColor && node.backgroundColor !== "transparent" && ( - - )} - handleNodeDragEnd(e, node.id)} listening={listeningEnabled} /> - - ); - } + ); + } - return null; - }; + if (node.type === "rectangle") { + return ( + handleNodeMouseDown(e, node.id)} + onTap={(e) => + handleNodeMouseDown( + e as Konva.KonvaEventObject, + node.id, + ) + } + onMouseEnter={handleNodeMouseEnter} + onMouseLeave={handleNodeMouseLeave} + onDragMove={(e) => handleNodeDragMove(e, node.id)} + onDragStart={(e) => handleNodeDragStart(e, node.id)} + onDragEnd={(e) => handleNodeDragEnd(e, node.id)} + listening={listeningEnabled} + /> + ); + } + + if (node.type === "triangle") { + const points = [ + node.width / 2, + 0, + 0, + node.height, + node.width, + node.height, + node.width / 2, + 0, + ]; + + return ( + handleNodeMouseDown(e, node.id)} + onTap={(e) => + handleNodeMouseDown( + e as Konva.KonvaEventObject, + node.id, + ) + } + onMouseEnter={handleNodeMouseEnter} + onMouseLeave={handleNodeMouseLeave} + onDragMove={(e) => handleNodeDragMove(e, node.id)} + onDragStart={(e) => handleNodeDragStart(e, node.id)} + onDragEnd={(e) => handleNodeDragEnd(e, node.id)} + listening={listeningEnabled} + /> + ); + } + + if (node.type === "image") { + return ( + + {node.backgroundColor && node.backgroundColor !== "transparent" && ( + + )} + handleNodeMouseDown(e, node.id)} + onTap={(e) => + handleNodeMouseDown( + e as Konva.KonvaEventObject, + node.id, + ) + } + onMouseEnter={handleNodeMouseEnter} + onMouseLeave={handleNodeMouseLeave} + onDragMove={(e) => handleNodeDragMove(e, node.id)} + onDragStart={(e) => handleNodeDragStart(e, node.id)} + onDragEnd={(e) => handleNodeDragEnd(e, node.id)} + listening={listeningEnabled} + /> + + ); + } + + return null; + }, + [activeTool, nodes, lineNodes, selectedNodeIds, transformerRefs], + ); // Load the checkboard image for transparency background useEffect(() => { @@ -1294,7 +1420,7 @@ export const PaintSurface = ({ // Don't render individual transformers when multiple nodes are selected if (selectedNodeIds.length > 1) return null; - return [...nodes, ...store.lineNodes].map((node) => { + return [...nodes, ...lineNodes].map((node) => { const isSelected = selectedNodeIds.includes(node.id); if (!isSelected) return null; @@ -1348,7 +1474,7 @@ export const PaintSurface = ({ const finalOffsetY = konvaNode.offsetY(); // Find the corresponding node in the store to check its type - const storeNode = store.nodes.find((n) => n.id === nodeId); + const storeNode = nodes.find((n) => n.id === nodeId); // Calculate new dimensions based on the scale that the Transformer applied const newWidth = konvaNode.width() * finalScaleX; @@ -1376,10 +1502,10 @@ export const PaintSurface = ({ offsetY: finalOffsetY, }; - const isLineNode = store.lineNodes.find((ln) => ln.id === nodeId); + const isLineNode = lineNodes.find((ln) => ln.id === nodeId); if (isLineNode) { - store.updateLineNode( + updateLineNode( nodeId, { x: finalX, @@ -1393,7 +1519,7 @@ export const PaintSurface = ({ false, ); } else { - store.updateNode(nodeId, { ...commonNodeUpdates }, false); + updateNode(nodeId, { ...commonNodeUpdates }, false); } }} /> @@ -1454,8 +1580,11 @@ export const PaintSurface = ({ const inpaintingLineNodes: LineNode[] = []; const drawLineNodes: LineNode[] = []; - store.lineNodes.forEach((line) => { - if (line.id.startsWith("line-eraser") || line.id.startsWith("line-inpaint")) { + lineNodes.forEach((line) => { + if ( + line.id.startsWith("line-eraser") || + line.id.startsWith("line-inpaint") + ) { inpaintingLineNodes.push(line); } if (line.id.startsWith("line-eraser") || line.id.startsWith("line-draw")) { @@ -1500,13 +1629,14 @@ export const PaintSurface = ({ ctx.rect( 0, 0, - store.getAspectRatioDimensions().width, - store.getAspectRatioDimensions().height, + getAspectRatioDimensions().width, + getAspectRatioDimensions().height, ); // leftPanelWidth, leftPanelHeight); }} imageSmoothingEnabled={false} // Disable image smoothing for pixel art zIndex={-2} id={BG_LAYER_ID} + pixelRatio={1} > @@ -1548,20 +1678,39 @@ export const PaintSurface = ({ ctx.rect( 0, 0, - store.getAspectRatioDimensions().width, - store.getAspectRatioDimensions().height, + getAspectRatioDimensions().width, + getAspectRatioDimensions().height, ); }} + pixelRatio={1} > + {nodes.map((node) => renderNode(node))} - {nodes - .map((node) => renderNode(node))} + {drawLineNodes.map((node) => renderNode(node))} - - {drawLineNodes - .map((node) => renderNode(node))} - + {currentLineId && + !currentLineId.startsWith("line-inpaint") && + currentLinePendingRef.current && ( + + )} {selectionRect && ( - {inpaintingLineNodes - .map((node) => renderNode(node))} + {inpaintingLineNodes.map((node) => renderNode(node))} + {currentLineId && + currentLineId.startsWith("line-inpaint") && + currentLinePendingRef.current && ( + + )} - + {/* Separate layer for transformers - no clipping so they remain visible when nodes extend beyond canvas */} @@ -1653,9 +1830,7 @@ export const PaintSurface = ({ const finalOffsetY = konvaNode.offsetY(); // Find the corresponding node in the store to check its type - const storeNode = store.nodes.find( - (n) => n.id === nodeId, - ); + const storeNode = nodes.find((n) => n.id === nodeId); // Calculate new dimensions based on the scale that the Transformer applied const newWidth = konvaNode.width() * finalScaleX; @@ -1683,12 +1858,12 @@ export const PaintSurface = ({ offsetY: finalOffsetY, }; - const isLineNode = store.lineNodes.find( + const isLineNode = lineNodes.find( (ln) => ln.id === nodeId, ); if (isLineNode) { - store.updateLineNode( + updateLineNode( nodeId, { x: finalX, @@ -1702,15 +1877,11 @@ export const PaintSurface = ({ false, ); } else { - store.updateNode( - nodeId, - { ...commonNodeUpdates }, - false, - ); + updateNode(nodeId, { ...commonNodeUpdates }, false); } } }); - store.saveState(); + saveState(); }} /> )} @@ -1743,18 +1914,14 @@ export const PaintSurface = ({ tgt.lastY = tgt.y(); selectedNodeIds.forEach((id) => { - const lineNode = store.lineNodes.find( - (ln) => ln.id === id, - ); + const lineNode = lineNodes.find((ln) => ln.id === id); if (lineNode) { - store.moveLineNode(id, dx, dy); + moveLineNode(id, dx, dy); } else { - const currentNode = store.nodes.find( - (n) => n.id === id, - ); + const currentNode = nodes.find((n) => n.id === id); if (currentNode) { // Move each shape individually without triggering extra multi-select propagation - store.moveNode( + moveNode( id, currentNode.x + dx, currentNode.y + dy, @@ -1767,7 +1934,7 @@ export const PaintSurface = ({ }); }} onDragEnd={() => { - store.saveState(); + saveState(); }} listening={true} strokeWidth={0} diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/hooks/useGlobalMouseUp.ts b/frontend/apps/artcraft/app/src/pages/PageDraw/hooks/useGlobalMouseUp.ts index 32b8ce5ba5..28fa3ed229 100644 --- a/frontend/apps/artcraft/app/src/pages/PageDraw/hooks/useGlobalMouseUp.ts +++ b/frontend/apps/artcraft/app/src/pages/PageDraw/hooks/useGlobalMouseUp.ts @@ -1,6 +1,6 @@ -import { Vector2d } from 'konva/lib/types'; -import { useEffect } from 'react'; -import { DragState } from '~/pages/PageEdit/EditPaintSurface'; +import { Vector2d } from "konva/lib/types"; +import { useEffect } from "react"; +import { DragState } from "~/pages/PageEdit/EditPaintSurface"; // This piece of code ensures that when you are selecting or dragging and go off the stage, // it releases the controls mouse drag. export const useGlobalMouseUp = ( @@ -10,11 +10,12 @@ export const useGlobalMouseUp = ( setIsSelecting: (value: boolean) => void, isSelectingRef: React.MutableRefObject, setSelectionRect: (value: any) => void, - setLastPoint: (value: { x: number, y: number } | null) => void, onSelectionChange?: (isSelecting: boolean) => void, + onBeforeReset?: () => void, ) => { useEffect(() => { const handleGlobalMouseUp = () => { + onBeforeReset?.(); // Always reset all states when mouse is released anywhere setIsDragging(undefined); setIsDrawing(false); @@ -23,16 +24,15 @@ export const useGlobalMouseUp = ( isSelectingRef.current = false; onSelectionChange?.(false); setSelectionRect(null); - setLastPoint(null); }; // Listen to mouseup on the entire window - window.addEventListener('mouseup', handleGlobalMouseUp); - window.addEventListener('touchend', handleGlobalMouseUp); + window.addEventListener("mouseup", handleGlobalMouseUp); + window.addEventListener("touchend", handleGlobalMouseUp); return () => { - window.removeEventListener('mouseup', handleGlobalMouseUp); - window.removeEventListener('touchend', handleGlobalMouseUp); + window.removeEventListener("mouseup", handleGlobalMouseUp); + window.removeEventListener("touchend", handleGlobalMouseUp); }; - }, [onSelectionChange]); -} + }, [onSelectionChange, onBeforeReset]); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/stores/SceneState.ts b/frontend/apps/artcraft/app/src/pages/PageDraw/stores/SceneState.ts index 907dd0041e..1e9ccfddd0 100644 --- a/frontend/apps/artcraft/app/src/pages/PageDraw/stores/SceneState.ts +++ b/frontend/apps/artcraft/app/src/pages/PageDraw/stores/SceneState.ts @@ -86,11 +86,17 @@ type SerializedNodeData = { locked: boolean; }; -interface SceneState { +interface HistoryNodeData { + nodes: Node[]; + lineNodes: LineNode[]; +} + +export interface SceneState { // Nodes nodes: Node[]; selectedNodeIds: string[]; lineNodes: LineNode[]; // Add lineNodes to state + historyImageNodeMap: Map; // Clipboard clipboard: (Node | LineNode)[]; // To store copied items @@ -292,6 +298,7 @@ export const useSceneStore = create((set, get, store) => ({ nodes: [], lineNodes: [], selectedNodeIds: [], + historyImageNodeMap: new Map(), clipboard: [], history: [], historyIndex: -1, @@ -1587,15 +1594,40 @@ export const useSceneStore = create((set, get, store) => ({ }, setBaseImageInfo: (image: BaseSelectorImage | null) => { - set({ baseImageInfo: image }); - + // If the new base image is null, return early because nothing needs to be loaded. if (!image) { + // Set the new base image info - setting null is allowed. + set({ baseImageInfo: null }); return; } - // Load the image bitmap as well + // Save the current node data to the history image node map + const currentBaseImage = get().baseImageInfo; + if (currentBaseImage) { + get().historyImageNodeMap.set(currentBaseImage, { + nodes: get().nodes, + lineNodes: get().lineNodes, + }); + } + + // Set the new base image info + set({ baseImageInfo: image }); + + // Load the image bitmap now const imgBitmap = new Image(); imgBitmap.onload = () => { + // Once the base image is set, check previous node data - or create new empty one + const previousNodeData = get().historyImageNodeMap.get(image); + if (previousNodeData) { + set({ + nodes: previousNodeData.nodes, + lineNodes: previousNodeData.lineNodes, + }); + } else { + set({ nodes: [], lineNodes: [] }); + } + + // Finally, set the base image bitmap set({ baseImageBitmap: imgBitmap }); }; imgBitmap.onerror = (event) => { @@ -1615,7 +1647,10 @@ export const useSceneStore = create((set, get, store) => ({ }, clearHistoryImages: () => { - set({ historyImageBundles: [] }); + set({ + historyImageBundles: [], + historyImageNodeMap: new Map(), + }); }, addHistoryImageBundle(bundle) { @@ -1627,6 +1662,10 @@ export const useSceneStore = create((set, get, store) => ({ removeHistoryImage(image) { console.log("Removing history image:", image); set((state) => { + // Remove the history image node data + state.historyImageNodeMap.delete(image); + + // Remove the image from the history image bundles const updatedBundles = state.historyImageBundles .map((bundle) => { return { diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/EditPaintSurface.tsx b/frontend/apps/artcraft/app/src/pages/PageEdit/EditPaintSurface.tsx index cdae628bb2..537b757876 100644 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/EditPaintSurface.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageEdit/EditPaintSurface.tsx @@ -90,7 +90,6 @@ export const EditPaintSurface = ({ /* 1️⃣ Track SplitPane percent so we can re-measure */ const [leftPct, setLeftPct] = useState(singlePaneMode ? 100 : 50); const [isDrawing, setIsDrawing] = useState(false); - const [, setLastPoint] = useState<{ x: number; y: number } | null>(null); const selectionRectRef = React.useRef(null); const [selectionRect, setSelectionRect] = useState<{ @@ -144,7 +143,6 @@ export const EditPaintSurface = ({ setIsSelecting, isSelectingRef, setSelectionRect, - setLastPoint, onSelectionChange, ); @@ -282,7 +280,6 @@ export const EditPaintSurface = ({ store.addLineNode(newLineNode, false); setCurrentLineId(lineId); setIsDrawing(true); - setLastPoint(stagePoint); return; } @@ -451,8 +448,6 @@ export const EditPaintSurface = ({ lineNode.points(currentPoints); lineNode.getLayer()?.batchDraw(); } - - setLastPoint(stagePoint); } // Handle selection rectangle - Update directly through Konva @@ -481,11 +476,11 @@ export const EditPaintSurface = ({ setSelectionRect((prev) => prev ? { - // Ensure prev is not null - ...prev, - endX: clampedPoint.x, - endY: clampedPoint.y, - } + // Ensure prev is not null + ...prev, + endX: clampedPoint.x, + endY: clampedPoint.y, + } : null, ); } @@ -562,7 +557,6 @@ export const EditPaintSurface = ({ isSelectingRef.current = false; onSelectionChange?.(false); setSelectionRect(null); - setLastPoint(null); setIsMiddleMousePressed(false); // Reset middle mouse state }; @@ -638,8 +632,8 @@ export const EditPaintSurface = ({ if (!container) return; const defaultCursor = activeTool === "edit" || - activeTool === "marker" || - activeTool === "eraser" + activeTool === "marker" || + activeTool === "eraser" ? "none" : activeTool === "select" ? "grab" @@ -693,7 +687,7 @@ export const EditPaintSurface = ({ const touch2 = touches[1]; return Math.sqrt( Math.pow(touch2.clientX - touch1.clientX, 2) + - Math.pow(touch2.clientY - touch1.clientY, 2), + Math.pow(touch2.clientY - touch1.clientY, 2), ); }; @@ -1473,11 +1467,11 @@ export const EditPaintSurface = ({ scaleY={previewScale} x={0} y={0} - // style={{ - // background: '#f5f5f5', - // border: '1px solid #ddd', - // borderRadius: '8px', - // }} + // style={{ + // background: '#f5f5f5', + // border: '1px solid #ddd', + // borderRadius: '8px', + // }} >