codebase overhaul (#697)

This commit is contained in:
Maze
2026-01-31 00:20:04 +01:00
committed by GitHub
parent 0173db9944
commit 7bf0984698
469 changed files with 36184 additions and 32931 deletions
@@ -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