diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-02-10 16:28:21 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-02-10 16:28:21 +0100 |
| commit | fca99d6126c31fbb18ed9f1034cee6f940b040e8 (patch) | |
| tree | 11500ff0fcc4a933650e3e91f1ea00fd315c4d0c /apps/web/src/components/editor/timeline/timeline-element.tsx | |
| parent | 33a8705f0d967891402c61dd3192e6bad24921ef (diff) | |
| download | opencut-fca99d6126c31fbb18ed9f1034cee6f940b040e8.tar.gz | |
refactor
Diffstat (limited to 'apps/web/src/components/editor/timeline/timeline-element.tsx')
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-element.tsx | 554 |
1 files changed, 0 insertions, 554 deletions
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx deleted file mode 100644 index fd292ad8..00000000 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ /dev/null @@ -1,554 +0,0 @@ -"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 { - getTrackClasses, - getTrackHeight, - canElementHaveAudio, - canElementBeHidden, - hasMediaId, -} from "@/lib/timeline"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "../../ui/context-menu"; -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, - onSnapPointChange, - onResizeStateChange, - onElementMouseDown, - onElementClick, - dragState, -}: TimelineElementProps) { - const editor = useEditor(); - const { selectedElements } = useElementSelection(); - const { requestRevealMedia } = useAssetsPanelStore(); - - const mediaAssets = editor.media.getAssets(); - let mediaAsset: MediaAsset | null = null; - - if (hasMediaId(element)) { - mediaAsset = - mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; - } - - 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 ( - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className={`absolute top-0 h-full select-none ${ - isBeingDragged ? "z-30" : "z-10" - }`} - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - transform: - isBeingDragged && dragState.isDragging - ? `translate3d(0, ${dragOffsetY}px, 0)` - : undefined, - }} - > - <ElementInner - element={element} - track={track} - isSelected={isSelected} - isBeingDragged={isBeingDragged} - hasAudio={hasAudio} - isMuted={isMuted} - mediaAssets={mediaAssets} - onElementClick={onElementClick} - onElementMouseDown={onElementMouseDown} - handleResizeStart={handleResizeStart} - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent className="z-200 w-64"> - <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}> - Split - </ActionMenuItem> - <CopyMenuItem /> - {canElementHaveAudio(element) && hasAudio && ( - <MuteMenuItem - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - isMuted={isMuted} - /> - )} - {canElementBeHidden(element) && ( - <VisibilityMenuItem - element={element} - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - /> - )} - {selectedElements.length === 1 && ( - <ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> - Duplicate - </ActionMenuItem> - )} - {selectedElements.length === 1 && hasMediaId(element) && ( - <> - <ContextMenuItem - icon={<HugeiconsIcon icon={Search01Icon} />} - onClick={(event) => 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, - 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 ( - <div - className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( - { - type: track.type, - }, - )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} - > - <button - type="button" - className="absolute inset-0 size-full cursor-pointer" - onClick={(e) => onElementClick(e, element)} - onMouseDown={(e) => onElementMouseDown(e, element)} - > - <div className="absolute inset-0 flex h-full items-center"> - <ElementContent - element={element} - track={track} - isSelected={isSelected} - mediaAssets={mediaAssets} - /> - </div> - - {(hasAudio - ? isMuted - : canElementBeHidden(element) && element.hidden) && ( - <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black"> - {hasAudio ? ( - <HugeiconsIcon - icon={VolumeHighIcon} - className="size-6 text-white" - /> - ) : ( - <HugeiconsIcon - icon={VolumeOffIcon} - className="size-6 text-white" - /> - )} - </div> - )} - </button> - - {isSelected && ( - <> - <ResizeHandle - side="left" - elementId={element.id} - handleResizeStart={handleResizeStart} - /> - <ResizeHandle - side="right" - elementId={element.id} - handleResizeStart={handleResizeStart} - /> - </> - )} - </div> - ); -} - -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 ( - <button - type="button" - className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} - onMouseDown={(e) => handleResizeStart({ e, elementId, side })} - aria-label={`${isLeft ? "Left" : "Right"} resize handle`} - > - <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> - </button> - ); -} - -function ElementContent({ - element, - track, - isSelected, - mediaAssets, -}: { - element: TimelineElementType; - track: TimelineTrack; - isSelected: boolean; - mediaAssets: MediaAsset[]; -}) { - if (element.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> - ); - } - - if (element.type === "sticker") { - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`} - alt={element.name} - className="size-5 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); - } - - 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 ( - <div className="flex size-full items-center gap-2"> - <div className="min-w-0 flex-1"> - <AudioWaveform - audioBuffer={audioBuffer} - audioUrl={audioUrl} - height={24} - className="w-full" - /> - </div> - </div> - ); - } - - return ( - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> - ); - } - - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - if (!mediaAsset) { - return ( - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> - ); - } - - if ( - mediaAsset.type === "image" || - (mediaAsset.type === "video" && mediaAsset.thumbnailUrl) - ) { - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - const imageUrl = - mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl; - - return ( - <div className="flex size-full items-center justify-center"> - <div - className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`} - > - <div - className="absolute right-0 left-0" - style={{ - backgroundImage: imageUrl ? `url(${imageUrl})` : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - top: isSelected ? "0.25rem" : "0rem", - bottom: isSelected ? "0.25rem" : "0rem", - }} - /> - </div> - </div> - ); - } - - return ( - <span className="text-foreground/80 truncate text-xs">{element.name}</span> - ); -} - -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={VolumeHighIcon} /> - ) : ( - <HugeiconsIcon icon={VolumeOffIcon} /> - ); - }; - - 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: TAction; -}) { - return ( - <ContextMenuItem - onClick={(event) => { - event.stopPropagation(); - invokeAction(action); - }} - textRight={getDisplayShortcut(action)} - {...props} - > - {children} - </ContextMenuItem> - ); -} |
