Browse Source

refactor: make EaseCurveNode full-bleed video-only, fix presets popup positioning

Strip editor tab, tab bar, and all editor-related state/handlers from EaseCurveNode
since controls now live in the ControlPanel. Node body shows only the video preview
full-bleed. Fix ControlPanel presets popup to open leftward (right-anchored) so it
stays on-screen when panel is at the right viewport edge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
f352f45934
  1. 4
      src/components/nodes/ControlPanel.tsx
  2. 485
      src/components/nodes/EaseCurveNode.tsx

4
src/components/nodes/ControlPanel.tsx

@ -874,10 +874,10 @@ function EaseCurveControls({ node }: { node: Node }) {
{showPresets && typeof document !== 'undefined' && createPortal(
<div
className="fixed z-[100] bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-2 max-h-96 overflow-y-auto nowheel"
className="fixed z-[100] bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-2 max-h-[60vh] overflow-y-auto nowheel"
style={{
top: presetsButtonRef.current?.getBoundingClientRect().bottom || 0,
left: presetsButtonRef.current?.getBoundingClientRect().left || 0,
right: window.innerWidth - (presetsButtonRef.current?.getBoundingClientRect().left || 0),
width: 240,
}}
>

485
src/components/nodes/EaseCurveNode.tsx

@ -1,80 +1,25 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
import React, { useCallback, useEffect, useMemo } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { EaseCurveNodeData } from "@/types";
import { checkEncoderSupport } from "@/hooks/useStitchVideos";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { CubicBezierEditor } from "@/components/CubicBezierEditor";
import {
EASING_PRESETS,
getPresetBezier,
getEasingBezier,
} from "@/lib/easing-presets";
import { getAllEasingNames, getEasingFunction } from "@/lib/easing-functions";
type EaseCurveNodeType = Node<EaseCurveNodeData, "easeCurve">;
// Generate SVG polyline points by sampling an easing function
function generateEasingPolyline(
easingName: string,
width: number,
height: number,
samples: number = 20
): string {
const fn = getEasingFunction(easingName);
return Array.from({ length: samples + 1 }, (_, i) => {
const t = i / samples;
const y = fn(t);
return `${(t * width).toFixed(1)},${((1 - y) * height).toFixed(1)}`;
}).join(" ");
}
// Categorize easing names into presets (with Bezier handles) and others (polyline only)
const ALL_EASING_NAMES = getAllEasingNames();
const PRESET_NAMES = new Set(EASING_PRESETS);
const VIDEO_HEIGHT = 320;
export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeType>) {
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
const edges = useWorkflowStore((state) => state.edges);
const removeEdge = useWorkflowStore((state) => state.removeEdge);
const { setNodes } = useReactFlow();
const videoBlobUrl = useVideoBlobUrl(nodeData.outputVideo ?? null);
const [activeTab, setActiveTab] = useState<"editor" | "video">("editor");
const [showPresets, setShowPresets] = useState(false);
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
const EDITOR_HEIGHT = 480;
const VIDEO_HEIGHT = 320;
const switchTab = useCallback((tab: "editor" | "video") => {
setActiveTab(tab);
const height = tab === "editor" ? EDITOR_HEIGHT : VIDEO_HEIGHT;
setNodes((nodes) =>
nodes.map((n) =>
n.id === id ? { ...n, style: { ...n.style, height } } : n
)
);
}, [id, setNodes]);
// Auto-switch to Video tab when processing completes
const prevOutputRef = useRef(nodeData.outputVideo);
useEffect(() => {
if (!prevOutputRef.current && nodeData.outputVideo) {
switchTab("video");
}
prevOutputRef.current = nodeData.outputVideo;
}, [nodeData.outputVideo, switchTab]);
// Check encoder support on mount
useEffect(() => {
if (nodeData.encoderSupported === null) {
@ -84,123 +29,10 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
}
}, [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
useEffect(() => {
if (!showPresets) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
presetsButtonRef.current && !presetsButtonRef.current.contains(target) &&
presetsPopoverRef.current && !presetsPopoverRef.current.contains(target)
) {
setShowPresets(false);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setShowPresets(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [showPresets]);
const handleBezierChange = useCallback(
(value: [number, number, number, number]) => {
updateNodeData(id, { bezierHandles: value, easingPreset: null });
},
[id, updateNodeData]
);
const handleBezierCommit = useCallback(
(value: [number, number, number, number]) => {
updateNodeData(id, { bezierHandles: value, easingPreset: null });
},
[id, updateNodeData]
);
const handleSelectPreset = useCallback(
(name: string) => {
updateNodeData(id, {
easingPreset: name,
bezierHandles: getPresetBezier(name),
});
setShowPresets(false);
},
[id, updateNodeData]
);
const handleSelectEasing = useCallback(
(name: string) => {
updateNodeData(id, {
easingPreset: name,
bezierHandles: getEasingBezier(name),
});
setShowPresets(false);
},
[id, updateNodeData]
);
const handleDurationChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
updateNodeData(id, { outputDuration: isNaN(val) ? 1.5 : Math.max(0.1, Math.min(30, val)) });
},
[id, updateNodeData]
);
const handleRun = useCallback(() => {
regenerateNode(id);
}, [id, regenerateNode]);
// Check if this node has an incoming easeCurve connection (inheritance)
const inheritedEdge = useMemo(() => {
return edges.find((e) => e.target === id && e.targetHandle === "easeCurve") || null;
}, [edges, id]);
const isInherited = inheritedEdge !== null;
// Default to video tab when inherited
useEffect(() => {
if (isInherited) {
switchTab("video");
}
}, [isInherited, switchTab]);
const handleBreakInheritance = useCallback(() => {
if (inheritedEdge) {
@ -209,21 +41,6 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
}
}, [inheritedEdge, removeEdge, id, updateNodeData]);
// Compute easing curve polyline for the editor overlay (higher sample count than thumbnails)
const editorEasingCurve = useMemo(() => {
if (!nodeData.easingPreset) return undefined;
return generateEasingPolyline(nodeData.easingPreset, 100, 100, 50);
}, [nodeData.easingPreset]);
// Memoize the preset thumbnail SVGs
const presetThumbnails = useMemo(() => {
return ALL_EASING_NAMES.map((name) => ({
name,
polyline: generateEasingPolyline(name, 36, 36),
isPreset: PRESET_NAMES.has(name as any),
}));
}, []);
// Shared handles rendered in ALL states (4 handles with labels)
const renderHandles = () => (
<>
@ -299,8 +116,9 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
<BaseNode
id={id}
selected={selected}
fullBleed
minWidth={340}
minHeight={480}
minHeight={VIDEO_HEIGHT}
>
{renderHandles()}
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-center px-4">
@ -329,8 +147,9 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
<BaseNode
id={id}
selected={selected}
fullBleed
minWidth={340}
minHeight={480}
minHeight={VIDEO_HEIGHT}
>
{renderHandles()}
<div className="flex-1 flex items-center justify-center">
@ -350,6 +169,7 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
<BaseNode
id={id}
selected={selected}
fullBleed
isExecuting={isRunning}
hasError={nodeData.status === "error"}
minWidth={340}
@ -357,263 +177,50 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
>
{renderHandles()}
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Tab bar */}
<div className="flex border-b border-neutral-700">
{/* Video preview (full-bleed) */}
{nodeData.outputVideo ? (
<div className="relative w-full h-full">
<video
src={videoBlobUrl ?? undefined}
controls
autoPlay
loop
muted
className="absolute inset-0 w-full h-full object-contain rounded-lg"
playsInline
/>
<button
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
activeTab === "editor"
? "text-lime-300 border-b-2 border-lime-300"
: "text-neutral-400 hover:text-neutral-300"
}`}
onClick={() => switchTab("editor")}
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"
>
Editor
</button>
<button
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
activeTab === "video"
? "text-lime-300 border-b-2 border-lime-300"
: "text-neutral-400 hover:text-neutral-300"
}`}
onClick={() => switchTab("video")}
>
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="w-full h-full flex items-center justify-center bg-neutral-900/40 rounded-lg">
<span className="text-[10px] text-neutral-500">Run workflow to apply ease curve</span>
</div>
)}
{/* Tab content */}
{activeTab === "editor" && (
<div className="flex-1 flex flex-col min-h-0 gap-2 relative">
{/* Editor controls - dimmed when inherited */}
<div className={`flex flex-col gap-2 ${isInherited ? "pointer-events-none opacity-40" : ""}`}>
{/* Bezier curve editor - fixed size, centered */}
<div className="flex justify-center">
<div className="w-60">
<CubicBezierEditor
value={nodeData.bezierHandles}
onChange={handleBezierChange}
onCommit={handleBezierCommit}
disabled={nodeData.status === "loading" || isInherited}
easingCurve={editorEasingCurve}
/>
</div>
</div>
{/* Preset label */}
{nodeData.easingPreset && (
<div className="text-center">
<span className="text-[10px] text-lime-300/70 font-medium">
{nodeData.easingPreset}
</span>
</div>
)}
{/* Controls row: Duration + Presets button */}
<div className="flex items-center gap-2 px-2">
<label className="text-[10px] text-neutral-400 whitespace-nowrap">Duration</label>
<input
type="number"
min="0.1"
max="30"
step="0.1"
value={nodeData.outputDuration}
onChange={handleDurationChange}
className="nodrag w-16 px-1.5 py-1 bg-neutral-800 border border-neutral-600 rounded text-xs text-neutral-200 text-center"
/>
<span className="text-[10px] text-neutral-500">sec</span>
{/* Presets button */}
<div className="ml-auto">
<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"
onClick={() => setShowPresets(!showPresets)}
disabled={isInherited}
>
{/* Curve icon */}
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 14 C 6 14, 4 2, 14 2" strokeLinecap="round" />
</svg>
<span>Presets</span>
</button>
</div>
</div>
{/* Apply button */}
<div className="flex justify-end 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={videoBlobUrl ?? undefined}
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>
{/* 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) */}
<div className="mb-2">
<div className="text-[9px] text-neutral-500 uppercase tracking-wider mb-1 px-1">
Bezier Presets
</div>
<div className="grid grid-cols-5 gap-1">
{EASING_PRESETS.map((name) => {
const thumb = presetThumbnails.find((t) => t.name === name);
const isActive = nodeData.easingPreset === name;
return (
<button
key={name}
className={`flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${
isActive
? "bg-lime-300/20 border border-lime-300/40"
: "hover:bg-neutral-700 border border-transparent"
}`}
onClick={() => handleSelectPreset(name)}
title={name}
>
<svg width="40" height="40" viewBox="-2 -2 40 40" className="flex-shrink-0">
<rect x="-2" y="-2" width="40" height="40" fill="transparent" />
<line x1="0" y1="36" x2="36" y2="0" stroke="rgba(255,255,255,0.1)" strokeWidth="0.5" strokeDasharray="2 2" />
{thumb && (
<polyline
points={thumb.polyline}
fill="none"
stroke={isActive ? "#bef264" : "rgba(255,255,255,0.5)"}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
<span className="text-[7px] text-neutral-400 truncate w-full text-center leading-none">
{name.replace(/^ease/, "")}
</span>
</button>
);
})}
</div>
</div>
{/* Processing overlay */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 rounded-lg 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>
)}
{/* All easing functions (scrollable grid) */}
<div className="border-t border-neutral-700 pt-2">
<div className="text-[9px] text-neutral-500 uppercase tracking-wider mb-1 px-1">
All Easing Functions
</div>
<div className="grid grid-cols-5 gap-1 max-h-[200px] overflow-y-auto nowheel">
{presetThumbnails.map(({ name, polyline }) => {
const isActive = nodeData.easingPreset === name;
return (
<button
key={name}
className={`flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${
isActive
? "bg-lime-300/20 border border-lime-300/40"
: "hover:bg-neutral-700 border border-transparent"
}`}
onClick={() => handleSelectEasing(name)}
title={name}
>
<svg width="40" height="40" viewBox="-2 -2 40 40" className="flex-shrink-0">
<rect x="-2" y="-2" width="40" height="40" fill="transparent" />
<line x1="0" y1="36" x2="36" y2="0" stroke="rgba(255,255,255,0.1)" strokeWidth="0.5" strokeDasharray="2 2" />
<polyline
points={polyline}
fill="none"
stroke={isActive ? "#bef264" : "rgba(255,255,255,0.5)"}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="text-[7px] text-neutral-400 truncate w-full text-center leading-none">
{name.replace(/^ease/, "")}
</span>
</button>
);
})}
</div>
</div>
</div>,
document.body
{/* Error display */}
{nodeData.status === "error" && nodeData.error && (
<div className="absolute bottom-2 left-2 right-2 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>
)}
</BaseNode>
);

Loading…
Cancel
Save