Browse Source

feat(46-03): update ImageInputNode and OutputNode for full-bleed content, add calculateNodeSizeForFullBleed

- ImageInputNode: remove header props, use contentClassName with overflow-clip, full-bleed image with object-cover
- OutputNode: same full-bleed pattern, floating download button overlays content
- nodeDimensions: add calculateNodeSizeForFullBleed for nodes without header chrome
- Empty states use subtle bg-neutral-900/40 fill with centered icons (no dashed borders)

Task 1 in progress - 23 more nodes need same pattern applied
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
e5ee635d5a
  1. 35
      src/components/nodes/ImageInputNode.tsx
  2. 49
      src/components/nodes/OutputNode.tsx
  3. 73
      src/utils/nodeDimensions.ts

35
src/components/nodes/ImageInputNode.tsx

@ -84,13 +84,8 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
return ( return (
<BaseNode <BaseNode
id={id} id={id}
title="Image"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
selected={selected} selected={selected}
commentNavigation={commentNavigation ?? undefined} contentClassName="flex-1 min-h-0 overflow-clip"
> >
{/* Reference input handle for visual links from Split Grid node */} {/* Reference input handle for visual links from Split Grid node */}
<Handle <Handle
@ -110,44 +105,32 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
/> />
{nodeData.image ? ( {nodeData.image ? (
<div className="relative group flex-1 flex flex-col min-h-0"> <div className="relative group w-full h-full">
<img <img
src={nodeData.image} src={nodeData.image}
alt={nodeData.filename || "Uploaded image"} alt={nodeData.filename || "Uploaded image"}
className="w-full flex-1 min-h-0 object-contain rounded" className="w-full h-full object-cover"
/> />
<button <button
onClick={handleRemove} onClick={handleRemove}
className="absolute top-1 right-1 w-5 h-5 bg-black/60 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center" className="absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-red-600/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center"
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
<div className="mt-1.5 flex items-center justify-between shrink-0">
<span className="text-[10px] text-neutral-400 truncate max-w-[120px]">
{nodeData.filename}
</span>
{nodeData.dimensions && (
<span className="text-[10px] text-neutral-500">
{nodeData.dimensions.width}x{nodeData.dimensions.height}
</span>
)}
</div>
</div> </div>
) : ( ) : (
<div <div
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
className="w-full flex-1 min-h-[112px] border border-dashed border-neutral-600 rounded flex flex-col items-center justify-center cursor-pointer hover:border-neutral-500 hover:bg-neutral-700/50 transition-colors" className="w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center cursor-pointer hover:bg-neutral-900/60 transition-colors"
> >
<svg className="w-5 h-5 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg> </svg>
<span className="text-[10px] text-neutral-400 mt-1"> <span className="text-xs text-neutral-500 mt-2">Drop image</span>
Drop or click
</span>
</div> </div>
)} )}

49
src/components/nodes/OutputNode.tsx

@ -110,16 +110,10 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
<> <>
<BaseNode <BaseNode
id={id} id={id}
title="Output"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
onRun={handleRun}
isExecuting={isRunning}
selected={selected} selected={selected}
isExecuting={isRunning}
contentClassName="flex-1 min-h-0 overflow-clip relative"
className="min-w-[200px]" className="min-w-[200px]"
commentNavigation={commentNavigation ?? undefined}
> >
<Handle <Handle
type="target" type="target"
@ -136,9 +130,9 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
/> />
{contentSrc ? ( {contentSrc ? (
<div className="flex-1 flex flex-col min-h-0 gap-2"> <>
{isAudio ? ( {isAudio ? (
<div className="flex-1 flex items-center min-h-0 py-2"> <div className="w-full h-full flex items-center justify-center p-4">
<audio <audio
src={contentSrc} src={contentSrc}
controls controls
@ -147,7 +141,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
</div> </div>
) : ( ) : (
<div <div
className="relative cursor-pointer group flex-1 min-h-0" className="relative cursor-pointer group w-full h-full"
onClick={() => setShowLightbox(true)} onClick={() => setShowLightbox(true)}
> >
{isVideo ? ( {isVideo ? (
@ -158,17 +152,17 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
muted muted
autoPlay autoPlay
playsInline playsInline
className="w-full h-full object-contain rounded" className="w-full h-full object-cover"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : ( ) : (
<img <img
src={contentSrc} src={contentSrc}
alt="Output" alt="Output"
className="w-full h-full object-contain rounded" className="w-full h-full object-cover"
/> />
)} )}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center rounded pointer-events-none"> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center pointer-events-none">
<span className="text-[10px] font-medium text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 px-2 py-1 rounded"> <span className="text-[10px] font-medium text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 px-2 py-1 rounded">
View full size View full size
</span> </span>
@ -177,27 +171,22 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
)} )}
<button <button
onClick={handleDownload} onClick={handleDownload}
className="w-full py-1.5 bg-white hover:bg-neutral-200 text-neutral-900 text-[10px] font-medium rounded transition-colors shrink-0" className="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white text-xs rounded transition-colors flex items-center gap-1"
title="Download"
> >
Download <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button> </button>
</div> </>
) : ( ) : (
<div className="w-full flex-1 min-h-[144px] border border-dashed border-neutral-600 rounded flex items-center justify-center"> <div className="w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center">
<span className="text-neutral-500 text-[10px]">Waiting for image, video, or audio</span> <svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
<span className="text-xs text-neutral-500 mt-2">Connect input</span>
</div> </div>
)} )}
{/* Filename input */}
<div className="mt-2 shrink-0">
<input
type="text"
value={nodeData.outputFilename || ""}
onChange={(e) => updateNodeData(id, { outputFilename: e.target.value })}
placeholder="Output filename (optional)"
className="nodrag nopan w-full px-2 py-1.5 text-[10px] bg-neutral-900/50 border border-neutral-700 rounded text-neutral-200 placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-600"
/>
</div>
</BaseNode> </BaseNode>
{/* Lightbox Modal (skip for audio) */} {/* Lightbox Modal (skip for audio) */}

73
src/utils/nodeDimensions.ts

@ -166,11 +166,13 @@ export function calculateNodeSize(
* *
* @param aspectRatio - Width divided by height of the content * @param aspectRatio - Width divided by height of the content
* @param currentHeight - The node's current height (if manually set) * @param currentHeight - The node's current height (if manually set)
* @param skipChromeOffset - If true, skip subtracting NODE_CHROME_HEIGHT (for full-bleed nodes with floating headers)
* @returns {width, height} dimensions that preserve height when possible * @returns {width, height} dimensions that preserve height when possible
*/ */
export function calculateNodeSizePreservingHeight( export function calculateNodeSizePreservingHeight(
aspectRatio: number, aspectRatio: number,
currentHeight?: number currentHeight?: number,
skipChromeOffset: boolean = false
): { width: number; height: number } { ): { width: number; height: number } {
// Handle invalid aspect ratios // Handle invalid aspect ratios
if (!aspectRatio || aspectRatio <= 0 || !isFinite(aspectRatio)) { if (!aspectRatio || aspectRatio <= 0 || !isFinite(aspectRatio)) {
@ -179,11 +181,12 @@ export function calculateNodeSizePreservingHeight(
// No current height or below minimum = use default behavior // No current height or below minimum = use default behavior
if (!currentHeight || currentHeight < MIN_HEIGHT) { if (!currentHeight || currentHeight < MIN_HEIGHT) {
return calculateNodeSize(aspectRatio); return skipChromeOffset ? calculateNodeSizeForFullBleed(aspectRatio) : calculateNodeSize(aspectRatio);
} }
// Preserve height, calculate width to maintain aspect ratio // Preserve height, calculate width to maintain aspect ratio
const contentHeight = currentHeight - NODE_CHROME_HEIGHT; const chromeHeight = skipChromeOffset ? 0 : NODE_CHROME_HEIGHT;
const contentHeight = currentHeight - chromeHeight;
let newWidth = contentHeight * aspectRatio; let newWidth = contentHeight * aspectRatio;
// Clamp width to constraints // Clamp width to constraints
@ -194,3 +197,67 @@ export function calculateNodeSizePreservingHeight(
height: Math.round(currentHeight), height: Math.round(currentHeight),
}; };
} }
/**
* Calculate node dimensions for full-bleed content (no header chrome).
* Used by nodes with floating headers where content fills the entire node area.
*
* @param aspectRatio - Width divided by height (e.g., 16/9 for landscape, 9/16 for portrait)
* @param currentHeight - Optional current height to preserve (if manually resized)
* @returns {width, height} dimensions that fit within constraints
*/
export function calculateNodeSizeForFullBleed(
aspectRatio: number,
currentHeight?: number
): { width: number; height: number } {
// Handle invalid aspect ratios
if (!aspectRatio || aspectRatio <= 0 || !isFinite(aspectRatio)) {
return { width: 300, height: 300 }; // Return default square
}
// If preserving height, calculate width
if (currentHeight && currentHeight >= MIN_HEIGHT) {
let width = currentHeight * aspectRatio;
width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width));
return {
width: Math.round(width),
height: Math.round(currentHeight),
};
}
// Start with base width and calculate height
let width = 300; // Default starting width
let height = width / aspectRatio;
// Check if height exceeds max - if so, scale down width to fit
if (height > MAX_HEIGHT) {
height = MAX_HEIGHT;
width = height * aspectRatio;
}
// Check if height is below min - if so, scale up width to fit
if (height < MIN_HEIGHT) {
height = MIN_HEIGHT;
width = height * aspectRatio;
}
// Clamp width to constraints
if (width > MAX_WIDTH) {
width = MAX_WIDTH;
height = width / aspectRatio;
// Re-clamp height
height = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, height));
}
if (width < MIN_WIDTH) {
width = MIN_WIDTH;
height = width / aspectRatio;
// Re-clamp height
height = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, height));
}
return {
width: Math.round(width),
height: Math.round(height),
};
}

Loading…
Cancel
Save