Browse Source

feat: double-click resize handle to aspect-fit node to media content

When a node has media (image/video) and resize handles are visible,
double-clicking any resize handle resizes the node to match the media's
aspect ratio, preferring to grow rather than shrink. Applies to all
selected nodes for multi-selection consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
09dcfe723b
  1. 1
      src/components/nodes/AnnotationNode.tsx
  2. 70
      src/components/nodes/BaseNode.tsx
  3. 1
      src/components/nodes/EaseCurveNode.tsx
  4. 1
      src/components/nodes/GLBViewerNode.tsx
  5. 1
      src/components/nodes/GenerateImageNode.tsx
  6. 1
      src/components/nodes/GenerateVideoNode.tsx
  7. 1
      src/components/nodes/ImageInputNode.tsx
  8. 1
      src/components/nodes/OutputNode.tsx
  9. 1
      src/components/nodes/VideoFrameGrabNode.tsx
  10. 1
      src/components/nodes/VideoStitchNode.tsx
  11. 1
      src/components/nodes/VideoTrimNode.tsx
  12. 61
      src/utils/nodeDimensions.ts

1
src/components/nodes/AnnotationNode.tsx

@ -95,6 +95,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
id={id}
selected={selected}
contentClassName="flex-1 min-h-0 overflow-clip"
aspectFitMedia={nodeData.outputImage}
>
<input
ref={fileInputRef}

70
src/components/nodes/BaseNode.tsx

@ -1,8 +1,9 @@
"use client";
import { ReactNode, useCallback } from "react";
import { ReactNode, useCallback, useEffect, useRef } from "react";
import { NodeResizer, OnResize, useReactFlow } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
import { getMediaDimensions, calculateAspectFitSize } from "@/utils/nodeDimensions";
interface BaseNodeProps {
id: string;
@ -16,6 +17,8 @@ interface BaseNodeProps {
minHeight?: number;
/** When true, node has no background/border — content fills the entire node area */
fullBleed?: boolean;
/** Media URL (image/video) to use for aspect-fit resize on resize-handle double-click */
aspectFitMedia?: string | null;
}
export function BaseNode({
@ -29,6 +32,7 @@ export function BaseNode({
minWidth = 180,
minHeight = 100,
fullBleed = false,
aspectFitMedia,
}: BaseNodeProps) {
const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds);
const nodes = useWorkflowStore((state) => state.nodes);
@ -63,6 +67,69 @@ export function BaseNode({
[id, getNodes, setNodes]
);
// Double-click resize handle → aspect-fit to media
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!aspectFitMedia || !selected) return;
const el = containerRef.current;
if (!el) return;
// Walk up to .react-flow__node wrapper
const nodeWrapper = el.closest(".react-flow__node");
if (!nodeWrapper) return;
const handles = nodeWrapper.querySelectorAll(".react-flow__resize-control");
if (handles.length === 0) return;
const handler = async (e: Event) => {
e.stopPropagation();
const dims = await getMediaDimensions(aspectFitMedia);
if (!dims) return;
const aspectRatio = dims.width / dims.height;
const allNodes = getNodes();
const thisNode = allNodes.find((n) => n.id === id);
if (!thisNode) return;
const currentWidth =
(thisNode.style?.width as number) ??
(thisNode.measured?.width as number) ??
thisNode.width ??
300;
const currentHeight =
(thisNode.style?.height as number) ??
(thisNode.measured?.height as number) ??
thisNode.height ??
300;
const newSize = calculateAspectFitSize(
aspectRatio,
currentWidth,
currentHeight,
fullBleed
);
setNodes((nds) =>
nds.map((n) => {
if (n.id === id || (n.selected && n.id !== id)) {
return {
...n,
style: { ...n.style, width: newSize.width, height: newSize.height },
};
}
return n;
})
);
};
handles.forEach((h) => h.addEventListener("dblclick", handler));
return () => {
handles.forEach((h) => h.removeEventListener("dblclick", handler));
};
}, [aspectFitMedia, selected, id, fullBleed, getNodes, setNodes]);
return (
<>
<NodeResizer
@ -74,6 +141,7 @@ export function BaseNode({
onResize={handleResize}
/>
<div
ref={containerRef}
className={`
h-full w-full flex flex-col overflow-visible
${fullBleed ? "rounded-lg bg-neutral-800/50 border border-neutral-700/40" : "bg-neutral-800 rounded-lg shadow-lg border"}

1
src/components/nodes/EaseCurveNode.tsx

@ -174,6 +174,7 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
hasError={nodeData.status === "error"}
minWidth={340}
minHeight={VIDEO_HEIGHT}
aspectFitMedia={nodeData.outputVideo}
>
{renderHandles()}

1
src/components/nodes/GLBViewerNode.tsx

@ -360,6 +360,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
id={id}
selected={selected}
contentClassName={nodeData.glbUrl ? "flex-1 min-h-0 overflow-hidden flex flex-col" : undefined}
aspectFitMedia={nodeData.capturedImage}
>
<input
ref={fileInputRef}

1
src/components/nodes/GenerateImageNode.tsx

@ -487,6 +487,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
isExecuting={isRunning}
hasError={nodeData.status === "error"}
fullBleed
aspectFitMedia={nodeData.outputImage}
>
{/* Input handles - ALWAYS use same IDs and positions for connection stability */}
{/* Image input at 35%, Text input at 65% - never changes regardless of model */}

1
src/components/nodes/GenerateVideoNode.tsx

@ -395,6 +395,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
isExecuting={isRunning}
hasError={nodeData.status === "error"}
fullBleed
aspectFitMedia={nodeData.outputVideo}
>
{/* Dynamic input handles based on model schema */}
{nodeData.inputSchema && nodeData.inputSchema.length > 0 ? (

1
src/components/nodes/ImageInputNode.tsx

@ -86,6 +86,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
id={id}
selected={selected}
contentClassName="flex-1 min-h-0 overflow-clip"
aspectFitMedia={nodeData.image}
>
{/* Reference input handle for visual links from Split Grid node */}
<Handle

1
src/components/nodes/OutputNode.tsx

@ -114,6 +114,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
isExecuting={isRunning}
contentClassName="flex-1 min-h-0 relative"
className="min-w-[200px]"
aspectFitMedia={isAudio ? null : contentSrc}
>
<Handle
type="target"

1
src/components/nodes/VideoFrameGrabNode.tsx

@ -43,6 +43,7 @@ export function VideoFrameGrabNode({ id, data, selected }: NodeProps<VideoFrameG
hasError={nodeData.status === "error"}
minWidth={320}
minHeight={320}
aspectFitMedia={nodeData.outputImage}
>
{/* Video In (target, left, 50%) */}
<Handle

1
src/components/nodes/VideoStitchNode.tsx

@ -441,6 +441,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
hasError={nodeData.status === "error"}
minWidth={500}
minHeight={280}
aspectFitMedia={nodeData.outputVideo}
>
{renderHandles()}

1
src/components/nodes/VideoTrimNode.tsx

@ -258,6 +258,7 @@ export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeTyp
hasError={nodeData.status === "error"}
minWidth={360}
minHeight={360}
aspectFitMedia={nodeData.outputVideo}
>
{renderHandles()}

61
src/utils/nodeDimensions.ts

@ -90,6 +90,67 @@ export function getVideoDimensions(
});
}
/**
* Detect media type from URL and return dimensions using the appropriate loader.
* Handles data:image/*, data:video/*, blob:*, and http(s) URLs.
*/
export function getMediaDimensions(
url: string | null | undefined
): Promise<{ width: number; height: number } | null> {
if (!url) return Promise.resolve(null);
if (url.startsWith("data:image")) {
return getImageDimensions(url);
}
// data:video/*, blob:*, or http(s) URLs → treat as video
if (
url.startsWith("data:video") ||
url.startsWith("blob:") ||
url.startsWith("http")
) {
return getVideoDimensions(url);
}
return Promise.resolve(null);
}
/**
* Calculate a node size that matches the given aspect ratio, preferring to grow.
* No min/max clamping the node sizes freely to fit the content.
*
* @param aspectRatio - content width / content height
* @param currentWidth - the node's current width
* @param currentHeight - the node's current height
* @param fullBleed - if true, skip chrome height offset
* @returns {width, height} that preserves the aspect ratio at the larger-area candidate
*/
export function calculateAspectFitSize(
aspectRatio: number,
currentWidth: number,
currentHeight: number,
fullBleed: boolean = false
): { width: number; height: number } {
if (!aspectRatio || aspectRatio <= 0 || !isFinite(aspectRatio)) {
return { width: currentWidth, height: currentHeight };
}
const chromeHeight = fullBleed ? 0 : NODE_CHROME_HEIGHT;
// Candidate A: keep current width, adjust height
const heightA = currentWidth / aspectRatio + chromeHeight;
const areaA = currentWidth * heightA;
// Candidate B: keep current height, adjust width
const widthB = (currentHeight - chromeHeight) * aspectRatio;
const areaB = widthB * currentHeight;
if (areaA >= areaB) {
return { width: Math.round(currentWidth), height: Math.round(heightA) };
}
return { width: Math.round(widthB), height: Math.round(currentHeight) };
}
// Node sizing constraints
const MIN_WIDTH = 200;
const MAX_WIDTH = 500;

Loading…
Cancel
Save