Compare commits

...
Author SHA1 Message Date
bflatastic 15dd81f8f0 styling updates to sidebar 2025-06-04 12:05:44 +07:00
bflatastic b1709c6ecf replaced promptbox with storyteller's 2025-06-04 10:59:14 +07:00
CrossPr0duct 2eb91528e7 CSS issues need to be fixed. 2025-06-03 22:28:00 -04:00
CrossPr0duct 57169c470a It compiles and works, issues with z values and it being an overlay. 2025-06-03 22:02:40 -04:00
CrossPr0duct 6341f8120e trying to get it to compile 2025-06-03 21:29:03 -04:00
CrossPr0duct 44c0dfd920 Made fixes to the bugs. 2025-06-03 14:27:51 -04:00
CrossPr0duct d742c61390 New 2D code UI Components.
Added Scene Saving.
2025-06-03 10:51:12 -04:00
37 changed files with 4509 additions and 17 deletions
@@ -0,0 +1,156 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* @import "tw-animate-css"; */
@custom-variant dark (&:is(.dark *));
.pegboard-bg {
position: fixed;
inset: 0;
z-index: 0;
background-color: #1b1b1b;
/* background-image: radial-gradient(#bbb 1px, transparent 1px),
radial-gradient(#bbb 1px, transparent 1px); */
background-size: 50px 50px;
background-position: 0 0, 15px 15px;
}
/*
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
} */
/* @layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
} */
.brush-picker .react-colorful {
border: 1px solid rgb(255, 255, 255);
margin-bottom: 16px;
}
.brush-picker .react-colorful__saturation {
border: 1px solid rgb(255, 255, 255);
border-radius: 15px 15px 15px 15px; /* top-rounded square */
margin-bottom: 12px;
}
.brush-picker .react-colorful__hue {
border: 1px solid rgba(255, 255, 255, 0.2); /* bar thickness */
border-radius: 30px 30px 30px 30px; /* rounded bottom only */
margin-bottom: 12px;
}
.brush-picker .react-colorful__alpha {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 15px 15px 15px 15px;
margin-bottom: 12px;
}
@@ -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,159 @@
export type NodeType = 'rectangle' | 'circle' | 'triangle' | 'image';
export interface NodeProps {
id: string;
x: number;
y: number;
width: number;
height: number;
fill: string;
type: NodeType;
stroke?: string;
strokeWidth?: number;
draggable?: boolean;
imageUrl?: string;
imageFile?: File;
imageElement?: HTMLImageElement;
backgroundColor?: string;
rotation?: number;
scaleX?: number;
scaleY?: number;
offsetX?: number;
offsetY?: number;
}
export class Node {
id: string;
x: number;
y: number;
width: number;
height: number;
fill: string;
type: NodeType;
stroke: string;
strokeWidth: number;
draggable: boolean;
imageUrl?: string;
imageFile?: File;
imageElement?: HTMLImageElement;
backgroundColor?: string;
rotation: number;
scaleX: number;
scaleY: number;
offsetX: number;
offsetY: number;
constructor({
id,
x,
y,
width,
height,
fill,
type,
stroke = 'black',
strokeWidth = 1,
draggable = false,
imageUrl,
imageFile,
imageElement,
backgroundColor,
rotation = 0,
scaleX = 1,
scaleY = 1,
offsetX = 0,
offsetY = 0
}: NodeProps) {
this.id = id;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.fill = fill;
this.type = type;
this.stroke = stroke;
this.strokeWidth = strokeWidth;
this.draggable = draggable;
this.imageUrl = imageUrl;
this.imageFile = imageFile;
this.imageElement = imageElement;
this.backgroundColor = backgroundColor;
this.rotation = rotation;
this.scaleX = scaleX;
this.scaleY = scaleY;
this.offsetX = offsetX;
this.offsetY = offsetY;
}
setPosition(x: number, y: number): void {
this.x = x;
this.y = y;
}
setSize(width: number, height: number): void {
this.width = width;
this.height = height;
}
setStyle(fill: string, stroke?: string, strokeWidth?: number): void {
this.fill = fill;
if (stroke) this.stroke = stroke;
if (strokeWidth) this.strokeWidth = strokeWidth;
}
setImageFromUrl(imageUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
this.imageElement = img;
this.imageUrl = imageUrl;
this.imageFile = undefined;
resolve();
};
img.onerror = reject;
img.crossOrigin = 'anonymous';
img.src = imageUrl;
});
}
setImageFromFile(file: File): Promise<void> {
return new Promise((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('File is not an image'));
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
if (dataUrl) {
const img = new Image();
img.onload = () => {
this.imageElement = img;
this.imageFile = file;
this.imageUrl = undefined;
resolve();
};
img.onerror = reject;
img.src = dataUrl;
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
setImage(source: string | File): Promise<void> {
if (typeof source === 'string') {
return this.setImageFromUrl(source);
} else {
return this.setImageFromFile(source);
}
}
}
function generateId(): string {
return `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
@@ -0,0 +1,160 @@
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 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 = () => {
// State for canvas dimensions
const canvasWidth = React.useRef<number>(1024);
const canvasHeight = React.useRef<number>(1024);
// Add new state to track if user is selecting
const [isSelecting, setIsSelecting] = useState<boolean>(false);
// 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,
});
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,
);
});
};
return (
<>
<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");
}}
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);
}
}
};
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 => {
canvasWidth.current = width;
canvasHeight.current = height;
}}
fillColor={store.fillColor}
activeTool={store.activeTool}
brushColor={store.brushColor}
brushSize={store.brushSize}
onSelectionChange={setIsSelecting}
/>
</div>
</>
);
};
export default PageDraw;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
import React, { useState, useRef } from 'react';
import { X } from 'lucide-react';
import { ImagePreviewProps } from './types';
const ImagePreview: React.FC<ImagePreviewProps> = ({ image, onUpdate, onRemove }) => {
const [showControls, setShowControls] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const sliderRef = useRef<HTMLDivElement>(null);
const handleWeightChange = (clientY: number) => {
if (sliderRef.current) {
const rect = sliderRef.current.getBoundingClientRect();
const height = rect.height;
const relativeY = clientY - rect.top;
// Calculate inverse value (1.0 at top, 0.0 at bottom)
const value = Math.max(0, Math.min(1, 1 - (relativeY / height)));
onUpdate(image.id, { weight: parseFloat(value.toFixed(1)) });
}
};
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDragging(true);
handleWeightChange(e.clientY);
// Add event listeners
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
handleWeightChange(e.clientY);
}
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
return (
<div
className="relative h-20 w-20 rounded-md overflow-hidden group"
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => !isDragging && setShowControls(false)}
>
<img
src={image.url}
alt="Style reference"
className="w-full h-full object-cover"
/>
{showControls && (
<>
<button
className="absolute top-1 right-1 bg-black bg-opacity-50 rounded-full p-0.5 text-white hover:bg-opacity-70 transition-colors"
onClick={() => onRemove(image.id)}
>
<X size={16} />
</button>
<div
ref={sliderRef}
className="absolute inset-0 bg-gradient-to-b from-transparent to-black bg-opacity-40 cursor-ns-resize"
onMouseDown={handleMouseDown}
>
<div
className="absolute left-0 right-0 h-0.5 bg-white"
style={{ bottom: `${image.weight * 100}%` }}
/>
<div className="absolute bottom-1 right-1 text-xs font-medium text-white">
{image.weight.toFixed(1)}
</div>
</div>
</>
)}
</div>
);
};
export default ImagePreview;
@@ -0,0 +1,33 @@
import React, { useRef } from 'react';
import { ImageStyleSelectorProps } from './types';
const ImageStyleSelector: React.FC<ImageStyleSelectorProps> = ({ onImageSelect }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onImageSelect(files[0]);
// Reset input value so the same file can be selected again
e.target.value = '';
}
};
return (
<>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileChange}
/>
</>
);
};
export default ImageStyleSelector;
@@ -0,0 +1,65 @@
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> = ({
onAIStrengthChange,
onImageStyleChange,
}) => {
const [aiStrength, setAIStrength] = useState(0.75);
const [images, setImages] = useState<ImageStyle[]>([]);
const handleAIStrengthChange = (strength: number) => {
setAIStrength(strength);
onAIStrengthChange?.(strength);
};
const handleImageSelect = (file: File) => {
const imageUrl = URL.createObjectURL(file);
const newImage: ImageStyle = {
id: uuidv4(),
url: imageUrl,
weight: 0.5,
};
const updatedImages = [...images, newImage];
setImages(updatedImages);
onImageStyleChange?.(updatedImages);
};
return (
<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>
<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;
@@ -0,0 +1,118 @@
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 & {
aspectRatio: AspectRatio;
onAspectRatioChange: (r: AspectRatio) => void;
onStyleClick: () => void;
onImageStyleClick: () => void;
onRandomizeClick: () => void;
onVaryClick: () => void;
}
> = ({
value,
onChange,
images,
onImageUpdate,
onImageRemove,
aspectRatio,
onAspectRatioChange,
onStyleClick,
onImageStyleClick,
onRandomizeClick,
onVaryClick,
}) => {
/* helper to cycle aspect ratios */
const getNextAspectRatio = (): AspectRatio => {
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";
/* ───────── render ───────── */
return (
<div className="flex w-full overflow-hidden rounded-2xl bg-[#2A2A2A]">
{/* LEFT ▸ prompt + toolbar */}
<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="
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>
{/* bottom toolbar */}
<div className="flex items-center gap-4 px-5 py-3">
{/* Style */}
<button onClick={onStyleClick} className={buttonClass}>
<SparklesIcon size={16} />
<span>Style</span>
</button>
{/* Image Style */}
<button onClick={onImageStyleClick} className={buttonClass}>
<LayoutPanelTop size={16} />
<span>Image&nbsp;Style</span>
</button>
{/* Aspect Ratio */}
<button
onClick={() => onAspectRatioChange(getNextAspectRatio())}
className={buttonClass}
>
<Smartphone
size={16}
className={aspectRatio !== "1:1" ? "rotate-90" : ""}
/>
<span>{aspectRatio}</span>
</button>
{/* Randomize */}
<button onClick={onRandomizeClick} className={buttonClass}>
<div className="flex h-4 w-4 items-center justify-center">🎲</div>
<span>Randomize</span>
</button>
{/* Vary */}
<button onClick={onVaryClick} className={buttonClass}>
<Undo2 size={16} />
<span>Vary</span>
</button>
</div>
</div>
{/* RIGHT ▸ image strip (optional) */}
{images.length > 0 && (
<div className="flex w-24 flex-col gap-2 bg-[#232323] p-2">
{images.map((img) => (
<ImagePreview
key={img.id}
image={img}
onUpdate={onImageUpdate}
onRemove={onImageRemove}
/>
))}
</div>
)}
</div>
);
};
export default PromptTextArea;
@@ -0,0 +1,122 @@
// AIStrengthSlider.tsx
import React, { useEffect, useRef, useState } from 'react';
import { AIStrengthSliderProps } from './types';
const Slider: React.FC<AIStrengthSliderProps> = ({
value,
onChange,
width = '100%',
height = 48,
inset = false,
className = '',
}) => {
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [hover, setHover] = useState(false);
const [dragging, setDragging] = useState(false); // ← new
/* ----- paint helpers ----- */
const clamp = (v: number) => Math.max(0, Math.min(1, v));
const paint = (v: number) => {
fillRef.current!.style.transform = `scaleX(${v})`;
handleRef.current!.style.left = `${v * 100}%`;
};
useEffect(() => paint(value), [value]);
/* ----- pointer logic ----- */
const valAt = (x: number) => {
const { left, width } = trackRef.current!.getBoundingClientRect();
return clamp((x - left) / width);
};
const start = (e: React.PointerEvent) => {
setDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const v = valAt(e.clientX);
paint(v);
onChange(v);
};
const move = (e: PointerEvent) => {
if (!dragging) return;
const v = valAt(e.clientX);
paint(v);
onChange(v); // fine to call every frame now
};
const stop = () => setDragging(false);
useEffect(() => {
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
return () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
};
}, [dragging]);
/* ----- derived sizes ----- */
const trackH = typeof height === 'number' ? `${height}px` : height;
/* ----- render ----- */
const showHandle = hover || dragging;
return (
<div className="relative select-none" style={{ width }}>
<div
ref={trackRef}
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value * 100)}
onPointerDown={start}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={`
group flex items-center rounded-xl cursor-pointer overflow-hidden
bg-[#1A1A1A] hover:bg-[#2A2A2A] transition-colors relative
${inset ? 'shadow-inner' : ''}
${className}
`}
style={{ height: trackH }}
>
{/* fill */}
<div
ref={fillRef}
className="absolute inset-0 bg-white/15 will-change-transform"
style={{ transformOrigin: 'left' }}
/>
{/* handle — visible only on hover OR drag */}
<div
ref={handleRef}
className={`
absolute translate-x-1/2 bg-white will-change-transform
transition-opacity duration-150
${showHandle ? 'opacity-100' : 'opacity-50'}
`}
style={{ width: '1px', height: '60%' }}
/>
{/* left-aligned, vertically centred label */}
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-sm font-medium text-[#ACACAC]">
AI Strength
</span>
{/* percentage on hover */}
<span className="
absolute right-4 top-1/2 -translate-y-1/2 text-sm text-white
opacity-0 group-hover:opacity-100 transition-opacity
min-w-[40px] text-right
">
{Math.round(value * 100)}
</span>
</div>
</div>
);
};
export default Slider;
@@ -0,0 +1,53 @@
export interface PromptEditorProps {
initialPrompt?: string;
onPromptChange?: (prompt: string) => void;
onRandomize?: () => void;
onVary?: () => void;
onAIStrengthChange?: (strength: number) => void;
onAspectRatioChange?: (ratio: AspectRatio) => void;
onImageStyleChange?: (images: ImageStyle[]) => void;
}
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<ImageStyle>) => 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<ImageStyle>) => void;
onRemove: (id: string) => void;
}
@@ -0,0 +1,2 @@
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,352 @@
import React, { useState, useRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
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 { 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 */
function useDebounced<T extends (...args: A) => void, A extends unknown[]>(
fn: T,
ms = 75,
) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
return (...args: A) => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => fn(...args), ms);
};
}
export interface SideToolbarProps {
onSelect: () => void;
onAddShape: (shape: "rectangle" | "circle" | "triangle") => void;
onPaintBrush: (hex: string, size: number) => void;
onEraser: (size: number) => void;
onCanvasBackground: (hex: string) => void;
onGenerateImage: () => void;
onUploadImage: () => void;
onDelete: () => void;
activeToolId: string;
className?: string;
}
const SideToolbar: React.FC<SideToolbarProps> = ({
onSelect,
onAddShape,
onPaintBrush,
onEraser,
onCanvasBackground,
onGenerateImage,
onUploadImage,
onDelete,
activeToolId,
className = "",
}) => {
/* ------------------------------------------------ state ---------- */
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,
});
/* debounced parent calls */
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,
) => (
<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
color={hsva}
onChange={(c) => {
setHsva(c);
sendHex(hsvaToHex(c));
}}
className="brush-picker"
/>
{extra}
</div>
);
const BrushPopout = makePicker(
brushHsva,
setBrushHsva,
(hex) => sendPaint(hex, brushSize),
<>
<div className="relative">
<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);
/* ------------------------------------------------ tools ---------- */
const store = useSceneStore(); // Use store directly
const tools = [
{
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: <FontAwesomeIcon icon={faShapes} className="h-5 w-5" />,
popout: (
<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;
onAddShape(shapes[i]);
setOpen(null);
}}
>
<FontAwesomeIcon icon={faIcon} className="h-5 w-5 text-white" />
</button>
))}
</div>
),
},
{
id: "generate",
label: "Generate Image",
icon: <FontAwesomeIcon icon={faSparkles} className="h-5 w-5" />,
onClick: () => {
onGenerateImage();
},
},
{
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) }}
/>
),
onClick: () => {
sendPaint(hsvaToHex(brushHsva), brushSize);
},
popout: BrushPopout,
},
{
id: "eraser",
label: "Eraser",
icon: <FontAwesomeIcon icon={faEraser} className="h-5 w-5" />,
onClick: () => {
onEraser(brushSize);
},
popout: (
<div className="p-4">
<SliderWithIndicator
value={brushSize}
onChange={(size) => {
setBrushSize(size);
onEraser(size);
}}
label="Eraser Size"
/>
</div>
),
},
{
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) }}
/>
),
popout: BgPopout,
},
{
id: "save-scene",
label: "Save Scene",
icon: <FontAwesomeIcon icon={faFileExport} className="h-5 w-5" />,
onClick: () => {
store.saveSceneToFile();
},
},
{
id: "load-scene",
label: "Load Scene",
icon: <FontAwesomeIcon icon={faFileImport} className="h-5 w-5" />,
onClick: () => {
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");
} else {
console.error("Failed to load scene");
}
}
};
input.click();
},
},
];
/* ------------------------------------------------ render ---------- */
const baseBtn =
"relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors border-2 border-transparent";
return (
<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-primary/30 border-2 !border-primary text-white"
: "hover:bg-white/10 text-white";
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={`${baseBtn} ${btnStyle}`}
>
{icon}
</button>
) : (
<button
onClick={() => {
onClick?.();
}}
className={`${baseBtn} ${btnStyle}`}
>
{icon}
</button>
)}
</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>
);
};
export default SideToolbar;
@@ -0,0 +1,78 @@
// src/SliderWithIndicator.tsx
import React, { useState, useRef } from "react";
interface SliderWithIndicatorProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
label?: string;
className?: string;
}
const SliderWithIndicator: React.FC<SliderWithIndicatorProps> = ({
value,
onChange,
min = 1,
max = 64,
label,
className = "w-48",
}) => {
const [isDragging, setIsDragging] = useState(false);
const [sliderPosition, setSliderPosition] = useState(0);
const sliderRef = useRef<HTMLInputElement>(null);
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) {
const rect = slider.getBoundingClientRect();
const percentage = (newValue - min) / (max - min);
const position = rect.width * percentage;
setSliderPosition(position);
}
};
return (
<div className="relative">
{label && <p className="mb-2 text-sm font-medium text-white">{label}</p>}
<div className="relative">
<input
ref={sliderRef}
type="range"
min={min}
max={max}
value={value}
onChange={handleSliderChange}
onMouseDown={() => setIsDragging(true)}
onMouseUp={() => setIsDragging(false)}
onMouseLeave={() => setIsDragging(false)}
className={`${className} accent-black-600`}
/>
{isDragging && (
<div
className="
absolute
top-6 -translate-x-1/2
transform rounded
bg-[#5F5F68]
px-2 py-1
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` }}
>
{value}
</div>
)}
</div>
</div>
);
};
export default SliderWithIndicator;
@@ -0,0 +1,82 @@
import { useRef, useState, useCallback, ReactNode, CSSProperties } from "react";
export type SplitPaneProps = {
/** What should appear in the left pane */
left: ReactNode;
/** What should appear in the right pane */
right: ReactNode;
/** Starting width of the left pane (in %), default = 50 % */
initialPercent?: number;
/** Smallest allowed left-pane width, default = 15 % */
minPercent?: number;
/** Largest allowed left-pane width, default = 85 % */
maxPercent?: number;
/** Fires on every drag with the new % */
onChange?: (percent: number) => void;
/** Extra classes for the outer wrapper */
className?: string;
};
export default function SplitPane({
left,
right,
initialPercent = 50,
minPercent = 15,
maxPercent = 85,
onChange,
className = "",
}: SplitPaneProps) {
const rootRef = useRef<HTMLDivElement>(null);
const [leftPct, setLeftPct] = useState(initialPercent);
/** Same logic for mouse + touch */
const beginDrag = useCallback(() => {
const move = (clientX: number) => {
const { left: rootLeft, width } =
rootRef.current!.getBoundingClientRect();
const pct = ((clientX - rootLeft) / width) * 100;
const clamped = Math.min(maxPercent, Math.max(minPercent, pct));
setLeftPct(clamped);
onChange?.(clamped);
};
const handleMove = (e: MouseEvent | TouchEvent) =>
move(e instanceof MouseEvent ? e.clientX : e.touches[0].clientX);
/* attach & clean up */
window.addEventListener("mousemove", handleMove);
window.addEventListener("touchmove", handleMove, { passive: false });
const endDrag = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("touchmove", handleMove);
window.removeEventListener("mouseup", endDrag);
window.removeEventListener("touchend", endDrag);
};
window.addEventListener("mouseup", endDrag, { once: true });
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}%` };
return (
<div
ref={rootRef}
className={`pegboard-bg relative flex h-screen w-full overflow-hidden ${className}`}
>
<div style={leftStyle} className="h-full min-w-0 overflow-hidden">
{left}
</div>
<div
className="w-1 cursor-col-resize select-none bg-gray-500/80
transition hover:bg-gray-600"
onMouseDown={beginDrag}
onTouchStart={beginDrag}
/>
<div className="h-full flex-1 overflow-hidden">{right}</div>
</div>
);
}
@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "../../lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react';
import Konva from 'konva';
export const useStageCentering = (
stageRef: React.RefObject<Konva.Stage>,
leftPct: number,
leftPanelWidth: number,
leftPanelHeight: number
) => {
const [stagePosition, setStagePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const stage = stageRef.current;
if (stage) {
const containerWidth = window.innerWidth * (leftPct / 100);
const containerHeight = window.innerHeight;
// Calculate center position
const centerX = (containerWidth - leftPanelWidth) / 2;
const centerY = (containerHeight - leftPanelHeight) / 2;
setStagePosition({ x: centerX, y: centerY });
stage.position({ x: centerX, y: centerY });
}
}, [leftPct, leftPanelWidth, leftPanelHeight, window.innerWidth, window.innerHeight]);
return stagePosition;
};
@@ -0,0 +1,42 @@
import { useEffect } from 'react';
interface UseCopyPasteHotkeysProps {
onCopy: () => void;
onPaste: () => void;
// Optional: to disable hotkeys when an input is focused
disableWhenInputFocused?: boolean;
}
export const useCopyPasteHotkeys = ({
onCopy,
onPaste,
disableWhenInputFocused = true
}: UseCopyPasteHotkeysProps): void => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (disableWhenInputFocused) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return; // Ignore if typing in an input, textarea, etc.
}
}
if (event.ctrlKey || event.metaKey) { // Ctrl or Cmd key
if (event.key === 'c' || event.key === 'C') {
event.preventDefault(); // Prevent browser's default copy action
console.log("Copy")
onCopy();
} else if (event.key === 'v' || event.key === 'V') {
event.preventDefault(); // Prevent browser's default paste action
console.log("Paste")
onPaste();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [onCopy, onPaste, disableWhenInputFocused]); // Add disableWhenInputFocused to dependencies
};
@@ -0,0 +1,29 @@
import { useEffect, useCallback } from 'react';
interface DeleteHotkeysProps {
onDelete: () => void;
target?: Document | HTMLElement;
}
export const useDeleteHotkeys = ({ onDelete, target = document }: DeleteHotkeysProps): void => {
const handleKeyDown = useCallback((event: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const isDelete = event.key === 'Delete' ||
event.key === 'Backspace' ||
(isMac && event.metaKey && event.key === 'Backspace');
if (isDelete) {
event.preventDefault();
onDelete();
}
}, [onDelete]);
useEffect(() => {
const currentTarget = target || document;
currentTarget.addEventListener('keydown', handleKeyDown as EventListener);
return () => {
currentTarget.removeEventListener('keydown', handleKeyDown as EventListener);
};
}, [handleKeyDown, target]);
};
@@ -0,0 +1,36 @@
import { useEffect } from 'react';
// This piece of code ensures that when you are selecting or dragging and go off the stage,
// it releases the controls mouse drag.
export const useGlobalMouseUp = (
setIsDragging: (value: boolean) => void,
setIsDrawing: (value: boolean) => void,
setCurrentLineId: (value: string | null) => void,
setIsSelecting: (value: boolean) => void,
isSelectingRef: React.MutableRefObject<boolean>,
setSelectionRect: (value: any) => void,
setLastPoint: (value: { x: number, y: number } | null) => void,
onSelectionChange?: (isSelecting: boolean) => void,
) => {
useEffect(() => {
const handleGlobalMouseUp = () => {
// Always reset all states when mouse is released anywhere
setIsDragging(false);
setIsDrawing(false);
setCurrentLineId(null);
setIsSelecting(false);
isSelectingRef.current = false;
onSelectionChange?.(false);
setSelectionRect(null);
setLastPoint(null);
};
// Listen to mouseup on the entire window
window.addEventListener('mouseup', handleGlobalMouseUp);
window.addEventListener('touchend', handleGlobalMouseUp);
return () => {
window.removeEventListener('mouseup', handleGlobalMouseUp);
window.removeEventListener('touchend', handleGlobalMouseUp);
};
}, [onSelectionChange]);
}
@@ -0,0 +1,29 @@
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);
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]);
return previewScale;
};
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
interface UndoRedoHotkeysProps {
undo: () => void;
redo: () => void;
target?: Document | HTMLElement; // Optional target, defaults to document
}
export const useUndoRedoHotkeys = ({ undo, redo, target = document }: UndoRedoHotkeysProps): void => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const isZ = event.key.toLowerCase() === 'z';
const isY = event.key.toLowerCase() === 'y';
// Use a flag to ensure we only call undo/redo once per event,
// even if multiple conditions for redo are met.
let actionPerformed = false;
const isUndo = (isMac ? event.metaKey : event.ctrlKey) && isZ && !event.shiftKey;
// Redo conditions:
// 1. Mac: Cmd + Shift + Z
// 2. Windows/Linux: Ctrl + Y
// 3. Windows/Linux (alternative): Ctrl + Shift + Z
const isRedoMac = isMac && event.metaKey && event.shiftKey && isZ;
const isRedoWinY = !isMac && event.ctrlKey && isY;
const isRedoWinShiftZ = !isMac && event.ctrlKey && event.shiftKey && isZ;
if (isUndo) {
event.preventDefault();
console.log('Undo action triggered');
undo();
actionPerformed = true;
} else if (isRedoMac || isRedoWinY || isRedoWinShiftZ) {
event.preventDefault();
console.log('Redo action triggered');
redo();
actionPerformed = true;
}
};
const currentTarget = target || document;
// Ensure 'keydown' is used as some systems might not fire 'keypress' for Ctrl/Cmd combinations
currentTarget.addEventListener('keydown', handleKeyDown as EventListener);
return () => {
currentTarget.removeEventListener('keydown', handleKeyDown as EventListener);
};
}, [undo, redo, target]); // Re-run effect if callbacks or target change
};
@@ -0,0 +1,73 @@
import { useState, useEffect } from 'react';
const debugOutput = (uri:string, name:string)=> {
var link = document.createElement('a');
link.download = name;
link.href = uri;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
export const useStageSnapshot = (
stageRef: React.RefObject<any>,
imageRef: React.RefObject<any>,
isSelectingRef: React.RefObject<boolean>,
transformerRefs: React.RefObject<{ [key: string]: Konva.Transformer }>
) => {
const [animationFrameId, setAnimationFrameId] = useState<number | null>(null);
const captureStageImage = (stageRef: any): string => {
const oldPos = { ...stageRef.current.position() };
const oldScale = { x: stageRef.current.scaleX(), y: stageRef.current.scaleY() };
// Hide all transformers
Object.values(transformerRefs.current).forEach(transformer => {
transformer.visible(false);
});
stageRef.current.position({ x: 0, y: 0 });
stageRef.current.scale({ x: 1, y: 1 });
stageRef.current.draw();
const uri = stageRef.current.toDataURL({ width: 1024, height: 1024, pixelRatio: 1 });
// Restore transformers
Object.values(transformerRefs.current).forEach(transformer => {
transformer.visible(true);
});
stageRef.current.position(oldPos);
stageRef.current.scale(oldScale);
stageRef.current.draw();
return uri;
};
useEffect(() => {
const updateSnapshot = () => {
if (!isSelectingRef.current && stageRef.current && imageRef.current) {
const uri = captureStageImage(stageRef);
if (uri) {
const img = new window.Image();
img.onload = () => {
imageRef.current?.setAttrs({
image: img
});
};
img.src = uri;
}
}
const frameId = requestAnimationFrame(updateSnapshot);
setAnimationFrameId(frameId);
};
const frameId = requestAnimationFrame(updateSnapshot);
setAnimationFrameId(frameId);
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
}, []);
};
@@ -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,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,870 @@
import { create } from 'zustand';
import { Node } from '../Node';
// Add LineNode type
export type LineNode = {
id: string;
type: 'line';
points: number[];
stroke: string;
strokeWidth: number;
draggable: boolean;
x?: number;
y?: number;
rotation?: number;
scaleX?: number;
scaleY?: number;
offsetX?: number;
offsetY?: number;
};
export type ActiveTool = 'select' | 'draw' | 'eraser' | 'backgroundColor' | 'shape';
interface SceneState {
// Nodes
nodes: Node[];
selectedNodeIds: string[];
lineNodes: LineNode[]; // Add lineNodes to state
// Clipboard
clipboard: (Node | LineNode)[]; // To store copied items
// Toolbar state
activeTool: ActiveTool;
brushColor: string;
brushSize: number;
fillColor: string;
// Cursor state
cursorPosition: { x: number; y: number } | null;
cursorVisible: boolean;
// Actions
addNode: (node: Node) => void;
removeNode: (id: string, shouldSaveState?: boolean) => void;
updateNode: (id: string, updates: Partial<Node>, 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;
// Node creation helpers
createRectangle: (x: number, y: number, width?: number, height?: number, fill?: string) => void;
createCircle: (x: number, y: number, radius?: number, fill?: string) => void;
createTriangle: (x: number, y: number, width?: number, height?: number, fill?: string) => void;
createImage: (x: number, y: number, source: string | File, width?: number, height?: number) => 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) => void;
removeLineNode: (id: string, shouldSaveState?: boolean) => void;
updateLineNode: (id: string, updates: Partial<LineNode>) => void;
moveLineNode: (id: string, dx: number, dy: number) => 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: ActiveTool) => void;
setBrushColor: (color: string) => void;
setBrushSize: (size: number) => void;
setFillColor: (color: string) => void;
// Cursor actions
setCursorPosition: (position: { x: number; y: number } | null) => void;
setCursorVisible: (visible: boolean) => void;
// Scene save/load actions
exportSceneAsJson: () => Promise<string>;
importSceneFromJson: (jsonString: string) => boolean;
saveSceneToFile: () => Promise<void>;
loadSceneFromFile: (file: File) => Promise<boolean>;
}
const generateId = (): string => {
return Math.random().toString(36).substring(2, 9);
};
// Add a flag to prevent saving state during restoration
let isRestoring = false;
export const useSceneStore = create<SceneState>((set, get) => ({
// Initial state
nodes: [],
lineNodes: [],
selectedNodeIds: [],
clipboard: [], // Initialize clipboard
history: [],
historyIndex: -1,
// Toolbar initial state
activeTool: 'select',
brushColor: '#000000',
brushSize: 5,
fillColor: 'white',
// Cursor initial state
cursorPosition: null,
cursorVisible: false,
// Actions
addNode: (node: Node) => {
set((state) => {
const newNodes = [...state.nodes, node];
return { nodes: newNodes };
});
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<Node>, shouldSaveState: boolean = true) => {
set((state) => {
const newNodes = state.nodes.map(node => {
if (node.id === id) {
return new Node({ ...node, ...updates });
}
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();
},
// Node creation helpers
createRectangle: (x: number, y: number, width = 100, height = 100, fill = '#e0e0e0') => {
const node = new Node({
id: generateId(),
type: 'rectangle',
x,
y,
width,
height,
fill,
stroke: '#444',
strokeWidth: 2,
draggable: true
});
get().addNode(node);
get().setActiveTool('select'); // Switch to select tool after creating
},
createCircle: (x: number, y: number, radius = 50, fill = '#f0f0f0') => {
const node = new Node({
id: generateId(),
type: 'circle',
x,
y,
width: radius * 2,
height: radius * 2,
fill,
stroke: '#333',
strokeWidth: 2,
draggable: true
});
get().addNode(node);
get().setActiveTool('select'); // Switch to select tool after creating
},
createTriangle: (x: number, y: number, width = 100, height = 100, fill = '#d0d0d0') => {
const node = new Node({
id: generateId(),
type: 'triangle',
x,
y,
width,
height,
fill,
stroke: '#555',
strokeWidth: 2,
draggable: true
});
get().addNode(node);
get().setActiveTool('select'); // Switch to select tool after creating
},
// 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) => {
set((state) => {
const newLineNodes = [...state.lineNodes, lineNode];
return { lineNodes: newLineNodes };
});
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<LineNode>) => {
set((state) => {
const newLineNodes = state.lineNodes.map(node => {
if (node.id === id) {
return { ...node, ...updates };
}
return node;
});
return { lineNodes: newLineNodes };
});
get().saveState();
},
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;
const finalWidth = width || Math.min(img.naturalWidth, 300);
const finalHeight = height || finalWidth / aspectRatio;
get().createImage(x, y, file, finalWidth, finalHeight);
};
img.src = dataUrl;
}
};
reader.readAsDataURL(file);
},
// Add method for URL-based images
createImageFromUrl: (x: number, y: number, url: string, width?: number, height?: number) => {
// Similar to createImageFromFile but handles URL
const img = new Image();
img.crossOrigin = "Anonymous"; // Handle CORS if necessary
img.onload = () => {
const aspectRatio = img.width / img.height;
const newWidth = width || (height ? height * aspectRatio : 200); // Default width or calculate from height
const newHeight = height || (width ? width / aspectRatio : newWidth / aspectRatio); // Default height or calculate from width
const node = new Node({
id: generateId(),
type: 'image',
x,
y,
width: newWidth,
height: newHeight,
fill: 'transparent',
imageElement: img, // Use the loaded image object
draggable: true,
imageUrl: url // Store the URL as the source
});
get().addNode(node);
};
img.onerror = () => {
console.error("Error loading image from URL:", url);
// Optionally handle the error, e.g., by creating a placeholder or notifying the user
};
img.src = url;
},
// Update the createImage method to handle both URLs and Files
createImage: (x: number, y: number, source: string | File, width = 200, height = 200) => {
const nodeId = generateId(); // Define nodeId here to use it in the catch block for logging
const node = new Node({
id: nodeId,
type: 'image',
x,
y,
width,
height,
fill: 'transparent',
stroke: '#333',
strokeWidth: 2,
draggable: true,
imageUrl: typeof source === 'string' ? source : undefined,
imageFile: typeof source === 'string' ? undefined : source
});
// Load the image asynchronously
node.setImage(source).then(() => {
// Update the node in the store after image loads
get().updateNode(node.id, node, false);
}).catch(console.error);
get().addNode(node);
},
// 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: ActiveTool) => set({ activeTool: tool }),
setBrushColor: (color: string) => set({ brushColor: color }),
setBrushSize: (size: number) => set({ brushSize: size }),
setFillColor: (color: string) => set({ fillColor: 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<string> => {
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;
}
},
}));
@@ -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);
}
@@ -48,10 +48,7 @@ import { EngineContext } from "./contexts/EngineContext";
import Queue, { QueueNames } from "./Queue";
import { toEngineActions } from "./Queue/toEngineActions";
import { UnionedDataTypes } from "./Queue/Queue";
import { LoginModal } from "@storyteller/ui-login-modal";
import { DomLevels } from "./signals/hotkeys";
import { AUTH_STATUS } from "../../enums/authentication";
import { authentication } from "../../signals/authentication/authentication";
import {
topNavMediaId,
topNavMediaUrl,
@@ -69,6 +66,8 @@ import {
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import PageDraw from "../PageDraw/PageDraw";
export const PageEditor = () => {
useSignals();
@@ -438,9 +437,10 @@ export const PageEditor = () => {
</div>
</div>
</>
<KonvaCanvasContainer style={{ display: display2d }}>
{/* <KonvaCanvasContainer style={{ display: display2d }}>
<KonvaRootComponent className="h-full w-full" />
</KonvaCanvasContainer>
</KonvaCanvasContainer> */}
<PageDraw className="h-full w-full" style={{ display: display2d }} />
<div style={{ display: displayVideo }}>
<ImageToVideo
@@ -456,7 +456,7 @@ export const PageEditor = () => {
/>
</div>
<LoginModal
{/* <LoginModal
onClose={() => { }}
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
@@ -472,7 +472,7 @@ export const PageEditor = () => {
authentication.status.value = AUTH_STATUS.LOGGED_IN;
authentication.userInfo.value = userInfo;
}}
/>
/> */}
</div>
);
};
+3
View File
@@ -40,6 +40,7 @@
"@netlify/remix-edge-adapter": "^3.2.0",
"@netlify/remix-runtime": "^2.2.0",
"@preact/signals-react": "^2.0.1",
"@radix-ui/react-tooltip": "^1.2.7",
"@remix-run/deno": "^2.8.1",
"@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.8.1",
@@ -49,6 +50,7 @@
"@tweenjs/tween.js": "^18.6.4",
"@types/lodash": "^4.17.6",
"@types/react-virtualized": "^9.22.2",
"@uiw/color-convert": "^2.5.5",
"@zip.js/zip.js": "^2.7.51",
"ammo.js": "^0.0.10",
"ccapture.js": "github:aceslowman/ccapture.js#npm-fix",
@@ -69,6 +71,7 @@
"react-drag-drop-files": "^2.3.10",
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.5",
"react-konva": "^18.2.10",
"react-router": "^6.26.1",
"react-router-dom": "^6.26.1",
"react-virtualized": "^9.22.6",
@@ -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("");
+534 -2
View File
@@ -17,12 +17,14 @@
"@preact/signals-react": "^3.0.1",
"@remix-run/serve": "^2.15.0",
"framer-motion": "^12.11.0",
"lucide-react": "^0.512.0",
"pnpm": "^10.8.1",
"react": "18.3.1",
"react-device-detect": "^2.2.3",
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"react-router-dom": "6.29.0"
"react-router-dom": "6.29.0",
"zustand": "^5.0.5"
},
"devDependencies": {
"@babel/core": "^7.14.5",
@@ -150,6 +152,7 @@
"@netlify/remix-edge-adapter": "^3.2.0",
"@netlify/remix-runtime": "^2.2.0",
"@preact/signals-react": "^2.0.1",
"@radix-ui/react-tooltip": "^1.2.7",
"@remix-run/deno": "^2.8.1",
"@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.8.1",
@@ -159,6 +162,7 @@
"@tweenjs/tween.js": "^18.6.4",
"@types/lodash": "^4.17.6",
"@types/react-virtualized": "^9.22.2",
"@uiw/color-convert": "^2.5.5",
"@zip.js/zip.js": "^2.7.51",
"ammo.js": "^0.0.10",
"ccapture.js": "github:aceslowman/ccapture.js#npm-fix",
@@ -179,6 +183,7 @@
"react-drag-drop-files": "^2.3.10",
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.5",
"react-konva": "^18.2.10",
"react-router": "^6.26.1",
"react-router-dom": "^6.26.1",
"react-virtualized": "^9.22.6",
@@ -780,6 +785,15 @@
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"apps/editor3d/node_modules/@types/react-reconciler": {
"version": "0.28.9",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"apps/editor3d/node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
@@ -1158,6 +1172,49 @@
}
}
},
"apps/editor3d/node_modules/react-konva": {
"version": "18.2.10",
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz",
"integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.2",
"its-fine": "^1.1.1",
"react-reconciler": "~0.29.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"apps/editor3d/node_modules/react-konva/node_modules/its-fine": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz",
"integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==",
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.0"
},
"peerDependencies": {
"react": ">=18.0"
}
},
"apps/editor3d/node_modules/vite": {
"version": "5.4.18",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz",
@@ -6308,6 +6365,415 @@
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
"integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
"integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.7",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-aria/focus": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.1.tgz",
@@ -10418,7 +10884,7 @@
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-1KfiQKsH1o00p9m5ag12axHQSb3FOU9H20UTrujVSkNhuCrRHiQWFqgEnTNK5ZNfnzZv8UWrnXVqCmCF9fgY3w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
@@ -11010,6 +11476,18 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@uiw/color-convert": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.5.5.tgz",
"integrity": "sha512-sNKhJe3h/nMxxoB3NEr02RTjkSE85sLIwQyl1lVK4scVCQumPbz1giWcWlYnSRLBHmrYYat7xShrDf/+Uc3UAg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -20333,6 +20811,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.512.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.512.0.tgz",
"integrity": "sha512-VCLpMynBVa+UvEPhs8fXluoa5nh7oPn3JtJ9F29+LmNi6q70IRxx80OBFT5KT6/T5/2hX+XkoWOC8zcRQI7Mzg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/luxon": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
@@ -23597,6 +24084,22 @@
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-reconciler": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -28133,6 +28636,35 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz",
"integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+3 -1
View File
@@ -9,12 +9,14 @@
"@preact/signals-react": "^3.0.1",
"@remix-run/serve": "^2.15.0",
"framer-motion": "^12.11.0",
"lucide-react": "^0.512.0",
"pnpm": "^10.8.1",
"react": "18.3.1",
"react-device-detect": "^2.2.3",
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"react-router-dom": "6.29.0"
"react-router-dom": "6.29.0",
"zustand": "^5.0.5"
},
"devDependencies": {
"@babel/core": "^7.14.5",