mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: working z indexing and borders
This commit is contained in:
@@ -82,7 +82,7 @@ export function TimelinePlayhead({
|
|||||||
|
|
||||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||||
<div
|
<div
|
||||||
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
|
className={`sticky top-1 left-1 -translate-x-[40%] transform w-3 h-3 rounded-full shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -915,12 +915,12 @@ export function TimelineTrackContent({
|
|||||||
>
|
>
|
||||||
{track.elements.length === 0 ? (
|
{track.elements.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
|
className={`h-full w-full border-inset border-b border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
|
||||||
isDropping
|
isDropping
|
||||||
? wouldOverlap
|
? wouldOverlap
|
||||||
? "border-red-500 bg-red-500/10 text-red-600"
|
? "rounded-sm border-red-500 bg-red-500/10 text-red-600"
|
||||||
: "border-blue-500 bg-blue-500/10 text-blue-600"
|
: "rounded-sm border-blue-500 bg-blue-500/10 text-blue-600"
|
||||||
: "border-muted/30"
|
: "border-background"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isDropping
|
{isDropping
|
||||||
@@ -930,7 +930,9 @@ export function TimelineTrackContent({
|
|||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div
|
||||||
|
className={`h-full w-full border-b border-dashed border-background flex items-center justify-center text-xs text-muted-foreground transition-colors`}
|
||||||
|
>
|
||||||
{track.elements.map((element) => {
|
{track.elements.map((element) => {
|
||||||
const isSelected = selectedElements.some(
|
const isSelected = selectedElements.some(
|
||||||
(c) => c.trackId === track.id && c.elementId === element.id
|
(c) => c.trackId === track.id && c.elementId === element.id
|
||||||
@@ -989,7 +991,7 @@ export function TimelineTrackContent({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
TypeIcon,
|
TypeIcon,
|
||||||
Lock,
|
Lock,
|
||||||
LockOpen,
|
LockOpen,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -37,7 +38,7 @@ import { useProjectStore } from "@/stores/project-store";
|
|||||||
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||||
import { processMediaFiles } from "@/lib/media-processing";
|
import { processMediaFiles } from "@/lib/media-processing";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback, Fragment } from "react";
|
||||||
import { TimelineTrackContent } from "./timeline-track";
|
import { TimelineTrackContent } from "./timeline-track";
|
||||||
import {
|
import {
|
||||||
TimelinePlayhead,
|
TimelinePlayhead,
|
||||||
@@ -108,15 +109,18 @@ export function Timeline() {
|
|||||||
timelineRef.current?.clientWidth || 1000 // Minimum width
|
timelineRef.current?.clientWidth || 1000 // Minimum width
|
||||||
);
|
);
|
||||||
|
|
||||||
// Essential refs for timeline functionality
|
// Scroll synchronization and auto-scroll to playhead
|
||||||
const playheadRef = useRef<HTMLDivElement>(null);
|
|
||||||
const tracksContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Temporary refs for compatibility (should be removed when TimelinePlayhead is updated)
|
|
||||||
const rulerScrollRef = useRef<HTMLDivElement>(null);
|
const rulerScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const tracksScrollRef = useRef<HTMLDivElement>(null);
|
const tracksScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const trackLabelsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const playheadRef = useRef<HTMLDivElement>(null);
|
||||||
|
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isUpdatingRef = useRef(false);
|
||||||
|
const lastRulerSync = useRef(0);
|
||||||
|
const lastTracksSync = useRef(0);
|
||||||
|
const lastVerticalSync = useRef(0);
|
||||||
|
|
||||||
// Timeline playhead ruler handlers - temporarily keeping all refs for compatibility
|
// Timeline playhead ruler handlers
|
||||||
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
|
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
@@ -129,6 +133,7 @@ export function Timeline() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Selection box functionality
|
// Selection box functionality
|
||||||
|
const tracksContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const {
|
const {
|
||||||
selectionBox,
|
selectionBox,
|
||||||
handleMouseDown: handleSelectionMouseDown,
|
handleMouseDown: handleSelectionMouseDown,
|
||||||
@@ -155,7 +160,7 @@ export function Timeline() {
|
|||||||
setCurrentSnapPoint(snapPoint);
|
setCurrentSnapPoint(snapPoint);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Timeline content click to seek handler - simplified for single scroll area
|
// Timeline content click to seek handler
|
||||||
const handleTimelineContentClick = useCallback(
|
const handleTimelineContentClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -240,10 +245,11 @@ export function Timeline() {
|
|||||||
duration,
|
duration,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
seek,
|
seek,
|
||||||
|
rulerScrollRef,
|
||||||
|
tracksScrollRef,
|
||||||
clearSelectedElements,
|
clearSelectedElements,
|
||||||
isSelecting,
|
isSelecting,
|
||||||
justFinishedSelecting,
|
justFinishedSelecting,
|
||||||
activeProject?.fps,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -499,6 +505,85 @@ export function Timeline() {
|
|||||||
clearSelectedElements();
|
clearSelectedElements();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Scroll synchronization effect ---
|
||||||
|
useEffect(() => {
|
||||||
|
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 || !tracksViewport) return;
|
||||||
|
|
||||||
|
// Horizontal scroll synchronization between ruler and tracks
|
||||||
|
const handleRulerScroll = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
|
||||||
|
lastRulerSync.current = now;
|
||||||
|
isUpdatingRef.current = true;
|
||||||
|
tracksViewport.scrollLeft = rulerViewport.scrollLeft;
|
||||||
|
isUpdatingRef.current = false;
|
||||||
|
};
|
||||||
|
const handleTracksScroll = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
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) {
|
||||||
|
const handleTrackLabelsScroll = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||||
|
return;
|
||||||
|
lastVerticalSync.current = now;
|
||||||
|
isUpdatingRef.current = true;
|
||||||
|
tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
|
||||||
|
isUpdatingRef.current = false;
|
||||||
|
};
|
||||||
|
const handleTracksVerticalScroll = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Add wheel event listeners with passive: false to allow preventDefault
|
// Add wheel event listeners with passive: false to allow preventDefault
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timelineContainer = timelineRef.current;
|
const timelineContainer = timelineRef.current;
|
||||||
@@ -525,7 +610,7 @@ export function Timeline() {
|
|||||||
onMouseLeave={() => setIsInTimeline(false)}
|
onMouseLeave={() => setIsInTimeline(false)}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="border-b flex items-center justify-between px-2 py-1 bg-background z-90">
|
<div className="border-b flex items-center justify-between px-2 py-1 bg-card z-90">
|
||||||
<div className="flex items-center gap-1 w-full">
|
<div className="flex items-center gap-1 w-full">
|
||||||
<TooltipProvider delayDuration={500}>
|
<TooltipProvider delayDuration={500}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -692,55 +777,31 @@ export function Timeline() {
|
|||||||
|
|
||||||
{/* Timeline Container */}
|
{/* Timeline Container */}
|
||||||
|
|
||||||
<ScrollArea className=" w-full h-full" style={{ minWidth: `${dynamicTimelineWidth}px` }}>
|
{/* Timeline Container with Grid Layout */}
|
||||||
|
|
||||||
{/* Timeline Tracks Content */}
|
|
||||||
<div
|
<div
|
||||||
ref={timelineRef}
|
ref={timelineRef}
|
||||||
className="relative w-full overflow-x-auto h-full min-h-72"
|
className="relative h-full w-full overflow-auto border"
|
||||||
|
onMouseEnter={() => setIsInTimeline(true)}
|
||||||
|
onMouseLeave={() => setIsInTimeline(false)}
|
||||||
onMouseDown={handleSelectionMouseDown}
|
onMouseDown={handleSelectionMouseDown}
|
||||||
onClick={handleTimelineContentClick}
|
onClick={handleTimelineContentClick}
|
||||||
>
|
>
|
||||||
<SelectionBox
|
<div className="min-w-max min-h-max" style={{ minWidth: `${dynamicTimelineWidth}px` }}>
|
||||||
startPos={selectionBox?.startPos || null}
|
<div className={`grid grid-cols-[192px_1fr] ${tracks.length > 0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : 'grid-rows-[20px_1fr]'}`}>
|
||||||
currentPos={selectionBox?.currentPos || null}
|
|
||||||
containerRef={tracksContainerRef}
|
|
||||||
isActive={selectionBox?.isActive || false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TimelinePlayhead
|
{/* Top-Left Corner (Empty space above track labels) */}
|
||||||
currentTime={currentTime}
|
<div className="sticky top-0 left-0 border-inset bg-card/[0.99]"></div>
|
||||||
duration={duration}
|
|
||||||
zoomLevel={zoomLevel}
|
{/* Top Row (Sticky Ruler Header) */}
|
||||||
tracks={tracks}
|
|
||||||
seek={seek}
|
|
||||||
rulerRef={rulerRef}
|
|
||||||
rulerScrollRef={rulerScrollRef}
|
|
||||||
tracksScrollRef={tracksScrollRef}
|
|
||||||
timelineRef={timelineRef}
|
|
||||||
playheadRef={playheadRef}
|
|
||||||
isSnappingToPlayhead={
|
|
||||||
showSnapIndicator && currentSnapPoint?.type === "playhead"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SnapIndicator
|
|
||||||
snapPoint={currentSnapPoint}
|
|
||||||
zoomLevel={zoomLevel}
|
|
||||||
tracks={tracks}
|
|
||||||
timelineRef={timelineRef}
|
|
||||||
isVisible={showSnapIndicator}
|
|
||||||
/>
|
|
||||||
<div ref={tracksContainerRef} className="relative w-full h-full min-h-64">
|
|
||||||
<div
|
<div
|
||||||
className="sticky top-0 h-5 w-full ml-48 z-[99] bg-card/[0.99] border-b border-muted/30"
|
className="sticky top-0 bg-card/[0.99] border-b border-muted/30 z-[95]"
|
||||||
onMouseDown={handleSelectionMouseDown}
|
onMouseDown={handleSelectionMouseDown}
|
||||||
onClick={handleTimelineContentClick}
|
onClick={handleTimelineContentClick}
|
||||||
data-ruler-area
|
data-ruler-area
|
||||||
>
|
>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={rulerRef}
|
ref={rulerRef}
|
||||||
className="relative h-5 select-none cursor-default pb-1"
|
className="relative h-5 select-none cursor-default pb-1 z-[95]"
|
||||||
onMouseDown={handleRulerMouseDown}
|
onMouseDown={handleRulerMouseDown}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -770,7 +831,7 @@ export function Timeline() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`absolute top-0 bottom-0 ${isMainMarker
|
className={`absolute top-0 bottom-0 z-[95] ${isMainMarker
|
||||||
? "border-l border-muted-foreground/40"
|
? "border-l border-muted-foreground/40"
|
||||||
: "border-l border-muted-foreground/20"
|
: "border-l border-muted-foreground/20"
|
||||||
}`}
|
}`}
|
||||||
@@ -779,11 +840,12 @@ export function Timeline() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-1 left-1 text-[0.6rem] ${isMainMarker
|
className={`absolute top-1 left-1 text-[0.6rem] z-[95] ${isMainMarker
|
||||||
? "text-muted-foreground font-medium"
|
? "text-muted-foreground font-medium"
|
||||||
: "text-muted-foreground/70"
|
: "text-muted-foreground/70"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
@@ -809,11 +871,13 @@ export function Timeline() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{tracks.map((track, index) => (
|
|
||||||
<div key={track.id} className="relative flex flex-row w-full">
|
|
||||||
|
|
||||||
|
{/* Track Rows */}
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<Fragment key={track.id}>
|
||||||
|
{/* Left Column (Sticky Track Labels) */}
|
||||||
<div
|
<div
|
||||||
className="sticky left-0 flex w-48 items-center border-b border-muted/30 group bg-card/[0.99] z-[101]"
|
className="sticky left-0 flex items-center border-b border-panel border-inset group bg-card/[0.99] z-[90]"
|
||||||
style={{ height: `${getTrackHeight(track.type)}px` }}
|
style={{ height: `${getTrackHeight(track.type)}px` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 px-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
@@ -825,10 +889,12 @@ export function Timeline() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Track Content */}
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 right-0 h-full border-b border-muted/30 ml-48"
|
className=" h-full"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// If clicking empty area (not on a element), deselect all elements
|
// If clicking empty area (not on a element), deselect all elements
|
||||||
if (
|
if (
|
||||||
@@ -858,12 +924,57 @@ export function Timeline() {
|
|||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</div>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
{/* Add Track Button - spans full width */}
|
||||||
|
<div
|
||||||
|
onClick={() => addTrack('media')}
|
||||||
|
className="col-span-1 sticky left-0 w-full flex items-center border-b border-muted bg-card/[0.99] hover:bg-card/50 transition-colors cursor-pointer z-[89]"
|
||||||
|
style={{ height: `${getTrackHeight("media")}px` }}
|
||||||
|
>
|
||||||
|
<div className="w-full flex justify-center items-center">
|
||||||
|
<Plus className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay Components */}
|
||||||
|
<SelectionBox
|
||||||
|
startPos={selectionBox?.startPos || null}
|
||||||
|
currentPos={selectionBox?.currentPos || null}
|
||||||
|
containerRef={tracksContainerRef}
|
||||||
|
isActive={selectionBox?.isActive || false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TimelinePlayhead
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
tracks={tracks}
|
||||||
|
seek={seek}
|
||||||
|
rulerRef={rulerRef}
|
||||||
|
rulerScrollRef={rulerScrollRef}
|
||||||
|
tracksScrollRef={tracksScrollRef}
|
||||||
|
trackLabelsRef={trackLabelsRef}
|
||||||
|
timelineRef={timelineRef}
|
||||||
|
playheadRef={playheadRef}
|
||||||
|
isSnappingToPlayhead={
|
||||||
|
showSnapIndicator && currentSnapPoint?.type === "playhead"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SnapIndicator
|
||||||
|
snapPoint={currentSnapPoint}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
tracks={tracks}
|
||||||
|
timelineRef={timelineRef}
|
||||||
|
trackLabelsRef={trackLabelsRef}
|
||||||
|
isVisible={showSnapIndicator}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user