Compare commits

...
4 changed files with 432 additions and 581 deletions
@@ -1,41 +1,30 @@
import { useState, useRef, useEffect } from "react";
import { PaintSurface } from "./PaintSurface";
// https://github.com/SaladTechnologies/comfyui-api
import "./App.css";
import PromptEditor from "./PromptEditor/PromptEditor";
import SideToolbar from "./components/ui/SideToolbar";
// Import the Zustand store
import { AspectRatioType, useSceneStore } from "./stores/SceneState";
import { useUndoRedoHotkeys } from "./hooks/useUndoRedoHotkeys";
import { useDeleteHotkeys } from "./hooks/useDeleteHotkeys";
import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys"; // Import the hook
import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys";
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock, faImage } from "@fortawesome/pro-solid-svg-icons";
import Konva from "konva"; // just for types
import Konva from "konva";
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap";
import { captureStageImageBitmap } from "./hooks/useUpdateSnapshot";
import { ContextMenuContainer } from "./components/ui/ContextMenu";
import { FalBackgroundRemoval } from "@storyteller/tauri-api";
/**
* This decodes Base64 encoded PNG images into an image element.
* @param base64String a standard (not "web safe") base64-encoded string
*/
export const DecodeBase64ToImage = async (
base64String: string,
): Promise<ImageBitmap> => {
// Create an image element
const img = document.createElement("img");
// Convert base64 to data URL if it doesn't include the prefix
const dataUrl = base64String.startsWith("data:")
? base64String
: `data:image/png;base64,${base64String}`;
// Create a promise to handle the image loading
return new Promise((resolve, reject) => {
img.onload = async () => {
try {
@@ -48,28 +37,19 @@ export const DecodeBase64ToImage = async (
img.onerror = () => reject(new Error("Failed to load image"));
// Set the source to trigger loading
img.src = dataUrl;
});
};
const PageDraw = () => {
//useStateSceneLoader();
// State for canvas dimensions
const canvasWidth = useRef<number>(1024);
const canvasHeight = useRef<number>(1024);
// Add new state to track if user is selecting
const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("GPT-4o");
// Create refs for stage and image
const stageRef = useRef<Konva.Stage>({} as Konva.Stage);
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
// Use the Zustand store
const store = useSceneStore();
// Pass store actions directly as callbacks
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
useUndoRedoHotkeys({ undo: store.undo, redo: store.redo });
useCopyPasteHotkeys({
@@ -83,7 +63,6 @@ const PageDraw = () => {
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(
@@ -91,7 +70,6 @@ const PageDraw = () => {
);
return;
}
// Transform canvas coordinates to stage coordinates
// Account for stage position, scale, and transformations
const stageX = stage.x();
@@ -106,7 +84,6 @@ const PageDraw = () => {
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");
@@ -115,7 +92,6 @@ const PageDraw = () => {
console.log("Creating image from URL:", imageUrl);
// Use the store's createImageFromUrl method directly
store.createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
console.log(
@@ -124,13 +100,11 @@ const PageDraw = () => {
);
};
// Add event listener
window.addEventListener(
"gallery-2d-drop",
handleGallery2DDrop as EventListener,
);
// Cleanup
return () => {
window.removeEventListener(
"gallery-2d-drop",
@@ -139,27 +113,34 @@ const PageDraw = () => {
};
}, [store]);
const handleImageUpload = (files: File[]): void => {
// Place images at center of viewport with offset for multiple images
const centerX = 512; // leftPanelWidth / 2
const centerY = 512; // leftPanelHeight / 2
const handleImageUpload = async (files: File[]): Promise<void> => {
// Determine current canvas dimensions from the store (according to aspect-ratio)
const { width: canvasW, height: canvasH } =
store.getAspectRatioDimensions();
console.log("Image upload started with files:", files);
console.log("Center coordinates:", { centerX, centerY });
// Target maximum size – 85 % of the canvas in each direction
const maxW = canvasW * 0.85;
const maxH = canvasH * 0.85;
files.forEach((file, index) => {
console.log(`Processing file ${index}:`, file.name);
for (const file of files) {
// Pre-load the image to get its intrinsic dimensions
const img = new Image();
img.onload = () => {
const { naturalWidth, naturalHeight } = img;
store.createImageFromFile(
centerX + index * 60, // Offset each image
centerY + index * 60,
file,
);
console.log(`Created image at position:`, {
x: centerX + index * 60,
y: centerY + index * 60,
});
});
// Compute scale to fit within the frame while preserving aspect-ratio
const scale = Math.min(maxW / naturalWidth, maxH / naturalHeight, 1);
const finalW = naturalWidth * scale;
const finalH = naturalHeight * scale;
// Center the image in the canvas
const x = (canvasW - finalW) / 2;
const y = (canvasH - finalH) / 2;
store.createImageFromFile(x, y, file, finalW, finalH);
};
img.src = URL.createObjectURL(file);
}
};
const modelList: PopoverItem[] = [
@@ -237,12 +218,11 @@ const PageDraw = () => {
useEffect(() => {
const autoFitCanvas = async () => {
let attempts = 0;
const maxAttempts = 20; // Increased attempts
const maxAttempts = 20;
const tryFit = async () => {
const stage = stageRef.current;
if (stage && stage.container && stage.container().offsetWidth > 0) {
// Wait a bit more to ensure everything is rendered
await new Promise((resolve) => setTimeout(resolve, 50));
onFitPressed();
return true;
@@ -260,7 +240,8 @@ const PageDraw = () => {
};
autoFitCanvas();
}, []); // Run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
@@ -273,19 +254,14 @@ const PageDraw = () => {
initialPrompt=""
onPromptChange={(prompt: string) => {
console.log("Prompt changed:", prompt);
// Handle prompt changes here
}}
onRandomize={() => {
console.log("Randomize clicked");
// Handle randomize action here
}}
onVary={() => {
console.log("Vary clicked");
// Handle vary action here
}}
onAspectRatioChange={async (ratio: string) => {
console.log("Aspect ratio:", ratio);
// Convert ratio string to AspectRatioType enum
const ratioToType = (ratio: string): AspectRatioType => {
switch (ratio) {
case "2:3":
@@ -302,7 +278,6 @@ const PageDraw = () => {
const aspectRatioType = ratioToType(ratio);
store.setAspectRatioType(aspectRatioType);
// Wait for the next frame to ensure the store has updated
await new Promise((resolve) => requestAnimationFrame(resolve));
onFitPressed();
}}
@@ -343,9 +318,7 @@ const PageDraw = () => {
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.style.display = "none"; // Hide the input
// Attach to DOM
input.style.display = "none";
document.body.appendChild(input);
input.onchange = (e: Event) => {
@@ -368,16 +341,13 @@ const PageDraw = () => {
} else {
console.log("No files selected");
}
// Clean up: remove input from DOM after use
document.body.removeChild(input);
};
// Reset value before click (for same file selection)
input.value = "";
input.click();
}}
onDelete={(): void => {
// This onDelete prop for SideToolbar might still be needed for the button
store.deleteSelectedItems();
}}
activeToolId={store.activeTool}
@@ -390,7 +360,6 @@ const PageDraw = () => {
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.");
@@ -405,7 +374,6 @@ const PageDraw = () => {
store.toggleLock(store.selectedNodeIds);
break;
case "REMOVE_BACKGROUND":
// returns a success if we have selected images only
await store.removeBackground(
store.selectedNodeIds,
async (
@@ -467,7 +435,7 @@ const PageDraw = () => {
store.deleteSelectedItems();
break;
default:
// No action needed for unhandled cases
// No action
}
}}
isLocked={store.selectedNodeIds.some((id) => {
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import React, { useState, useRef } from "react";
import React, { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faMousePointer,
@@ -23,11 +23,10 @@ import SliderWithIndicator from "./SliderWithIndicator";
import { useSceneStore } from "../../stores/SceneState";
import { Tooltip } from "@storyteller/ui-tooltip";
/* visual constants */
const shapeIconBtn =
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-white/10";
/* small debounce */
// Debounce function
function useDebounced<T extends (...args: A) => void, A extends unknown[]>(
fn: T,
ms = 75,
@@ -62,10 +61,8 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
activeToolId,
className = "",
}) => {
/* ------------------------------------------------ state ---------- */
const store = useSceneStore();
const [open, setOpen] = useState<string | null>(null);
const [brushSize, setBrushSize] = useState(16);
const [brushHsva, setBrushHsva] = useState<HsvaColor>({
h: 120,
@@ -73,7 +70,6 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
v: 100,
a: 1,
});
const [bgHsva, setBgHsva] = useState<HsvaColor>({
h: 0,
s: 0,
@@ -92,15 +88,14 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
// State for shape color picker
const [shapeHsva, setShapeHsva] = useState<HsvaColor>({
h: 240, // Blue hue
s: 100, // Full saturation
v: 70, // Medium brightness blue
h: 240,
s: 100,
v: 70,
a: 1,
});
// Update shape color picker when selection changes or store's shapeColor changes
React.useEffect(() => {
// Use selected node's color if available, otherwise use store's default shapeColor
useEffect(() => {
const colorToUse = firstSelectedNode?.fill || store.shapeColor;
if (colorToUse && colorToUse.startsWith("#")) {
@@ -127,7 +122,6 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
}
}, [firstSelectedNode?.fill, store.shapeColor]);
/* debounced parent calls */
const sendPaint = useDebounced<
(hex: string, size: number, opacity: number) => void,
[string, number, number]
@@ -138,17 +132,14 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
75,
);
// Debounced shape color update - now updates ALL selected nodes AND store's shapeColor
const sendShapeColor = useDebounced<(hex: string) => void, [string]>(
(hex: string) => {
// Update the store's default shape color for future shapes
store.setShapeColor(hex);
// Update all selected nodes
selectedNodes.forEach((node) => {
store.updateNode(node.id, { fill: hex }, false); // Don't save state for each individual update
store.updateNode(node.id, { fill: hex }, false);
});
// Save state once after all updates
if (selectedNodes.length > 0) {
store.saveState();
}
@@ -156,7 +147,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
75,
);
/* picker helper */
// Picker helper
const makePicker = (
hsva: HsvaColor,
setHsva: React.Dispatch<React.SetStateAction<HsvaColor>>,
@@ -203,12 +194,9 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
);
const BgPopout = makePicker(bgHsva, setBgHsva, sendBg);
// Shape color popout
const ShapePopout = makePicker(shapeHsva, setShapeHsva, sendShapeColor);
/* ------------------------------------------------ tools ---------- */
// Tools
const tools = [
{
id: "select",
@@ -226,7 +214,6 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
label: "Add Shape",
icon: <FontAwesomeIcon icon={faShapes} className="h-5 w-5" />,
onClick: () => {
// Activate shape tool using currently selected shape if any and clear selection
store.selectNode(null);
if (!store.currentShape) {
store.setCurrentShape("rectangle");
@@ -304,7 +291,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
label: "Undo",
icon: <FontAwesomeIcon icon={faUndo} className="h-5 w-5" />,
onClick: () => {
store.undo(); // Assuming store has an undo method
store.undo();
},
},
{
@@ -312,12 +299,11 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
label: "Redo",
icon: <FontAwesomeIcon icon={faRedo} className="h-5 w-5" />,
onClick: () => {
store.redo(); // Assuming store has a redo method
store.redo();
},
},
];
/* ------------------------------------------------ render ---------- */
const baseBtn =
"relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors border-2 border-transparent";
@@ -364,7 +350,6 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
? "bg-primary/30 border-2 !border-primary text-white"
: "hover:bg-white/10 text-white";
// Dynamic icon for brush/draw tool
let displayIcon = icon;
if (id === "draw") {
displayIcon = active ? (
@@ -377,7 +362,6 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
);
}
// Dynamic icon for shape tool
if (id === "add-shape" && activeToolId === "shape" && currentShape) {
const shapeIcons = {
rectangle: faSquare,
@@ -407,23 +391,18 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
<button
onClick={() => {
onClick?.();
// For brush tool, don't toggle popout on click when active - only show on hover
if (id !== "draw" || !active) {
setOpen(open === id ? null : id);
}
}}
onMouseEnter={() => {
// Show popover on hover for active brush tool
if (id === "draw" && active) {
setOpen(id);
}
}}
onMouseLeave={() => {
// Don't immediately hide popover for active brush tool - let popover handle it
if (id === "draw" && active) {
// Add a small delay to allow moving to popover
setTimeout(() => {
// Only close if mouse is not over the popover
const popoverElement = document.querySelector(
`[data-popover="${id}"]`,
);
@@ -460,23 +439,18 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
<button
onClick={() => {
onClick?.();
// For brush tool, don't toggle popout on click when active - only show on hover
if (id !== "draw" || !active) {
setOpen(open === id ? null : id);
}
}}
onMouseEnter={() => {
// Show popover on hover for active brush tool
if (id === "draw" && active) {
setOpen(id);
}
}}
onMouseLeave={() => {
// Don't immediately hide popover for active brush tool - let popover handle it
if (id === "draw" && active) {
// Add a small delay to allow moving to popover
setTimeout(() => {
// Only close if mouse is not over the popover
const popoverElement = document.querySelector(
`[data-popover="${id}"]`,
);
@@ -510,7 +484,6 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
<div
data-popover={id}
onMouseEnter={() => {
// Keep popover open when mouse enters
if (id === "draw" && active) {
setOpen(id);
}
@@ -1,29 +1,33 @@
import { useState,useLayoutEffect } from 'react';
import { useState, useLayoutEffect } from "react";
export const useRightPanelLayoutManagement = (
rightCointainerRef: React.RefObject<HTMLDivElement>,
naturalWidth: number,
naturalHeight: number,
leftPct: number,
onCanvasSizeChange?: (width: number, height: number) => void,
) => {
const [previewScale, setPreviewScale] = useState(1);
rightContainerRef: React.RefObject<HTMLDivElement | null>,
naturalWidth: number,
naturalHeight: number,
leftPct: number,
onCanvasSizeChange?: (width: number, height: number) => void,
) => {
const [previewScale, setPreviewScale] = useState(1);
useLayoutEffect(() => {
const update = () => {
if (!rightCointainerRef.current) return;
const { clientWidth, clientHeight } = rightCointainerRef.current;
let s = Math.min(clientWidth / naturalWidth, clientHeight / naturalHeight);
if (s > 1.0) {
s = 1.0;
}
setPreviewScale(s);
onCanvasSizeChange?.(clientWidth, clientHeight);
};
update(); // run once
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [leftPct]);
useLayoutEffect(() => {
const update = () => {
if (!rightContainerRef.current) return;
const { clientWidth, clientHeight } = rightContainerRef.current;
let s = Math.min(
clientWidth / naturalWidth,
clientHeight / naturalHeight,
);
if (s > 1.0) {
s = 1.0;
}
setPreviewScale(s);
onCanvasSizeChange?.(clientWidth, clientHeight);
};
update(); // run once
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftPct]);
return previewScale;
};
return previewScale;
};