mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
codebase overhaul (#697)
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl?: string;
|
||||
audioBuffer?: AudioBuffer;
|
||||
height?: number;
|
||||
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,
|
||||
className = "",
|
||||
}: AudioWaveformProps) {
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurfer = useRef<WaveSurfer | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const ws = wavesurfer.current;
|
||||
|
||||
const initWaveSurfer = async () => {
|
||||
if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
|
||||
|
||||
try {
|
||||
if (ws) {
|
||||
wavesurfer.current = null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (ws) {
|
||||
const wsToDestroy = ws;
|
||||
wavesurfer.current = null;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
wsToDestroy.destroy();
|
||||
} catch {}
|
||||
if (mounted) {
|
||||
initWaveSurfer();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
initWaveSurfer();
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
|
||||
const wsToDestroy = wavesurfer.current;
|
||||
|
||||
wavesurfer.current = null;
|
||||
|
||||
if (wsToDestroy) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
wsToDestroy.destroy();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [audioUrl, audioBuffer, height]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center ${className}`}
|
||||
style={{ height }}
|
||||
>
|
||||
<span className="text-foreground/60 text-xs">Audio unavailable</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-foreground/60 text-xs">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={waveformRef}
|
||||
className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`}
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AudioWaveform;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { Bookmark02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
interface TimelineBookmarksRowProps {
|
||||
zoomLevel: number;
|
||||
dynamicTimelineWidth: number;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function TimelineBookmarksRow({
|
||||
zoomLevel,
|
||||
dynamicTimelineWidth,
|
||||
handleWheel,
|
||||
handleTimelineContentClick,
|
||||
handleRulerTrackingMouseDown,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineBookmarksRowProps) {
|
||||
const editor = useEditor();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
return (
|
||||
<div className="relative h-4 flex-1 overflow-hidden">
|
||||
<button
|
||||
className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
|
||||
style={{
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
aria-label="Timeline ruler"
|
||||
type="button"
|
||||
onWheel={handleWheel}
|
||||
onClick={handleTimelineContentClick}
|
||||
onMouseDown={(event) => {
|
||||
handleRulerMouseDown(event);
|
||||
handleRulerTrackingMouseDown(event);
|
||||
}}
|
||||
>
|
||||
{activeScene.bookmarks.map((time: number) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-row-${time}`}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
))}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineBookmark({
|
||||
time,
|
||||
zoomLevel,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const handleBookmarkActivate = ({
|
||||
event,
|
||||
}: {
|
||||
event:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
const framesPerSecond = activeProject?.settings.fps ?? 30;
|
||||
const snappedTime = getSnappedSeekTime({
|
||||
rawTime: time,
|
||||
duration,
|
||||
fps: framesPerSecond,
|
||||
});
|
||||
editor.playback.seek({ time: snappedTime });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
aria-label={`Seek to bookmark at ${time}s`}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => handleBookmarkActivate({ event })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
handleBookmarkActivate({ event });
|
||||
}}
|
||||
>
|
||||
<div className="text-primary absolute top-[-1px] left-[-5px]">
|
||||
<HugeiconsIcon
|
||||
icon={Bookmark02Icon}
|
||||
aria-hidden="true"
|
||||
className="fill-primary size-3"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getDropLineY } from "@/lib/timeline/drop-utils";
|
||||
import type { TimelineTrack, DropTarget } from "@/types/timeline";
|
||||
|
||||
interface DragLineProps {
|
||||
dropTarget: DropTarget | null;
|
||||
tracks: TimelineTrack[];
|
||||
isVisible: boolean;
|
||||
headerHeight?: number;
|
||||
}
|
||||
|
||||
export function DragLine({
|
||||
dropTarget,
|
||||
tracks,
|
||||
isVisible,
|
||||
headerHeight = 0,
|
||||
}: DragLineProps) {
|
||||
if (!isVisible || !dropTarget) return null;
|
||||
|
||||
const y = getDropLineY({ dropTarget, tracks });
|
||||
const lineTop = y + headerHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
|
||||
style={{ top: `${lineTop}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
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>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function SnapIndicator({
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
isVisible,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
}: SnapIndicatorProps) {
|
||||
const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
});
|
||||
|
||||
if (!isVisible || !snapPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute z-90"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: topPosition,
|
||||
height: `${height}px`,
|
||||
width: "2px",
|
||||
}}
|
||||
>
|
||||
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { MediaFile } from "@/types/media";
|
||||
import { TProject } from "@/types/project";
|
||||
import { useSceneStore } from "@/stores/scene-store";
|
||||
|
||||
interface CacheSegment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
interface TimelineCacheIndicatorProps {
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
tracks: TimelineTrack[];
|
||||
mediaFiles: MediaFile[];
|
||||
activeProject: TProject | null;
|
||||
getRenderStatus: (
|
||||
time: number,
|
||||
tracks: TimelineTrack[],
|
||||
mediaFiles: MediaFile[],
|
||||
activeProject: TProject | null,
|
||||
sceneId?: string
|
||||
) => "cached" | "not-cached";
|
||||
}
|
||||
|
||||
export function TimelineCacheIndicator({
|
||||
duration,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
getRenderStatus,
|
||||
}: TimelineCacheIndicatorProps) {
|
||||
const { currentScene } = useSceneStore();
|
||||
|
||||
// Calculate cache segments by sampling the timeline
|
||||
const calculateCacheSegments = (): CacheSegment[] => {
|
||||
const segments: CacheSegment[] = [];
|
||||
const sampleRate = 10; // Sample every 0.1 seconds
|
||||
const totalSamples = Math.ceil(duration * sampleRate);
|
||||
|
||||
if (totalSamples === 0) {
|
||||
return [{ startTime: 0, endTime: duration, cached: false }];
|
||||
}
|
||||
|
||||
let currentSegment: CacheSegment | null = null;
|
||||
|
||||
for (let i = 0; i <= totalSamples; i++) {
|
||||
const time = i / sampleRate;
|
||||
const cached =
|
||||
getRenderStatus(
|
||||
time,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
currentScene?.id
|
||||
) === "cached";
|
||||
|
||||
if (!currentSegment) {
|
||||
// Start first segment
|
||||
currentSegment = {
|
||||
startTime: time,
|
||||
endTime: time,
|
||||
cached,
|
||||
};
|
||||
} else if (currentSegment.cached === cached) {
|
||||
// Extend current segment
|
||||
currentSegment.endTime = time;
|
||||
} else {
|
||||
// Finish current segment and start new one
|
||||
segments.push(currentSegment);
|
||||
currentSegment = {
|
||||
startTime: time,
|
||||
endTime: time,
|
||||
cached,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last segment
|
||||
if (currentSegment) {
|
||||
currentSegment.endTime = duration;
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
const cacheSegments = calculateCacheSegments();
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 left-0 right-0 h-px z-10 pointer-events-none">
|
||||
{cacheSegments.map((segment, index) => {
|
||||
const startX =
|
||||
segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const endX =
|
||||
segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const width = endX - startX;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"absolute top-0 h-px",
|
||||
segment.cached ? "bg-primary" : "bg-transparent"
|
||||
)}
|
||||
style={{
|
||||
left: `${startX}px`,
|
||||
width: `${width}px`,
|
||||
}}
|
||||
title={
|
||||
segment.cached
|
||||
? "Cached (fast playback)"
|
||||
: "Not cached (will render)"
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,387 +1,554 @@
|
||||
"use client";
|
||||
|
||||
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 type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
Scissors,
|
||||
Trash2,
|
||||
Copy,
|
||||
Search,
|
||||
RefreshCw,
|
||||
EyeOff,
|
||||
Eye,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import AudioWaveform from "../audio-waveform";
|
||||
import { toast } from "sonner";
|
||||
import { TimelineElementProps, MediaElement } from "@/types/timeline";
|
||||
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
||||
getTrackClasses,
|
||||
getTrackHeight,
|
||||
canElementHaveAudio,
|
||||
canElementBeHidden,
|
||||
hasMediaId,
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
getTrackElementClasses,
|
||||
TIMELINE_CONSTANTS,
|
||||
getTrackHeight,
|
||||
} from "@/constants/timeline-constants";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
import { useMediaPanelStore } from "../media-panel/store";
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
ElementDragState,
|
||||
} from "@/types/timeline";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||
import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ScissorIcon,
|
||||
Delete02Icon,
|
||||
Copy01Icon,
|
||||
ViewIcon,
|
||||
ViewOffSlashIcon,
|
||||
VolumeHighIcon,
|
||||
VolumeOffIcon,
|
||||
VolumeMute02Icon,
|
||||
Search01Icon,
|
||||
Exchange01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
function getDisplayShortcut(action: TAction) {
|
||||
const { defaultShortcuts } = getActionDefinition(action);
|
||||
if (!defaultShortcuts?.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return uppercase({
|
||||
string: defaultShortcuts[0].replace("+", " "),
|
||||
});
|
||||
}
|
||||
|
||||
interface TimelineElementProps {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
isSelected: boolean;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
onResizeStateChange?: (params: { isResizing: boolean }) => void;
|
||||
onElementMouseDown: (
|
||||
e: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
|
||||
dragState: ElementDragState;
|
||||
}
|
||||
|
||||
export function TimelineElement({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
isSelected,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
isSelected,
|
||||
onSnapPointChange,
|
||||
onResizeStateChange,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
dragState,
|
||||
}: TimelineElementProps) {
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const {
|
||||
dragState,
|
||||
copySelected,
|
||||
selectedElements,
|
||||
deleteSelected,
|
||||
splitSelected,
|
||||
toggleSelectedHidden,
|
||||
toggleSelectedMuted,
|
||||
duplicateElement,
|
||||
revealElementInMedia,
|
||||
replaceElementWithFile,
|
||||
getContextMenuState,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const editor = useEditor();
|
||||
const { selectedElements } = useElementSelection();
|
||||
const { requestRevealMedia } = useAssetsPanelStore();
|
||||
|
||||
const mediaItem =
|
||||
element.type === "media"
|
||||
? mediaFiles.find((file) => file.id === element.mediaId)
|
||||
: null;
|
||||
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
let mediaAsset: MediaAsset | null = null;
|
||||
|
||||
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
|
||||
useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
});
|
||||
if (hasMediaId(element)) {
|
||||
mediaAsset =
|
||||
mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
|
||||
}
|
||||
|
||||
const {
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
hasAudioElements,
|
||||
canSplitSelected,
|
||||
} = getContextMenuState(track.id, element.id);
|
||||
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
|
||||
|
||||
const effectiveDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
const elementWidth = Math.max(
|
||||
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
|
||||
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
|
||||
);
|
||||
const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
|
||||
useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
onResizeStateChange,
|
||||
});
|
||||
|
||||
const isBeingDragged = dragState.elementId === element.id;
|
||||
const elementStartTime =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime
|
||||
: element.startTime;
|
||||
const isCurrentElementSelected = selectedElements.some(
|
||||
(selected) =>
|
||||
selected.elementId === element.id && selected.trackId === track.id,
|
||||
);
|
||||
|
||||
const elementLeft = elementStartTime * 50 * zoomLevel;
|
||||
const isBeingDragged = dragState.elementId === element.id;
|
||||
const dragOffsetY =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentMouseY - dragState.startMouseY
|
||||
: 0;
|
||||
const elementStartTime =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime
|
||||
: 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 handleElementSplitContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
splitSelected(
|
||||
currentTime,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id
|
||||
);
|
||||
};
|
||||
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
|
||||
event.stopPropagation();
|
||||
if (hasMediaId(element)) {
|
||||
requestRevealMedia(element.mediaId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleElementDuplicateContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
duplicateElement(track.id, element.id);
|
||||
};
|
||||
const isMuted = canElementHaveAudio(element) && element.muted === true;
|
||||
|
||||
const handleElementCopyContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copySelected();
|
||||
};
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`absolute top-0 h-full select-none ${
|
||||
isBeingDragged ? "z-30" : "z-10"
|
||||
}`}
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
transform:
|
||||
isBeingDragged && dragState.isDragging
|
||||
? `translate3d(0, ${dragOffsetY}px, 0)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ElementInner
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isBeingDragged={isBeingDragged}
|
||||
hasAudio={hasAudio}
|
||||
isMuted={isMuted}
|
||||
mediaAssets={mediaAssets}
|
||||
onElementClick={onElementClick}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
handleResizeStart={handleResizeStart}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200 w-64">
|
||||
<ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}>
|
||||
Split
|
||||
</ActionMenuItem>
|
||||
<CopyMenuItem />
|
||||
{canElementHaveAudio(element) && hasAudio && (
|
||||
<MuteMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
isMuted={isMuted}
|
||||
/>
|
||||
)}
|
||||
{canElementBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
/>
|
||||
)}
|
||||
{selectedElements.length === 1 && (
|
||||
<ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
||||
Duplicate
|
||||
</ActionMenuItem>
|
||||
)}
|
||||
{selectedElements.length === 1 && hasMediaId(element) && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Search01Icon} />}
|
||||
onClick={(event) => handleRevealInMedia({ event })}
|
||||
>
|
||||
Reveal media
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Exchange01Icon} />}
|
||||
disabled
|
||||
>
|
||||
Replace media
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<DeleteMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
elementType={element.type}
|
||||
selectedCount={selectedElements.length}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const handleElementDeleteContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
deleteSelected(
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id
|
||||
);
|
||||
};
|
||||
function ElementInner({
|
||||
element,
|
||||
track,
|
||||
isSelected,
|
||||
isBeingDragged,
|
||||
hasAudio,
|
||||
isMuted,
|
||||
mediaAssets,
|
||||
onElementClick,
|
||||
onElementMouseDown,
|
||||
handleResizeStart,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
isBeingDragged: boolean;
|
||||
hasAudio: boolean;
|
||||
isMuted: boolean;
|
||||
mediaAssets: MediaAsset[];
|
||||
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
|
||||
onElementMouseDown: (
|
||||
e: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
handleResizeStart: (params: {
|
||||
e: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
|
||||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 size-full cursor-pointer"
|
||||
onClick={(e) => onElementClick(e, element)}
|
||||
onMouseDown={(e) => onElementMouseDown(e, element)}
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full items-center">
|
||||
<ElementContent
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
mediaAssets={mediaAssets}
|
||||
/>
|
||||
</div>
|
||||
|
||||
const handleToggleElementContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
{(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>
|
||||
|
||||
if (hasAudio && element.type === "media") {
|
||||
toggleSelectedMuted(
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id
|
||||
);
|
||||
} else {
|
||||
toggleSelectedHidden(
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id
|
||||
);
|
||||
}
|
||||
};
|
||||
{isSelected && (
|
||||
<>
|
||||
<ResizeHandle
|
||||
side="left"
|
||||
elementId={element.id}
|
||||
handleResizeStart={handleResizeStart}
|
||||
/>
|
||||
<ResizeHandle
|
||||
side="right"
|
||||
elementId={element.id}
|
||||
handleResizeStart={handleResizeStart}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleReplaceClip = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
function ResizeHandle({
|
||||
side,
|
||||
elementId,
|
||||
handleResizeStart,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
elementId: string;
|
||||
handleResizeStart: (params: {
|
||||
e: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
}) {
|
||||
const isLeft = side === "left";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
|
||||
onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
|
||||
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
|
||||
>
|
||||
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "video/*,audio/*,image/*";
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
await replaceElementWithFile(track.id, element.id, file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
function ElementContent({
|
||||
element,
|
||||
track,
|
||||
isSelected,
|
||||
mediaAssets,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
mediaAssets: MediaAsset[];
|
||||
}) {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-start pl-2">
|
||||
<span className="truncate text-xs text-white">{element.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRevealInMedia = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
revealElementInMedia(element.id);
|
||||
};
|
||||
if (element.type === "sticker") {
|
||||
return (
|
||||
<div className="flex size-full items-center gap-2 pl-2">
|
||||
<Image
|
||||
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
|
||||
alt={element.name}
|
||||
className="size-5 shrink-0"
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
<span className="truncate text-xs text-white">{element.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderElementContent = () => {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-start pl-2">
|
||||
<span className="text-xs text-white truncate">{element.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (element.type === "audio") {
|
||||
const audioBuffer =
|
||||
element.sourceType === "library" ? element.buffer : undefined;
|
||||
|
||||
const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
|
||||
if (!mediaItem) {
|
||||
return (
|
||||
<span className="text-xs text-foreground/80 truncate">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const audioUrl =
|
||||
element.sourceType === "library"
|
||||
? element.sourceUrl
|
||||
: mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
|
||||
|
||||
if (
|
||||
mediaItem.type === "image" ||
|
||||
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
|
||||
) {
|
||||
const trackHeight = getTrackHeight(track.type);
|
||||
const tileWidth = trackHeight * (16 / 9);
|
||||
if (audioBuffer || audioUrl) {
|
||||
return (
|
||||
<div className="flex size-full items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<AudioWaveform
|
||||
audioBuffer={audioBuffer}
|
||||
audioUrl={audioUrl}
|
||||
height={24}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrl =
|
||||
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div
|
||||
className={`w-full h-full relative ${
|
||||
isSelected ? "bg-primary" : "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-[0.25rem] bottom-[0.25rem] left-0 right-0`}
|
||||
style={{
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
|
||||
if (!mediaAsset) {
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<AudioWaveform
|
||||
audioUrl={mediaItem.url || ""}
|
||||
height={24}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<span className="text-xs text-foreground/80 truncate">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const handleElementMouseDown = (e: React.MouseEvent) => {
|
||||
if (onElementMouseDown) {
|
||||
onElementMouseDown(e, element);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">{element.name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isMuted = element.type === "media" && element.muted;
|
||||
function CopyMenuItem() {
|
||||
return (
|
||||
<ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
||||
Copy
|
||||
</ActionMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`absolute top-0 h-full select-none timeline-element ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
}`}
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
}}
|
||||
data-element-id={element.id}
|
||||
data-track-id={track.id}
|
||||
onMouseMove={resizing ? handleResizeMove : undefined}
|
||||
onMouseUp={resizing ? handleResizeEnd : undefined}
|
||||
onMouseLeave={resizing ? handleResizeEnd : undefined}
|
||||
>
|
||||
<div
|
||||
className={`relative h-full rounded-[0.5rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
|
||||
track.type
|
||||
)} ${isSelected ? "" : ""} ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
} ${element.hidden ? "opacity-50" : ""}`}
|
||||
onClick={(e) => onElementClick && onElementClick(e, element)}
|
||||
onMouseDown={handleElementMouseDown}
|
||||
onContextMenu={(e) =>
|
||||
onElementMouseDown && onElementMouseDown(e, element)
|
||||
}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center h-full">
|
||||
{renderElementContent()}
|
||||
</div>
|
||||
function MuteMenuItem({
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
isMuted,
|
||||
}: {
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
isMuted: boolean;
|
||||
}) {
|
||||
const getIcon = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return <HugeiconsIcon icon={VolumeMute02Icon} />;
|
||||
}
|
||||
return isMuted ? (
|
||||
<HugeiconsIcon icon={VolumeHighIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={VolumeOffIcon} />
|
||||
);
|
||||
};
|
||||
|
||||
{(hasAudio ? isMuted : element.hidden) && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
|
||||
{hasAudio ? (
|
||||
<VolumeX className="h-6 w-6 text-white" />
|
||||
) : (
|
||||
<EyeOff className="h-6 w-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
|
||||
{isMuted ? "Unmute" : "Mute"}
|
||||
</ActionMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-[0.6rem] cursor-w-resize bg-primary z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
|
||||
>
|
||||
<div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-[0.6rem] cursor-e-resize bg-primary z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
|
||||
>
|
||||
<div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200">
|
||||
{(!isMultipleSelected ||
|
||||
(isMultipleSelected &&
|
||||
isCurrentElementSelected &&
|
||||
canSplitSelected)) && (
|
||||
<ContextMenuItem onClick={handleElementSplitContext}>
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Split ${selectedElements.length} elements at playhead`
|
||||
: "Split at playhead"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
function VisibilityMenuItem({
|
||||
element,
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
}) {
|
||||
const isHidden = canElementBeHidden(element) && element.hidden;
|
||||
|
||||
<ContextMenuItem onClick={handleElementCopyContext}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Copy ${selectedElements.length} elements`
|
||||
: "Copy element"}
|
||||
</ContextMenuItem>
|
||||
const getIcon = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return <HugeiconsIcon icon={ViewOffSlashIcon} />;
|
||||
}
|
||||
return isHidden ? (
|
||||
<HugeiconsIcon icon={ViewIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={ViewOffSlashIcon} />
|
||||
);
|
||||
};
|
||||
|
||||
<ContextMenuItem onClick={handleToggleElementContext}>
|
||||
{isMultipleSelected && isCurrentElementSelected ? (
|
||||
hasAudioElements ? (
|
||||
<VolumeX className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
)
|
||||
) : hasAudio ? (
|
||||
isMuted ? (
|
||||
<Volume2 className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<VolumeX className="h-4 w-4 mr-2" />
|
||||
)
|
||||
) : element.hidden ? (
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
<span>
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? hasAudioElements
|
||||
? `Toggle mute ${selectedElements.length} elements`
|
||||
: `Toggle visibility ${selectedElements.length} elements`
|
||||
: hasAudio
|
||||
? isMuted
|
||||
? "Unmute"
|
||||
: "Mute"
|
||||
: element.hidden
|
||||
? "Show"
|
||||
: "Hide"}{" "}
|
||||
{!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
return (
|
||||
<ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}>
|
||||
{isHidden ? "Show" : "Hide"}
|
||||
</ActionMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
{!isMultipleSelected && (
|
||||
<ContextMenuItem onClick={handleElementDuplicateContext}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
function DeleteMenuItem({
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
elementType,
|
||||
selectedCount,
|
||||
}: {
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
elementType: TimelineElementType["type"];
|
||||
selectedCount: number;
|
||||
}) {
|
||||
return (
|
||||
<ActionMenuItem
|
||||
action="delete-selected"
|
||||
variant="destructive"
|
||||
icon={<HugeiconsIcon icon={Delete02Icon} />}
|
||||
>
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Delete ${selectedCount} elements`
|
||||
: `Delete ${elementType === "text" ? "text" : "clip"}`}
|
||||
</ActionMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
{!isMultipleSelected && element.type === "media" && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleRevealInMedia}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Reveal in media
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleReplaceClip}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Replace clip
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
onClick={handleElementDeleteContext}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Delete ${selectedElements.length} elements`
|
||||
: `Delete ${element.type === "text" ? "text" : "clip"}`}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
function ActionMenuItem({
|
||||
action,
|
||||
children,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
|
||||
action: TAction;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
invokeAction(action);
|
||||
}}
|
||||
textRight={getDisplayShortcut(action)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface TimelineMarkerProps {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
interval: number;
|
||||
isMainMarker: boolean;
|
||||
}
|
||||
|
||||
export function TimelineMarker({
|
||||
time,
|
||||
zoomLevel,
|
||||
interval,
|
||||
isMainMarker,
|
||||
}: TimelineMarkerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 h-4",
|
||||
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={cn(
|
||||
"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")}`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${Math.floor(secs)
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
if (interval >= 1) {
|
||||
return `${Math.floor(secs)}s`;
|
||||
}
|
||||
return `${secs.toFixed(1)}s`;
|
||||
};
|
||||
return formatTime(time);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { useRef } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
tracks: TimelineTrack[];
|
||||
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>;
|
||||
isSnappingToPlayhead?: boolean;
|
||||
zoomLevel: number;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
isSnappingToPlayhead?: boolean;
|
||||
}
|
||||
|
||||
export function TimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsRef,
|
||||
timelineRef,
|
||||
playheadRef: externalPlayheadRef,
|
||||
isSnappingToPlayhead = false,
|
||||
zoomLevel,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
timelineRef,
|
||||
playheadRef: externalPlayheadRef,
|
||||
isSnappingToPlayhead = false,
|
||||
}: TimelinePlayheadProps) {
|
||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const editor = useEditor();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||
|
||||
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
||||
zoomLevel,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
// Track scroll position to lock playhead to frame
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
const timelineContainerHeight =
|
||||
tracksScrollRef.current?.clientHeight ??
|
||||
timelineRef.current?.clientHeight ??
|
||||
400;
|
||||
const totalHeight = Math.max(0, timelineContainerHeight - 4);
|
||||
|
||||
if (!tracksViewport) return;
|
||||
const timelinePosition =
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const leftPosition = timelinePosition;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
};
|
||||
const handlePlayheadKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
|
||||
// Set initial scroll position
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
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),
|
||||
);
|
||||
|
||||
tracksViewport.addEventListener("scroll", handleScroll);
|
||||
return () => tracksViewport.removeEventListener("scroll", handleScroll);
|
||||
}, [tracksScrollRef]);
|
||||
editor.playback.seek({ time: nextTime });
|
||||
};
|
||||
|
||||
// Use timeline container height minus a few pixels for breathing room
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 4;
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
role="slider"
|
||||
aria-label="Timeline playhead"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={playheadPosition}
|
||||
tabIndex={0}
|
||||
className="pointer-events-auto absolute z-60"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px",
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
onKeyDown={handlePlayheadKeyDown}
|
||||
>
|
||||
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
|
||||
|
||||
// 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;
|
||||
|
||||
// Calculate position locked to timeline content (accounting for scroll)
|
||||
const timelinePosition =
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
|
||||
// Get the timeline content width and viewport width for right boundary
|
||||
const timelineContentWidth =
|
||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
const viewportWidth = tracksViewport?.clientWidth || 1000;
|
||||
|
||||
// Constrain playhead to never appear outside the timeline area
|
||||
const leftBoundary = trackLabelsWidth;
|
||||
const rightBoundary = Math.min(
|
||||
trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
|
||||
trackLabelsWidth + viewportWidth // Don't go beyond viewport
|
||||
);
|
||||
|
||||
const leftPosition = Math.max(
|
||||
leftBoundary,
|
||||
Math.min(rightBoundary, rawLeftPosition)
|
||||
);
|
||||
|
||||
// Debug logging when playhead might go outside
|
||||
if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) {
|
||||
console.log(
|
||||
"PLAYHEAD VISUAL DEBUG:",
|
||||
JSON.stringify({
|
||||
playheadPosition,
|
||||
timelinePosition,
|
||||
trackLabelsWidth,
|
||||
scrollLeft,
|
||||
rawLeftPosition,
|
||||
constrainedLeftPosition: leftPosition,
|
||||
leftBoundary,
|
||||
rightBoundary,
|
||||
timelineContentWidth,
|
||||
viewportWidth,
|
||||
zoomLevel,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-40"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px", // Slightly wider for better click target
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* 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-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<div
|
||||
className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export a hook for getting ruler handlers
|
||||
export function useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
|
||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
return { handleRulerMouseDown, isDraggingRuler };
|
||||
}
|
||||
|
||||
export { TimelinePlayhead as default };
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { TIMELINE_CONSTANTS } 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";
|
||||
import { TimelineTick } from "./timeline-tick";
|
||||
|
||||
interface TimelineRulerProps {
|
||||
zoomLevel: number;
|
||||
dynamicTimelineWidth: number;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function TimelineRuler({
|
||||
zoomLevel,
|
||||
dynamicTimelineWidth,
|
||||
rulerRef,
|
||||
handleWheel,
|
||||
handleTimelineContentClick,
|
||||
handleRulerTrackingMouseDown,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineRulerProps) {
|
||||
const editor = useEditor();
|
||||
const duration = editor.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 { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
|
||||
zoomLevel,
|
||||
fps,
|
||||
});
|
||||
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
|
||||
|
||||
const timelineTicks: Array<JSX.Element> = [];
|
||||
for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
|
||||
const time = tickIndex * tickIntervalSeconds;
|
||||
if (time > effectiveDuration) break;
|
||||
|
||||
const showLabel = shouldShowLabel({ time, labelIntervalSeconds });
|
||||
timelineTicks.push(
|
||||
<TimelineTick
|
||||
key={tickIndex}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
fps={fps}
|
||||
showLabel={showLabel}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Timeline ruler"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={effectiveDuration}
|
||||
aria-valuenow={0}
|
||||
className="relative h-4 flex-1 overflow-x-visible"
|
||||
onWheel={handleWheel}
|
||||
onClick={handleTimelineContentClick}
|
||||
onMouseDown={handleRulerTrackingMouseDown}
|
||||
onKeyDown={() => {}}
|
||||
>
|
||||
<div
|
||||
role="none"
|
||||
ref={rulerRef}
|
||||
className="relative h-4 cursor-default select-none"
|
||||
style={{
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
>
|
||||
{timelineTicks}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
|
||||
|
||||
interface TimelineTickProps {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
fps: number;
|
||||
showLabel: boolean;
|
||||
}
|
||||
|
||||
export function TimelineTick({
|
||||
time,
|
||||
zoomLevel,
|
||||
fps,
|
||||
showLabel,
|
||||
}: TimelineTickProps) {
|
||||
const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
if (showLabel) {
|
||||
const label = formatRulerLabel({ timeInSeconds: time, fps });
|
||||
return (
|
||||
<span
|
||||
className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none"
|
||||
style={{ left: `${leftPosition}px` }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l"
|
||||
style={{ left: `${leftPosition}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,397 +1,355 @@
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useSceneStore } from "@/stores/scene-store";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkipBack, SplitSquareHorizontal } from "lucide-react";
|
||||
import {
|
||||
Pause,
|
||||
Play,
|
||||
SkipBack,
|
||||
Bookmark,
|
||||
Magnet,
|
||||
Link,
|
||||
ZoomOut,
|
||||
ZoomIn,
|
||||
Copy,
|
||||
Trash2,
|
||||
Snowflake,
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
SplitSquareHorizontal,
|
||||
Scissors,
|
||||
LayersIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SplitButton,
|
||||
SplitButtonLeft,
|
||||
SplitButtonRight,
|
||||
SplitButtonSeparator,
|
||||
SplitButton,
|
||||
SplitButtonLeft,
|
||||
SplitButtonRight,
|
||||
SplitButtonSeparator,
|
||||
} from "@/components/ui/split-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { DEFAULT_FPS } from "@/stores/project-store";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { ScenesView } from "../scenes-view";
|
||||
import { type TAction, invokeAction } from "@/lib/actions";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Bookmark02Icon,
|
||||
Delete02Icon,
|
||||
SnowIcon,
|
||||
ScissorIcon,
|
||||
MagnetIcon,
|
||||
Link04Icon,
|
||||
SearchAddIcon,
|
||||
SearchMinusIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
Copy01Icon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
Layers01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
export function TimelineToolbar({
|
||||
zoomLevel,
|
||||
setZoomLevel,
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
setZoomLevel,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoom: number) => void;
|
||||
zoomLevel: number;
|
||||
minZoom: number;
|
||||
setZoomLevel: ({ zoom }: { zoom: number }) => void;
|
||||
}) {
|
||||
const {
|
||||
tracks,
|
||||
addTrack,
|
||||
addElementToTrack,
|
||||
selectedElements,
|
||||
clearSelectedElements,
|
||||
deleteSelected,
|
||||
splitSelected,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
snappingEnabled,
|
||||
toggleSnapping,
|
||||
rippleEditingEnabled,
|
||||
toggleRippleEditing,
|
||||
} = useTimelineStore();
|
||||
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
|
||||
const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
|
||||
const { scenes, currentScene } = useSceneStore();
|
||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||
const newZoomLevel =
|
||||
direction === "in"
|
||||
? Math.min(
|
||||
TIMELINE_CONSTANTS.ZOOM_MAX,
|
||||
zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
|
||||
)
|
||||
: Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP);
|
||||
setZoomLevel({ zoom: newZoomLevel });
|
||||
};
|
||||
|
||||
const handleSplitSelected = () => {
|
||||
splitSelected(currentTime);
|
||||
};
|
||||
return (
|
||||
<ScrollArea className="scrollbar-hidden">
|
||||
<div className="flex h-10 items-center justify-between border-b px-2 py-1">
|
||||
<ToolbarLeftSection />
|
||||
|
||||
const handleDuplicateSelected = () => {
|
||||
if (selectedElements.length === 0) return;
|
||||
const canDuplicate = selectedElements.length === 1;
|
||||
if (!canDuplicate) return;
|
||||
<SceneSelector />
|
||||
|
||||
selectedElements.forEach(({ trackId, elementId }) => {
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((el) => el.id === elementId);
|
||||
if (element) {
|
||||
const newStartTime =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1;
|
||||
const { id, ...elementWithoutId } = element;
|
||||
addElementToTrack(trackId, {
|
||||
...elementWithoutId,
|
||||
startTime: newStartTime,
|
||||
});
|
||||
}
|
||||
});
|
||||
clearSelectedElements();
|
||||
};
|
||||
<ToolbarRightSection
|
||||
zoomLevel={zoomLevel}
|
||||
minZoom={minZoom}
|
||||
onZoomChange={(zoom) => setZoomLevel({ zoom })}
|
||||
onZoom={handleZoom}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFreezeSelected = () => {
|
||||
toast.info("Freeze frame functionality coming soon!");
|
||||
};
|
||||
function ToolbarLeftSection() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
|
||||
|
||||
const handleSplitAndKeepLeft = () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((c) => c.id === elementId);
|
||||
if (!element) return;
|
||||
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 selected element");
|
||||
return;
|
||||
}
|
||||
splitAndKeepLeft(trackId, elementId, currentTime);
|
||||
};
|
||||
const handleAction = ({
|
||||
action,
|
||||
event,
|
||||
}: {
|
||||
action: TAction;
|
||||
event: React.MouseEvent;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
invokeAction(action);
|
||||
};
|
||||
|
||||
const handleSplitAndKeepRight = () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((c) => c.id === elementId);
|
||||
if (!element) return;
|
||||
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 selected element");
|
||||
return;
|
||||
}
|
||||
splitAndKeepRight(trackId, elementId, currentTime);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
isPlaying ? (
|
||||
<HugeiconsIcon icon={PauseIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={PlayIcon} />
|
||||
)
|
||||
}
|
||||
tooltip={isPlaying ? "Pause" : "Play"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-play", event })
|
||||
}
|
||||
/>
|
||||
|
||||
const handleSeparateAudio = () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one media element to separate audio");
|
||||
return;
|
||||
}
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
if (!track || track.type !== "media") {
|
||||
toast.error("Select a media element to separate audio");
|
||||
return;
|
||||
}
|
||||
separateAudio(trackId, elementId);
|
||||
};
|
||||
<ToolbarButton
|
||||
icon={<SkipBack />}
|
||||
tooltip="Go to start"
|
||||
onClick={({ event }) => handleAction({ action: "goto-start", event })}
|
||||
/>
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
deleteSelected();
|
||||
};
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setZoomLevel(Math.min(4, zoomLevel + 0.25));
|
||||
};
|
||||
<TimeDisplay />
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
|
||||
};
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
const handleZoomSliderChange = (values: number[]) => {
|
||||
setZoomLevel(values[0]);
|
||||
};
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||
tooltip="Split element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split", event })
|
||||
}
|
||||
/>
|
||||
|
||||
const handleToggleBookmark = async () => {
|
||||
await toggleBookmark(currentTime);
|
||||
};
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
|
||||
tooltip="Split left"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-left", event })
|
||||
}
|
||||
/>
|
||||
|
||||
const currentBookmarked = isBookmarked(currentTime);
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b h-10">
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={toggle}>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={() => seek(0)}>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
{/* Time Display */}
|
||||
<div className="flex flex-row items-center justify-center px-2">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={duration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={activeProject?.fps ?? DEFAULT_FPS}
|
||||
onTimeChange={seek}
|
||||
className="text-center"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground font-mono px-2">
|
||||
/
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono text-center">
|
||||
{formatTimeCode(duration, "HH:MM:SS:FF")}
|
||||
</div>
|
||||
</div>
|
||||
{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>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
|
||||
<Bookmark
|
||||
className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div>
|
||||
<SplitButton className="border border-foreground/10">
|
||||
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
|
||||
<SplitButtonSeparator />
|
||||
<ScenesView>
|
||||
<SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}>
|
||||
<LayersIcon />
|
||||
</SplitButtonRight>
|
||||
</ScenesView>
|
||||
</SplitButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={toggleSnapping}>
|
||||
{snappingEnabled ? (
|
||||
<Magnet className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Magnet className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto snapping</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={toggleRippleEditing}>
|
||||
<Link
|
||||
className={`h-4 w-4 ${
|
||||
rippleEditingEnabled ? "text-primary" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{rippleEditingEnabled
|
||||
? "Disable Ripple Editing"
|
||||
: "Enable Ripple Editing"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={AlignRightIcon} />}
|
||||
tooltip="Split right"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-right", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-border mx-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="text" size="icon" onClick={handleZoomOut}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Slider
|
||||
className="w-24"
|
||||
value={[zoomLevel]}
|
||||
onValueChange={handleZoomSliderChange}
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.25}
|
||||
/>
|
||||
<Button variant="text" size="icon" onClick={handleZoomIn}>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<ToolbarButton
|
||||
icon={<SplitSquareHorizontal />}
|
||||
tooltip="Coming soon" /* separate audio */
|
||||
disabled={true}
|
||||
onClick={({ event: _event }) => {}}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
tooltip="Duplicate element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "duplicate-selected", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={SnowIcon} />}
|
||||
tooltip="Coming soon" /* freeze frame */
|
||||
disabled={true}
|
||||
onClick={({ event: _event }) => {}}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={Delete02Icon} />}
|
||||
tooltip="Delete element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "delete-selected", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<Tooltip>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={Bookmark02Icon}
|
||||
className={currentBookmarked ? "fill-primary text-primary" : ""}
|
||||
/>
|
||||
}
|
||||
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeDisplay() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const totalDuration = editor.timeline.getTotalDuration();
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center px-2">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
|
||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
||||
{formatTimeCode({
|
||||
timeInSeconds: totalDuration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneSelector() {
|
||||
const editor = useEditor();
|
||||
const currentScene = editor.scenes.getActiveScene();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SplitButton className="border-foreground/10 border">
|
||||
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
|
||||
<SplitButtonSeparator />
|
||||
<ScenesView>
|
||||
<SplitButtonRight onClick={() => {}} type="button">
|
||||
<HugeiconsIcon icon={Layers01Icon} className="size-4" />
|
||||
</SplitButtonRight>
|
||||
</ScenesView>
|
||||
</SplitButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarRightSection({
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
onZoomChange,
|
||||
onZoom,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
minZoom: number;
|
||||
onZoomChange: (zoom: number) => void;
|
||||
onZoom: (options: { direction: "in" | "out" }) => void;
|
||||
}) {
|
||||
const {
|
||||
snappingEnabled,
|
||||
rippleEditingEnabled,
|
||||
toggleSnapping,
|
||||
toggleRippleEditing,
|
||||
} = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={MagnetIcon}
|
||||
className={cn(snappingEnabled ? "text-primary" : "")}
|
||||
/>
|
||||
}
|
||||
tooltip="Auto snapping"
|
||||
onClick={() => toggleSnapping()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={Link04Icon}
|
||||
className={cn(
|
||||
rippleEditingEnabled ? "text-primary" : "",
|
||||
"scale-110",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
tooltip="Ripple editing"
|
||||
onClick={() => toggleRippleEditing()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "out" })}
|
||||
>
|
||||
<HugeiconsIcon icon={SearchAddIcon} />
|
||||
</Button>
|
||||
<Slider
|
||||
className="w-24"
|
||||
value={[zoomLevel]}
|
||||
onValueChange={(values) => onZoomChange(values[0])}
|
||||
min={minZoom}
|
||||
max={TIMELINE_CONSTANTS.ZOOM_MAX}
|
||||
step={TIMELINE_CONSTANTS.ZOOM_STEP}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "in" })}
|
||||
>
|
||||
<HugeiconsIcon icon={SearchMinusIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
icon,
|
||||
tooltip,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={disabled ? "cursor-not-allowed opacity-50" : ""}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user