diff options
Diffstat (limited to 'apps/web/src/components/editor')
10 files changed, 1504 insertions, 223 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 285b37d3..5b076466 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -8,6 +8,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item" import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useEditor } from "@/hooks/use-editor";
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
import {
@@ -70,41 +71,25 @@ export function StickersView() { />
</div>
- <div className="mt-2 flex min-h-0 flex-1 flex-col">
- <div className="border-b border-border px-2">
- <div
- role="tablist"
- aria-label="Sticker categories"
- className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
- >
- {Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
- const isActive = key === selectedCategory;
- return (
- <button
- key={key}
- type="button"
- role="tab"
- aria-selected={isActive}
- onClick={() =>
- setSelectedCategory({
- category: key as StickerCategory,
- })
- }
- className={cn(
- "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
- isActive && "text-primary border-primary",
- )}
- >
- {label}
- </button>
- );
- })}
- </div>
- </div>
+ <Tabs
+ value={selectedCategory}
+ onValueChange={(value) => {
+ setSelectedCategory({ category: value as StickerCategory });
+ }}
+ variant="underline"
+ className="mt-2 flex min-h-0 flex-1 flex-col"
+ >
+ <TabsList aria-label="Sticker categories">
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
+ <TabsTrigger key={key} value={key}>
+ {label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
<StickersContentView />
</div>
- </div>
+ </Tabs>
</div>
);
}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 95f338e1..a900cb13 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -9,8 +9,7 @@ export function useElementPlayhead({ startTime: number;
duration: number;
}) {
- const editor = useEditor();
- const playheadTime = editor.playback.getCurrentTime();
+ const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: startTime,
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 92b5d634..c044ff5f 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -1,148 +1,150 @@ -"use client";
-
-import { useEditor } from "@/hooks/use-editor";
-import {
- buildGraphicParamPath,
- getKeyframeAtTime,
- getParamDefaultInterpolation,
- getParamValueKind,
- hasKeyframesForPath,
- upsertPathKeyframe,
-} from "@/lib/animation";
-import type {
- ElementAnimations,
-} from "@/lib/animation/types";
-import type { ParamDefinition } from "@/lib/params";
-import type { TimelineElement } from "@/lib/timeline";
-
-export interface KeyframedParamPropertyResult {
- hasAnimatedKeyframes: boolean;
- isKeyframedAtTime: boolean;
- keyframeIdAtTime: string | null;
- onPreview: (value: number | string | boolean) => void;
- onCommit: () => void;
- toggleKeyframe: () => void;
-}
-
-export function useKeyframedParamProperty({
- param,
- trackId,
- elementId,
- animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue,
- buildBaseUpdates,
-}: {
- param: ParamDefinition;
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: number | string | boolean;
- buildBaseUpdates: ({
- value,
- }: {
- value: number | string | boolean;
- }) => Partial<TimelineElement>;
-}): KeyframedParamPropertyResult {
- const editor = useEditor();
- const propertyPath = buildGraphicParamPath({ paramKey: param.key });
- const hasAnimatedKeyframes = hasKeyframesForPath({
- animations,
- propertyPath,
- });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({
- animations,
- propertyPath,
- time: localTime,
- })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
- if (shouldUseAnimatedChannel) {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- animations: upsertPathKeyframe({
- animations,
- propertyPath,
- time: localTime,
- value,
- valueKind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({
- param,
- }),
- numericRange:
- param.type === "number"
- ? { min: param.min, max: param.max, step: param.step }
- : undefined,
- }),
- },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: buildBaseUpdates({ value }),
- },
- ],
- });
- };
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- keyframeId: keyframeIdAtTime,
- },
- ],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- time: localTime,
- value: resolvedValue,
- },
- ],
- });
- };
-
- return {
- hasAnimatedKeyframes,
- isKeyframedAtTime,
- keyframeIdAtTime,
- onPreview: previewValue,
- onCommit: () => editor.timeline.commitPreview(),
- toggleKeyframe,
- };
-}
+"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { + buildGraphicParamPath, + coerceAnimationValueForParam, + getKeyframeAtTime, + getParamDefaultInterpolation, + getParamValueKind, + hasKeyframesForPath, + upsertPathKeyframe, +} from "@/lib/animation"; +import type { + ElementAnimations, +} from "@/lib/animation/types"; +import type { ParamDefinition } from "@/lib/params"; +import type { TimelineElement } from "@/lib/timeline"; + +export interface KeyframedParamPropertyResult { + hasAnimatedKeyframes: boolean; + isKeyframedAtTime: boolean; + keyframeIdAtTime: string | null; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; + toggleKeyframe: () => void; +} + +export function useKeyframedParamProperty({ + param, + trackId, + elementId, + animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates, +}: { + param: ParamDefinition; + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: number | string | boolean; + buildBaseUpdates: ({ + value, + }: { + value: number | string | boolean; + }) => Partial<TimelineElement>; +}): KeyframedParamPropertyResult { + const editor = useEditor(); + const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ + animations, + propertyPath, + time: localTime, + }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertPathKeyframe({ + animations, + propertyPath, + time: localTime, + value, + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ + param, + }), + coerceValue: (nextValue) => + coerceAnimationValueForParam({ + param, + value: nextValue, + }), + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + return { + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + onPreview: previewValue, + onCommit: () => editor.timeline.commitPreview(), + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx new file mode 100644 index 00000000..32f39174 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx new file mode 100644 index 00000000..86665392 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts new file mode 100644 index 00000000..cbcdf566 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts @@ -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(); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts new file mode 100644 index 00000000..148fa7b4 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts @@ -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, + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts new file mode 100644 index 00000000..3b7520e6 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts @@ -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, + }; +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 25218d78..1d3606e6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -292,10 +292,7 @@ export function TimelineElement({ const canToggleCurrentSourceAudio =
selectedElements.length === 1 &&
isCurrentElementSelected &&
- canToggleSourceAudio({
- element,
- mediaAsset,
- });
+ canToggleSourceAudio(element, mediaAsset);
const sourceAudioLabel =
element.type === "video"
? getSourceAudioActionLabel({ element })
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index ecaf37e2..80ea10d0 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -14,9 +14,7 @@ import { SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { - TIMELINE_ZOOM_BUTTON_FACTOR, -} from "./interaction"; +import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction"; import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; import { ScenesView } from "@/components/editor/scenes-view"; @@ -36,7 +34,6 @@ import { SnowIcon, ScissorIcon, MagnetIcon, - Link04Icon, SearchAddIcon, SearchMinusIcon, Copy01Icon, @@ -49,6 +46,9 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; 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({ zoomLevel, @@ -62,10 +62,7 @@ export function TimelineToolbar({ const handleZoom = ({ direction }: { direction: "in" | "out" }) => { const newZoomLevel = direction === "in" - ? Math.min( - TIMELINE_ZOOM_MAX, - zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR, - ) + ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR) : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR); setZoomLevel({ zoom: newZoomLevel }); }; @@ -90,8 +87,11 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); - const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets()); + const mediaAssets = useEditor((currentEditor) => + currentEditor.media.getAssets(), + ); const { selectedElements } = useElementSelection(); + const graphEditor = useGraphEditorController(); const isCurrentlyBookmarked = useEditor((e) => e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), ); @@ -115,10 +115,7 @@ function ToolbarLeftSection() { })(); const canToggleSelectedSourceAudio = !!selectedElement && - canToggleSourceAudio({ - element: selectedElement.element, - mediaAsset: selectedMediaAsset, - }); + canToggleSourceAudio(selectedElement.element, selectedMediaAsset); const sourceAudioLabel = selectedElement?.element.type === "video" ? getSourceAudioActionLabel({ @@ -166,11 +163,11 @@ function ToolbarLeftSection() { /> <ToolbarButton - icon={ - <HugeiconsIcon - icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon} - /> - } + icon={ + <HugeiconsIcon + icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon} + /> + } tooltip={sourceAudioLabel} disabled={!canToggleSelectedSourceAudio} onClick={({ event }) => @@ -214,13 +211,35 @@ function ToolbarLeftSection() { /> </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 icon={<HugeiconsIcon icon={Chart03Icon} />} - tooltip="Open graph editor" - onClick={() => {}} + tooltip={graphEditor.tooltip} + disabled={!graphEditor.canOpen} + buttonWrapper={(button) => + graphEditor.canOpen ? ( + <PopoverTrigger asChild>{button}</PopoverTrigger> + ) : ( + button + ) + } /> - </Tooltip> + </GraphEditorPopover> </TooltipProvider> </div> ); @@ -317,29 +336,40 @@ function ToolbarButton({ onClick, disabled, isActive, + buttonWrapper, }: { icon: React.ReactNode; tooltip: string; - onClick: ({ event }: { event: React.MouseEvent }) => void; + onClick?: ({ event }: { event: React.MouseEvent }) => void; disabled?: boolean; isActive?: boolean; + buttonWrapper?: (button: React.ReactElement) => React.ReactElement; }) { + const button = ( + <Button + variant={isActive ? "secondary" : "text"} + size="icon" + disabled={disabled} + onClick={onClick ? (event) => onClick({ event }) : undefined} + className={cn( + "rounded-sm", + disabled ? "cursor-not-allowed opacity-50" : "", + )} + > + {icon} + </Button> + ); + const trigger = disabled ? ( + <span className="inline-flex">{button}</span> + ) : buttonWrapper ? ( + buttonWrapper(button) + ) : ( + button + ); + return ( <Tooltip delayDuration={200}> - <TooltipTrigger asChild> - <Button - variant={isActive ? "secondary" : "text"} - size="icon" - disabled={disabled} - onClick={(event) => onClick({ event })} - className={cn( - "rounded-sm", - disabled ? "cursor-not-allowed opacity-50" : "", - )} - > - {icon} - </Button> - </TooltipTrigger> + <TooltipTrigger asChild>{trigger}</TooltipTrigger> <TooltipContent>{tooltip}</TooltipContent> </Tooltip> ); |
