Browse Source

Merge branch 'feature/add_provider_popi' of https://git.yuanzoo.cn/weitao/popiart-node-canvas into feature/add_provider_popi

feature/add_provider_popi
huangmin 1 month ago
parent
commit
a3d71c9d54
  1. 11
      src/components/WorkflowCanvas.tsx
  2. 198
      src/components/nodes/FloatingNodeHeader.tsx
  3. 4
      src/components/nodes/GenerateAudioNode.tsx
  4. 2
      src/components/nodes/GenerateImageNode.tsx
  5. 3
      src/components/nodes/GenerateVideoNode.tsx
  6. 8
      src/components/nodes/LLMGenerateNode.tsx
  7. 65
      src/components/nodes/OutputGalleryNode.tsx
  8. 141
      src/components/nodes/OutputNode.tsx
  9. 10
      src/constants/nodeDimensions.ts

11
src/components/WorkflowCanvas.tsx

@ -223,7 +223,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[]
case "splitGrid":
return { inputs: ["image"], outputs: ["reference"] };
case "output":
return { inputs: ["image", "video", "audio", "3d"], outputs: [] };
return { inputs: ["image", "video", "audio"], outputs: [] };
case "outputGallery":
return { inputs: ["image", "video"], outputs: [] };
case "imageCompare":
@ -2756,6 +2756,8 @@ export function WorkflowCanvas() {
if (node.type === "imageInput") return null;
if (node.type === "audioInput") return null;
if (node.type === "videoInput") return null;
if (node.type === "output") return null;
if (node.type === "outputGallery") return null;
const defaultWidth = defaultNodeDimensions[node.type as NodeType]?.width ?? 250;
const headerWidth = node.measured?.width || (node.style?.width as number) || defaultWidth;
@ -2840,6 +2842,11 @@ export function WorkflowCanvas() {
const effectiveModel = getEffectiveGenerationModel(node);
const nodeTitle = getNodeTitle(node);
const mediaMeta = getNodeHeaderMediaMeta(node, nodeTitle);
const isCompactGenerationHeader =
node.type === "nanoBanana" ||
node.type === "generateVideo" ||
node.type === "generateAudio" ||
node.type === "llmGenerate";
return (
<FloatingNodeHeader
key={`header-${node.id}`}
@ -2855,6 +2862,8 @@ export function WorkflowCanvas() {
mediaMeta={mediaMeta}
customTitle={node.data?.customTitle}
comment={node.data?.comment}
showCommentButton={!isCompactGenerationHeader}
showRunButton={!isCompactGenerationHeader}
provider={popiProviderMode ? undefined : effectiveModel?.provider}
headerAction={(browseAction || fallbackButton) ? (
<>

198
src/components/nodes/FloatingNodeHeader.tsx

@ -85,6 +85,8 @@ interface FloatingNodeHeaderProps {
mediaMeta?: NodeHeaderMediaMeta | null;
customTitle?: string;
comment?: string;
showCommentButton?: boolean;
showRunButton?: boolean;
onCustomTitleChange?: (nodeId: string, title: string) => void;
onCommentChange?: (nodeId: string, comment: string) => void;
commentNavigation?: CommentNavigationProps;
@ -109,11 +111,13 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
mediaMeta,
customTitle,
comment,
showCommentButton = true,
showRunButton = true,
onCustomTitleChange,
onCommentChange,
commentNavigation,
}: FloatingNodeHeaderProps) {
const canRun = RUNNABLE_TYPES.has(type);
const canRun = showRunButton && RUNNABLE_TYPES.has(type);
const canExpand = EXPANDABLE_TYPES.has(type);
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
const isBodyHovered = useWorkflowStore((state) => state.hoveredNodeId === id);
@ -252,7 +256,7 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
isInLockedGroup ||
canExpand ||
canRun ||
Boolean(onCommentChange)
Boolean(showCommentButton && onCommentChange)
);
// Drag-to-move: allow repositioning nodes by dragging the header
@ -456,110 +460,112 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
{headerButtons}
{/* Comment Icon */}
<div className="relative shrink-0 flex items-center gap-1" ref={commentPopoverRef}>
<button
ref={commentButtonRef}
onClick={() => setIsEditingComment(!isEditingComment)}
onMouseEnter={() => comment && !isCommentFocused && setShowCommentTooltip(true)}
onMouseLeave={() => setShowCommentTooltip(false)}
className={`nodrag nopan p-0.5 rounded transition-colors ${
comment
? "text-blue-400 hover:text-blue-200"
: "text-neutral-500 hover:text-neutral-200 border border-neutral-600"
}`}
title={comment ? "Edit comment" : "Add comment"}
>
{comment ? (
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97a48.901 48.901 0 01-3.476.383.39.39 0 00-.297.17l-2.755 4.133a.75.75 0 01-1.248 0l-2.755-4.133a.39.39 0 00-.297-.17 48.9 48.9 0 01-3.476-.384c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z" />
</svg>
)}
</button>
{/* Comment Tooltip with Navigation */}
{(showCommentTooltip || isCommentFocused) && comment && !isEditingComment && tooltipPosition && createPortal(
<div
ref={tooltipRef}
className="fixed z-[9999] p-3 text-sm text-neutral-200 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl"
style={{
top: tooltipPosition.top,
left: tooltipPosition.left,
transform: "translateY(-100%) translateX(-50%)",
}}
{showCommentButton && (
<div className="relative shrink-0 flex items-center gap-1" ref={commentPopoverRef}>
<button
ref={commentButtonRef}
onClick={() => setIsEditingComment(!isEditingComment)}
onMouseEnter={() => comment && !isCommentFocused && setShowCommentTooltip(true)}
onMouseLeave={() => setShowCommentTooltip(false)}
className={`nodrag nopan p-0.5 rounded transition-colors ${
comment
? "text-blue-400 hover:text-blue-200"
: "text-neutral-500 hover:text-neutral-200 border border-neutral-600"
}`}
title={comment ? "Edit comment" : "Add comment"}
>
{isCommentFocused && commentNavigation && (
<div className="flex items-center justify-center gap-3 mb-2 pb-2 border-b border-neutral-700">
{comment ? (
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97a48.901 48.901 0 01-3.476.383.39.39 0 00-.297.17l-2.755 4.133a.75.75 0 01-1.248 0l-2.755-4.133a.39.39 0 00-.297-.17 48.9 48.9 0 01-3.476-.384c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z" />
</svg>
)}
</button>
{/* Comment Tooltip with Navigation */}
{(showCommentTooltip || isCommentFocused) && comment && !isEditingComment && tooltipPosition && createPortal(
<div
ref={tooltipRef}
className="fixed z-[9999] p-3 text-sm text-neutral-200 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl"
style={{
top: tooltipPosition.top,
left: tooltipPosition.left,
transform: "translateY(-100%) translateX(-50%)",
}}
>
{isCommentFocused && commentNavigation && (
<div className="flex items-center justify-center gap-3 mb-2 pb-2 border-b border-neutral-700">
<button
onClick={(e) => {
e.stopPropagation();
commentNavigation.onPrevious();
}}
className="nodrag nopan w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700 rounded transition-colors"
title="Previous comment"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-xs text-neutral-400 min-w-[32px] text-center">
{commentNavigation.currentIndex}/{commentNavigation.totalCount}
</span>
<button
onClick={(e) => {
e.stopPropagation();
commentNavigation.onNext();
}}
className="nodrag nopan w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700 rounded transition-colors"
title="Next comment"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
<div className="max-w-[240px] whitespace-pre-wrap break-words">
{comment}
</div>
</div>,
document.body
)}
{/* Comment Edit Popover */}
{isEditingComment && (
<div className="absolute z-[60] right-0 top-full mt-1 w-64 p-2 bg-neutral-800 border border-neutral-600 rounded shadow-lg">
<textarea
value={editCommentValue}
onChange={(e) => setEditCommentValue(e.target.value)}
onKeyDown={handleCommentKeyDown}
placeholder="Add a comment..."
autoFocus
className="nodrag nopan nowheel w-full h-20 p-2 text-xs text-neutral-100 bg-neutral-900/50 border border-neutral-700 rounded resize-none focus:outline-none focus:ring-1 focus:ring-neutral-600"
/>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={(e) => {
e.stopPropagation();
commentNavigation.onPrevious();
onClick={() => {
setEditCommentValue(comment || "");
setIsEditingComment(false);
}}
className="nodrag nopan w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700 rounded transition-colors"
title="Previous comment"
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Cancel
</button>
<span className="text-xs text-neutral-400 min-w-[32px] text-center">
{commentNavigation.currentIndex}/{commentNavigation.totalCount}
</span>
<button
onClick={(e) => {
e.stopPropagation();
commentNavigation.onNext();
}}
className="nodrag nopan w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700 rounded transition-colors"
title="Next comment"
onClick={handleCommentSubmit}
className="px-2 py-1 text-xs text-white bg-blue-600 hover:bg-blue-500 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
Save
</button>
</div>
)}
<div className="max-w-[240px] whitespace-pre-wrap break-words">
{comment}
</div>
</div>,
document.body
)}
{/* Comment Edit Popover */}
{isEditingComment && (
<div className="absolute z-[60] right-0 top-full mt-1 w-64 p-2 bg-neutral-800 border border-neutral-600 rounded shadow-lg">
<textarea
value={editCommentValue}
onChange={(e) => setEditCommentValue(e.target.value)}
onKeyDown={handleCommentKeyDown}
placeholder="Add a comment..."
autoFocus
className="nodrag nopan nowheel w-full h-20 p-2 text-xs text-neutral-100 bg-neutral-900/50 border border-neutral-700 rounded resize-none focus:outline-none focus:ring-1 focus:ring-neutral-600"
/>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => {
setEditCommentValue(comment || "");
setIsEditingComment(false);
}}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleCommentSubmit}
className="px-2 py-1 text-xs text-white bg-blue-600 hover:bg-blue-500 rounded transition-colors"
>
Save
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Expand Button */}
{canExpand && onExpandNode && (

4
src/components/nodes/GenerateAudioNode.tsx

@ -343,7 +343,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
: handleType === "video"
? t("toolbar.video")
: handleType === "audio"
? "Audio"
? t("modelSearch.audio")
: t("node.prompt"),
schemaName: firstInput.name,
description: firstInput.description || firstInput.label,
@ -651,7 +651,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
data-handletype="audio"
style={{ top: "50%", zIndex: 10 }}
/>
<HandleLabel label="Audio" side="source" color="var(--handle-color-audio)" visible={showLabels} />
<HandleLabel label={t("modelSearch.audio")} side="source" color="var(--handle-color-audio)" visible={showLabels} />
</BaseNode>

2
src/components/nodes/GenerateImageNode.tsx

@ -850,7 +850,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
: type === "video"
? t("toolbar.video")
: type === "audio"
? "Audio"
? t("modelSearch.audio")
: t("node.prompt"),
schemaName: firstInput.name,
description: firstInput.description || firstInput.label,

3
src/components/nodes/GenerateVideoNode.tsx

@ -566,6 +566,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
if (normalized === "text") return t("node.text");
if (normalized === "prompt") return t("node.prompt");
if (normalized === "video") return t("toolbar.video");
if (normalized === "audio") return t("modelSearch.audio");
return label;
}, [t]);
@ -800,7 +801,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
: type === "video"
? t("toolbar.video")
: type === "audio"
? "Audio"
? t("modelSearch.audio")
: t("node.prompt"),
count: typeHandles.length,
};

8
src/components/nodes/LLMGenerateNode.tsx

@ -289,7 +289,7 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
style={{ top: "28%" }}
data-handletype="image"
/>
<HandleLabel label="Image" side="target" color="var(--handle-color-image)" top="calc(28% - 18px)" visible={showLabels} />
<HandleLabel label={t("node.image")} side="target" color="var(--handle-color-image)" top="calc(28% - 18px)" visible={showLabels} />
{/* Video input - optional */}
<Handle
type="target"
@ -298,7 +298,7 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
style={{ top: "50%" }}
data-handletype="video"
/>
<HandleLabel label="Video" side="target" color="var(--handle-color-video)" top="calc(50% - 18px)" visible={showLabels} />
<HandleLabel label={t("toolbar.video")} side="target" color="var(--handle-color-video)" top="calc(50% - 18px)" visible={showLabels} />
{/* Text input */}
<Handle
type="target"
@ -307,7 +307,7 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
style={{ top: "72%" }}
data-handletype="text"
/>
<HandleLabel label="Prompt" side="target" color="var(--handle-color-text)" top="calc(72% - 18px)" visible={showLabels} />
<HandleLabel label={t("node.prompt")} side="target" color="var(--handle-color-text)" top="calc(72% - 18px)" visible={showLabels} />
{/* Text output */}
<Handle
type="source"
@ -315,7 +315,7 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
id="text"
data-handletype="text"
/>
<HandleLabel label="Text" side="source" color="var(--handle-color-text)" visible={showLabels} />
<HandleLabel label={t("node.text")} side="source" color="var(--handle-color-text)" visible={showLabels} />
<div className="relative w-full h-full min-h-0 overflow-hidden rounded-lg">
{nodeData.status === "loading" ? (

65
src/components/nodes/OutputGalleryNode.tsx

@ -7,10 +7,11 @@ import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { OutputGalleryNodeData } from "@/types";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
import { defaultNodeDimensions } from "@/constants/nodeDimensions";
import { downloadMedia as downloadMediaUtil } from "@/utils/downloadMedia";
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel";
import { useI18n } from "@/i18n";
type MediaItem = { type: "image" | "video"; src: string };
@ -26,10 +27,38 @@ function AdaptiveGalleryThumbnail({ src, alt, nodeId }: { src: string; alt: stri
}
type OutputGalleryNodeType = Node<OutputGalleryNodeData, "outputGallery">;
const OUTPUT_GALLERY_DIMENSIONS = defaultNodeDimensions.outputGallery;
function getOutputGalleryNodeTitle(id: string, data: OutputGalleryNodeData, label: string) {
if (data.customTitle) return data.customTitle;
if (data.label) return data.label;
const match = id.match(/(\d+)$/);
return match ? `${label} ${match[1]}` : label;
}
function OutputGalleryTypeIcon() {
return (
<span className="flex h-7 w-12 items-center justify-center rounded-full bg-pink-600 text-white shadow-lg shadow-pink-500/20">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.9}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 5h7v7H4zM13 5h7v7h-7zM4 14h7v5H4zM13 14h7v5h-7z" />
</svg>
</span>
);
}
function DeleteIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5h6v2m-8 0 1 13h8l1-13" />
</svg>
);
}
export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGalleryNodeType>) {
const nodeData = data;
const { t } = useI18n();
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const removeNode = useWorkflowStore((state) => state.removeNode);
const addNode = useWorkflowStore((state) => state.addNode);
const { getNodes, setNodes } = useReactFlow();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@ -76,6 +105,12 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
);
}, [lightboxIndex, displayMedia]);
const handleDelete = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
removeNode(id);
}, [id, removeNode]);
const removeMedia = useCallback((index: number) => {
const item = displayMedia[index];
if (!item) return;
@ -173,14 +208,36 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
}, [lightboxIndex, closeLightbox, navigateLightbox]);
const currentItem = lightboxIndex !== null ? displayMedia[lightboxIndex] : null;
const title = getOutputGalleryNodeTitle(id, nodeData, t("node.outputGallery"));
return (
<>
<BaseNode
id={id}
selected={selected}
className="min-w-[200px]"
minWidth={OUTPUT_GALLERY_DIMENSIONS.width}
minHeight={OUTPUT_GALLERY_DIMENSIONS.height}
className={selected ? "!border-blue-500 !ring-0" : ""}
>
<div className="nodrag pointer-events-none absolute -top-10 left-0 z-[10001] flex items-center gap-2">
<OutputGalleryTypeIcon />
<span className="text-sm font-medium text-neutral-100 drop-shadow-sm">{title}</span>
</div>
{selected && (
<div className="nodrag nopan absolute left-1/2 -top-[86px] z-[10002] -translate-x-1/2">
<button
type="button"
onClick={handleDelete}
aria-label={t("prompt.deleteNode")}
className="flex h-10 min-w-[120px] items-center justify-center gap-2 rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-300 shadow-2xl shadow-black/40 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100"
>
<DeleteIcon />
<span className="whitespace-nowrap">{t("prompt.deleteNode")}</span>
</button>
</div>
)}
<Handle
type="target"
position={Position.Left}
@ -188,7 +245,7 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
data-handletype="image"
style={{ top: "40%" }}
/>
<HandleLabel label="Image" side="target" color="rgb(59, 130, 246)" top="calc(40% - 18px)" visible={showLabels} />
<HandleLabel label={t("node.image")} side="target" color="rgb(59, 130, 246)" top="calc(40% - 18px)" visible={showLabels} />
<Handle
type="target"
@ -197,7 +254,7 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
data-handletype="video"
style={{ top: "60%" }}
/>
<HandleLabel label="Video" side="target" color="var(--handle-color-video)" top="calc(60% - 18px)" visible={showLabels} />
<HandleLabel label={t("toolbar.video")} side="target" color="var(--handle-color-video)" top="calc(60% - 18px)" visible={showLabels} />
{displayMedia.length > 0 && (
<div className="flex items-center justify-between px-2 py-1">

141
src/components/nodes/OutputNode.tsx

@ -1,9 +1,8 @@
"use client";
import { useCallback, useState, useMemo, useEffect, useRef } from "react";
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore";
import { OutputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
@ -12,11 +11,12 @@ import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia, MediaType } from "@/utils/downloadMedia";
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel";
import { calculateAspectFitSize, getImageDimensions, getVideoDimensions } from "@/utils/nodeDimensions";
import { useI18n } from "@/i18n";
import { defaultNodeDimensions } from "@/constants/nodeDimensions";
type OutputNodeType = Node<OutputNodeData, "output">;
type OutputKind = "image" | "video" | "audio" | "3d";
const OUTPUT_DIMENSIONS = defaultNodeDimensions.output;
interface OutputItem {
id: string;
@ -37,13 +37,37 @@ function is3DSource(src: string): boolean {
return src.includes(".glb") || src.includes(".gltf");
}
function getOutputNodeTitle(id: string, data: OutputNodeData, label: string) {
if (data.customTitle) return data.customTitle;
if (data.label) return data.label;
const match = id.match(/(\d+)$/);
return match ? `${label} ${match[1]}` : label;
}
function OutputTypeIcon() {
return (
<span className="flex h-7 w-12 items-center justify-center rounded-full bg-rose-600 text-white shadow-lg shadow-rose-500/20">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.9}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5h14v14H5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="m8 15 3-3 2 2 3-4 3 5" />
</svg>
</span>
);
}
function DeleteIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5h6v2m-8 0 1 13h8l1-13" />
</svg>
);
}
export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const nodeData = data;
const { t } = useI18n();
const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const removeNode = useWorkflowStore((state) => state.removeNode);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const { setNodes } = useReactFlow();
const connectedEdgeCount = useWorkflowStore(
(state) => state.edges.filter((edge) => edge.target === id).length
);
@ -52,7 +76,6 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const [showLightbox, setShowLightbox] = useState(false);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const previousEdgeCountRef = useRef<number | null>(null);
const prevFitKeyRef = useRef<string | null>(null);
const videoAutoplayRef = useVideoAutoplay(id, selected);
const typeLabels = useMemo<Record<OutputKind, string>>(() => ({
@ -123,55 +146,6 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const adaptiveImage = useAdaptiveImageSrc(imageSrc, id);
const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null);
useEffect(() => {
if (!contentSrc || isAudio || is3D) {
prevFitKeyRef.current = null;
return;
}
const fitKey = `${isVideo ? "video" : "image"}:${contentSrc}`;
if (fitKey === prevFitKeyRef.current) return;
prevFitKeyRef.current = fitKey;
requestAnimationFrame(() => {
const dimensionsPromise = isVideo
? getVideoDimensions(contentSrc)
: getImageDimensions(contentSrc);
dimensionsPromise.then((dims) => {
if (!dims) return;
const aspectRatio = dims.width / dims.height;
if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) return;
setNodes((nodes) =>
nodes.map((node) => {
if (node.id !== id) return node;
const currentWidth = typeof node.width === "number"
? node.width
: typeof node.style?.width === "number"
? node.style.width
: 300;
const currentHeight = typeof node.height === "number"
? node.height
: typeof node.style?.height === "number"
? node.style.height
: 280;
const newSize = calculateAspectFitSize(aspectRatio, currentWidth, currentHeight, true);
return {
...node,
width: newSize.width,
height: newSize.height,
style: { ...node.style, width: newSize.width, height: newSize.height },
};
})
);
});
});
}, [contentSrc, id, isAudio, is3D, isVideo, setNodes]);
// Auto-trigger execution when a new connection is made
useEffect(() => {
if (previousEdgeCountRef.current === null) {
@ -185,10 +159,11 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
previousEdgeCountRef.current = connectedEdgeCount;
}, [connectedEdgeCount, id, regenerateNode]);
// Handle Run button click
const handleRun = useCallback(() => {
regenerateNode(id);
}, [id, regenerateNode]);
const handleDelete = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
removeNode(id);
}, [id, removeNode]);
const handleDownload = useCallback(async () => {
if (!contentSrc || !activeItem) return;
@ -217,6 +192,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
? t("output.downloadCurrent", { type: activeItem.label })
: t("common.download");
const downloadAllTitle = t("output.downloadAll");
const title = getOutputNodeTitle(id, nodeData, t("node.output"));
return (
<>
@ -224,42 +200,53 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
id={id}
selected={selected}
isExecuting={isRunning}
contentClassName="flex-1 min-h-0 relative"
className="min-w-[200px]"
aspectFitMedia={isAudio || is3D ? null : contentSrc}
minWidth={OUTPUT_DIMENSIONS.width}
minHeight={OUTPUT_DIMENSIONS.height}
className={selected ? "!border-blue-500 !ring-0" : ""}
>
<div className="nodrag pointer-events-none absolute -top-10 left-0 z-[10001] flex items-center gap-2">
<OutputTypeIcon />
<span className="text-sm font-medium text-neutral-100 drop-shadow-sm">{title}</span>
</div>
{selected && (
<div className="nodrag nopan absolute left-1/2 -top-[86px] z-[10002] -translate-x-1/2">
<button
type="button"
onClick={handleDelete}
aria-label={t("prompt.deleteNode")}
className="flex h-10 min-w-[120px] items-center justify-center gap-2 rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-300 shadow-2xl shadow-black/40 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100"
>
<DeleteIcon />
<span className="whitespace-nowrap">{t("prompt.deleteNode")}</span>
</button>
</div>
)}
<Handle
type="target"
position={Position.Left}
id="image"
data-handletype="image"
style={{ top: "28%", zIndex: 10 }}
style={{ top: "32%", zIndex: 10 }}
/>
<HandleLabel label={typeLabels.image} side="target" color="var(--handle-color-image)" top="calc(28% - 18px)" visible={showLabels} />
<HandleLabel label={typeLabels.image} side="target" color="var(--handle-color-image)" top="calc(32% - 18px)" visible={showLabels} />
<Handle
type="target"
position={Position.Left}
id="video"
data-handletype="video"
style={{ top: "44%", zIndex: 10 }}
style={{ top: "50%", zIndex: 10 }}
/>
<HandleLabel label={typeLabels.video} side="target" color="var(--handle-color-video)" top="calc(44% - 18px)" visible={showLabels} />
<HandleLabel label={typeLabels.video} side="target" color="var(--handle-color-video)" top="calc(50% - 18px)" visible={showLabels} />
<Handle
type="target"
position={Position.Left}
id="audio"
data-handletype="audio"
style={{ top: "60%", background: "rgb(167, 139, 250)", zIndex: 10 }}
/>
<HandleLabel label={typeLabels.audio} side="target" color="var(--handle-color-audio)" top="calc(60% - 18px)" visible={showLabels} />
<Handle
type="target"
position={Position.Left}
id="3d"
data-handletype="3d"
style={{ top: "76%", background: "var(--handle-color-3d)", zIndex: 10 }}
style={{ top: "68%", background: "rgb(167, 139, 250)", zIndex: 10 }}
/>
<HandleLabel label="3D" side="target" color="var(--handle-color-3d)" top="calc(76% - 18px)" visible={showLabels} />
<HandleLabel label={typeLabels.audio} side="target" color="var(--handle-color-audio)" top="calc(68% - 18px)" visible={showLabels} />
<div className="relative w-full h-full overflow-hidden rounded-lg">
{outputItems.length > 1 && (

10
src/constants/nodeDimensions.ts

@ -10,14 +10,14 @@ export const defaultNodeDimensions: Record<NodeType, NodeDimensions> = {
prompt: { width: 300, height: 280 },
array: { width: 300, height: 280 },
promptConstructor: { width: 300, height: 280 },
nanoBanana: { width: 300, height: 300 },
generateVideo: { width: 300, height: 300 },
nanoBanana: { width: 300, height: 280 },
generateVideo: { width: 300, height: 280 },
generate3d: { width: 300, height: 300 },
generateAudio: { width: 300, height: 280 },
llmGenerate: { width: 320, height: 360 },
llmGenerate: { width: 300, height: 280 },
splitGrid: { width: 300, height: 320 },
output: { width: 320, height: 320 },
outputGallery: { width: 320, height: 360 },
output: { width: 300, height: 280 },
outputGallery: { width: 300, height: 280 },
imageCompare: { width: 400, height: 360 },
videoStitch: { width: 400, height: 280 },
easeCurve: { width: 340, height: 280 },

Loading…
Cancel
Save