diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline')
9 files changed, 582 insertions, 213 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index 64824f9a..864bb557 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -5,10 +5,7 @@ import type { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; -import { - DEFAULT_BOOKMARK_COLOR, - TIMELINE_CONSTANTS, -} from "@/constants/timeline-constants"; +import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { getSnappedSeekTime } from "@/lib/time"; import { @@ -28,8 +25,8 @@ import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; +import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline"; -const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; const BOOKMARK_MARKER_HEIGHT_PX = 15; @@ -139,10 +136,12 @@ function TimelineBookmark({ const time = bookmark.time; const bookmarkDuration = bookmark.duration ?? 0; const durationWidth = - bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0; + bookmarkDuration > 0 + ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) + : 0; const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX; const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0); - const left = displayTime * PIXELS_PER_SECOND * zoomLevel; + const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel }); const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR; diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index f31c6e28..291a4633 100644 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -21,7 +21,7 @@ export function DragLine({ return ( <div - className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5" + className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5" style={{ top: `${lineTop}px` }} /> ); diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 18f768a9..593976a8 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -27,7 +27,7 @@ import type { SnapPoint } from "@/lib/timeline/snap-utils"; import type { TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS, - TRACK_ICONS, + TRACK_CONFIG, } from "@/constants/timeline-constants"; import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; import { @@ -55,16 +55,18 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/lib/actions"; +const TRACKS_CONTAINER_MAX_HEIGHT = 800; +const FALLBACK_CONTAINER_WIDTH = 1000; + export function Timeline() { - const tracksContainerHeight = { min: 0, max: 800 }; - const { snappingEnabled } = useTimelineStore(); + const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); const { clearElementSelection, setElementSelection } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; const tracks = timeline.getTracks(); const seek = (time: number) => editor.playback.seek({ time }); - // refs const timelineRef = useRef<HTMLDivElement>(null); const timelineHeaderRef = useRef<HTMLDivElement>(null); const rulerRef = useRef<HTMLDivElement>(null); @@ -74,7 +76,6 @@ export function Timeline() { const playheadRef = useRef<HTMLDivElement>(null); const trackLabelsScrollRef = useRef<HTMLDivElement>(null); - // state const [isResizing, setIsResizing] = useState(false); const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( null, @@ -151,6 +152,7 @@ export function Timeline() { const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ containerRef: tracksContainerRef, headerRef: timelineHeaderRef, + tracksScrollRef, zoomLevel, }); @@ -161,6 +163,7 @@ export function Timeline() { shouldIgnoreClick, } = useSelectionBox({ containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, onSelectionComplete: (elements) => { setElementSelection({ elements }); }, @@ -168,7 +171,7 @@ export function Timeline() { zoomLevel, }); - const containerWidth = tracksContainerRef.current?.clientWidth || 1000; + const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const paddingPx = getTimelinePaddingPx({ @@ -326,7 +329,7 @@ export function Timeline() { <DragLine dropTarget={dropTarget} tracks={timeline.getTracks()} - isVisible={isDragOver} + isVisible={isDragOver && !dropTarget?.targetElement} headerHeight={timelineHeaderHeight} /> <DragLine @@ -421,7 +424,20 @@ export function Timeline() { {tracks.length === 0 ? ( <div /> ) : ( - tracks.map((track, index) => ( + [...tracks] + .map((track, index) => ({ track, index })) + .sort((a, b) => { + const aHasDragged = a.track.elements.some( + (element) => element.id === dragState.elementId, + ); + const bHasDragged = b.track.elements.some( + (element) => element.id === dragState.elementId, + ); + if (aHasDragged) return 1; + if (bHasDragged) return -1; + return 0; + }) + .map(({ track, index }) => ( <ContextMenu key={track.id}> <ContextMenuTrigger asChild> <div @@ -453,56 +469,61 @@ export function Timeline() { }} onTrackClick={handleTracksClick} shouldIgnoreClick={shouldIgnoreClick} + targetElementId={ + isDragOver + ? dropTarget?.targetElement?.elementId ?? null + : null + } /> </div> </ContextMenuTrigger> <ContextMenuContent className="w-40"> <ContextMenuItem icon={<HugeiconsIcon icon={TaskAdd02Icon} />} - onClick={(e) => { - e.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - </ContextMenuItem> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - timeline.toggleTrackMute({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={VolumeHighIcon} /> - <span> - {canTracktHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - timeline.toggleTrackVisibility({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={ViewIcon} /> - <span> - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - timeline.removeTrack({ - trackId: track.id, - }); - }} + onClick={(event) => { + event.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + </ContextMenuItem> + <ContextMenuItem + onClick={(event) => { + event.stopPropagation(); + timeline.toggleTrackMute({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={VolumeHighIcon} /> + <span> + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(event) => { + event.stopPropagation(); + timeline.toggleTrackVisibility({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={ViewIcon} /> + <span> + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(event) => { + event.stopPropagation(); + timeline.removeTrack({ + trackId: track.id, + }); + }} variant="destructive" > <HugeiconsIcon icon={Delete02Icon} /> @@ -523,7 +544,7 @@ export function Timeline() { } function TrackIcon({ track }: { track: TimelineTrack }) { - return <>{TRACK_ICONS[track.type]}</>; + return <>{TRACK_CONFIG[track.type].icon}</>; } function TrackToggleIcon({ diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 54ce5237..927d336a 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -2,6 +2,10 @@ import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, +} from "@/lib/timeline"; import type { TimelineTrack } from "@/types/timeline"; interface SnapIndicatorProps { @@ -40,10 +44,10 @@ export function SnapIndicator({ <div className="pointer-events-none absolute" style={{ - left: `${leftPosition}px`, + left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`, top: topPosition, height: `${height}px`, - width: "2px", + width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, }} > <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> 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 657e9a8b..d5b8cade 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -4,14 +4,21 @@ 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 { + useKeyframeDrag, + type KeyframeDragState, +} from "@/hooks/timeline/element/use-keyframe-drag"; +import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getElementKeyframes } from "@/lib/animation"; import { getTrackClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, hasMediaId, + timelineTimeToPixels, + timelineTimeToSnappedPixels, } from "@/lib/timeline"; import { ContextMenu, @@ -19,10 +26,11 @@ import { ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from "../../../ui/context-menu"; +} from "@/components/ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, + VisualElement, ElementDragState, } from "@/types/timeline"; import type { MediaAsset } from "@/types/assets"; @@ -42,13 +50,111 @@ import { VolumeMute02Icon, Search01Icon, Exchange01Icon, + KeyframeIcon, + MagicWand05Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; +import { cn } from "@/utils/ui"; +import { Button } from "@/components/ui/button"; +import { usePropertiesStore } from "@/stores/properties-store"; + +const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; +const ELEMENT_RING_WIDTH_PX = 1.5; + +interface KeyframeIndicator { + time: number; + offsetPx: number; + keyframes: SelectedKeyframeRef[]; +} + +export function buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, +}: { + keyframe: ElementKeyframe; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; +}): { + time: number; + offsetPx: number; + keyframeRef: SelectedKeyframeRef; +} { + const keyframeRef = { + trackId, + elementId, + propertyPath: keyframe.propertyPath, + keyframeId: keyframe.id, + }; + const keyframeLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime + keyframe.time, + zoomLevel, + }); + return { + time: keyframe.time, + offsetPx: keyframeLeft - elementLeft, + keyframeRef, + }; +} -function getDisplayShortcut(action: TAction) { - const { defaultShortcuts } = getActionDefinition(action); +export function getKeyframeIndicators({ + keyframes, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, +}: { + keyframes: ElementKeyframe[]; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + elementWidth: number; +}): KeyframeIndicator[] { + if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { + return []; + } + + const keyframesByTime = new Map<number, KeyframeIndicator>(); + for (const keyframe of keyframes) { + const indicator = buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + }); + const existingIndicator = keyframesByTime.get(indicator.time); + if (!existingIndicator) { + keyframesByTime.set(indicator.time, { + time: indicator.time, + offsetPx: indicator.offsetPx, + keyframes: [indicator.keyframeRef], + }); + continue; + } + + existingIndicator.keyframes.push(indicator.keyframeRef); + } + + return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); +} + +export function getDisplayShortcut({ action }: { action: TAction }) { + const { defaultShortcuts } = getActionDefinition({ action }); if (!defaultShortcuts?.length) { return ""; } @@ -66,11 +172,15 @@ interface TimelineElementProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onResizeStateChange?: (params: { isResizing: boolean }) => void; onElementMouseDown: ( - e: React.MouseEvent, + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: ( + event: React.MouseEvent, element: TimelineElementType, ) => void; - onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; + isDropTarget?: boolean; } export function TimelineElement({ @@ -83,17 +193,18 @@ export function TimelineElement({ onElementMouseDown, onElementClick, dragState, + isDropTarget = false, }: 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; + editor.media.getAssets().find((asset) => asset.id === element.mediaId) ?? + null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); @@ -123,10 +234,32 @@ export function TimelineElement({ : 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 elementWidth = timelineTimeToPixels({ + time: displayedDuration, + zoomLevel, + }); + const elementLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime, + zoomLevel, + }); + const keyframeIndicators = isSelected + ? getKeyframeIndicators({ + keyframes: getElementKeyframes({ animations: element.animations }), + trackId: track.id, + elementId: element.id, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + }) + : []; + const { + keyframeDragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + } = useKeyframeDrag({ zoomLevel, element, displayedStartTime }); const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -140,7 +273,7 @@ export function TimelineElement({ <ContextMenu> <ContextMenuTrigger asChild> <div - className={`absolute top-0 h-full select-none`} + className="absolute top-0 h-full select-none" style={{ left: `${elementLeft}px`, width: `${elementWidth}px`, @@ -154,13 +287,24 @@ export function TimelineElement({ element={element} track={track} isSelected={isSelected} - hasAudio={hasAudio} - isMuted={isMuted} - mediaAssets={mediaAssets} onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} + isDropTarget={isDropTarget} /> + {isSelected && ( + <div className="pointer-events-none absolute inset-0 overflow-hidden"> + <KeyframeIndicators + indicators={keyframeIndicators} + dragState={keyframeDragState} + displayedStartTime={displayedStartTime} + elementLeft={elementLeft} + onKeyframeMouseDown={handleKeyframeMouseDown} + onKeyframeClick={handleKeyframeClick} + getVisualOffsetPx={getVisualOffsetPx} + /> + </div> + )} </div> </ContextMenuTrigger> <ContextMenuContent className="w-64"> @@ -197,7 +341,9 @@ export function TimelineElement({ <> <ContextMenuItem icon={<HugeiconsIcon icon={Search01Icon} />} - onClick={(event) => handleRevealInMedia({ event })} + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } > Reveal media </ContextMenuItem> @@ -225,22 +371,20 @@ function ElementInner({ element, track, isSelected, - hasAudio, - isMuted, - mediaAssets, onElementClick, onElementMouseDown, handleResizeStart, + isDropTarget = false, }: { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; - hasAudio: boolean; - isMuted: boolean; - mediaAssets: MediaAsset[]; - onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + onElementClick: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; onElementMouseDown: ( - e: React.MouseEvent, + event: React.MouseEvent, element: TimelineElementType, ) => void; handleResizeStart: (params: { @@ -248,48 +392,62 @@ function ElementInner({ elementId: string; side: "left" | "right"; }) => void; + isDropTarget?: boolean; }) { + const opacityClass = + (canElementBeHidden(element) && element.hidden) || isDropTarget + ? "opacity-50" + : ""; + const closeClipEffects = usePropertiesStore( + (state) => state.closeClipEffects, + ); + return ( <div - className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( - { - type: track.type, - }, - )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + className="relative h-full cursor-pointer" + style={{ marginInline: ELEMENT_RING_WIDTH_PX }} > - <button - type="button" - className="absolute inset-0 size-full cursor-pointer" - onClick={(e) => onElementClick(e, element)} - onMouseDown={(e) => onElementMouseDown(e, element)} + <div + className={cn( + "absolute inset-0 overflow-hidden rounded-sm", + getTrackClasses({ type: track.type }), + opacityClass, + )} + style={ + isSelected + ? { + boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`, + } + : undefined + } > - <div className="absolute inset-0 flex h-full items-center"> - <ElementContent - element={element} - track={track} - isSelected={isSelected} - mediaAssets={mediaAssets} + <button + type="button" + className="absolute inset-0 size-full cursor-pointer flex flex-col" + onClick={(event) => { + closeClipEffects(); + onElementClick(event, element); + }} + onMouseDown={(event) => onElementMouseDown(event, element)} + > + <div className="flex flex-1 min-h-0 items-center overflow-hidden"> + <ElementContent + element={element} + track={track} + isSelected={isSelected} + /> + </div> + </button> + </div> + + {element.type !== "audio" && element.type !== "effect" && ( + <div className="sticky left-1 mt-1 ml-1 w-fit"> + <EffectsButton + element={element as VisualElement} + trackId={track.id} /> </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 && ( <> @@ -326,61 +484,242 @@ function ResizeHandle({ return ( <button type="button" - className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} + className={cn( + "absolute top-0 bottom-0 w-2", + isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize", + )} onMouseDown={(event) => handleResizeStart({ event, elementId, side })} + onClick={(event) => event.stopPropagation()} aria-label={`${isLeft ? "Left" : "Right"} resize handle`} - > - <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> - </button> + ></button> ); } -function ElementContent({ - element, - track, - isSelected, - mediaAssets, +function KeyframeIndicators({ + indicators, + dragState, + displayedStartTime, + elementLeft, + onKeyframeMouseDown, + onKeyframeClick, + getVisualOffsetPx, }: { + indicators: KeyframeIndicator[]; + dragState: KeyframeDragState; + displayedStartTime: number; + elementLeft: number; + onKeyframeMouseDown: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => void; + onKeyframeClick: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }) => void; + getVisualOffsetPx: (params: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }) => number; +}) { + const { isKeyframeSelected } = useKeyframeSelection(); + const orderedKeyframes = indicators.flatMap( + (indicator) => indicator.keyframes, + ); + + return indicators.map((indicator) => { + const isIndicatorSelected = indicator.keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + const isBeingDragged = indicator.keyframes.some((kf) => + dragState.draggingKeyframeIds.has(kf.keyframeId), + ); + const visualOffsetPx = getVisualOffsetPx({ + indicatorTime: indicator.time, + indicatorOffsetPx: indicator.offsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }); + + return ( + <button + key={indicator.time} + type="button" + className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab" + style={{ left: visualOffsetPx }} + onMouseDown={(event) => + onKeyframeMouseDown({ event, keyframes: indicator.keyframes }) + } + onClick={(event) => + onKeyframeClick({ + event, + keyframes: indicator.keyframes, + orderedKeyframes, + indicatorTime: indicator.time, + }) + } + aria-label="Select keyframe" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn( + "size-3.5 mt-1.5 text-black", + isIndicatorSelected ? "fill-primary" : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + ); + }); +} + +interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; +} + +interface ElementContentRendererProps extends ElementContentProps { mediaAssets: MediaAsset[]; +} + +type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode; + +export function renderTiledMedia({ + element, + imageUrl, + track, +}: { + element: VisualElement; + imageUrl: string | undefined; + track: ElementContentProps["track"]; +}): ReactNode { + if (!imageUrl) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + + return ( + <div + className="absolute inset-0" + style={{ + backgroundImage: `url(${imageUrl})`, + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + /> + ); +} + +function EffectsButton({ + element, + trackId, + className, +}: { + element: VisualElement; + trackId: string; + className?: string; }) { - if (element.type === "text") { + const openClipEffects = usePropertiesStore((state) => state.openClipEffects); + const { selectElement } = useElementSelection(); + + if (!element.effects?.length) { + return null; + } + + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + selectElement({ elementId: element.id, trackId }); + openClipEffects({ elementId: element.id, trackId }); + }; + + return ( + <Button + variant="text" + size="icon" + className={cn("rounded-sm !size-5 bg-background/50", className)} + onClick={handleClick} + onMouseDown={(event) => event.stopPropagation()} + > + <HugeiconsIcon icon={MagicWand05Icon} /> + </Button> + ); +} + +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> + <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 ml-1">{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 ( @@ -399,53 +738,52 @@ function ElementContent({ return ( <span className="text-foreground/80 truncate text-xs"> - {element.name} + {audioElement.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> + }, + video: ({ element, track, mediaAssets }) => { + const videoElement = element as Extract< + TimelineElementType, + { type: "video" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === videoElement.mediaId, ); - } - - 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 renderTiledMedia({ + element: videoElement, + imageUrl: mediaAsset?.thumbnailUrl, + track, + }); + }, + image: ({ element, track, mediaAssets }) => { + const imageElement = element as Extract< + TimelineElementType, + { type: "image" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === imageElement.mediaId, ); - } + return renderTiledMedia({ + element: imageElement, + imageUrl: mediaAsset?.url, + track, + }); + }, +}; +function ElementContent({ element, track, isSelected }: ElementContentProps) { + const editor = useEditor(); + const renderer = ELEMENT_CONTENT_RENDERERS[element.type]; return ( - <span className="text-foreground/80 truncate text-xs">{element.name}</span> + <> + {renderer({ + element, + track, + isSelected, + mediaAssets: editor.media.getAssets(), + })} + </> ); } @@ -474,9 +812,9 @@ function MuteMenuItem({ return <HugeiconsIcon icon={VolumeMute02Icon} />; } return isMuted ? ( - <HugeiconsIcon icon={VolumeHighIcon} /> - ) : ( <HugeiconsIcon icon={VolumeOffIcon} /> + ) : ( + <HugeiconsIcon icon={VolumeHighIcon} /> ); }; @@ -549,14 +887,15 @@ function ActionMenuItem({ ...props }: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { action: TAction; + children: ReactNode; }) { return ( <ContextMenuItem - onClick={(event) => { + onClick={(event: React.MouseEvent) => { event.stopPropagation(); invokeAction(action); }} - textRight={getDisplayShortcut(action)} + textRight={getDisplayShortcut({ action })} {...props} > {children} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index ebb2a37e..e290259e 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -1,7 +1,11 @@ "use client"; import { useRef } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, + timelineTimeToSnappedPixels, +} from "@/lib/timeline"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useEditor } from "@/hooks/use-editor"; @@ -43,9 +47,11 @@ export function TimelinePlayhead({ 400; const totalHeight = Math.max(0, timelineContainerHeight - 4); - const timelinePosition = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const leftPosition = timelinePosition; + const centerPosition = timelineTimeToSnappedPixels({ + time: playheadPosition, + zoomLevel, + }); + const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); const handlePlayheadKeyDown = ( event: React.KeyboardEvent<HTMLDivElement>, @@ -77,7 +83,7 @@ export function TimelinePlayhead({ left: `${leftPosition}px`, top: 0, height: `${totalHeight}px`, - width: "2px", + width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, }} onKeyDown={handlePlayheadKeyDown} > diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 9402d25e..22cd52cf 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx @@ -1,6 +1,6 @@ "use client"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; interface TimelineTickProps { @@ -16,7 +16,7 @@ export function TimelineTick({ fps, showLabel, }: TimelineTickProps) { - const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel }); if (showLabel) { const label = formatRulerLabel({ timeInSeconds: time, fps }); diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 03ce88eb..533ea63d 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -78,7 +78,7 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); const currentTime = editor.playback.getCurrentTime(); - const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime }); const handleAction = ({ action, @@ -116,7 +116,7 @@ function ToolbarLeftSection() { <ToolbarButton icon={<SplitSquareHorizontal />} - tooltip="Coming soon" /* separate audio */ + tooltip="Separate audio (coming soon)" disabled={true} onClick={({ event: _event }) => {}} /> @@ -131,7 +131,7 @@ function ToolbarLeftSection() { <ToolbarButton icon={<HugeiconsIcon icon={SnowIcon} />} - tooltip="Coming soon" /* freeze frame */ + tooltip="Freeze frame (coming soon)" disabled={true} onClick={({ event: _event }) => {}} /> @@ -149,8 +149,8 @@ function ToolbarLeftSection() { <Tooltip> <ToolbarButton icon={<HugeiconsIcon icon={Bookmark02Icon} />} - isActive={currentBookmarked} - tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} + isActive={isCurrentlyBookmarked} + tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} onClick={({ event }) => handleAction({ action: "toggle-bookmark", event }) } @@ -191,12 +191,10 @@ function ToolbarRightSection({ onZoomChange: (zoom: number) => void; onZoom: (options: { direction: "in" | "out" }) => void; }) { - const { - snappingEnabled, - rippleEditingEnabled, - toggleSnapping, - toggleRippleEditing, - } = useTimelineStore(); + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); + const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); + const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); return ( <div className="flex items-center gap-1"> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 42faadaf..81cda7a6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -32,6 +32,7 @@ interface TimelineTrackContentProps { onTrackMouseDown?: (event: React.MouseEvent) => void; onTrackClick?: (event: React.MouseEvent) => void; shouldIgnoreClick?: () => boolean; + targetElementId?: string | null; } export function TimelineTrackContent({ @@ -48,9 +49,10 @@ export function TimelineTrackContent({ onTrackMouseDown, onTrackClick, shouldIgnoreClick, + targetElementId = null, }: TimelineTrackContentProps) { const editor = useEditor(); - const { isElementSelected, clearElementSelection } = useElementSelection(); + const { isElementSelected } = useElementSelection(); const duration = editor.timeline.getTotalDuration(); @@ -67,7 +69,6 @@ export function TimelineTrackContent({ className="size-full" onClick={(event) => { if (shouldIgnoreClick?.()) return; - clearElementSelection(); onTrackClick?.(event); }} onMouseDown={(event) => { @@ -102,6 +103,7 @@ export function TimelineTrackContent({ onElementClick({ event, element, track }) } dragState={dragState} + isDropTarget={element.id === targetElementId} /> ); }) |
