@ -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 >
) ;
) ;
}
}