diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx index fd86beffa5..7dcd38eeaa 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx @@ -71,7 +71,7 @@ import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBac import { TabId, useTabStore } from "~/pages/Stores/TabState"; import { authentication } from "~/signals"; import { setLogoutStates } from "~/signals/authentication/utilities"; -import { BaseSelectorImage } from "../../../pages/PageEdit/BaseImageSelector"; +import type { BaseSelectorImage } from "@storyteller/ui-pagedraw"; import { AppsQuickMenu } from "./AppsQuickMenu"; import { SceneTitleInput } from "./SceneTitleInput"; import { TaskQueue } from "./TaskQueue"; diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx b/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx index 5a32b4a895..6d0179ab0d 100644 --- a/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx @@ -43,8 +43,7 @@ import { useCostBreakdownModalStore, } from "@storyteller/ui-pricing-modal"; import { HelpMenuButton } from "@storyteller/ui-help-menu"; -import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack"; -import type { BaseSelectorImage } from "../PageEdit/BaseImageSelector"; +import { HistoryStack, type ImageBundle, type BaseSelectorImage } from "@storyteller/ui-pagedraw"; const ANGLES_MODEL_PAGE = ModelPage.Angles; @@ -562,12 +561,6 @@ export const Angles = () => { selectedImageToken={selectedImageToken} onImageSelect={handleHistoryImageSelect} onImageRemove={handleHistoryImageRemove} - onNewImageBundle={() => { - /* handled by our own event listener */ - }} - onResolvePending={() => { - /* handled by our own event listener */ - }} /> diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/BaseImageSelector.tsx b/frontend/apps/artcraft/app/src/pages/PageEdit/BaseImageSelector.tsx deleted file mode 100644 index f83dbba4f3..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/BaseImageSelector.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { faImages, faPencil } from "@fortawesome/pro-solid-svg-icons"; -import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi"; -import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal"; -import { useState } from "react"; -import toast from "react-hot-toast"; -import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage"; -import { UploaderStates } from "~/enums"; -import { MediaFilesApi } from "@storyteller/api"; -import { HelpMenuButton } from "@storyteller/ui-help-menu"; -import { CostCalculatorButton } from "@storyteller/ui-pricing-modal"; -import { ModelPage } from "@storyteller/ui-model-selector"; -import { UploadEntryCard } from "~/components/media/UploadEntryCard"; - -export interface BaseSelectorImage { - url: string; - mediaToken: string; - - // Link to the thumbnail *template* URL of the image - // This is not a URL itself! It must be converted into a URL by replacing parameters. - thumbnailUrlTemplate?: string; - - // Link to the full image URL of the full image asset - fullImageUrl?: string; -} - -export type BaseImageSelectorProps = { - onImageSelect: (imageUrl: BaseSelectorImage) => void; - showLoading?: boolean; -}; - -const MAX_GALLERY_SELECTIONS = 1; - -export const BaseImageSelector = ({ - onImageSelect, - showLoading = false, -}: BaseImageSelectorProps) => { - const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false); - const [selectedGalleryImages, setSelectedGalleryImages] = useState( - [], - ); - const [isLoading, setIsLoading] = useState(false); - - const handleGalleryClick = () => setIsGalleryModalOpen(true); - - const handleGalleryClose = () => { - setIsGalleryModalOpen(false); - setSelectedGalleryImages([]); - }; - - const handleImageSelect = (mediaToken: string) => { - setSelectedGalleryImages((prev) => { - if (prev.includes(mediaToken)) - return prev.filter((x) => x !== mediaToken); - const maxSelections = 1; - if (prev.length >= maxSelections) { - return maxSelections === 1 ? [mediaToken] : prev; - } - return [...prev, mediaToken]; - }); - }; - - const handleUseGalleryImages = (selectedItems: GalleryItem[]) => { - const item = selectedItems[0]; - if (!item || !item.fullImage) { - toast.error("No image selected"); - return; - } - - const referenceImage: BaseSelectorImage = { - url: item.fullImage, - mediaToken: item.id, - thumbnailUrlTemplate: item.thumbnailUrlTemplate, - }; - sendImageEvent(referenceImage); - }; - - const handleFileUpload = (files: FileList) => { - setIsLoading(true); - - Array.from(files).forEach((file) => { - const reader = new FileReader(); - reader.onloadend = () => { - uploadImage({ - title: `reference-image-${Math.random() - .toString(36) - .substring(2, 15)}`, - assetFile: file, - progressCallback: (newState) => { - console.debug("Upload progress:", newState.data); - if (newState.status === UploaderStates.success && newState.data) { - const mediaToken = newState.data || ""; - (async () => { - let finalUrl = reader.result as string; - let thumbnailUrlTemplate = undefined; - try { - const api = new MediaFilesApi(); - const result = await api.GetMediaFileByToken({ - mediaFileToken: mediaToken, - }); - if (result.success && result.data) { - finalUrl = - result.data.media_links?.cdn_url || - result.data.public_bucket_url || - finalUrl; - const mediaLinks = result.data.media_links as - | { - thumbnail_template?: string; - maybe_thumbnail_template?: string; - } - | undefined; - thumbnailUrlTemplate = - mediaLinks?.thumbnail_template || - mediaLinks?.maybe_thumbnail_template; - } - } catch (e) { - console.warn( - "Falling back to data URL for uploaded image", - e, - ); - } - - const referenceImage: BaseSelectorImage = { - mediaToken, - url: finalUrl, - fullImageUrl: finalUrl, - thumbnailUrlTemplate: thumbnailUrlTemplate, - }; - - toast.success("Image uploaded successfully!"); - sendImageEvent(referenceImage); - setIsLoading(false); - })(); - } else if ( - newState.status === UploaderStates.assetError || - newState.status === UploaderStates.imageCreateError - ) { - toast.error("Upload failed. Please try again."); - setIsLoading(false); - } - }, - }); - }; - - reader.readAsDataURL(file); - }); - }; - - const sendImageEvent = (image: BaseSelectorImage) => { - handleGalleryClose(); - onImageSelect(image); - }; - - return ( - <> -
-
- -
-
-
- - -
- - - ); -}; diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/EditPaintSurface.tsx b/frontend/apps/artcraft/app/src/pages/PageEdit/EditPaintSurface.tsx deleted file mode 100644 index 143792578c..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/EditPaintSurface.tsx +++ /dev/null @@ -1,1489 +0,0 @@ -import React, { useState, useEffect, useLayoutEffect } from "react"; -import { - Stage, - Layer, - Rect, - Circle, - Line, - Image, - RegularPolygon, - Transformer, -} from "react-konva"; -import Konva from "konva"; // Import Konva namespace for types - -// https://github.com/SaladTechnologies/comfyui-api - -import { ActiveEditTool, useEditStore } from "./stores/EditState"; -import { SplitPane, useStageCentering, useGlobalMouseUp, useRightPanelLayoutManagement, type LineNode, Node } from "@storyteller/ui-pagedraw"; -import { checkerboard } from "@storyteller/common"; -import { loadImageFromUrl } from "~/Helpers/ImageHelpers"; -import { Vector2d } from "konva/lib/types"; - -export interface DragState extends Vector2d { - anchorX: number; - anchorY: number; -} - -export type EditPaintSurfaceProps = { - nodes: Node[]; - selectedNodeIds: string[]; - onCanvasSizeChange?: (width: number, height: number) => void; - //fillColor?: string; - activeTool?: ActiveEditTool; - brushColor?: string; - brushSize?: number; - markerBrushSize?: number; - eraserBrushSize?: number; - markerColor?: string; - onSelectionChange?: (isSelecting: boolean) => void; - stageRef: React.RefObject; - transformerRefs: React.RefObject<{ [key: string]: Konva.Transformer }>; - leftPanelRef: React.RefObject; - baseImageRef: React.RefObject; -}; - -export const EditPaintSurface = ({ - nodes, - selectedNodeIds, - onCanvasSizeChange, - //fillColor, - activeTool = "select", - brushColor = "#000000", - brushSize = 5, - markerBrushSize = 5, - eraserBrushSize = 25, - markerColor = "#FF0000", - onSelectionChange, - stageRef, - transformerRefs, - leftPanelRef, - baseImageRef, -}: EditPaintSurfaceProps) => { - // switch off to be preview panel mode. - const singlePaneMode = true; - - const store = useEditStore(); // Use store directly - const imageRef = React.useRef(null); - // Snapshot image state used in other components - const [snapshotImage] = useState(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( - null, - ); - const rightContainerRef = React.useRef(null); - const cursorLayerRef = React.useRef(null); - const cursorShapeRef = React.useRef(null); - // Removed generating border refs - - // Layout dimensions - const leftPanelWidth = 1024; - const leftPanelHeight = 1024; - const rightPanelWidth = 1024; - const rightPanelHeight = 1024; - - /* 1️⃣ Track SplitPane percent so we can re-measure */ - const [leftPct, setLeftPct] = useState(singlePaneMode ? 100 : 50); - const [isDrawing, setIsDrawing] = useState(false); - - const selectionRectRef = React.useRef(null); - const [selectionRect, setSelectionRect] = useState<{ - startX: number; - startY: number; - endX: number; - endY: number; - } | null>(null); - const [isSelecting, setIsSelecting] = useState(false); - const [isDragging, setIsDragging] = useState( - undefined, - ); - // Add a ref to track if we're currently selecting - const isSelectingRef = React.useRef(false); - - // Add state to track the current line being drawn - const [currentLineId, setCurrentLineId] = useState(null); - const stagePosition = useStageCentering( - stageRef, - leftPct, - leftPanelWidth, - leftPanelHeight, - ); - - const NATURAL_WIDTH = rightPanelWidth; - const NATURAL_HEIGHT = rightPanelHeight; - - // transform variables - const multiSelectTransformerRef = React.useRef(null); - - // better to double buffer and use a queue and submit to a queue - // useStageSnapshot( - // stageRef, - // imageRef, - // isSelectingRef, - // transformerRefs - // ); - - const previewScale = useRightPanelLayoutManagement( - rightContainerRef, - NATURAL_WIDTH, - NATURAL_HEIGHT, - leftPct, - onCanvasSizeChange, - ); - - useGlobalMouseUp( - setIsDragging, - setIsDrawing, - setCurrentLineId, - setIsSelecting, - isSelectingRef, - setSelectionRect, - onSelectionChange, - ); - - const clampToLeftPanel = (point: { - x: number; - y: number; - }): { x: number; y: number } => { - return { - x: Math.max(0, Math.min(point.x, store.getAspectRatioDimensions().width)), //leftPanelWidth)), - y: Math.max( - 0, - Math.min(point.y, store.getAspectRatioDimensions().height), - ), //leftPanelHeight)), - }; - }; - - const isWithinLeftPanel = (point: { x: number; y: number }): boolean => { - return ( - point.x >= 0 && - point.x <= store.getAspectRatioDimensions().width && //leftPanelWidth && - point.y >= 0 && - point.y <= store.getAspectRatioDimensions().height // leftPanelHeight - ); - }; - - // Helper function to determine if nodes should be draggable - const draggableIfToolsNotActive = ( - activeTool: string, - nodeDraggable: boolean, - ): boolean => { - return ( - activeTool !== "edit" && - activeTool !== "marker" && - activeTool !== "eraser" && - nodeDraggable && - !isMiddleMousePressed - ); - }; - - // Add state for middle mouse button - const [isMiddleMousePressed, setIsMiddleMousePressed] = useState(false); - - // Combined mouse handlers - const handleStageMouseDown = ( - e: Konva.KonvaEventObject, - ) => { - const stage = stageRef.current; - if (!stage) return; - const point = stage.getPointerPosition(); - if (!point) return; - - const stagePoint = { - x: (point.x - stage.x()) / stage.scaleX(), - y: (point.y - stage.y()) / stage.scaleY(), - }; - - // Check if the target is a transformer or its children (handles) - const isTransformerTarget = - e.target.getClassName() === "Transformer" || - e.target.getParent()?.getClassName() === "Transformer" || - e.target.name()?.includes("_anchor") || - e.target.name()?.includes("rotater"); - - if (!isWithinLeftPanel(stagePoint) && !isTransformerTarget) { - setIsDragging({ ...point, anchorX: stage.x(), anchorY: stage.y() }); - return; - } - - // Improved handling for mouse buttons - if ("button" in e.evt) { - const mouseEvent = e.evt as MouseEvent; - - if (mouseEvent.button === 1) { - // Middle mouse button - setIsMiddleMousePressed(true); - setIsDragging({ ...point, anchorX: stage.x(), anchorY: stage.y() }); - return; - } - - if (mouseEvent.button === 2) { - // Right mouse button - if (!isTransformerTarget) { - setIsDragging({ ...point, anchorX: stage.x(), anchorY: stage.y() }); - } - return; - } - } - - // Handle drawing tools - only start if within bounds - if ( - (activeTool === "edit" || - activeTool === "marker" || - activeTool === "eraser") && - isWithinLeftPanel(stagePoint) - ) { - const lineId = `line-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const opacity = activeTool === "edit" ? store.brushOpacity : 1; - const composite = - activeTool === "eraser" - ? "destination-out" - : activeTool === "marker" - ? "source-over" - : store.editOperation === "add" - ? "source-over" - : "destination-out"; - const strokeColor = - activeTool === "eraser" - ? "#FFFFFF" - : activeTool === "marker" - ? markerColor - : store.editOperation === "add" - ? brushColor - : "#FFFFFF"; - - const lineStrokeWidth = - activeTool === "eraser" - ? eraserBrushSize / stage.scaleX() - : activeTool === "marker" - ? markerBrushSize / stage.scaleX() - : brushSize / stage.scaleX(); - - const newLineNode: LineNode = { - id: lineId, - type: "line", - points: [stagePoint.x, stagePoint.y], - stroke: strokeColor, - strokeWidth: lineStrokeWidth, - draggable: activeTool === "marker", - opacity: opacity, - locked: false, - zIndex: 0, - globalCompositeOperation: composite, - }; - store.selectNode(null); - store.addLineNode(newLineNode, false); - setCurrentLineId(lineId); - setIsDrawing(true); - return; - } - - // Handle selection rectangle - if (activeTool === "select" && e.target === e.target.getStage()) { - const stage = stageRef.current; - if (!stage) return; - const point = stage.getPointerPosition(); - if (!point) return; - - const stagePoint = { - x: (point.x - stage.x()) / stage.scaleX(), - y: (point.y - stage.y()) / stage.scaleY(), - }; - - // Clamp the starting point to left panel bounds - const clampedPoint = clampToLeftPanel(stagePoint); - - setIsSelecting(true); - isSelectingRef.current = true; - onSelectionChange?.(true); - setSelectionRect({ - startX: clampedPoint.x, - startY: clampedPoint.y, - endX: clampedPoint.x, - endY: clampedPoint.y, - }); - } - }; - - 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); - - // Update cursor position and visibility based on tool and canvas position - if ( - activeTool === "edit" || - activeTool === "marker" || - activeTool === "eraser" - ) { - if (isWithinCanvas || isDrawing) { - stage.container().style.cursor = "none"; - - // Update cursor directly without triggering React re-render - const cursorNode = cursorShapeRef.current; - const cursorLayer = cursorLayerRef.current; - if (cursorNode && cursorLayer) { - const stageX = stage.x(); - const stageY = stage.y(); - const scaleX = stage.scaleX(); - const scaleY = stage.scaleY(); - - cursorLayer.x(-stageX / scaleX); - cursorLayer.y(-stageY / scaleY); - cursorLayer.scaleX(1 / scaleX); - cursorLayer.scaleY(1 / scaleY); - - cursorNode.visible(true); - cursorNode.position(pointer); - - const cursorRadius = - activeTool === "eraser" - ? eraserBrushSize / 2 - : activeTool === "marker" - ? markerBrushSize / 2 - : brushSize / 2; - cursorNode.radius(cursorRadius); - - if (activeTool === "marker") { - cursorNode.fill(markerColor); - cursorNode.stroke("rgba(255, 255, 255, 0.7)"); - cursorNode.strokeWidth(1); - } else if (activeTool === "eraser") { - cursorNode.fill("rgba(255, 255, 255, 0.3)"); - cursorNode.stroke("rgba(0, 0, 0, 0.7)"); - cursorNode.strokeWidth(1); - } else if (store.editOperation === "add") { - cursorNode.fill(brushColor); - cursorNode.stroke("rgba(255, 255, 255, 0.7)"); - cursorNode.strokeWidth(1); - } else { - cursorNode.fill("rgba(255, 255, 255, 0.3)"); - cursorNode.stroke("rgba(0, 0, 0, 0.7)"); - cursorNode.strokeWidth(1); - } - - cursorLayer.batchDraw(); - } - } else { - stage.container().style.cursor = "grab"; - const cursorNode = cursorShapeRef.current; - const cursorLayer = cursorLayerRef.current; - if (cursorNode && cursorLayer) { - cursorNode.visible(false); - cursorLayer.batchDraw(); - } - } - } else { - if (isWithinCanvas) { - stage.container().style.cursor = "default"; - } else { - stage.container().style.cursor = "grab"; - } - const cursorNode = cursorShapeRef.current; - const cursorLayer = cursorLayerRef.current; - if (cursorNode && cursorLayer) { - cursorNode.visible(false); - cursorLayer.batchDraw(); - } - } - - // Handle panning with improved dragging - 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, - }; - console.log(isDragging, displacement, newPos); - currentStage.position(newPos); - - return; - } - } - - // Handle drawing - only add points if within bounds - if ( - isDrawing && - currentLineId && - (activeTool === "edit" || - activeTool === "marker" || - activeTool === "eraser") - ) { - const point = stage.getPointerPosition(); - if (!point) return; - - const stagePoint = { - x: (point.x - stage.x()) / stage.scaleX(), - y: (point.y - stage.y()) / stage.scaleY(), - }; - - // Update line directly on the Konva layer without React - const lineNode = stage.findOne(`#${currentLineId}`) as Konva.Line; - if (lineNode) { - const currentPoints = lineNode.points(); - currentPoints.push(stagePoint.x, stagePoint.y); - lineNode.points(currentPoints); - lineNode.getLayer()?.batchDraw(); - } - } - - // Handle selection rectangle - Update directly through Konva - 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(), - }; - - // Clamp the current point to left panel bounds - const clampedPoint = clampToLeftPanel(stagePoint); - - // Update the rectangle directly through Konva without React state - 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), - }); - } - - // Only update React state for the end points (needed for selection logic) - setSelectionRect((prev) => - prev - ? { - // Ensure prev is not null - ...prev, - endX: clampedPoint.x, - endY: clampedPoint.y, - } - : null, - ); - } - }; - - const handleStageMouseUp = () => { - if (isDrawing && currentLineId) { - // Update store with final line points after drawing is complete - const stage = stageRef.current; - if (stage) { - const lineNode = stage.findOne(`#${currentLineId}`) as Konva.Line; - if (lineNode) { - const finalPoints = lineNode.points(); - store.updateLineNode(currentLineId, { points: finalPoints }, false); - } - } - store.saveState(); // Save state only when the stroke is complete - } - - if (isSelecting && selectionRect) { - // Calculate the bounds of the selection rectangle - const left = Math.min(selectionRect.startX, selectionRect.endX); - const right = Math.max(selectionRect.startX, selectionRect.endX); - const top = Math.min(selectionRect.startY, selectionRect.endY); - const bottom = Math.max(selectionRect.startY, selectionRect.endY); - - // Find all nodes that intersect with the selection rectangle - const selectedIds: string[] = []; - - // Check regular nodes - nodes.forEach((node) => { - if ( - node.x + node.width >= left && - node.x <= right && - node.y + node.height >= top && - node.y <= bottom - ) { - selectedIds.push(node.id); - } - }); - - // Check line nodes - store.lineNodes.forEach((node) => { - // Check if any point of the line is within the selection rectangle - const isInSelection = node.points.some((point, index) => { - if (index % 2 === 0) { - // x coordinate - const x = point; - const y = node.points[index + 1]; - return x >= left && x <= right && y >= top && y <= bottom; - } - return false; - }); - - if (isInSelection) { - selectedIds.push(node.id); - } - }); - - // Select all found nodes - if (selectedIds.length > 0) { - store.selectNode(selectedIds[0], true); - selectedIds.slice(1).forEach((id) => { - store.selectNode(id, true); - }); - } - } - - // Always reset all states - setIsDragging(undefined); - setIsDrawing(false); - setCurrentLineId(null); // Clear the current line ID - setIsSelecting(false); - isSelectingRef.current = false; - onSelectionChange?.(false); - setSelectionRect(null); - setIsMiddleMousePressed(false); // Reset middle mouse state - }; - - const handleStageWheel = (e: Konva.KonvaEventObject) => { - e.evt.preventDefault(); - const stage = e.target.getStage(); - if (!stage) return; - const oldScale = stage.scaleX(); - const pointer = stage.getPointerPosition(); - if (!pointer) return; - - const mousePointTo = { - x: (pointer.x - stage.x()) / oldScale, - y: (pointer.y - stage.y()) / oldScale, - }; - - // Improved zoom handling with better precision - const isPinchGesture = e.evt.ctrlKey; - const deltaY = e.evt.deltaY; - const absDelta = Math.abs(deltaY); - - const isMac = navigator.userAgent.includes("Mac"); - const isMacTrackpadScroll = - isMac && !isPinchGesture && e.evt.deltaMode === 0 && absDelta <= 10; - - let zoomFactor; - - if (isPinchGesture) { - const pinchSensitivity = 0.2; - zoomFactor = 1 + deltaY * pinchSensitivity * -0.01; - } else if (isMacTrackpadScroll) { - return; - } else { - const mouseSensitivity = 0.0005; - zoomFactor = Math.exp(-deltaY * mouseSensitivity); - } - - const newScale = Math.max(0.1, Math.min(10, oldScale * zoomFactor)); - - if (newScale !== oldScale) { - const newPos = { - x: pointer.x - mousePointTo.x * newScale, - y: pointer.y - mousePointTo.y * newScale, - }; - - stage.scale({ x: newScale, y: newScale }); - stage.position(newPos); - } - }; - - // Add click handler for the stage to clear selection - const handleStageClick = ( - e: Konva.KonvaEventObject, - ) => { - // Only clear selection if clicking directly on the stage - if (e.target === e.target.getStage()) { - store.selectNode(null); - } - }; - - // End Node Callbacks - - // Add hover handlers - const handleNodeMouseEnter = (e: Konva.KonvaEventObject) => { - if (activeTool === "select") { - const container = e.target.getStage()?.container(); - if (container) container.style.cursor = "move"; - } - }; - - const handleNodeMouseLeave = (e: Konva.KonvaEventObject) => { - const container = e.target.getStage()?.container(); - if (!container) return; - const defaultCursor = - activeTool === "edit" || - activeTool === "marker" || - activeTool === "eraser" - ? "none" - : activeTool === "select" - ? "grab" - : "default"; - container.style.cursor = defaultCursor; - }; - - // Add stage hover handlers - const handleStageMouseEnter = () => { - const stage = stageRef.current; - if (!stage) { - console.error("Stage reference is not available"); - return; - } - - if ( - activeTool === "edit" || - activeTool === "marker" || - activeTool === "eraser" - ) { - stage.container().style.cursor = "none"; - } else { - stage.container().style.cursor = "default"; - } - }; - - const handleStageMouseLeave = () => { - const stage = stageRef.current; - if (stage) { - stage.container().style.cursor = "default"; - } - const cursorNode = cursorShapeRef.current; - const cursorLayer = cursorLayerRef.current; - if (cursorNode && cursorLayer) { - cursorNode.visible(false); - cursorLayer.batchDraw(); - } - }; - - // Update cursor appearance - // Adding touch-related state - const [lastPinchDistance, setLastPinchDistance] = useState( - null, - ); - const [isPinching, setIsPinching] = useState(false); - - // Touch helper functions - const getTouchDistance = (touches: TouchList) => { - if (touches.length < 2) return 0; - const touch1 = touches[0]; - const touch2 = touches[1]; - return Math.sqrt( - Math.pow(touch2.clientX - touch1.clientX, 2) + - Math.pow(touch2.clientY - touch1.clientY, 2), - ); - }; - - const getTouchCenter = (touches: TouchList) => { - if (touches.length < 2) return null; - const touch1 = touches[0]; - const touch2 = touches[1]; - return { - x: (touch1.clientX + touch2.clientX) / 2, - y: (touch1.clientY + touch2.clientY) / 2, - }; - }; - - // Touch event handlers - const handleTouchStart = (e: Konva.KonvaEventObject) => { - const touches = e.evt.touches; - if (touches.length === 2) { - setIsPinching(true); - setLastPinchDistance(getTouchDistance(touches)); - } - }; - - const handleTouchMove = (e: Konva.KonvaEventObject) => { - const touches = e.evt.touches; - if (touches.length === 2 && isPinching && lastPinchDistance) { - e.evt.preventDefault(); - - const stage = e.target.getStage(); - if (!stage) return; - - const currentDistance = getTouchDistance(touches); - const center = getTouchCenter(touches); - if (!center) return; - - const oldScale = stage.scaleX(); - const scaleChange = currentDistance / lastPinchDistance; - - const newScale = Math.max(0.1, Math.min(10, oldScale * scaleChange)); - - if (newScale !== oldScale) { - const stageCenter = { - x: (center.x - stage.x()) / oldScale, - y: (center.y - stage.y()) / oldScale, - }; - - const newPos = { - x: center.x - stageCenter.x * newScale, - y: center.y - stageCenter.y * newScale, - }; - - stage.scale({ x: newScale, y: newScale }); - stage.position(newPos); - } - - setLastPinchDistance(currentDistance); - } - }; - - const handleTouchEnd = (e: Konva.KonvaEventObject) => { - const touches = e.evt.touches; - if (touches.length < 2) { - setIsPinching(false); - setLastPinchDistance(null); - } - }; - - // Only update cursor appearance when tool or size changes, not on every mouse move - useLayoutEffect(() => { - const cursorNode = cursorShapeRef.current; - const cursorLayer = cursorLayerRef.current; - if (!cursorNode || !cursorLayer) return; - - const cursorRadius = - activeTool === "eraser" - ? eraserBrushSize / 2 - : activeTool === "marker" - ? markerBrushSize / 2 - : brushSize / 2; - cursorNode.radius(cursorRadius); - - if (activeTool === "marker") { - cursorNode.fill(markerColor); - cursorNode.stroke("rgba(255, 255, 255, 0.7)"); - cursorNode.strokeWidth(1); - } else if (activeTool === "eraser") { - cursorNode.fill("rgba(255, 255, 255, 0.3)"); - cursorNode.stroke("rgba(0, 0, 0, 0.7)"); - cursorNode.strokeWidth(1); - } else if (store.editOperation === "add") { - cursorNode.fill(brushColor); - cursorNode.stroke("rgba(255, 255, 255, 0.7)"); - cursorNode.strokeWidth(1); - } else { - cursorNode.fill("rgba(255, 255, 255, 0.3)"); - cursorNode.stroke("rgba(0, 0, 0, 0.7)"); - cursorNode.strokeWidth(1); - } - - if (cursorNode.visible()) { - cursorLayer.batchDraw(); - } - }, [ - activeTool, - brushColor, - brushSize, - markerBrushSize, - eraserBrushSize, - markerColor, - store.editOperation, - ]); - - const renderNode = (node: Node | LineNode) => { - // Node Callbacks - const handleNodeMouseDown = ( - e: Konva.KonvaEventObject, - nodeId: string, - ) => { - // Don't select nodes when edit, marker, or eraser tool is active - if ( - activeTool === "edit" || - activeTool === "marker" || - activeTool === "eraser" - ) { - 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; - } - - // Handle right click for context menu or selection - 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 locked, only allow selection for context menu - if (isLocked) { - store.selectNode(nodeId); - return; - } - } - - // Don't select locked nodes - const node = - nodes.find((n) => n.id === nodeId) || - store.lineNodes.find((n) => n.id === nodeId); - if (node?.locked) { - return; - } - - // Check if Ctrl/Cmd key is pressed - const isMultiSelect = - "ctrlKey" in e.evt - ? (e.evt as MouseEvent).ctrlKey || (e.evt as MouseEvent).metaKey - : false; - - // If clicking directly on the stage, clear selection - if (e.target === e.target.getStage()) { - store.selectNode(null); - return; - } - - // If the node is already selected and we're not multi-selecting, preserve the current selection - const isAlreadySelected = selectedNodeIds.includes(nodeId); - if (isAlreadySelected && !isMultiSelect && selectedNodeIds.length > 1) { - // Don't change selection - preserve multi-select for dragging - return; - } - - store.selectNode(nodeId, isMultiSelect); - }; - - 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(); - - store.moveNode(nodeId, targetNode.x(), targetNode.y(), 0, 0, false); - }; - - const handleNodeDragMove = ( - e: Konva.KonvaEventObject, - nodeId: string, - ) => { - const targetNode = e.target as Konva.Node & { - lastX?: number; - lastY?: number; - }; - - // Calculate the movement delta - const dx = targetNode.x() - (targetNode.lastX || targetNode.x()); - const dy = targetNode.y() - (targetNode.lastY || targetNode.y()); - - // Update the last position - targetNode.lastX = targetNode.x(); - targetNode.lastY = targetNode.y(); - - store.moveNode(nodeId, targetNode.x(), targetNode.y(), 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(); - - store.moveNode(nodeId, targetNode.x(), targetNode.y(), 0, 0, true); - }; - - const isSelected = selectedNodeIds.includes(node.id); - - const renderTransformer = () => { - if (isSelected) { - return ( - { - // Added type for ref - if (ref) { - transformerRefs.current[node.id] = ref; - } - }} - boundBoxFunc={(oldBox, newBox) => { - const minSize = 5; - const maxSize = Math.max(leftPanelWidth, leftPanelHeight); - - if ( - newBox.width < minSize || - newBox.height < minSize || - newBox.width > maxSize || - newBox.height > maxSize - ) { - return oldBox; - } - return newBox; - }} - enabledAnchors={[ - "top-left", - "top-center", - "top-right", - "middle-right", - "middle-left", - "bottom-left", - "bottom-center", - "bottom-right", - ]} - rotateEnabled={true} - keepRatio={true} - centeredScaling={true} - padding={5} - shiftBehavior="inverted" - ignoreStroke={true} - onTransformEnd={(e: Konva.KonvaEventObject) => { - // Cast not needed as we're not using event properties that differ between MouseEvent and TouchEvent - // Typed event - const konvaNode = e.target; - const nodeId = konvaNode.id(); - - // Get all transformation values including anchor points - const finalRotation = konvaNode.rotation(); - const finalScaleX = konvaNode.scaleX(); - const finalScaleY = konvaNode.scaleY(); - const finalX = konvaNode.x(); - const finalY = konvaNode.y(); - const finalOffsetX = konvaNode.offsetX(); - const finalOffsetY = konvaNode.offsetY(); - - const isLineNode = store.lineNodes.find((ln) => ln.id === nodeId); - - // Save all transformation values to the store - if (isLineNode) { - store.updateLineNode( - nodeId, - { - x: finalX, - y: finalY, - rotation: finalRotation, - scaleX: finalScaleX, - scaleY: finalScaleY, - offsetX: finalOffsetX, - offsetY: finalOffsetY, - }, - true, - ); - } else { - store.updateNode( - nodeId, - { - x: finalX, - y: finalY, - rotation: finalRotation, - scaleX: finalScaleX, - scaleY: finalScaleY, - offsetX: finalOffsetX, - offsetY: finalOffsetY, - }, - true, - ); // Assuming shouldSaveState is true for regular nodes - } - }} - /> - ); - } - return null; - }; - - if (node.type === "line") { - const lineNode = node as LineNode; - - const handleLineDragStart = ( - e: Konva.KonvaEventObject, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _nodeId: string, // NodeId is not used but kept for function signature compatibility - ) => { - const targetNode = e.target as Konva.Node & { - lastX?: number; - lastY?: number; - }; - targetNode.lastX = targetNode.x(); - targetNode.lastY = targetNode.y(); - }; - const handleLineDragMove = ( - e: Konva.KonvaEventObject, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _nodeId: string, // NodeId is not used but kept for function signature compatibility - ) => { - const targetNode = e.target as Konva.Node & { - lastX?: number; - lastY?: number; - }; - targetNode.lastX = targetNode.x(); // This doesn't seem to be used for lines to calculate dx/dy - targetNode.lastY = targetNode.y(); // but is set for consistency or future use - }; - const handleLineDragEnd = ( - e: Konva.KonvaEventObject, - nodeId: string, - ) => { - // Move the line node in the store - const targetNode = e.target as Konva.Node; - const finalX = targetNode.x(); - const finalY = targetNode.y(); - const finalOffsetX = targetNode.offsetX(); // Konva might adjust offsetX/Y - const finalOffsetY = targetNode.offsetY(); - - // Update the line node in the store with its final position and transformation - store.updateLineNode( - nodeId, - { - x: finalX, - y: finalY, - offsetX: finalOffsetX, - offsetY: finalOffsetY, - }, - true, - ); - }; - - return ( - - handleNodeMouseDown(e, lineNode.id)} - onTap={(e) => handleNodeMouseDown(e, lineNode.id)} - onMouseEnter={(e) => handleNodeMouseEnter(e)} - onMouseLeave={(e) => handleNodeMouseLeave(e)} - 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} - zIndex={lineNode.zIndex} - // @ts-expect-error: We don't have globalCompositeOperation in the type definition - globalCompositeOperation={ - lineNode.globalCompositeOperation || "source-over" - } - /> - {renderTransformer()} - - ); - } - - if (node.type === "circle") { - return ( - - handleNodeMouseDown(e, node.id)} - onTap={(e) => handleNodeMouseDown(e, node.id)} - onMouseEnter={(e) => handleNodeMouseEnter(e)} - onMouseLeave={(e) => handleNodeMouseLeave(e)} - onDragMove={(e) => handleNodeDragMove(e, node.id)} - onDragStart={(e) => handleNodeDragStart(e, node.id)} - onDragEnd={(e) => handleNodeDragEnd(e, node.id)} - /> - {renderTransformer()} - - ); - } - - if (node.type === "rectangle") { - return ( - - handleNodeMouseDown(e, node.id)} - onTap={(e) => handleNodeMouseDown(e, node.id)} - onMouseEnter={(e) => handleNodeMouseEnter(e)} - onMouseLeave={(e) => handleNodeMouseLeave(e)} - onDragMove={(e) => handleNodeDragMove(e, node.id)} - onDragStart={(e) => handleNodeDragStart(e, node.id)} - onDragEnd={(e) => handleNodeDragEnd(e, node.id)} - /> - {renderTransformer()} - - ); - } - - if (node.type === "triangle") { - return ( - - handleNodeMouseDown(e, node.id)} - onTap={(e) => handleNodeMouseDown(e, node.id)} - onMouseEnter={(e) => handleNodeMouseEnter(e)} - onMouseLeave={(e) => handleNodeMouseLeave(e)} - onDragMove={(e) => handleNodeDragMove(e, node.id)} - onDragStart={(e) => handleNodeDragStart(e, node.id)} - onDragEnd={(e) => handleNodeDragEnd(e, node.id)} - /> - {renderTransformer()} - - ); - } - - if (node.type === "image") { - return ( - - {/* Render background rectangle if backgroundColor is set */} - {node.backgroundColor && node.backgroundColor !== "transparent" && ( - - )} - handleNodeMouseDown(e, node.id)} - onTap={(e) => handleNodeMouseDown(e, node.id)} - onMouseEnter={(e) => handleNodeMouseEnter(e)} - onMouseLeave={(e) => handleNodeMouseLeave(e)} - onDragMove={(e) => handleNodeDragMove(e, node.id)} - onDragStart={(e) => handleNodeDragStart(e, node.id)} - onDragEnd={(e) => handleNodeDragEnd(e, node.id)} - /> - {renderTransformer()} - - ); - } - - return null; - }; - - // TODO refactor this out into zustand? and render the draw ? - useEffect(() => { - if (!stageRef.current) return; - // Update individual transformers - Object.entries(transformerRefs.current).forEach(([nodeId, transformer]) => { - if (!transformer) return; // Transformer might be null if ref was cleared - - const isSelected = selectedNodeIds.includes(nodeId); - transformer.visible(isSelected); - - if (isSelected) { - const node = stageRef.current!.findOne(`#${nodeId}`); // stageRef.current is checked - if (node) { - transformer.nodes([node]); - transformer.getLayer()?.batchDraw(); - } - } - }); - - // Update multi-select transformer - if (multiSelectTransformerRef.current && selectedNodeIds.length > 1) { - const nodes = selectedNodeIds - .map((id) => stageRef.current.findOne(`#${id}`)) - .filter(Boolean) as Konva.Node[]; - if (nodes.length > 0) { - multiSelectTransformerRef.current.nodes(nodes); - multiSelectTransformerRef.current.getLayer()?.batchDraw(); - } - } - }, [selectedNodeIds, stageRef, transformerRefs]); - - // Load the checkboard image for transparency background - useEffect(() => { - loadImageFromUrl(checkerboard).then(setCheckerImage); - }, []); - - // Smooth fade transition when base image changes - useEffect(() => { - if (!store.baseImageBitmap) return; - - const imageNode = baseImageRef.current; - if (!imageNode) return; - - // Fade out - const fadeOut = new Konva.Tween({ - node: imageNode, - opacity: 0, - duration: 0.08, - onFinish: () => { - // Fade in - const fadeIn = new Konva.Tween({ - node: imageNode, - opacity: 1, - duration: 0.15, - }); - fadeIn.play(); - }, - }); - - fadeOut.play(); - - return () => { - fadeOut.destroy(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [store.baseImageBitmap]); - - return ( - -
- - { - ctx.rect( - 0, - 0, - store.getAspectRatioDimensions().width, - store.getAspectRatioDimensions().height, - ); // leftPanelWidth, leftPanelHeight); - }} - imageSmoothingEnabled={false} // Disable image smoothing for pixel art - > - - - { - ctx.rect( - 0, - 0, - store.getAspectRatioDimensions().width, - store.getAspectRatioDimensions().height, - ); // leftPanelWidth, leftPanelHeight); - }} - listening={false} - > - - - {/* Left Panel */} - { - ctx.rect( - 0, - 0, - store.getAspectRatioDimensions().width, - store.getAspectRatioDimensions().height, - ); // leftPanelWidth, leftPanelHeight); - }} - > - {/* Render all nodes including line nodes */} - {[...nodes, ...store.lineNodes] - .sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0)) - .map((node) => { - // console.log("Rendering node:", node); - return renderNode(node); - })} - - {/* Render selection rectangle */} - {selectionRect && ( - - )} - - - - - -
- - } - right={ -
- - - - - -
- } - /> - ); -}; diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/HistoryStack.tsx b/frontend/apps/artcraft/app/src/pages/PageEdit/HistoryStack.tsx deleted file mode 100644 index 02ff3b3a5d..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/HistoryStack.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import { Button } from "@storyteller/ui-button"; -import { useImageEditCompleteEvent } from "@storyteller/tauri-events"; -import { - faClockRotateLeft, - faTrashAlt, - faTrashXmark, - faXmark, -} from "@fortawesome/pro-solid-svg-icons"; -import { Fragment, useEffect, useRef } from "react"; -import { twMerge } from "tailwind-merge"; -import { BaseSelectorImage } from "./BaseImageSelector"; -import { Tooltip } from "@storyteller/ui-tooltip"; -import { - isActionReminderOpen, - showActionReminder, -} from "@storyteller/ui-action-reminder-modal"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -export interface ImageBundle { - images: BaseSelectorImage[]; -} - -interface HistoryStackProps { - onClear: () => void; - onImageSelect?: (image: BaseSelectorImage) => void; - onImageRemove?: (image: BaseSelectorImage) => void; - imageBundles: ImageBundle[]; - onNewImageBundle?: (newBundle: ImageBundle) => void; - pendingPlaceholders?: { id: string; count: number }[]; - onResolvePending?: (id: string) => void; - selectedImageToken?: string; - blurredBackgroundUrl?: string; -} - -export const HistoryStack = ({ - onClear, - onImageSelect = () => {}, - onImageRemove = () => {}, - imageBundles, - onNewImageBundle = () => {}, - pendingPlaceholders = [], - onResolvePending = () => {}, - selectedImageToken, - blurredBackgroundUrl, -}: HistoryStackProps) => { - const handleSelectWithPreload = (image: BaseSelectorImage) => { - const preload = new Image(); - preload.crossOrigin = "anonymous"; - ( - preload as HTMLImageElement & { decoding?: "sync" | "async" | "auto" } - ).decoding = "async"; - preload.src = image.url; - const select = () => onImageSelect(image); - // Prefer decode() when available to ensure the image is fully decoded before swap - // Fallback to onload/onerror which also ensures layout is ready - const decodeFn = ( - preload as HTMLImageElement & { decode?: () => Promise } - ).decode; - if (typeof decodeFn === "function") { - decodeFn.call(preload).then(select).catch(select); - } else { - preload.onload = select; - preload.onerror = select; - } - }; - - useImageEditCompleteEvent(async (event) => { - const newBundle: ImageBundle = { - images: event.edited_images.map( - (editedImage) => - ({ - url: editedImage.cdn_url, - mediaToken: editedImage.media_token, - thumbnailUrlTemplate: editedImage.maybe_thumbnail_template, - fullImageUrl: editedImage.cdn_url, - }) as BaseSelectorImage, - ), - }; - - onNewImageBundle(newBundle); - // Resolve any matching pending placeholders using subscriber id - if (event.maybe_frontend_subscriber_id) { - onResolvePending(event.maybe_frontend_subscriber_id); - } - // Do not auto-select the new image; only scroll to top for visibility - setTimeout(() => { - scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); - }, 0); - }); - - // This is used to force image reloads in different sessions - // and prevent fetching CORS-tainted images from cache - const sessionRandBuster = useRef(Math.random()); - const scrollRef = useRef(null); - const prevPendingCountRef = useRef(0); - - const handleClear = () => { - onClear(); - }; - - const handleOnImageRemove = (baseImage: BaseSelectorImage) => { - onImageRemove(baseImage); - }; - - // Scroll to top when new pending placeholders are added (after enqueue) - useEffect(() => { - const current = pendingPlaceholders.length; - if (current > prevPendingCountRef.current) { - setTimeout(() => { - scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); - }, 0); - } - prevPendingCountRef.current = current; - }, [pendingPlaceholders.length]); - - const getImageThumbnailSource = (image: BaseSelectorImage) => { - if (!!image.thumbnailUrlTemplate) { - // NB: this is the preferred way to populate the history stack thumbnails - return image.thumbnailUrlTemplate.replace("{WIDTH}", "256"); - } - if (image.url.startsWith("data:") || image.url.startsWith("blob:")) { - return image.url; - } - console.warn("Using older image source for history stack image", image); - return `${image.url}?historystack+${sessionRandBuster.current}`; - }; - - return ( -
-
-
- -
-
- {/* Pending placeholders first (newest at top) */} - {(() => { - const reversed = [...pendingPlaceholders].slice().reverse(); - return reversed.map((p, idx) => ( - - {Array.from({ length: Math.max(1, p.count || 1) }).map( - (_, i) => ( -
-
- {blurredBackgroundUrl && ( - - )} -
-
-
- {/* SVG running border (single solid line) */} - - - -
-
- ), - )} - {idx < reversed.length - 1 && ( -
- )} - - )); - })()} - - {pendingPlaceholders.length > 0 && ( -
- )} - - {/* Completed images below placeholders, newest bundles first */} - {[...imageBundles] - .slice() - .reverse() - .map((bundle, index) => ( - - {bundle.images.map((image) => ( - - ))} - {index < imageBundles.length - 1 && ( -
- )} -
- ))} -
-
- -
-
-
- - - -
-
-
-
- ); -}; diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/LeftResetStack.tsx b/frontend/apps/artcraft/app/src/pages/PageEdit/LeftResetStack.tsx deleted file mode 100644 index b87c7d6f71..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/LeftResetStack.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from "react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faXmark, faTrashXmark } from "@fortawesome/pro-solid-svg-icons"; -import { Tooltip } from "@storyteller/ui-tooltip"; -import { - showActionReminder, - isActionReminderOpen, -} from "@storyteller/ui-action-reminder-modal"; - -interface LeftResetStackProps { - onReset: () => void; - className?: string; -} - -const LeftResetStack: React.FC = ({ - onReset, - className = "", -}) => { - return ( -
-
- - - -
-
- ); -}; - -export default LeftResetStack; diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/MarkerToolControlBar.tsx b/frontend/apps/artcraft/app/src/pages/PageEdit/MarkerToolControlBar.tsx deleted file mode 100644 index 5cb8864ac6..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/MarkerToolControlBar.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useState, useRef } from "react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faEyeDropper } from "@fortawesome/pro-solid-svg-icons"; -import { SliderV2 } from "@storyteller/ui-sliderv2"; -import { HsvaColorPicker, HsvaColor } from "react-colorful"; -import { hsvaToHex, hexToHsva } from "@uiw/color-convert"; - -export interface MarkerToolControlBarProps { - currentSize: number; - currentColor: string; - activeTool: string; - showColorPicker: boolean; - onSizeChange?: (size: number) => void; - onColorChange?: (color: string) => void; -} - -function useDebounced void, A extends unknown[]>( - fn: T, - ms = 75, -) { - const timer = useRef | null>(null); - return (...args: A) => { - if (timer.current) clearTimeout(timer.current); - timer.current = setTimeout(() => fn(...args), ms); - }; -} - -const MarkerToolControlBar = ({ - currentSize, - currentColor, - activeTool, - showColorPicker: showColorPickerProp, - onSizeChange, - onColorChange, -}: MarkerToolControlBarProps) => { - const [showColorPickerPanel, setShowColorPickerPanel] = useState(false); - const [hsva, setHsva] = useState(hexToHsva(currentColor)); - - const isEraser = activeTool === "eraser"; - const shouldShowColorPicker = showColorPickerProp && !isEraser; - const maxSize = isEraser ? 50 : 20; - - const sendColor = useDebounced<(hex: string) => void, [string]>( - (hex: string) => { - onColorChange?.(hex); - }, - 75, - ); - - return ( -
-
- {shouldShowColorPicker && ( -
- - - {showColorPickerPanel && ( -
setShowColorPickerPanel(false)} - > -
- - - { - setHsva(c); - const hex = hsvaToHex(c); - sendColor(hex); - }} - className="brush-picker" - /> -
-
- )} -
- )} - - onSizeChange?.(size)} - step={1} - innerLabel={"Size " + currentSize + "pt"} - showDecrement - showIncrement - /> -
-
- ); -}; - -export default MarkerToolControlBar; diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/PageEdit.tsx b/frontend/apps/artcraft/app/src/pages/PageEdit/PageEdit.tsx deleted file mode 100644 index 8d5c0845cc..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/PageEdit.tsx +++ /dev/null @@ -1,759 +0,0 @@ -import { useState, useRef, useEffect, useCallback } from "react"; -import Konva from "konva"; // just for types -import { - EnqueueEditImage, - EnqueueImageInpaint, - EnqueueEditImageSize, - EnqueueEditImageResolution, - EnqueueEditImageRequest, - EnqueueImageInpaintRequest, -} from "@storyteller/tauri-api"; -import { PromptsApi } from "@storyteller/api"; -import { RefImage, usePromptEditStore } from "@storyteller/ui-promptbox"; -import { ContextMenuContainer, useCopyPasteHotkeys, useDeleteHotkeys, useUndoRedoHotkeys } from "@storyteller/ui-pagedraw"; -import PromptEditor from "./PromptEditor/PromptEditor"; -import { ActiveEditTool, useEditStore } from "./stores/EditState"; -import { EditPaintSurface } from "./EditPaintSurface"; -import { normalizeCanvas } from "../../Helpers/CanvasHelpers"; -import { BaseImageSelector, BaseSelectorImage } from "./BaseImageSelector"; -import MarkerToolControlBar from "./MarkerToolControlBar"; -import { - ClassyModelSelector, - useSelectedImageModel, - useSelectedProviderForModel, - ModelPage, - IMAGE_EDITOR_PAGE_MODEL_LIST, - //ProviderSelector, - //PROVIDER_LOOKUP_BY_PAGE, -} from "@storyteller/ui-model-selector"; -import { CommonAspectRatio, ImageModel } from "@storyteller/model-list"; -import { HistoryStack, ImageBundle } from "./HistoryStack"; -import { HelpMenuButton } from "@storyteller/ui-help-menu"; -import { - CostCalculatorButton, - useCostBreakdownModalStore, -} from "@storyteller/ui-pricing-modal"; -import { GenerationProvider } from "@storyteller/api-enums"; - -const PAGE_ID: ModelPage = ModelPage.ImageEditor; - -const PageEdit = () => { - //useStateSceneLoader(); - - const selectedImageModel: ImageModel | undefined = - useSelectedImageModel(PAGE_ID); - - const selectedProvider: GenerationProvider | undefined = - useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id); - - const imageCredits = useCostBreakdownModalStore( - (s) => s.estimatedCreditsByPage[PAGE_ID], - ); - - // State for canvas dimensions - const canvasWidth = useRef(1024); - const canvasHeight = useRef(1024); - - // Add new state to track if user is selecting - const [isSelecting, setIsSelecting] = useState(false); - - // Create refs for stage and image - const stageRef = useRef({} as Konva.Stage); - const leftPanelRef = useRef({} as Konva.Layer); - const baseImageKonvaRef = useRef({} as Konva.Image); - const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({}); - const [pendingGenerations, setPendingGenerations] = useState< - { id: string; count: number }[] - >([]); - const [generationCount, setGenerationCount] = useState(1); - - // New aspect ratio state will begin to phase out old aspect ratio - const [commonAspectRatio, setCommonAspectRatio] = useState< - CommonAspectRatio | undefined - >(undefined); - - // Use the Zustand store - const store = useEditStore(); - const baseImageUrl = store.baseImageInfo?.url; - const addHistoryImageBundle = useEditStore( - (state) => state.addHistoryImageBundle, - ); - const removeHistoryImage = useEditStore((state) => state.removeHistoryImage); - const historyImageBundles = useEditStore( - (state) => state.historyImageBundles, - ); - const referenceImages = usePromptEditStore((s) => s.referenceImages); - - // Pass store actions directly as callbacks - useDeleteHotkeys({ onDelete: store.deleteSelectedItems }); - useUndoRedoHotkeys({ undo: store.undo, redo: store.redo }); - useCopyPasteHotkeys({ - onCopy: store.copySelectedItems, - onPaste: store.pasteItems, - }); - - // Listen for gallery drag and drop events - useEffect(() => { - const handleGallery2DDrop = (event: CustomEvent) => { - const { item, canvasPosition } = event.detail; - console.log("Received 2D gallery drop:", { item, canvasPosition }); - - // Get the stage to transform coordinates properly - const stage = stageRef.current; - if (!stage) { - console.error( - "Stage reference not available for coordinate transformation", - ); - return; - } - - // Transform canvas coordinates to stage coordinates - // Account for stage position, scale, and transformations - const stageX = stage.x(); - const stageY = stage.y(); - const scaleX = stage.scaleX(); - const scaleY = stage.scaleY(); - - const stagePoint = { - x: (canvasPosition.x - stageX) / scaleX, - y: (canvasPosition.y - stageY) / scaleY, - }; - - console.log("Transformed stage coordinates:", stagePoint); - - // Use the direct URL for the image - const imageUrl = item.fullImage || item.thumbnail; - if (!imageUrl) { - console.error("No image URL available for dropped item"); - return; - } - - console.log("Creating image from URL:", imageUrl); - - // Use the store's createImageFromUrl method directly - store.createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl); - - console.log( - `Created image "${item.label}" at stage position:`, - stagePoint, - ); - }; - - // Add event listener - window.addEventListener( - "gallery-2d-drop", - handleGallery2DDrop as EventListener, - ); - - // Cleanup - return () => { - window.removeEventListener( - "gallery-2d-drop", - handleGallery2DDrop as EventListener, - ); - }; - }, [store]); - - const onFitPressed = useCallback(async () => { - // Get the stage and its container dimensions - const stage = stageRef.current; - if (!stage) return; - - // Get container dimensions - const containerWidth = stage.container().offsetWidth; - const containerHeight = stage.container().offsetHeight; - - // Get canvas dimensions from store aspect ratio - const canvasW = store.getAspectRatioDimensions().width; - const canvasH = store.getAspectRatioDimensions().height; - - // Account for top toolbar and bottom prompt box - // Top toolbar (MarkerToolControlBar): ~140px from top (toolbar + padding) - // Bottom prompt box: ~200px from bottom (can vary with reference images) - // Mode selector above prompt: ~60px - const hasTopToolbar = - store.activeTool === "marker" || - store.activeTool === "eraser" || - store.activeTool === "edit"; - const topToolbarHeight = hasTopToolbar ? 140 : 80; - - // Check if model supports masking or is nano banana (has mode selector) - const hasModeSelectorUI = - selectedImageModel?.usesInpaintingMask || - selectedImageModel?.isNanoBananaModel(); - const bottomUIHeight = hasModeSelectorUI ? 260 : 200; - const totalVerticalReserved = topToolbarHeight + bottomUIHeight; - - // Calculate optimal scale to fit the canvas in the container - const paddingFactor = 0.95; // horizontal padding (left/right) - const scaleX = (containerWidth * paddingFactor) / canvasW; - const availableHeight = Math.max( - 0, - containerHeight - totalVerticalReserved, - ); - const scaleY = availableHeight / canvasH; - - // Use the smaller scale to ensure the canvas fits in both dimensions - const scale = Math.min(scaleX, scaleY); - - // Limit the scale between reasonable bounds (0.1 to 3.0) - const boundedScale = Math.max(0.1, Math.min(3.0, scale)); - - // Apply the calculated scale - stage.scale({ x: boundedScale, y: boundedScale }); - - // Calculate position to center canvas in available space (between toolbars) - const verticalOffset = (topToolbarHeight - bottomUIHeight) / 2; - stage.position({ - x: (containerWidth - canvasW * boundedScale) / 2, - y: (containerHeight - canvasH * boundedScale) / 2 + verticalOffset, - }); - }, [selectedImageModel, store]); - - // Auto-fit when the base image becomes available and stage is ready - // Also re-fit when active tool or model changes (as UI layout changes) - useEffect(() => { - if (!store.baseImageBitmap) return; - if (!stageRef.current) return; - // Defer to ensure container has laid out with correct size and toolbars have rendered - const id = requestAnimationFrame(() => { - const id2 = requestAnimationFrame(() => { - void onFitPressed(); - }); - return () => cancelAnimationFrame(id2); - }); - return () => cancelAnimationFrame(id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - store.baseImageBitmap, - stageRef.current, - store.activeTool, - selectedImageModel, - ]); - - // Re-center on window resize when an image is loaded - useEffect(() => { - let timeoutId: number | undefined; - const handleResize = () => { - if (!store.baseImageBitmap) return; - if (!stageRef.current) return; - if (timeoutId !== undefined) window.clearTimeout(timeoutId); - timeoutId = window.setTimeout(() => { - requestAnimationFrame(() => { - void onFitPressed(); - }); - }, 150); - }; - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - if (timeoutId !== undefined) window.clearTimeout(timeoutId); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [store.baseImageBitmap, stageRef.current, onFitPressed]); - - // Create a function to use the left layer ref and download the bitmap from it - const getMaskArrayBuffer = async (): Promise => { - if ( - !stageRef.current || - !leftPanelRef.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 = leftPanelRef.current; - - // Get the canvas area that's covered by the image/rectangle - const rect = baseImageKonvaRef.current; - const layerCrop = layer.toCanvas({ - x: stageRef.current.x(), - y: stageRef.current.y(), - width: rect.width() * stageRef.current.scaleX(), - height: rect.height() * stageRef.current.scaleY(), - pixelRatio: 1 / stageRef.current.scaleX(), - }); - - // Using the pixelRatio scaling may result in off-by-one rounding errors, - // So we re-fit the image to a canvas of precise size. - const fittedCanvas = normalizeCanvas( - layerCrop, - rect.width(), - rect.height(), - ); - - // Convert colored canvas to alpha mask - // NOTE: This isn't needed because the tauri backend uses the alpha channel anyway - // drawAlphaMask(fittedCanvas, rect.width(), rect.height()); - - const blob = await fittedCanvas.convertToBlob({ type: "image/png" }); - const arrayBuffer = await blob.arrayBuffer(); - - return new Uint8Array(arrayBuffer); - }; - - const getCompositeCanvasFile = async (): Promise => { - if ( - !stageRef.current || - !leftPanelRef.current || - !baseImageKonvaRef.current || - !store.baseImageBitmap - ) { - return null; - } - - const rect = baseImageKonvaRef.current; - const width = rect.width(); - const height = rect.height(); - - const canvas = new OffscreenCanvas(width, height); - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - - ctx.drawImage(store.baseImageBitmap, 0, 0, width, height); - - const markerLayerCanvas = leftPanelRef.current.toCanvas({ - x: stageRef.current.x(), - y: stageRef.current.y(), - width: rect.width() * stageRef.current.scaleX(), - height: rect.height() * stageRef.current.scaleY(), - pixelRatio: 1 / stageRef.current.scaleX(), - }); - const fittedMarkerCanvas = normalizeCanvas( - markerLayerCanvas, - width, - height, - ); - ctx.drawImage(fittedMarkerCanvas, 0, 0, width, height); - - const blob = await canvas.convertToBlob({ type: "image/png" }); - const uuid = crypto.randomUUID(); - return new File([blob], `${uuid}.png`, { type: "image/png" }); - }; - - const handleGenerate = useCallback( - async ( - prompt: string, - options?: { - aspectRatio?: string; - resolution?: string; - images?: RefImage[]; - selectedProvider?: GenerationProvider; - }, - ) => { - const editedImageToken = store.baseImageInfo?.mediaToken; - - if (!editedImageToken) { - console.error("Base image is not available"); - 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 subscriberId: string = - crypto?.randomUUID?.() ?? - `inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`; - - window.__storeTaskEnqueueMeta?.({ - prompt, - refImageUrls: (options?.images || []) - .map((img) => img.url) - .filter(Boolean), - modelType: - (selectedImageModel as any)?.tauriId || String(selectedImageModel), - timestamp: Date.now(), - }); - - try { - let result; - - if (selectedImageModel?.editingIsInpainting) { - // CASE 1 - INPAINTING (Only a few models do this!) - const arrayBuffer = await getMaskArrayBuffer(); - const request: EnqueueImageInpaintRequest = { - model: selectedImageModel, - image_media_token: editedImageToken, - mask_image_raw_bytes: arrayBuffer, - prompt: prompt, - image_count: generationCount, - frontend_caller: "image_editor", - frontend_subscriber_id: subscriberId, - }; - if (options?.selectedProvider) { - request.provider = options.selectedProvider; - } - //if (selectedImageModel?.supportsNewAspectRatio()) { - // request.common_aspect_ratio = commonAspectRatio; - //} - result = await EnqueueImageInpaint(request); - } else if (selectedImageModel?.isNanoBananaModel()) { - // CASE 2 - NANO BANANA - const compositeFile = await getCompositeCanvasFile(); - if (!compositeFile) { - console.error("Failed to create composite canvas"); - return; - } - const api = new PromptsApi(); - const snapshotResult = await api.uploadSceneSnapshot({ - screenshot: compositeFile, - }); - if (!snapshotResult.data) { - console.error("Failed to upload scene snapshot"); - return; - } - const imgs = options?.images || []; - const request: EnqueueEditImageRequest = { - model: selectedImageModel, - scene_image_media_token: snapshotResult.data, - image_media_tokens: imgs - .map((img) => img.mediaToken) - .filter((t) => t.length > 0), - prompt: prompt, - image_count: generationCount, - frontend_caller: "image_editor", - frontend_subscriber_id: subscriberId, - aspect_ratio: mapAspectRatio(options?.aspectRatio), - image_resolution: mapResolution(options?.resolution), - }; - if (options?.selectedProvider) { - request.provider = options.selectedProvider; - } - if (selectedImageModel?.supportsNewAspectRatio()) { - request.common_aspect_ratio = commonAspectRatio; - } - result = await EnqueueEditImage(request); - } else { - // CASE 3 - DEFAULT - const imgs = options?.images || []; - const request: EnqueueEditImageRequest = { - model: selectedImageModel, - image_media_tokens: [ - editedImageToken, - ...imgs - .filter((img) => img.mediaToken !== editedImageToken) - .map((img) => img.mediaToken), - ].filter((t) => t.length > 0), - disable_system_prompt: true, - prompt: prompt, - image_count: generationCount, - frontend_caller: "image_editor", - frontend_subscriber_id: subscriberId, - aspect_ratio: mapAspectRatio(options?.aspectRatio), - image_resolution: mapResolution(options?.resolution), - }; - if (options?.selectedProvider) { - request.provider = options.selectedProvider; - } - if (selectedImageModel?.supportsNewAspectRatio()) { - request.common_aspect_ratio = commonAspectRatio; - } - result = await EnqueueEditImage(request); - } - - if (result?.status === "success") { - setPendingGenerations((prev) => [ - ...prev, - { id: subscriberId as string, count: generationCount }, - ]); - } - } catch (error) { - setPendingGenerations((prev) => - prev.filter((p) => p.id !== subscriberId), - ); - throw error; - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - generationCount, - selectedImageModel, - store.baseImageInfo?.mediaToken, - referenceImages, - ], - ); - - const isNanoBananaModel = selectedImageModel?.isNanoBananaModel(); - - const supportsMaskedInpainting = - selectedImageModel?.usesInpaintingMask ?? false; - - useEffect(() => { - if (isNanoBananaModel) { - if (store.activeTool !== "marker" && store.activeTool !== "eraser") { - store.setActiveTool("marker"); - } - } else if ( - !supportsMaskedInpainting && - (store.activeTool !== "select" || store.lineNodes.length > 0) - ) { - store.setActiveTool("select"); - store.clearLineNodes(); - } else if ( - supportsMaskedInpainting && - (store.activeTool === "select" || store.activeTool === "marker") - ) { - store.setActiveTool("edit"); - } - }, [store, supportsMaskedInpainting, isNanoBananaModel]); - - /* - * - * Only component logic below this - * - * - * - * - */ - - // 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) { - return ( -
-
-
- { - addHistoryImageBundle({ images: [image] }); - store.setBaseImageInfo(image); - }} - showLoading={ - store.baseImageInfo !== null && store.baseImageInfo === null - } - /> -
-
-
- ); - } - - return ( - <> -
- {(store.activeTool === "marker" || store.activeTool === "eraser") && - isNanoBananaModel && ( -
- -
- )} - {(store.activeTool === "edit" || store.activeTool === "eraser") && - supportsMaskedInpainting && ( -
- -
- )} -
- { - store.RESET(); - setPendingGenerations([]); - }} - imageBundles={historyImageBundles} - pendingPlaceholders={pendingGenerations} - blurredBackgroundUrl={baseImageUrl} - onImageSelect={(baseImage) => { - store.clearLineNodes(); - store.setBaseImageInfo(baseImage); - }} - onImageRemove={(baseImage) => { - if ( - pendingGenerations.length === 0 && - store.historyImageBundles.length === 1 && - store.historyImageBundles[0].images.length <= 1 - ) { - store.RESET(); - } else { - removeHistoryImage(baseImage); - } - }} - onNewImageBundle={(newBundle: ImageBundle) => { - addHistoryImageBundle(newBundle); - }} - onResolvePending={(id: string) => - setPendingGenerations((prev) => prev.filter((p) => p.id !== id)) - } - selectedImageToken={store.baseImageInfo?.mediaToken} - /> -
-
- { - store.setActiveTool(mode as ActiveEditTool); - }} - selectedMode={store.activeTool} - onGenerateClick={handleGenerate} - onFitPressed={onFitPressed} - isDisabled={false} - generationCount={generationCount} - onGenerationCountChange={setGenerationCount} - supportsMaskedInpainting={supportsMaskedInpainting} - isNanoBananaModel={isNanoBananaModel} - onUndo={store.undo} - onRedo={store.redo} - credits={imageCredits} - /> -
-
- { - if (action === "contextMenu") { - const hasSelection = store.selectedNodeIds.length > 0; - if (hasSelection) { - console.log("An item is selected."); - // You can add additional actions here based on the selection - return true; - } else { - console.log("No item is selected."); - return false; - } - } - return false; - }} - onMenuAction={async (action) => { - switch (action) { - case "DUPLICATE": - store.copySelectedItems(); - store.pasteItems(); - break; - case "DELETE": - store.deleteSelectedItems(); - break; - default: - // No action needed for unhandled cases - } - }} - isLocked={store.selectedNodeIds.some((id) => { - const node = store.nodes.find((n) => n.id === id); - const lineNode = store.lineNodes.find((n) => n.id === id); - return (node?.locked || lineNode?.locked) ?? false; - })} - > - { - canvasWidth.current = width; - canvasHeight.current = height; - }} - //fillColor={store.fillColor} - activeTool={store.activeTool} - brushColor={"rgba(39, 187, 245, 0.54)"} - brushSize={store.brushSize} - markerBrushSize={store.markerBrushSize} - eraserBrushSize={store.eraserBrushSize} - markerColor={store.markerColor} - onSelectionChange={setIsSelecting} - stageRef={stageRef} - transformerRefs={transformerRefs} - leftPanelRef={leftPanelRef} - baseImageRef={baseImageKonvaRef} - /> - -
-
- -
-
- - -
- - ); -}; - -export default PageEdit; diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/PromptEditor/PromptEditor.tsx b/frontend/apps/artcraft/app/src/pages/PageEdit/PromptEditor/PromptEditor.tsx deleted file mode 100644 index ef1f998bbb..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/PromptEditor/PromptEditor.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; -import { PromptBoxEdit, PromptBoxEditProps } from "@storyteller/ui-promptbox"; -import { uploadImage } from "../../../components/reusable/UploadModalMedia/uploadImage"; - -const PromptEditor: React.FC = (props) => { - return ( -
- -
- ); -}; - -export default React.memo(PromptEditor); diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/PromptEditor/types.ts b/frontend/apps/artcraft/app/src/pages/PageEdit/PromptEditor/types.ts deleted file mode 100644 index 6eddd8d70f..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/PromptEditor/types.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type AspectRatio = '1:1' | '3:2' | '2:3'; - -export interface ImageStyle { - id: string; - url: string; - weight: number; -} - -export interface PromptTextAreaProps { - value: string; - onChange: (value: string) => void; - images: ImageStyle[]; - onImageUpdate: (id: string, updates: Partial) => void; - onImageRemove: (id: string) => void; -} - -export interface AIStrengthSliderProps { - value: number; - onChange: (value: number) => void; - width?: string | number; - height?: string | number; - inset?: boolean; - className?: string; -} - -export interface ControlButtonsProps { - aspectRatio: AspectRatio; - onAspectRatioChange: (ratio: AspectRatio) => void; - onStyleClick: () => void; - onImageStyleClick: () => void; - onRandomizeClick: () => void; - onVaryClick: () => void; -} - -export interface ImageStyleSelectorProps { - onImageSelect: (image: File) => void; -} - -export interface ImagePreviewProps { - image: ImageStyle; - onUpdate: (id: string, updates: Partial) => void; - onRemove: (id: string) => void; -} diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/stores/EditState.ts b/frontend/apps/artcraft/app/src/pages/PageEdit/stores/EditState.ts deleted file mode 100644 index a59855cdac..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/stores/EditState.ts +++ /dev/null @@ -1,1062 +0,0 @@ -import { create } from "zustand"; -import { LineNode, generateId, Node, type BaseSelectorImage, type ImageBundle } from "@storyteller/ui-pagedraw"; -import { FetchProxy } from "@storyteller/tauri-utils"; - -export type ActiveEditTool = "select" | "edit" | "expand" | "marker" | "eraser"; -export type EditOperation = "add" | "minus"; - -interface EditState { - // Nodes - nodes: Node[]; - selectedNodeIds: string[]; - lineNodes: LineNode[]; // Add lineNodes to state - - // Clipboard - clipboard: (Node | LineNode)[]; // To store copied items - - // Toolbar state - activeTool: ActiveEditTool; - editOperation: EditOperation; - brushColor: string; - brushSize: number; - markerBrushSize: number; - eraserBrushSize: number; - brushOpacity: number; - fillColor: string; - markerColor: string; - - // Cursor state - cursorPosition: { x: number; y: number } | null; - cursorVisible: boolean; - - // Base image state - baseImageInfo: BaseSelectorImage | null; - baseImageBitmap: HTMLImageElement | null; - setBaseImageInfo: (image: BaseSelectorImage | null) => void; - - // Actions - addNode: (node: Node) => void; - removeNode: (id: string, shouldSaveState?: boolean) => void; - updateNode: ( - id: string, - updates: Partial, - shouldSaveState: boolean, - ) => void; - selectNode: (id: string | null, isMultiSelect?: boolean) => void; - moveNode: ( - id: string, - x: number, - y: number, - dx?: number, - dy?: number, - shouldSaveState?: boolean, - ) => void; - - // Batch operations - setNodes: (nodes: Node[]) => void; - - // History management - history: { nodes: Node[]; lineNodes: LineNode[] }[]; - historyIndex: number; - undo: () => void; - redo: () => void; - saveState: () => void; - - // Add new actions for line nodes - addLineNode: (lineNode: LineNode, shouldSaveState: boolean) => void; - removeLineNode: (id: string, shouldSaveState?: boolean) => void; - updateLineNode: ( - id: string, - updates: Partial, - shouldSaveState: boolean, - ) => void; - moveLineNode: (id: string, dx: number, dy: number) => void; - clearLineNodes: () => void; - - // Add a specific method for file uploads - createImageFromFile: ( - x: number, - y: number, - file: File, - width?: number, - height?: number, - ) => void; - - // Add method for URL-based images - createImageFromUrl: ( - x: number, - y: number, - url: string, - width?: number, - height?: number, - ) => void; - - // Action for deleting selected items - deleteSelectedItems: () => void; - - // Clipboard actions - copySelectedItems: () => void; - pasteItems: () => void; - - // Toolbar actions - setActiveTool: (tool: ActiveEditTool) => void; - setEditOperation: (mode: EditOperation) => void; - setBrushColor: (color: string) => void; - setBrushOpacity: (opacity: number) => void; - setBrushSize: (size: number) => void; - setMarkerBrushSize: (size: number) => void; - setEraserBrushSize: (size: number) => void; - setFillColor: (color: string) => void; - setMarkerColor: (color: string) => void; - - // Cursor actions - setCursorPosition: (position: { x: number; y: number } | null) => void; - setCursorVisible: (visible: boolean) => void; - - // Scene save/load actions - exportSceneAsJson: () => Promise; - importSceneFromJson: (jsonString: string) => boolean; - saveSceneToFile: () => Promise; - loadSceneFromFile: (file: File) => Promise; - - // Add these two properties - serializeSceneToString: () => Promise; - loadSceneFromString: (jsonString: string) => boolean; - - // Add aspect ratio action - getAspectRatioDimensions: () => { width: number; height: number }; - - // History stack operations - // pushHistory: () => void; - // clearHistory: () => void; - RESET: () => void; - - // For the history stack - historyImageBundles: ImageBundle[]; - clearHistoryImages: () => void; - addHistoryImageBundle: (bundle: ImageBundle) => void; - removeHistoryImage: (image: BaseSelectorImage) => void; -} - -// Add a flag to prevent saving state during restoration -let isRestoring = false; - -// Add this helper function at the top of the store (before create) -const getNextZIndex = (nodes: Node[], lineNodes: LineNode[]): number => { - const allZIndices = [ - ...nodes.map((n, index) => { - //console.log(`Node: ${JSON.stringify(n)}, Index: ${index}`); - return n.zIndex || 0; - }), - ...lineNodes.map((n, index) => { - //console.log(`LineNode: ${JSON.stringify(n)}, Index: ${index}`); - return n.zIndex || 0; - }), - ]; - - return allZIndices.length > 0 ? Math.max(...allZIndices) + 1 : 0; -}; - -export const useEditStore = create((set, get, store) => ({ - // Initial state - nodes: [], - lineNodes: [], - selectedNodeIds: [], - clipboard: [], // Initialize clipboard - history: [], - historyIndex: -1, - - // Toolbar initial state - activeTool: "edit", // Default to 'edit' tool - editOperation: "add", // Default to 'add' operation - brushColor: "#000000", - brushSize: 30, - markerBrushSize: 5, - eraserBrushSize: 25, - brushOpacity: 1, - fillColor: "rgba(0, 0, 255, 0.5)", - markerColor: "#FF0000", - - // Cursor initial state - cursorPosition: null, - cursorVisible: false, - - // Base image initial state - baseImageInfo: null, - baseImageBitmap: null, - - // History stack state - historyImageBundles: [], - - // Actions - addNode: (node: Node) => { - set((state) => { - const nextZ = getNextZIndex(state.nodes, state.lineNodes); - console.log(`NextZ for node ID ${node.id}: ${nextZ}`); - const newNode = new Node({ - ...node, - zIndex: nextZ, - }); - // console.log("New Node with ID:", newNode.id); - const nodes = [...state.nodes, newNode]; - console.log("Nodes after update"); - console.log(nodes); - return { nodes: nodes }; - }); - get().saveState(); - }, - - removeNode: (id: string, shouldSaveState: boolean = true) => { - set((state) => { - // Remove the node - const newNodes = state.nodes.filter((node) => node.id !== id); - - // Update selection state - const newSelectedIds = state.selectedNodeIds.filter( - (nodeId) => nodeId !== id, - ); - - return { - nodes: newNodes, - selectedNodeIds: newSelectedIds, - }; - }); - if (shouldSaveState) { - get().saveState(); - } - }, - - updateNode: ( - id: string, - updates: Partial, - shouldSaveState: boolean = true, - ) => { - set((state) => { - const newNodes = state.nodes.map((node) => { - const newZIndex = - updates.zIndex !== undefined ? updates.zIndex : node.zIndex; - - if (node.id === id) { - return new Node({ - ...node, - ...updates, - zIndex: newZIndex, - }); - } - return node; - }); - return { nodes: newNodes }; - }); - if (shouldSaveState && !isRestoring) { - get().saveState(); - } - }, - - selectNode: (id: string | null, isMultiSelect = false) => { - set((state) => { - if (!id) { - return { selectedNodeIds: [] }; - } - - if (isMultiSelect) { - // Toggle selection if already selected - if (state.selectedNodeIds.includes(id)) { - return { - selectedNodeIds: state.selectedNodeIds.filter( - (nodeId) => nodeId !== id, - ), - }; - } - // Add to selection - return { - selectedNodeIds: [...state.selectedNodeIds, id], - }; - } - - // Single select - return { selectedNodeIds: [id] }; - }); - }, - - moveNode: ( - id: string, - x: number, - y: number, - dx?: number, - dy?: number, - shouldSaveState: boolean = false, - ) => { - set((state) => { - // Handle regular nodes - const newNodes = state.nodes.map((node) => { - if (node.id === id) { - node.setPosition(x, y); - return node; - } - if ( - state.selectedNodeIds.includes(node.id) && - dx !== undefined && - dy !== undefined - ) { - node.setPosition(node.x + dx, node.y + dy); - return node; - } - return node; - }); - - const newState = { - nodes: newNodes, - }; - - // Only save state if explicitly requested - if (shouldSaveState) { - get().saveState(); - } - - return newState; - }); - }, - - setNodes: (nodes: Node[]) => { - set({ nodes }); - get().saveState(); - }, - - // History management - saveState: () => { - // Don't save state if we're in the middle of restoring - if (isRestoring) return; - - set((state) => { - // Create a deep copy but exclude non-serializable properties - const serializableState = { - nodes: state.nodes.map((node) => ({ - id: node.id, - x: node.x, - y: node.y, - width: node.width, - height: node.height, - fill: node.fill, - type: node.type, - stroke: node.stroke, - strokeWidth: node.strokeWidth, - draggable: node.draggable, - imageUrl: node.imageUrl, - imageFile: node.imageFile, - backgroundColor: node.backgroundColor, - rotation: node.rotation || 0, - scaleX: node.scaleX || 1, - scaleY: node.scaleY || 1, - offsetX: node.offsetX || 0, // Include offset in history - offsetY: node.offsetY || 0, // Include offset in history - })), - lineNodes: JSON.parse(JSON.stringify(state.lineNodes)), - }; - - const newHistory = state.history.slice(0, state.historyIndex + 1); - newHistory.push(serializableState); - - return { - history: newHistory, - historyIndex: newHistory.length - 1, - }; - }); - }, - - undo: () => { - set((state) => { - if (state.historyIndex < 0) return state; - - const newIndex = state.historyIndex - 1; - - // If we're going back to before the first saved state, return to initial empty state - if (newIndex < 0) { - return { - nodes: [], - lineNodes: [], - selectedNodeIds: [], - historyIndex: newIndex, - }; - } - - const previousState = state.history[newIndex]; - - // Set the restoring flag - isRestoring = true; - - // Recreate nodes and immediately start loading images - const restoredNodes = previousState.nodes.map((nodeData) => { - const node = new Node(nodeData as any); - - // If it's an image node, start loading the image immediately - if (node.type === "image" && (node.imageUrl || node.imageFile)) { - const loadImage = async () => { - try { - if (node.imageUrl) { - await node.setImageFromUrl(node.imageUrl); - } else if (node.imageFile) { - await node.setImageFromFile(node.imageFile); - } - // Update the specific node without triggering state save - get().updateNode(node.id, node, false); - } catch (error) { - console.error("Failed to restore image:", error); - } finally { - // Reset the flag after a delay to ensure all async operations complete - setTimeout(() => { - isRestoring = false; - }, 100); - } - }; - - loadImage(); - } - - return node; - }); - - // Reset the flag if no images to load - const hasImages = restoredNodes.some((node) => node.type === "image"); - if (!hasImages) { - isRestoring = false; - } - - return { - nodes: restoredNodes, - lineNodes: previousState.lineNodes, - selectedNodeIds: [], // Clear selection on undo - historyIndex: newIndex, - }; - }); - }, - - redo: () => { - set((state) => { - if (state.historyIndex >= state.history.length - 1) return state; - - const newIndex = state.historyIndex + 1; - const nextState = state.history[newIndex]; - - // Set the restoring flag - isRestoring = true; - - // Recreate nodes and immediately start loading images - const restoredNodes = nextState.nodes.map((nodeData) => { - const node = new Node(nodeData as any); - - // If it's an image node, start loading the image immediately - if (node.type === "image" && (node.imageUrl || node.imageFile)) { - const loadImage = async () => { - try { - if (node.imageUrl) { - await node.setImageFromUrl(node.imageUrl); - } else if (node.imageFile) { - await node.setImageFromFile(node.imageFile); - } - // Update the specific node without triggering state save - get().updateNode(node.id, node, false); - } catch (error) { - console.error("Failed to restore image:", error); - } finally { - // Reset the flag after a delay to ensure all async operations complete - setTimeout(() => { - isRestoring = false; - }, 100); - } - }; - - loadImage(); - } - - return node; - }); - - // Reset the flag if no images to load - const hasImages = restoredNodes.some((node) => node.type === "image"); - if (!hasImages) { - isRestoring = false; - } - - return { - nodes: restoredNodes, - lineNodes: nextState.lineNodes, - selectedNodeIds: [], // Clear selection on redo - historyIndex: newIndex, - }; - }); - }, - - // Add new line node actions - addLineNode: (lineNode: LineNode, shouldSaveState: boolean = true) => { - set((state) => { - const nextZ = getNextZIndex(state.nodes, state.lineNodes); - const newLineNode = { - ...lineNode, - zIndex: nextZ, - }; - return { lineNodes: [...state.lineNodes, newLineNode] }; - }); - if (shouldSaveState) { - get().saveState(); - } - }, - - removeLineNode: (id: string, shouldSaveState: boolean = true) => { - set((state) => { - // Remove the line node - const newLineNodes = state.lineNodes.filter((node) => node.id !== id); - - // Update selection state - const newSelectedIds = state.selectedNodeIds.filter( - (nodeId) => nodeId !== id, - ); - - return { - lineNodes: newLineNodes, - selectedNodeIds: newSelectedIds, - }; - }); - if (shouldSaveState) { - get().saveState(); - } - }, - - updateLineNode: ( - id: string, - updates: Partial, - shouldSaveState: boolean = true, - ) => { - set((state) => { - const newLineNodes = state.lineNodes.map((node) => { - if (node.id === id) { - return { - ...node, - ...updates, - zIndex: updates.zIndex !== undefined ? updates.zIndex : node.zIndex, - }; - } - return node; - }); - return { lineNodes: newLineNodes }; - }); - if (shouldSaveState) { - get().saveState(); - } - }, - - clearLineNodes() { - set({ lineNodes: [] }); - }, - - moveLineNode: (id: string, dx: number, dy: number) => { - set((state) => { - const newLineNodes = state.lineNodes.map((node) => { - if (node.id === id) { - return { - ...node, - points: node.points.map((point, index) => { - // Even indices are x coordinates, odd are y - return index % 2 === 0 ? point + dx : point + dy; - }), - }; - } - return node; - }); - return { lineNodes: newLineNodes }; - }); - get().saveState(); - }, - - // Add a specific method for file uploads - createImageFromFile: ( - x: number, - y: number, - file: File, - width?: number, - height?: number, - ) => { - // Auto-detect dimensions from the image if not provided - const reader = new FileReader(); - reader.onload = (event) => { - const dataUrl = event.target?.result as string; - if (dataUrl) { - const img = new Image(); - img.onload = () => { - const aspectRatio = img.naturalWidth / img.naturalHeight; - // Increase default size to 512px width while maintaining aspect ratio - const finalWidth = width || Math.min(img.naturalWidth, 512); - const finalHeight = height || finalWidth / aspectRatio; - console.log("Image loaded with dimensions:", { - naturalWidth: img.naturalWidth, - naturalHeight: img.naturalHeight, - finalWidth: finalWidth, - finalHeight: finalHeight, - }); - get().createImage(x, y, file, finalWidth, finalHeight); - }; - img.src = dataUrl; - } - }; - reader.readAsDataURL(file); - }, - - // Add method for URL-based images - createImageFromUrl: async ( - x: number, - y: number, - url: string, - width?: number, - height?: number, - ) => { - try { - // Fetch the image and convert to blob - const response = await fetch(url); - const blob = await response.blob(); - - // Create a File from the blob - const filename = url.split("/").pop() || "image.png"; - const file = new File([blob], filename, { type: blob.type }); - - // Use existing createImage function - get().createImageFromFile(x, y, file, width, height); - } catch (error) { - console.error("Error loading image from URL:", url, error); - } - }, - - setBaseImageInfo: (image: BaseSelectorImage | null) => { - set({ baseImageInfo: image }); - - if (!image) { - return; - } - - // Load the image bitmap as well - const imgBitmap = new Image(); - imgBitmap.onload = () => { - set({ baseImageBitmap: imgBitmap }); - }; - imgBitmap.onerror = (event) => { - console.error("Failed to load base image, discarding", event); - set({ baseImageInfo: null, baseImageBitmap: null }); - imgBitmap.onload = null; - imgBitmap.onerror = null; - }; - imgBitmap.crossOrigin = "anonymous"; - const isDataUrl = - typeof image.url === "string" && image.url.startsWith("data:"); - imgBitmap.src = isDataUrl ? image.url : image.url + "?basecanvasimg"; - }, - - // Action for deleting selected items - deleteSelectedItems: () => { - const initialSelectedIds = [...get().selectedNodeIds]; // Operate on a copy of the original selection - - if (initialSelectedIds.length > 0) { - initialSelectedIds.forEach((id) => { - get().removeNode(id, false); // Pass shouldSaveState = false - get().removeLineNode(id, false); // Pass shouldSaveState = false - }); - - // After all deletions, ensure selectedNodeIds is empty in the state. - set((state) => ({ - ...state, // Preserve other state properties - selectedNodeIds: [], - })); - - get().saveState(); // Save the final state once - } - }, - - // Clipboard actions - copySelectedItems: () => { - set((state) => { - const selectedNodes = state.nodes.filter((node) => - state.selectedNodeIds.includes(node.id), - ); - const selectedLineNodes = state.lineNodes.filter((lineNode) => - state.selectedNodeIds.includes(lineNode.id), - ); - - // Deep copy nodes. For Node instances, we create new instances. - const copiedNodes = selectedNodes.map((node) => { - // Create a new Node instance with the properties of the old one. - // This ensures methods are available if needed. - // For imageFile (File object), the reference is copied. The new Node - // instance will need to handle loading this File object again if necessary. - const nodeData = { ...node }; // Shallow copy properties first - // If Node class has complex internal state not covered by spread, adjust accordingly - return new Node(nodeData); - }); - - // Deep copy line nodes (plain objects) - const copiedLineNodes = JSON.parse(JSON.stringify(selectedLineNodes)); - - return { clipboard: [...copiedNodes, ...copiedLineNodes] }; - }); - }, - - pasteItems: () => { - const { clipboard } = get(); - if (clipboard.length === 0) return; - - const newPastedItemIds: string[] = []; - const offset = 20; // Offset for pasted items - - const nodesToAdd: Node[] = []; - const lineNodesToAdd: LineNode[] = []; - - clipboard.forEach((item) => { - const newId = generateId(); - newPastedItemIds.push(newId); - - if (item instanceof Node) { - // It's a regular Node (shape or image) - // Create a new Node instance for the pasted item - const pastedNodeData = { - ...(item as Node), // Spread existing properties - id: newId, - x: (item as Node).x + offset, - y: (item as Node).y + offset, - // imageFile and imageUrl are carried over from 'item' - // The new Node instance's constructor or setImage method will handle loading - }; - const newNodeInstance = new Node(pastedNodeData); - - // If it's an image, and its dimensions might change upon load, - // we need to ensure the store is updated after the image is loaded. - // The Node constructor itself might call setImage. - // Similar to createImage, an updateNode call after image load is robust. - if ( - newNodeInstance.type === "image" && - (newNodeInstance.imageUrl || newNodeInstance.imageFile) - ) { - const source = newNodeInstance.imageUrl || newNodeInstance.imageFile; - if (source) { - newNodeInstance - .setImage(source) - .then(() => { - if (get().nodes.find((n) => n.id === newId)) { - const { - id, - x, - y, - width, - height, - fill, - stroke, - strokeWidth, - draggable, - imageUrl, - imageFile, - rotation, - scaleX, - scaleY, - offsetX, - offsetY, - } = newNodeInstance; - get().updateNode( - id, - { - x, - y, - width, - height, - fill, - stroke, - strokeWidth, - draggable, - imageUrl, - imageFile, - rotation, - scaleX, - scaleY, - offsetX, - offsetY, - }, - false, - ); - } - }) - .catch((error) => { - console.error( - `Failed to load image for pasted node ${newId}:`, - error, - ); - }); - } - } - nodesToAdd.push(newNodeInstance); - } else if ( - item && - typeof item === "object" && - "points" in item && - "type" in item && - item.type === "line" - ) { - // It's a LineNode (duck-typing for safety) - const lineNode = item as LineNode; - const pastedLineNode: LineNode = { - ...lineNode, - id: newId, - points: lineNode.points.map((point, index) => { - return index % 2 === 0 ? point + offset : point + offset; // Offset x and y - }), - // Ensure optional properties are handled: - // If x,y were part of LineNode for positioning the whole line, offset them too. - // The current LineNode type doesn't enforce top-level x,y for the line itself for dragging, - // but if it did, they'd be offset here. - // The points themselves define the line's position. - x: lineNode.x !== undefined ? lineNode.x + offset : undefined, - y: lineNode.y !== undefined ? lineNode.y + offset : undefined, - }; - lineNodesToAdd.push(pastedLineNode); - } - }); - - set((state) => { - const newNodes = [...state.nodes, ...nodesToAdd]; - const newLineNodes = [...state.lineNodes, ...lineNodesToAdd]; - return { - nodes: newNodes, - lineNodes: newLineNodes, - selectedNodeIds: newPastedItemIds, // Select the newly pasted items - }; - }); - - get().saveState(); - }, - - // Toolbar actions - setActiveTool: (tool: ActiveEditTool) => { - // Avoid triggering state change if the tool is same - if (tool === get().activeTool) { - return; - } - - set({ activeTool: tool }); - }, - setEditOperation: (mode: EditOperation) => set({ editOperation: mode }), - setBrushColor: (color: string) => set({ brushColor: color }), - setBrushSize: (size: number) => set({ brushSize: size }), - setMarkerBrushSize: (size: number) => set({ markerBrushSize: size }), - setEraserBrushSize: (size: number) => set({ eraserBrushSize: size }), - setFillColor: (color: string) => set({ fillColor: color }), - setBrushOpacity: (opacity: number) => set({ brushOpacity: opacity }), - setMarkerColor: (color: string) => set({ markerColor: color }), - - // Cursor actions - setCursorPosition: (position: { x: number; y: number } | null) => - set({ cursorPosition: position }), - setCursorVisible: (visible: boolean) => set({ cursorVisible: visible }), - - // Scene save/load actions - exportSceneAsJson: async () => { - const state = get(); - - // Helper function to convert File to base64 - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); - }; - - // Process nodes and convert File objects to base64 - const processedNodes = await Promise.all( - state.nodes.map(async (node) => { - const nodeData = { - id: node.id, - x: node.x, - y: node.y, - width: node.width, - height: node.height, - fill: node.fill, - type: node.type, - stroke: node.stroke, - strokeWidth: node.strokeWidth, - draggable: node.draggable, - imageUrl: node.imageUrl, - backgroundColor: node.backgroundColor, - rotation: node.rotation || 0, - scaleX: node.scaleX || 1, - scaleY: node.scaleY || 1, - offsetX: node.offsetX || 0, - offsetY: node.offsetY || 0, - }; - - // Convert imageFile to base64 if it exists - if (node.imageFile && node.imageFile instanceof File) { - try { - const base64 = await fileToBase64(node.imageFile); - return { ...nodeData, imageDataUrl: base64 }; - } catch (error) { - console.error("Failed to convert image file to base64:", error); - } - } - - return nodeData; - }), - ); - - const sceneData = { - nodes: processedNodes, - lineNodes: JSON.parse(JSON.stringify(state.lineNodes)), - brushColor: state.brushColor, - brushSize: state.brushSize, - fillColor: state.fillColor, - version: "1.0", - }; - - return JSON.stringify(sceneData, null, 2); - }, - - importSceneFromJson: (jsonString: string) => { - try { - const sceneData = JSON.parse(jsonString); - - isRestoring = true; - - // Helper function to convert base64 to File - const base64ToFile = (base64: string, filename: string): File => { - const arr = base64.split(","); - const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png"; - const bstr = atob(arr[1]); - let n = bstr.length; - const u8arr = new Uint8Array(n); - while (n--) { - u8arr[n] = bstr.charCodeAt(n); - } - return new File([u8arr], filename, { type: mime }); - }; - - // Recreate nodes - const restoredNodes = sceneData.nodes.map((nodeData: any) => { - const node = new Node(nodeData); - - // Handle image restoration - if (node.type === "image") { - const loadImage = async () => { - try { - if (nodeData.imageDataUrl) { - // Restore from base64 data URL - const file = base64ToFile( - nodeData.imageDataUrl, - `restored-image-${node.id}.png`, - ); - await node.setImageFromFile(file); - } else if (node.imageUrl) { - // Restore from URL - await node.setImageFromUrl(node.imageUrl); - } - get().updateNode(node.id, node, false); - } catch (error) { - console.error("Failed to restore image:", error); - } - }; - loadImage(); - } - - return node; - }); - - set({ - nodes: restoredNodes, - lineNodes: sceneData.lineNodes || [], - selectedNodeIds: [], - brushColor: sceneData.brushColor || "#000000", - brushSize: sceneData.brushSize || 5, - fillColor: sceneData.fillColor || "white", - }); - - isRestoring = false; - get().saveState(); // Save the loaded state to history - - return true; - } catch (error) { - console.error("Failed to import scene:", error); - isRestoring = false; - return false; - } - }, - - saveSceneToFile: async () => { - const jsonString = await get().exportSceneAsJson(); - const blob = new Blob([jsonString], { type: "application/json" }); - const url = URL.createObjectURL(blob); - - const link = document.createElement("a"); - link.href = url; - link.download = `mirai-scene-${new Date().toISOString().slice(0, 10)}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - URL.revokeObjectURL(url); - }, - - loadSceneFromFile: async (file: File) => { - try { - const text = await file.text(); - return get().importSceneFromJson(text); - } catch (error) { - console.error("Failed to load scene from file:", error); - return false; - } - }, - - serializeSceneToString: async (): Promise => { - return get().exportSceneAsJson(); - }, - loadSceneFromString: (jsonString: string): boolean => { - return get().importSceneFromJson(jsonString); - }, - - // Add aspect ratio actions - getAspectRatioDimensions: () => { - // First we check the base image info if it exists - // Otherwise we use default landscape - const baseImageInfo = get().baseImageBitmap; - - if (!baseImageInfo) { - return { width: 1024, height: 683 }; // Default to landscape - } - - // If base image exists, use its dimensions - return { width: baseImageInfo.width, height: baseImageInfo.height }; - }, - - RESET: () => { - set(store.getInitialState()); - }, - - clearHistoryImages: () => { - set({ historyImageBundles: [] }); - }, - - addHistoryImageBundle(bundle) { - set((state) => ({ - historyImageBundles: [...state.historyImageBundles, bundle], - })); - }, - - removeHistoryImage(image) { - console.log("Removing history image:", image); - set((state) => { - const updatedBundles = state.historyImageBundles - .map((bundle) => { - return { - ...bundle, - images: bundle.images.filter( - (img) => img.mediaToken !== image.mediaToken, - ), - }; - }) - .filter((bundle) => bundle.images.length > 0); // Remove empty bundles - console.log("Updated history image bundles:", updatedBundles); - return { historyImageBundles: updatedBundles }; - }); - }, -})); diff --git a/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx b/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx index fe93acdc3c..83c96caae0 100644 --- a/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx @@ -86,7 +86,6 @@ import { import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal"; import PageDraw from "../PageDraw/PageDraw"; import { useTabStore } from "../Stores/TabState"; -import PageEdit from "../PageEdit/PageEdit"; import { ImageModel } from "@storyteller/model-list"; import { HelpMenuButton } from "@storyteller/ui-help-menu"; import { diff --git a/frontend/libs/components/pagedraw/src/index.ts b/frontend/libs/components/pagedraw/src/index.ts index ec86279d9f..a14fdd7645 100644 --- a/frontend/libs/components/pagedraw/src/index.ts +++ b/frontend/libs/components/pagedraw/src/index.ts @@ -3,6 +3,7 @@ // Main component export { default as PageDraw } from "./lib/PageDraw"; export { BlankCanvasModal } from "./lib/BlankCanvasModal"; +export { HistoryStack } from "./lib/HistoryStack"; // Store export { useSceneStore, generateId } from "./lib/stores/SceneState";