From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001
From: Maze <167211895+mazeincoding@users.noreply.github.com>
Date: Sat, 31 Jan 2026 00:20:04 +0100
Subject: codebase overhaul (#697)
---
.../editor/timeline/timeline-element.tsx | 923 ++++++++++++---------
1 file changed, 545 insertions(+), 378 deletions(-)
(limited to 'apps/web/src/components/editor/timeline/timeline-element.tsx')
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index ca47d3ca..fd292ad8 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -1,387 +1,554 @@
"use client";
+import { useEditor } from "@/hooks/use-editor";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import AudioWaveform from "./audio-waveform";
+import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
- Scissors,
- Trash2,
- Copy,
- Search,
- RefreshCw,
- EyeOff,
- Eye,
- Volume2,
- VolumeX,
-} from "lucide-react";
-import { useMediaStore } from "@/stores/media-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import AudioWaveform from "../audio-waveform";
-import { toast } from "sonner";
-import { TimelineElementProps, MediaElement } from "@/types/timeline";
-import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
+ getTrackClasses,
+ getTrackHeight,
+ canElementHaveAudio,
+ canElementBeHidden,
+ hasMediaId,
+} from "@/lib/timeline";
import {
- getTrackElementClasses,
- TIMELINE_CONSTANTS,
- getTrackHeight,
-} from "@/constants/timeline-constants";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
} from "../../ui/context-menu";
-import { useMediaPanelStore } from "../media-panel/store";
+import type {
+ TimelineElement as TimelineElementType,
+ TimelineTrack,
+ ElementDragState,
+} from "@/types/timeline";
+import type { MediaAsset } from "@/types/assets";
+import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import Image from "next/image";
+import {
+ ScissorIcon,
+ Delete02Icon,
+ Copy01Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+ VolumeMute02Icon,
+ Search01Icon,
+ Exchange01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { uppercase } from "@/utils/string";
+import type { ComponentProps } from "react";
+
+function getDisplayShortcut(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: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ dragState: ElementDragState;
+}
export function TimelineElement({
- element,
- track,
- zoomLevel,
- isSelected,
- onElementMouseDown,
- onElementClick,
+ element,
+ track,
+ zoomLevel,
+ isSelected,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ dragState,
}: TimelineElementProps) {
- const { mediaFiles } = useMediaStore();
- const {
- dragState,
- copySelected,
- selectedElements,
- deleteSelected,
- splitSelected,
- toggleSelectedHidden,
- toggleSelectedMuted,
- duplicateElement,
- revealElementInMedia,
- replaceElementWithFile,
- getContextMenuState,
- } = useTimelineStore();
- const { currentTime } = usePlaybackStore();
-
- const mediaItem =
- element.type === "media"
- ? mediaFiles.find((file) => file.id === element.mediaId)
- : null;
- const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
-
- const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
- useTimelineElementResize({
- element,
- track,
- zoomLevel,
- });
-
- const {
- isMultipleSelected,
- isCurrentElementSelected,
- hasAudioElements,
- canSplitSelected,
- } = getContextMenuState(track.id, element.id);
-
- const effectiveDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementWidth = Math.max(
- TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
- effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
- );
-
- const isBeingDragged = dragState.elementId === element.id;
- const elementStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime
- : element.startTime;
-
- const elementLeft = elementStartTime * 50 * zoomLevel;
-
- const handleElementSplitContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- splitSelected(
- currentTime,
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- };
-
- const handleElementDuplicateContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- duplicateElement(track.id, element.id);
- };
-
- const handleElementCopyContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- copySelected();
- };
-
- const handleElementDeleteContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- deleteSelected(
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- };
-
- const handleToggleElementContext = (e: React.MouseEvent) => {
- e.stopPropagation();
-
- if (hasAudio && element.type === "media") {
- toggleSelectedMuted(
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- } else {
- toggleSelectedHidden(
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- }
- };
-
- const handleReplaceClip = (e: React.MouseEvent) => {
- e.stopPropagation();
-
- const input = document.createElement("input");
- input.type = "file";
- input.accept = "video/*,audio/*,image/*";
- input.onchange = async (e) => {
- const file = (e.target as HTMLInputElement).files?.[0];
- if (file) {
- await replaceElementWithFile(track.id, element.id, file);
- }
- };
- input.click();
- };
-
- const handleRevealInMedia = (e: React.MouseEvent) => {
- e.stopPropagation();
- revealElementInMedia(element.id);
- };
-
- const renderElementContent = () => {
- if (element.type === "text") {
- return (
-
- {element.content}
-
- );
- }
-
- const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
- if (!mediaItem) {
- return (
-
- {element.name}
-
- );
- }
-
- if (
- mediaItem.type === "image" ||
- (mediaItem.type === "video" && mediaItem.thumbnailUrl)
- ) {
- const trackHeight = getTrackHeight(track.type);
- const tileWidth = trackHeight * (16 / 9);
-
- const imageUrl =
- mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
-
- return (
-
- );
- }
-
- if (mediaItem.type === "audio") {
- return (
-
- );
- }
-
- return (
-
- {element.name}
-
- );
- };
-
- const handleElementMouseDown = (e: React.MouseEvent) => {
- if (onElementMouseDown) {
- onElementMouseDown(e, element);
- }
- };
-
- const isMuted = element.type === "media" && element.muted;
-
- return (
-
-
-
-
onElementClick && onElementClick(e, element)}
- onMouseDown={handleElementMouseDown}
- onContextMenu={(e) =>
- onElementMouseDown && onElementMouseDown(e, element)
- }
- >
-
- {renderElementContent()}
-
-
- {(hasAudio ? isMuted : element.hidden) && (
-
- {hasAudio ? (
-
- ) : (
-
- )}
-
- )}
-
- {isSelected && (
- <>
-
handleResizeStart(e, element.id, "left")}
- >
-
-
-
handleResizeStart(e, element.id, "right")}
- >
-
-
- >
- )}
-
-
-
-
- {(!isMultipleSelected ||
- (isMultipleSelected &&
- isCurrentElementSelected &&
- canSplitSelected)) && (
-
-
- {isMultipleSelected && isCurrentElementSelected
- ? `Split ${selectedElements.length} elements at playhead`
- : "Split at playhead"}
-
- )}
-
-
-
- {isMultipleSelected && isCurrentElementSelected
- ? `Copy ${selectedElements.length} elements`
- : "Copy element"}
-
-
-
- {isMultipleSelected && isCurrentElementSelected ? (
- hasAudioElements ? (
-
- ) : (
-
- )
- ) : hasAudio ? (
- isMuted ? (
-
- ) : (
-
- )
- ) : element.hidden ? (
-
- ) : (
-
- )}
-
- {isMultipleSelected && isCurrentElementSelected
- ? hasAudioElements
- ? `Toggle mute ${selectedElements.length} elements`
- : `Toggle visibility ${selectedElements.length} elements`
- : hasAudio
- ? isMuted
- ? "Unmute"
- : "Mute"
- : element.hidden
- ? "Show"
- : "Hide"}{" "}
- {!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
-
-
-
- {!isMultipleSelected && (
-
-
- Duplicate {element.type === "text" ? "text" : "clip"}
-
- )}
-
- {!isMultipleSelected && element.type === "media" && (
- <>
-
-
- Reveal in media
-
-
-
- Replace clip
-
- >
- )}
-
-
-
-
-
- {isMultipleSelected && isCurrentElementSelected
- ? `Delete ${selectedElements.length} elements`
- : `Delete ${element.type === "text" ? "text" : "clip"}`}
-
-
-
- );
+ const editor = useEditor();
+ const { selectedElements } = useElementSelection();
+ const { requestRevealMedia } = useAssetsPanelStore();
+
+ const mediaAssets = editor.media.getAssets();
+ let mediaAsset: MediaAsset | null = null;
+
+ if (hasMediaId(element)) {
+ mediaAsset =
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ }
+
+ const hasAudio = mediaSupportsAudio({ media: mediaAsset });
+
+ const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
+ useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onSnapPointChange,
+ onResizeStateChange,
+ });
+
+ const isCurrentElementSelected = selectedElements.some(
+ (selected) =>
+ selected.elementId === element.id && selected.trackId === track.id,
+ );
+
+ const isBeingDragged = dragState.elementId === element.id;
+ const dragOffsetY =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentMouseY - dragState.startMouseY
+ : 0;
+ const elementStartTime =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentTime
+ : element.startTime;
+ const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
+ const displayedDuration = isResizing ? currentDuration : element.duration;
+ const elementWidth =
+ displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const elementLeft = displayedStartTime * 50 * zoomLevel;
+
+ const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
+ event.stopPropagation();
+ if (hasMediaId(element)) {
+ requestRevealMedia(element.mediaId);
+ }
+ };
+
+ const isMuted = canElementHaveAudio(element) && element.muted === true;
+
+ return (
+
+
+
+
+
+
+
+ }>
+ Split
+
+
+ {canElementHaveAudio(element) && hasAudio && (
+ 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canElementBeHidden(element) && (
+ 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {selectedElements.length === 1 && (
+ }>
+ Duplicate
+
+ )}
+ {selectedElements.length === 1 && hasMediaId(element) && (
+ <>
+ }
+ onClick={(event) => handleRevealInMedia({ event })}
+ >
+ Reveal media
+
+ }
+ disabled
+ >
+ Replace media
+
+ >
+ )}
+
+ 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ elementType={element.type}
+ selectedCount={selectedElements.length}
+ />
+
+
+ );
+}
+
+function ElementInner({
+ element,
+ track,
+ isSelected,
+ isBeingDragged,
+ hasAudio,
+ isMuted,
+ mediaAssets,
+ onElementClick,
+ onElementMouseDown,
+ handleResizeStart,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ isBeingDragged: boolean;
+ hasAudio: boolean;
+ isMuted: boolean;
+ mediaAssets: MediaAsset[];
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ onElementMouseDown: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ return (
+
+
+
+ {isSelected && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
+function ResizeHandle({
+ side,
+ elementId,
+ handleResizeStart,
+}: {
+ side: "left" | "right";
+ elementId: string;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ const isLeft = side === "left";
+ return (
+
+ );
+}
+
+function ElementContent({
+ element,
+ track,
+ isSelected,
+ mediaAssets,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ mediaAssets: MediaAsset[];
+}) {
+ if (element.type === "text") {
+ return (
+
+ {element.content}
+
+ );
+ }
+
+ if (element.type === "sticker") {
+ return (
+
+
+ {element.name}
+
+ );
+ }
+
+ if (element.type === "audio") {
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+
+ const audioUrl =
+ element.sourceType === "library"
+ ? element.sourceUrl
+ : mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
+
+ if (audioBuffer || audioUrl) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {element.name}
+
+ );
+ }
+
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ if (!mediaAsset) {
+ return (
+
+ {element.name}
+
+ );
+ }
+
+ if (
+ mediaAsset.type === "image" ||
+ (mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
+ ) {
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * (16 / 9);
+ const imageUrl =
+ mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
+
+ return (
+
+ );
+ }
+
+ return (
+ {element.name}
+ );
+}
+
+function CopyMenuItem() {
+ return (
+ }>
+ Copy
+
+ );
+}
+
+function MuteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ isMuted,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ isMuted: boolean;
+}) {
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return ;
+ }
+ return isMuted ? (
+
+ ) : (
+
+ );
+ };
+
+ return (
+
+ {isMuted ? "Unmute" : "Mute"}
+
+ );
+}
+
+function VisibilityMenuItem({
+ element,
+ isMultipleSelected,
+ isCurrentElementSelected,
+}: {
+ element: TimelineElementType;
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+}) {
+ const isHidden = canElementBeHidden(element) && element.hidden;
+
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return ;
+ }
+ return isHidden ? (
+
+ ) : (
+
+ );
+ };
+
+ return (
+
+ {isHidden ? "Show" : "Hide"}
+
+ );
+}
+
+function DeleteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ elementType,
+ selectedCount,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ elementType: TimelineElementType["type"];
+ selectedCount: number;
+}) {
+ return (
+ }
+ >
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedCount} elements`
+ : `Delete ${elementType === "text" ? "text" : "clip"}`}
+
+ );
+}
+
+function ActionMenuItem({
+ action,
+ children,
+ ...props
+}: Omit, "onClick" | "textRight"> & {
+ action: TAction;
+}) {
+ return (
+ {
+ event.stopPropagation();
+ invokeAction(action);
+ }}
+ textRight={getDisplayShortcut(action)}
+ {...props}
+ >
+ {children}
+
+ );
}
--
cgit v1.3.1