summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline')
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx13
-rw-r--r--apps/web/src/components/editor/panels/timeline/drag-line.tsx2
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx127
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx8
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx599
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx16
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx4
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx20
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx6
9 files changed, 582 insertions, 213 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index 64824f9a..864bb557 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -5,10 +5,7 @@ import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
-import {
- DEFAULT_BOOKMARK_COLOR,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
+import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import {
@@ -28,8 +25,8 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
+import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline";
-const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
const BOOKMARK_MARKER_HEIGHT_PX = 15;
@@ -139,10 +136,12 @@ function TimelineBookmark({
const time = bookmark.time;
const bookmarkDuration = bookmark.duration ?? 0;
const durationWidth =
- bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
+ bookmarkDuration > 0
+ ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
+ : 0;
const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
- const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
+ const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
index f31c6e28..291a4633 100644
--- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx
+++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
@@ -21,7 +21,7 @@ export function DragLine({
return (
<div
- className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
+ className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
style={{ top: `${lineTop}px` }}
/>
);
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 18f768a9..593976a8 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -27,7 +27,7 @@ import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
- TRACK_ICONS,
+ TRACK_CONFIG,
} from "@/constants/timeline-constants";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
@@ -55,16 +55,18 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
+const TRACKS_CONTAINER_MAX_HEIGHT = 800;
+const FALLBACK_CONTAINER_WIDTH = 1000;
+
export function Timeline() {
- const tracksContainerHeight = { min: 0, max: 800 };
- const { snappingEnabled } = useTimelineStore();
+ const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
+ const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
const { clearElementSelection, setElementSelection } = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
const tracks = timeline.getTracks();
const seek = (time: number) => editor.playback.seek({ time });
- // refs
const timelineRef = useRef<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
@@ -74,7 +76,6 @@ export function Timeline() {
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
- // state
const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
@@ -151,6 +152,7 @@ export function Timeline() {
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
headerRef: timelineHeaderRef,
+ tracksScrollRef,
zoomLevel,
});
@@ -161,6 +163,7 @@ export function Timeline() {
shouldIgnoreClick,
} = useSelectionBox({
containerRef: tracksContainerRef,
+ headerRef: timelineHeaderRef,
onSelectionComplete: (elements) => {
setElementSelection({ elements });
},
@@ -168,7 +171,7 @@ export function Timeline() {
zoomLevel,
});
- const containerWidth = tracksContainerRef.current?.clientWidth || 1000;
+ const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({
@@ -326,7 +329,7 @@ export function Timeline() {
<DragLine
dropTarget={dropTarget}
tracks={timeline.getTracks()}
- isVisible={isDragOver}
+ isVisible={isDragOver && !dropTarget?.targetElement}
headerHeight={timelineHeaderHeight}
/>
<DragLine
@@ -421,7 +424,20 @@ export function Timeline() {
{tracks.length === 0 ? (
<div />
) : (
- tracks.map((track, index) => (
+ [...tracks]
+ .map((track, index) => ({ track, index }))
+ .sort((a, b) => {
+ const aHasDragged = a.track.elements.some(
+ (element) => element.id === dragState.elementId,
+ );
+ const bHasDragged = b.track.elements.some(
+ (element) => element.id === dragState.elementId,
+ );
+ if (aHasDragged) return 1;
+ if (bHasDragged) return -1;
+ return 0;
+ })
+ .map(({ track, index }) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
@@ -453,56 +469,61 @@ export function Timeline() {
}}
onTrackClick={handleTracksClick}
shouldIgnoreClick={shouldIgnoreClick}
+ targetElementId={
+ isDragOver
+ ? dropTarget?.targetElement?.elementId ?? null
+ : null
+ }
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
- onClick={(e) => {
- e.stopPropagation();
- invokeAction("paste-copied");
- }}
- >
- Paste elements
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- timeline.toggleTrackMute({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={VolumeHighIcon} />
- <span>
- {canTracktHaveAudio(track) && track.muted
- ? "Unmute track"
- : "Mute track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- timeline.toggleTrackVisibility({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={ViewIcon} />
- <span>
- {canTrackBeHidden(track) && track.hidden
- ? "Show track"
- : "Hide track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- timeline.removeTrack({
- trackId: track.id,
- });
- }}
+ onClick={(event) => {
+ event.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ timeline.toggleTrackMute({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ <span>
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ timeline.toggleTrackVisibility({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={ViewIcon} />
+ <span>
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ timeline.removeTrack({
+ trackId: track.id,
+ });
+ }}
variant="destructive"
>
<HugeiconsIcon icon={Delete02Icon} />
@@ -523,7 +544,7 @@ export function Timeline() {
}
function TrackIcon({ track }: { track: TimelineTrack }) {
- return <>{TRACK_ICONS[track.type]}</>;
+ return <>{TRACK_CONFIG[track.type].icon}</>;
}
function TrackToggleIcon({
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
index 54ce5237..927d336a 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -2,6 +2,10 @@
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import {
+ getCenteredLineLeft,
+ TIMELINE_INDICATOR_LINE_WIDTH_PX,
+} from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
interface SnapIndicatorProps {
@@ -40,10 +44,10 @@ export function SnapIndicator({
<div
className="pointer-events-none absolute"
style={{
- left: `${leftPosition}px`,
+ left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
top: topPosition,
height: `${height}px`,
- width: "2px",
+ width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
}}
>
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
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..d5b8cade 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,21 @@ 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 {
+ useKeyframeDrag,
+ type KeyframeDragState,
+} from "@/hooks/timeline/element/use-keyframe-drag";
+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,10 +26,11 @@ import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
-} from "../../../ui/context-menu";
+} from "@/components/ui/context-menu";
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
+ VisualElement,
ElementDragState,
} from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
@@ -42,13 +50,111 @@ import {
VolumeMute02Icon,
Search01Icon,
Exchange01Icon,
+ KeyframeIcon,
+ MagicWand05Icon,
} 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";
+import { Button } from "@/components/ui/button";
+import { usePropertiesStore } from "@/stores/properties-store";
+
+const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
+const ELEMENT_RING_WIDTH_PX = 1.5;
+
+interface KeyframeIndicator {
+ time: number;
+ offsetPx: number;
+ keyframes: SelectedKeyframeRef[];
+}
+
+export function buildKeyframeIndicator({
+ keyframe,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+}: {
+ keyframe: ElementKeyframe;
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+}): {
+ time: number;
+ offsetPx: number;
+ keyframeRef: SelectedKeyframeRef;
+} {
+ 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,
+ };
+}
-function getDisplayShortcut(action: TAction) {
- const { defaultShortcuts } = getActionDefinition(action);
+export function getKeyframeIndicators({
+ keyframes,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+}: {
+ keyframes: ElementKeyframe[];
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+ elementWidth: number;
+}): 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,
+ });
+ const existingIndicator = keyframesByTime.get(indicator.time);
+ if (!existingIndicator) {
+ keyframesByTime.set(indicator.time, {
+ time: indicator.time,
+ offsetPx: indicator.offsetPx,
+ keyframes: [indicator.keyframeRef],
+ });
+ continue;
+ }
+
+ existingIndicator.keyframes.push(indicator.keyframeRef);
+ }
+
+ return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
+}
+
+export function getDisplayShortcut({ action }: { action: TAction }) {
+ const { defaultShortcuts } = getActionDefinition({ action });
if (!defaultShortcuts?.length) {
return "";
}
@@ -66,11 +172,15 @@ interface TimelineElementProps {
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onElementMouseDown: (
- e: React.MouseEvent,
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (
+ event: React.MouseEvent,
element: TimelineElementType,
) => void;
- onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
dragState: ElementDragState;
+ isDropTarget?: boolean;
}
export function TimelineElement({
@@ -83,17 +193,18 @@ export function TimelineElement({
onElementMouseDown,
onElementClick,
dragState,
+ isDropTarget = false,
}: TimelineElementProps) {
const editor = useEditor();
const { selectedElements } = useElementSelection();
const { requestRevealMedia } = useAssetsPanelStore();
- const mediaAssets = editor.media.getAssets();
let mediaAsset: MediaAsset | null = null;
if (hasMediaId(element)) {
mediaAsset =
- mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
+ null;
}
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
@@ -123,10 +234,32 @@ 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 = isSelected
+ ? getKeyframeIndicators({
+ keyframes: getElementKeyframes({ animations: element.animations }),
+ trackId: track.id,
+ elementId: element.id,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+ })
+ : [];
+ const {
+ keyframeDragState,
+ handleKeyframeMouseDown,
+ handleKeyframeClick,
+ getVisualOffsetPx,
+ } = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
event.stopPropagation();
if (hasMediaId(element)) {
@@ -140,7 +273,7 @@ export function TimelineElement({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
- className={`absolute top-0 h-full select-none`}
+ className="absolute top-0 h-full select-none"
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
@@ -154,13 +287,24 @@ export function TimelineElement({
element={element}
track={track}
isSelected={isSelected}
- hasAudio={hasAudio}
- isMuted={isMuted}
- mediaAssets={mediaAssets}
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart}
+ isDropTarget={isDropTarget}
/>
+ {isSelected && (
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
+ <KeyframeIndicators
+ indicators={keyframeIndicators}
+ dragState={keyframeDragState}
+ displayedStartTime={displayedStartTime}
+ elementLeft={elementLeft}
+ onKeyframeMouseDown={handleKeyframeMouseDown}
+ onKeyframeClick={handleKeyframeClick}
+ getVisualOffsetPx={getVisualOffsetPx}
+ />
+ </div>
+ )}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
@@ -197,7 +341,9 @@ export function TimelineElement({
<>
<ContextMenuItem
icon={<HugeiconsIcon icon={Search01Icon} />}
- onClick={(event) => handleRevealInMedia({ event })}
+ onClick={(event: React.MouseEvent) =>
+ handleRevealInMedia({ event })
+ }
>
Reveal media
</ContextMenuItem>
@@ -225,22 +371,20 @@ function ElementInner({
element,
track,
isSelected,
- hasAudio,
- isMuted,
- mediaAssets,
onElementClick,
onElementMouseDown,
handleResizeStart,
+ isDropTarget = false,
}: {
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
- hasAudio: boolean;
- isMuted: boolean;
- mediaAssets: MediaAsset[];
- onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ onElementClick: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
onElementMouseDown: (
- e: React.MouseEvent,
+ event: React.MouseEvent,
element: TimelineElementType,
) => void;
handleResizeStart: (params: {
@@ -248,48 +392,62 @@ function ElementInner({
elementId: string;
side: "left" | "right";
}) => void;
+ isDropTarget?: boolean;
}) {
+ const opacityClass =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget
+ ? "opacity-50"
+ : "";
+ const closeClipEffects = usePropertiesStore(
+ (state) => state.closeClipEffects,
+ );
+
return (
<div
- className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
- {
- type: track.type,
- },
- )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
+ className="relative h-full cursor-pointer"
+ style={{ marginInline: ELEMENT_RING_WIDTH_PX }}
>
- <button
- type="button"
- className="absolute inset-0 size-full cursor-pointer"
- onClick={(e) => onElementClick(e, element)}
- onMouseDown={(e) => onElementMouseDown(e, element)}
+ <div
+ className={cn(
+ "absolute inset-0 overflow-hidden rounded-sm",
+ getTrackClasses({ type: track.type }),
+ opacityClass,
+ )}
+ style={
+ isSelected
+ ? {
+ boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`,
+ }
+ : undefined
+ }
>
- <div className="absolute inset-0 flex h-full items-center">
- <ElementContent
- element={element}
- track={track}
- isSelected={isSelected}
- mediaAssets={mediaAssets}
+ <button
+ type="button"
+ className="absolute inset-0 size-full cursor-pointer flex flex-col"
+ onClick={(event) => {
+ closeClipEffects();
+ onElementClick(event, element);
+ }}
+ onMouseDown={(event) => onElementMouseDown(event, element)}
+ >
+ <div className="flex flex-1 min-h-0 items-center overflow-hidden">
+ <ElementContent
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ />
+ </div>
+ </button>
+ </div>
+
+ {element.type !== "audio" && element.type !== "effect" && (
+ <div className="sticky left-1 mt-1 ml-1 w-fit">
+ <EffectsButton
+ element={element as VisualElement}
+ trackId={track.id}
/>
</div>
-
- {(hasAudio
- ? isMuted
- : canElementBeHidden(element) && element.hidden) && (
- <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
- {hasAudio ? (
- <HugeiconsIcon
- icon={VolumeHighIcon}
- className="size-6 text-white"
- />
- ) : (
- <HugeiconsIcon
- icon={VolumeOffIcon}
- className="size-6 text-white"
- />
- )}
- </div>
- )}
- </button>
+ )}
{isSelected && (
<>
@@ -326,61 +484,242 @@ function ResizeHandle({
return (
<button
type="button"
- className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
+ className={cn(
+ "absolute top-0 bottom-0 w-2",
+ isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
+ )}
onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
+ onClick={(event) => event.stopPropagation()}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
- >
- <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
- </button>
+ ></button>
);
}
-function ElementContent({
- element,
- track,
- isSelected,
- mediaAssets,
+function KeyframeIndicators({
+ indicators,
+ dragState,
+ displayedStartTime,
+ elementLeft,
+ onKeyframeMouseDown,
+ onKeyframeClick,
+ getVisualOffsetPx,
}: {
+ indicators: KeyframeIndicator[];
+ dragState: KeyframeDragState;
+ displayedStartTime: number;
+ elementLeft: number;
+ onKeyframeMouseDown: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => void;
+ onKeyframeClick: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => void;
+ getVisualOffsetPx: (params: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }) => number;
+}) {
+ const { isKeyframeSelected } = useKeyframeSelection();
+ const orderedKeyframes = indicators.flatMap(
+ (indicator) => indicator.keyframes,
+ );
+
+ return indicators.map((indicator) => {
+ const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ const isBeingDragged = indicator.keyframes.some((kf) =>
+ dragState.draggingKeyframeIds.has(kf.keyframeId),
+ );
+ const visualOffsetPx = getVisualOffsetPx({
+ indicatorTime: indicator.time,
+ indicatorOffsetPx: indicator.offsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ });
+
+ return (
+ <button
+ key={indicator.time}
+ type="button"
+ className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab"
+ style={{ left: visualOffsetPx }}
+ onMouseDown={(event) =>
+ onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
+ }
+ onClick={(event) =>
+ onKeyframeClick({
+ event,
+ keyframes: indicator.keyframes,
+ orderedKeyframes,
+ indicatorTime: indicator.time,
+ })
+ }
+ aria-label="Select keyframe"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(
+ "size-3.5 mt-1.5 text-black",
+ isIndicatorSelected ? "fill-primary" : "fill-white",
+ )}
+ strokeWidth={1.5}
+ />
+ </button>
+ );
+ });
+}
+
+interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
+}
+
+interface ElementContentRendererProps extends ElementContentProps {
mediaAssets: MediaAsset[];
+}
+
+type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode;
+
+export function renderTiledMedia({
+ element,
+ imageUrl,
+ track,
+}: {
+ element: VisualElement;
+ imageUrl: string | undefined;
+ track: ElementContentProps["track"];
+}): ReactNode {
+ if (!imageUrl) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
+
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * (16 / 9);
+
+ return (
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundImage: `url(${imageUrl})`,
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ />
+ );
+}
+
+function EffectsButton({
+ element,
+ trackId,
+ className,
+}: {
+ element: VisualElement;
+ trackId: string;
+ className?: string;
}) {
- if (element.type === "text") {
+ const openClipEffects = usePropertiesStore((state) => state.openClipEffects);
+ const { selectElement } = useElementSelection();
+
+ if (!element.effects?.length) {
+ return null;
+ }
+
+ const handleClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ selectElement({ elementId: element.id, trackId });
+ openClipEffects({ elementId: element.id, trackId });
+ };
+
+ return (
+ <Button
+ variant="text"
+ size="icon"
+ className={cn("rounded-sm !size-5 bg-background/50", className)}
+ onClick={handleClick}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+ <HugeiconsIcon icon={MagicWand05Icon} />
+ </Button>
+ );
+}
+
+const ELEMENT_CONTENT_RENDERERS: Record<
+ TimelineElementType["type"],
+ ElementContentRenderer
+> = {
+ text: ({ element }) => {
+ const textElement = element as Extract<
+ TimelineElementType,
+ { type: "text" }
+ >;
return (
<div className="flex size-full items-center justify-start pl-2">
- <span className="truncate text-xs text-white">{element.content}</span>
+ <span className="truncate text-xs text-white">
+ {textElement.content}
+ </span>
</div>
);
- }
-
- if (element.type === "sticker") {
+ },
+ effect: ({ element }) => (
+ <div className="flex size-full items-center justify-start gap-1 pl-2">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-4 shrink-0 text-white"
+ />
+ <span className="truncate text-xs text-white ml-1">{element.name}</span>
+ </div>
+ ),
+ sticker: ({ element }) => {
+ const stickerElement = element as Extract<
+ TimelineElementType,
+ { type: "sticker" }
+ >;
return (
<div className="flex size-full items-center gap-2 pl-2">
<Image
src={resolveStickerId({
- stickerId: element.stickerId,
+ stickerId: stickerElement.stickerId,
options: { width: 20, height: 20 },
})}
- alt={element.name}
+ alt={stickerElement.name}
className="size-5 shrink-0"
width={20}
height={20}
unoptimized
/>
- <span className="truncate text-xs text-white">{element.name}</span>
+ <span className="truncate text-xs text-white">
+ {stickerElement.name}
+ </span>
</div>
);
- }
-
- if (element.type === "audio") {
+ },
+ audio: ({ element, mediaAssets }) => {
+ const audioElement = element as Extract<
+ TimelineElementType,
+ { type: "audio" }
+ >;
const audioBuffer =
- element.sourceType === "library" ? element.buffer : undefined;
-
+ audioElement.sourceType === "library" ? audioElement.buffer : undefined;
const audioUrl =
- element.sourceType === "library"
- ? element.sourceUrl
- : mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
+ audioElement.sourceType === "library"
+ ? audioElement.sourceUrl
+ : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url;
if (audioBuffer || audioUrl) {
return (
@@ -399,53 +738,52 @@ function ElementContent({
return (
<span className="text-foreground/80 truncate text-xs">
- {element.name}
+ {audioElement.name}
</span>
);
- }
-
- const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
- if (!mediaAsset) {
- return (
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
+ },
+ video: ({ element, track, mediaAssets }) => {
+ const videoElement = element as Extract<
+ TimelineElementType,
+ { type: "video" }
+ >;
+ const mediaAsset = mediaAssets.find(
+ (asset) => asset.id === videoElement.mediaId,
);
- }
-
- if (
- mediaAsset.type === "image" ||
- (mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
- ) {
- const trackHeight = getTrackHeight({ type: track.type });
- const tileWidth = trackHeight * (16 / 9);
- const imageUrl =
- mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
-
- return (
- <div className="flex size-full items-center justify-center">
- <div
- className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
- >
- <div
- className="absolute right-0 left-0"
- style={{
- backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${trackHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- top: isSelected ? "0.25rem" : "0rem",
- bottom: isSelected ? "0.25rem" : "0rem",
- }}
- />
- </div>
- </div>
+ return renderTiledMedia({
+ element: videoElement,
+ imageUrl: mediaAsset?.thumbnailUrl,
+ track,
+ });
+ },
+ image: ({ element, track, mediaAssets }) => {
+ const imageElement = element as Extract<
+ TimelineElementType,
+ { type: "image" }
+ >;
+ const mediaAsset = mediaAssets.find(
+ (asset) => asset.id === imageElement.mediaId,
);
- }
+ return renderTiledMedia({
+ element: imageElement,
+ imageUrl: mediaAsset?.url,
+ track,
+ });
+ },
+};
+function ElementContent({ element, track, isSelected }: ElementContentProps) {
+ const editor = useEditor();
+ const renderer = ELEMENT_CONTENT_RENDERERS[element.type];
return (
- <span className="text-foreground/80 truncate text-xs">{element.name}</span>
+ <>
+ {renderer({
+ element,
+ track,
+ isSelected,
+ mediaAssets: editor.media.getAssets(),
+ })}
+ </>
);
}
@@ -474,9 +812,9 @@ function MuteMenuItem({
return <HugeiconsIcon icon={VolumeMute02Icon} />;
}
return isMuted ? (
- <HugeiconsIcon icon={VolumeHighIcon} />
- ) : (
<HugeiconsIcon icon={VolumeOffIcon} />
+ ) : (
+ <HugeiconsIcon icon={VolumeHighIcon} />
);
};
@@ -549,14 +887,15 @@ 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);
}}
- textRight={getDisplayShortcut(action)}
+ textRight={getDisplayShortcut({ action })}
{...props}
>
{children}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
index ebb2a37e..e290259e 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -1,7 +1,11 @@
"use client";
import { useRef } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ getCenteredLineLeft,
+ TIMELINE_INDICATOR_LINE_WIDTH_PX,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
@@ -43,9 +47,11 @@ export function TimelinePlayhead({
400;
const totalHeight = Math.max(0, timelineContainerHeight - 4);
- const timelinePosition =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const leftPosition = timelinePosition;
+ const centerPosition = timelineTimeToSnappedPixels({
+ time: playheadPosition,
+ zoomLevel,
+ });
+ const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
const handlePlayheadKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>,
@@ -77,7 +83,7 @@ export function TimelinePlayhead({
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
- width: "2px",
+ width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
}}
onKeyDown={handlePlayheadKeyDown}
>
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
index 9402d25e..22cd52cf 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
@@ -1,6 +1,6 @@
"use client";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
interface TimelineTickProps {
@@ -16,7 +16,7 @@ export function TimelineTick({
fps,
showLabel,
}: TimelineTickProps) {
- const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
if (showLabel) {
const label = formatRulerLabel({ timeInSeconds: time, fps });
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 03ce88eb..533ea63d 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -78,7 +78,7 @@ export function TimelineToolbar({
function ToolbarLeftSection() {
const editor = useEditor();
const currentTime = editor.playback.getCurrentTime();
- const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
+ const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime });
const handleAction = ({
action,
@@ -116,7 +116,7 @@ function ToolbarLeftSection() {
<ToolbarButton
icon={<SplitSquareHorizontal />}
- tooltip="Coming soon" /* separate audio */
+ tooltip="Separate audio (coming soon)"
disabled={true}
onClick={({ event: _event }) => {}}
/>
@@ -131,7 +131,7 @@ function ToolbarLeftSection() {
<ToolbarButton
icon={<HugeiconsIcon icon={SnowIcon} />}
- tooltip="Coming soon" /* freeze frame */
+ tooltip="Freeze frame (coming soon)"
disabled={true}
onClick={({ event: _event }) => {}}
/>
@@ -149,8 +149,8 @@ function ToolbarLeftSection() {
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
- isActive={currentBookmarked}
- tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
+ isActive={isCurrentlyBookmarked}
+ tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event })
}
@@ -191,12 +191,10 @@ function ToolbarRightSection({
onZoomChange: (zoom: number) => void;
onZoom: (options: { direction: "in" | "out" }) => void;
}) {
- const {
- snappingEnabled,
- rippleEditingEnabled,
- toggleSnapping,
- toggleRippleEditing,
- } = useTimelineStore();
+ const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
+ const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
+ const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
+ const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
return (
<div className="flex items-center gap-1">
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
index 42faadaf..81cda7a6 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -32,6 +32,7 @@ interface TimelineTrackContentProps {
onTrackMouseDown?: (event: React.MouseEvent) => void;
onTrackClick?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
+ targetElementId?: string | null;
}
export function TimelineTrackContent({
@@ -48,9 +49,10 @@ export function TimelineTrackContent({
onTrackMouseDown,
onTrackClick,
shouldIgnoreClick,
+ targetElementId = null,
}: TimelineTrackContentProps) {
const editor = useEditor();
- const { isElementSelected, clearElementSelection } = useElementSelection();
+ const { isElementSelected } = useElementSelection();
const duration = editor.timeline.getTotalDuration();
@@ -67,7 +69,6 @@ export function TimelineTrackContent({
className="size-full"
onClick={(event) => {
if (shouldIgnoreClick?.()) return;
- clearElementSelection();
onTrackClick?.(event);
}}
onMouseDown={(event) => {
@@ -102,6 +103,7 @@ export function TimelineTrackContent({
onElementClick({ event, element, track })
}
dragState={dragState}
+ isDropTarget={element.id === targetElementId}
/>
);
})