diff options
Diffstat (limited to 'apps/web/src/components/editor')
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/timeline-element.tsx | 2529 |
1 files changed, 1280 insertions, 1249 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 e47bcba0..a3555e94 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -1,1249 +1,1280 @@ -"use client";
-
-import { useEditor } from "@/hooks/use-editor";
-import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-import { AudioWaveform } from "./audio-waveform";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import {
- useKeyframeDrag,
- type KeyframeDragState,
-} from "@/hooks/timeline/element/use-keyframe-drag";
-import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
-import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select";
-import { SelectionBox } from "@/lib/selection/selection-box";
-import { getElementKeyframes } from "@/lib/animation";
-import {
- canElementHaveAudio,
- canElementBeHidden,
- hasElementEffects,
- hasMediaId,
- timelineTimeToPixels,
- timelineTimeToSnappedPixels,
-} from "@/lib/timeline";
-import { getTrackHeight } from "./track-layout";
-import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import type {
- TimelineElement as TimelineElementType,
- TimelineTrack,
- ElementDragState,
- VideoElement,
- ImageElement,
- AudioElement,
-} from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
-import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import {
- canToggleSourceAudio,
- getSourceAudioActionLabel,
- isSourceAudioSeparated,
-} from "@/lib/timeline/audio-separation";
-import {
- getActionDefinition,
- type TAction,
- type TActionWithOptionalArgs,
- invokeAction,
-} from "@/lib/actions";
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { resolveStickerId } from "@/lib/stickers";
-import { buildGraphicPreviewUrl } from "@/lib/graphics";
-import Image from "next/image";
-import {
- ScissorIcon,
- Delete02Icon,
- Copy01Icon,
- ViewIcon,
- ViewOffSlashIcon,
- VolumeHighIcon,
- VolumeOffIcon,
- VolumeMute02Icon,
- Search01Icon,
- Exchange01Icon,
- KeyframeIcon,
- MagicWand05Icon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { uppercase } from "@/utils/string";
-import { useMemo, type ComponentProps, type ReactNode } from "react";
-import type {
- SelectedKeyframeRef,
- ElementKeyframe,
-} from "@/lib/animation/types";
-import { cn } from "@/utils/ui";
-import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
-import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
-import {
- getExpandedRows,
- getExpansionHeight,
- type ExpandedRow,
-} from "./expanded-layout";
-
-const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
-const ELEMENT_RING_WIDTH_PX = 1.5;
-const THUMBNAIL_ASPECT_RATIO = 16 / 9;
-
-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,
- };
-}
-
-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 "";
- }
-
- return uppercase({
- string: defaultShortcuts[0].replace("+", " "),
- });
-}
-
-interface TimelineElementProps {
- element: TimelineElementType;
- track: TimelineTrack;
- zoomLevel: number;
- isSelected: boolean;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => void;
- onElementMouseDown: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onElementClick: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- dragState: ElementDragState;
- isDropTarget?: boolean;
-}
-
-export function TimelineElement({
- element,
- track,
- zoomLevel,
- isSelected,
- onResizeStart,
- onElementMouseDown,
- onElementClick,
- dragState,
- isDropTarget = false,
-}: TimelineElementProps) {
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const { selectedElements } = useElementSelection();
- const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
- const { renderElement } = useElementPreview({
- trackId: track.id,
- elementId: element.id,
- fallback: element,
- });
-
- let mediaAsset: MediaAsset | null = null;
-
- if (hasMediaId(element)) {
- mediaAsset =
- mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
- }
-
- const hasAudio = mediaSupportsAudio({ media: mediaAsset });
-
- const isCurrentElementSelected = selectedElements.some(
- (selected) =>
- selected.elementId === element.id && selected.trackId === track.id,
- );
-
- const isBeingDragged = dragState.dragElementIds.includes(element.id);
- const dragOffsetY =
- isBeingDragged && dragState.isDragging
- ? dragState.currentMouseY - dragState.startMouseY
- : 0;
- const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
- const elementStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime + dragTimeOffset
- : renderElement.startTime;
- const displayedStartTime = elementStartTime;
- const displayedDuration = renderElement.duration;
- 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 elementKeyframes = getElementKeyframes({
- animations: element.animations,
- });
-
- const isExpanded = useTimelineStore((s) =>
- s.expandedElementIds.has(element.id),
- );
- const toggleElementExpanded = useTimelineStore(
- (s) => s.toggleElementExpanded,
- );
- const expandedRows = useMemo(
- () =>
- isExpanded
- ? getExpandedRows({ animations: element.animations })
- : [],
- [isExpanded, element.animations],
- );
-
- const {
- containerRef: expandedLanesRef,
- selectionBox: keyframeSelectionBox,
- isBoxSelecting: isKeyframeBoxSelecting,
- handleExpandedAreaMouseDown,
- handleExpandedAreaClick,
- } = useKeyframeBoxSelect({
- trackId: track.id,
- elementId: element.id,
- rows: expandedRows,
- keyframes: elementKeyframes,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- });
-
- const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
- event.stopPropagation();
- if (hasMediaId(element)) {
- requestRevealMedia(element.mediaId);
- }
- };
-
- const isMuted = canElementHaveAudio(element) && element.muted === true;
- const canToggleCurrentSourceAudio =
- selectedElements.length === 1 &&
- isCurrentElementSelected &&
- canToggleSourceAudio(element, mediaAsset);
- const sourceAudioLabel =
- element.type === "video"
- ? getSourceAudioActionLabel({ element })
- : "Extract audio";
- const isElementSourceAudioSeparated =
- element.type === "video" && isSourceAudioSeparated({ element });
- const hasKeyframes = elementKeyframes.length > 0;
- const expansionHeight = getExpansionHeight({ rows: expandedRows });
- const baseTrackHeight = getTrackHeight({ type: track.type });
-
- const expandedContent =
- isExpanded && expandedRows.length > 0 ? (
- <ExpandedKeyframeLanes
- rows={expandedRows}
- keyframes={elementKeyframes}
- trackId={track.id}
- elementId={element.id}
- displayedStartTime={displayedStartTime}
- zoomLevel={zoomLevel}
- elementLeft={elementLeft}
- keyframeDragState={keyframeDragState}
- onKeyframeMouseDown={handleKeyframeMouseDown}
- onKeyframeClick={handleKeyframeClick}
- getVisualOffsetPx={getVisualOffsetPx}
- containerRef={expandedLanesRef}
- onLaneMouseDown={handleExpandedAreaMouseDown}
- onLaneClick={handleExpandedAreaClick}
- selectionBox={keyframeSelectionBox}
- isBoxSelecting={isKeyframeBoxSelecting}
- />
- ) : null;
-
- return (
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- className="absolute top-0 select-none"
- style={{
- left: `${elementLeft}px`,
- width: `${elementWidth}px`,
- height:
- expandedRows.length > 0
- ? `${baseTrackHeight + expansionHeight}px`
- : "100%",
- transform:
- isBeingDragged && dragState.isDragging
- ? `translate3d(0, ${dragOffsetY}px, 0)`
- : undefined,
- }}
- >
- <ElementInner
- element={element}
- track={track}
- isSelected={isSelected}
- isExpanded={expandedRows.length > 0}
- baseTrackHeight={baseTrackHeight}
- expandedContent={expandedContent}
- onElementClick={onElementClick}
- onElementMouseDown={onElementMouseDown}
- onResizeStart={onResizeStart}
- isDropTarget={isDropTarget}
- />
- {isSelected && (
- <div
- className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
- style={{ height: `${baseTrackHeight}px` }}
- >
- <KeyframeIndicators
- indicators={keyframeIndicators}
- dragState={keyframeDragState}
- displayedStartTime={displayedStartTime}
- elementLeft={elementLeft}
- onKeyframeMouseDown={handleKeyframeMouseDown}
- onKeyframeClick={handleKeyframeClick}
- getVisualOffsetPx={getVisualOffsetPx}
- />
- </div>
- )}
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="w-64">
- <ActionMenuItem
- action="split"
- icon={<HugeiconsIcon icon={ScissorIcon} />}
- >
- Split
- </ActionMenuItem>
- <CopyMenuItem />
- {selectedElements.length === 1 && (
- <ActionMenuItem
- action="duplicate-selected"
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- >
- Duplicate
- </ActionMenuItem>
- )}
- {canElementHaveAudio(element) && hasAudio && (
- <MuteMenuItem
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- isMuted={isMuted}
- />
- )}
- {canToggleCurrentSourceAudio && (
- <ContextMenuItem
- icon={
- <HugeiconsIcon
- icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon}
- />
- }
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- invokeAction("toggle-source-audio");
- }}
- >
- {sourceAudioLabel}
- </ContextMenuItem>
- )}
- {canElementBeHidden(element) && (
- <VisibilityMenuItem
- element={element}
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- />
- )}
- {hasKeyframes && (
- <ContextMenuItem
- icon={<HugeiconsIcon icon={KeyframeIcon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- toggleElementExpanded(element.id);
- }}
- >
- {isExpanded ? "Collapse keyframes" : "Expand keyframes"}
- </ContextMenuItem>
- )}
- {selectedElements.length === 1 && hasMediaId(element) && (
- <>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Search01Icon} />}
- onClick={(event: React.MouseEvent) =>
- handleRevealInMedia({ event })
- }
- >
- Reveal media
- </ContextMenuItem>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Exchange01Icon} />}
- disabled
- >
- Replace media
- </ContextMenuItem>
- </>
- )}
- <ContextMenuSeparator />
- <DeleteMenuItem
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- elementType={element.type}
- selectedCount={selectedElements.length}
- />
- </ContextMenuContent>
- </ContextMenu>
- );
-}
-
-function ElementInner({
- element,
- track,
- isSelected,
- isExpanded,
- baseTrackHeight,
- expandedContent,
- onElementClick,
- onElementMouseDown,
- onResizeStart,
- isDropTarget = false,
-}: {
- element: TimelineElementType;
- track: TimelineTrack;
- isSelected: boolean;
- isExpanded: boolean;
- baseTrackHeight: number;
- expandedContent: React.ReactNode;
- onElementClick: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onElementMouseDown: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => void;
- isDropTarget?: boolean;
-}) {
- const isReducedOpacity =
- (canElementBeHidden(element) && element.hidden) || isDropTarget;
- return (
- <div
- className="absolute top-0 bottom-0"
- style={{
- left: `${ELEMENT_RING_WIDTH_PX}px`,
- right: `${ELEMENT_RING_WIDTH_PX}px`,
- }}
- >
- <div
- className="absolute inset-0 rounded-sm"
- style={
- isSelected
- ? {
- boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
- }
- : undefined
- }
- >
- <div
- className={cn(
- "absolute inset-0 overflow-hidden rounded-sm",
- isExpanded && "bg-background",
- )}
- >
- <button
- type="button"
- tabIndex={-1}
- className="absolute inset-0 size-full flex flex-col"
- onClick={(event) => onElementClick(event, element)}
- onMouseDown={(event) => onElementMouseDown(event, element)}
- >
- <div
- className={cn(
- "flex shrink-0 items-center overflow-hidden",
- getTimelineElementClassName({
- type: getTrackTypeForElementType({
- elementType: element.type,
- }),
- }),
- isReducedOpacity && "opacity-50",
- )}
- style={{ height: `${baseTrackHeight}px` }}
- >
- <div className="flex flex-1 min-h-0 items-center overflow-hidden">
- <ElementContent element={element} track={track} />
- </div>
- </div>
- {expandedContent}
- </button>
- </div>
- </div>
-
- {isSelected && (
- <>
- <ResizeHandle
- side="left"
- element={element}
- track={track}
- onResizeStart={onResizeStart}
- />
- <ResizeHandle
- side="right"
- element={element}
- track={track}
- onResizeStart={onResizeStart}
- />
- </>
- )}
- </div>
- );
-}
-
-function ResizeHandle({
- side,
- element,
- track,
- onResizeStart,
-}: {
- side: "left" | "right";
- element: TimelineElementType;
- track: TimelineTrack;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => void;
-}) {
- const isLeft = side === "left";
- return (
- <button
- type="button"
- className={cn(
- "absolute top-0 bottom-0 w-2",
- isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
- )}
- onMouseDown={(event) => onResizeStart({ event, element, track, side })}
- onClick={(event) => event.stopPropagation()}
- aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
- ></button>
- );
-}
-
-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((keyframe) =>
- dragState.draggingKeyframeIds.has(keyframe.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 mr-0.5"
- 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 text-black",
- isIndicatorSelected ? "fill-primary" : "fill-white",
- )}
- strokeWidth={1.5}
- />
- </button>
- );
- });
-}
-
-function ExpandedKeyframeLanes({
- rows,
- keyframes,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- keyframeDragState,
- onKeyframeMouseDown,
- onKeyframeClick,
- getVisualOffsetPx,
- containerRef,
- onLaneMouseDown,
- onLaneClick,
- selectionBox,
- isBoxSelecting,
-}: {
- rows: ExpandedRow[];
- keyframes: ElementKeyframe[];
- trackId: string;
- elementId: string;
- displayedStartTime: number;
- zoomLevel: number;
- elementLeft: number;
- keyframeDragState: KeyframeDragState;
- onKeyframeMouseDown: (params: {
- event: React.MouseEvent;
- keyframes: SelectedKeyframeRef[];
- }) => void;
- containerRef: React.RefObject<HTMLDivElement | null>;
- onLaneMouseDown: (event: React.MouseEvent) => void;
- onLaneClick: (event: React.MouseEvent) => void;
- selectionBox: {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- isActive: boolean;
- } | null;
- isBoxSelecting: boolean;
- 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 = useMemo(
- () =>
- [...keyframes]
- .sort(
- (a, b) =>
- a.time - b.time ||
- a.propertyPath.localeCompare(b.propertyPath),
- )
- .map((kf) => ({
- trackId,
- elementId,
- propertyPath: kf.propertyPath,
- keyframeId: kf.id,
- })),
- [keyframes, trackId, elementId],
- );
-
- return (
- <div
- ref={containerRef}
- className="relative flex flex-col"
- onMouseDown={onLaneMouseDown}
- onClick={onLaneClick}
- >
- {rows.map((row) => {
- const laneKeyframes = keyframes.filter(
- (kf) => kf.propertyPath === row.propertyPath,
- );
- return (
- <div
- key={row.propertyPath}
- className={cn(
- "relative flex items-center bg-muted/50",
- )}
- style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
- >
- {laneKeyframes.map((kf) => {
- const keyframeRef: SelectedKeyframeRef = {
- trackId,
- elementId,
- propertyPath: row.propertyPath,
- keyframeId: kf.id,
- };
- const isBeingDragged =
- keyframeDragState.draggingKeyframeIds.has(kf.id);
- const kfLeft = timelineTimeToSnappedPixels({
- time: displayedStartTime + kf.time,
- zoomLevel,
- });
- const offsetPx = kfLeft - elementLeft;
- const visualOffset = getVisualOffsetPx({
- indicatorTime: kf.time,
- indicatorOffsetPx: offsetPx,
- isBeingDragged,
- displayedStartTime,
- elementLeft,
- });
- const isSelected = isKeyframeSelected({
- keyframe: keyframeRef,
- });
-
- return (
- <button
- key={kf.id}
- type="button"
- className={cn(
- "pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab",
- isBoxSelecting && "pointer-events-none",
- )}
- style={{ left: visualOffset }}
- onMouseDown={(event) => {
- event.stopPropagation();
- onKeyframeMouseDown({
- event,
- keyframes: [keyframeRef],
- });
- }}
- onClick={(event) => {
- event.stopPropagation();
- onKeyframeClick({
- event,
- keyframes: [keyframeRef],
- orderedKeyframes,
- indicatorTime: kf.time,
- });
- }}
- aria-label="Select keyframe"
- >
- <HugeiconsIcon
- icon={KeyframeIcon}
- className={cn(
- "size-3.5 text-black mr-1",
- isSelected
- ? "fill-primary"
- : "fill-white",
- )}
- strokeWidth={1.5}
- />
- </button>
- );
- })}
- </div>
- );
- })}
- {selectionBox && (
- <SelectionBox
- startPos={selectionBox.startPos}
- currentPos={selectionBox.currentPos}
- containerRef={containerRef}
- isActive={selectionBox.isActive}
- />
- )}
- </div>
- );
-}
-
-interface ElementContentProps {
- element: TimelineElementType;
- track: TimelineTrack;
-}
-
-function TextElementContent({
- element,
-}: {
- element: 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>
- </div>
- );
-}
-
-function EffectElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "effect" }>;
-}) {
- return (
- <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">{element.name}</span>
- </div>
- );
-}
-
-function StickerElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "sticker" }>;
-}) {
- return (
- <div className="flex size-full items-center gap-2 pl-2">
- <Image
- src={resolveStickerId({
- stickerId: element.stickerId,
- options: { width: 20, height: 20 },
- })}
- alt={element.name}
- className="size-4 shrink-0"
- width={20}
- height={20}
- unoptimized
- />
- <span className="truncate text-xs text-white">{element.name}</span>
- </div>
- );
-}
-
-function GraphicElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "graphic" }>;
-}) {
- return (
- <div className="flex size-full items-center gap-2 pl-2">
- <Image
- src={buildGraphicPreviewUrl({
- definitionId: element.definitionId,
- params: element.params,
- size: 20,
- })}
- alt={element.name}
- className="size-4 shrink-0"
- width={20}
- height={20}
- unoptimized
- />
- <span className="truncate text-xs text-white">{element.name}</span>
- </div>
- );
-}
-
-function AudioElementContent({ element }: { element: AudioElement }) {
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const mediaAsset =
- element.sourceType === "upload"
- ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
- : null;
-
- const audioBuffer =
- element.sourceType === "library" ? element.buffer : undefined;
- const audioUrl =
- element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
- const mediaLabel = mediaAsset?.name ?? element.name;
-
- if (audioBuffer || audioUrl) {
- return (
- <div className="relative size-full">
- <AudioWaveform
- audioBuffer={audioBuffer}
- audioUrl={audioUrl}
- color={TIMELINE_TRACK_THEME.audio.waveformColor}
- />
- <MediaElementHeader name={mediaLabel} hasFade={false} />
- </div>
- );
- }
-
- return (
- <span className="text-foreground/80 truncate text-xs">{element.name}</span>
- );
-}
-
-function EffectsButton({
- element,
- track,
-}: {
- element: VideoElement | ImageElement;
- track: TimelineTrack;
-}) {
- const editor = useEditor();
- const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
-
- const handleClick = (event: React.MouseEvent) => {
- event.stopPropagation();
- editor.selection.setSelectedElements({
- elements: [{ trackId: track.id, elementId: element.id }],
- });
- setActiveTab(element.type, "effects");
- };
-
- return (
- <button
- type="button"
- className="flex shrink-0 justify-center text-white cursor-pointer"
- onMouseDown={(event) => event.stopPropagation()}
- onClick={handleClick}
- >
- <HugeiconsIcon icon={MagicWand05Icon} size={12} />
- </button>
- );
-}
-
-function TiledMediaContent({
- element,
- track,
-}: {
- element: VideoElement | ImageElement;
- track: TimelineTrack;
-}) {
- const mediaAssets = useEditor((e) => e.media.getAssets());
-
- const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
- const imageUrl =
- element.type === "video"
- ? mediaAsset?.thumbnailUrl
- : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
-
- if (!imageUrl) {
- return (
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
- );
- }
-
- const trackHeight = getTrackHeight({ type: track.type });
- const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
-
- return (
- <>
- <div
- className="absolute inset-0"
- style={{
- backgroundColor: "var(--muted)",
- backgroundImage: `url(${imageUrl})`,
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${trackHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- />
- <MediaElementHeader
- name={mediaAsset?.name}
- leading={
- hasElementEffects({ element }) ? (
- <EffectsButton element={element} track={track} />
- ) : null
- }
- hasFade={true}
- />
- </>
- );
-}
-
-function MediaElementHeader({
- name,
- leading,
- hasFade,
-}: {
- name?: string | null;
- leading?: ReactNode;
- hasFade?: boolean;
-}) {
- if (!name && !leading) {
- return null;
- }
-
- return (
- <div
- className={cn(
- "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1",
- hasFade && "from-black/30 to-transparent",
- )}
- >
- {leading && <div className="pl-1">{leading}</div>}
- {name && (
- <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
- {name}
- </span>
- )}
- </div>
- );
-}
-
-function ElementContent({ element, track }: ElementContentProps) {
- switch (element.type) {
- case "text":
- return <TextElementContent element={element} />;
- case "effect":
- return <EffectElementContent element={element} />;
- case "sticker":
- return <StickerElementContent element={element} />;
- case "graphic":
- return <GraphicElementContent element={element} />;
- case "audio":
- return <AudioElementContent element={element} />;
- case "video":
- case "image":
- return <TiledMediaContent element={element} track={track} />;
- }
-}
-
-function CopyMenuItem() {
- return (
- <ActionMenuItem
- action="copy-selected"
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- >
- Copy
- </ActionMenuItem>
- );
-}
-
-function MuteMenuItem({
- isMultipleSelected,
- isCurrentElementSelected,
- isMuted,
-}: {
- isMultipleSelected: boolean;
- isCurrentElementSelected: boolean;
- isMuted: boolean;
-}) {
- const getIcon = () => {
- if (isMultipleSelected && isCurrentElementSelected) {
- return <HugeiconsIcon icon={VolumeMute02Icon} />;
- }
- return isMuted ? (
- <HugeiconsIcon icon={VolumeOffIcon} />
- ) : (
- <HugeiconsIcon icon={VolumeHighIcon} />
- );
- };
-
- return (
- <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
- {isMuted ? "Unmute" : "Mute"}
- </ActionMenuItem>
- );
-}
-
-function VisibilityMenuItem({
- element,
- isMultipleSelected,
- isCurrentElementSelected,
-}: {
- element: TimelineElementType;
- isMultipleSelected: boolean;
- isCurrentElementSelected: boolean;
-}) {
- const isHidden = canElementBeHidden(element) && element.hidden;
-
- const getIcon = () => {
- if (isMultipleSelected && isCurrentElementSelected) {
- return <HugeiconsIcon icon={ViewOffSlashIcon} />;
- }
- return isHidden ? (
- <HugeiconsIcon icon={ViewIcon} />
- ) : (
- <HugeiconsIcon icon={ViewOffSlashIcon} />
- );
- };
-
- return (
- <ActionMenuItem
- action="toggle-elements-visibility-selected"
- icon={getIcon()}
- >
- {isHidden ? "Show" : "Hide"}
- </ActionMenuItem>
- );
-}
-
-function DeleteMenuItem({
- isMultipleSelected,
- isCurrentElementSelected,
- elementType,
- selectedCount,
-}: {
- isMultipleSelected: boolean;
- isCurrentElementSelected: boolean;
- elementType: TimelineElementType["type"];
- selectedCount: number;
-}) {
- return (
- <ActionMenuItem
- action="delete-selected"
- variant="destructive"
- icon={<HugeiconsIcon icon={Delete02Icon} />}
- >
- {isMultipleSelected && isCurrentElementSelected
- ? `Delete ${selectedCount} elements`
- : `Delete ${elementType === "text" ? "text" : "clip"}`}
- </ActionMenuItem>
- );
-}
-
-function ActionMenuItem({
- action,
- children,
- ...props
-}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
- action: TActionWithOptionalArgs;
- children: ReactNode;
-}) {
- return (
- <ContextMenuItem
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- invokeAction(action);
- }}
- textRight={getDisplayShortcut({ action })}
- {...props}
- >
- {children}
- </ContextMenuItem>
- );
-}
+"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import { AudioWaveform } from "./audio-waveform"; +import { useElementPreview } from "@/hooks/use-element-preview"; +import { + useKeyframeDrag, + type KeyframeDragState, +} from "@/hooks/timeline/element/use-keyframe-drag"; +import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; +import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select"; +import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; +import { SelectionBox } from "@/lib/selection/selection-box"; +import { getElementKeyframes } from "@/lib/animation"; +import { + canElementHaveAudio, + canElementBeHidden, + hasElementEffects, + hasMediaId, + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/lib/timeline"; +import { getTrackHeight } from "./track-layout"; +import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import type { + TimelineElement as TimelineElementType, + TimelineTrack, + ElementDragState, + VideoElement, + ImageElement, + AudioElement, +} from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; +import { mediaSupportsAudio } from "@/lib/media/media-utils"; +import { + canToggleSourceAudio, + getSourceAudioActionLabel, + isSourceAudioSeparated, +} from "@/lib/timeline/audio-separation"; +import { + getActionDefinition, + type TAction, + type TActionWithOptionalArgs, + invokeAction, +} from "@/lib/actions"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { resolveStickerId } from "@/lib/stickers"; +import { buildGraphicPreviewUrl } from "@/lib/graphics"; +import Image from "next/image"; +import { + ScissorIcon, + Delete02Icon, + Copy01Icon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, + VolumeMute02Icon, + Search01Icon, + Exchange01Icon, + KeyframeIcon, + MagicWand05Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { uppercase } from "@/utils/string"; +import { useMemo, type ComponentProps, type ReactNode } from "react"; +import type { + SelectedKeyframeRef, + ElementKeyframe, +} from "@/lib/animation/types"; +import { cn } from "@/utils/ui"; +import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; +import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { KEYFRAME_LANE_HEIGHT_PX } from "./layout"; +import { + getExpandedRows, + getExpansionHeight, + type ExpandedRow, +} from "./expanded-layout"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; + +const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; +const ELEMENT_RING_WIDTH_PX = 1.5; +const THUMBNAIL_ASPECT_RATIO = 16 / 9; + +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, + }; +} + +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 ""; + } + + return uppercase({ + string: defaultShortcuts[0].replace("+", " "), + }); +} + +interface TimelineElementProps { + element: TimelineElementType; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; + onResizeStateChange?: (params: { isResizing: boolean }) => void; + onElementMouseDown: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + dragState: ElementDragState; + isDropTarget?: boolean; +} + +export function TimelineElement({ + element, + track, + zoomLevel, + isSelected, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + dragState, + isDropTarget = false, +}: TimelineElementProps) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + const { selectedElements } = useElementSelection(); + const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); + const { renderElement } = useElementPreview({ + trackId: track.id, + elementId: element.id, + fallback: element, + }); + const { + currentDuration, + currentStartTime, + handleResizeStart, + isResizing, + } = useTimelineElementResize({ + element, + track, + zoomLevel, + onSnapPointChange, + onResizeStateChange, + }); + + let mediaAsset: MediaAsset | null = null; + + if (hasMediaId(element)) { + mediaAsset = + mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + } + + const hasAudio = mediaSupportsAudio({ media: mediaAsset }); + + const isCurrentElementSelected = selectedElements.some( + (selected) => + selected.elementId === element.id && selected.trackId === track.id, + ); + + const isBeingDragged = dragState.dragElementIds.includes(element.id); + const dragOffsetY = + isBeingDragged && dragState.isDragging + ? dragState.currentMouseY - dragState.startMouseY + : 0; + const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; + const elementStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + dragTimeOffset + : isResizing + ? currentStartTime + : renderElement.startTime; + const displayedStartTime = elementStartTime; + const displayedDuration = isResizing + ? currentDuration + : renderElement.duration; + const elementWidth = timelineTimeToPixels({ + time: displayedDuration, + zoomLevel, + }); + const elementLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime, + zoomLevel, + }); + const handleElementResizeStart = ({ + event, + element, + side, + }: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + side: "left" | "right"; + }) => { + handleResizeStart({ + event, + elementId: element.id, + side, + }); + }; + 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 elementKeyframes = getElementKeyframes({ + animations: element.animations, + }); + + const isExpanded = useTimelineStore((s) => + s.expandedElementIds.has(element.id), + ); + const toggleElementExpanded = useTimelineStore( + (s) => s.toggleElementExpanded, + ); + const expandedRows = useMemo( + () => + isExpanded + ? getExpandedRows({ animations: element.animations }) + : [], + [isExpanded, element.animations], + ); + + const { + containerRef: expandedLanesRef, + selectionBox: keyframeSelectionBox, + isBoxSelecting: isKeyframeBoxSelecting, + handleExpandedAreaMouseDown, + handleExpandedAreaClick, + } = useKeyframeBoxSelect({ + trackId: track.id, + elementId: element.id, + rows: expandedRows, + keyframes: elementKeyframes, + displayedStartTime, + zoomLevel, + elementLeft, + }); + + const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { + event.stopPropagation(); + if (hasMediaId(element)) { + requestRevealMedia(element.mediaId); + } + }; + + const isMuted = canElementHaveAudio(element) && element.muted === true; + const canToggleCurrentSourceAudio = + selectedElements.length === 1 && + isCurrentElementSelected && + canToggleSourceAudio(element, mediaAsset); + const sourceAudioLabel = + element.type === "video" + ? getSourceAudioActionLabel({ element }) + : "Extract audio"; + const isElementSourceAudioSeparated = + element.type === "video" && isSourceAudioSeparated({ element }); + const hasKeyframes = elementKeyframes.length > 0; + const expansionHeight = getExpansionHeight({ rows: expandedRows }); + const baseTrackHeight = getTrackHeight({ type: track.type }); + + const expandedContent = + isExpanded && expandedRows.length > 0 ? ( + <ExpandedKeyframeLanes + rows={expandedRows} + keyframes={elementKeyframes} + trackId={track.id} + elementId={element.id} + displayedStartTime={displayedStartTime} + zoomLevel={zoomLevel} + elementLeft={elementLeft} + keyframeDragState={keyframeDragState} + onKeyframeMouseDown={handleKeyframeMouseDown} + onKeyframeClick={handleKeyframeClick} + getVisualOffsetPx={getVisualOffsetPx} + containerRef={expandedLanesRef} + onLaneMouseDown={handleExpandedAreaMouseDown} + onLaneClick={handleExpandedAreaClick} + selectionBox={keyframeSelectionBox} + isBoxSelecting={isKeyframeBoxSelecting} + /> + ) : null; + + return ( + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className="absolute top-0 select-none" + style={{ + left: `${elementLeft}px`, + width: `${elementWidth}px`, + height: + expandedRows.length > 0 + ? `${baseTrackHeight + expansionHeight}px` + : "100%", + transform: + isBeingDragged && dragState.isDragging + ? `translate3d(0, ${dragOffsetY}px, 0)` + : undefined, + }} + > + <ElementInner + element={element} + track={track} + isSelected={isSelected} + isExpanded={expandedRows.length > 0} + baseTrackHeight={baseTrackHeight} + expandedContent={expandedContent} + onElementClick={onElementClick} + onElementMouseDown={onElementMouseDown} + onResizeStart={handleElementResizeStart} + isDropTarget={isDropTarget} + /> + {isSelected && ( + <div + className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden" + style={{ height: `${baseTrackHeight}px` }} + > + <KeyframeIndicators + indicators={keyframeIndicators} + dragState={keyframeDragState} + displayedStartTime={displayedStartTime} + elementLeft={elementLeft} + onKeyframeMouseDown={handleKeyframeMouseDown} + onKeyframeClick={handleKeyframeClick} + getVisualOffsetPx={getVisualOffsetPx} + /> + </div> + )} + </div> + </ContextMenuTrigger> + <ContextMenuContent className="w-64"> + <ActionMenuItem + action="split" + icon={<HugeiconsIcon icon={ScissorIcon} />} + > + Split + </ActionMenuItem> + <CopyMenuItem /> + {selectedElements.length === 1 && ( + <ActionMenuItem + action="duplicate-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > + Duplicate + </ActionMenuItem> + )} + {canElementHaveAudio(element) && hasAudio && ( + <MuteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + isMuted={isMuted} + /> + )} + {canToggleCurrentSourceAudio && ( + <ContextMenuItem + icon={ + <HugeiconsIcon + icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon} + /> + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("toggle-source-audio"); + }} + > + {sourceAudioLabel} + </ContextMenuItem> + )} + {canElementBeHidden(element) && ( + <VisibilityMenuItem + element={element} + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + /> + )} + {hasKeyframes && ( + <ContextMenuItem + icon={<HugeiconsIcon icon={KeyframeIcon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + toggleElementExpanded(element.id); + }} + > + {isExpanded ? "Collapse keyframes" : "Expand keyframes"} + </ContextMenuItem> + )} + {selectedElements.length === 1 && hasMediaId(element) && ( + <> + <ContextMenuItem + icon={<HugeiconsIcon icon={Search01Icon} />} + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } + > + Reveal media + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={Exchange01Icon} />} + disabled + > + Replace media + </ContextMenuItem> + </> + )} + <ContextMenuSeparator /> + <DeleteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + elementType={element.type} + selectedCount={selectedElements.length} + /> + </ContextMenuContent> + </ContextMenu> + ); +} + +function ElementInner({ + element, + track, + isSelected, + isExpanded, + baseTrackHeight, + expandedContent, + onElementClick, + onElementMouseDown, + onResizeStart, + isDropTarget = false, +}: { + element: TimelineElementType; + track: TimelineTrack; + isSelected: boolean; + isExpanded: boolean; + baseTrackHeight: number; + expandedContent: React.ReactNode; + onElementClick: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementMouseDown: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onResizeStart: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + side: "left" | "right"; + }) => void; + isDropTarget?: boolean; +}) { + const isReducedOpacity = + (canElementBeHidden(element) && element.hidden) || isDropTarget; + return ( + <div + className="absolute top-0 bottom-0" + style={{ + left: `${ELEMENT_RING_WIDTH_PX}px`, + right: `${ELEMENT_RING_WIDTH_PX}px`, + }} + > + <div + className="absolute inset-0 rounded-sm" + style={ + isSelected + ? { + boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`, + } + : undefined + } + > + <div + className={cn( + "absolute inset-0 overflow-hidden rounded-sm", + isExpanded && "bg-background", + )} + > + <button + type="button" + tabIndex={-1} + className="absolute inset-0 size-full flex flex-col" + onClick={(event) => onElementClick(event, element)} + onMouseDown={(event) => onElementMouseDown(event, element)} + > + <div + className={cn( + "flex shrink-0 items-center overflow-hidden", + getTimelineElementClassName({ + type: getTrackTypeForElementType({ + elementType: element.type, + }), + }), + isReducedOpacity && "opacity-50", + )} + style={{ height: `${baseTrackHeight}px` }} + > + <div className="flex flex-1 min-h-0 items-center overflow-hidden"> + <ElementContent element={element} track={track} /> + </div> + </div> + {expandedContent} + </button> + </div> + </div> + + {isSelected && ( + <> + <ResizeHandle + side="left" + element={element} + track={track} + onResizeStart={onResizeStart} + /> + <ResizeHandle + side="right" + element={element} + track={track} + onResizeStart={onResizeStart} + /> + </> + )} + </div> + ); +} + +function ResizeHandle({ + side, + element, + track, + onResizeStart, +}: { + side: "left" | "right"; + element: TimelineElementType; + track: TimelineTrack; + onResizeStart: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + side: "left" | "right"; + }) => void; +}) { + const isLeft = side === "left"; + return ( + <button + type="button" + className={cn( + "absolute top-0 bottom-0 w-2", + isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize", + )} + onMouseDown={(event) => onResizeStart({ event, element, track, side })} + onClick={(event) => event.stopPropagation()} + aria-label={`${isLeft ? "Left" : "Right"} resize handle`} + ></button> + ); +} + +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((keyframe) => + dragState.draggingKeyframeIds.has(keyframe.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 mr-0.5" + 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 text-black", + isIndicatorSelected ? "fill-primary" : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + ); + }); +} + +function ExpandedKeyframeLanes({ + rows, + keyframes, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + keyframeDragState, + onKeyframeMouseDown, + onKeyframeClick, + getVisualOffsetPx, + containerRef, + onLaneMouseDown, + onLaneClick, + selectionBox, + isBoxSelecting, +}: { + rows: ExpandedRow[]; + keyframes: ElementKeyframe[]; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + keyframeDragState: KeyframeDragState; + onKeyframeMouseDown: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => void; + containerRef: React.RefObject<HTMLDivElement | null>; + onLaneMouseDown: (event: React.MouseEvent) => void; + onLaneClick: (event: React.MouseEvent) => void; + selectionBox: { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + isActive: boolean; + } | null; + isBoxSelecting: boolean; + 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 = useMemo( + () => + [...keyframes] + .sort( + (a, b) => + a.time - b.time || + a.propertyPath.localeCompare(b.propertyPath), + ) + .map((kf) => ({ + trackId, + elementId, + propertyPath: kf.propertyPath, + keyframeId: kf.id, + })), + [keyframes, trackId, elementId], + ); + + return ( + <div + ref={containerRef} + className="relative flex flex-col" + onMouseDown={onLaneMouseDown} + onClick={onLaneClick} + > + {rows.map((row) => { + const laneKeyframes = keyframes.filter( + (kf) => kf.propertyPath === row.propertyPath, + ); + return ( + <div + key={row.propertyPath} + className={cn( + "relative flex items-center bg-muted/50", + )} + style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} + > + {laneKeyframes.map((kf) => { + const keyframeRef: SelectedKeyframeRef = { + trackId, + elementId, + propertyPath: row.propertyPath, + keyframeId: kf.id, + }; + const isBeingDragged = + keyframeDragState.draggingKeyframeIds.has(kf.id); + const kfLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime + kf.time, + zoomLevel, + }); + const offsetPx = kfLeft - elementLeft; + const visualOffset = getVisualOffsetPx({ + indicatorTime: kf.time, + indicatorOffsetPx: offsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }); + const isSelected = isKeyframeSelected({ + keyframe: keyframeRef, + }); + + return ( + <button + key={kf.id} + type="button" + className={cn( + "pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab", + isBoxSelecting && "pointer-events-none", + )} + style={{ left: visualOffset }} + onMouseDown={(event) => { + event.stopPropagation(); + onKeyframeMouseDown({ + event, + keyframes: [keyframeRef], + }); + }} + onClick={(event) => { + event.stopPropagation(); + onKeyframeClick({ + event, + keyframes: [keyframeRef], + orderedKeyframes, + indicatorTime: kf.time, + }); + }} + aria-label="Select keyframe" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn( + "size-3.5 text-black mr-1", + isSelected + ? "fill-primary" + : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + ); + })} + </div> + ); + })} + {selectionBox && ( + <SelectionBox + startPos={selectionBox.startPos} + currentPos={selectionBox.currentPos} + containerRef={containerRef} + isActive={selectionBox.isActive} + /> + )} + </div> + ); +} + +interface ElementContentProps { + element: TimelineElementType; + track: TimelineTrack; +} + +function TextElementContent({ + element, +}: { + element: 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> + </div> + ); +} + +function EffectElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "effect" }>; +}) { + return ( + <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">{element.name}</span> + </div> + ); +} + +function StickerElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "sticker" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={resolveStickerId({ + stickerId: element.stickerId, + options: { width: 20, height: 20 }, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} + +function GraphicElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "graphic" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={buildGraphicPreviewUrl({ + definitionId: element.definitionId, + params: element.params, + size: 20, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} + +function AudioElementContent({ element }: { element: AudioElement }) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + const mediaAsset = + element.sourceType === "upload" + ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) + : null; + + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; + const audioUrl = + element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; + const mediaLabel = mediaAsset?.name ?? element.name; + + if (audioBuffer || audioUrl) { + return ( + <div className="relative size-full"> + <AudioWaveform + audioBuffer={audioBuffer} + audioUrl={audioUrl} + color={TIMELINE_TRACK_THEME.audio.waveformColor} + /> + <MediaElementHeader name={mediaLabel} hasFade={false} /> + </div> + ); + } + + return ( + <span className="text-foreground/80 truncate text-xs">{element.name}</span> + ); +} + +function EffectsButton({ + element, + track, +}: { + element: VideoElement | ImageElement; + track: TimelineTrack; +}) { + const editor = useEditor(); + const setActiveTab = usePropertiesStore((s) => s.setActiveTab); + + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + editor.selection.setSelectedElements({ + elements: [{ trackId: track.id, elementId: element.id }], + }); + setActiveTab(element.type, "effects"); + }; + + return ( + <button + type="button" + className="flex shrink-0 justify-center text-white cursor-pointer" + onMouseDown={(event) => event.stopPropagation()} + onClick={handleClick} + > + <HugeiconsIcon icon={MagicWand05Icon} size={12} /> + </button> + ); +} + +function TiledMediaContent({ + element, + track, +}: { + element: VideoElement | ImageElement; + track: TimelineTrack; +}) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + const imageUrl = + element.type === "video" + ? mediaAsset?.thumbnailUrl + : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); + + if (!imageUrl) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; + + return ( + <> + <div + className="absolute inset-0" + style={{ + backgroundColor: "var(--muted)", + backgroundImage: `url(${imageUrl})`, + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + /> + <MediaElementHeader + name={mediaAsset?.name} + leading={ + hasElementEffects({ element }) ? ( + <EffectsButton element={element} track={track} /> + ) : null + } + hasFade={true} + /> + </> + ); +} + +function MediaElementHeader({ + name, + leading, + hasFade, +}: { + name?: string | null; + leading?: ReactNode; + hasFade?: boolean; +}) { + if (!name && !leading) { + return null; + } + + return ( + <div + className={cn( + "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1", + hasFade && "from-black/30 to-transparent", + )} + > + {leading && <div className="pl-1">{leading}</div>} + {name && ( + <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75"> + {name} + </span> + )} + </div> + ); +} + +function ElementContent({ element, track }: ElementContentProps) { + switch (element.type) { + case "text": + return <TextElementContent element={element} />; + case "effect": + return <EffectElementContent element={element} />; + case "sticker": + return <StickerElementContent element={element} />; + case "graphic": + return <GraphicElementContent element={element} />; + case "audio": + return <AudioElementContent element={element} />; + case "video": + case "image": + return <TiledMediaContent element={element} track={track} />; + } +} + +function CopyMenuItem() { + return ( + <ActionMenuItem + action="copy-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > + Copy + </ActionMenuItem> + ); +} + +function MuteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + isMuted, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + isMuted: boolean; +}) { + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={VolumeMute02Icon} />; + } + return isMuted ? ( + <HugeiconsIcon icon={VolumeOffIcon} /> + ) : ( + <HugeiconsIcon icon={VolumeHighIcon} /> + ); + }; + + return ( + <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}> + {isMuted ? "Unmute" : "Mute"} + </ActionMenuItem> + ); +} + +function VisibilityMenuItem({ + element, + isMultipleSelected, + isCurrentElementSelected, +}: { + element: TimelineElementType; + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; +}) { + const isHidden = canElementBeHidden(element) && element.hidden; + + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={ViewOffSlashIcon} />; + } + return isHidden ? ( + <HugeiconsIcon icon={ViewIcon} /> + ) : ( + <HugeiconsIcon icon={ViewOffSlashIcon} /> + ); + }; + + return ( + <ActionMenuItem + action="toggle-elements-visibility-selected" + icon={getIcon()} + > + {isHidden ? "Show" : "Hide"} + </ActionMenuItem> + ); +} + +function DeleteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + elementType, + selectedCount, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + elementType: TimelineElementType["type"]; + selectedCount: number; +}) { + return ( + <ActionMenuItem + action="delete-selected" + variant="destructive" + icon={<HugeiconsIcon icon={Delete02Icon} />} + > + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedCount} elements` + : `Delete ${elementType === "text" ? "text" : "clip"}`} + </ActionMenuItem> + ); +} + +function ActionMenuItem({ + action, + children, + ...props +}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { + action: TActionWithOptionalArgs; + children: ReactNode; +}) { + return ( + <ContextMenuItem + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction(action); + }} + textRight={getDisplayShortcut({ action })} + {...props} + > + {children} + </ContextMenuItem> + ); +} |
