summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor/properties-panel/index.tsx6
-rw-r--r--apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx24
-rw-r--r--apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx78
-rw-r--r--apps/web/src/components/editor/timeline-element.tsx88
-rw-r--r--apps/web/src/components/editor/timeline-playhead.tsx32
-rw-r--r--apps/web/src/components/editor/timeline.tsx244
6 files changed, 175 insertions, 297 deletions
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx
index dc0dd4b2..9e28850f 100644
--- a/apps/web/src/components/editor/properties-panel/index.tsx
+++ b/apps/web/src/components/editor/properties-panel/index.tsx
@@ -83,11 +83,7 @@ export function PropertiesPanel() {
);
if (mediaItem?.type === "audio") {
- return (
- <div key={elementId}>
- <AudioProperties element={element} />
- </div>
- );
+ return <AudioProperties element={element} />;
}
return (
diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx
deleted file mode 100644
index c0efc19e..00000000
--- a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { TimelineCanvasRulerWrapperProps } from "@/types/timeline";
-import React, { forwardRef } from "react";
-
-/**
- * Wrapper div around the canvas-based timeline ruler.
- * Captures mouse events for scrubbing and ensures pointer events are properly enabled.
- */
-const TimelineCanvasRulerWrapper = forwardRef<
- HTMLDivElement,
- TimelineCanvasRulerWrapperProps
->(({ children, onMouseDown, className = "" }, ref) => {
- return (
- <div
- ref={ref}
- className={`relative overflow-hidden h-5 w-full select-none cursor-pointer ${className}`}
- onMouseDown={onMouseDown}
- data-ruler-wrapper
- >
- {children}
- </div>
- );
-});
-
-export default TimelineCanvasRulerWrapper;
diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx
deleted file mode 100644
index c40cdbda..00000000
--- a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { TimelineCanvasRulerProps } from "@/types/timeline";
-import React, { useRef, useEffect } from "react";
-
-/**
- * TimelineCanvasRuler renders the timeline ticks and labels using Canvas.
- * Should be wrapped by TimelineCanvasRulerWrapper for interaction handling.
- */
-export default function TimelineCanvasRuler({
- zoomLevel,
- duration,
- width,
- height = 20,
-}: TimelineCanvasRulerProps) {
- const canvasRef = useRef<HTMLCanvasElement>(null);
-
- useEffect(() => {
- const ctx = canvasRef.current?.getContext("2d");
- if (!ctx) return;
-
- ctx.clearRect(0, 0, width, height);
-
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
-
- const getTimeInterval = () => {
- if (pixelsPerSecond >= 200) return 0.1;
- if (pixelsPerSecond >= 100) return 0.5;
- if (pixelsPerSecond >= 50) return 1;
- if (pixelsPerSecond >= 25) return 2;
- if (pixelsPerSecond >= 12) return 5;
- if (pixelsPerSecond >= 6) return 10;
- return 30;
- };
-
- const mainInterval = getTimeInterval();
- const tickPerMain = 5;
- const subInterval = mainInterval / tickPerMain;
-
- const totalTicks = Math.ceil(duration / subInterval) + 1;
-
- for (let i = 0; i < totalTicks; i++) {
- const time = i * subInterval;
- const x = time * pixelsPerSecond;
-
- const isMain = i % tickPerMain === 0;
-
- ctx.beginPath();
- ctx.moveTo(x, 0);
- ctx.lineTo(x, isMain ? height * 0.3 : height * 0.1);
- ctx.strokeStyle = isMain ? "#999" : "#ccc";
- ctx.lineWidth = isMain ? 1 : 0.5;
- ctx.stroke();
-
- // Label
- if (isMain) {
- ctx.fillStyle = "#666";
- ctx.font = "10px sans-serif";
- const mins = Math.floor(time / 60);
- const secs = Math.floor(time % 60);
- let label = "";
- let xTranslate = 10;
- if (mainInterval < 1) {
- label = `${time.toFixed(1)}s`;
- } else if (mins > 0) {
- label = `${mins}:${secs.toString().padStart(2, "0")}`;
- } else {
- label = `${secs}s`;
- xTranslate = 5;
- }
- ctx.fillText(label, x - xTranslate, height - 4);
- }
- }
- }, [zoomLevel, duration, width, height]);
-
- return (
- <canvas ref={canvasRef} width={width} height={height} className="block" />
- );
-}
diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx
index 71b2cd26..4ba889ef 100644
--- a/apps/web/src/components/editor/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline-element.tsx
@@ -96,93 +96,9 @@ export function TimelineElement({
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
- const elementLeft = elementStartTime * 50 * zoomLevel;
-
- const handleDeleteElement = () => {
- removeElementFromTrack(track.id, element.id);
- setElementMenuOpen(false);
- };
-
- const handleSplitElement = () => {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within element to split");
- return;
- }
-
- const secondElementId = splitElement(track.id, element.id, currentTime);
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- setElementMenuOpen(false);
- };
-
- const handleSplitAndKeepLeft = () => {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within element");
- return;
- }
-
- splitAndKeepLeft(track.id, element.id, currentTime);
- setElementMenuOpen(false);
- };
-
- const handleSplitAndKeepRight = () => {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within element");
- return;
- }
-
- splitAndKeepRight(track.id, element.id, currentTime);
- setElementMenuOpen(false);
- };
-
- const handleSeparateAudio = () => {
- if (element.type !== "media") {
- toast.error("Audio separation only available for media elements");
- return;
- }
-
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- if (!mediaItem || mediaItem.type !== "video") {
- toast.error("Audio separation only available for video elements");
- return;
- }
-
- const audioElementId = separateAudio(track.id, element.id);
- if (!audioElementId) {
- toast.error("Failed to separate audio");
- }
- setElementMenuOpen(false);
- };
-
- const canSplitAtPlayhead = () => {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- return currentTime > effectiveStart && currentTime < effectiveEnd;
- };
- const canSeparateAudio = () => {
- if (element.type !== "media") return false;
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- return mediaItem?.type === "video" && track.type === "media";
- };
+ // Element should always be positioned at startTime - trimStart only affects content, not position
+ const elementLeft = elementStartTime * 50 * zoomLevel;
const handleElementSplitContext = () => {
const effectiveStart = element.startTime;
diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx
index 390abc3d..78aed3a8 100644
--- a/apps/web/src/components/editor/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline-playhead.tsx
@@ -2,7 +2,10 @@
import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ TIMELINE_CONSTANTS,
+ getTotalTracksHeight,
+} from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
@@ -13,6 +16,7 @@ interface TimelinePlayheadProps {
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
@@ -27,6 +31,7 @@ export function TimelinePlayhead({
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
@@ -34,13 +39,15 @@ export function TimelinePlayhead({
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handleRulerMouseDown } = useTimelinePlayhead({
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
});
// Use timeline container height minus a few pixels for breathing room
@@ -59,14 +66,14 @@ export function TimelinePlayhead({
return (
<div
ref={playheadRef}
- className="absolute pointer-events-auto z-[50]"
+ className="absolute pointer-events-auto z-[100]"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
}}
- onMouseDown={handleRulerMouseDown}
+ onMouseDown={handlePlayheadMouseDown}
>
{/* The playhead line spanning full height */}
<div
@@ -89,24 +96,21 @@ export function useTimelinePlayheadRuler({
seek,
rulerRef,
rulerScrollRef,
-}: Omit<
- TimelinePlayheadProps,
- | "tracks"
- | "trackLabelsRef"
- | "timelineRef"
- | "tracksScrollRef"
- | "playheadRef"
->) {
- const { handleRulerMouseDown } = useTimelinePlayhead({
+ tracksScrollRef,
+ playheadRef,
+}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
+ const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
});
- return { handleRulerMouseDown };
+ return { handleRulerMouseDown, isDraggingRuler };
}
export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 5130c753..a78eb3f6 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -16,8 +16,6 @@ import {
Music,
TypeIcon,
Lock,
- ZoomIn,
- ZoomOut,
LockOpen,
} from "lucide-react";
import {
@@ -57,9 +55,6 @@ import {
TIMELINE_CONSTANTS,
snapTimeToFrame,
} from "@/constants/timeline-constants";
-import { Slider } from "../ui/slider";
-import TimelineCanvasRuler from "./timeline-canvas/timeline-canvas-ruler";
-import TimelineCanvasRulerWrapper from "./timeline-canvas/timeline-canvas-ruler-wrapper";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements.
@@ -99,13 +94,10 @@ export function Timeline() {
const [isInTimeline, setIsInTimeline] = useState(false);
// Timeline zoom functionality
- const {
- zoomLevel,
- zoomStep,
- handleChangeZoomLevel,
- handleChangeZoomStep,
- handleWheel,
- } = useTimelineZoom();
+ const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
+ containerRef: timelineRef,
+ isInTimeline,
+ });
// Old marquee selection removed - using new SelectionBox component instead
@@ -118,6 +110,7 @@ export function Timeline() {
// Scroll synchronization and auto-scroll to playhead
const rulerScrollRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
@@ -134,6 +127,8 @@ export function Timeline() {
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
});
// Selection box functionality
@@ -203,14 +198,14 @@ export function Timeline() {
clearSelectedElements();
// Determine if we're clicking in ruler or tracks area
- const isTracksAreaClick = (e.target as HTMLElement).closest(
- "[data-tracks-area]"
+ const isRulerClick = (e.target as HTMLElement).closest(
+ "[data-ruler-area]"
);
let mouseX: number;
let scrollLeft = 0;
- if (isTracksAreaClick) {
+ if (isRulerClick) {
// Calculate based on ruler position
const rulerContent = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
@@ -220,7 +215,14 @@ export function Timeline() {
mouseX = e.clientX - rect.left;
scrollLeft = rulerContent.scrollLeft;
} else {
- return;
+ // Calculate based on tracks content position
+ const tracksContent = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!tracksContent) return;
+ const rect = tracksContent.getBoundingClientRect();
+ mouseX = e.clientX - rect.left;
+ scrollLeft = tracksContent.scrollLeft;
}
const rawTime = Math.max(
@@ -243,6 +245,7 @@ export function Timeline() {
zoomLevel,
seek,
rulerScrollRef,
+ tracksScrollRef,
clearSelectedElements,
isSelecting,
justFinishedSelecting,
@@ -506,11 +509,14 @@ export function Timeline() {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
- if (!rulerViewport) return;
+ if (!rulerViewport || !tracksViewport) return;
// Horizontal scroll synchronization between ruler and tracks
const handleRulerScroll = () => {
@@ -518,6 +524,7 @@ export function Timeline() {
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
lastRulerSync.current = now;
isUpdatingRef.current = true;
+ tracksViewport.scrollLeft = rulerViewport.scrollLeft;
isUpdatingRef.current = false;
};
const handleTracksScroll = () => {
@@ -525,10 +532,12 @@ export function Timeline() {
if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
lastTracksSync.current = now;
isUpdatingRef.current = true;
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft;
isUpdatingRef.current = false;
};
rulerViewport.addEventListener("scroll", handleRulerScroll);
+ tracksViewport.addEventListener("scroll", handleTracksScroll);
// Vertical scroll synchronization between track labels and tracks content
if (trackLabelsViewport) {
@@ -538,6 +547,7 @@ export function Timeline() {
return;
lastVerticalSync.current = now;
isUpdatingRef.current = true;
+ tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
isUpdatingRef.current = false;
};
const handleTracksVerticalScroll = () => {
@@ -546,22 +556,30 @@ export function Timeline() {
return;
lastVerticalSync.current = now;
isUpdatingRef.current = true;
+ trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
isUpdatingRef.current = false;
};
trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
return () => {
rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
trackLabelsViewport.removeEventListener(
"scroll",
handleTrackLabelsScroll
);
+ tracksViewport.removeEventListener(
+ "scroll",
+ handleTracksVerticalScroll
+ );
};
}
return () => {
rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
};
}, []);
@@ -721,39 +739,6 @@ export function Timeline() {
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- className="ml-auto"
- variant="text"
- size="icon"
- onClick={() => handleChangeZoomLevel(zoomLevel - 0.15)}
- >
- <ZoomOut className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Zoom Out</TooltipContent>
- </Tooltip>
- <Slider
- className="max-w-24"
- max={TIMELINE_CONSTANTS.MAX_ZOOM_STEP}
- min={0}
- step={1}
- value={[zoomStep]}
- onValueChange={(value) => handleChangeZoomStep(value[0])}
- />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={() => handleChangeZoomLevel(zoomLevel + 0.15)}
- >
- <ZoomIn className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Zoom In</TooltipContent>
- </Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-1">
@@ -787,6 +772,7 @@ export function Timeline() {
seek={seek}
rulerRef={rulerRef}
rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
trackLabelsRef={trackLabelsRef}
timelineRef={timelineRef}
playheadRef={playheadRef}
@@ -804,38 +790,114 @@ export function Timeline() {
/>
{/* Timeline Header with Ruler */}
<div className="flex bg-panel sticky top-0 z-10">
- <div className="w-48 flex-shrink-0 bg-muted/30 border-r h-5 px-3" />
+ {/* Track Labels Header */}
+ <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
+ {/* Empty space */}
+ <span className="text-sm font-medium text-muted-foreground opacity-0">
+ .
+ </span>
+ </div>
- <div className="flex-1 overflow-hidden h-5">
+ {/* Timeline Ruler */}
+ <div
+ className="flex-1 relative overflow-hidden h-4"
+ onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
+ onClick={handleTimelineContentClick}
+ data-ruler-area
+ >
<ScrollArea className="w-full" ref={rulerScrollRef}>
- <TimelineCanvasRulerWrapper
+ <div
ref={rulerRef}
+ className="relative h-4 select-none cursor-default"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
onMouseDown={handleRulerMouseDown}
>
- <TimelineCanvasRuler
- zoomLevel={zoomLevel}
- duration={duration}
- width={dynamicTimelineWidth}
- />
- </TimelineCanvasRulerWrapper>
+ {/* Time markers */}
+ {(() => {
+ // Calculate appropriate time interval based on zoom level
+ const getTimeInterval = (zoom: number) => {
+ const pixelsPerSecond =
+ TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
+ if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
+ if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
+ if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
+ if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
+ if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
+ if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
+ return 30; // Every 30s when extremely zoomed out
+ };
+
+ const interval = getTimeInterval(zoomLevel);
+ const markerCount = Math.ceil(duration / interval) + 1;
+
+ return Array.from({ length: markerCount }, (_, i) => {
+ const time = i * interval;
+ if (time > duration) return null;
+
+ const isMainMarker =
+ time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
+
+ return (
+ <div
+ key={i}
+ className={`absolute top-0 bottom-0 ${
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ }`}
+ style={{
+ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ >
+ <span
+ className={`absolute top-1 left-1 text-[0.6rem] ${
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ }`}
+ >
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ } else {
+ return `${secs.toFixed(1)}s`;
+ }
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+ }).filter(Boolean);
+ })()}
+ </div>
</ScrollArea>
</div>
</div>
{/* Tracks Area */}
- <ScrollArea className="w-full h-full">
- <div
- className="flex-1 flex overflow-hidden overflow-y-auto"
- data-tracks-area
- >
- {/* Track Labels */}
- {tracks.length > 0 && (
- <div
- ref={trackLabelsRef}
- className="w-48 flex-shrink-0 border-r bg-panel-accent "
- data-track-labels
- >
- <div className="flex flex-col gap-1" ref={trackLabelsScrollRef}>
+ <div className="flex-1 flex overflow-hidden">
+ {/* Track Labels */}
+ {tracks.length > 0 && (
+ <div
+ ref={trackLabelsRef}
+ className="w-48 flex-shrink-0 border-r border-black overflow-y-auto"
+ data-track-labels
+ >
+ <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
+ <div className="flex flex-col gap-1">
{tracks.map((track) => (
<div
key={track.id}
@@ -853,23 +915,25 @@ export function Timeline() {
</div>
))}
</div>
- </div>
- )}
+ </ScrollArea>
+ </div>
+ )}
- {/* Timeline Tracks Content */}
- <div
- className="flex-1 relative overflow-hidden"
- onWheel={handleWheel}
- onMouseDown={handleSelectionMouseDown}
- onClick={handleTimelineContentClick}
- ref={tracksContainerRef}
- >
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
+ {/* Timeline Tracks Content */}
+ <div
+ className="flex-1 relative overflow-hidden"
+ onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
+ onClick={handleTimelineContentClick}
+ ref={tracksContainerRef}
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
style={{
@@ -923,9 +987,9 @@ export function Timeline() {
</>
)}
</div>
- </div>
+ </ScrollArea>
</div>
- </ScrollArea>
+ </div>
</div>
</div>
);