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 4 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 (
<BaseNode
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}
commentNavigation={commentNavigation ?? undefined}
contentClassName="flex-1 min-h-0 overflow-clip"
>
{/* Reference input handle for visual links from Split Grid node */}
<Handle
@ -110,44 +105,32 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
/>
{nodeData.image ? (
<div className="relative group flex-1 flex flex-col min-h-0">
<div className="relative group w-full h-full">
<img
src={nodeData.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
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" />
</svg>
</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
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
<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="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>
<span className="text-[10px] text-neutral-400 mt-1">
Drop or click
</span>
<span className="text-xs text-neutral-500 mt-2">Drop image</span>
</div>
)}

49
src/components/nodes/OutputNode.tsx

@ -110,16 +110,10 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
<>
<BaseNode
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}
isExecuting={isRunning}
contentClassName="flex-1 min-h-0 overflow-clip relative"
className="min-w-[200px]"
commentNavigation={commentNavigation ?? undefined}
>
<Handle
type="target"
@ -136,9 +130,9 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
/>
{contentSrc ? (
<div className="flex-1 flex flex-col min-h-0 gap-2">
<>
{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
src={contentSrc}
controls
@ -147,7 +141,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
</div>
) : (
<div
className="relative cursor-pointer group flex-1 min-h-0"
className="relative cursor-pointer group w-full h-full"
onClick={() => setShowLightbox(true)}
>
{isVideo ? (
@ -158,17 +152,17 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
muted
autoPlay
playsInline
className="w-full h-full object-contain rounded"
className="w-full h-full object-cover"
onClick={(e) => e.stopPropagation()}
/>
) : (
<img
src={contentSrc}
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">
View full size
</span>
@ -177,27 +171,22 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
)}
<button
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>
</div>
</>
) : (
<div className="w-full flex-1 min-h-[144px] border border-dashed border-neutral-600 rounded flex items-center justify-center">
<span className="text-neutral-500 text-[10px]">Waiting for image, video, or audio</span>
<div className="w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center">
<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>
)}
{/* 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>
{/* 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 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
*/
export function calculateNodeSizePreservingHeight(
aspectRatio: number,
currentHeight?: number
currentHeight?: number,
skipChromeOffset: boolean = false
): { width: number; height: number } {
// Handle invalid aspect ratios
if (!aspectRatio || aspectRatio <= 0 || !isFinite(aspectRatio)) {
@ -179,11 +181,12 @@ export function calculateNodeSizePreservingHeight(
// No current height or below minimum = use default behavior
if (!currentHeight || currentHeight < MIN_HEIGHT) {
return calculateNodeSize(aspectRatio);
return skipChromeOffset ? calculateNodeSizeForFullBleed(aspectRatio) : calculateNodeSize(aspectRatio);
}
// 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;
// Clamp width to constraints
@ -194,3 +197,67 @@ export function calculateNodeSizePreservingHeight(
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