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} + {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