diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/timeline/timeline-element.tsx | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/timeline/timeline-element.tsx')
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-element.tsx | 861 |
1 files changed, 514 insertions, 347 deletions
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"; -export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onElementMouseDown, - onElementClick, -}: TimelineElementProps) { - const { mediaFiles } = useMediaStore(); - const { - dragState, - copySelected, - selectedElements, - deleteSelected, - splitSelected, - toggleSelectedHidden, - toggleSelectedMuted, - duplicateElement, - revealElementInMedia, - replaceElementWithFile, - getContextMenuState, - } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); +function getDisplayShortcut(action: TAction) { + const { defaultShortcuts } = getActionDefinition(action); + if (!defaultShortcuts?.length) { + return ""; + } - const mediaItem = - element.type === "media" - ? mediaFiles.find((file) => file.id === element.mediaId) - : null; - const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video"; + return uppercase({ + string: defaultShortcuts[0].replace("+", " "), + }); +} - const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = - useTimelineElementResize({ - element, - track, - zoomLevel, - }); +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; +} - const { - isMultipleSelected, - isCurrentElementSelected, - hasAudioElements, - canSplitSelected, - } = getContextMenuState(track.id, element.id); +export function TimelineElement({ + element, + track, + zoomLevel, + isSelected, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + dragState, +}: TimelineElementProps) { + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + const { requestRevealMedia } = useAssetsPanelStore(); - 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 mediaAssets = editor.media.getAssets(); + let mediaAsset: MediaAsset | null = null; - const isBeingDragged = dragState.elementId === element.id; - const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime - : element.startTime; + if (hasMediaId(element)) { + mediaAsset = + mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + } - const elementLeft = elementStartTime * 50 * zoomLevel; + const hasAudio = mediaSupportsAudio({ media: mediaAsset }); - const handleElementSplitContext = (e: React.MouseEvent) => { - e.stopPropagation(); - splitSelected( - currentTime, - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - }; + const { handleResizeStart, isResizing, currentStartTime, currentDuration } = + useTimelineElementResize({ + element, + track, + zoomLevel, + onSnapPointChange, + onResizeStateChange, + }); - const handleElementDuplicateContext = (e: React.MouseEvent) => { - e.stopPropagation(); - duplicateElement(track.id, element.id); - }; + const isCurrentElementSelected = selectedElements.some( + (selected) => + selected.elementId === element.id && selected.trackId === track.id, + ); - const handleElementCopyContext = (e: React.MouseEvent) => { - e.stopPropagation(); - copySelected(); - }; + 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 handleElementDeleteContext = (e: React.MouseEvent) => { - e.stopPropagation(); - deleteSelected( - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - }; + const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { + event.stopPropagation(); + if (hasMediaId(element)) { + requestRevealMedia(element.mediaId); + } + }; - const handleToggleElementContext = (e: React.MouseEvent) => { - e.stopPropagation(); + const isMuted = canElementHaveAudio(element) && element.muted === true; - 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 - ); - } - }; + 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> + ); +} - const handleReplaceClip = (e: React.MouseEvent) => { - e.stopPropagation(); +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> - 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(); - }; + {(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> - const handleRevealInMedia = (e: React.MouseEvent) => { - e.stopPropagation(); - revealElementInMedia(element.id); - }; + {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> + ); +} - const renderElementContent = () => { - if (element.type === "text") { - return ( - <div className="w-full h-full flex items-center justify-start pl-2"> - <span className="text-xs text-white truncate">{element.content}</span> - </div> - ); - } +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> + ); + } - const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); - if (!mediaItem) { - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - } + 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 ( - mediaItem.type === "image" || - (mediaItem.type === "video" && mediaItem.thumbnailUrl) - ) { - const trackHeight = getTrackHeight(track.type); - const tileWidth = trackHeight * (16 / 9); + if (element.type === "audio") { + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; - const imageUrl = - mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; + const audioUrl = + element.sourceType === "library" + ? element.sourceUrl + : mediaAssets.find((asset) => asset.id === element.mediaId)?.url; - return ( - <div className="w-full h-full flex items-center justify-center"> - <div - className={`w-full h-full relative ${ - isSelected ? "bg-primary" : "bg-transparent" - }`} - > - <div - className={`absolute top-[0.25rem] bottom-[0.25rem] left-0 right-0`} - style={{ - backgroundImage: imageUrl ? `url(${imageUrl})` : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`} - /> - </div> - </div> - ); - } + 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> + ); + } - if (mediaItem.type === "audio") { - return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 min-w-0"> - <AudioWaveform - audioUrl={mediaItem.url || ""} - height={24} - className="w-full" - /> - </div> - </div> - ); - } + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } - return ( - <span className="text-xs text-foreground/80 truncate"> - {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> + ); + } - const handleElementMouseDown = (e: React.MouseEvent) => { - if (onElementMouseDown) { - onElementMouseDown(e, element); - } - }; + 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; - const isMuted = element.type === "media" && element.muted; + 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 ( - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className={`absolute top-0 h-full select-none timeline-element ${ - isBeingDragged ? "z-50" : "z-10" - }`} - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - }} - data-element-id={element.id} - data-track-id={track.id} - onMouseMove={resizing ? handleResizeMove : undefined} - onMouseUp={resizing ? handleResizeEnd : undefined} - onMouseLeave={resizing ? handleResizeEnd : undefined} - > - <div - className={`relative h-full rounded-[0.5rem] cursor-pointer overflow-hidden ${getTrackElementClasses( - track.type - )} ${isSelected ? "" : ""} ${ - isBeingDragged ? "z-50" : "z-10" - } ${element.hidden ? "opacity-50" : ""}`} - onClick={(e) => onElementClick && onElementClick(e, element)} - onMouseDown={handleElementMouseDown} - onContextMenu={(e) => - onElementMouseDown && onElementMouseDown(e, element) - } - > - <div className="absolute inset-0 flex items-center h-full"> - {renderElementContent()} - </div> + return ( + <span className="text-foreground/80 truncate text-xs">{element.name}</span> + ); +} - {(hasAudio ? isMuted : element.hidden) && ( - <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none"> - {hasAudio ? ( - <VolumeX className="h-6 w-6 text-white" /> - ) : ( - <EyeOff className="h-6 w-6 text-white" /> - )} - </div> - )} +function CopyMenuItem() { + return ( + <ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> + Copy + </ActionMenuItem> + ); +} - {isSelected && ( - <> - <div - className="absolute left-0 top-0 bottom-0 w-[0.6rem] cursor-w-resize bg-primary z-50 flex items-center justify-center" - onMouseDown={(e) => handleResizeStart(e, element.id, "left")} - > - <div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" /> - </div> - <div - className="absolute right-0 top-0 bottom-0 w-[0.6rem] cursor-e-resize bg-primary z-50 flex items-center justify-center" - onMouseDown={(e) => handleResizeStart(e, element.id, "right")} - > - <div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" /> - </div> - </> - )} - </div> - </div> - </ContextMenuTrigger> - <ContextMenuContent className="z-200"> - {(!isMultipleSelected || - (isMultipleSelected && - isCurrentElementSelected && - canSplitSelected)) && ( - <ContextMenuItem onClick={handleElementSplitContext}> - <Scissors className="h-4 w-4 mr-2" /> - {isMultipleSelected && isCurrentElementSelected - ? `Split ${selectedElements.length} elements at playhead` - : "Split at playhead"} - </ContextMenuItem> - )} +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} /> + ); + }; - <ContextMenuItem onClick={handleElementCopyContext}> - <Copy className="h-4 w-4 mr-2" /> - {isMultipleSelected && isCurrentElementSelected - ? `Copy ${selectedElements.length} elements` - : "Copy element"} - </ContextMenuItem> + return ( + <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}> + {isMuted ? "Unmute" : "Mute"} + </ActionMenuItem> + ); +} - <ContextMenuItem onClick={handleToggleElementContext}> - {isMultipleSelected && isCurrentElementSelected ? ( - hasAudioElements ? ( - <VolumeX className="h-4 w-4 mr-2" /> - ) : ( - <EyeOff className="h-4 w-4 mr-2" /> - ) - ) : hasAudio ? ( - isMuted ? ( - <Volume2 className="h-4 w-4 mr-2" /> - ) : ( - <VolumeX className="h-4 w-4 mr-2" /> - ) - ) : element.hidden ? ( - <Eye className="h-4 w-4 mr-2" /> - ) : ( - <EyeOff className="h-4 w-4 mr-2" /> - )} - <span> - {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")} - </span> - </ContextMenuItem> +function VisibilityMenuItem({ + element, + isMultipleSelected, + isCurrentElementSelected, +}: { + element: TimelineElementType; + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; +}) { + const isHidden = canElementBeHidden(element) && element.hidden; - {!isMultipleSelected && ( - <ContextMenuItem onClick={handleElementDuplicateContext}> - <Copy className="h-4 w-4 mr-2" /> - Duplicate {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - )} + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={ViewOffSlashIcon} />; + } + return isHidden ? ( + <HugeiconsIcon icon={ViewIcon} /> + ) : ( + <HugeiconsIcon icon={ViewOffSlashIcon} /> + ); + }; - {!isMultipleSelected && element.type === "media" && ( - <> - <ContextMenuItem onClick={handleRevealInMedia}> - <Search className="h-4 w-4 mr-2" /> - Reveal in media - </ContextMenuItem> - <ContextMenuItem onClick={handleReplaceClip}> - <RefreshCw className="h-4 w-4 mr-2" /> - Replace clip - </ContextMenuItem> - </> - )} + return ( + <ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}> + {isHidden ? "Show" : "Hide"} + </ActionMenuItem> + ); +} - <ContextMenuSeparator /> +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> + ); +} - <ContextMenuItem - onClick={handleElementDeleteContext} - className="text-destructive focus:text-destructive" - > - <Trash2 className="h-4 w-4 mr-2" /> - {isMultipleSelected && isCurrentElementSelected - ? `Delete ${selectedElements.length} elements` - : `Delete ${element.type === "text" ? "text" : "clip"}`} - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ); +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> + ); } |
