feat: graphs popover
This commit is contained in:
parent
0997466331
commit
dedb13546a
|
|
@ -0,0 +1,249 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type PointerEvent } from "react";
|
||||||
|
import { getBezierPoint } from "@/lib/animation/bezier";
|
||||||
|
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||||
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
|
const GRAPH_WIDTH = 140;
|
||||||
|
const GRAPH_HEIGHT = 94;
|
||||||
|
const GRAPH_PADDING = 12;
|
||||||
|
const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
|
||||||
|
const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
|
||||||
|
const HANDLE_RADIUS = 3.5;
|
||||||
|
const ENDPOINT_RADIUS = 2;
|
||||||
|
const SNAP_THRESHOLD = 0.06;
|
||||||
|
const SNAP_TARGETS = [0, 1];
|
||||||
|
const CURVE_SEGMENTS = 64;
|
||||||
|
const Y_CLAMP_MIN = -0.5;
|
||||||
|
const Y_CLAMP_MAX = 1.5;
|
||||||
|
|
||||||
|
type BezierHandle = "c1" | "c2";
|
||||||
|
|
||||||
|
export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
|
||||||
|
|
||||||
|
function snap({
|
||||||
|
value,
|
||||||
|
targets,
|
||||||
|
isEnabled,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
targets: number[];
|
||||||
|
isEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
if (!isEnabled) return value;
|
||||||
|
for (const target of targets) {
|
||||||
|
if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSvgX({ value }: { value: number }) {
|
||||||
|
return GRAPH_PADDING + value * GRAPH_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSvgY({ value }: { value: number }) {
|
||||||
|
return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromSvgX({ svgX }: { svgX: number }) {
|
||||||
|
return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromSvgY({ svgY }: { svgY: number }) {
|
||||||
|
return Math.max(
|
||||||
|
Y_CLAMP_MIN,
|
||||||
|
Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
|
||||||
|
const points: string[] = [];
|
||||||
|
for (let i = 0; i <= CURVE_SEGMENTS; i++) {
|
||||||
|
const progress = i / CURVE_SEGMENTS;
|
||||||
|
points.push(
|
||||||
|
`${toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) })},${toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) })}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `M${points.join("L")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampHandleY({ svgY }: { svgY: number }) {
|
||||||
|
return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BezierGraph({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onChangeEnd,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
value: NormalizedCubicBezier;
|
||||||
|
onChange?: (value: NormalizedCubicBezier) => void;
|
||||||
|
onChangeEnd?: (value: NormalizedCubicBezier) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}) {
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
|
||||||
|
const isShiftPressedRef = useRef(false);
|
||||||
|
const latestValueRef = useRef(value);
|
||||||
|
|
||||||
|
latestValueRef.current = value;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Shift") isShiftPressedRef.current = true;
|
||||||
|
};
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Shift") isShiftPressedRef.current = false;
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("keyup", onKeyUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("keyup", onKeyUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function getPointerPosition({
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
event: PointerEvent;
|
||||||
|
}): { x: number; y: number } {
|
||||||
|
const svg = svgRef.current;
|
||||||
|
if (!svg) return { x: 0, y: 0 };
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const scale = SVG_WIDTH / rect.width;
|
||||||
|
return {
|
||||||
|
x: (event.clientX - rect.left) * scale,
|
||||||
|
y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
|
||||||
|
return (event: PointerEvent<SVGCircleElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setActiveHandle(handle);
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
|
||||||
|
if (!activeHandle) return;
|
||||||
|
const pointerPos = getPointerPosition({ event });
|
||||||
|
const x = fromSvgX({ svgX: pointerPos.x });
|
||||||
|
const y = snap({
|
||||||
|
value: fromSvgY({ svgY: pointerPos.y }),
|
||||||
|
targets: SNAP_TARGETS,
|
||||||
|
isEnabled: !isShiftPressedRef.current,
|
||||||
|
});
|
||||||
|
const next: NormalizedCubicBezier = [...value];
|
||||||
|
if (activeHandle === "c1") {
|
||||||
|
next[0] = x;
|
||||||
|
next[1] = y;
|
||||||
|
} else {
|
||||||
|
next[2] = x;
|
||||||
|
next[3] = y;
|
||||||
|
}
|
||||||
|
latestValueRef.current = next;
|
||||||
|
onChange?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
if (!activeHandle) return;
|
||||||
|
setActiveHandle(null);
|
||||||
|
onChangeEnd?.(latestValueRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel() {
|
||||||
|
if (!activeHandle) return;
|
||||||
|
setActiveHandle(null);
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = curvePath({ curve: value });
|
||||||
|
const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
|
||||||
|
const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
|
||||||
|
const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
|
||||||
|
const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
|
||||||
|
const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
|
||||||
|
const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
|
||||||
|
className="bg-foreground/3 w-full cursor-crosshair select-none"
|
||||||
|
onPointerMove={(event) => onPointerMove({ event })}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerCancel}
|
||||||
|
>
|
||||||
|
<title>Bezier curve editor</title>
|
||||||
|
<line
|
||||||
|
x1={p0.x}
|
||||||
|
y1={p0.y}
|
||||||
|
x2={p1.x}
|
||||||
|
y2={p1.y}
|
||||||
|
className="stroke-foreground/8"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={p0.x}
|
||||||
|
y1={p0.y}
|
||||||
|
x2={c1Clamped.x}
|
||||||
|
y2={c1Clamped.y}
|
||||||
|
className="stroke-primary/30"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={p1.x}
|
||||||
|
y1={p1.y}
|
||||||
|
x2={c2Clamped.x}
|
||||||
|
y2={c2Clamped.y}
|
||||||
|
className="stroke-primary/30"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-primary"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={p0.x}
|
||||||
|
cy={p0.y}
|
||||||
|
r={ENDPOINT_RADIUS}
|
||||||
|
className="fill-foreground/20"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={p1.x}
|
||||||
|
cy={p1.y}
|
||||||
|
r={ENDPOINT_RADIUS}
|
||||||
|
className="fill-foreground/20"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={c1Clamped.x}
|
||||||
|
cy={c1Clamped.y}
|
||||||
|
r={HANDLE_RADIUS}
|
||||||
|
className={cn(
|
||||||
|
"fill-primary cursor-grab",
|
||||||
|
activeHandle === "c1" && "cursor-grabbing",
|
||||||
|
)}
|
||||||
|
onPointerDown={onHandlePointerDown({ handle: "c1" })}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={c2Clamped.x}
|
||||||
|
cy={c2Clamped.y}
|
||||||
|
r={HANDLE_RADIUS}
|
||||||
|
className={cn(
|
||||||
|
"fill-primary cursor-grab",
|
||||||
|
activeHandle === "c2" && "cursor-grabbing",
|
||||||
|
)}
|
||||||
|
onPointerDown={onHandlePointerDown({ handle: "c2" })}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Popover, PopoverContent } from "@/components/ui/popover";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/utils/ui";
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import {
|
||||||
|
ArrowDown01Icon,
|
||||||
|
Delete02Icon,
|
||||||
|
PlusSignIcon,
|
||||||
|
} from "@hugeicons/core-free-icons";
|
||||||
|
import { getBezierPoint } from "@/lib/animation/bezier";
|
||||||
|
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||||
|
import type { GraphEditorComponentOption } from "./session";
|
||||||
|
import {
|
||||||
|
BUILTIN_PRESETS,
|
||||||
|
PRESET_MATCH_TOLERANCE,
|
||||||
|
removePreset,
|
||||||
|
savePreset,
|
||||||
|
type EasingPreset,
|
||||||
|
useCustomPresets,
|
||||||
|
} from "./presets";
|
||||||
|
import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
|
||||||
|
|
||||||
|
const COLLAPSED_MAX = 6;
|
||||||
|
const THUMB_SEGMENTS = 24;
|
||||||
|
const THUMB_WIDTH = 40;
|
||||||
|
const THUMB_HEIGHT = 22;
|
||||||
|
const THUMB_PADDING_X = 4;
|
||||||
|
const THUMB_PADDING_Y = 3;
|
||||||
|
const COLLAPSED_GRID_MAX_HEIGHT = 120;
|
||||||
|
const EXPANDED_GRID_MAX_HEIGHT = 240;
|
||||||
|
|
||||||
|
export function GraphEditorPopover({
|
||||||
|
children,
|
||||||
|
side,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
value,
|
||||||
|
message,
|
||||||
|
componentOptions,
|
||||||
|
activeComponentKey,
|
||||||
|
onActiveComponentKeyChange,
|
||||||
|
onPreviewValue,
|
||||||
|
onCommitValue,
|
||||||
|
onCancelPreview,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
side?: "top" | "bottom" | "left" | "right";
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
value: NormalizedCubicBezier | null;
|
||||||
|
message: string;
|
||||||
|
componentOptions: GraphEditorComponentOption[];
|
||||||
|
activeComponentKey: string | null;
|
||||||
|
onActiveComponentKeyChange?: (componentKey: string) => void;
|
||||||
|
onPreviewValue?: (value: NormalizedCubicBezier) => void;
|
||||||
|
onCommitValue?: (value: NormalizedCubicBezier) => void;
|
||||||
|
onCancelPreview?: () => void;
|
||||||
|
}) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const custom = useCustomPresets();
|
||||||
|
const allPresets = [...BUILTIN_PRESETS, ...custom];
|
||||||
|
const canEdit = value !== null;
|
||||||
|
const activePresetId =
|
||||||
|
value == null
|
||||||
|
? null
|
||||||
|
: (allPresets.find((preset) =>
|
||||||
|
preset.value.every(
|
||||||
|
(presetValue, index) =>
|
||||||
|
Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE,
|
||||||
|
),
|
||||||
|
)?.id ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
onCancelPreview?.();
|
||||||
|
}
|
||||||
|
onOpenChange?.(nextOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<PopoverContent
|
||||||
|
side={side}
|
||||||
|
sideOffset={8}
|
||||||
|
className="w-60 overflow-hidden px-0"
|
||||||
|
>
|
||||||
|
{componentOptions.length > 1 && (
|
||||||
|
<div className="border-b px-3 py-2">
|
||||||
|
<div className="bg-muted/40 inline-flex rounded-md p-0.5">
|
||||||
|
{componentOptions.map((component) => (
|
||||||
|
<button
|
||||||
|
key={component.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActiveComponentKeyChange?.(component.key)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer rounded-sm px-2 py-1 text-xs font-medium",
|
||||||
|
activeComponentKey === component.key
|
||||||
|
? "bg-background text-foreground shadow-xs"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{component.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-3 py-3">
|
||||||
|
{value ? (
|
||||||
|
<BezierGraph
|
||||||
|
value={value}
|
||||||
|
onChange={onPreviewValue}
|
||||||
|
onChangeEnd={onCommitValue}
|
||||||
|
onCancel={onCancelPreview}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GraphEditorEmptyState message={message} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2">
|
||||||
|
<TabsList className="px-3">
|
||||||
|
<TabsTrigger value="presets" className="text-xs">
|
||||||
|
Presets
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="saved" className="text-xs">
|
||||||
|
Saved
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="presets" className="px-3 pb-0">
|
||||||
|
<ExpandableGrid
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX}
|
||||||
|
onExpand={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
{BUILTIN_PRESETS.map((preset) => (
|
||||||
|
<PresetItem
|
||||||
|
key={preset.id}
|
||||||
|
preset={preset}
|
||||||
|
isActive={activePresetId === preset.id}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onSelect={() => onCommitValue?.(preset.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ExpandableGrid>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="saved" className="px-3">
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{custom.map((preset) => (
|
||||||
|
<PresetItem
|
||||||
|
key={preset.id}
|
||||||
|
preset={preset}
|
||||||
|
isActive={activePresetId === preset.id}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onSelect={() => onCommitValue?.(preset.value)}
|
||||||
|
onDelete={() => removePreset(preset.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => value && savePreset(value)}
|
||||||
|
disabled={!canEdit}
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
|
||||||
|
canEdit
|
||||||
|
? "hover:bg-foreground/5 cursor-pointer"
|
||||||
|
: "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed">
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={PlusSignIcon}
|
||||||
|
className="size-3.5 opacity-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] leading-tight">Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GraphEditorEmptyState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }}
|
||||||
|
className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpandableGrid({
|
||||||
|
children,
|
||||||
|
isExpanded,
|
||||||
|
shouldExpand,
|
||||||
|
onExpand,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isExpanded: boolean;
|
||||||
|
shouldExpand: boolean;
|
||||||
|
onExpand: () => void;
|
||||||
|
}) {
|
||||||
|
const gridStyle = shouldExpand
|
||||||
|
? isExpanded
|
||||||
|
? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const }
|
||||||
|
: { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="grid grid-cols-3 gap-1" style={gridStyle}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{!isExpanded && shouldExpand && (
|
||||||
|
<div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-5"
|
||||||
|
onClick={onExpand}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={ArrowDown01Icon}
|
||||||
|
className="text-muted-foreground size-3"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresetItem({
|
||||||
|
preset,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
preset: EasingPreset;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1",
|
||||||
|
disabled
|
||||||
|
? "cursor-not-allowed opacity-50"
|
||||||
|
: "hover:bg-foreground/5 cursor-pointer",
|
||||||
|
isActive && "bg-primary/5! text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5",
|
||||||
|
isActive && "bg-primary/5!",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CurveThumb value={preset.value} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] leading-tight",
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</span>
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurveThumb({ value }: { value: NormalizedCubicBezier }) {
|
||||||
|
const points: string[] = [];
|
||||||
|
for (let i = 0; i <= THUMB_SEGMENTS; i++) {
|
||||||
|
const progress = i / THUMB_SEGMENTS;
|
||||||
|
points.push(
|
||||||
|
`${THUMB_PADDING_X + getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) * (THUMB_WIDTH - THUMB_PADDING_X * 2)},${THUMB_PADDING_Y + (1 - getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 })) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={THUMB_WIDTH}
|
||||||
|
height={THUMB_HEIGHT}
|
||||||
|
viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
|
||||||
|
>
|
||||||
|
<title>Curve preset preview</title>
|
||||||
|
<path
|
||||||
|
d={`M${points.join("L")}`}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-current"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "opencut:graph-editor-presets";
|
||||||
|
export const PRESET_MATCH_TOLERANCE = 0.02;
|
||||||
|
|
||||||
|
export interface EasingPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: NormalizedCubicBezier;
|
||||||
|
isCustom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUILTIN_PRESETS: EasingPreset[] = [
|
||||||
|
{ id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
|
||||||
|
{ id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
|
||||||
|
{ id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
|
||||||
|
{ id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
|
||||||
|
{ id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
|
||||||
|
{ id: "linear", label: "Linear", value: [0, 0, 1, 1] },
|
||||||
|
];
|
||||||
|
|
||||||
|
let cachedPresets: EasingPreset[] | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function readFromStorage(): EasingPreset[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
// JSON.parse can throw if the stored value is corrupted
|
||||||
|
return raw ? (JSON.parse(raw) as EasingPreset[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToStorage(presets: EasingPreset[]): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): EasingPreset[] {
|
||||||
|
cachedPresets ??= readFromStorage();
|
||||||
|
return cachedPresets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerSnapshot(): EasingPreset[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
cachedPresets = null;
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("storage", (event) => {
|
||||||
|
if (event.key === STORAGE_KEY) notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomPresets(): EasingPreset[] {
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePreset(value: NormalizedCubicBezier): void {
|
||||||
|
const current = getSnapshot();
|
||||||
|
writeToStorage([
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `custom-${Date.now()}`,
|
||||||
|
label: `Custom ${current.length + 1}`,
|
||||||
|
value,
|
||||||
|
isCustom: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePreset(id: string): void {
|
||||||
|
writeToStorage(getSnapshot().filter((preset) => preset.id !== id));
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,448 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCurveHandlesForNormalizedCubicBezier,
|
||||||
|
getEditableScalarChannels,
|
||||||
|
getNormalizedCubicBezierForScalarSegment,
|
||||||
|
getScalarKeyframeContext,
|
||||||
|
updateScalarKeyframeCurve,
|
||||||
|
} from "@/lib/animation";
|
||||||
|
import type {
|
||||||
|
AnimationPath,
|
||||||
|
ElementAnimations,
|
||||||
|
NormalizedCubicBezier,
|
||||||
|
ScalarCurveKeyframePatch,
|
||||||
|
ScalarGraphKeyframeContext,
|
||||||
|
SelectedKeyframeRef,
|
||||||
|
} from "@/lib/animation/types";
|
||||||
|
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||||
|
|
||||||
|
const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
|
||||||
|
const FLAT_VALUE_EPSILON = 1e-6;
|
||||||
|
const LINEAR_CURVE_EPSILON = 1e-6;
|
||||||
|
|
||||||
|
export type GraphEditorUnavailableReason =
|
||||||
|
| "no-keyframe-selected"
|
||||||
|
| "multiple-keyframes-selected"
|
||||||
|
| "selected-element-missing"
|
||||||
|
| "selected-element-has-no-animations"
|
||||||
|
| "selected-keyframe-has-no-scalar-channel"
|
||||||
|
| "selected-keyframe-missing-on-channel"
|
||||||
|
| "selected-keyframe-has-no-next-segment"
|
||||||
|
| "selected-segment-is-hold"
|
||||||
|
| "selected-segment-is-flat";
|
||||||
|
|
||||||
|
export interface GraphEditorComponentOption {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphEditorBaseSelectionState {
|
||||||
|
componentOptions: GraphEditorComponentOption[];
|
||||||
|
activeComponentKey: string | null;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEditorUnavailableState
|
||||||
|
extends GraphEditorBaseSelectionState {
|
||||||
|
status: "unavailable";
|
||||||
|
reason: GraphEditorUnavailableReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
|
||||||
|
status: "ready";
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
propertyPath: SelectedKeyframeRef["propertyPath"];
|
||||||
|
keyframeId: string;
|
||||||
|
element: TimelineElement;
|
||||||
|
context: ScalarGraphKeyframeContext;
|
||||||
|
cubicBezier: NormalizedCubicBezier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GraphEditorSelectionState =
|
||||||
|
| GraphEditorUnavailableState
|
||||||
|
| GraphEditorReadyState;
|
||||||
|
|
||||||
|
export interface GraphEditorCurvePatch {
|
||||||
|
keyframeId: string;
|
||||||
|
patch: ScalarCurveKeyframePatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUnavailableState({
|
||||||
|
reason,
|
||||||
|
message,
|
||||||
|
componentOptions = [],
|
||||||
|
activeComponentKey = null,
|
||||||
|
}: {
|
||||||
|
reason: GraphEditorUnavailableReason;
|
||||||
|
message: string;
|
||||||
|
componentOptions?: GraphEditorComponentOption[];
|
||||||
|
activeComponentKey?: string | null;
|
||||||
|
}): GraphEditorUnavailableState {
|
||||||
|
return {
|
||||||
|
status: "unavailable",
|
||||||
|
reason,
|
||||||
|
message,
|
||||||
|
componentOptions,
|
||||||
|
activeComponentKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findElementByKeyframe({
|
||||||
|
tracks,
|
||||||
|
keyframe,
|
||||||
|
}: {
|
||||||
|
tracks: TimelineTrack[];
|
||||||
|
keyframe: SelectedKeyframeRef;
|
||||||
|
}): { element: TimelineElement; trackId: string; elementId: string } | null {
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (track.id !== keyframe.trackId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = track.elements.find(
|
||||||
|
(trackElement) => trackElement.id === keyframe.elementId,
|
||||||
|
);
|
||||||
|
if (!element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
trackId: track.id,
|
||||||
|
elementId: element.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findKeyframeTime({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
keyframeId,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
keyframeId: string;
|
||||||
|
}): number | null {
|
||||||
|
const binding = animations.bindings[propertyPath];
|
||||||
|
if (!binding) return null;
|
||||||
|
|
||||||
|
for (const component of binding.components) {
|
||||||
|
const channel = animations.channels[component.channelId];
|
||||||
|
if (channel?.kind !== "scalar") continue;
|
||||||
|
const key = channel.keys.find((k) => k.id === keyframeId);
|
||||||
|
if (key !== undefined) return key.time;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComponentLabel({ componentKey }: { componentKey: string }): string {
|
||||||
|
switch (componentKey) {
|
||||||
|
case "value":
|
||||||
|
return "Value";
|
||||||
|
default:
|
||||||
|
return componentKey.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFlatSegment({
|
||||||
|
context,
|
||||||
|
}: {
|
||||||
|
context: ScalarGraphKeyframeContext;
|
||||||
|
}): boolean {
|
||||||
|
if (!context.nextKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.abs(context.nextKey.value - context.keyframe.value) <=
|
||||||
|
FLAT_VALUE_EPSILON
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLinearCurve({
|
||||||
|
cubicBezier,
|
||||||
|
}: {
|
||||||
|
cubicBezier: NormalizedCubicBezier;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
|
||||||
|
Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
|
||||||
|
Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
|
||||||
|
Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGraphEditorSelectionState({
|
||||||
|
tracks,
|
||||||
|
selectedKeyframes,
|
||||||
|
preferredComponentKey,
|
||||||
|
}: {
|
||||||
|
tracks: TimelineTrack[];
|
||||||
|
selectedKeyframes: SelectedKeyframeRef[];
|
||||||
|
preferredComponentKey?: string | null;
|
||||||
|
}): GraphEditorSelectionState {
|
||||||
|
if (selectedKeyframes.length === 0) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "no-keyframe-selected",
|
||||||
|
message: "Select a keyframe to edit its curve.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedKeyframes.length > 2) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "multiple-keyframes-selected",
|
||||||
|
message: "Select one or two adjacent keyframes to edit a curve.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedKeyframes.length === 2) {
|
||||||
|
const [kf1, kf2] = selectedKeyframes;
|
||||||
|
if (
|
||||||
|
kf1.trackId !== kf2.trackId ||
|
||||||
|
kf1.elementId !== kf2.elementId ||
|
||||||
|
kf1.propertyPath !== kf2.propertyPath
|
||||||
|
) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "multiple-keyframes-selected",
|
||||||
|
message: "Selected keyframes must be on the same element and property.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryKeyframe = selectedKeyframes[0];
|
||||||
|
const secondaryKeyframeId =
|
||||||
|
selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null;
|
||||||
|
|
||||||
|
const selectedElement = findElementByKeyframe({
|
||||||
|
tracks,
|
||||||
|
keyframe: primaryKeyframe,
|
||||||
|
});
|
||||||
|
if (!selectedElement) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "selected-element-missing",
|
||||||
|
message: "The selected keyframe could not be resolved.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedElement.element.animations) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "selected-element-has-no-animations",
|
||||||
|
message: "The selected keyframe has no editable graph.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scalarChannels = getEditableScalarChannels({
|
||||||
|
animations: selectedElement.element.animations,
|
||||||
|
propertyPath: primaryKeyframe.propertyPath,
|
||||||
|
});
|
||||||
|
if (scalarChannels.length === 0) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "selected-keyframe-has-no-scalar-channel",
|
||||||
|
message: "The selected keyframe has no editable graph channel.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// When 2 keyframes are selected, resolve the earlier one as the outgoing-segment
|
||||||
|
// anchor so the graph editor edits the curve between the two selected keyframes.
|
||||||
|
let resolvedKeyframeId = primaryKeyframe.keyframeId;
|
||||||
|
if (secondaryKeyframeId !== null) {
|
||||||
|
const time1 = findKeyframeTime({
|
||||||
|
animations: selectedElement.element.animations,
|
||||||
|
propertyPath: primaryKeyframe.propertyPath,
|
||||||
|
keyframeId: primaryKeyframe.keyframeId,
|
||||||
|
});
|
||||||
|
const time2 = findKeyframeTime({
|
||||||
|
animations: selectedElement.element.animations,
|
||||||
|
propertyPath: primaryKeyframe.propertyPath,
|
||||||
|
keyframeId: secondaryKeyframeId,
|
||||||
|
});
|
||||||
|
if (time2 !== null && (time1 === null || time2 < time1)) {
|
||||||
|
resolvedKeyframeId = secondaryKeyframeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contexts = scalarChannels.flatMap((channel) => {
|
||||||
|
const context = getScalarKeyframeContext({
|
||||||
|
animations: selectedElement.element.animations,
|
||||||
|
propertyPath: primaryKeyframe.propertyPath,
|
||||||
|
componentKey: channel.componentKey,
|
||||||
|
keyframeId: resolvedKeyframeId,
|
||||||
|
});
|
||||||
|
if (!context) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
context,
|
||||||
|
option: {
|
||||||
|
key: channel.componentKey,
|
||||||
|
label: getComponentLabel({ componentKey: channel.componentKey }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contexts.length === 0) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "selected-keyframe-missing-on-channel",
|
||||||
|
message: "The selected keyframe is not editable as a graph segment.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSegmentContexts = contexts.filter(
|
||||||
|
({ context }) => context.nextKey !== null,
|
||||||
|
);
|
||||||
|
const preferredContext =
|
||||||
|
contexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
|
||||||
|
const activeContext =
|
||||||
|
preferredContext ?? nextSegmentContexts[0] ?? contexts[0];
|
||||||
|
const componentOptions = contexts.map(({ option }) => option);
|
||||||
|
|
||||||
|
if (!activeContext.context.nextKey) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "selected-keyframe-has-no-next-segment",
|
||||||
|
message: "Select a keyframe that has an outgoing segment.",
|
||||||
|
componentOptions,
|
||||||
|
activeComponentKey: activeContext.option.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFlatSegment({ context: activeContext.context })) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "selected-segment-is-flat",
|
||||||
|
message: "Flat segments are not graph-editable in this popover yet.",
|
||||||
|
componentOptions,
|
||||||
|
activeComponentKey: activeContext.option.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeContext.context.keyframe.segmentToNext === "step") {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "selected-segment-is-hold",
|
||||||
|
message: "Hold segments are not graph-editable in this popover yet.",
|
||||||
|
componentOptions,
|
||||||
|
activeComponentKey: activeContext.option.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cubicBezier =
|
||||||
|
activeContext.context.keyframe.segmentToNext === "linear"
|
||||||
|
? GRAPH_LINEAR_CURVE
|
||||||
|
: getNormalizedCubicBezierForScalarSegment({
|
||||||
|
leftKey: activeContext.context.keyframe,
|
||||||
|
rightKey: activeContext.context.nextKey,
|
||||||
|
});
|
||||||
|
if (!cubicBezier) {
|
||||||
|
return createUnavailableState({
|
||||||
|
reason: "selected-segment-is-flat",
|
||||||
|
message: "The selected segment cannot be represented in this graph view.",
|
||||||
|
componentOptions,
|
||||||
|
activeComponentKey: activeContext.option.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
message: "Edit graph",
|
||||||
|
componentOptions,
|
||||||
|
activeComponentKey: activeContext.option.key,
|
||||||
|
trackId: selectedElement.trackId,
|
||||||
|
elementId: selectedElement.elementId,
|
||||||
|
propertyPath: primaryKeyframe.propertyPath,
|
||||||
|
keyframeId: resolvedKeyframeId,
|
||||||
|
element: selectedElement.element,
|
||||||
|
context: activeContext.context,
|
||||||
|
cubicBezier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGraphEditorCurvePatches({
|
||||||
|
context,
|
||||||
|
cubicBezier,
|
||||||
|
}: {
|
||||||
|
context: ScalarGraphKeyframeContext;
|
||||||
|
cubicBezier: NormalizedCubicBezier;
|
||||||
|
}): GraphEditorCurvePatch[] | null {
|
||||||
|
if (!context.nextKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLinearCurve({ cubicBezier })) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
keyframeId: context.keyframe.id,
|
||||||
|
patch: {
|
||||||
|
segmentToNext: "linear",
|
||||||
|
rightHandle: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keyframeId: context.nextKey.id,
|
||||||
|
patch: {
|
||||||
|
leftHandle: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const handles = getCurveHandlesForNormalizedCubicBezier({
|
||||||
|
leftKey: context.keyframe,
|
||||||
|
rightKey: context.nextKey,
|
||||||
|
cubicBezier,
|
||||||
|
});
|
||||||
|
if (!handles) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
keyframeId: context.keyframe.id,
|
||||||
|
patch: {
|
||||||
|
segmentToNext: "bezier",
|
||||||
|
rightHandle: handles.rightHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keyframeId: context.nextKey.id,
|
||||||
|
patch: {
|
||||||
|
leftHandle: handles.leftHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyGraphEditorCurvePreview({
|
||||||
|
animations,
|
||||||
|
context,
|
||||||
|
cubicBezier,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
context: ScalarGraphKeyframeContext;
|
||||||
|
cubicBezier: NormalizedCubicBezier;
|
||||||
|
}): ElementAnimations | undefined {
|
||||||
|
const patches = buildGraphEditorCurvePatches({
|
||||||
|
context,
|
||||||
|
cubicBezier,
|
||||||
|
});
|
||||||
|
if (!patches) {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return patches.reduce<ElementAnimations | undefined>(
|
||||||
|
(nextAnimations, { keyframeId, patch }) =>
|
||||||
|
updateScalarKeyframeCurve({
|
||||||
|
animations: nextAnimations,
|
||||||
|
propertyPath: context.propertyPath,
|
||||||
|
componentKey: context.componentKey,
|
||||||
|
keyframeId,
|
||||||
|
patch,
|
||||||
|
}),
|
||||||
|
animations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
import { registerCanceller } from "@/lib/cancel-interaction";
|
||||||
|
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||||
|
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
||||||
|
import {
|
||||||
|
applyGraphEditorCurvePreview,
|
||||||
|
buildGraphEditorCurvePatches,
|
||||||
|
resolveGraphEditorSelectionState,
|
||||||
|
type GraphEditorSelectionState,
|
||||||
|
} from "./session";
|
||||||
|
|
||||||
|
export function useGraphEditorController() {
|
||||||
|
const editor = useEditor();
|
||||||
|
const renderTracks = useEditor((currentEditor) =>
|
||||||
|
currentEditor.timeline.getRenderTracks(),
|
||||||
|
);
|
||||||
|
const { selectedKeyframes } = useKeyframeSelection();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeComponentKey, setActiveComponentKey] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const hasPreviewRef = useRef(false);
|
||||||
|
|
||||||
|
const state = useMemo<GraphEditorSelectionState>(
|
||||||
|
() =>
|
||||||
|
resolveGraphEditorSelectionState({
|
||||||
|
tracks: renderTracks,
|
||||||
|
selectedKeyframes,
|
||||||
|
preferredComponentKey: activeComponentKey,
|
||||||
|
}),
|
||||||
|
[activeComponentKey, renderTracks, selectedKeyframes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stateKey =
|
||||||
|
state.status === "ready"
|
||||||
|
? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}`
|
||||||
|
: `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
|
||||||
|
const previousStateKeyRef = useRef(stateKey);
|
||||||
|
|
||||||
|
const discardPreview = useCallback(() => {
|
||||||
|
if (!hasPreviewRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.timeline.discardPreview();
|
||||||
|
hasPreviewRef.current = false;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) {
|
||||||
|
discardPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
previousStateKeyRef.current = stateKey;
|
||||||
|
}, [discardPreview, stateKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return registerCanceller({
|
||||||
|
fn: () => {
|
||||||
|
discardPreview();
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [discardPreview, open]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(nextOpen: boolean) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
discardPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(nextOpen);
|
||||||
|
},
|
||||||
|
[discardPreview],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleActiveComponentKeyChange = useCallback(
|
||||||
|
(nextComponentKey: string) => {
|
||||||
|
discardPreview();
|
||||||
|
setActiveComponentKey(nextComponentKey);
|
||||||
|
},
|
||||||
|
[discardPreview],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handlePreviewValue(nextValue: NormalizedCubicBezier) {
|
||||||
|
if (state.status !== "ready") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAnimations = applyGraphEditorCurvePreview({
|
||||||
|
animations: state.element.animations,
|
||||||
|
context: state.context,
|
||||||
|
cubicBezier: nextValue,
|
||||||
|
});
|
||||||
|
editor.timeline.previewElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId: state.trackId,
|
||||||
|
elementId: state.elementId,
|
||||||
|
updates: {
|
||||||
|
animations: nextAnimations,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
hasPreviewRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommitValue(nextValue: NormalizedCubicBezier) {
|
||||||
|
if (state.status !== "ready") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patches = buildGraphEditorCurvePatches({
|
||||||
|
context: state.context,
|
||||||
|
cubicBezier: nextValue,
|
||||||
|
});
|
||||||
|
if (!patches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.timeline.updateKeyframeCurves({
|
||||||
|
keyframes: patches.map(({ keyframeId, patch }) => ({
|
||||||
|
trackId: state.trackId,
|
||||||
|
elementId: state.elementId,
|
||||||
|
propertyPath: state.propertyPath,
|
||||||
|
componentKey: state.context.componentKey,
|
||||||
|
keyframeId,
|
||||||
|
patch,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
hasPreviewRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
onOpenChange: handleOpenChange,
|
||||||
|
canOpen: state.status === "ready",
|
||||||
|
tooltip: state.status === "ready" ? "Open graph editor" : state.message,
|
||||||
|
state,
|
||||||
|
onActiveComponentKeyChange: handleActiveComponentKeyChange,
|
||||||
|
onPreviewValue: handlePreviewValue,
|
||||||
|
onCommitValue: handleCommitValue,
|
||||||
|
onCancelPreview: discardPreview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -14,9 +14,7 @@ import {
|
||||||
SplitButtonSeparator,
|
SplitButtonSeparator,
|
||||||
} from "@/components/ui/split-button";
|
} from "@/components/ui/split-button";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import {
|
import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
|
||||||
TIMELINE_ZOOM_BUTTON_FACTOR,
|
|
||||||
} from "./interaction";
|
|
||||||
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
|
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
|
||||||
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||||
import { ScenesView } from "@/components/editor/scenes-view";
|
import { ScenesView } from "@/components/editor/scenes-view";
|
||||||
|
|
@ -36,7 +34,6 @@ import {
|
||||||
SnowIcon,
|
SnowIcon,
|
||||||
ScissorIcon,
|
ScissorIcon,
|
||||||
MagnetIcon,
|
MagnetIcon,
|
||||||
Link04Icon,
|
|
||||||
SearchAddIcon,
|
SearchAddIcon,
|
||||||
SearchMinusIcon,
|
SearchMinusIcon,
|
||||||
Copy01Icon,
|
Copy01Icon,
|
||||||
|
|
@ -49,7 +46,9 @@ import {
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { OcRippleIcon } from "@/components/icons";
|
import { OcRippleIcon } from "@/components/icons";
|
||||||
|
import { GraphEditorPopover } from "./graph-editor/popover";
|
||||||
|
import { PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { useGraphEditorController } from "./graph-editor/use-controller";
|
||||||
|
|
||||||
export function TimelineToolbar({
|
export function TimelineToolbar({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|
@ -63,10 +62,7 @@ export function TimelineToolbar({
|
||||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||||
const newZoomLevel =
|
const newZoomLevel =
|
||||||
direction === "in"
|
direction === "in"
|
||||||
? Math.min(
|
? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
|
||||||
TIMELINE_ZOOM_MAX,
|
|
||||||
zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR,
|
|
||||||
)
|
|
||||||
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
|
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
|
||||||
setZoomLevel({ zoom: newZoomLevel });
|
setZoomLevel({ zoom: newZoomLevel });
|
||||||
};
|
};
|
||||||
|
|
@ -91,8 +87,11 @@ export function TimelineToolbar({
|
||||||
|
|
||||||
function ToolbarLeftSection() {
|
function ToolbarLeftSection() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets());
|
const mediaAssets = useEditor((currentEditor) =>
|
||||||
|
currentEditor.media.getAssets(),
|
||||||
|
);
|
||||||
const { selectedElements } = useElementSelection();
|
const { selectedElements } = useElementSelection();
|
||||||
|
const graphEditor = useGraphEditorController();
|
||||||
const isCurrentlyBookmarked = useEditor((e) =>
|
const isCurrentlyBookmarked = useEditor((e) =>
|
||||||
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
|
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
|
||||||
);
|
);
|
||||||
|
|
@ -164,11 +163,11 @@ function ToolbarLeftSection() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={
|
icon={
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon
|
||||||
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
tooltip={sourceAudioLabel}
|
tooltip={sourceAudioLabel}
|
||||||
disabled={!canToggleSelectedSourceAudio}
|
disabled={!canToggleSelectedSourceAudio}
|
||||||
onClick={({ event }) =>
|
onClick={({ event }) =>
|
||||||
|
|
@ -212,13 +211,35 @@ function ToolbarLeftSection() {
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<GraphEditorPopover
|
||||||
|
open={graphEditor.open}
|
||||||
|
onOpenChange={graphEditor.onOpenChange}
|
||||||
|
value={
|
||||||
|
graphEditor.state.status === "ready"
|
||||||
|
? graphEditor.state.cubicBezier
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
message={graphEditor.state.message}
|
||||||
|
componentOptions={graphEditor.state.componentOptions}
|
||||||
|
activeComponentKey={graphEditor.state.activeComponentKey}
|
||||||
|
onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange}
|
||||||
|
onPreviewValue={graphEditor.onPreviewValue}
|
||||||
|
onCommitValue={graphEditor.onCommitValue}
|
||||||
|
onCancelPreview={graphEditor.onCancelPreview}
|
||||||
|
>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<HugeiconsIcon icon={Chart03Icon} />}
|
icon={<HugeiconsIcon icon={Chart03Icon} />}
|
||||||
tooltip="Open graph editor"
|
tooltip={graphEditor.tooltip}
|
||||||
onClick={() => {}}
|
disabled={!graphEditor.canOpen}
|
||||||
|
buttonWrapper={(button) =>
|
||||||
|
graphEditor.canOpen ? (
|
||||||
|
<PopoverTrigger asChild>{button}</PopoverTrigger>
|
||||||
|
) : (
|
||||||
|
button
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</GraphEditorPopover>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -338,12 +359,17 @@ function ToolbarButton({
|
||||||
{icon}
|
{icon}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
const trigger = disabled ? (
|
||||||
|
<span className="inline-flex">{button}</span>
|
||||||
|
) : buttonWrapper ? (
|
||||||
|
buttonWrapper(button)
|
||||||
|
) : (
|
||||||
|
button
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={200}>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||||
{buttonWrapper ? buttonWrapper(button) : button}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{tooltip}</TooltipContent>
|
<TooltipContent>{tooltip}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ const TabsContent = React.forwardRef<
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
|
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
|
||||||
variant === "underline" && "px-4",
|
variant === "underline" && "px-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
|
ScalarCurveKeyframePatch,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { calculateTotalDuration } from "@/lib/timeline";
|
import { calculateTotalDuration } from "@/lib/timeline";
|
||||||
import { getLastFrameTime } from "opencut-wasm";
|
import { getLastFrameTime } from "opencut-wasm";
|
||||||
|
|
@ -36,6 +37,7 @@ import {
|
||||||
UpsertKeyframeCommand,
|
UpsertKeyframeCommand,
|
||||||
RemoveKeyframeCommand,
|
RemoveKeyframeCommand,
|
||||||
RetimeKeyframeCommand,
|
RetimeKeyframeCommand,
|
||||||
|
UpdateScalarKeyframeCurveCommand,
|
||||||
AddClipEffectCommand,
|
AddClipEffectCommand,
|
||||||
RemoveClipEffectCommand,
|
RemoveClipEffectCommand,
|
||||||
UpdateClipEffectParamsCommand,
|
UpdateClipEffectParamsCommand,
|
||||||
|
|
@ -549,6 +551,45 @@ export class TimelineManager {
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateKeyframeCurves({
|
||||||
|
keyframes,
|
||||||
|
}: {
|
||||||
|
keyframes: Array<{
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
keyframeId: string;
|
||||||
|
patch: ScalarCurveKeyframePatch;
|
||||||
|
}>;
|
||||||
|
}): void {
|
||||||
|
if (keyframes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = keyframes.map(
|
||||||
|
({
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
keyframeId,
|
||||||
|
patch,
|
||||||
|
}) =>
|
||||||
|
new UpdateScalarKeyframeCurveCommand({
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
keyframeId,
|
||||||
|
patch,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const command =
|
||||||
|
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
||||||
|
this.editor.command.execute({ command });
|
||||||
|
}
|
||||||
|
|
||||||
upsertEffectParamKeyframe({
|
upsertEffectParamKeyframe({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||||
|
import {
|
||||||
|
getDefaultLeftHandle,
|
||||||
|
getDefaultRightHandle,
|
||||||
|
} from "@/lib/animation/bezier";
|
||||||
|
import type {
|
||||||
|
CurveHandle,
|
||||||
|
NormalizedCubicBezier,
|
||||||
|
ScalarAnimationKey,
|
||||||
|
} from "@/lib/animation/types";
|
||||||
|
|
||||||
|
const VALUE_EPSILON = 1e-6;
|
||||||
|
|
||||||
|
function clamp01({ value }: { value: number }): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNormalizedCubicBezierForScalarSegment({
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
}: {
|
||||||
|
leftKey: ScalarAnimationKey;
|
||||||
|
rightKey: ScalarAnimationKey;
|
||||||
|
}): NormalizedCubicBezier | null {
|
||||||
|
const spanTime = rightKey.time - leftKey.time;
|
||||||
|
const spanValue = rightKey.value - leftKey.value;
|
||||||
|
if (
|
||||||
|
Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
|
||||||
|
Math.abs(spanValue) <= VALUE_EPSILON
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightHandle =
|
||||||
|
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||||
|
const leftHandle =
|
||||||
|
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||||
|
|
||||||
|
return [
|
||||||
|
clamp01({ value: rightHandle.dt / spanTime }),
|
||||||
|
rightHandle.dv / spanValue,
|
||||||
|
clamp01({ value: 1 + leftHandle.dt / spanTime }),
|
||||||
|
1 + leftHandle.dv / spanValue,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurveHandlesForNormalizedCubicBezier({
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
cubicBezier,
|
||||||
|
}: {
|
||||||
|
leftKey: ScalarAnimationKey;
|
||||||
|
rightKey: ScalarAnimationKey;
|
||||||
|
cubicBezier: NormalizedCubicBezier;
|
||||||
|
}): {
|
||||||
|
rightHandle: CurveHandle;
|
||||||
|
leftHandle: CurveHandle;
|
||||||
|
} | null {
|
||||||
|
const spanTime = rightKey.time - leftKey.time;
|
||||||
|
const spanValue = rightKey.value - leftKey.value;
|
||||||
|
if (
|
||||||
|
Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
|
||||||
|
Math.abs(spanValue) <= VALUE_EPSILON
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rawX1, y1, rawX2, y2] = cubicBezier;
|
||||||
|
const x1 = clamp01({ value: rawX1 });
|
||||||
|
const x2 = clamp01({ value: rawX2 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
rightHandle: {
|
||||||
|
dt: spanTime * x1,
|
||||||
|
dv: spanValue * y1,
|
||||||
|
},
|
||||||
|
leftHandle: {
|
||||||
|
dt: spanTime * (x2 - 1),
|
||||||
|
dv: spanValue * (y2 - 1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import type {
|
||||||
|
AnimationPath,
|
||||||
|
ElementAnimations,
|
||||||
|
ScalarAnimationChannel,
|
||||||
|
ScalarGraphChannel,
|
||||||
|
ScalarGraphKeyframeContext,
|
||||||
|
} from "@/lib/animation/types";
|
||||||
|
|
||||||
|
function isScalarAnimationChannel(
|
||||||
|
channel: ElementAnimations["channels"][string],
|
||||||
|
): channel is ScalarAnimationChannel {
|
||||||
|
return channel?.kind === "scalar";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEditableScalarChannels({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
}): ScalarGraphChannel[] {
|
||||||
|
const binding = animations?.bindings[propertyPath];
|
||||||
|
if (!binding) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.components.flatMap((component) => {
|
||||||
|
const channel = animations?.channels[component.channelId];
|
||||||
|
if (!isScalarAnimationChannel(channel)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
propertyPath,
|
||||||
|
componentKey: component.key,
|
||||||
|
channelId: component.channelId,
|
||||||
|
channel,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEditableScalarChannel({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
}): ScalarGraphChannel | null {
|
||||||
|
return (
|
||||||
|
getEditableScalarChannels({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
}).find((channel) => channel.componentKey === componentKey) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScalarKeyframeContext({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
keyframeId,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
keyframeId: string;
|
||||||
|
}): ScalarGraphKeyframeContext | null {
|
||||||
|
const scalarChannel = getEditableScalarChannel({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
});
|
||||||
|
if (!scalarChannel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframeIndex = scalarChannel.channel.keys.findIndex(
|
||||||
|
(keyframe) => keyframe.id === keyframeId,
|
||||||
|
);
|
||||||
|
if (keyframeIndex < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...scalarChannel,
|
||||||
|
keyframe: scalarChannel.channel.keys[keyframeIndex],
|
||||||
|
keyframeIndex,
|
||||||
|
previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null,
|
||||||
|
nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -12,8 +12,10 @@ export {
|
||||||
getChannel,
|
getChannel,
|
||||||
removeElementKeyframe,
|
removeElementKeyframe,
|
||||||
retimeElementKeyframe,
|
retimeElementKeyframe,
|
||||||
|
setBindingComponentChannel,
|
||||||
setChannel,
|
setChannel,
|
||||||
splitAnimationsAtTime,
|
splitAnimationsAtTime,
|
||||||
|
updateScalarKeyframeCurve,
|
||||||
upsertElementKeyframe,
|
upsertElementKeyframe,
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "./keyframes";
|
} from "./keyframes";
|
||||||
|
|
@ -46,6 +48,17 @@ export {
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
} from "./keyframe-query";
|
} from "./keyframe-query";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getEditableScalarChannel,
|
||||||
|
getEditableScalarChannels,
|
||||||
|
getScalarKeyframeContext,
|
||||||
|
} from "./graph-channels";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getCurveHandlesForNormalizedCubicBezier,
|
||||||
|
getNormalizedCubicBezierForScalarSegment,
|
||||||
|
} from "./curve-bridge";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
buildGraphicParamPath,
|
buildGraphicParamPath,
|
||||||
isGraphicParamPath,
|
isGraphicParamPath,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
ScalarAnimationChannel,
|
ScalarAnimationChannel,
|
||||||
ScalarAnimationKey,
|
ScalarAnimationKey,
|
||||||
|
ScalarCurveKeyframePatch,
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||||
|
|
@ -209,7 +210,7 @@ function getBinding({
|
||||||
propertyPath,
|
propertyPath,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: string;
|
propertyPath: AnimationPath;
|
||||||
}): AnimationBindingInstance | undefined {
|
}): AnimationBindingInstance | undefined {
|
||||||
return animations?.bindings[propertyPath];
|
return animations?.bindings[propertyPath];
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +225,16 @@ function getChannelById({
|
||||||
return animations?.channels[channelId];
|
return animations?.channels[channelId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBindingComponent({
|
||||||
|
binding,
|
||||||
|
componentKey,
|
||||||
|
}: {
|
||||||
|
binding: AnimationBindingInstance;
|
||||||
|
componentKey: string;
|
||||||
|
}) {
|
||||||
|
return binding.components.find((component) => component.key === componentKey) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function getTargetKeyMetadata({
|
function getTargetKeyMetadata({
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
|
|
@ -402,7 +413,7 @@ export function getChannel({
|
||||||
propertyPath,
|
propertyPath,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: string;
|
propertyPath: AnimationPath;
|
||||||
}): AnimationChannel | undefined {
|
}): AnimationChannel | undefined {
|
||||||
const binding = getBinding({ animations, propertyPath });
|
const binding = getBinding({ animations, propertyPath });
|
||||||
const primaryChannelId =
|
const primaryChannelId =
|
||||||
|
|
@ -651,7 +662,7 @@ export function setChannel({
|
||||||
channel,
|
channel,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: string;
|
propertyPath: AnimationPath;
|
||||||
channel: AnimationChannel | undefined;
|
channel: AnimationChannel | undefined;
|
||||||
}): ElementAnimations | undefined {
|
}): ElementAnimations | undefined {
|
||||||
const binding = getBinding({ animations, propertyPath });
|
const binding = getBinding({ animations, propertyPath });
|
||||||
|
|
@ -659,29 +670,66 @@ export function setChannel({
|
||||||
return animations;
|
return animations;
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryComponent = getPrimaryComponent({ binding });
|
|
||||||
if (!primaryComponent) {
|
|
||||||
return animations;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextAnimations = cloneAnimationsState({ animations });
|
|
||||||
if (!channel || !hasChannelKeys({ channel })) {
|
|
||||||
for (const component of binding.components) {
|
|
||||||
delete nextAnimations.channels[component.channelId];
|
|
||||||
}
|
|
||||||
delete nextAnimations.bindings[propertyPath];
|
|
||||||
return toAnimation({
|
|
||||||
animations: nextAnimations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.components.length !== 1) {
|
if (binding.components.length !== 1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
|
`setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextAnimations.channels[primaryComponent.channelId] = normalizeChannel({
|
const primaryComponent = getPrimaryComponent({ binding });
|
||||||
|
if (!primaryComponent) {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return setBindingComponentChannel({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey: primaryComponent.key,
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBindingComponentChannel({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
channel,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
channel: AnimationChannel | undefined;
|
||||||
|
}): ElementAnimations | undefined {
|
||||||
|
const binding = getBinding({ animations, propertyPath });
|
||||||
|
if (!binding) {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = getBindingComponent({
|
||||||
|
binding,
|
||||||
|
componentKey,
|
||||||
|
});
|
||||||
|
if (!component) {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAnimations = cloneAnimationsState({ animations });
|
||||||
|
if (!channel || !hasChannelKeys({ channel })) {
|
||||||
|
delete nextAnimations.channels[component.channelId];
|
||||||
|
const hasRemainingKeys = binding.components.some((candidate) =>
|
||||||
|
hasChannelKeys({
|
||||||
|
channel: nextAnimations.channels[candidate.channelId],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!hasRemainingKeys) {
|
||||||
|
delete nextAnimations.bindings[propertyPath];
|
||||||
|
}
|
||||||
|
return toAnimation({
|
||||||
|
animations: nextAnimations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextAnimations.channels[component.channelId] = normalizeChannel({
|
||||||
channel,
|
channel,
|
||||||
});
|
});
|
||||||
return toAnimation({
|
return toAnimation({
|
||||||
|
|
@ -689,6 +737,73 @@ export function setChannel({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateScalarKeyframeCurve({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
keyframeId,
|
||||||
|
patch,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
keyframeId: string;
|
||||||
|
patch: ScalarCurveKeyframePatch;
|
||||||
|
}): ElementAnimations | undefined {
|
||||||
|
const binding = getBinding({ animations, propertyPath });
|
||||||
|
if (!binding) {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = getBindingComponent({
|
||||||
|
binding,
|
||||||
|
componentKey,
|
||||||
|
});
|
||||||
|
if (!component) {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = getChannelById({
|
||||||
|
animations,
|
||||||
|
channelId: component.channelId,
|
||||||
|
});
|
||||||
|
if (channel?.kind !== "scalar") {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId);
|
||||||
|
if (keyframeIndex < 0) {
|
||||||
|
return animations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextKeys = [...channel.keys];
|
||||||
|
const currentKey = nextKeys[keyframeIndex];
|
||||||
|
nextKeys[keyframeIndex] = {
|
||||||
|
...currentKey,
|
||||||
|
leftHandle:
|
||||||
|
patch.leftHandle === undefined
|
||||||
|
? currentKey.leftHandle
|
||||||
|
: patch.leftHandle ?? undefined,
|
||||||
|
rightHandle:
|
||||||
|
patch.rightHandle === undefined
|
||||||
|
? currentKey.rightHandle
|
||||||
|
: patch.rightHandle ?? undefined,
|
||||||
|
segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext,
|
||||||
|
tangentMode: patch.tangentMode ?? currentKey.tangentMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
return setBindingComponentChannel({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
channel: {
|
||||||
|
kind: "scalar",
|
||||||
|
keys: nextKeys,
|
||||||
|
extrapolation: channel.extrapolation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function cloneAnimations({
|
export function cloneAnimations({
|
||||||
animations,
|
animations,
|
||||||
shouldRegenerateKeyframeIds = false,
|
shouldRegenerateKeyframeIds = false,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import type {
|
import type {
|
||||||
|
AnimationColorPropertyPath,
|
||||||
|
AnimationNumericPropertyPath,
|
||||||
|
AnimationPath,
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
|
AnimationValueForPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import type { Transform } from "@/lib/rendering";
|
import type { Transform } from "@/lib/rendering";
|
||||||
|
|
@ -96,7 +100,7 @@ export function resolveNumberAtTime({
|
||||||
}: {
|
}: {
|
||||||
baseValue: number;
|
baseValue: number;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationNumericPropertyPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): number {
|
}): number {
|
||||||
return resolveAnimationPathValueAtTime({
|
return resolveAnimationPathValueAtTime({
|
||||||
|
|
@ -115,7 +119,7 @@ export function resolveColorAtTime({
|
||||||
}: {
|
}: {
|
||||||
baseColor: string;
|
baseColor: string;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationColorPropertyPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): string {
|
}): string {
|
||||||
return resolveAnimationPathValueAtTime({
|
return resolveAnimationPathValueAtTime({
|
||||||
|
|
@ -126,19 +130,17 @@ export function resolveColorAtTime({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAnimationPathValueAtTime<
|
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||||
T extends number | string | boolean | Transform["position"],
|
|
||||||
>({
|
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
localTime,
|
localTime,
|
||||||
fallbackValue,
|
fallbackValue,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: string;
|
propertyPath: TPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
fallbackValue: T;
|
fallbackValue: AnimationValueForPath<TPath>;
|
||||||
}): T {
|
}): AnimationValueForPath<TPath> {
|
||||||
const binding = animations?.bindings[propertyPath];
|
const binding = animations?.bindings[propertyPath];
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
|
|
@ -170,5 +172,5 @@ export function resolveAnimationPathValueAtTime<
|
||||||
return (composeAnimationValue({
|
return (composeAnimationValue({
|
||||||
binding,
|
binding,
|
||||||
componentValues,
|
componentValues,
|
||||||
}) ?? fallbackValue) as T;
|
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { ParamValues } from "@/lib/params";
|
||||||
|
|
||||||
export const ANIMATION_PROPERTY_PATHS = [
|
export const ANIMATION_PROPERTY_PATHS = [
|
||||||
"transform.position",
|
"transform.position",
|
||||||
"transform.scaleX",
|
"transform.scaleX",
|
||||||
|
|
@ -31,6 +33,34 @@ export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||||
export type VectorValue = { x: number; y: number };
|
export type VectorValue = { x: number; y: number };
|
||||||
export type DiscreteValue = boolean | string;
|
export type DiscreteValue = boolean | string;
|
||||||
export type AnimationValue = number | string | boolean | VectorValue;
|
export type AnimationValue = number | string | boolean | VectorValue;
|
||||||
|
export interface AnimationPropertyValueMap {
|
||||||
|
"transform.position": VectorValue;
|
||||||
|
"transform.scaleX": number;
|
||||||
|
"transform.scaleY": number;
|
||||||
|
"transform.rotate": number;
|
||||||
|
opacity: number;
|
||||||
|
volume: number;
|
||||||
|
color: string;
|
||||||
|
"background.color": string;
|
||||||
|
"background.paddingX": number;
|
||||||
|
"background.paddingY": number;
|
||||||
|
"background.offsetX": number;
|
||||||
|
"background.offsetY": number;
|
||||||
|
"background.cornerRadius": number;
|
||||||
|
}
|
||||||
|
export type DynamicAnimationPathValue = ParamValues[string];
|
||||||
|
export type AnimationValueForPath<TPath extends AnimationPath> =
|
||||||
|
TPath extends AnimationPropertyPath
|
||||||
|
? AnimationPropertyValueMap[TPath]
|
||||||
|
: TPath extends GraphicParamPath | EffectParamPath
|
||||||
|
? DynamicAnimationPathValue
|
||||||
|
: never;
|
||||||
|
export type AnimationNumericPropertyPath = {
|
||||||
|
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
|
||||||
|
}[AnimationPropertyPath];
|
||||||
|
export type AnimationColorPropertyPath = {
|
||||||
|
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
|
||||||
|
}[AnimationPropertyPath];
|
||||||
|
|
||||||
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||||
export type DiscreteKeyframeInterpolation = "hold";
|
export type DiscreteKeyframeInterpolation = "hold";
|
||||||
|
|
@ -144,6 +174,36 @@ export interface ElementAnimations {
|
||||||
channels: ElementAnimationChannelMap;
|
channels: ElementAnimationChannelMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NormalizedCubicBezier = [number, number, number, number];
|
||||||
|
|
||||||
|
export interface ScalarGraphChannelTarget {
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
channelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
|
||||||
|
channel: ScalarAnimationChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
|
||||||
|
keyframeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
|
||||||
|
keyframe: ScalarAnimationKey;
|
||||||
|
keyframeIndex: number;
|
||||||
|
previousKey: ScalarAnimationKey | null;
|
||||||
|
nextKey: ScalarAnimationKey | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScalarCurveKeyframePatch {
|
||||||
|
leftHandle?: CurveHandle | null;
|
||||||
|
rightHandle?: CurveHandle | null;
|
||||||
|
segmentToNext?: ScalarSegmentType;
|
||||||
|
tangentMode?: TangentMode;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElementKeyframe {
|
export interface ElementKeyframe {
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from "./remove-effect-param-keyframe";
|
export * from "./remove-effect-param-keyframe";
|
||||||
export * from "./remove-keyframe";
|
export * from "./remove-keyframe";
|
||||||
export * from "./retime-keyframe";
|
export * from "./retime-keyframe";
|
||||||
export * from "./upsert-effect-param-keyframe";
|
export * from "./update-scalar-keyframe-curve";
|
||||||
export * from "./upsert-keyframe";
|
export * from "./upsert-effect-param-keyframe";
|
||||||
|
export * from "./upsert-keyframe";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { EditorCore } from "@/core";
|
||||||
|
import {
|
||||||
|
resolveAnimationTarget,
|
||||||
|
updateScalarKeyframeCurve,
|
||||||
|
} from "@/lib/animation";
|
||||||
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
|
import { updateElementInTracks } from "@/lib/timeline";
|
||||||
|
import type {
|
||||||
|
AnimationPath,
|
||||||
|
ScalarCurveKeyframePatch,
|
||||||
|
} from "@/lib/animation/types";
|
||||||
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
|
|
||||||
|
export class UpdateScalarKeyframeCurveCommand extends Command {
|
||||||
|
private savedState: TimelineTrack[] | null = null;
|
||||||
|
private readonly trackId: string;
|
||||||
|
private readonly elementId: string;
|
||||||
|
private readonly propertyPath: AnimationPath;
|
||||||
|
private readonly componentKey: string;
|
||||||
|
private readonly keyframeId: string;
|
||||||
|
private readonly patch: ScalarCurveKeyframePatch;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
propertyPath,
|
||||||
|
componentKey,
|
||||||
|
keyframeId,
|
||||||
|
patch,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
componentKey: string;
|
||||||
|
keyframeId: string;
|
||||||
|
patch: ScalarCurveKeyframePatch;
|
||||||
|
}) {
|
||||||
|
super();
|
||||||
|
this.trackId = trackId;
|
||||||
|
this.elementId = elementId;
|
||||||
|
this.propertyPath = propertyPath;
|
||||||
|
this.componentKey = componentKey;
|
||||||
|
this.keyframeId = keyframeId;
|
||||||
|
this.patch = patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): CommandResult | undefined {
|
||||||
|
const editor = EditorCore.getInstance();
|
||||||
|
this.savedState = editor.timeline.getTracks();
|
||||||
|
|
||||||
|
const updatedTracks = updateElementInTracks({
|
||||||
|
tracks: this.savedState,
|
||||||
|
trackId: this.trackId,
|
||||||
|
elementId: this.elementId,
|
||||||
|
update: (element) => {
|
||||||
|
if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
animations: updateScalarKeyframeCurve({
|
||||||
|
animations: element.animations,
|
||||||
|
propertyPath: this.propertyPath,
|
||||||
|
componentKey: this.componentKey,
|
||||||
|
keyframeId: this.keyframeId,
|
||||||
|
patch: this.patch,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.savedState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = EditorCore.getInstance();
|
||||||
|
editor.timeline.updateTracks(this.savedState);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue