mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge pull request #266 from anwarulislam/feat/snapping
feature: add timeline snapping functionality
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
interface SnapIndicatorProps {
|
||||
snapPoint: SnapPoint | null;
|
||||
zoomLevel: number;
|
||||
isVisible: boolean;
|
||||
tracks: TimelineTrack[];
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function SnapIndicator({
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
isVisible,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
}: SnapIndicatorProps) {
|
||||
if (!isVisible || !snapPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
||||
|
||||
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
||||
const trackLabelsWidth =
|
||||
tracks.length > 0 && trackLabelsRef?.current
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
|
||||
const leftPosition =
|
||||
trackLabelsWidth +
|
||||
snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none z-[90]"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px",
|
||||
}}
|
||||
>
|
||||
<div className={`w-0.5 h-full bg-primary/40 opacity-80`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ interface TimelinePlayheadProps {
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
isSnappingToPlayhead?: boolean;
|
||||
}
|
||||
|
||||
export function TimelinePlayhead({
|
||||
@@ -34,6 +35,7 @@ export function TimelinePlayhead({
|
||||
trackLabelsRef,
|
||||
timelineRef,
|
||||
playheadRef: externalPlayheadRef,
|
||||
isSnappingToPlayhead = false,
|
||||
}: TimelinePlayheadProps) {
|
||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||
@@ -73,11 +75,15 @@ export function TimelinePlayhead({
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
{/* The red line spanning full height */}
|
||||
<div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* Red dot indicator at the top (in ruler area) */}
|
||||
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-foreground rounded-full border-2 border-foreground shadow-sm" />
|
||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||
<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"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,13 +22,16 @@ import {
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
|
||||
export function TimelineTrackContent({
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
@@ -45,8 +48,18 @@ export function TimelineTrackContent({
|
||||
endDrag: endDragAction,
|
||||
clearSelectedElements,
|
||||
insertTrackAt,
|
||||
snappingEnabled,
|
||||
} = useTimelineStore();
|
||||
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
// Initialize snapping hook
|
||||
const { snapElementPosition } = useTimelineSnapping({
|
||||
snapThreshold: 10,
|
||||
enableElementSnapping: snappingEnabled,
|
||||
enablePlayheadSnapping: snappingEnabled,
|
||||
});
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
||||
@@ -85,12 +98,34 @@ export function TimelineTrackContent({
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
// Use frame snapping if project has FPS, otherwise use decimal snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(adjustedTime, projectFps);
|
||||
|
||||
updateDragTime(snappedTime);
|
||||
// Apply snapping if enabled
|
||||
let finalTime = adjustedTime;
|
||||
let snapPoint = null;
|
||||
if (snappingEnabled) {
|
||||
const snapResult = snapElementPosition(
|
||||
adjustedTime,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
dragState.elementId || undefined
|
||||
);
|
||||
finalTime = snapResult.snappedTime;
|
||||
snapPoint = snapResult.snapPoint;
|
||||
|
||||
// Notify parent component about snap point change
|
||||
onSnapPointChange?.(snapPoint);
|
||||
} else {
|
||||
// Use frame snapping if project has FPS, otherwise use decimal snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
finalTime = snapTimeToFrame(adjustedTime, projectFps);
|
||||
|
||||
// Clear snap point when not snapping
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
|
||||
updateDragTime(finalTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
@@ -108,6 +143,8 @@ export function TimelineTrackContent({
|
||||
dragState.currentTime
|
||||
);
|
||||
endDragAction();
|
||||
// Clear snap point when drag ends
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -204,6 +241,8 @@ export function TimelineTrackContent({
|
||||
|
||||
if (isTrackThatStartedDrag) {
|
||||
endDragAction();
|
||||
// Clear snap point when drag ends
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,6 +268,7 @@ export function TimelineTrackContent({
|
||||
endDragAction,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
onSnapPointChange,
|
||||
]);
|
||||
|
||||
const handleElementMouseDown = (
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
Video,
|
||||
Music,
|
||||
TypeIcon,
|
||||
Magnet,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -36,13 +38,6 @@ import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import {
|
||||
TimelinePlayhead,
|
||||
@@ -50,6 +45,8 @@ import {
|
||||
} from "./timeline-playhead";
|
||||
import { SelectionBox } from "./selection-box";
|
||||
import { useSelectionBox } from "@/hooks/use-selection-box";
|
||||
import { SnapIndicator } from "./snap-indicator";
|
||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import type { DragData, TimelineTrack } from "@/types/timeline";
|
||||
import {
|
||||
getTrackHeight,
|
||||
@@ -78,6 +75,9 @@ export function Timeline() {
|
||||
separateAudio,
|
||||
undo,
|
||||
redo,
|
||||
snappingEnabled,
|
||||
toggleSnapping,
|
||||
dragState,
|
||||
} = useTimelineStore();
|
||||
const { mediaItems, addMediaItem } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
@@ -118,7 +118,7 @@ export function Timeline() {
|
||||
const lastVerticalSync = useRef(0);
|
||||
|
||||
// Timeline playhead ruler handlers
|
||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({
|
||||
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
@@ -145,6 +145,18 @@ export function Timeline() {
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate snap indicator state
|
||||
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
||||
null
|
||||
);
|
||||
const showSnapIndicator =
|
||||
dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
|
||||
|
||||
// Callback to handle snap point changes from TimelineTrackContent
|
||||
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
|
||||
setCurrentSnapPoint(snapPoint);
|
||||
}, []);
|
||||
|
||||
// Timeline content click to seek handler
|
||||
const handleTimelineContentClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -686,136 +698,168 @@ export function Timeline() {
|
||||
onMouseLeave={() => setIsInTimeline(false)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="border-b flex items-center px-2 py-1 gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
{/* Play/Pause Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
className="mr-2"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
{/* Time Display */}
|
||||
<div
|
||||
className="text-xs text-muted-foreground font-mono px-2"
|
||||
style={{ minWidth: "18ch", textAlign: "center" }}
|
||||
>
|
||||
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
|
||||
</div>
|
||||
{/* Test Clip Button - for debugging */}
|
||||
{tracks.length === 0 && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const trackId = addTrack("media");
|
||||
addElementToTrack(trackId, {
|
||||
type: "media",
|
||||
mediaId: "test",
|
||||
name: "Test Clip",
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Add Test Clip
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add a test clip to try playback</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleSplitSelected}>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepLeft}
|
||||
>
|
||||
<ArrowLeftToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepRight}
|
||||
>
|
||||
<ArrowRightToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleDuplicateSelected}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
|
||||
<Snowflake className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete element (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="border-b flex items-center justify-between px-2 py-1">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
{/* Play/Pause Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
className="mr-2"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
{/* Time Display */}
|
||||
<div
|
||||
className="text-xs text-muted-foreground font-mono px-2"
|
||||
style={{ minWidth: "18ch", textAlign: "center" }}
|
||||
>
|
||||
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
|
||||
</div>
|
||||
{/* Test Clip Button - for debugging */}
|
||||
{tracks.length === 0 && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const trackId = addTrack("media");
|
||||
addElementToTrack(trackId, {
|
||||
type: "media",
|
||||
mediaId: "test",
|
||||
name: "Test Clip",
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Add Test Clip
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Add a test clip to try playback
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitSelected}
|
||||
>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepLeft}
|
||||
>
|
||||
<ArrowLeftToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepRight}
|
||||
>
|
||||
<ArrowRightToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSeparateAudio}
|
||||
>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleDuplicateSelected}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleFreezeSelected}
|
||||
>
|
||||
<Snowflake className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete element (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={toggleSnapping}>
|
||||
<Lock className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto snapping</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Container */}
|
||||
@@ -835,6 +879,17 @@ export function Timeline() {
|
||||
trackLabelsRef={trackLabelsRef}
|
||||
timelineRef={timelineRef}
|
||||
playheadRef={playheadRef}
|
||||
isSnappingToPlayhead={
|
||||
showSnapIndicator && currentSnapPoint?.type === "playhead"
|
||||
}
|
||||
/>
|
||||
<SnapIndicator
|
||||
snapPoint={currentSnapPoint}
|
||||
zoomLevel={zoomLevel}
|
||||
tracks={tracks}
|
||||
timelineRef={timelineRef}
|
||||
trackLabelsRef={trackLabelsRef}
|
||||
isVisible={showSnapIndicator}
|
||||
/>
|
||||
{/* Timeline Header with Ruler */}
|
||||
<div className="flex bg-panel sticky top-0 z-10">
|
||||
@@ -1016,6 +1071,7 @@ export function Timeline() {
|
||||
<TimelineTrackContent
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
onSnapPointChange={handleSnapPointChange}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
@@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 overflow-hidden rounded-md bg-foreground/10 px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user