diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/timeline-element.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/timeline-element.tsx | 266 |
1 files changed, 169 insertions, 97 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 7ad63692..38cf953b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -46,6 +46,7 @@ import { Search01Icon, Exchange01Icon, KeyframeIcon, + MagicWand05Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; @@ -59,7 +60,6 @@ interface KeyframeIndicator { time: number; offsetPx: number; keyframes: SelectedKeyframeRef[]; - isSelected: boolean; } function buildKeyframeIndicator({ @@ -69,7 +69,6 @@ function buildKeyframeIndicator({ displayedStartTime, zoomLevel, elementLeft, - isKeyframeSelected, }: { keyframe: ElementKeyframe; trackId: string; @@ -77,16 +76,10 @@ function buildKeyframeIndicator({ displayedStartTime: number; zoomLevel: number; elementLeft: number; - isKeyframeSelected: ({ - keyframe, - }: { - keyframe: SelectedKeyframeRef; - }) => boolean; }): { time: number; offsetPx: number; keyframeRef: SelectedKeyframeRef; - isSelected: boolean; } { const keyframeRef = { trackId, @@ -102,7 +95,6 @@ function buildKeyframeIndicator({ time: keyframe.time, offsetPx: keyframeLeft - elementLeft, keyframeRef, - isSelected: isKeyframeSelected({ keyframe: keyframeRef }), }; } @@ -114,7 +106,6 @@ function getKeyframeIndicators({ zoomLevel, elementLeft, elementWidth, - isKeyframeSelected, }: { keyframes: ElementKeyframe[]; trackId: string; @@ -123,11 +114,6 @@ function getKeyframeIndicators({ zoomLevel: number; elementLeft: number; elementWidth: number; - isKeyframeSelected: ({ - keyframe, - }: { - keyframe: SelectedKeyframeRef; - }) => boolean; }): KeyframeIndicator[] { if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { return []; @@ -142,7 +128,6 @@ function getKeyframeIndicators({ displayedStartTime, zoomLevel, elementLeft, - isKeyframeSelected, }); const existingIndicator = keyframesByTime.get(indicator.time); if (!existingIndicator) { @@ -150,14 +135,11 @@ function getKeyframeIndicators({ time: indicator.time, offsetPx: indicator.offsetPx, keyframes: [indicator.keyframeRef], - isSelected: indicator.isSelected, }); continue; } existingIndicator.keyframes.push(indicator.keyframeRef); - existingIndicator.isSelected = - existingIndicator.isSelected || indicator.isSelected; } return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); @@ -187,6 +169,7 @@ interface TimelineElementProps { ) => void; onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; + isDropTarget?: boolean; } export function TimelineElement({ @@ -199,10 +182,10 @@ export function TimelineElement({ onElementMouseDown, onElementClick, dragState, + isDropTarget = false, }: TimelineElementProps) { const editor = useEditor(); const { selectedElements } = useElementSelection(); - const { isKeyframeSelected } = useKeyframeSelection(); const { requestRevealMedia } = useAssetsPanelStore(); const mediaAssets = editor.media.getAssets(); @@ -248,16 +231,17 @@ export function TimelineElement({ time: displayedStartTime, zoomLevel, }); - const keyframeIndicators = getKeyframeIndicators({ - keyframes: getElementKeyframes({ animations: element.animations }), - trackId: track.id, - elementId: element.id, - displayedStartTime, - zoomLevel, - elementLeft, - elementWidth, - isKeyframeSelected, - }); + const keyframeIndicators = isSelected + ? getKeyframeIndicators({ + keyframes: getElementKeyframes({ animations: element.animations }), + trackId: track.id, + elementId: element.id, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + }) + : []; const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -291,8 +275,9 @@ export function TimelineElement({ onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} + isDropTarget={isDropTarget} /> - <KeyframeIndicators indicators={keyframeIndicators} /> + {isSelected && <KeyframeIndicators indicators={keyframeIndicators} />} </div> </ContextMenuTrigger> <ContextMenuContent className="w-64"> @@ -365,6 +350,7 @@ function ElementInner({ onElementClick, onElementMouseDown, handleResizeStart, + isDropTarget = false, }: { element: TimelineElementType; track: TimelineTrack; @@ -382,14 +368,20 @@ function ElementInner({ elementId: string; side: "left" | "right"; }) => void; + isDropTarget?: boolean; }) { + const opacityClass = + (canElementBeHidden(element) && element.hidden) || isDropTarget + ? "opacity-50" + : ""; + return ( <div className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( { type: track.type, }, - )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + )} ${opacityClass}`} > <button type="button" @@ -473,9 +465,11 @@ function KeyframeIndicators({ }: { indicators: KeyframeIndicator[]; }) { - const { toggleKeyframeSelection, selectKeyframeRange } = + const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } = useKeyframeSelection(); - const orderedKeyframes = indicators.flatMap((indicator) => indicator.keyframes); + const orderedKeyframes = indicators.flatMap( + (indicator) => indicator.keyframes, + ); const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => { event.preventDefault(); @@ -505,76 +499,103 @@ function KeyframeIndicators({ }); }; - return indicators.map((indicator) => ( - <button - key={indicator.time} - type="button" - className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer" - style={{ left: indicator.offsetPx }} - onMouseDown={(event) => handleKeyframeMouseDown({ event })} - onClick={(event) => - handleKeyframeClick({ event, keyframes: indicator.keyframes }) - } - aria-label="Select keyframe" - > - <HugeiconsIcon - icon={KeyframeIcon} - className={cn( - "size-3.5 text-black", - indicator.isSelected ? "fill-primary" : "fill-white", - )} - strokeWidth={1.5} - /> - </button> - )); + return indicators.map((indicator) => { + const isIndicatorSelected = indicator.keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + + return ( + <button + key={indicator.time} + type="button" + className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer" + style={{ left: indicator.offsetPx }} + onMouseDown={(event) => handleKeyframeMouseDown({ event })} + onClick={(event) => + handleKeyframeClick({ event, keyframes: indicator.keyframes }) + } + aria-label="Select keyframe" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn( + "size-3.5 text-black", + isIndicatorSelected ? "fill-primary" : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + ); + }); } -function ElementContent({ - element, - track, - isSelected, - mediaAssets, -}: { +interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; mediaAssets: MediaAsset[]; -}) { - if (element.type === "text") { +} + +type ElementContentRenderer = (props: ElementContentProps) => ReactNode; + +const ELEMENT_CONTENT_RENDERERS: Record< + TimelineElementType["type"], + ElementContentRenderer +> = { + text: ({ element }) => { + const textElement = element as 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 className="flex size-full items-center justify-start pl-3"> + <span className="truncate text-xs text-white"> + {textElement.content} + </span> </div> ); - } - - if (element.type === "sticker") { + }, + effect: ({ element }) => ( + <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> + ), + sticker: ({ element }) => { + const stickerElement = element as Extract< + TimelineElementType, + { type: "sticker" } + >; return ( <div className="flex size-full items-center gap-2 pl-2"> <Image src={resolveStickerId({ - stickerId: element.stickerId, + stickerId: stickerElement.stickerId, options: { width: 20, height: 20 }, })} - alt={element.name} + alt={stickerElement.name} className="size-5 shrink-0" width={20} height={20} unoptimized /> - <span className="truncate text-xs text-white">{element.name}</span> + <span className="truncate text-xs text-white"> + {stickerElement.name} + </span> </div> ); - } - - if (element.type === "audio") { + }, + audio: ({ element, mediaAssets }) => { + const audioElement = element as Extract< + TimelineElementType, + { type: "audio" } + >; const audioBuffer = - element.sourceType === "library" ? element.buffer : undefined; - + audioElement.sourceType === "library" ? audioElement.buffer : undefined; const audioUrl = - element.sourceType === "library" - ? element.sourceUrl - : mediaAssets.find((asset) => asset.id === element.mediaId)?.url; + audioElement.sourceType === "library" + ? audioElement.sourceUrl + : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url; if (audioBuffer || audioUrl) { return ( @@ -593,28 +614,78 @@ function ElementContent({ return ( <span className="text-foreground/80 truncate text-xs"> - {element.name} + {audioElement.name} </span> ); - } + }, + video: ({ element, track, isSelected, mediaAssets }) => { + const videoElement = element as Extract< + TimelineElementType, + { type: "video" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === videoElement.mediaId, + ); + + if (!mediaAsset) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {videoElement.name} + </span> + ); + } + + if (mediaAsset.thumbnailUrl) { + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + + 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: `url(${mediaAsset.thumbnailUrl})`, + 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> + ); + } - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - if (!mediaAsset) { return ( <span className="text-foreground/80 truncate text-xs"> - {element.name} + {videoElement.name} </span> ); - } + }, + image: ({ element, track, isSelected, mediaAssets }) => { + const imageElement = element as Extract< + TimelineElementType, + { type: "image" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === imageElement.mediaId, + ); + + if (!mediaAsset?.url) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {imageElement.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"> @@ -624,7 +695,7 @@ function ElementContent({ <div className="absolute right-0 left-0" style={{ - backgroundImage: imageUrl ? `url(${imageUrl})` : "none", + backgroundImage: `url(${mediaAsset.url})`, backgroundRepeat: "repeat-x", backgroundSize: `${tileWidth}px ${trackHeight}px`, backgroundPosition: "left center", @@ -636,11 +707,12 @@ function ElementContent({ </div> </div> ); - } + }, +}; - return ( - <span className="text-foreground/80 truncate text-xs">{element.name}</span> - ); +function ElementContent(props: ElementContentProps) { + const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type]; + return <>{renderer(props)}</>; } function CopyMenuItem() { |
