mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-23 10:09:29 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b76ec47c9d | ||
|
|
340741a5ef | ||
|
|
9bb7a4be4d | ||
|
|
0f65bc8fac |
@@ -16,7 +16,8 @@
|
||||
"width": 2400,
|
||||
"height": 1300,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
"fullscreen": false,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -33,7 +34,7 @@
|
||||
"https://www.googletagmanager.com",
|
||||
"ipc://localhost",
|
||||
"tauri://localhost"
|
||||
],
|
||||
],
|
||||
"connect-src": [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface NodeProps {
|
||||
scaleY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export class Node {
|
||||
@@ -43,7 +42,6 @@ export class Node {
|
||||
scaleY: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
zIndex: number;
|
||||
|
||||
constructor({
|
||||
id,
|
||||
@@ -64,8 +62,7 @@ export class Node {
|
||||
scaleX = 1,
|
||||
scaleY = 1,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
zIndex = 0
|
||||
offsetY = 0
|
||||
}: NodeProps) {
|
||||
this.id = id;
|
||||
this.x = x;
|
||||
@@ -86,7 +83,6 @@ export class Node {
|
||||
this.scaleY = scaleY;
|
||||
this.offsetX = offsetX;
|
||||
this.offsetY = offsetY;
|
||||
this.zIndex = zIndex;
|
||||
}
|
||||
|
||||
setPosition(x: number, y: number): void {
|
||||
|
||||
@@ -14,8 +14,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faClock, faImage } from "@fortawesome/pro-solid-svg-icons";
|
||||
import Konva from "konva"; // just for types
|
||||
|
||||
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap"
|
||||
import { captureStageImageBitmap } from "./hooks/useUpdateSnapshot"
|
||||
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap";
|
||||
import { captureStageImageBitmap } from "./hooks/useUpdateSnapshot";
|
||||
import { ContextMenuContainer } from "./components/ui/ContextMenu";
|
||||
const PageDraw = () => {
|
||||
//useStateSceneLoader();
|
||||
@@ -271,58 +271,23 @@ const PageDraw = () => {
|
||||
activeToolId={store.activeTool}
|
||||
/>
|
||||
<div className="relative z-0">
|
||||
<ContextMenuContainer
|
||||
onAction={(e, action) => {
|
||||
|
||||
if (action === "contextMenu") {
|
||||
const hasSelection = store.selectedNodeIds.length > 0;
|
||||
if (hasSelection) {
|
||||
console.log("An item is selected.");
|
||||
// You can add additional actions here based on the selection
|
||||
return true
|
||||
} else {
|
||||
console.log("No item is selected.");
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}}
|
||||
onMenuAction={(action) => {
|
||||
|
||||
switch (action) {
|
||||
case 'BRING_TO_FRONT':
|
||||
store.bringToFront(store.selectedNodeIds);
|
||||
break;
|
||||
case 'BRING_FORWARD':
|
||||
store.bringForward(store.selectedNodeIds);
|
||||
break;
|
||||
case 'SEND_BACKWARD':
|
||||
store.sendBackward(store.selectedNodeIds);
|
||||
break;
|
||||
case 'SEND_TO_BACK':
|
||||
store.sendToBack(store.selectedNodeIds);
|
||||
break;
|
||||
default:
|
||||
// No action needed for unhandled cases
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
stageRef={stageRef}
|
||||
transformerRefs={transformerRefs}
|
||||
/>
|
||||
</ContextMenuContainer>
|
||||
<ContextMenuContainer>
|
||||
<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}
|
||||
stageRef={stageRef}
|
||||
transformerRefs={transformerRefs}
|
||||
/>
|
||||
</ContextMenuContainer>
|
||||
</div>
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
|
||||
<PopoverMenu
|
||||
|
||||
@@ -774,7 +774,6 @@ export const PaintSurface = ({
|
||||
rotation={lineNode.rotation || 0}
|
||||
offsetX={lineNode.offsetX || 0}
|
||||
offsetY={lineNode.offsetY || 0}
|
||||
zIndex={lineNode.zIndex}
|
||||
/>
|
||||
{renderTransformer()}
|
||||
</React.Fragment>
|
||||
@@ -792,13 +791,12 @@ export const PaintSurface = ({
|
||||
height={node.height}
|
||||
fill={node.fill}
|
||||
stroke={node.stroke}
|
||||
strokeWidth={0}
|
||||
strokeWidth={node.strokeWidth}
|
||||
rotation={node.rotation || 0}
|
||||
scaleX={node.scaleX || 1}
|
||||
scaleY={node.scaleY || 1}
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
zIndex={node.zIndex}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
@@ -824,13 +822,12 @@ export const PaintSurface = ({
|
||||
height={node.height}
|
||||
fill={node.fill}
|
||||
stroke={node.stroke}
|
||||
strokeWidth={0}
|
||||
strokeWidth={node.strokeWidth}
|
||||
rotation={node.rotation || 0}
|
||||
scaleX={node.scaleX || 1}
|
||||
scaleY={node.scaleY || 1}
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
zIndex={node.zIndex}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
@@ -856,13 +853,12 @@ export const PaintSurface = ({
|
||||
radius={Math.min(node.width, node.height) / 2}
|
||||
fill={node.fill}
|
||||
stroke={node.stroke}
|
||||
strokeWidth={0}
|
||||
strokeWidth={node.strokeWidth}
|
||||
rotation={node.rotation || 0}
|
||||
scaleX={node.scaleX || 1}
|
||||
scaleY={node.scaleY || 1}
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
zIndex={node.zIndex}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
@@ -896,7 +892,6 @@ export const PaintSurface = ({
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
listening={false}
|
||||
zIndex={node.zIndex}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
@@ -1004,12 +999,7 @@ export const PaintSurface = ({
|
||||
/>
|
||||
|
||||
{/* Render all nodes including line nodes */}
|
||||
{[...nodes, ...store.lineNodes]
|
||||
.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0))
|
||||
.map((node, index) => {
|
||||
// console.log(`Node ${index}:`, node);
|
||||
return renderNode(node);
|
||||
})}
|
||||
{[...nodes, ...store.lineNodes].map((node) => renderNode(node))}
|
||||
|
||||
{/* Render selection rectangle */}
|
||||
{selectionRect && (
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export interface MenuPosition {
|
||||
interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
interface MenuItem {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
action: string; // Instead of onClick, we use an action identifier
|
||||
onClick: () => void;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
items: MenuItem[];
|
||||
onAction: (action: string) => void; // Single callback for all menu items
|
||||
onClose: () => void;
|
||||
position: MenuPosition;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({ items, onAction, onClose, position }) => {
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({ items, onClose, position }) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,7 +45,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({ items, onAction, onClo
|
||||
<React.Fragment key={index}>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction(item.action); // Pass the action identifier up
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full px-4 py-2.5 flex items-center gap-3 hover:bg-[#333333] text-[#ECECEC] text-[13px] transition-colors duration-75 ease-in-out"
|
||||
@@ -63,79 +62,30 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({ items, onAction, onClo
|
||||
);
|
||||
};
|
||||
|
||||
interface ContextMenuContainerProps {
|
||||
children: React.ReactNode;
|
||||
items?: MenuItem[];
|
||||
onAction?: (e: React.MouseEvent, actionName: string) => boolean | void;
|
||||
onMenuAction?: (action: string) => void; // New prop for menu item actions
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ContextMenuContainer: React.FC<ContextMenuContainerProps> = ({
|
||||
children,
|
||||
items = defaultMenuItems,
|
||||
onAction,
|
||||
onMenuAction,
|
||||
className = ''
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = React.useState(false);
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0 });
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const newPosition = { x: e.clientX, y: e.clientY };
|
||||
setPosition(newPosition);
|
||||
|
||||
const shouldShowMenu = onAction?.(e, 'contextMenu');
|
||||
if (shouldShowMenu !== undefined) {
|
||||
setShowMenu(shouldShowMenu);
|
||||
} else {
|
||||
setShowMenu(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
onAction?.(e, 'click');
|
||||
};
|
||||
|
||||
const handleMenuAction = (action: string) => {
|
||||
onMenuAction?.(action);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`w-full h-full ${className}`}
|
||||
>
|
||||
{children}
|
||||
{showMenu && (
|
||||
<ContextMenu
|
||||
items={items}
|
||||
onAction={handleMenuAction}
|
||||
position={position}
|
||||
onClose={() => setShowMenu(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Default menu items now use action identifiers instead of callbacks
|
||||
const defaultMenuItems: MenuItem[] = [
|
||||
// Example usage with icons
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 3L4 7.5L12 12L20 7.5L12 3Z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M4 16.5L12 21L20 16.5" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M4 12L12 16.5L20 12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>,
|
||||
label: 'Convert to 3D',
|
||||
onClick: () => console.log('Convert to 3D')
|
||||
},
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 4v16h16M4 20l16-16" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>,
|
||||
label: 'Remove background',
|
||||
action: 'REMOVE_BACKGROUND'
|
||||
onClick: () => console.log('Remove background')
|
||||
},
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 10V14M8 6H16C17.1046 6 18 6.89543 18 8V16C18 17.1046 17.1046 18 16 18H8C6.89543 18 6 17.1046 6 16V8C6 6.89543 6.89543 6 8 6Z" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>,
|
||||
label: 'Lock',
|
||||
action: 'LOCK',
|
||||
onClick: () => console.log('Lock'),
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
@@ -143,28 +93,28 @@ const defaultMenuItems: MenuItem[] = [
|
||||
<path d="M5 15L12 8L19 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>,
|
||||
label: 'Bring to front',
|
||||
action: 'BRING_TO_FRONT'
|
||||
onClick: () => console.log('Bring to front')
|
||||
},
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M8 14L12 10L16 14" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>,
|
||||
label: 'Bring forward',
|
||||
action: 'BRING_FORWARD'
|
||||
onClick: () => console.log('Bring forward')
|
||||
},
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M8 10L12 14L16 10" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>,
|
||||
label: 'Send backward',
|
||||
action: 'SEND_BACKWARD'
|
||||
onClick: () => console.log('Send backward')
|
||||
},
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M5 9L12 16L19 9" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>,
|
||||
label: 'Send to back',
|
||||
action: 'SEND_TO_BACK',
|
||||
onClick: () => console.log('Send to back'),
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
@@ -172,7 +122,53 @@ const defaultMenuItems: MenuItem[] = [
|
||||
<path d="M16 8V6M16 8H8M16 8V16M8 8V6M8 8V16M8 16H6M8 16H16M16 16H18M6 6H8M8 6H16M16 6H18" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>,
|
||||
label: 'Duplicate',
|
||||
action: 'DUPLICATE'
|
||||
onClick: () => console.log('Duplicate')
|
||||
},
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M7 16L17 8M7 8L17 16" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>,
|
||||
label: 'Flip Horizontal',
|
||||
onClick: () => console.log('Flip Horizontal')
|
||||
},
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M8 7L16 17M8 17L16 7" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>,
|
||||
label: 'Flip Vertical',
|
||||
onClick: () => console.log('Flip Vertical')
|
||||
},
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 12V6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V18C20 19.1046 19.1046 20 18 20H12" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M9 15H4V20" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>,
|
||||
label: 'Fit to screen',
|
||||
onClick: () => console.log('Fit to screen')
|
||||
}
|
||||
];
|
||||
|
||||
// Container component that handles the right-click
|
||||
export const ContextMenuContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [showMenu, setShowMenu] = React.useState(false);
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0 });
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setPosition({ x: e.clientX, y: e.clientY });
|
||||
setShowMenu(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div onContextMenu={handleContextMenu} className="w-full h-full">
|
||||
{children}
|
||||
{showMenu && (
|
||||
<ContextMenu
|
||||
items={menuItems}
|
||||
position={position}
|
||||
onClose={() => setShowMenu(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ export type LineNode = {
|
||||
scaleY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type ActiveTool = 'select' | 'draw' | 'eraser' | 'backgroundColor' | 'shape';
|
||||
@@ -104,12 +103,6 @@ interface SceneState {
|
||||
// Add these two properties
|
||||
serializeSceneToString: () => Promise<string>;
|
||||
loadSceneFromString: (jsonString: string) => boolean;
|
||||
|
||||
// New functions
|
||||
bringToFront: (nodeIds: string[]) => void;
|
||||
sendToBack: (nodeIds: string[]) => void;
|
||||
bringForward: (nodeIds: string[]) => void;
|
||||
sendBackward: (nodeIds: string[]) => void;
|
||||
}
|
||||
|
||||
const generateId = (): string => {
|
||||
@@ -119,22 +112,6 @@ const generateId = (): string => {
|
||||
// Add a flag to prevent saving state during restoration
|
||||
let isRestoring = false;
|
||||
|
||||
// Add this helper function at the top of the store (before create<SceneState>)
|
||||
const getNextZIndex = (nodes: Node[], lineNodes: LineNode[]): number => {
|
||||
const allZIndices = [
|
||||
...nodes.map((n, index) => {
|
||||
//console.log(`Node: ${JSON.stringify(n)}, Index: ${index}`);
|
||||
return n.zIndex || 0;
|
||||
}),
|
||||
...lineNodes.map((n, index) => {
|
||||
//console.log(`LineNode: ${JSON.stringify(n)}, Index: ${index}`);
|
||||
return n.zIndex || 0;
|
||||
})
|
||||
];
|
||||
|
||||
return allZIndices.length > 0 ? Math.max(...allZIndices) + 1 : 0;
|
||||
};
|
||||
|
||||
export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
// Initial state
|
||||
nodes: [],
|
||||
@@ -158,17 +135,8 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
// Actions
|
||||
addNode: (node: Node) => {
|
||||
set((state) => {
|
||||
const nextZ = getNextZIndex(state.nodes, state.lineNodes);
|
||||
console.log(`NextZ for node ID ${node.id}: ${nextZ}`);
|
||||
const newNode = new Node({
|
||||
...node,
|
||||
zIndex: nextZ
|
||||
});
|
||||
// console.log("New Node with ID:", newNode.id);
|
||||
let nodes = [...state.nodes, newNode]
|
||||
console.log("Nodes after update")
|
||||
console.log(nodes)
|
||||
return { nodes: nodes };
|
||||
const newNodes = [...state.nodes, node];
|
||||
return { nodes: newNodes };
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
@@ -193,17 +161,9 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
|
||||
updateNode: (id: string, updates: Partial<Node>, shouldSaveState: boolean = true) => {
|
||||
set((state) => {
|
||||
|
||||
const newNodes = state.nodes.map(node => {
|
||||
let newZIndex = updates.zIndex !== undefined ? updates.zIndex : node.zIndex
|
||||
|
||||
|
||||
if (node.id === id) {
|
||||
return new Node({
|
||||
...node,
|
||||
...updates,
|
||||
zIndex: newZIndex
|
||||
});
|
||||
return new Node({ ...node, ...updates });
|
||||
}
|
||||
return node;
|
||||
});
|
||||
@@ -491,13 +451,8 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
// Add new line node actions
|
||||
addLineNode: (lineNode: LineNode) => {
|
||||
set((state) => {
|
||||
const nextZ = getNextZIndex(state.nodes, state.lineNodes);
|
||||
|
||||
const newLineNode = {
|
||||
...lineNode,
|
||||
zIndex: nextZ
|
||||
};
|
||||
return { lineNodes: [...state.lineNodes, newLineNode] };
|
||||
const newLineNodes = [...state.lineNodes, lineNode];
|
||||
return { lineNodes: newLineNodes };
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
@@ -524,11 +479,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
set((state) => {
|
||||
const newLineNodes = state.lineNodes.map(node => {
|
||||
if (node.id === id) {
|
||||
return {
|
||||
...node,
|
||||
...updates,
|
||||
zIndex: updates.zIndex !== undefined ? updates.zIndex : node.zIndex
|
||||
};
|
||||
return { ...node, ...updates };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
@@ -634,11 +585,11 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
// Load the image asynchronously
|
||||
node.setImage(source).then(() => {
|
||||
// Update the node in the store after image loads
|
||||
get().addNode(node);
|
||||
//get().updateNode(node.id, node, false);
|
||||
console.log('Image loaded for node:', node.id);
|
||||
get().updateNode(node.id, node, false);
|
||||
}).catch((error)=>console.error('Error loading image:', error));
|
||||
|
||||
|
||||
get().addNode(node);
|
||||
},
|
||||
|
||||
// Action for deleting selected items
|
||||
@@ -937,198 +888,5 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
},
|
||||
loadSceneFromString: (jsonString: string): boolean => {
|
||||
return get().importSceneFromJson(jsonString);
|
||||
},
|
||||
|
||||
// New functions
|
||||
bringToFront: (nodeIds: string[]) => {
|
||||
set(state => {
|
||||
// Get highest zIndex from all nodes and line nodes
|
||||
const allZIndices = [
|
||||
...state.nodes.map(n => n.zIndex || 0),
|
||||
...state.lineNodes.map(n => n.zIndex || 0)
|
||||
];
|
||||
const maxZ = Math.max(...allZIndices, 0) + 1;
|
||||
|
||||
return {
|
||||
nodes: state.nodes.map(node =>
|
||||
nodeIds.includes(node.id)
|
||||
? new Node({ ...node, zIndex: maxZ })
|
||||
: node
|
||||
),
|
||||
lineNodes: state.lineNodes.map(node =>
|
||||
nodeIds.includes(node.id)
|
||||
? { ...node, zIndex: maxZ }
|
||||
: node
|
||||
)
|
||||
};
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
|
||||
sendToBack: (nodeIds: string[]) => {
|
||||
set(state => {
|
||||
// Get lowest zIndex from all nodes and line nodes
|
||||
const allZIndices = [
|
||||
...state.nodes.map(n => n.zIndex || 0),
|
||||
...state.lineNodes.map(n => n.zIndex || 0)
|
||||
];
|
||||
const minZ = Math.min(...allZIndices, 0) - 1;
|
||||
|
||||
return {
|
||||
nodes: state.nodes.map(node =>
|
||||
nodeIds.includes(node.id)
|
||||
? new Node({ ...node, zIndex: minZ })
|
||||
: node
|
||||
),
|
||||
lineNodes: state.lineNodes.map(node =>
|
||||
nodeIds.includes(node.id)
|
||||
? { ...node, zIndex: minZ }
|
||||
: node
|
||||
)
|
||||
};
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
|
||||
bringForward: (nodeIds: string[]) => {
|
||||
set(state => {
|
||||
// Combine all items with their current zIndex
|
||||
const allItems = [
|
||||
...state.nodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) })),
|
||||
...state.lineNodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) }))
|
||||
].sort((a, b) => a.z - b.z);
|
||||
|
||||
// Find the highest z-index among selected items
|
||||
const selectedItems = allItems.filter(item => item.selected);
|
||||
if (selectedItems.length === 0) return state;
|
||||
|
||||
const highestSelectedZ = Math.max(...selectedItems.map(item => item.z));
|
||||
|
||||
// Find the closest item above our selection
|
||||
const nextItem = allItems.find(item => !item.selected && item.z > highestSelectedZ);
|
||||
|
||||
// If no item above, swap with the highest non-selected item
|
||||
if (!nextItem) {
|
||||
const highestNonSelected = allItems.filter(item => !item.selected)
|
||||
.sort((a, b) => b.z - a.z)[0];
|
||||
if (!highestNonSelected) return state;
|
||||
|
||||
// Swap positions
|
||||
return {
|
||||
nodes: state.nodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return new Node({ ...node, zIndex: highestNonSelected.z });
|
||||
}
|
||||
if (node.id === highestNonSelected.id) {
|
||||
return new Node({ ...node, zIndex: highestSelectedZ });
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
lineNodes: state.lineNodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return { ...node, zIndex: highestNonSelected.z };
|
||||
}
|
||||
if (node.id === highestNonSelected.id) {
|
||||
return { ...node, zIndex: highestSelectedZ };
|
||||
}
|
||||
return node;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const newZ = nextItem.z;
|
||||
return {
|
||||
nodes: state.nodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return new Node({ ...node, zIndex: newZ });
|
||||
}
|
||||
if (node.id === nextItem.id) {
|
||||
return new Node({ ...node, zIndex: highestSelectedZ });
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
lineNodes: state.lineNodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return { ...node, zIndex: newZ };
|
||||
}
|
||||
if (node.id === nextItem.id) {
|
||||
return { ...node, zIndex: highestSelectedZ };
|
||||
}
|
||||
return node;
|
||||
})
|
||||
};
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
|
||||
sendBackward: (nodeIds: string[]) => {
|
||||
set(state => {
|
||||
// Combine all items with their current zIndex
|
||||
const allItems = [
|
||||
...state.nodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) })),
|
||||
...state.lineNodes.map(n => ({ id: n.id, z: Math.max(0, n.zIndex || 0), selected: nodeIds.includes(n.id) }))
|
||||
].sort((a, b) => b.z - a.z);
|
||||
|
||||
// Find the lowest z-index among selected items
|
||||
const selectedItems = allItems.filter(item => item.selected);
|
||||
if (selectedItems.length === 0) return state;
|
||||
|
||||
const lowestSelectedZ = Math.min(...selectedItems.map(item => item.z));
|
||||
|
||||
// Find the closest item below our selection
|
||||
const nextItem = allItems.find(item => !item.selected && item.z < lowestSelectedZ);
|
||||
|
||||
// If no item below, swap with the lowest non-selected item
|
||||
if (!nextItem) {
|
||||
const lowestNonSelected = allItems.filter(item => !item.selected)
|
||||
.sort((a, b) => a.z - b.z)[0];
|
||||
if (!lowestNonSelected) return state;
|
||||
|
||||
// Swap positions
|
||||
return {
|
||||
nodes: state.nodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return new Node({ ...node, zIndex: lowestNonSelected.z });
|
||||
}
|
||||
if (node.id === lowestNonSelected.id) {
|
||||
return new Node({ ...node, zIndex: lowestSelectedZ });
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
lineNodes: state.lineNodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return { ...node, zIndex: lowestNonSelected.z };
|
||||
}
|
||||
if (node.id === lowestNonSelected.id) {
|
||||
return { ...node, zIndex: lowestSelectedZ };
|
||||
}
|
||||
return node;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const newZ = nextItem.z;
|
||||
return {
|
||||
nodes: state.nodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return new Node({ ...node, zIndex: newZ });
|
||||
}
|
||||
if (node.id === nextItem.id) {
|
||||
return new Node({ ...node, zIndex: lowestSelectedZ });
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
lineNodes: state.lineNodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return { ...node, zIndex: newZ };
|
||||
}
|
||||
if (node.id === nextItem.id) {
|
||||
return { ...node, zIndex: lowestSelectedZ };
|
||||
}
|
||||
return node;
|
||||
})
|
||||
};
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -8,19 +8,42 @@ import {
|
||||
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
showActionReminder,
|
||||
isActionReminderOpen,
|
||||
} from "@storyteller/ui-action-reminder-modal";
|
||||
import { SettingsModal } from "@storyteller/ui-settings-modal";
|
||||
import { EngineContext } from "../../contexts/EngineContext";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState, useRef, useCallback } from "react";
|
||||
import { assetModalVisibleDuringDrag, assetModalVisible } from "../../signals";
|
||||
import {
|
||||
Create3dModal,
|
||||
useCreate3dModalStore,
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
} from "@storyteller/ui-create-3d-modal";
|
||||
// import { v4 as uuidv4 } from "uuid";
|
||||
// import { addObject } from "../../signals/objectGroup/addObject";
|
||||
// import { MediaItem } from "../../models/assets";
|
||||
import { GetFalApiKey } from "@storyteller/tauri-api"; // Fix import path
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
// import { AssetType } from "~/enums";
|
||||
import { AssetModal } from "../AssetMenu/AssetModal";
|
||||
import { selectedMode } from "../../signals/selectedMode";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { outlinerState } from "../../signals/outliner/outliner";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { setLogoutStates } from "~/signals/authentication/utilities";
|
||||
|
||||
export const Controls3D = () => {
|
||||
useSignals();
|
||||
const editorEngine = useContext(EngineContext);
|
||||
const [showEmptySceneTooltip, setShowEmptySceneTooltip] = useState(false);
|
||||
// Action reminder is now handled through signals
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
|
||||
// Track processed 3D models by their media token to prevent duplicates
|
||||
const processedModelsRef = useRef<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// Check if scene is empty and onboarding helper is not visible
|
||||
@@ -79,6 +102,85 @@ export const Controls3D = () => {
|
||||
editorEngine.change_mode("scale");
|
||||
};
|
||||
|
||||
// ----- TODO LATER - BFlat for auto add 3d model to scene -----
|
||||
|
||||
// Function to add the generated 3D model to the scene
|
||||
// const addGeneratedModelToScene = useCallback((mediaToken: string) => {
|
||||
// console.log("[DEBUG] addGeneratedModelToScene called with token:", mediaToken);
|
||||
|
||||
// // Create a MediaItem object for the 3D model
|
||||
// const mediaItem: MediaItem = {
|
||||
// version: 1,
|
||||
// type: AssetType.OBJECT,
|
||||
// media_id: mediaToken,
|
||||
// name: "3D Model",
|
||||
// object_uuid: uuidv4(),
|
||||
// };
|
||||
|
||||
// // Add the object to the scene
|
||||
// console.log("[DEBUG] Calling addObject with:", mediaItem);
|
||||
// try {
|
||||
// addObject(mediaItem);
|
||||
// console.log("[DEBUG] addObject called successfully");
|
||||
// } catch (error) {
|
||||
// console.error("[DEBUG] Error in addObject:", error);
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
assetModalVisibleDuringDrag.value = true;
|
||||
assetModalVisible.value = true;
|
||||
};
|
||||
|
||||
const handleOpenCreate3dModal = async () => {
|
||||
try {
|
||||
const falApiKeyResult = await GetFalApiKey();
|
||||
|
||||
if (
|
||||
falApiKeyResult.status === "success" &&
|
||||
"payload" in falApiKeyResult &&
|
||||
falApiKeyResult.payload?.key
|
||||
) {
|
||||
// API key exists, open the create 3D modal
|
||||
useCreate3dModalStore.getState().open();
|
||||
} else {
|
||||
// No API key, show the action reminder modal
|
||||
showActionReminder({
|
||||
reminderType: "default",
|
||||
title: "FAL API Key Required",
|
||||
message:
|
||||
"To generate 3D models, you need to add a valid FAL API key in your settings.",
|
||||
primaryActionText: "Add now",
|
||||
secondaryActionText: "Cancel",
|
||||
onPrimaryAction: () => {
|
||||
// Close the action reminder modal first
|
||||
isActionReminderOpen.value = false;
|
||||
// Then open the settings modal
|
||||
openSettingsModal();
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking FAL API key:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const openSettingsModal = () => {
|
||||
setIsSettingsModalOpen(true);
|
||||
};
|
||||
|
||||
// Handle completed models from the Create3dModal component
|
||||
const handleModelComplete = useCallback((mediaToken: string) => {
|
||||
// Check if we've already processed this model
|
||||
if (mediaToken && !processedModelsRef.current[mediaToken]) {
|
||||
// Mark this model as processed to avoid duplicates
|
||||
processedModelsRef.current[mediaToken] = true;
|
||||
|
||||
// Add the generated 3D model to the scene
|
||||
// addGeneratedModelToScene(mediaToken);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modes = [
|
||||
{
|
||||
value: "move",
|
||||
@@ -100,11 +202,6 @@ export const Controls3D = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const handleOpenModal = () => {
|
||||
assetModalVisibleDuringDrag.value = true;
|
||||
assetModalVisible.value = true;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
@@ -148,10 +245,9 @@ export const Controls3D = () => {
|
||||
>
|
||||
<Button
|
||||
icon={faMagicWandSparkles}
|
||||
className="text-md h-9 w-9 rounded-[10px] bg-white/10"
|
||||
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
|
||||
variant="secondary"
|
||||
disabled={true}
|
||||
onClick={handleOpenModal}
|
||||
onClick={handleOpenCreate3dModal}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -167,6 +263,15 @@ export const Controls3D = () => {
|
||||
</div>
|
||||
|
||||
<AssetModal />
|
||||
|
||||
<Create3dModal onModelComplete={handleModelComplete} />
|
||||
|
||||
<SettingsModal
|
||||
isOpen={isSettingsModalOpen}
|
||||
onClose={() => setIsSettingsModalOpen(false)}
|
||||
globalAccountLogoutCallback={() => setLogoutStates()}
|
||||
initialSection="accounts"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
"noEmit": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libs/components/model-selector"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pagination"
|
||||
},
|
||||
@@ -49,9 +46,15 @@
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/create-3d-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/button-icon-select"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tab-selector"
|
||||
},
|
||||
@@ -61,32 +64,29 @@
|
||||
{
|
||||
"path": "../../libs/components/button-dropdown"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/tauri-api"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/login-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/soundboard"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/popover"
|
||||
"path": "../../libs/components/model-selector"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/soundboard"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/common"
|
||||
"path": "../../libs/components/popover"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/tauri-api"
|
||||
"path": "../../libs/common"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/menu-icon-selector"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# @storyteller/ui-create-3d-modal
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test @storyteller/ui-create-3d-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@storyteller/ui-create-3d-modal",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./lib/create-3d-modal";
|
||||
export * from "./lib/create-3d-modal-store";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Simplified store that only handles the modal open state
|
||||
interface Create3dModalState {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export const useCreate3dModalStore = create<Create3dModalState>((set) => ({
|
||||
isOpen: false,
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
}));
|
||||
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* Replace this with your own classes
|
||||
*
|
||||
* e.g.
|
||||
* .container {
|
||||
* }
|
||||
*/
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import Create3dModal from './create-3d-modal';
|
||||
|
||||
describe('Create3dModal', () => {
|
||||
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<Create3dModal />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useCreate3dModalStore } from "./create-3d-modal-store";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
|
||||
import { ImageInput, ImageFile } from "@storyteller/ui-image-input";
|
||||
import { faCube } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { MediaUploadApi } from "@storyteller/api";
|
||||
import {
|
||||
EnqueueImageTo3dObject,
|
||||
EnqueueImageTo3dObjectModel,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
enum GenerationAction {
|
||||
ImageTo3d = "image_to_3d",
|
||||
}
|
||||
|
||||
interface Create3dModalProps {
|
||||
onModelComplete?: (mediaToken: string) => void;
|
||||
}
|
||||
|
||||
export const Create3dModal = ({ onModelComplete }: Create3dModalProps = {}) => {
|
||||
const { isOpen, close } = useCreate3dModalStore();
|
||||
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
|
||||
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedImage, setSelectedImage] = useState<ImageFile | null>(null);
|
||||
|
||||
// Store active toast IDs for 3D model generation
|
||||
const activeToastIds = useRef<Record<string, string>>({});
|
||||
|
||||
// Listen for generation complete events
|
||||
useEffect(() => {
|
||||
let isUnmounted = false;
|
||||
let unlisten: Promise<UnlistenFn>;
|
||||
|
||||
const setup = async () => {
|
||||
unlisten = listen("generation-complete-event", (event: any) => {
|
||||
console.log("[DEBUG] Generation complete event received:", event);
|
||||
|
||||
// Check if this is an ImageTo3d completion event
|
||||
const eventData = event.payload?.data;
|
||||
|
||||
console.log("[DEBUG] eventData:", eventData);
|
||||
console.log("[DEBUG] Current activeToastIds:", {...activeToastIds.current});
|
||||
|
||||
if (eventData?.action === GenerationAction.ImageTo3d) {
|
||||
console.log("[DEBUG] This is an ImageTo3d completion event");
|
||||
|
||||
// Find any active toasts for 3D model generation and update them
|
||||
const entries = Object.entries(activeToastIds.current);
|
||||
console.log("[DEBUG] Processing", entries.length, "active toast entries");
|
||||
|
||||
entries.forEach(
|
||||
([mediaToken, toastId]) => {
|
||||
console.log("[DEBUG] Processing toast for mediaToken:", mediaToken);
|
||||
|
||||
// Dismiss the loading toast
|
||||
toast.dismiss(toastId);
|
||||
console.log("[DEBUG] Toast dismissed");
|
||||
|
||||
// Call the callback if provided
|
||||
if (onModelComplete) {
|
||||
console.log("[DEBUG] Calling onModelComplete with mediaToken:", mediaToken);
|
||||
onModelComplete(mediaToken);
|
||||
} else {
|
||||
console.log("[DEBUG] onModelComplete callback is not provided");
|
||||
}
|
||||
|
||||
// Remove from active toasts
|
||||
delete activeToastIds.current[mediaToken];
|
||||
console.log("[DEBUG] Removed mediaToken from activeToastIds");
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log("[DEBUG] Not an ImageTo3d event, action:", eventData?.action);
|
||||
}
|
||||
});
|
||||
|
||||
if (isUnmounted) {
|
||||
unlisten.then((f) => f()); // Unsubscribe if unmounted early.
|
||||
}
|
||||
};
|
||||
|
||||
setup();
|
||||
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
if (unlisten) {
|
||||
unlisten.then((f) => f());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenGallery = () => {
|
||||
setIsGalleryModalOpen(true);
|
||||
};
|
||||
|
||||
const handleGalleryClose = () => {
|
||||
setIsGalleryModalOpen(false);
|
||||
setSelectedGalleryImages([]);
|
||||
};
|
||||
|
||||
const handleImageSelect = (id: string) => {
|
||||
setSelectedGalleryImages((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter((imageId) => imageId !== id);
|
||||
}
|
||||
// Only allow single selection for 3D model creation
|
||||
return [id];
|
||||
});
|
||||
};
|
||||
|
||||
const handleGalleryImages = (selectedItems: GalleryItem[]) => {
|
||||
if (selectedItems && selectedItems.length > 0) {
|
||||
const item = selectedItems[0];
|
||||
if (!item.fullImage) return;
|
||||
|
||||
const newImage: ImageFile = {
|
||||
id: item.id,
|
||||
url: item.fullImage,
|
||||
file: new File([], "gallery-image"),
|
||||
mediaToken: item.id,
|
||||
};
|
||||
setSelectedImage(newImage);
|
||||
}
|
||||
setIsGalleryModalOpen(false);
|
||||
setSelectedGalleryImages([]);
|
||||
};
|
||||
|
||||
// Process image for 3D model creation
|
||||
const processImageFor3D = useCallback(async (imageToProcess: ImageFile) => {
|
||||
let mediaToken = imageToProcess.mediaToken;
|
||||
let toastId: string;
|
||||
|
||||
try {
|
||||
// toast that won't disappear until we manually dismiss it
|
||||
toastId = toast.loading("Generating 3D model...", {
|
||||
duration: 300000, // max five minutes
|
||||
});
|
||||
|
||||
// If the image is from a local file and doesn't have a media token yet, upload it first
|
||||
if (!mediaToken && imageToProcess.file) {
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
const uuid = uuidv4();
|
||||
|
||||
const uploadResult = await mediaUploadApi.UploadImage({
|
||||
blob: imageToProcess.file,
|
||||
fileName: imageToProcess.file.name,
|
||||
uuid: uuid,
|
||||
});
|
||||
|
||||
if (!uploadResult.success || !uploadResult.data) {
|
||||
throw new Error("Failed to upload image");
|
||||
}
|
||||
|
||||
mediaToken = uploadResult.data;
|
||||
}
|
||||
|
||||
if (!mediaToken) {
|
||||
throw new Error("No media token available");
|
||||
}
|
||||
|
||||
// Update toast message to show progress
|
||||
toast.loading("Uploading and preparing image...", { id: toastId });
|
||||
|
||||
// Enqueue the image for 3D model creation using the Hunyuan 3D 2 model
|
||||
const result = await EnqueueImageTo3dObject({
|
||||
image_media_token: mediaToken,
|
||||
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
|
||||
});
|
||||
|
||||
if ("error_type" in result) {
|
||||
throw new Error(result.error_message || result.error_type);
|
||||
}
|
||||
|
||||
toast.loading("Generating 3D model...", {
|
||||
id: toastId,
|
||||
});
|
||||
|
||||
// Store the toast ID with the media token so we can update it when generation completes
|
||||
if (mediaToken) {
|
||||
activeToastIds.current[mediaToken] = toastId;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Handle errors
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred";
|
||||
toast.error(`Failed to create 3D model: ${errorMessage}`, {
|
||||
id: toastId!, // Use the non-null assertion as we know toastId is defined
|
||||
duration: 5000,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreate3DModel = () => {
|
||||
if (!selectedImage) return;
|
||||
|
||||
// Store the selected image information before closing the modal
|
||||
const imageToProcess = selectedImage;
|
||||
|
||||
// Clear the image field after starting the process
|
||||
setSelectedImage(null);
|
||||
|
||||
// Close the modal immediately
|
||||
close();
|
||||
|
||||
// Start the processing in the background
|
||||
processImageFor3D(imageToProcess);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
className={twMerge("max-w-xl duration-200")}
|
||||
backdropClassName={twMerge("duration-200")}
|
||||
childPadding={true}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-2.5 py-0.5">
|
||||
<h2 className="text-[18px] font-semibold">
|
||||
Create 3D Model from Image
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow space-y-6 py-4">
|
||||
<div className="text-center">
|
||||
{/* Image Input Component */}
|
||||
<ImageInput
|
||||
value={selectedImage}
|
||||
onChange={setSelectedImage}
|
||||
onGalleryOpen={handleOpenGallery}
|
||||
placeholderText="Drag and drop an image here"
|
||||
className="mb-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={!selectedImage}
|
||||
onClick={handleCreate3DModel}
|
||||
className="w-full"
|
||||
icon={faCube}
|
||||
>
|
||||
Create 3D Model
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<GalleryModal
|
||||
isOpen={isGalleryModalOpen}
|
||||
onClose={handleGalleryClose}
|
||||
mode="select"
|
||||
selectedItemIds={selectedGalleryImages}
|
||||
onSelectItem={handleImageSelect}
|
||||
maxSelections={1}
|
||||
forceFilter="image"
|
||||
onUseSelected={handleGalleryImages}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Create3dModal;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../button"
|
||||
},
|
||||
{
|
||||
"path": "../modal"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../../tsconfig.base.json"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
"@nx/react/typings/image.d.ts",
|
||||
"vite/client"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
|
||||
},
|
||||
"exclude": [
|
||||
"out-tsc",
|
||||
"dist",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../button/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../modal/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import * as path from 'path';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/components/create-3d-modal',
|
||||
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
// Configuration for building your library.
|
||||
// See: https://vitejs.dev/guide/build.html#library-mode
|
||||
build: {
|
||||
outDir: './dist',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points.
|
||||
entry: 'src/index.ts',
|
||||
name: '@storyteller/ui-create-3d-modal',
|
||||
fileName: 'index',
|
||||
// Change this to the formats you want to support.
|
||||
// Don't forget to update your package.json as well.
|
||||
formats: ['es' as const]
|
||||
},
|
||||
rollupOptions: {
|
||||
// External packages that should not be bundled into your library.
|
||||
external: ['react','react-dom','react/jsx-runtime']
|
||||
},
|
||||
},
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: './test-output/vitest/coverage',
|
||||
provider: 'v8' as const,
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -89,12 +89,12 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={twMerge(
|
||||
"w-full group relative overflow-visible rounded-md border-[3px] transition-all focus:outline-none focus:ring-2 focus:ring-primary",
|
||||
"w-full group relative overflow-visible rounded-md border-transparent border-[3px] transition-all",
|
||||
activeFilter === "video" ? "aspect-square" : "aspect-square",
|
||||
selected
|
||||
? "border-primary"
|
||||
: "border-transparent hover:border-primary",
|
||||
item.mediaClass === "video"
|
||||
: "border-transparent hover:border-primary/80",
|
||||
mode === "select" || item.mediaClass === "video"
|
||||
? "cursor-pointer"
|
||||
: "cursor-grab hover:cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
@@ -161,7 +161,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
selected
|
||||
? "border-primary"
|
||||
: "border-transparent hover:border-primary",
|
||||
item.mediaClass === "video"
|
||||
mode === "select" || item.mediaClass === "video"
|
||||
? "cursor-pointer"
|
||||
: "cursor-grab hover:cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,13 @@ import { LightboxModal } from "@storyteller/ui-lightbox-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { CloseButton } from "@storyteller/ui-close-button";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
FilterMediaClasses,
|
||||
GalleryModalApi,
|
||||
@@ -61,6 +67,11 @@ interface GalleryModalProps {
|
||||
media_id: string | undefined
|
||||
) => Promise<void>;
|
||||
isOpen?: boolean;
|
||||
/**
|
||||
* Optional filter to force when opening the modal. When set, users cannot change the filter.
|
||||
* Possible values: "all", "image", "video", "3d", "uploaded"
|
||||
*/
|
||||
forceFilter?: string;
|
||||
}
|
||||
|
||||
export const GalleryModal = React.memo(
|
||||
@@ -74,6 +85,7 @@ export const GalleryModal = React.memo(
|
||||
onDownloadClicked,
|
||||
onAddToSceneClicked,
|
||||
isOpen,
|
||||
forceFilter,
|
||||
}: GalleryModalProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lightboxImage, setLightboxImage] = useState<GalleryItem | null>(
|
||||
@@ -82,7 +94,15 @@ export const GalleryModal = React.memo(
|
||||
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
|
||||
const [failedImageUrls] = useState<Set<string>>(new Set());
|
||||
const [username, setUsername] = useState<string>("");
|
||||
const [activeFilter, setActiveFilter] = useState("all");
|
||||
const forceFilterRef = useRef(forceFilter);
|
||||
const [activeFilter, setActiveFilter] = useState(forceFilter || "all");
|
||||
|
||||
// If forceFilter is provided, always use it
|
||||
useEffect(() => {
|
||||
if (forceFilterRef.current) {
|
||||
setActiveFilter(forceFilterRef.current);
|
||||
}
|
||||
}, [forceFilterRef]);
|
||||
const minColumns = 3;
|
||||
const maxColumns = 12;
|
||||
// Default gridColumns to 5
|
||||
@@ -436,23 +456,40 @@ export const GalleryModal = React.memo(
|
||||
/>
|
||||
</div>
|
||||
{/* Filter popover */}
|
||||
<Tooltip position="top" content="Filter" closeOnClick={true}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
content={
|
||||
forceFilterRef.current ? "Filter locked" : "Filter"
|
||||
}
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
panelTitle="Filter"
|
||||
position="bottom"
|
||||
align="end"
|
||||
buttonClassName="relative z-[51] mr-3"
|
||||
buttonClassName={`relative z-[51] mr-3 ${
|
||||
forceFilterRef.current
|
||||
? "opacity-70 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
panelClassName="min-w-36"
|
||||
items={FILTERS.map((f) => ({
|
||||
label: f.label,
|
||||
selected: activeFilter === f.id,
|
||||
icon: f.icon,
|
||||
// Use a custom property that will be passed through but not cause type errors
|
||||
customProps: {
|
||||
disabled: forceFilterRef.current !== undefined,
|
||||
},
|
||||
}))}
|
||||
onSelect={(item) => {
|
||||
const filter = FILTERS.find(
|
||||
(f) => f.label === item.label
|
||||
);
|
||||
if (filter) setActiveFilter(filter.id);
|
||||
// Only allow filter changes if no forceFilter was provided
|
||||
if (!forceFilterRef.current) {
|
||||
const filter = FILTERS.find(
|
||||
(f) => f.label === item.label
|
||||
);
|
||||
if (filter) setActiveFilter(filter.id);
|
||||
}
|
||||
}}
|
||||
triggerIcon={<FontAwesomeIcon icon={faFilter} />}
|
||||
triggerLabel={
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# @storyteller/ui-image-input
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test @storyteller/ui-image-input` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@storyteller/ui-image-input",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export * from './lib/image-input';
|
||||
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* Replace this with your own classes
|
||||
*
|
||||
* e.g.
|
||||
* .container {
|
||||
* }
|
||||
*/
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import ImageInput from './image-input';
|
||||
|
||||
describe('ImageInput', () => {
|
||||
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<ImageInput />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUpload, faImages, faTrash } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
export interface ImageFile {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
mediaToken?: string;
|
||||
}
|
||||
|
||||
export interface ImageInputProps {
|
||||
/** Selected image */
|
||||
value?: ImageFile | null;
|
||||
/** Callback when image is selected or removed */
|
||||
onChange: (image: ImageFile | null) => void;
|
||||
/** Handler for opening gallery modal */
|
||||
onGalleryOpen?: () => void;
|
||||
/** Show gallery button */
|
||||
showGalleryButton?: boolean;
|
||||
/** Custom placeholder text */
|
||||
placeholderText?: string;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ImageInput({
|
||||
value,
|
||||
onChange,
|
||||
onGalleryOpen,
|
||||
showGalleryButton = true,
|
||||
placeholderText = "Drag and drop an image here",
|
||||
className,
|
||||
}: ImageInputProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const processImageFile = (file: File) => {
|
||||
// Only accept image files
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please upload an image file");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const newImage: ImageFile = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: reader.result as string,
|
||||
file: file,
|
||||
};
|
||||
onChange(newImage);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (files && files[0]) {
|
||||
const file = files[0];
|
||||
processImageFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
// Enhanced drag and drop handlers for better desktop compatibility
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDragging) setIsDragging(true);
|
||||
|
||||
// Explicitly set effectAllowed to make sure the browser shows the correct drop effect
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
},
|
||||
[isDragging]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only set isDragging to false if we're leaving the drop target
|
||||
// and not entering a child element
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
// Debug logging
|
||||
console.log("Files dropped:", e.dataTransfer.files);
|
||||
console.log("DataTransfer types:", e.dataTransfer.types);
|
||||
|
||||
try {
|
||||
// First try the files collection
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
const file = e.dataTransfer.files[0]; // Just take the first file
|
||||
console.log("Processing file:", file.name, file.type, file.size);
|
||||
processImageFile(file);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then try the items collection
|
||||
const items = e.dataTransfer.items;
|
||||
if (items && items.length > 0) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].kind === "file") {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
console.log("Processing item as file:", file.name);
|
||||
processImageFile(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we got here, we couldn't process the drop
|
||||
console.log("Could not process dropped content");
|
||||
toast.error(
|
||||
"Could not process the dropped file. Please try uploading instead."
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error processing drop:", error);
|
||||
toast.error(
|
||||
"Error processing the dropped file. Please try uploading instead."
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={twMerge("relative", className)}>
|
||||
{/* Drag & drop area or image preview */}
|
||||
<div
|
||||
className={twMerge(
|
||||
"mx-auto flex h-60 w-full flex-col items-center justify-center rounded-lg border border-dashed border-white/20 bg-white/5 p-4 transition-colors",
|
||||
isDragging && "border-blue-400 bg-blue-500/10"
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
>
|
||||
{value ? (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<img
|
||||
src={value.url}
|
||||
alt="Selected image"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
|
||||
<FontAwesomeIcon icon={faImages} className="text-2xl mb-2" />
|
||||
<p>{placeholderText}</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 my-4 w-full">
|
||||
<div className="h-[1px] w-12 bg-white/10" />
|
||||
<p className="text-sm px-3">or</p>
|
||||
<div className="h-[1px] w-12 bg-white/10" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
{showGalleryButton && onGalleryOpen && (
|
||||
<Button
|
||||
onClick={onGalleryOpen}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-sm bg-white/15"
|
||||
>
|
||||
<FontAwesomeIcon icon={faImages} />
|
||||
Choose from Library
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleUploadClick}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-sm bg-white/15"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUpload} />
|
||||
Upload Image
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Action buttons when image is selected */}
|
||||
{value && (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-3">
|
||||
{showGalleryButton && onGalleryOpen && (
|
||||
<Button
|
||||
onClick={onGalleryOpen}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-sm"
|
||||
icon={faImages}
|
||||
>
|
||||
Choose Different Image
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleUploadClick}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-sm"
|
||||
icon={faUpload}
|
||||
>
|
||||
Upload Different Image
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
// Reset file input value to allow selecting the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}}
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-sm"
|
||||
icon={faTrash}
|
||||
>
|
||||
Remove Image
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageInput;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../../tsconfig.base.json"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
"@nx/react/typings/image.d.ts",
|
||||
"vite/client"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
|
||||
},
|
||||
"exclude": [
|
||||
"out-tsc",
|
||||
"dist",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import * as path from 'path';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/components/image-input',
|
||||
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
// Configuration for building your library.
|
||||
// See: https://vitejs.dev/guide/build.html#library-mode
|
||||
build: {
|
||||
outDir: './dist',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points.
|
||||
entry: 'src/index.ts',
|
||||
name: '@storyteller/ui-image-input',
|
||||
fileName: 'index',
|
||||
// Change this to the formats you want to support.
|
||||
// Don't forget to update your package.json as well.
|
||||
formats: ['es' as const]
|
||||
},
|
||||
rollupOptions: {
|
||||
// External packages that should not be bundled into your library.
|
||||
external: ['react','react-dom','react/jsx-runtime']
|
||||
},
|
||||
},
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: './test-output/vitest/coverage',
|
||||
provider: 'v8' as const,
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -625,6 +625,7 @@ export const PromptBox2D = ({
|
||||
maxSelections={4}
|
||||
onUseSelected={handleGalleryImages}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
forceFilter="image"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
SoraSessionState,
|
||||
waitForSoraLogin,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { showActionReminder } from "libs/components/action-reminder-modal/src/lib/action-reminder-modal-signals";
|
||||
import { showActionReminder } from "@storyteller/ui-action-reminder-modal";
|
||||
|
||||
interface ReferenceImage {
|
||||
id: string;
|
||||
@@ -821,6 +821,7 @@ export const PromptBox3D = ({
|
||||
maxSelections={4}
|
||||
onUseSelected={handleGalleryImages}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
forceFilter="image"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,10 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { Button, ToggleButton } from "@storyteller/ui-button";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { EnqueueTextToImage, EnqueueTextToImageModel } from "@storyteller/tauri-api";
|
||||
import {
|
||||
EnqueueTextToImage,
|
||||
EnqueueTextToImageModel,
|
||||
} from "@storyteller/tauri-api";
|
||||
import {
|
||||
faMessageXmark,
|
||||
faMessageCheck,
|
||||
@@ -221,7 +224,7 @@ export const PromptBoxImage = ({
|
||||
return iconElement.props.icon;
|
||||
};
|
||||
|
||||
const getModelByName = (name: string) : EnqueueTextToImageModel => {
|
||||
const getModelByName = (name: string): EnqueueTextToImageModel => {
|
||||
switch (name) {
|
||||
case "Flux Pro Ultra":
|
||||
return EnqueueTextToImageModel.FluxProUltra;
|
||||
@@ -401,6 +404,7 @@ export const PromptBoxImage = ({
|
||||
maxSelections={1}
|
||||
onUseSelected={handleGalleryImages}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
forceFilter="image"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -375,6 +375,7 @@ export const PromptBoxVideo = ({
|
||||
maxSelections={1}
|
||||
onUseSelected={handleGalleryImages}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
forceFilter="image"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
globalAccountLogoutCallback: () => void;
|
||||
initialSection?: SettingsSection;
|
||||
}
|
||||
|
||||
//type SettingsSection = "misc" | "audio" | "accounts" | "video" | "image";
|
||||
@@ -26,9 +27,10 @@ export const SettingsModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
globalAccountLogoutCallback,
|
||||
initialSection = "general",
|
||||
}: SettingsModalProps) => {
|
||||
const [selectedSection, setSelectedSection] =
|
||||
useState<SettingsSection>("general");
|
||||
useState<SettingsSection>(initialSection);
|
||||
|
||||
const sections = [
|
||||
{ id: "general" as const, label: "General", icon: faCog },
|
||||
|
||||
@@ -4,12 +4,12 @@ import { CommandResult } from "../common/CommandStatus";
|
||||
export interface GetFalApiKeySuccessResult extends CommandResult {
|
||||
payload: {
|
||||
key: string;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type GetFalApiKeyResult = GetFalApiKeySuccessResult | CommandResult;
|
||||
|
||||
export const GetFalApiKey = async () : Promise<GetFalApiKeyResult> => {
|
||||
export const GetFalApiKey = async (): Promise<GetFalApiKeyResult> => {
|
||||
let result = await invoke("get_fal_api_key_command");
|
||||
return (result as GetFalApiKeyResult);
|
||||
}
|
||||
return result as GetFalApiKeyResult;
|
||||
};
|
||||
|
||||
Generated
+20
@@ -1390,6 +1390,10 @@
|
||||
"name": "@storyteller/ui-close-button",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/create-3d-modal": {
|
||||
"name": "@storyteller/ui-create-3d-modal",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/demo-modal": {
|
||||
"name": "@storyteller/ui-demo-modal",
|
||||
"version": "0.0.1"
|
||||
@@ -1406,6 +1410,14 @@
|
||||
"name": "@storyteller/ui-gravatar",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/image-field": {
|
||||
"version": "0.0.1",
|
||||
"extraneous": true
|
||||
},
|
||||
"libs/components/image-input": {
|
||||
"name": "@storyteller/ui-image-input",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/input": {
|
||||
"name": "@storyteller/ui-input",
|
||||
"version": "0.0.1"
|
||||
@@ -9681,6 +9693,10 @@
|
||||
"resolved": "libs/components/close-button",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@storyteller/ui-create-3d-modal": {
|
||||
"resolved": "libs/components/create-3d-modal",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@storyteller/ui-demo-modal": {
|
||||
"resolved": "libs/components/demo-modal",
|
||||
"link": true
|
||||
@@ -9697,6 +9713,10 @@
|
||||
"resolved": "libs/components/gravatar",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@storyteller/ui-image-input": {
|
||||
"resolved": "libs/components/image-input",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@storyteller/ui-input": {
|
||||
"resolved": "libs/components/input",
|
||||
"link": true
|
||||
|
||||
@@ -134,6 +134,12 @@
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/model-selector"
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/create-3d-modal"
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/image-input"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user