Browse Source

Introduce expand and run functionalities in BaseNode, and integrate modal for prompt editing in PromptNode.

handoff-20260429-1057
shrimbly 7 months ago
parent
commit
21defe6c9e
  1. 15
      src/components/WorkflowCanvas.tsx
  2. 147
      src/components/modals/PromptEditorModal.tsx
  3. 55
      src/components/nodes/BaseNode.tsx
  4. 13
      src/components/nodes/NanoBananaNode.tsx
  5. 83
      src/components/nodes/PromptNode.tsx
  6. 9
      src/store/workflowStore.ts

15
src/components/WorkflowCanvas.tsx

@ -187,7 +187,7 @@ const findScrollableAncestor = (target: HTMLElement, deltaX: number, deltaY: num
};
export function WorkflowCanvas() {
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow } =
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen } =
useWorkflowStore();
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport } = useReactFlow();
const [isDragOver, setIsDragOver] = useState(false);
@ -1091,17 +1091,20 @@ export function WorkflowCanvas() {
fitView
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
selectionOnDrag={isMacOS}
panOnDrag={!isMacOS}
selectionOnDrag={isMacOS && !isModalOpen}
panOnDrag={!isMacOS && !isModalOpen}
selectNodesOnDrag={false}
nodeDragThreshold={5}
zoomOnScroll={false}
zoomOnPinch={true}
zoomOnPinch={!isModalOpen}
minZoom={0.1}
maxZoom={4}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
panActivationKeyCode="Space"
onWheel={handleWheel}
panActivationKeyCode={isModalOpen ? null : "Space"}
onWheel={isModalOpen ? undefined : handleWheel}
nodesDraggable={!isModalOpen}
nodesConnectable={!isModalOpen}
elementsSelectable={!isModalOpen}
className="bg-neutral-900"
defaultEdgeOptions={{
type: "editable",

147
src/components/modals/PromptEditorModal.tsx

@ -0,0 +1,147 @@
import React, { useState, useEffect, useCallback } from 'react';
const FONT_SIZE_STORAGE_KEY = 'prompt-editor-font-size';
const DEFAULT_FONT_SIZE = 14;
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const FONT_SIZE_OPTIONS = [10, 12, 14, 16, 18, 20, 24];
interface PromptEditorModalProps {
isOpen: boolean;
initialPrompt: string;
onSubmit: (prompt: string) => void;
onClose: () => void;
}
export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
isOpen,
initialPrompt,
onSubmit,
onClose,
}) => {
const [prompt, setPrompt] = useState(initialPrompt);
const [fontSize, setFontSize] = useState(() => {
// Load font size from localStorage on mount
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(FONT_SIZE_STORAGE_KEY);
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= MIN_FONT_SIZE && parsed <= MAX_FONT_SIZE) {
return parsed;
}
}
}
return DEFAULT_FONT_SIZE;
});
// Update local state when initial prompt changes
useEffect(() => {
setPrompt(initialPrompt);
}, [initialPrompt]);
// Save font size to localStorage when it changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem(FONT_SIZE_STORAGE_KEY, fontSize.toString());
}
}, [fontSize]);
// Handle Escape key to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
const handleSubmit = useCallback(() => {
onSubmit(prompt);
onClose();
}, [prompt, onSubmit, onClose]);
const handleFontSizeChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setFontSize(parseInt(e.target.value, 10));
}, []);
const handleBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only close if clicking the backdrop itself, not the dialog content
if (e.target === e.currentTarget) {
onClose();
}
},
[onClose]
);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50"
onClick={handleBackdropClick}
>
<div className="bg-neutral-800 border border-neutral-700 rounded-lg shadow-2xl w-full max-w-3xl h-[85vh] flex flex-col mx-4">
{/* Header */}
<div className="px-6 pt-6 pb-4">
<h2 className="text-xl font-semibold text-neutral-100">
Edit Prompt
</h2>
</div>
{/* Box containing toolbar and textarea */}
<div className="mx-6 flex-1 flex flex-col border border-neutral-700 rounded bg-neutral-900/30 overflow-hidden mb-4">
{/* Toolbar - header of the box */}
<div className="h-12 bg-neutral-900 border-b border-neutral-700 flex items-center px-4 gap-3 shrink-0">
{/* Font Size Control */}
<select
value={fontSize}
onChange={handleFontSizeChange}
className="text-sm py-1 px-2 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300"
>
{FONT_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}px
</option>
))}
</select>
</div>
{/* Textarea */}
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe what to generate..."
className="nodrag nopan nowheel flex-1 w-full p-6 leading-relaxed text-neutral-100 bg-transparent border-0 resize-none focus:outline-none placeholder:text-neutral-500"
style={{ fontSize: `${fontSize}px` }}
autoFocus
/>
</div>
{/* Footer with buttons */}
<div className="flex justify-end gap-3 px-6 pb-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-700 hover:bg-neutral-600 rounded transition-colors focus:outline-none focus:ring-1 focus:ring-neutral-500"
>
Cancel
</button>
<button
onClick={handleSubmit}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 rounded transition-colors focus:outline-none focus:ring-1 focus:ring-blue-400"
>
Submit
</button>
</div>
</div>
</div>
);
};

55
src/components/nodes/BaseNode.tsx

@ -12,6 +12,8 @@ interface BaseNodeProps {
comment?: string;
onCustomTitleChange?: (title: string) => void;
onCommentChange?: (comment: string) => void;
onExpand?: () => void;
onRun?: () => void;
children: ReactNode;
selected?: boolean;
isExecuting?: boolean;
@ -28,6 +30,8 @@ export function BaseNode({
comment,
onCustomTitleChange,
onCommentChange,
onExpand,
onRun,
children,
selected = false,
isExecuting = false,
@ -235,8 +239,8 @@ export function BaseNode({
onMouseLeave={() => setShowCommentTooltip(false)}
className={`nodrag nopan p-0.5 rounded transition-colors ${
comment
? "text-blue-400 hover:text-blue-300"
: "text-neutral-500 hover:text-neutral-400 border border-neutral-600"
? "text-blue-400 hover:text-blue-200"
: "text-neutral-500 hover:text-neutral-200 border border-neutral-600"
}`}
title={comment ? "Edit comment" : "Add comment"}
>
@ -297,6 +301,53 @@ export function BaseNode({
</div>
)}
</div>
{/* Expand Button */}
{onExpand && (
<div className="relative ml-2 shrink-0 group">
<button
onClick={onExpand}
className="nodrag nopan p-0.5 rounded transition-all duration-200 ease-in-out text-neutral-500 group-hover:text-neutral-200 border border-neutral-600 flex items-center overflow-hidden group-hover:pr-2"
title="Expand editor"
>
<svg
className="w-3.5 h-3.5 flex-shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
<span className="max-w-0 opacity-0 whitespace-nowrap text-[10px] transition-all duration-200 ease-in-out overflow-hidden group-hover:max-w-[60px] group-hover:opacity-100 group-hover:ml-1">
Expand
</span>
</button>
</div>
)}
{/* Run Button */}
{onRun && (
<div className="relative ml-2 shrink-0 group">
<button
onClick={onRun}
className="nodrag nopan p-0.5 rounded transition-all duration-200 ease-in-out text-neutral-500 group-hover:text-neutral-200 border border-neutral-600 flex items-center overflow-hidden group-hover:pr-2"
title="Run this node"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
<span className="max-w-0 opacity-0 whitespace-nowrap text-[10px] transition-all duration-200 ease-in-out overflow-hidden group-hover:max-w-[60px] group-hover:opacity-100 group-hover:ml-1">
Run node
</span>
</button>
</div>
)}
</div>
<div className="px-3 pb-4 h-[calc(100%-28px)] overflow-hidden flex flex-col">{children}</div>
</div>

13
src/components/nodes/NanoBananaNode.tsx

@ -152,6 +152,7 @@ export function NanoBananaNode({ id, data, selected }: NodeProps<NanoBananaNodeT
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
onRun={handleRegenerate}
selected={selected}
hasError={nodeData.status === "error"}
>
@ -238,17 +239,7 @@ export function NanoBananaNode({ id, data, selected }: NodeProps<NanoBananaNodeT
</svg>
</div>
)}
<div className="absolute top-1 right-1 flex gap-1">
<button
onClick={handleRegenerate}
disabled={isRunning}
className="w-5 h-5 bg-neutral-900/80 hover:bg-blue-600/80 disabled:opacity-50 disabled:cursor-not-allowed rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Regenerate"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<div className="absolute top-1 right-1">
<button
onClick={handleClearImage}
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"

83
src/components/nodes/PromptNode.tsx

@ -1,16 +1,20 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { PromptNodeData } from "@/types";
import { PromptEditorModal } from "@/components/modals/PromptEditorModal";
type PromptNodeType = Node<PromptNodeData, "prompt">;
export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const setIsModalOpen = useWorkflowStore((state) => state.setIsModalOpen);
const [isModalOpenLocal, setIsModalOpenLocal] = useState(false);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -19,29 +23,60 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
[id, updateNodeData]
);
const handleOpenModal = useCallback(() => {
setIsModalOpenLocal(true);
setIsModalOpen(true);
}, [setIsModalOpen]);
const handleCloseModal = useCallback(() => {
setIsModalOpenLocal(false);
setIsModalOpen(false);
}, [setIsModalOpen]);
const handleSubmitModal = useCallback(
(prompt: string) => {
updateNodeData(id, { prompt });
},
[id, updateNodeData]
);
return (
<BaseNode
id={id}
title="Prompt"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
selected={selected}
>
<textarea
value={nodeData.prompt}
onChange={handleChange}
placeholder="Describe what to generate..."
className="nodrag nopan nowheel w-full flex-1 min-h-[70px] p-2 text-xs leading-relaxed text-neutral-100 border border-neutral-700 rounded bg-neutral-900/50 resize-none focus:outline-none focus:ring-1 focus:ring-neutral-600 focus:border-neutral-600 placeholder:text-neutral-500"
/>
<Handle
type="source"
position={Position.Right}
id="text"
data-handletype="text"
/>
</BaseNode>
<>
<BaseNode
id={id}
title="Prompt"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
onExpand={handleOpenModal}
selected={selected}
>
<textarea
value={nodeData.prompt}
onChange={handleChange}
placeholder="Describe what to generate..."
className="nodrag nopan nowheel w-full flex-1 min-h-[70px] p-2 text-xs leading-relaxed text-neutral-100 border border-neutral-700 rounded bg-neutral-900/50 resize-none focus:outline-none focus:ring-1 focus:ring-neutral-600 focus:border-neutral-600 placeholder:text-neutral-500"
/>
<Handle
type="source"
position={Position.Right}
id="text"
data-handletype="text"
/>
</BaseNode>
{/* Modal - rendered via portal to escape React Flow stacking context */}
{isModalOpenLocal && createPortal(
<PromptEditorModal
isOpen={isModalOpenLocal}
initialPrompt={nodeData.prompt}
onSubmit={handleSubmitModal}
onClose={handleCloseModal}
/>,
document.body
)}
</>
);
}

9
src/store/workflowStore.ts

@ -87,6 +87,10 @@ interface WorkflowStore {
moveGroupNodes: (groupId: string, delta: { x: number; y: number }) => void;
setNodeGroupId: (nodeId: string, groupId: string | undefined) => void;
// UI State
isModalOpen: boolean;
setIsModalOpen: (isOpen: boolean) => void;
// Execution
isRunning: boolean;
currentNodeId: string | null;
@ -318,6 +322,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
edgeStyle: "curved" as EdgeStyle,
clipboard: null,
groups: {},
isModalOpen: false,
isRunning: false,
currentNodeId: null,
pausedAtNodeId: null,
@ -340,6 +345,10 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ edgeStyle: style });
},
setIsModalOpen: (isOpen: boolean) => {
set({ isModalOpen: isOpen });
},
addNode: (type: NodeType, position: XYPosition) => {
const id = `${type}-${++nodeIdCounter}`;

Loading…
Cancel
Save