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.
493 lines
16 KiB
493 lines
16 KiB
"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 (
|
|
<div className={`${className} flex items-center justify-center bg-neutral-900 text-[10px] text-neutral-500`}>
|
|
{item.mediaType === "video" ? "Video" : "Image"}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (item.mediaType === "video" && item.coverUrl) {
|
|
return (
|
|
<img
|
|
src={item.coverUrl}
|
|
alt=""
|
|
className={className}
|
|
draggable={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return item.mediaType === "video" ? (
|
|
<video
|
|
src={src}
|
|
muted
|
|
loop
|
|
playsInline
|
|
preload="metadata"
|
|
className={className}
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className={className}
|
|
draggable={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
draggable={Boolean(mediaSrc)}
|
|
onDragStart={(event) => 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) || ""}...`}
|
|
>
|
|
<MediaThumb item={item} src={mediaSrc} className="h-full w-full object-cover pointer-events-none" />
|
|
{item.mediaType === "video" && (
|
|
<span className="pointer-events-none absolute bottom-1 left-1 rounded bg-black/70 px-1 text-[9px] text-white">
|
|
VID
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement>(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(
|
|
<div
|
|
ref={sidebarRef}
|
|
className="flex flex-col rounded-lg border border-neutral-600 bg-neutral-800 shadow-xl"
|
|
style={sidebarStyle}
|
|
>
|
|
<div className="flex shrink-0 items-center justify-between border-b border-neutral-700 px-4 py-3">
|
|
<span className="text-sm font-medium text-neutral-200">
|
|
Generated Media ({history.length})
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex h-5 w-5 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-700 hover:text-white"
|
|
title="Close"
|
|
>
|
|
<svg className="h-3 w-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 className="flex-1 space-y-1.5 overflow-y-auto p-2">
|
|
{history.map((item, index) => {
|
|
const mediaSrc = item.mediaUrl;
|
|
return (
|
|
<div
|
|
key={item.key}
|
|
draggable={Boolean(mediaSrc)}
|
|
onDragStart={(event) => 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"
|
|
>
|
|
<div className="h-14 w-14 shrink-0 overflow-hidden rounded border border-neutral-600 transition-colors group-hover:border-blue-500">
|
|
<MediaThumb item={item} src={mediaSrc} className="h-full w-full object-cover pointer-events-none" />
|
|
</div>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
|
<p className="truncate text-[11px] text-neutral-300">
|
|
{item.prompt?.substring(0, 60) || "No prompt"}
|
|
</p>
|
|
<p className="mt-0.5 text-[10px] text-neutral-500">
|
|
{formatRelativeTime(item.timestamp)} · {item.mediaType === "video" ? "Video" : "Image"} · {item.modelDisplayName || item.model}
|
|
</p>
|
|
</div>
|
|
|
|
<span className="sr-only">History {index + 1}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t border-neutral-700 bg-neutral-900/50 px-4 py-2">
|
|
<span className="text-[10px] text-neutral-500">Drag media to canvas to create nodes</span>
|
|
</div>
|
|
</div>,
|
|
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<HTMLDivElement>(null);
|
|
const triggerRef = useRef<HTMLButtonElement>(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 (
|
|
<div ref={drawerRef} className={`nodrag nopan nowheel ${placementClass} ${className}`}>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="relative flex h-8 w-8 items-center justify-center rounded-lg border border-neutral-600 bg-neutral-800 text-neutral-400 shadow-lg transition-colors hover:bg-neutral-700 hover:text-white"
|
|
title={`${history.length} generated media item${history.length > 1 ? "s" : ""}`}
|
|
>
|
|
<svg
|
|
className="h-4 w-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>
|
|
<span className="absolute -right-1.5 -top-1.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-blue-500 px-1 text-[10px] font-bold text-white">
|
|
{history.length > 99 ? "99+" : history.length}
|
|
</span>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute bottom-full right-1/2 mb-2 translate-x-1/2" style={{ zIndex: 100 }}>
|
|
<div className="relative h-0 w-0">
|
|
{fanItems.map((item, index) => (
|
|
<FanItem
|
|
key={item.key}
|
|
item={item}
|
|
mediaSrc={item.mediaUrl}
|
|
index={index}
|
|
onDragStart={handleDragStart}
|
|
onPreview={handlePreview}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{hasOverflow && (() => {
|
|
const topItemPos = calculateFanPosition(fanItems.length - 1);
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
setShowSidebar(true);
|
|
}}
|
|
className="absolute animate-fan-enter whitespace-nowrap rounded-lg border border-neutral-600 bg-neutral-800 px-2 py-1 text-[10px] text-neutral-300 shadow-lg transition-colors hover:bg-neutral-700 hover:text-white"
|
|
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>
|
|
)}
|
|
|
|
{showSidebar && (
|
|
<HistorySidebar
|
|
history={history}
|
|
onClose={() => setShowSidebar(false)}
|
|
onDragStart={handleDragStart}
|
|
onPreview={handlePreview}
|
|
triggerRect={triggerRef.current?.getBoundingClientRect() || null}
|
|
/>
|
|
)}
|
|
|
|
{previewItem && (
|
|
<MediaPreviewModal
|
|
type={previewItem.item.mediaType}
|
|
src={previewItem.src}
|
|
downloadSrc={previewItem.src}
|
|
alt="Generated media preview"
|
|
onClose={() => setPreviewItem(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|