diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-02-27 16:33:57 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-02-27 16:33:57 +0100 |
| commit | 49426f19cd8eaed7cf6c47999b66fee7ee637a56 (patch) | |
| tree | 1b37451529cfcb36e6cae79494c61b9c616077df /apps/web/src/components/editor/panels/timeline/timeline-element.tsx | |
| parent | 9b94f89def4f96836c4f42a23a73e97fe647a43c (diff) | |
| download | opencut-49426f19cd8eaed7cf6c47999b66fee7ee637a56.tar.gz | |
feat: implement keyframe animation system
Add keyframe support for transform, opacity, and volume properties.
Includes animation engine (interpolation, mutations, resolvers), timeline
markers with selection/snapping, properties panel toggles, keyframe-aware
renderer, and full undo/redo command support.
Also refactor element command constructors to object params, extract
timeline pixel math to pixel-utils.ts, and update cursor rules.
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/timeline-element.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/timeline-element.tsx | 215 |
1 files changed, 205 insertions, 10 deletions
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 657e9a8b..7ad63692 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -4,14 +4,17 @@ import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import AudioWaveform from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; +import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getElementKeyframes } from "@/lib/animation"; import { getTrackClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, hasMediaId, + timelineTimeToPixels, + timelineTimeToSnappedPixels, } from "@/lib/timeline"; import { ContextMenu, @@ -19,7 +22,7 @@ import { ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from "../../../ui/context-menu"; +} from "@/components/ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, @@ -42,10 +45,123 @@ import { VolumeMute02Icon, Search01Icon, Exchange01Icon, + KeyframeIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; +import { cn } from "@/utils/ui"; + +const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; + +interface KeyframeIndicator { + time: number; + offsetPx: number; + keyframes: SelectedKeyframeRef[]; + isSelected: boolean; +} + +function buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + isKeyframeSelected, +}: { + keyframe: ElementKeyframe; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + isKeyframeSelected: ({ + keyframe, + }: { + keyframe: SelectedKeyframeRef; + }) => boolean; +}): { + time: number; + offsetPx: number; + keyframeRef: SelectedKeyframeRef; + isSelected: boolean; +} { + const keyframeRef = { + trackId, + elementId, + propertyPath: keyframe.propertyPath, + keyframeId: keyframe.id, + }; + const keyframeLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime + keyframe.time, + zoomLevel, + }); + return { + time: keyframe.time, + offsetPx: keyframeLeft - elementLeft, + keyframeRef, + isSelected: isKeyframeSelected({ keyframe: keyframeRef }), + }; +} + +function getKeyframeIndicators({ + keyframes, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + isKeyframeSelected, +}: { + keyframes: ElementKeyframe[]; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + elementWidth: number; + isKeyframeSelected: ({ + keyframe, + }: { + keyframe: SelectedKeyframeRef; + }) => boolean; +}): KeyframeIndicator[] { + if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { + return []; + } + + const keyframesByTime = new Map<number, KeyframeIndicator>(); + for (const keyframe of keyframes) { + const indicator = buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + isKeyframeSelected, + }); + const existingIndicator = keyframesByTime.get(indicator.time); + if (!existingIndicator) { + keyframesByTime.set(indicator.time, { + time: indicator.time, + offsetPx: indicator.offsetPx, + keyframes: [indicator.keyframeRef], + isSelected: indicator.isSelected, + }); + continue; + } + + existingIndicator.keyframes.push(indicator.keyframeRef); + existingIndicator.isSelected = + existingIndicator.isSelected || indicator.isSelected; + } + + return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); +} function getDisplayShortcut(action: TAction) { const { defaultShortcuts } = getActionDefinition(action); @@ -86,6 +202,7 @@ export function TimelineElement({ }: TimelineElementProps) { const editor = useEditor(); const { selectedElements } = useElementSelection(); + const { isKeyframeSelected } = useKeyframeSelection(); const { requestRevealMedia } = useAssetsPanelStore(); const mediaAssets = editor.media.getAssets(); @@ -123,10 +240,24 @@ export function TimelineElement({ : element.startTime; const displayedStartTime = isResizing ? currentStartTime : elementStartTime; const displayedDuration = isResizing ? currentDuration : element.duration; - const elementWidth = - displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const elementLeft = displayedStartTime * 50 * zoomLevel; - + const elementWidth = timelineTimeToPixels({ + time: displayedDuration, + zoomLevel, + }); + const elementLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime, + zoomLevel, + }); + const keyframeIndicators = getKeyframeIndicators({ + keyframes: getElementKeyframes({ animations: element.animations }), + trackId: track.id, + elementId: element.id, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + isKeyframeSelected, + }); const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -161,6 +292,7 @@ export function TimelineElement({ onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} /> + <KeyframeIndicators indicators={keyframeIndicators} /> </div> </ContextMenuTrigger> <ContextMenuContent className="w-64"> @@ -197,7 +329,9 @@ export function TimelineElement({ <> <ContextMenuItem icon={<HugeiconsIcon icon={Search01Icon} />} - onClick={(event) => handleRevealInMedia({ event })} + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } > Reveal media </ContextMenuItem> @@ -271,7 +405,6 @@ function ElementInner({ mediaAssets={mediaAssets} /> </div> - {(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && ( @@ -335,6 +468,67 @@ function ResizeHandle({ ); } +function KeyframeIndicators({ + indicators, +}: { + indicators: KeyframeIndicator[]; +}) { + const { toggleKeyframeSelection, selectKeyframeRange } = + useKeyframeSelection(); + const orderedKeyframes = indicators.flatMap((indicator) => indicator.keyframes); + + const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const handleKeyframeClick = ({ + event, + keyframes, + }: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => { + event.stopPropagation(); + if (event.shiftKey) { + selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + }; + + return indicators.map((indicator) => ( + <button + key={indicator.time} + type="button" + className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer" + style={{ left: indicator.offsetPx }} + onMouseDown={(event) => handleKeyframeMouseDown({ event })} + onClick={(event) => + handleKeyframeClick({ event, keyframes: indicator.keyframes }) + } + aria-label="Select keyframe" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn( + "size-3.5 text-black", + indicator.isSelected ? "fill-primary" : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + )); +} + function ElementContent({ element, track, @@ -549,10 +743,11 @@ function ActionMenuItem({ ...props }: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { action: TAction; + children: ReactNode; }) { return ( <ContextMenuItem - onClick={(event) => { + onClick={(event: React.MouseEvent) => { event.stopPropagation(); invokeAction(action); }} |
