1281 lines
31 KiB
TypeScript
1281 lines
31 KiB
TypeScript
"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>
|
|
);
|
|
}
|