mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15dd81f8f0 | ||
|
|
b1709c6ecf | ||
|
|
2eb91528e7 | ||
|
|
57169c470a | ||
|
|
6341f8120e | ||
|
|
44c0dfd920 | ||
|
|
d742c61390 |
@@ -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 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
|
||||
Generated
+534
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user