diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
index 1723dc11..cc9190e2 100644
--- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -1,411 +1,419 @@
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { toast } from "sonner";
-import {
- TooltipProvider,
- Tooltip,
- TooltipTrigger,
- TooltipContent,
-} from "@/components/ui/tooltip";
-import { Button } from "@/components/ui/button";
-import {
- Pause,
- Play,
- SkipBack,
- Bookmark,
- Magnet,
- Link,
- ZoomOut,
- ZoomIn,
- Copy,
- Trash2,
- Snowflake,
- ArrowLeftToLine,
- ArrowRightToLine,
- SplitSquareHorizontal,
- Scissors,
-} from "lucide-react";
-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";
-
-export function TimelineToolbar({
- zoomLevel,
- setZoomLevel,
-}: {
- zoomLevel: number;
- setZoomLevel: (zoom: number) => void;
-}) {
- const {
- tracks,
- addTrack,
- addElementToTrack,
- removeElementFromTrack,
- removeElementFromTrackWithRipple,
- selectedElements,
- clearSelectedElements,
- splitElement,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- snappingEnabled,
- toggleSnapping,
- rippleEditingEnabled,
- toggleRippleEditing,
- } = useTimelineStore();
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
- const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
-
- const handleSplitSelected = () => {
- if (selectedElements.length === 0) return;
- let splitCount = 0;
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (element && track) {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const newElementId = splitElement(trackId, elementId, currentTime);
- if (newElementId) splitCount++;
- }
- }
- });
- if (splitCount === 0) {
- toast.error("Playhead must be within selected elements to split");
- }
- };
-
- const handleDuplicateSelected = () => {
- if (selectedElements.length === 0) return;
- const canDuplicate = selectedElements.length === 1;
- if (!canDuplicate) return;
-
- 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();
- };
-
- const handleFreezeSelected = () => {
- toast.info("Freeze frame functionality coming soon!");
- };
-
- 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 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);
- };
-
- 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);
- };
-
- const handleDeleteSelected = () => {
- if (selectedElements.length === 0) return;
- selectedElements.forEach(({ trackId, elementId }) => {
- if (rippleEditingEnabled) {
- removeElementFromTrackWithRipple(trackId, elementId);
- } else {
- removeElementFromTrack(trackId, elementId);
- }
- });
- clearSelectedElements();
- };
-
- const handleZoomIn = () => {
- setZoomLevel(Math.min(4, zoomLevel + 0.25));
- };
-
- const handleZoomOut = () => {
- setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
- };
-
- const handleZoomSliderChange = (values: number[]) => {
- setZoomLevel(values[0]);
- };
-
- const handleToggleBookmark = async () => {
- await toggleBookmark(currentTime);
- };
-
- const currentBookmarked = isBookmarked(currentTime);
- return (
-
-
-
-
-
-
-
-
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
-
-
-
-
-
-
- Return to Start (Home / Enter)
-
-
- {/* Time Display */}
-
-
-
- /
-
-
- {formatTimeCode(duration, "HH:MM:SS:FF")}
-
-
- {tracks.length === 0 && (
- <>
-
-
-
-
-
- Add a test clip to try playback
-
- >
- )}
-
-
-
-
-
- Split element (Ctrl+S)
-
-
-
-
-
- Split and keep left (Ctrl+Q)
-
-
-
-
-
- Split and keep right (Ctrl+W)
-
-
-
-
-
- Separate audio (Ctrl+D)
-
-
-
-
-
- Duplicate element (Ctrl+D)
-
-
-
-
-
- Freeze frame (F)
-
-
-
-
-
- Delete element (Delete)
-
-
-
-
-
-
-
- {currentBookmarked ? "Remove bookmark" : "Add bookmark"}
-
-
-
-
-
-
-
-
-
-
- Auto snapping
-
-
-
-
-
-
- {rippleEditingEnabled
- ? "Disable Ripple Editing"
- : "Enable Ripple Editing"}
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { toast } from "sonner";
+import {
+ TooltipProvider,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+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,
+} 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";
+
+export function TimelineToolbar({
+ zoomLevel,
+ setZoomLevel,
+}: {
+ zoomLevel: number;
+ setZoomLevel: (zoom: number) => void;
+}) {
+ const {
+ tracks,
+ addTrack,
+ addElementToTrack,
+ removeElementFromTrack,
+ removeElementFromTrackWithRipple,
+ selectedElements,
+ clearSelectedElements,
+ splitElement,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
+ snappingEnabled,
+ toggleSnapping,
+ rippleEditingEnabled,
+ toggleRippleEditing,
+ } = useTimelineStore();
+ const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
+ const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
+
+ const handleSplitSelected = () => {
+ if (selectedElements.length === 0) return;
+ let splitCount = 0;
+ selectedElements.forEach(({ trackId, elementId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (element && track) {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ const newElementId = splitElement(trackId, elementId, currentTime);
+ if (newElementId) splitCount++;
+ }
+ }
+ });
+ if (splitCount === 0) {
+ toast.error("Playhead must be within selected elements to split");
+ }
+ };
+
+ const handleDuplicateSelected = () => {
+ if (selectedElements.length === 0) return;
+ const canDuplicate = selectedElements.length === 1;
+ if (!canDuplicate) return;
+
+ 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();
+ };
+
+ const handleFreezeSelected = () => {
+ toast.info("Freeze frame functionality coming soon!");
+ };
+
+ 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 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);
+ };
+
+ 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);
+ };
+
+ const handleDeleteSelected = () => {
+ if (selectedElements.length === 0) return;
+ selectedElements.forEach(({ trackId, elementId }) => {
+ if (rippleEditingEnabled) {
+ removeElementFromTrackWithRipple(trackId, elementId);
+ } else {
+ removeElementFromTrack(trackId, elementId);
+ }
+ });
+ clearSelectedElements();
+ };
+
+ const handleZoomIn = () => {
+ setZoomLevel(Math.min(4, zoomLevel + 0.25));
+ };
+
+ const handleZoomOut = () => {
+ setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
+ };
+
+ const handleZoomSliderChange = (values: number[]) => {
+ setZoomLevel(values[0]);
+ };
+
+ const handleToggleBookmark = async () => {
+ await toggleBookmark(currentTime);
+ };
+
+ const currentBookmarked = isBookmarked(currentTime);
+ return (
+
+
+
+
+
+
+
+
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+
+
+
+
+
+
+ Return to Start (Home / Enter)
+
+
+ {/* Time Display */}
+
+
+
+ /
+
+
+ {formatTimeCode(duration, "HH:MM:SS:FF")}
+
+
+ {tracks.length === 0 && (
+ <>
+
+
+
+
+
+ Add a test clip to try playback
+
+ >
+ )}
+
+
+
+
+
+ Split element (Ctrl+S)
+
+
+
+
+
+ Split and keep left (Ctrl+Q)
+
+
+
+
+
+ Split and keep right (Ctrl+W)
+
+
+
+
+
+ Separate audio (Ctrl+D)
+
+
+
+
+
+ Duplicate element (Ctrl+D)
+
+
+
+
+
+ Freeze frame (F)
+
+
+
+
+
+ Delete element (Delete)
+
+
+
+
+
+
+
+ {currentBookmarked ? "Remove bookmark" : "Add bookmark"}
+
+
+
+
+
+
+ Main scene
+
+ {}}>
+
+
+
+
+
+
+
+
+
+
+ Auto snapping
+
+
+
+
+
+
+ {rippleEditingEnabled
+ ? "Disable Ripple Editing"
+ : "Enable Ripple Editing"}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx
new file mode 100644
index 00000000..1ef29500
--- /dev/null
+++ b/apps/web/src/components/ui/split-button.tsx
@@ -0,0 +1,90 @@
+import { Button, ButtonProps } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { ReactNode, forwardRef } from "react";
+import { cn } from "@/lib/utils";
+
+interface SplitButtonProps {
+ children: ReactNode;
+ className?: string;
+}
+
+interface SplitButtonSideProps extends Omit {
+ children: ReactNode;
+}
+
+const SplitButton = forwardRef(
+ ({ children, className, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+SplitButton.displayName = "SplitButton";
+
+const SplitButtonSide = forwardRef<
+ HTMLButtonElement,
+ SplitButtonSideProps & { paddingClass: string }
+>(({ children, className, paddingClass, onClick, ...props }, ref) => {
+ return (
+
+ );
+});
+SplitButtonSide.displayName = "SplitButtonSide";
+
+const SplitButtonLeft = forwardRef(
+ ({ ...props }, ref) => {
+ return ;
+ }
+);
+SplitButtonLeft.displayName = "SplitButtonLeft";
+
+const SplitButtonRight = forwardRef(
+ ({ ...props }, ref) => {
+ return ;
+ }
+);
+SplitButtonRight.displayName = "SplitButtonRight";
+
+const SplitButtonSeparator = forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SplitButtonSeparator.displayName = "SplitButtonSeparator";
+
+export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };