mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e88dc8285 | ||
|
|
dce7182eb0 | ||
|
|
3e9ba23a4c |
@@ -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);
|
||||
}
|
||||
|
||||
+29
-25
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user