From 09dcfe723b04b015ba4f20b0d23a95097d07cdc6 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 7 Mar 2026 21:06:25 +1300 Subject: [PATCH] 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 --- src/components/nodes/AnnotationNode.tsx | 1 + src/components/nodes/BaseNode.tsx | 70 ++++++++++++++++++++- src/components/nodes/EaseCurveNode.tsx | 1 + src/components/nodes/GLBViewerNode.tsx | 1 + src/components/nodes/GenerateImageNode.tsx | 1 + src/components/nodes/GenerateVideoNode.tsx | 1 + src/components/nodes/ImageInputNode.tsx | 1 + src/components/nodes/OutputNode.tsx | 1 + src/components/nodes/VideoFrameGrabNode.tsx | 1 + src/components/nodes/VideoStitchNode.tsx | 1 + src/components/nodes/VideoTrimNode.tsx | 1 + src/utils/nodeDimensions.ts | 61 ++++++++++++++++++ 12 files changed, 140 insertions(+), 1 deletion(-) diff --git a/src/components/nodes/AnnotationNode.tsx b/src/components/nodes/AnnotationNode.tsx index a1340599..09a8afd5 100644 --- a/src/components/nodes/AnnotationNode.tsx +++ b/src/components/nodes/AnnotationNode.tsx @@ -95,6 +95,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps 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(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 ( <>
{renderHandles()} diff --git a/src/components/nodes/GLBViewerNode.tsx b/src/components/nodes/GLBViewerNode.tsx index 7dae3e4d..27a81507 100644 --- a/src/components/nodes/GLBViewerNode.tsx +++ b/src/components/nodes/GLBViewerNode.tsx @@ -360,6 +360,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps {/* Input handles - ALWAYS use same IDs and positions for connection stability */} {/* Image input at 35%, Text input at 65% - never changes regardless of model */} diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index 0925fbce..b56ab2f5 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -395,6 +395,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps {/* Dynamic input handles based on model schema */} {nodeData.inputSchema && nodeData.inputSchema.length > 0 ? ( diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index 99d7b250..849fa190 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -86,6 +86,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps {/* Reference input handle for visual links from Split Grid node */} ) { isExecuting={isRunning} contentClassName="flex-1 min-h-0 relative" className="min-w-[200px]" + aspectFitMedia={isAudio ? null : contentSrc} > {/* Video In (target, left, 50%) */} {renderHandles()} diff --git a/src/components/nodes/VideoTrimNode.tsx b/src/components/nodes/VideoTrimNode.tsx index e23599f0..d75a2237 100644 --- a/src/components/nodes/VideoTrimNode.tsx +++ b/src/components/nodes/VideoTrimNode.tsx @@ -258,6 +258,7 @@ export function VideoTrimNode({ id, data, selected }: NodeProps {renderHandles()} diff --git a/src/utils/nodeDimensions.ts b/src/utils/nodeDimensions.ts index 1521dc9d..37ded0a0 100644 --- a/src/utils/nodeDimensions.ts +++ b/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;