mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 17:38:10 +00:00
styling updates (page draw) (#655)
This commit is contained in:
1 parent
2eb91528e7
commit
267fb64fe2
16 files changed
+1511
-1260
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { JobContextType } from "@storyteller/common";
|
||||
const JobContext = createContext<JobContextType | undefined>(undefined);
|
||||
|
||||
export const JobProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [jobTokens, setJobTokens] = useState<string[]>([]);
|
||||
|
||||
const addJobToken = (token: string) => {
|
||||
setJobTokens((prevTokens) => [...prevTokens, token]);
|
||||
};
|
||||
|
||||
const removeJobToken = (token: string) => {
|
||||
setJobTokens((prevTokens) => prevTokens.filter((t) => t !== token));
|
||||
};
|
||||
|
||||
const clearJobTokens = () => {
|
||||
setJobTokens([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<JobContext.Provider
|
||||
value={{ jobTokens, addJobToken, removeJobToken, clearJobTokens }}
|
||||
>
|
||||
{children}
|
||||
</JobContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useJobContext = () => {
|
||||
const context = useContext(JobContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useJobContext must be used within a JobProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,17 +1,16 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { PaintSurface } from './PaintSurface';
|
||||
import React, { useState } 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 "./App.css";
|
||||
import PromptEditor from "./PromptEditor/PromptEditor";
|
||||
import SideToolbar from "./components/ui/SideToolbar";
|
||||
// Import the Zustand store
|
||||
import { useSceneStore } from './stores/SceneState';
|
||||
import { useUndoRedoHotkeys } from './hooks/useUndoRedoHotkeys';
|
||||
import { useDeleteHotkeys } from './hooks/useDeleteHotkeys';
|
||||
import { useCopyPasteHotkeys } from './hooks/useCopyPasteHotkeys'; // Import the hook
|
||||
|
||||
const PageDraw = (): JSX.Element => {
|
||||
import { useSceneStore } from "./stores/SceneState";
|
||||
import { useUndoRedoHotkeys } from "./hooks/useUndoRedoHotkeys";
|
||||
import { useDeleteHotkeys } from "./hooks/useDeleteHotkeys";
|
||||
import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys"; // Import the hook
|
||||
|
||||
const PageDraw = () => {
|
||||
// State for canvas dimensions
|
||||
const canvasWidth = React.useRef<number>(1024);
|
||||
const canvasHeight = React.useRef<number>(1024);
|
||||
@@ -20,125 +19,127 @@ const PageDraw = (): JSX.Element => {
|
||||
|
||||
// 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({ onCopy: store.copySelectedItems, onPaste: store.pasteItems });
|
||||
useCopyPasteHotkeys({
|
||||
onCopy: store.copySelectedItems,
|
||||
onPaste: store.pasteItems,
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
|
||||
files.forEach((file, index) => {
|
||||
store.createImageFromFile(
|
||||
centerX + (index * 60), // Offset each image
|
||||
centerY + (index * 60),
|
||||
file
|
||||
centerX + index * 60, // Offset each image
|
||||
centerY + index * 60,
|
||||
file,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`fixed bottom-0 left-1/2 transform -translate-x-1/2 preserve-aspect-ratio z-10 ${
|
||||
isSelecting ? 'pointer-events-none' : 'pointer-events-auto'
|
||||
}`}>
|
||||
<PromptEditor
|
||||
initialPrompt=""
|
||||
onPromptChange={(prompt: string) => {
|
||||
console.log('Prompt changed:', prompt);
|
||||
// Handle prompt changes here
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<PromptEditor
|
||||
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
|
||||
}}
|
||||
onAIStrengthChange={(strength: number) => {
|
||||
console.log("AI Strength:", strength);
|
||||
// Handle AI strength changes here
|
||||
}}
|
||||
onAspectRatioChange={(ratio: string) => {
|
||||
console.log("Aspect ratio:", ratio);
|
||||
// Handle aspect ratio changes here
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<SideToolbar
|
||||
className="fixed left-0 top-1/2 z-10 -translate-y-1/2 transform"
|
||||
onSelect={(): void => {
|
||||
store.setActiveTool("select");
|
||||
}}
|
||||
onRandomize={() => {
|
||||
console.log('Randomize clicked');
|
||||
// Handle randomize action here
|
||||
}}
|
||||
onVary={() => {
|
||||
console.log('Vary clicked');
|
||||
// Handle vary action here
|
||||
}}
|
||||
onAIStrengthChange={(strength: number) => {
|
||||
console.log('AI Strength:', strength);
|
||||
// Handle AI strength changes here
|
||||
}}
|
||||
onAspectRatioChange={(ratio: string) => {
|
||||
console.log('Aspect ratio:', ratio);
|
||||
// Handle aspect ratio changes here
|
||||
}}
|
||||
onImageStyleChange={(images: string[]) => {
|
||||
console.log('Image styles:', images);
|
||||
// Handle image style changes here
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<SideToolbar className="fixed left-0 top-1/2 transform -translate-y-1/2 z-10"
|
||||
onSelect={(): void => {
|
||||
store.setActiveTool("select")
|
||||
}}
|
||||
onAddShape={(shape: 'rectangle' | 'circle' | 'triangle'): void => {
|
||||
// Calculate center position based on canvas dimensions
|
||||
const centerX = canvasWidth.current / 3;
|
||||
const centerY = canvasHeight.current / 3;
|
||||
if (shape === 'rectangle') {
|
||||
store.createRectangle(centerX, centerY);
|
||||
} else if (shape === 'circle') {
|
||||
store.createCircle(centerX, centerY);
|
||||
} else if (shape === 'triangle') {
|
||||
store.createTriangle(centerX, centerY);
|
||||
}
|
||||
|
||||
}}
|
||||
onPaintBrush={(hex: string, size: number): void => {
|
||||
store.setActiveTool("draw")
|
||||
store.setBrushColor(hex);
|
||||
store.setBrushSize(size);
|
||||
}}
|
||||
onEraser={(size: number): void => {
|
||||
store.setActiveTool("eraser")
|
||||
store.setBrushSize(size);
|
||||
}}
|
||||
|
||||
onCanvasBackground={(hex: string): void => {
|
||||
console.log("Canvas background activated", { color: hex });
|
||||
// Add background change logic here
|
||||
// TODO: minor bug needs to update the preview panel
|
||||
// Debounce also causes issues with real time color change.
|
||||
store.setFillColor(hex);
|
||||
}}
|
||||
onGenerateImage={(): void => {
|
||||
console.log("Generate image activated");
|
||||
// Add image generation logic here
|
||||
|
||||
}}
|
||||
onUploadImage={(): void => {
|
||||
console.log("Clicking");
|
||||
// Create input element dynamically like in PromptEditor
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.multiple = true; // Allow multiple file selection
|
||||
input.onchange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files) {
|
||||
const files = Array.from(target.files);
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
handleImageUpload(imageFiles);
|
||||
}
|
||||
onAddShape={(shape: "rectangle" | "circle" | "triangle"): void => {
|
||||
// Calculate center position based on canvas dimensions
|
||||
const centerX = canvasWidth.current / 3;
|
||||
const centerY = canvasHeight.current / 3;
|
||||
if (shape === "rectangle") {
|
||||
store.createRectangle(centerX, centerY);
|
||||
} else if (shape === "circle") {
|
||||
store.createCircle(centerX, centerY);
|
||||
} else if (shape === "triangle") {
|
||||
store.createTriangle(centerX, centerY);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
onDelete={(): void => { // This onDelete prop for SideToolbar might still be needed for the button
|
||||
store.deleteSelectedItems();
|
||||
}}
|
||||
activeToolId={store.activeTool}
|
||||
/>
|
||||
<div className="relative z-0">
|
||||
<PaintSurface
|
||||
}}
|
||||
onPaintBrush={(hex: string, size: number): void => {
|
||||
store.setActiveTool("draw");
|
||||
store.setBrushColor(hex);
|
||||
store.setBrushSize(size);
|
||||
}}
|
||||
onEraser={(size: number): void => {
|
||||
store.setActiveTool("eraser");
|
||||
store.setBrushSize(size);
|
||||
}}
|
||||
onCanvasBackground={(hex: string): void => {
|
||||
console.log("Canvas background activated", { color: hex });
|
||||
// Add background change logic here
|
||||
// TODO: minor bug needs to update the preview panel
|
||||
// Debounce also causes issues with real time color change.
|
||||
store.setFillColor(hex);
|
||||
}}
|
||||
onGenerateImage={(): void => {
|
||||
console.log("Generate image activated");
|
||||
// Add image generation logic here
|
||||
}}
|
||||
onUploadImage={(): void => {
|
||||
console.log("Clicking");
|
||||
// Create input element dynamically like in PromptEditor
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true; // Allow multiple file selection
|
||||
input.onchange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files) {
|
||||
const files = Array.from(target.files);
|
||||
const imageFiles = files.filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
handleImageUpload(imageFiles);
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
onDelete={(): void => {
|
||||
// This onDelete prop for SideToolbar might still be needed for the button
|
||||
store.deleteSelectedItems();
|
||||
}}
|
||||
activeToolId={store.activeTool}
|
||||
/>
|
||||
<div className="relative z-0">
|
||||
<PaintSurface
|
||||
nodes={store.nodes}
|
||||
selectedNodeIds={store.selectedNodeIds}
|
||||
onCanvasSizeChange={(width: number, height: number): void => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,109 +1,65 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import Slider from './Slider';
|
||||
import PromptTextArea from './PromptTextArea';
|
||||
import ImageStyleSelector from './ImageStyleSelector';
|
||||
import { PromptEditorProps, AspectRatio, ImageStyle } from './types';
|
||||
import React, { useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Slider from "./Slider";
|
||||
import ImageStyleSelector from "./ImageStyleSelector";
|
||||
import { PromptEditorProps, ImageStyle } from "./types";
|
||||
import { PromptBox2D } from "@storyteller/ui-promptbox";
|
||||
import { uploadImage } from "../../../components/reusable/UploadModalMedia/uploadImage";
|
||||
import { getCanvasRenderBitmap } from "../../../signals/canvasRenderBitmap";
|
||||
import { EncodeImageBitmapToBase64 } from "../utilities/EncodeImageBitmapToBase64";
|
||||
import { JobProvider, useJobContext } from "../JobContext";
|
||||
|
||||
const PromptEditor: React.FC<PromptEditorProps> = ({
|
||||
initialPrompt = '',
|
||||
onPromptChange,
|
||||
onRandomize,
|
||||
onVary,
|
||||
onAIStrengthChange,
|
||||
onAspectRatioChange,
|
||||
onImageStyleChange
|
||||
onImageStyleChange,
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
const [aiStrength, setAIStrength] = useState(0.75);
|
||||
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('1:1');
|
||||
const [images, setImages] = useState<ImageStyle[]>([]);
|
||||
|
||||
const handlePromptChange = (newPrompt: string) => {
|
||||
setPrompt(newPrompt);
|
||||
onPromptChange?.(newPrompt);
|
||||
};
|
||||
|
||||
|
||||
const handleAIStrengthChange = (strength: number) => {
|
||||
setAIStrength(strength);
|
||||
onAIStrengthChange?.(strength);
|
||||
};
|
||||
|
||||
const handleAspectRatioChange = (ratio: AspectRatio) => {
|
||||
setAspectRatio(ratio);
|
||||
onAspectRatioChange?.(ratio);
|
||||
};
|
||||
|
||||
const handleImageSelect = (file: File) => {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
const newImage: ImageStyle = {
|
||||
id: uuidv4(),
|
||||
url: imageUrl,
|
||||
weight: 0.5
|
||||
weight: 0.5,
|
||||
};
|
||||
|
||||
|
||||
const updatedImages = [...images, newImage];
|
||||
setImages(updatedImages);
|
||||
onImageStyleChange?.(updatedImages);
|
||||
};
|
||||
|
||||
const handleImageStyleClick = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
handleImageSelect(target.files[0]);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleImageUpdate = (id: string, updates: Partial<ImageStyle>) => {
|
||||
const updatedImages = images.map(img =>
|
||||
img.id === id ? { ...img, ...updates } : img
|
||||
);
|
||||
setImages(updatedImages);
|
||||
onImageStyleChange?.(updatedImages);
|
||||
};
|
||||
|
||||
const handleImageRemove = (id: string) => {
|
||||
const updatedImages = images.filter(img => img.id !== id);
|
||||
setImages(updatedImages);
|
||||
onImageStyleChange?.(updatedImages);
|
||||
};
|
||||
|
||||
const handleRandomize = () => {
|
||||
onRandomize?.();
|
||||
};
|
||||
|
||||
const handleVary = () => {
|
||||
onVary?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full max-w-3xl mx-auto space-y-2">
|
||||
<div className="w-full flex justify-center">
|
||||
<Slider value={aiStrength} onChange={handleAIStrengthChange} height={32} width={'85%'} />
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col space-y-2">
|
||||
<div className="flex w-full justify-center">
|
||||
<Slider
|
||||
value={aiStrength}
|
||||
onChange={handleAIStrengthChange}
|
||||
height={32}
|
||||
width={"85%"}
|
||||
/>
|
||||
</div>
|
||||
<PromptTextArea
|
||||
value={prompt}
|
||||
onChange={handlePromptChange}
|
||||
images={images}
|
||||
onImageUpdate={handleImageUpdate}
|
||||
onImageRemove={handleImageRemove}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={handleAspectRatioChange}
|
||||
onStyleClick={() => {}}
|
||||
onImageStyleClick={handleImageStyleClick}
|
||||
onRandomizeClick={handleRandomize}
|
||||
onVaryClick={handleVary}
|
||||
/>
|
||||
|
||||
|
||||
<JobProvider>
|
||||
<PromptBox2D
|
||||
uploadImage={uploadImage}
|
||||
getCanvasRenderBitmap={getCanvasRenderBitmap}
|
||||
EncodeImageBitmapToBase64={EncodeImageBitmapToBase64}
|
||||
useJobContext={useJobContext}
|
||||
onEnqueuePressed={async () => {
|
||||
// await renderEngineRef.current?.render(); //TODO: Render the canvas
|
||||
}}
|
||||
/>
|
||||
</JobProvider>
|
||||
|
||||
<ImageStyleSelector onImageSelect={handleImageSelect} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptEditor;
|
||||
export default PromptEditor;
|
||||
@@ -1,12 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
SparklesIcon,
|
||||
Undo2,
|
||||
Smartphone,
|
||||
LayoutPanelTop,
|
||||
} from 'lucide-react';
|
||||
import { PromptTextAreaProps, AspectRatio } from './types';
|
||||
import ImagePreview from './ImagePreview';
|
||||
import React from "react";
|
||||
import { SparklesIcon, Undo2, Smartphone, LayoutPanelTop } from "lucide-react";
|
||||
import { PromptTextAreaProps, AspectRatio } from "./types";
|
||||
import ImagePreview from "./ImagePreview";
|
||||
|
||||
const PromptTextArea: React.FC<
|
||||
PromptTextAreaProps & {
|
||||
@@ -32,35 +27,36 @@ const PromptTextArea: React.FC<
|
||||
}) => {
|
||||
/* helper to cycle aspect ratios */
|
||||
const getNextAspectRatio = (): AspectRatio => {
|
||||
const ratios: AspectRatio[] = ['1:1', '3:2', '2:3'];
|
||||
const ratios: AspectRatio[] = ["1:1", "3:2", "2:3"];
|
||||
const i = ratios.indexOf(aspectRatio);
|
||||
return ratios[(i + 1) % ratios.length];
|
||||
};
|
||||
|
||||
/* button styling – matches existing toolbar */
|
||||
const buttonClass =
|
||||
'flex items-center gap-1 rounded-lg px-3 py-1 ' +
|
||||
'bg-[#3A3A3A] text-sm text-white hover:bg-[#4A4A4A] ' +
|
||||
'transition-colors';
|
||||
"flex items-center gap-1 rounded-lg px-3 py-1 " +
|
||||
"bg-[#3A3A3A] text-sm text-white hover:bg-[#4A4A4A] " +
|
||||
"transition-colors";
|
||||
|
||||
/* ───────── render ───────── */
|
||||
return (
|
||||
<div className="w-full bg-[#2A2A2A] rounded-2xl overflow-hidden flex">
|
||||
<div className="flex w-full overflow-hidden rounded-2xl bg-[#2A2A2A]">
|
||||
{/* LEFT ▸ prompt + toolbar */}
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="flex flex-grow flex-col">
|
||||
{/* prompt field */}
|
||||
<div className="flex-grow px-7 pt-5">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Enter your prompt here…"
|
||||
className="
|
||||
flex-grow w-full bg-transparent border-none outline-none
|
||||
text-xl text-white placeholder-gray-400
|
||||
resize-y /* keep the browser’s vertical resize */
|
||||
min-h-[120px] max-h-[175px] /* cap between 120 px and 300 px */
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Enter your prompt here…"
|
||||
className="
|
||||
max-h-[175px] min-h-[120px] w-full flex-grow resize-y
|
||||
border-none bg-transparent text-xl
|
||||
text-white
|
||||
placeholder-gray-400 outline-none
|
||||
"
|
||||
/></div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* bottom toolbar */}
|
||||
<div className="flex items-center gap-4 px-5 py-3">
|
||||
@@ -83,14 +79,14 @@ const PromptTextArea: React.FC<
|
||||
>
|
||||
<Smartphone
|
||||
size={16}
|
||||
className={aspectRatio !== '1:1' ? 'rotate-90' : ''}
|
||||
className={aspectRatio !== "1:1" ? "rotate-90" : ""}
|
||||
/>
|
||||
<span>{aspectRatio}</span>
|
||||
</button>
|
||||
|
||||
{/* Randomize */}
|
||||
<button onClick={onRandomizeClick} className={buttonClass}>
|
||||
<div className="w-4 h-4 flex items-center justify-center">🎲</div>
|
||||
<div className="flex h-4 w-4 items-center justify-center">🎲</div>
|
||||
<span>Randomize</span>
|
||||
</button>
|
||||
|
||||
@@ -104,7 +100,7 @@ const PromptTextArea: React.FC<
|
||||
|
||||
{/* RIGHT ▸ image strip (optional) */}
|
||||
{images.length > 0 && (
|
||||
<div className="w-24 bg-[#232323] flex flex-col gap-2 p-2">
|
||||
<div className="flex w-24 flex-col gap-2 bg-[#232323] p-2">
|
||||
{images.map((img) => (
|
||||
<ImagePreview
|
||||
key={img.id}
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
MousePointer2,
|
||||
Shapes,
|
||||
Eraser,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
Pipette,
|
||||
Image,
|
||||
Square,
|
||||
Circle,
|
||||
Triangle,
|
||||
Star,
|
||||
StarsIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "../../components/ui/tooltip";
|
||||
faMousePointer,
|
||||
faShapes,
|
||||
faEraser,
|
||||
faTrash,
|
||||
faEyeDropper,
|
||||
faImage,
|
||||
faSquare,
|
||||
faCircle,
|
||||
faPlay,
|
||||
faSparkles,
|
||||
faFileImport,
|
||||
faFileExport,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import "../../App.css";
|
||||
import { HsvaColorPicker, HsvaColor } from "react-colorful";
|
||||
import { hsvaToHex } from "@uiw/color-convert";
|
||||
import SliderWithIndicator from './SliderWithIndicator';
|
||||
import SliderWithIndicator from "./SliderWithIndicator";
|
||||
import { useSceneStore } from "../../stores/SceneState";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
|
||||
/* visual constants */
|
||||
const panelBg = "bg-zinc-800";
|
||||
const panelBorder = "border border-zinc-700";
|
||||
const shapeIconBtn =
|
||||
"flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-zinc-700 focus-visible:bg-zinc-700";
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-white/10";
|
||||
|
||||
/* small debounce */
|
||||
function useDebounced<T extends (...a: any[]) => void>(fn: T, ms = 75) {
|
||||
function useDebounced<T extends (...args: A) => void, A extends unknown[]>(
|
||||
fn: T,
|
||||
ms = 75,
|
||||
) {
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
return (...args: Parameters<T>) => {
|
||||
return (...args: A) => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
@@ -42,7 +39,7 @@ function useDebounced<T extends (...a: any[]) => void>(fn: T, ms = 75) {
|
||||
|
||||
export interface SideToolbarProps {
|
||||
onSelect: () => void;
|
||||
onAddShape: (shape: 'rectangle' | 'circle' | 'triangle') => void;
|
||||
onAddShape: (shape: "rectangle" | "circle" | "triangle") => void;
|
||||
onPaintBrush: (hex: string, size: number) => void;
|
||||
onEraser: (size: number) => void;
|
||||
onCanvasBackground: (hex: string) => void;
|
||||
@@ -66,44 +63,42 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
className = "",
|
||||
}) => {
|
||||
/* ------------------------------------------------ state ---------- */
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
const [brushSize, setBrushSize] = useState(16);
|
||||
const [brushHsva, setBrushHsva] = useState<HsvaColor>({ h:120,s:100,v:100,a:1 });
|
||||
const [bgHsva, setBgHsva] = useState<HsvaColor>({ h:0, s:100,v:100,a:1 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [sliderPosition, setSliderPosition] = useState(0);
|
||||
const sliderRef = useRef<HTMLInputElement>(null);
|
||||
const [brushHsva, setBrushHsva] = useState<HsvaColor>({
|
||||
h: 120,
|
||||
s: 100,
|
||||
v: 100,
|
||||
a: 1,
|
||||
});
|
||||
const [bgHsva, setBgHsva] = useState<HsvaColor>({
|
||||
h: 0,
|
||||
s: 100,
|
||||
v: 100,
|
||||
a: 1,
|
||||
});
|
||||
|
||||
/* debounced parent calls */
|
||||
const sendPaint = useDebounced(onPaintBrush, 75);
|
||||
const sendBg = useDebounced(onCanvasBackground, 75);
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const size = Number(e.target.value);
|
||||
setBrushSize(size);
|
||||
sendPaint(hsvaToHex(brushHsva), size);
|
||||
|
||||
// Calculate position for the tooltip
|
||||
const slider = sliderRef.current;
|
||||
if (slider) {
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const percentage = (size - 1) / (64 - 1); // Normalize to 0-1
|
||||
const position = rect.width * percentage;
|
||||
setSliderPosition(position);
|
||||
}
|
||||
};
|
||||
const sendPaint = useDebounced<
|
||||
(hex: string, size: number) => void,
|
||||
[string, number]
|
||||
>(onPaintBrush, 75);
|
||||
const sendBg = useDebounced<(hex: string) => void, [string]>(
|
||||
onCanvasBackground,
|
||||
75,
|
||||
);
|
||||
|
||||
/* picker helper */
|
||||
const makePicker = (
|
||||
hsva: HsvaColor,
|
||||
setHsva: React.Dispatch<React.SetStateAction<HsvaColor>>,
|
||||
sendHex: (hex: string) => void,
|
||||
extra?: React.ReactNode
|
||||
extra?: React.ReactNode,
|
||||
) => (
|
||||
<div className={`relative w-fit rounded-2xl bg-zinc-900 p-4 shadow-lg ${panelBg} ${panelBorder}`}>
|
||||
<button className="absolute left-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-zinc-700 text-zinc-300 hover:bg-zinc-600">
|
||||
<Pipette size={16} />
|
||||
<div className={`glass relative w-fit rounded-2xl p-4 shadow-lg`}>
|
||||
<button className="bg-zinc-700 text-zinc-300 hover:bg-zinc-600 absolute left-4 top-4 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<FontAwesomeIcon icon={faEyeDropper} size="sm" />
|
||||
</button>
|
||||
|
||||
<HsvaColorPicker
|
||||
@@ -125,40 +120,18 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
(hex) => sendPaint(hex, brushSize),
|
||||
<>
|
||||
<div className="relative">
|
||||
<p className="mb-2 text-sm font-medium text-zinc-100">Brush Size</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={sliderRef}
|
||||
type="range"
|
||||
min={1}
|
||||
max={64}
|
||||
value={brushSize}
|
||||
onChange={handleSliderChange}
|
||||
onMouseDown={() => setIsDragging(true)}
|
||||
onMouseUp={() => setIsDragging(false)}
|
||||
onMouseLeave={() => setIsDragging(false)}
|
||||
className="w-48 accent-zinc-700"
|
||||
/>
|
||||
{isDragging && (
|
||||
<div
|
||||
className="
|
||||
absolute top-6
|
||||
transform -translate-x-1/2
|
||||
bg-zinc-700 text-white
|
||||
text-sm font-medium
|
||||
transition-all duration-150
|
||||
rounded-full px-3 py-1.5
|
||||
after:content-[''] after:absolute after:left-1/2 after:-top-1.5 after:-translate-x-1/2
|
||||
after:border-[6px] after:border-transparent after:border-b-zinc-700
|
||||
"
|
||||
style={{ left: `${sliderPosition}px` }}
|
||||
>
|
||||
{brushSize}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-2 text-sm font-medium text-white">Brush Size</p>
|
||||
<SliderWithIndicator
|
||||
value={brushSize}
|
||||
onChange={(size) => {
|
||||
setBrushSize(size);
|
||||
sendPaint(hsvaToHex(brushHsva), size);
|
||||
}}
|
||||
min={1}
|
||||
max={64}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
|
||||
const BgPopout = makePicker(bgHsva, setBgHsva, sendBg);
|
||||
@@ -167,58 +140,71 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
|
||||
const store = useSceneStore(); // Use store directly
|
||||
const tools = [
|
||||
{
|
||||
id: "select",
|
||||
label: "Select & Move",
|
||||
icon: <MousePointer2 className="h-5 w-5"/>,
|
||||
{
|
||||
id: "select",
|
||||
label: "Select & Move",
|
||||
icon: (
|
||||
<FontAwesomeIcon icon={faMousePointer} className="pl-0.5 text-lg" />
|
||||
),
|
||||
onClick: () => {
|
||||
onSelect();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ id: "separator-1", type: "separator" },
|
||||
{
|
||||
id: "add-shape",
|
||||
label: "Add Shape",
|
||||
icon: <Shapes className="h-5 w-5" />,
|
||||
icon: <FontAwesomeIcon icon={faShapes} className="h-5 w-5" />,
|
||||
popout: (
|
||||
<div className={`flex items-center gap-4 rounded-full px-4 py-2 shadow-lg ${panelBg} ${panelBorder}`}>
|
||||
{[Square, Circle, Triangle].map((Icon, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={shapeIconBtn}
|
||||
<div
|
||||
className={`flex items-center gap-1.5 rounded-full px-1.5 py-1.5 shadow-lg`}
|
||||
>
|
||||
{[
|
||||
faSquare,
|
||||
faCircle,
|
||||
faPlay, // triangle
|
||||
].map((faIcon, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={shapeIconBtn}
|
||||
onClick={() => {
|
||||
const shapes = ['rectangle', 'circle', 'triangle'] as const;
|
||||
const shapes = ["rectangle", "circle", "triangle"] as const;
|
||||
onAddShape(shapes[i]);
|
||||
setOpen(null);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-white"/>
|
||||
<FontAwesomeIcon icon={faIcon} className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "generate",
|
||||
label: "Generate Image",
|
||||
icon: <StarsIcon className="h-5 w-5"/>,
|
||||
{
|
||||
id: "generate",
|
||||
label: "Generate Image",
|
||||
icon: <FontAwesomeIcon icon={faSparkles} className="h-5 w-5" />,
|
||||
onClick: () => {
|
||||
onGenerateImage();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "upload",
|
||||
label: "Upload Image",
|
||||
icon: <Image className="h-5 w-5"/>,
|
||||
{
|
||||
id: "upload",
|
||||
label: "Upload Image",
|
||||
icon: <FontAwesomeIcon icon={faImage} className="h-5 w-5" />,
|
||||
onClick: () => {
|
||||
onUploadImage();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ id: "separator-2", type: "separator" },
|
||||
{
|
||||
id: "paint",
|
||||
label: "Brush",
|
||||
icon: <span className="inline-block h-5 w-5 rounded-full border-2 border-white" style={{backgroundColor: hsvaToHex(brushHsva)}}/>,
|
||||
icon: (
|
||||
<span
|
||||
className="inline-block h-5 w-5 rounded-full border-2 border-white"
|
||||
style={{ backgroundColor: hsvaToHex(brushHsva) }}
|
||||
/>
|
||||
),
|
||||
onClick: () => {
|
||||
sendPaint(hsvaToHex(brushHsva), brushSize);
|
||||
},
|
||||
@@ -227,12 +213,12 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
{
|
||||
id: "eraser",
|
||||
label: "Eraser",
|
||||
icon: <Eraser className="h-5 w-5"/>,
|
||||
icon: <FontAwesomeIcon icon={faEraser} className="h-5 w-5" />,
|
||||
onClick: () => {
|
||||
onEraser(brushSize);
|
||||
},
|
||||
popout: (
|
||||
<div className="w-64 rounded-lg bg-zinc-800 p-4 shadow-lg">
|
||||
<div className="p-4">
|
||||
<SliderWithIndicator
|
||||
value={brushSize}
|
||||
onChange={(size) => {
|
||||
@@ -244,121 +230,122 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: <Trash2 className="h-5 w-5"/>,
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: <FontAwesomeIcon icon={faTrash} className="h-5 w-5" />,
|
||||
onClick: () => {
|
||||
onDelete();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ id: "separator-3", type: "separator" },
|
||||
{
|
||||
id: "background",
|
||||
label: "Canvas Background",
|
||||
icon: <span className="inline-block h-5 w-5 rounded-full border-2 border-white" style={{backgroundColor: hsvaToHex(bgHsva)}}/>,
|
||||
icon: (
|
||||
<span
|
||||
className="inline-block h-5 w-5 rounded-full border-2 border-white"
|
||||
style={{ backgroundColor: hsvaToHex(bgHsva) }}
|
||||
/>
|
||||
),
|
||||
popout: BgPopout,
|
||||
},
|
||||
{
|
||||
id: "save-scene",
|
||||
label: "Save Scene",
|
||||
icon: <UploadCloud className="h-5 w-5"/>,
|
||||
icon: <FontAwesomeIcon icon={faFileExport} className="h-5 w-5" />,
|
||||
onClick: () => {
|
||||
store.saveSceneToFile();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "load-scene",
|
||||
label: "Load Scene",
|
||||
icon: <UploadCloud className="h-5 w-5"/>,
|
||||
icon: <FontAwesomeIcon icon={faFileImport} className="h-5 w-5" />,
|
||||
onClick: () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
input.onchange = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
const success = await store.loadSceneFromFile(target.files[0]);
|
||||
if (success) {
|
||||
console.log('Scene loaded successfully');
|
||||
console.log("Scene loaded successfully");
|
||||
} else {
|
||||
console.error('Failed to load scene');
|
||||
console.error("Failed to load scene");
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/* ------------------------------------------------ render ---------- */
|
||||
const baseBtn =
|
||||
"relative flex h-11 w-11 items-center justify-center rounded-lg transition-colors ring-1 ring-transparent " +
|
||||
"hover:ring-zinc-400 focus-visible:ring-zinc-400 active:ring-zinc-400";
|
||||
"relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors border-2 border-transparent";
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<aside className={`ml-4 flex w-16 flex-col items-center gap-3 rounded-2xl ${panelBg} ${panelBorder} py-4 shadow-lg ${className}`}>
|
||||
{tools.map((tool) => {
|
||||
if (tool.type === "separator") {
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="w-8 h-px bg-zinc-600 my-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
<aside
|
||||
className={`glass ml-4 flex flex-col items-center gap-3 rounded-xl p-1.5 shadow-lg ${className}`}
|
||||
>
|
||||
{tools.map((tool) => {
|
||||
if (tool.type === "separator") {
|
||||
return <div key={tool.id} className="my-1 h-px w-8 bg-white/15" />;
|
||||
}
|
||||
|
||||
const { id, icon, onClick, popout, label } = tool;
|
||||
const active = id === activeToolId;
|
||||
const btnStyle = active ? "bg-zinc-600 text-white" : "hover:bg-zinc-800 text-zinc-100";
|
||||
const { id, icon, onClick, popout, label } = tool;
|
||||
const active = id === activeToolId;
|
||||
const btnStyle = active
|
||||
? "bg-primary/30 border-2 !border-primary text-white"
|
||||
: "hover:bg-white/10 text-white";
|
||||
|
||||
return (
|
||||
<div key={id} className="relative">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{popout ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick?.();
|
||||
setOpen(open === id ? null : id);
|
||||
}}
|
||||
className={`${baseBtn} ${btnStyle}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick?.();
|
||||
}}
|
||||
className={`${baseBtn} ${btnStyle}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="right" sideOffset={8} className={`${panelBg} ${panelBorder} rounded-md px-3 py-1 text-zinc-100`}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{open===id && popout && (
|
||||
<div
|
||||
onMouseLeave={() => {
|
||||
setOpen(null);
|
||||
return (
|
||||
<div key={id} className="relative">
|
||||
<Tooltip
|
||||
content={label}
|
||||
position="right"
|
||||
closeOnClick={true}
|
||||
className="ms-1 rounded-md px-3 py-1"
|
||||
delay={100}
|
||||
>
|
||||
{popout ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick?.();
|
||||
setOpen(open === id ? null : id);
|
||||
}}
|
||||
className="absolute left-20 top-1/2 -translate-y-1/2 transition-all duration-200 ease-in-out"
|
||||
className={`${baseBtn} ${btnStyle}`}
|
||||
>
|
||||
{popout}
|
||||
</div>
|
||||
{icon}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick?.();
|
||||
}}
|
||||
className={`${baseBtn} ${btnStyle}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
</Tooltip>
|
||||
|
||||
{open === id && popout && (
|
||||
<div
|
||||
onMouseLeave={() => {
|
||||
setOpen(null);
|
||||
}}
|
||||
className="absolute left-14 top-1/2 -translate-y-1/2 rounded-xl border border-[#404040] bg-[#303030] transition-all duration-200 ease-in-out"
|
||||
>
|
||||
{popout}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+14
-18
@@ -1,5 +1,5 @@
|
||||
// src/SliderWithIndicator.tsx
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef } from "react";
|
||||
|
||||
interface SliderWithIndicatorProps {
|
||||
value: number;
|
||||
@@ -16,7 +16,7 @@ const SliderWithIndicator: React.FC<SliderWithIndicatorProps> = ({
|
||||
min = 1,
|
||||
max = 64,
|
||||
label,
|
||||
className = "w-48"
|
||||
className = "w-48",
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [sliderPosition, setSliderPosition] = useState(0);
|
||||
@@ -25,7 +25,7 @@ const SliderWithIndicator: React.FC<SliderWithIndicatorProps> = ({
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = Number(e.target.value);
|
||||
onChange(newValue);
|
||||
|
||||
|
||||
// Calculate position for the tooltip
|
||||
const slider = sliderRef.current;
|
||||
if (slider) {
|
||||
@@ -38,9 +38,7 @@ const SliderWithIndicator: React.FC<SliderWithIndicatorProps> = ({
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{label && (
|
||||
<p className="mb-2 text-sm font-medium text-zinc-100">{label}</p>
|
||||
)}
|
||||
{label && <p className="mb-2 text-sm font-medium text-white">{label}</p>}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={sliderRef}
|
||||
@@ -55,19 +53,17 @@ const SliderWithIndicator: React.FC<SliderWithIndicatorProps> = ({
|
||||
className={`${className} accent-black-600`}
|
||||
/>
|
||||
{isDragging && (
|
||||
<div
|
||||
<div
|
||||
className="
|
||||
absolute top-6
|
||||
transform -translate-x-1/2
|
||||
bg-black-600/90 backdrop-blur-sm
|
||||
text-white
|
||||
text-xs font-medium
|
||||
transition-all duration-150
|
||||
absolute
|
||||
top-6 -translate-x-1/2
|
||||
transform rounded
|
||||
bg-[#5F5F68]
|
||||
px-2 py-1
|
||||
rounded
|
||||
shadow-lg
|
||||
after:content-[''] after:absolute after:left-1/2 after:-top-1.5 after:-translate-x-1/2
|
||||
after:border-4 after:border-transparent after:border-b-black-600/90
|
||||
text-xs font-medium
|
||||
text-white shadow-lg
|
||||
transition-all
|
||||
duration-150 after:absolute after:-top-1.5 after:left-1/2 after:-translate-x-1/2
|
||||
"
|
||||
style={{ left: `${sliderPosition}px` }}
|
||||
>
|
||||
@@ -79,4 +75,4 @@ const SliderWithIndicator: React.FC<SliderWithIndicatorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SliderWithIndicator;
|
||||
export default SliderWithIndicator;
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
CSSProperties,
|
||||
} from "react";
|
||||
import { useRef, useState, useCallback, ReactNode, CSSProperties } from "react";
|
||||
|
||||
export type SplitPaneProps = {
|
||||
/** What should appear in the left pane */
|
||||
@@ -38,7 +32,8 @@ export default function SplitPane({
|
||||
/** Same logic for mouse + touch */
|
||||
const beginDrag = useCallback(() => {
|
||||
const move = (clientX: number) => {
|
||||
const { left: rootLeft, width } = rootRef.current!.getBoundingClientRect();
|
||||
const { left: rootLeft, width } =
|
||||
rootRef.current!.getBoundingClientRect();
|
||||
const pct = ((clientX - rootLeft) / width) * 100;
|
||||
const clamped = Math.min(maxPercent, Math.max(minPercent, pct));
|
||||
setLeftPct(clamped);
|
||||
@@ -63,23 +58,25 @@ export default function SplitPane({
|
||||
window.addEventListener("touchend", endDrag, { once: true });
|
||||
}, [minPercent, maxPercent, onChange]);
|
||||
/* Tailwind handles almost all the styling—only flex-basis is inline */
|
||||
const leftStyle: CSSProperties = { flexBasis: `${leftPct}%`};
|
||||
const leftStyle: CSSProperties = { flexBasis: `${leftPct}%` };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`pegboard-bg flex h-screen w-full overflow-hidden relative ${className}`}
|
||||
className={`pegboard-bg relative flex h-screen w-full overflow-hidden ${className}`}
|
||||
>
|
||||
<div style={leftStyle} className="h-full overflow-hidden min-w-0">{left}</div>
|
||||
<div style={leftStyle} className="h-full min-w-0 overflow-hidden">
|
||||
{left}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-1 bg-gray-500/80 hover:bg-gray-600 transition
|
||||
cursor-col-resize select-none"
|
||||
className="w-1 cursor-col-resize select-none bg-gray-500/80
|
||||
transition hover:bg-gray-600"
|
||||
onMouseDown={beginDrag}
|
||||
onTouchStart={beginDrag}
|
||||
/>
|
||||
|
||||
<div className="flex-1 h-full overflow-hidden">{right}</div>
|
||||
<div className="h-full flex-1 overflow-hidden">{right}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { JobContextType } from "@storyteller/common";
|
||||
const JobContext = createContext<JobContextType | undefined>(undefined);
|
||||
|
||||
export const JobProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [jobTokens, setJobTokens] = useState<string[]>([]);
|
||||
|
||||
const addJobToken = (token: string) => {
|
||||
setJobTokens((prevTokens) => [...prevTokens, token]);
|
||||
};
|
||||
|
||||
const removeJobToken = (token: string) => {
|
||||
setJobTokens((prevTokens) => prevTokens.filter((t) => t !== token));
|
||||
};
|
||||
|
||||
const clearJobTokens = () => {
|
||||
setJobTokens([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<JobContext.Provider
|
||||
value={{ jobTokens, addJobToken, removeJobToken, clearJobTokens }}
|
||||
>
|
||||
{children}
|
||||
</JobContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useJobContext = () => {
|
||||
const context = useContext(JobContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useJobContext must be used within a JobProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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 {
|
||||
const bitmap = await createImageBitmap(img);
|
||||
resolve(bitmap);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
|
||||
// Set the source to trigger loading
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
export const EncodeImageBitmapToBase64 = async (imageBitmap: ImageBitmap): Promise<string> => {
|
||||
// Create a temporary canvas
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = imageBitmap.width;
|
||||
canvas.height = imageBitmap.height;
|
||||
|
||||
// Draw the ImageBitmap onto the canvas
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to get 2D context");
|
||||
|
||||
ctx.drawImage(imageBitmap, 0, 0);
|
||||
|
||||
// Convert to base64
|
||||
const base64String = canvas.toDataURL("image/png");
|
||||
|
||||
// Clean up
|
||||
canvas.remove();
|
||||
|
||||
// Remove the data:image/png;base64, prefix if you want just the base64 string
|
||||
return base64String.split(",")[1];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export class FileUtilities {
|
||||
static async downloadBlobZip(blob: Blob) {
|
||||
console.log("Download Blob");
|
||||
// Create a temporary URL for the Blob
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Create an anchor element to simulate the download
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "Test.zip";
|
||||
|
||||
// Append the anchor to the body
|
||||
document.body.appendChild(anchor);
|
||||
|
||||
// Programmatically click the anchor to trigger the download
|
||||
anchor.click();
|
||||
|
||||
// Clean up by removing the anchor and revoking the object URL
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
static async blobToFileJpeg(blob: Blob, index: string) {
|
||||
try {
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
const formattedIndex = String(index).padStart(4, "0");
|
||||
link.download = `${formattedIndex}.jpg`;
|
||||
// Trigger the download
|
||||
link.click();
|
||||
// Clean up the URL object
|
||||
URL.revokeObjectURL(link.href);
|
||||
console.log("Done");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async createImageFileFromUrl(
|
||||
url: string,
|
||||
fileName: string = "image.jpg",
|
||||
mimeType: string = "image/jpeg",
|
||||
): Promise<File> {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return new File([blob], fileName, { type: mimeType });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
export const SaveImageBitmapToFile = async (imageBitmap: ImageBitmap): Promise<void> => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = imageBitmap.width;
|
||||
canvas.height = imageBitmap.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to get 2D context");
|
||||
|
||||
ctx.drawImage(imageBitmap, 0, 0);
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b!), "image/png");
|
||||
});
|
||||
|
||||
await FileUtilities.blobToFileJpeg(blob, "output");
|
||||
} catch (error) {
|
||||
console.error("Error saving output:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const kebabCase = (str: string) =>
|
||||
str
|
||||
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.toLowerCase();
|
||||
|
||||
export const getFileName = (file: File) => {
|
||||
return file.name.substring(0, file.name.lastIndexOf("."));
|
||||
};
|
||||
export const getFileExtension = (file: File) => {
|
||||
return file.name.substring(file.name.lastIndexOf(".") + 1);
|
||||
};
|
||||
export const clamp = (num: number, min: number, max: number) => {
|
||||
return Math.min(Math.max(num, min), max);
|
||||
}
|
||||
export const normalize = (num: number, min: number, max: number) => {
|
||||
return (num - min) / (max - min);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
removeImageDropListener,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
|
||||
import PageDraw from "../PageDraw/PageDraw"
|
||||
import PageDraw from "../PageDraw/PageDraw";
|
||||
|
||||
export const PageEditor = () => {
|
||||
useSignals();
|
||||
@@ -440,7 +440,7 @@ export const PageEditor = () => {
|
||||
{/* <KonvaCanvasContainer style={{ display: display2d }}>
|
||||
<KonvaRootComponent className="h-full w-full" />
|
||||
</KonvaCanvasContainer> */}
|
||||
<PageDraw className="h-full w-full" style={{ display: display2d }}> </PageDraw>
|
||||
<PageDraw className="h-full w-full" style={{ display: display2d }} />
|
||||
|
||||
<div style={{ display: displayVideo }}>
|
||||
<ImageToVideo
|
||||
@@ -473,7 +473,6 @@ export const PageEditor = () => {
|
||||
authentication.userInfo.value = userInfo;
|
||||
}}
|
||||
/> */}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -32,13 +32,9 @@ import { IsDesktopApp } from "@storyteller/tauri-utils";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { PromptsApi } from "@storyteller/api";
|
||||
import {
|
||||
CommandSuccessStatus,
|
||||
GetAppPreferences,
|
||||
SoraImageRemix,
|
||||
SoraImageRemixErrorType,
|
||||
SoraImageRemixAspectRatio,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { SoundRegistry } from "@storyteller/soundboard";
|
||||
|
||||
interface ReferenceImage {
|
||||
id: string;
|
||||
@@ -75,8 +71,7 @@ export const PromptBox2D = ({
|
||||
console.log("Is this a desktop app?", IsDesktopApp());
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const { jobTokens, addJobToken, removeJobToken, clearJobTokens } =
|
||||
useJobContext();
|
||||
const { addJobToken } = useJobContext();
|
||||
|
||||
//const { lastRenderedBitmap } = useCanvasSignal();
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
Reference in new issue
Block a user