Browse Source

fix: render EaseCurveNode presets popover via portal to prevent clipping

The presets overlay was being clipped by BaseNode's overflow-hidden container.
Render it through createPortal to document.body with position tracking via
requestAnimationFrame, matching the existing pattern used for comment tooltips.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
497955c143
  1. 224
      src/components/nodes/EaseCurveNode.tsx

224
src/components/nodes/EaseCurveNode.tsx

@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
import { BaseNode } from "./BaseNode"; import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { useCommentNavigation } from "@/hooks/useCommentNavigation";
@ -48,7 +49,9 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
const [activeTab, setActiveTab] = useState<"editor" | "video">("editor"); const [activeTab, setActiveTab] = useState<"editor" | "video">("editor");
const [showPresets, setShowPresets] = useState(false); const [showPresets, setShowPresets] = useState(false);
const presetsRef = useRef<HTMLDivElement>(null); const presetsButtonRef = useRef<HTMLButtonElement>(null);
const presetsPopoverRef = useRef<HTMLDivElement>(null);
const [presetsPosition, setPresetsPosition] = useState<{ top: number; left: number } | null>(null);
// Auto-resize node height when switching tabs // Auto-resize node height when switching tabs
const EDITOR_HEIGHT = 480; const EDITOR_HEIGHT = 480;
@ -81,12 +84,45 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
} }
}, [id, nodeData.encoderSupported, updateNodeData]); }, [id, nodeData.encoderSupported, updateNodeData]);
// Position the presets popover above the button and track it
useEffect(() => {
if (!showPresets || !presetsButtonRef.current) {
setPresetsPosition(null);
return;
}
const updatePosition = () => {
if (presetsButtonRef.current) {
const rect = presetsButtonRef.current.getBoundingClientRect();
setPresetsPosition({
top: rect.top,
left: rect.right,
});
}
};
updatePosition();
let animationId: number;
const trackPosition = () => {
updatePosition();
animationId = requestAnimationFrame(trackPosition);
};
animationId = requestAnimationFrame(trackPosition);
return () => cancelAnimationFrame(animationId);
}, [showPresets]);
// Close presets popover on click outside or Escape // Close presets popover on click outside or Escape
useEffect(() => { useEffect(() => {
if (!showPresets) return; if (!showPresets) return;
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if (presetsRef.current && !presetsRef.current.contains(e.target as HTMLElement)) { const target = e.target as HTMLElement;
if (
presetsButtonRef.current && !presetsButtonRef.current.contains(target) &&
presetsPopoverRef.current && !presetsPopoverRef.current.contains(target)
) {
setShowPresets(false); setShowPresets(false);
} }
}; };
@ -407,8 +443,9 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
<span className="text-[10px] text-neutral-500">sec</span> <span className="text-[10px] text-neutral-500">sec</span>
{/* Presets button */} {/* Presets button */}
<div className="relative ml-auto" ref={presetsRef}> <div className="ml-auto">
<button <button
ref={presetsButtonRef}
className="nodrag nopan px-2 py-1 bg-neutral-800 hover:bg-neutral-700 border border-neutral-600 rounded text-xs text-neutral-300 transition-colors flex items-center gap-1 disabled:opacity-40 disabled:pointer-events-none" className="nodrag nopan px-2 py-1 bg-neutral-800 hover:bg-neutral-700 border border-neutral-600 rounded text-xs text-neutral-300 transition-colors flex items-center gap-1 disabled:opacity-40 disabled:pointer-events-none"
onClick={() => setShowPresets(!showPresets)} onClick={() => setShowPresets(!showPresets)}
disabled={isInherited} disabled={isInherited}
@ -419,10 +456,98 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
</svg> </svg>
<span>Presets</span> <span>Presets</span>
</button> </button>
</div>
</div>
{/* Apply button */}
<div className="px-2">
<button
className="nodrag nopan px-3 py-1.5 bg-lime-300/15 hover:bg-lime-300/25 border border-lime-300/30 rounded text-xs text-lime-300 font-medium transition-colors disabled:opacity-40 disabled:pointer-events-none"
onClick={handleRun}
disabled={isRunning || nodeData.status === "loading" || isInherited}
>
Apply
</button>
</div>
</div>
{/* Inheritance overlay */}
{isInherited && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-neutral-900/80 backdrop-blur-sm rounded z-10">
<p className="text-sm text-neutral-200 font-medium">Settings inherited from parent node</p>
<p className="text-[11px] text-neutral-400 mt-1">Break connection to edit manually</p>
<button
className="mt-3 px-3 py-1.5 bg-white/10 hover:bg-white/20 border border-white/20 rounded text-xs text-neutral-200 transition-colors"
onClick={handleBreakInheritance}
>
Control manually
</button>
</div>
)}
</div>
)}
{activeTab === "video" && (
<div className="flex-1 flex flex-col min-h-0">
{nodeData.outputVideo ? (
<div className="relative flex-1 min-h-0">
<video
src={nodeData.outputVideo}
controls
autoPlay
loop
muted
className="absolute inset-0 w-full h-full object-contain rounded"
playsInline
/>
<button
onClick={() => updateNodeData(id, { outputVideo: null, status: "idle" })}
className="absolute top-1 right-1 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"
title="Clear video"
>
<svg className="w-3 h-3" 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>
) : (
<div className="flex-1 flex items-center justify-center border border-dashed border-neutral-600 rounded">
<span className="text-[10px] text-neutral-500">Run workflow to apply ease curve</span>
</div>
)}
</div>
)}
{/* Processing overlay */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 rounded flex flex-col items-center justify-center gap-2">
<svg className="w-6 h-6 animate-spin text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span className="text-white text-xs">Processing... {Math.round(nodeData.progress)}%</span>
</div>
)}
{/* Presets popover */} {/* Error display */}
{showPresets && ( {nodeData.status === "error" && nodeData.error && (
<div className="absolute z-50 right-0 bottom-full mb-1 w-[280px] bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-2 nowheel"> <div className="px-2 py-1.5 bg-red-900/30 border border-red-700/50 rounded">
<p className="text-[10px] text-red-400 break-words">{nodeData.error}</p>
</div>
)}
</div>
{/* Presets popover - rendered via portal to avoid clipping */}
{showPresets && presetsPosition && createPortal(
<div
ref={presetsPopoverRef}
className="fixed z-[9999] w-[280px] bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-2 nowheel"
style={{
top: presetsPosition.top,
left: presetsPosition.left,
transform: "translateY(-100%) translateX(-100%)",
}}
>
{/* Preset Bezier thumbnails (top section) */} {/* Preset Bezier thumbnails (top section) */}
<div className="mb-2"> <div className="mb-2">
<div className="text-[9px] text-neutral-500 uppercase tracking-wider mb-1 px-1"> <div className="text-[9px] text-neutral-500 uppercase tracking-wider mb-1 px-1">
@ -435,7 +560,7 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
return ( return (
<button <button
key={name} key={name}
className={`nodrag nopan flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${ className={`flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${
isActive isActive
? "bg-lime-300/20 border border-lime-300/40" ? "bg-lime-300/20 border border-lime-300/40"
: "hover:bg-neutral-700 border border-transparent" : "hover:bg-neutral-700 border border-transparent"
@ -477,7 +602,7 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
return ( return (
<button <button
key={name} key={name}
className={`nodrag nopan flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${ className={`flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${
isActive isActive
? "bg-lime-300/20 border border-lime-300/40" ? "bg-lime-300/20 border border-lime-300/40"
: "hover:bg-neutral-700 border border-transparent" : "hover:bg-neutral-700 border border-transparent"
@ -505,88 +630,9 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
})} })}
</div> </div>
</div> </div>
</div> </div>,
)} document.body
</div>
</div>
{/* Apply button */}
<div className="px-2">
<button
className="nodrag nopan px-3 py-1.5 bg-lime-300/15 hover:bg-lime-300/25 border border-lime-300/30 rounded text-xs text-lime-300 font-medium transition-colors disabled:opacity-40 disabled:pointer-events-none"
onClick={handleRun}
disabled={isRunning || nodeData.status === "loading" || isInherited}
>
Apply
</button>
</div>
</div>
{/* Inheritance overlay */}
{isInherited && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-neutral-900/80 backdrop-blur-sm rounded z-10">
<p className="text-sm text-neutral-200 font-medium">Settings inherited from parent node</p>
<p className="text-[11px] text-neutral-400 mt-1">Break connection to edit manually</p>
<button
className="mt-3 px-3 py-1.5 bg-white/10 hover:bg-white/20 border border-white/20 rounded text-xs text-neutral-200 transition-colors"
onClick={handleBreakInheritance}
>
Control manually
</button>
</div>
)}
</div>
)} )}
{activeTab === "video" && (
<div className="flex-1 flex flex-col min-h-0">
{nodeData.outputVideo ? (
<div className="relative flex-1 min-h-0">
<video
src={nodeData.outputVideo}
controls
autoPlay
loop
muted
className="absolute inset-0 w-full h-full object-contain rounded"
playsInline
/>
<button
onClick={() => updateNodeData(id, { outputVideo: null, status: "idle" })}
className="absolute top-1 right-1 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"
title="Clear video"
>
<svg className="w-3 h-3" 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>
) : (
<div className="flex-1 flex items-center justify-center border border-dashed border-neutral-600 rounded">
<span className="text-[10px] text-neutral-500">Run workflow to apply ease curve</span>
</div>
)}
</div>
)}
{/* Processing overlay */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 rounded flex flex-col items-center justify-center gap-2">
<svg className="w-6 h-6 animate-spin text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span className="text-white text-xs">Processing... {Math.round(nodeData.progress)}%</span>
</div>
)}
{/* Error display */}
{nodeData.status === "error" && nodeData.error && (
<div className="px-2 py-1.5 bg-red-900/30 border border-red-700/50 rounded">
<p className="text-[10px] text-red-400 break-words">{nodeData.error}</p>
</div>
)}
</div>
</BaseNode> </BaseNode>
); );
} }

Loading…
Cancel
Save