"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useShallow } from "zustand/shallow";
import { useWorkflowStore } from "@/store/workflowStore";
import type {
GenerateVideoNodeData,
NanoBananaNodeData,
ProviderType,
WorkflowNode,
} from "@/types";
import { MediaPreviewModal } from "@/components/MediaPreviewModal";
type WorkflowGenerationHistoryItem = {
key: string;
id: string;
nodeId: string;
historyIndex: number;
mediaType: "image" | "video";
mediaUrl?: string;
coverUrl?: string;
timestamp: number;
prompt: string;
model: string;
modelDisplayName?: string;
modelProvider?: ProviderType;
};
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";
}
function calculateFanPosition(index: number) {
const verticalSpacing = 60;
const curveStrength = 0.15;
return {
x: -28 + index * index * curveStrength,
y: -(index * verticalSpacing + 56),
};
}
export function collectWorkflowGenerationHistory(nodes: WorkflowNode[]): WorkflowGenerationHistoryItem[] {
const items: WorkflowGenerationHistoryItem[] = [];
nodes.forEach((node) => {
if (node.type === "nanoBanana" || node.type === "smartImage") {
const data = node.data as NanoBananaNodeData;
(data.imageHistory || []).forEach((item, historyIndex) => {
items.push({
key: `${node.id}:image:${item.id}:${historyIndex}`,
id: item.id,
nodeId: node.id,
historyIndex,
mediaType: "image",
mediaUrl: item.image ?? item.previewImg,
timestamp: item.timestamp,
prompt: item.prompt,
model: item.model,
modelDisplayName: item.modelDisplayName,
modelProvider: item.modelProvider,
});
});
}
if (node.type === "generateVideo" || node.type === "smartVideo") {
const data = node.data as GenerateVideoNodeData;
(data.videoHistory || []).forEach((item, historyIndex) => {
items.push({
key: `${node.id}:video:${item.id}:${historyIndex}`,
id: item.id,
nodeId: node.id,
historyIndex,
mediaType: "video",
mediaUrl: item.video,
coverUrl: item.videoCover,
timestamp: item.timestamp,
prompt: item.prompt,
model: item.model,
modelDisplayName: item.modelDisplayName,
modelProvider: item.modelProvider,
});
});
}
});
return items.sort((a, b) => b.timestamp - a.timestamp);
}
function MediaThumb({
item,
src,
className,
}: {
item: WorkflowGenerationHistoryItem;
src?: string;
className: string;
}) {
if (!src) {
return (
{item.mediaType === "video" ? "Video" : "Image"}
);
}
if (item.mediaType === "video" && item.coverUrl) {
return (
);
}
return item.mediaType === "video" ? (
) : (
);
}
function FanItem({
item,
mediaSrc,
index,
onDragStart,
onPreview,
}: {
item: WorkflowGenerationHistoryItem;
mediaSrc?: string;
index: number;
onDragStart: (event: React.DragEvent, item: WorkflowGenerationHistoryItem, mediaSrc?: string) => void;
onPreview: (item: WorkflowGenerationHistoryItem, mediaSrc?: string) => void;
}) {
const { x, y } = calculateFanPosition(index);
return (
onDragStart(event, item, mediaSrc)}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
onPreview(item, mediaSrc);
}}
className="group absolute h-14 w-14 cursor-grab overflow-hidden rounded-lg border-2 border-neutral-600 shadow-lg transition-colors duration-150 animate-fan-enter hover:border-blue-500 active:cursor-grabbing"
style={
{
"--fan-x": `${x}px`,
"--fan-y": `${y}px`,
animationDelay: `${index * 30}ms`,
zIndex: 10 - index,
} as React.CSSProperties
}
title={`${formatRelativeTime(item.timestamp)}${item.modelDisplayName ? `\n${item.modelDisplayName}` : ""}\n${item.prompt?.substring(0, 50) || ""}...`}
>
{item.mediaType === "video" && (
VID
)}
);
}
function HistorySidebar({
history,
onClose,
onDragStart,
onPreview,
triggerRect,
}: {
history: WorkflowGenerationHistoryItem[];
onClose: () => void;
onDragStart: (event: React.DragEvent, item: WorkflowGenerationHistoryItem, mediaSrc?: string) => void;
onPreview: (item: WorkflowGenerationHistoryItem, mediaSrc?: string) => void;
triggerRect: DOMRect | null;
}) {
const sidebarRef = useRef(null);
const viewportMargin = 16;
const sidebarGap = 8;
const sidebarWidth = Math.min(320, window.innerWidth - viewportMargin * 2);
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]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const sidebarStyle: React.CSSProperties = {
position: "fixed",
width: `${sidebarWidth}px`,
maxHeight: "min(520px, calc(100vh - 32px))",
zIndex: 200,
};
if (triggerRect) {
const preferredLeft = triggerRect.right - sidebarWidth;
const maxLeft = window.innerWidth - sidebarWidth - viewportMargin;
sidebarStyle.left = `${Math.max(viewportMargin, Math.min(preferredLeft, maxLeft))}px`;
sidebarStyle.bottom = `${window.innerHeight - triggerRect.top + sidebarGap}px`;
sidebarStyle.maxHeight = `${Math.max(
240,
Math.min(520, triggerRect.top - viewportMargin - sidebarGap)
)}px`;
} else {
sidebarStyle.right = "100px";
sidebarStyle.bottom = "100px";
}
return createPortal(
Generated Media ({history.length})
{history.map((item, index) => {
const mediaSrc = item.mediaUrl;
return (
onDragStart(event, item, mediaSrc)}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
onPreview(item, mediaSrc);
}}
className="group flex cursor-grab gap-3 rounded-lg p-2 transition-colors hover:bg-neutral-700/50 active:cursor-grabbing"
>
{item.prompt?.substring(0, 60) || "No prompt"}
{formatRelativeTime(item.timestamp)} · {item.mediaType === "video" ? "Video" : "Image"} · {item.modelDisplayName || item.model}
History {index + 1}
);
})}
Drag media to canvas to create nodes
,
document.body
);
}
type GlobalImageHistoryProps = {
placement?: "floating" | "inline";
className?: string;
};
export function GlobalImageHistory({
placement = "floating",
className = "",
}: GlobalImageHistoryProps) {
const [isOpen, setIsOpen] = useState(false);
const [showSidebar, setShowSidebar] = useState(false);
const [previewItem, setPreviewItem] = useState<{
item: WorkflowGenerationHistoryItem;
src: string;
} | null>(null);
const drawerRef = useRef(null);
const triggerRef = useRef(null);
const { nodes } = useWorkflowStore(useShallow((state) => ({
nodes: state.nodes,
})));
const history = useMemo(() => collectWorkflowGenerationHistory(nodes), [nodes]);
const fanItems = history.slice(0, 10);
const hasOverflow = history.length > 10;
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]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.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(
(event: React.DragEvent, item: WorkflowGenerationHistoryItem, mediaSrc?: string) => {
if (!mediaSrc) {
event.preventDefault();
return;
}
const payload = JSON.stringify({
[item.mediaType]: mediaSrc,
...(item.mediaType === "video" && item.coverUrl ? { cover: item.coverUrl } : {}),
prompt: item.prompt,
timestamp: item.timestamp,
});
event.dataTransfer.setData(
item.mediaType === "video" ? "application/history-video" : "application/history-image",
payload
);
event.dataTransfer.effectAllowed = "copy";
setTimeout(() => {
setIsOpen(false);
setShowSidebar(false);
}, 0);
},
[]
);
const handlePreview = useCallback((item: WorkflowGenerationHistoryItem, mediaSrc?: string) => {
if (!mediaSrc) return;
setPreviewItem({ item, src: mediaSrc });
}, []);
if (history.length === 0) return null;
const placementClass =
placement === "inline"
? "relative z-10"
: "absolute bottom-4 right-10 z-30";
return (
{isOpen && (
{fanItems.map((item, index) => (
))}
{hasOverflow && (() => {
const topItemPos = calculateFanPosition(fanItems.length - 1);
return (
);
})()}
)}
{showSidebar && (
setShowSidebar(false)}
onDragStart={handleDragStart}
onPreview={handlePreview}
triggerRect={triggerRef.current?.getBoundingClientRect() || null}
/>
)}
{previewItem && (
setPreviewItem(null)}
/>
)}
);
}