You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
376 lines
12 KiB
376 lines
12 KiB
"use client";
|
|
|
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useWorkflowStore } from "@/store/workflowStore";
|
|
import { ImageHistoryItem } from "@/types";
|
|
|
|
// Helper function for relative time display
|
|
function formatRelativeTime(timestamp: number): string {
|
|
const diff = Date.now() - timestamp;
|
|
const seconds = Math.floor(diff / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours > 0) return `${hours}h ago`;
|
|
if (minutes > 0) return `${minutes}m ago`;
|
|
return "Just now";
|
|
}
|
|
|
|
// Calculate fan position for each item (vertical stack with slight curve to the right, like macOS Downloads)
|
|
function calculateFanPosition(index: number, total: number) {
|
|
// Vertical spacing between items
|
|
const verticalSpacing = 60;
|
|
|
|
// Curve to the right as items go up - slight quadratic curve
|
|
const curveStrength = 0.15;
|
|
const xOffset = index * index * curveStrength;
|
|
|
|
const x = -28 + xOffset; // start centered above icon, then curve to the right
|
|
const y = -(index * verticalSpacing + 56); // stack upward, start above icon with gap
|
|
|
|
return { x, y };
|
|
}
|
|
|
|
// Fan Item Component
|
|
function FanItem({
|
|
item,
|
|
index,
|
|
total,
|
|
onDragStart,
|
|
}: {
|
|
item: ImageHistoryItem;
|
|
index: number;
|
|
total: number;
|
|
onDragStart: (e: React.DragEvent, item: ImageHistoryItem) => void;
|
|
}) {
|
|
const { x, y } = calculateFanPosition(index, total);
|
|
const delay = index * 30;
|
|
|
|
return (
|
|
<button
|
|
draggable
|
|
onDragStart={(e) => onDragStart(e, item)}
|
|
className="absolute w-14 h-14 rounded-lg overflow-hidden border-2 border-neutral-600 hover:border-blue-500 shadow-lg cursor-grab active:cursor-grabbing transition-colors duration-150 animate-fan-enter group"
|
|
style={
|
|
{
|
|
"--fan-x": `${x}px`,
|
|
"--fan-y": `${y}px`,
|
|
animationDelay: `${delay}ms`,
|
|
zIndex: 10 - index,
|
|
} as React.CSSProperties
|
|
}
|
|
title={`${formatRelativeTime(item.timestamp)}\n${item.prompt?.substring(0, 50) || ""}...`}
|
|
>
|
|
<img
|
|
src={item.image}
|
|
alt={`History ${index + 1}`}
|
|
className="w-full h-full object-cover pointer-events-none"
|
|
draggable={false}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// Floating Sidebar for showing all history items
|
|
function HistorySidebar({
|
|
history,
|
|
onClear,
|
|
onClose,
|
|
onDragStart,
|
|
triggerRect,
|
|
}: {
|
|
history: ImageHistoryItem[];
|
|
onClear: () => void;
|
|
onClose: () => void;
|
|
onDragStart: (e: React.DragEvent, item: ImageHistoryItem) => void;
|
|
triggerRect: DOMRect | null;
|
|
}) {
|
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close on click outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
sidebarRef.current &&
|
|
!sidebarRef.current.contains(event.target as Node)
|
|
) {
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [onClose]);
|
|
|
|
// Close on Escape
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
// Position the sidebar near the trigger, but ensure it stays on screen
|
|
const sidebarStyle: React.CSSProperties = {
|
|
position: "fixed",
|
|
zIndex: 200,
|
|
};
|
|
|
|
if (triggerRect) {
|
|
// Position above the trigger
|
|
const left = Math.max(16, triggerRect.left - 140);
|
|
const bottom = window.innerHeight - triggerRect.top + 8;
|
|
sidebarStyle.left = `${left}px`;
|
|
sidebarStyle.bottom = `${bottom}px`;
|
|
} else {
|
|
// Fallback to bottom right
|
|
sidebarStyle.right = "100px";
|
|
sidebarStyle.bottom = "100px";
|
|
}
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={sidebarRef}
|
|
className="w-80 max-h-[420px] bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl flex flex-col"
|
|
style={sidebarStyle}
|
|
>
|
|
{/* Header */}
|
|
<div className="px-4 py-3 border-b border-neutral-700 flex items-center justify-between shrink-0">
|
|
<span className="text-sm text-neutral-200 font-medium">
|
|
All History ({history.length})
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onClear}
|
|
className="text-[10px] text-neutral-500 hover:text-red-400 transition-colors"
|
|
title="Clear all history"
|
|
>
|
|
Clear All
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-5 h-5 rounded hover:bg-neutral-700 flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
|
|
title="Close"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrollable list */}
|
|
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
|
|
{history.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
draggable
|
|
onDragStart={(e) => onDragStart(e, item)}
|
|
className="flex gap-3 p-2 rounded-lg hover:bg-neutral-700/50 cursor-grab active:cursor-grabbing group transition-colors"
|
|
>
|
|
{/* Thumbnail */}
|
|
<div className="w-14 h-14 rounded overflow-hidden shrink-0 border border-neutral-600 group-hover:border-blue-500 transition-colors">
|
|
<img
|
|
src={item.image}
|
|
alt={`History ${index + 1}`}
|
|
className="w-full h-full object-cover pointer-events-none"
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
|
<p className="text-[11px] text-neutral-300 truncate">
|
|
{item.prompt?.substring(0, 60) || "No prompt"}
|
|
</p>
|
|
<p className="text-[10px] text-neutral-500 mt-0.5">
|
|
{formatRelativeTime(item.timestamp)} · {item.model === "nano-banana-pro" ? "Pro" : "Standard"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-4 py-2 border-t border-neutral-700 bg-neutral-900/50 shrink-0">
|
|
<span className="text-[10px] text-neutral-500">Drag images to canvas to create nodes</span>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
export function GlobalImageHistory() {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [showSidebar, setShowSidebar] = useState(false);
|
|
const drawerRef = useRef<HTMLDivElement>(null);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
|
|
const history = useWorkflowStore((state) => state.globalImageHistory);
|
|
const clearGlobalHistory = useWorkflowStore((state) => state.clearGlobalHistory);
|
|
|
|
// Show max 10 items in fan
|
|
const fanItems = history.slice(0, 10);
|
|
const hasOverflow = history.length > 10;
|
|
|
|
// Close fan on click outside (but not sidebar)
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
drawerRef.current &&
|
|
!drawerRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen && !showSidebar) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isOpen, showSidebar]);
|
|
|
|
// Close on Escape key
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
if (showSidebar) {
|
|
setShowSidebar(false);
|
|
} else {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
};
|
|
if (isOpen || showSidebar) {
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
}
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [isOpen, showSidebar]);
|
|
|
|
const handleDragStart = useCallback(
|
|
(e: React.DragEvent, item: ImageHistoryItem) => {
|
|
e.dataTransfer.setData(
|
|
"application/history-image",
|
|
JSON.stringify({
|
|
image: item.image,
|
|
prompt: item.prompt,
|
|
timestamp: item.timestamp,
|
|
})
|
|
);
|
|
e.dataTransfer.effectAllowed = "copy";
|
|
|
|
// Close the fan/sidebar after drag has started
|
|
// Using setTimeout to defer state change until after the drag is properly initiated
|
|
// This prevents the draggable element from being unmounted mid-drag
|
|
setTimeout(() => {
|
|
setIsOpen(false);
|
|
setShowSidebar(false);
|
|
}, 0);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleShowAll = useCallback(() => {
|
|
setIsOpen(false);
|
|
setShowSidebar(true);
|
|
}, []);
|
|
|
|
const handleCloseSidebar = useCallback(() => {
|
|
setShowSidebar(false);
|
|
}, []);
|
|
|
|
const handleClear = useCallback(() => {
|
|
clearGlobalHistory();
|
|
setIsOpen(false);
|
|
setShowSidebar(false);
|
|
}, [clearGlobalHistory]);
|
|
|
|
if (history.length === 0) return null;
|
|
|
|
return (
|
|
<div ref={drawerRef} className="absolute bottom-4 right-64 z-10">
|
|
{/* Trigger Button */}
|
|
<button
|
|
ref={triggerRef}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={`
|
|
relative w-8 h-8 rounded-lg flex items-center justify-center
|
|
bg-neutral-800 hover:bg-neutral-700 border border-neutral-600
|
|
text-neutral-400 hover:text-white
|
|
shadow-lg transition-colors
|
|
`}
|
|
title={`${history.length} image${history.length > 1 ? "s" : ""} in history`}
|
|
>
|
|
{/* Clock/history icon */}
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
{/* Badge showing count */}
|
|
<span className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 bg-blue-500 rounded-full text-[10px] text-white flex items-center justify-center font-bold">
|
|
{history.length > 99 ? "99+" : history.length}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Fan Layout */}
|
|
{isOpen && (
|
|
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2" style={{ zIndex: 100 }}>
|
|
{/* Fan items */}
|
|
<div className="relative w-0 h-0">
|
|
{fanItems.map((item, index) => (
|
|
<FanItem
|
|
key={item.id}
|
|
item={item}
|
|
index={index}
|
|
total={fanItems.length}
|
|
onDragStart={handleDragStart}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Show All button (if more than 10 items) - positioned relative to top fan item */}
|
|
{hasOverflow && (() => {
|
|
const topItemPos = calculateFanPosition(fanItems.length - 1, fanItems.length);
|
|
return (
|
|
<button
|
|
onClick={handleShowAll}
|
|
className="absolute animate-fan-enter bg-neutral-800 hover:bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-1 text-[10px] text-neutral-300 hover:text-white shadow-lg transition-colors whitespace-nowrap"
|
|
style={
|
|
{
|
|
"--fan-x": `${topItemPos.x}px`,
|
|
"--fan-y": `${topItemPos.y - 60}px`,
|
|
animationDelay: `${fanItems.length * 30}ms`,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
+{history.length - 10} more
|
|
</button>
|
|
);
|
|
})()}
|
|
|
|
</div>
|
|
)}
|
|
|
|
{/* Sidebar for all items */}
|
|
{showSidebar && (
|
|
<HistorySidebar
|
|
history={history}
|
|
onClear={handleClear}
|
|
onClose={handleCloseSidebar}
|
|
onDragStart={handleDragStart}
|
|
triggerRect={triggerRef.current?.getBoundingClientRect() || null}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|