From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- .../editor/panels/timeline/audio-waveform.tsx | 281 ++++--- .../editor/panels/timeline/bookmarks.tsx | 27 +- .../editor/panels/timeline/drag-line.tsx | 5 +- .../components/editor/panels/timeline/index.tsx | 885 +++++++++++++-------- .../editor/panels/timeline/snap-indicator.tsx | 10 +- .../editor/panels/timeline/timeline-element.tsx | 472 ++++++----- .../editor/panels/timeline/timeline-playhead.tsx | 38 +- .../editor/panels/timeline/timeline-ruler.tsx | 26 +- .../editor/panels/timeline/timeline-tick.tsx | 4 +- .../editor/panels/timeline/timeline-toolbar.tsx | 16 +- .../editor/panels/timeline/timeline-track.tsx | 62 +- 11 files changed, 1072 insertions(+), 754 deletions(-) (limited to 'apps/web/src/components/editor/panels/timeline') diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx index bbc5cb83..cbb19bd0 100644 --- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx +++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx @@ -1,176 +1,167 @@ -import { useEffect, useRef, useState } from "react"; -import WaveSurfer from "wavesurfer.js"; +"use client"; +import { useCallback, useEffect, useRef } from "react"; +import { useResizeObserver } from "@/hooks/use-resize-observer"; +import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio"; +import { findScrollParent } from "@/utils/browser"; +import { cn } from "@/utils/ui"; + +const BAR_WIDTH = 2; +const BAR_GAP = 1; +const BAR_STEP = BAR_WIDTH + BAR_GAP; interface AudioWaveformProps { audioUrl?: string; audioBuffer?: AudioBuffer; - height?: number; + color?: string; className?: string; } -function extractPeaks({ - buffer, - length = 512, -}: { - buffer: AudioBuffer; - length?: number; -}): number[][] { - const channels = buffer.numberOfChannels; - const peaks: number[][] = []; - - for (let c = 0; c < channels; c++) { - const data = buffer.getChannelData(c); - const step = Math.floor(data.length / length); - const channelPeaks: number[] = []; - - for (let i = 0; i < length; i++) { - const start = i * step; - const end = Math.min(start + step, data.length); - let max = 0; - for (let j = start; j < end; j++) { - const abs = Math.abs(data[j]); - if (abs > max) max = abs; - } - channelPeaks.push(max); - } - peaks.push(channelPeaks); - } - - return peaks; -} - export function AudioWaveform({ audioUrl, audioBuffer, - height = 32, + color = "rgba(255, 255, 255, 0.7)", className = "", }: AudioWaveformProps) { - const waveformRef = useRef(null); - const wavesurfer = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(false); + const canvasRef = useRef(null); + const containerRef = useRef(null); + const bufferRef = useRef(null); + const globalMaxRef = useRef(1); + const scrollParentRef = useRef(null); + const heightRef = useRef(0); + + const drawVisible = useCallback(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + const buffer = bufferRef.current; + const height = heightRef.current; + + if (!container || !canvas || !buffer || height <= 0) return; + + const elementWidth = container.offsetWidth; + if (elementWidth <= 0) return; + + const containerRect = container.getBoundingClientRect(); + const scrollParent = scrollParentRef.current; + + let clipLeft: number; + let clipRight: number; + + if (scrollParent) { + const parentRect = scrollParent.getBoundingClientRect(); + clipLeft = Math.max(0, parentRect.left - containerRect.left); + clipRight = Math.min(elementWidth, parentRect.right - containerRect.left); + } else { + clipLeft = Math.max(0, -containerRect.left); + clipRight = Math.min( + elementWidth, + window.innerWidth - containerRect.left, + ); + } - useEffect(() => { - let mounted = true; - const ws = wavesurfer.current; + const visibleWidth = clipRight - clipLeft; + if (visibleWidth <= 0) return; - const initWaveSurfer = async () => { - if (!waveformRef.current || (!audioUrl && !audioBuffer)) return; + const dpr = window.devicePixelRatio || 1; + const canvasW = Math.round(visibleWidth * dpr); + const canvasH = Math.round(height * dpr); - try { - if (ws) { - wavesurfer.current = null; - } + if (canvasW <= 0 || canvasH <= 0) return; - const newWaveSurfer = WaveSurfer.create({ - container: waveformRef.current, - waveColor: "rgba(255, 255, 255, 0.6)", - progressColor: "rgba(255, 255, 255, 0.9)", - cursorColor: "transparent", - barWidth: 2, - barGap: 1, - height, - normalize: true, - interact: false, - }); - - if (mounted) { - wavesurfer.current = newWaveSurfer; - } else { - try { - newWaveSurfer.destroy(); - } catch {} - return; - } + canvas.width = canvasW; + canvas.height = canvasH; + canvas.style.width = `${visibleWidth}px`; + canvas.style.height = `${height}px`; + canvas.style.left = `${clipLeft}px`; - newWaveSurfer.on("ready", () => { - if (mounted) { - setIsLoading(false); - setError(false); - } - }); - - newWaveSurfer.on("error", (err) => { - if (mounted) { - console.error("WaveSurfer error:", err); - setError(true); - setIsLoading(false); - } - }); - - if (audioBuffer) { - const peaks = extractPeaks({ buffer: audioBuffer }); - newWaveSurfer.load("", peaks, audioBuffer.duration); - } else if (audioUrl) { - await newWaveSurfer.load(audioUrl); - } - } catch (err) { - if (mounted) { - console.error("Failed to initialize WaveSurfer:", err); - setError(true); - setIsLoading(false); - } - } - }; + const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); + const startFraction = clipLeft / elementWidth; + const endFraction = clipRight / elementWidth; + const startSample = Math.floor(startFraction * buffer.length); + const endSample = Math.min( + buffer.length, + Math.ceil(endFraction * buffer.length), + ); + + const peaks = extractRmsRange({ + buffer, + count: barCount, + startSample, + endSample, + globalMax: globalMaxRef.current, + }); + + const ctx = canvas.getContext("2d"); + if (!ctx) return; - if (ws) { - const wsToDestroy = ws; - wavesurfer.current = null; + ctx.clearRect(0, 0, canvasW, canvasH); + ctx.scale(dpr, dpr); + ctx.fillStyle = color; - requestAnimationFrame(() => { + const maxBarHeight = height * 0.7; + + for (let i = 0; i < barCount; i++) { + const scaled = Math.log1p(peaks[i]) / Math.log1p(1); + const barH = Math.max(1, scaled * maxBarHeight); + ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH); + } + }, [color]); + + useEffect(() => { + let isCancelled = false; + + async function load() { + let buffer = audioBuffer ?? null; + + if (!buffer && audioUrl) { try { - wsToDestroy.destroy(); - } catch {} - if (mounted) { - initWaveSurfer(); + const resp = await fetch(audioUrl); + const arrayBuffer = await resp.arrayBuffer(); + const actx = new AudioContext(); + buffer = await actx.decodeAudioData(arrayBuffer); + actx.close(); + } catch { + return; } - }); - } else { - initWaveSurfer(); + } + + if (!buffer || isCancelled) return; + + bufferRef.current = buffer; + globalMaxRef.current = computeGlobalMaxRms({ buffer }); + drawVisible(); } + load(); return () => { - mounted = false; + isCancelled = true; + }; + }, [audioUrl, audioBuffer, drawVisible]); - const wsToDestroy = wavesurfer.current; + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + scrollParentRef.current = findScrollParent({ element: container }); + const scrollParent = scrollParentRef.current; + if (!scrollParent) return; + + scrollParent.addEventListener("scroll", drawVisible, { passive: true }); + return () => scrollParent.removeEventListener("scroll", drawVisible); + }, [drawVisible]); + + const onResize = useCallback( + (entry: ResizeObserverEntry) => { + heightRef.current = entry.contentRect.height; + drawVisible(); + }, + [drawVisible], + ); - wavesurfer.current = null; - - if (wsToDestroy) { - requestAnimationFrame(() => { - try { - wsToDestroy.destroy(); - } catch {} - }); - } - }; - }, [audioUrl, audioBuffer, height]); - - if (error) { - return ( -
- Audio unavailable -
- ); - } + useResizeObserver({ ref: containerRef, onResize }); return ( -
- {isLoading && ( -
- Loading... -
- )} -
+
+
); } - -export default AudioWaveform; diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index 864bb557..7000f98b 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -5,7 +5,10 @@ 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 } from "@/constants/timeline-constants"; +import { + DEFAULT_BOOKMARK_COLOR, + TIMELINE_BOOKMARK_ROW_HEIGHT, +} from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { getSnappedSeekTime } from "@/lib/time"; import { @@ -13,7 +16,7 @@ import { Delete02Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import type { Bookmark } from "@/types/timeline"; +import type { Bookmark } from "@/lib/timeline"; import { Popover, PopoverAnchor, @@ -25,7 +28,10 @@ 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"; +import { + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/lib/timeline"; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; @@ -72,14 +78,17 @@ export function TimelineBookmarksRow({ handleRulerTrackingMouseDown, handleRulerMouseDown, }: TimelineBookmarksRowProps) { - const editor = useEditor(); - const activeScene = editor.scenes.getActiveScene(); + const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks); return ( -
+
-
- - {element.type !== "audio" && element.type !== "effect" && ( -
- +
- )} +
{isSelected && ( <> @@ -535,8 +533,8 @@ function KeyframeIndicators({ const isIndicatorSelected = indicator.keyframes.some((keyframe) => isKeyframeSelected({ keyframe }), ); - const isBeingDragged = indicator.keyframes.some((kf) => - dragState.draggingKeyframeIds.has(kf.keyframeId), + const isBeingDragged = indicator.keyframes.some((keyframe) => + dragState.draggingKeyframeIds.has(keyframe.keyframeId), ); const visualOffsetPx = getVisualOffsetPx({ indicatorTime: indicator.time, @@ -581,212 +579,244 @@ function KeyframeIndicators({ interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; - isSelected: boolean; } -interface ElementContentRendererProps extends ElementContentProps { - mediaAssets: MediaAsset[]; +function TextElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.content} +
+ ); +} + +function EffectElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ + {element.name} +
+ ); } -type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode; +function StickerElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.name} + {element.name} +
+ ); +} -export function renderTiledMedia({ +function GraphicElementContent({ element, - imageUrl, - track, }: { - element: VisualElement; - imageUrl: string | undefined; - track: ElementContentProps["track"]; -}): ReactNode { - if (!imageUrl) { + element: Extract; +}) { + return ( +
+ {element.name} + {element.name} +
+ ); +} + +function AudioElementContent({ element }: { element: AudioElement }) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + const mediaAsset = + element.sourceType === "upload" + ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) + : null; + + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; + const audioUrl = + element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; + const mediaLabel = mediaAsset?.name ?? element.name; + + if (audioBuffer || audioUrl) { return ( - - {element.name} - +
+ + +
); } - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - return ( -
+ {element.name} ); } function EffectsButton({ element, - trackId, - className, + track, }: { - element: VisualElement; - trackId: string; - className?: string; + element: VideoElement | ImageElement; + track: TimelineTrack; }) { - const openClipEffects = usePropertiesStore((state) => state.openClipEffects); - const { selectElement } = useElementSelection(); - - if (!element.effects?.length) { - return null; - } + const editor = useEditor(); + const setActiveTab = usePropertiesStore((s) => s.setActiveTab); const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); - selectElement({ elementId: element.id, trackId }); - openClipEffects({ elementId: element.id, trackId }); + editor.selection.setSelectedElements({ + elements: [{ trackId: track.id, elementId: element.id }], + }); + setActiveTab(element.type, "effects"); }; return ( - + + ); } -const ELEMENT_CONTENT_RENDERERS: Record< - TimelineElementType["type"], - ElementContentRenderer -> = { - text: ({ element }) => { - const textElement = element as Extract< - TimelineElementType, - { type: "text" } - >; - return ( -
- - {textElement.content} - -
- ); - }, - effect: ({ element }) => ( -
- - {element.name} -
- ), - sticker: ({ element }) => { - const stickerElement = element as Extract< - TimelineElementType, - { type: "sticker" } - >; - return ( -
- {stickerElement.name} - - {stickerElement.name} - -
- ); - }, - audio: ({ element, mediaAssets }) => { - const audioElement = element as Extract< - TimelineElementType, - { type: "audio" } - >; - const audioBuffer = - audioElement.sourceType === "library" ? audioElement.buffer : undefined; - const audioUrl = - audioElement.sourceType === "library" - ? audioElement.sourceUrl - : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url; - - if (audioBuffer || audioUrl) { - return ( -
-
- -
-
- ); - } +function TiledMediaContent({ + element, + track, +}: { + element: VideoElement | ImageElement; + track: TimelineTrack; +}) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + const imageUrl = + element.type === "video" + ? mediaAsset?.thumbnailUrl + : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); + if (!imageUrl) { return ( - {audioElement.name} + {element.name} ); - }, - video: ({ element, track, mediaAssets }) => { - const videoElement = element as Extract< - TimelineElementType, - { type: "video" } - >; - const mediaAsset = mediaAssets.find( - (asset) => asset.id === videoElement.mediaId, - ); - 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, - }); - }, -}; + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; -function ElementContent({ element, track, isSelected }: ElementContentProps) { - const editor = useEditor(); - const renderer = ELEMENT_CONTENT_RENDERERS[element.type]; return ( <> - {renderer({ - element, - track, - isSelected, - mediaAssets: editor.media.getAssets(), - })} +
+ + ) : null + } + hasFade={true} + /> ); } +function MediaElementHeader({ + name, + leading, + hasFade, +}: { + name?: string | null; + leading?: ReactNode; + hasFade?: boolean; +}) { + if (!name && !leading) { + return null; + } + + return ( +
+ {leading &&
{leading}
} + {name && ( + + {name} + + )} +
+ ); +} + +function ElementContent({ element, track }: ElementContentProps) { + switch (element.type) { + case "text": + return ; + case "effect": + return ; + case "sticker": + return ; + case "graphic": + return ; + case "audio": + return ; + case "video": + case "image": + return ; + } +} + function CopyMenuItem() { return ( , "onClick" | "textRight"> & { - action: TAction; + action: TActionWithOptionalArgs; children: ReactNode; }) { return ( 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 e290259e..1abe65bd 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -8,9 +8,14 @@ import { } from "@/lib/timeline"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useEditor } from "@/hooks/use-editor"; +import { + TIMELINE_LAYERS, + TIMELINE_SCROLLBAR_SIZE_PX, +} from "@/constants/timeline-constants"; interface TimelinePlayheadProps { zoomLevel: number; + hasHorizontalScrollbar: boolean; rulerRef: React.RefObject; rulerScrollRef: React.RefObject; tracksScrollRef: React.RefObject; @@ -21,6 +26,7 @@ interface TimelinePlayheadProps { export function TimelinePlayhead({ zoomLevel, + hasHorizontalScrollbar, rulerRef, rulerScrollRef, tracksScrollRef, @@ -33,7 +39,7 @@ export function TimelinePlayhead({ const internalPlayheadRef = useRef(null); const playheadRef = externalPlayheadRef || internalPlayheadRef; - const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + const { handlePlayheadMouseDown } = useTimelinePlayhead({ zoomLevel, rulerRef, rulerScrollRef, @@ -42,16 +48,23 @@ export function TimelinePlayhead({ }); const timelineContainerHeight = - tracksScrollRef.current?.clientHeight ?? timelineRef.current?.clientHeight ?? + tracksScrollRef.current?.clientHeight ?? 400; - const totalHeight = Math.max(0, timelineContainerHeight - 4); + const totalHeight = Math.max( + 0, + timelineContainerHeight - + (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0), + ); + const currentTime = editor.playback.getCurrentTime(); const centerPosition = timelineTimeToSnappedPixels({ - time: playheadPosition, + time: currentTime, zoomLevel, }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); + const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; + const leftPosition = + getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; const handlePlayheadKeyDown = ( event: React.KeyboardEvent, @@ -61,10 +74,8 @@ export function TimelinePlayhead({ event.preventDefault(); const step = 1 / Math.max(1, editor.project.getActive().settings.fps); const direction = event.key === "ArrowRight" ? 1 : -1; - const nextTime = Math.max( - 0, - Math.min(duration, playheadPosition + direction * step), - ); + const now = editor.playback.getCurrentTime(); + const nextTime = Math.max(0, Math.min(duration, now + direction * step)); editor.playback.seek({ time: nextTime }); }; @@ -76,23 +87,24 @@ export function TimelinePlayhead({ aria-label="Timeline playhead" aria-valuemin={0} aria-valuemax={duration} - aria-valuenow={playheadPosition} + aria-valuenow={currentTime} tabIndex={0} - className="pointer-events-none absolute z-5" + className="pointer-events-none absolute" style={{ left: `${leftPosition}px`, top: 0, height: `${totalHeight}px`, width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, + zIndex: TIMELINE_LAYERS.playhead, }} onKeyDown={handlePlayheadKeyDown} > -
+
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx index b0261aa2..09562f23 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx @@ -1,5 +1,8 @@ import { type JSX, useLayoutEffect, useRef } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + TIMELINE_CONSTANTS, + TIMELINE_RULER_HEIGHT, +} from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; @@ -27,13 +30,12 @@ export function TimelineRuler({ handleRulerTrackingMouseDown, handleRulerMouseDown, }: TimelineRulerProps) { - const editor = useEditor(); - const duration = editor.timeline.getTotalDuration(); + const duration = useEditor((e) => e.timeline.getTotalDuration()); const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const visibleDuration = dynamicTimelineWidth / pixelsPerSecond; const effectiveDuration = Math.max(duration, visibleDuration); - const project = editor.project.getActive(); - const fps = project?.settings.fps ?? DEFAULT_FPS; + const fps = + useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS; const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ zoomLevel, fps, @@ -104,17 +106,25 @@ export function TimelineRuler({ aria-valuemin={0} aria-valuemax={effectiveDuration} aria-valuenow={0} - className="relative h-4 flex-1 overflow-x-visible" + className="relative flex-1 overflow-x-visible" + style={{ height: TIMELINE_RULER_HEIGHT }} onWheel={handleWheel} - onClick={handleTimelineContentClick} + onClick={(event) => { + // Ruler seek already happens on mousedown via playhead scrubbing. + // Forwarding the follow-up click re-enters the selection-clearing path. + if (event.target === event.currentTarget) { + handleTimelineContentClick(event); + } + }} onMouseDown={handleRulerTrackingMouseDown} onKeyDown={() => {}} >
{label} @@ -32,7 +32,7 @@ export function TimelineTick({ return (
); 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 533ea63d..e785d147 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -16,8 +16,8 @@ import { import { Slider } from "@/components/ui/slider"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; -import { ScenesView } from "../../scenes-view"; -import { type TAction, invokeAction } from "@/lib/actions"; +import { ScenesView } from "@/components/editor/scenes-view"; +import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions"; import { cn } from "@/utils/ui"; import { useTimelineStore } from "@/stores/timeline-store"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -76,15 +76,15 @@ export function TimelineToolbar({ } function ToolbarLeftSection() { - const editor = useEditor(); - const currentTime = editor.playback.getCurrentTime(); - const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + const isCurrentlyBookmarked = useEditor((e) => + e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), + ); const handleAction = ({ action, event, }: { - action: TAction; + action: TActionWithOptionalArgs; event: React.MouseEvent; }) => { event.stopPropagation(); @@ -149,8 +149,8 @@ function ToolbarLeftSection() { } - isActive={isCurrentlyBookmarked} - tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} + isActive={isCurrentlyBookmarked} + tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} onClick={({ event }) => handleAction({ action: "toggle-bookmark", event }) } 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 81cda7a6..6a9e4dfe 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -2,12 +2,12 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { TimelineElement } from "./timeline-element"; -import type { TimelineTrack } from "@/types/timeline"; -import type { TimelineElement as TimelineElementType } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; +import type { TimelineElement as TimelineElementType } from "@/lib/timeline"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import type { ElementDragState } from "@/types/timeline"; +import type { ElementDragState } from "@/lib/timeline"; import { useEditor } from "@/hooks/use-editor"; interface TimelineTrackContentProps { @@ -30,7 +30,7 @@ interface TimelineTrackContentProps { track: TimelineTrack; }) => void; onTrackMouseDown?: (event: React.MouseEvent) => void; - onTrackClick?: (event: React.MouseEvent) => void; + onTrackMouseUp?: (event: React.MouseEvent) => void; shouldIgnoreClick?: () => boolean; targetElementId?: string | null; } @@ -47,14 +47,12 @@ export function TimelineTrackContent({ onElementMouseDown, onElementClick, onTrackMouseDown, - onTrackClick, + onTrackMouseUp, shouldIgnoreClick, targetElementId = null, }: TimelineTrackContentProps) { - const editor = useEditor(); const { isElementSelected } = useElementSelection(); - - const duration = editor.timeline.getTotalDuration(); + const duration = useEditor((e) => e.timeline.getTotalDuration()); useEdgeAutoScroll({ isActive: dragState.isDragging, @@ -65,21 +63,37 @@ export function TimelineTrackContent({ }); return ( - +
); } -- cgit v1.3.1