summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/timeline')
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx479
-rw-r--r--apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx126
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx190
-rw-r--r--apps/web/src/components/editor/timeline/timeline-marker.tsx67
-rw-r--r--apps/web/src/components/editor/timeline/timeline-toolbar.tsx397
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx15
6 files changed, 735 insertions, 539 deletions
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index c994d707..4a59fd21 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -57,6 +57,9 @@ import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "../snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
+import { TimelineCacheIndicator } from "./timeline-cache-indicator";
+import { TimelineMarker } from "./timeline-marker";
+import { useFrameCache } from "@/hooks/use-frame-cache";
import {
getTrackHeight,
getCumulativeHeightBefore,
@@ -67,6 +70,7 @@ import {
import { Slider } from "@/components/ui/slider";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { TimelineToolbar } from "./timeline-toolbar";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements.
@@ -85,6 +89,7 @@ export function Timeline() {
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
+ const { getRenderStatus } = useFrameCache();
const [isDragOver, setIsDragOver] = useState(false);
const { addElementToNewTrack } = useTimelineStore();
const dragCounterRef = useRef(0);
@@ -658,7 +663,21 @@ export function Timeline() {
onClick={handleTimelineContentClick}
data-ruler-area
>
- <ScrollArea className="w-full" ref={rulerScrollRef}>
+ <ScrollArea
+ className="w-full"
+ ref={rulerScrollRef}
+ onScroll={(e) => {
+ if (isUpdatingRef.current) return;
+ isUpdatingRef.current = true;
+ const tracksViewport = tracksScrollRef.current;
+ if (tracksViewport) {
+ tracksViewport.scrollLeft = (
+ e.currentTarget as HTMLDivElement
+ ).scrollLeft;
+ }
+ isUpdatingRef.current = false;
+ }}
+ >
<div
ref={rulerRef}
className="relative h-10 select-none cursor-default"
@@ -667,6 +686,14 @@ export function Timeline() {
}}
onMouseDown={handleRulerMouseDown}
>
+ <TimelineCacheIndicator
+ duration={duration}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ mediaFiles={mediaFiles}
+ activeProject={activeProject}
+ getRenderStatus={getRenderStatus}
+ />
{/* Time markers */}
{(() => {
// Calculate appropriate time interval based on zoom level
@@ -693,55 +720,13 @@ export function Timeline() {
time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
return (
- <div
+ <TimelineMarker
key={i}
- className={`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={`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>
+ time={time}
+ zoomLevel={zoomLevel}
+ interval={interval}
+ isMainMarker={isMainMarker}
+ />
);
}).filter(Boolean);
})()}
@@ -836,7 +821,21 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
- <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
+ <ScrollArea
+ className="w-full h-full"
+ ref={tracksScrollRef}
+ onScroll={(e) => {
+ if (isUpdatingRef.current) return;
+ isUpdatingRef.current = true;
+ const rulerViewport = rulerScrollRef.current;
+ if (rulerViewport) {
+ rulerViewport.scrollLeft = (
+ e.currentTarget as HTMLDivElement
+ ).scrollLeft;
+ }
+ isUpdatingRef.current = false;
+ }}
+ >
<div
className="relative flex-1"
style={{
@@ -924,383 +923,3 @@ function TrackIcon({ track }: { track: TimelineTrack }) {
</>
);
}
-
-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 (
- <div className="border-b flex items-center justify-between px-2 py-1">
- <div className="flex items-center gap-1 w-full">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- className="mr-2"
- >
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={() => seek(0)}
- className="mr-2"
- >
- <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 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>
-
- <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>
- );
-}
diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
new file mode 100644
index 00000000..7def2985
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
@@ -0,0 +1,126 @@
+"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>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index 677b9c61..ca47d3ca 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -16,7 +16,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "../audio-waveform";
import { toast } from "sonner";
-import { TimelineElementProps } from "@/types/timeline";
+import { TimelineElementProps, MediaElement } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
@@ -42,16 +42,17 @@ export function TimelineElement({
}: TimelineElementProps) {
const { mediaFiles } = useMediaStore();
const {
- removeElementFromTrack,
- removeElementFromTrackWithRipple,
dragState,
- splitElement,
- addElementToTrack,
- replaceElementMedia,
- rippleEditingEnabled,
- toggleElementHidden,
- toggleElementMuted,
copySelected,
+ selectedElements,
+ deleteSelected,
+ splitSelected,
+ toggleSelectedHidden,
+ toggleSelectedMuted,
+ duplicateElement,
+ revealElementInMedia,
+ replaceElementWithFile,
+ getContextMenuState,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -68,7 +69,12 @@ export function TimelineElement({
zoomLevel,
});
- const { requestRevealMedia } = useMediaPanelStore.getState();
+ const {
+ isMultipleSelected,
+ isCurrentElementSelected,
+ hasAudioElements,
+ canSplitSelected,
+ } = getContextMenuState(track.id, element.id);
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
@@ -77,44 +83,26 @@ export function TimelineElement({
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
);
- // Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.elementId === element.id;
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
- // Element should always be positioned at startTime - trimStart only affects content, not position
const elementLeft = elementStartTime * 50 * zoomLevel;
const handleElementSplitContext = (e: React.MouseEvent) => {
e.stopPropagation();
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const secondElementId = splitElement(track.id, element.id, currentTime);
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- } else {
- toast.error("Playhead must be within element to split");
- }
+ splitSelected(
+ currentTime,
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
};
const handleElementDuplicateContext = (e: React.MouseEvent) => {
e.stopPropagation();
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
+ duplicateElement(track.id, element.id);
};
const handleElementCopyContext = (e: React.MouseEvent) => {
@@ -124,49 +112,38 @@ export function TimelineElement({
const handleElementDeleteContext = (e: React.MouseEvent) => {
e.stopPropagation();
- if (rippleEditingEnabled) {
- removeElementFromTrackWithRipple(track.id, element.id);
- } else {
- removeElementFromTrack(track.id, element.id);
- }
+ deleteSelected(
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
};
const handleToggleElementContext = (e: React.MouseEvent) => {
e.stopPropagation();
+
if (hasAudio && element.type === "media") {
- toggleElementMuted(track.id, element.id);
- return;
+ toggleSelectedMuted(
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
+ } else {
+ toggleSelectedHidden(
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
}
- toggleElementHidden(track.id, element.id);
};
const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation();
- if (element.type !== "media") {
- toast.error("Replace is only available for media clips");
- return;
- }
- // Create a file input to select replacement media
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) return;
-
- try {
- const result = await replaceElementMedia(track.id, element.id, file);
- if (result.success) {
- toast.success("Clip replaced successfully");
- } else {
- toast.error(result.error || "Failed to replace clip");
- }
- } catch (error) {
- console.error("Unexpected error replacing clip:", error);
- toast.error(
- `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
- );
+ if (file) {
+ await replaceElementWithFile(track.id, element.id, file);
}
};
input.click();
@@ -174,12 +151,7 @@ export function TimelineElement({
const handleRevealInMedia = (e: React.MouseEvent) => {
e.stopPropagation();
- if (element.type !== "media") {
- toast.error("Reveal is only available for media clips");
- return;
- }
-
- requestRevealMedia(element.mediaId);
+ revealElementInMedia(element.id);
};
const renderElementContent = () => {
@@ -191,7 +163,6 @@ export function TimelineElement({
);
}
- // Render media element ->
const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaItem) {
return (
@@ -201,20 +172,15 @@ export function TimelineElement({
);
}
- const TILE_ASPECT_RATIO = 16 / 9;
-
if (
mediaItem.type === "image" ||
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
) {
- // Calculate tile size based on 16:9 aspect ratio
const trackHeight = getTrackHeight(track.type);
- const tileHeight = trackHeight;
- const tileWidth = tileHeight * TILE_ASPECT_RATIO;
+ const tileWidth = trackHeight * (16 / 9);
const imageUrl =
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
- const isImage = mediaItem.type === "image";
return (
<div className="w-full h-full flex items-center justify-center">
@@ -228,18 +194,17 @@ export function TimelineElement({
style={{
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${tileHeight}px`,
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
- aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`}
+ aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
/>
</div>
</div>
);
}
- // Render audio element ->
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
@@ -267,7 +232,7 @@ export function TimelineElement({
}
};
- const isMuted = element.type === "media" ? element.muted === true : false;
+ const isMuted = element.type === "media" && element.muted;
return (
<ContextMenu>
@@ -332,16 +297,33 @@ export function TimelineElement({
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200">
- <ContextMenuItem onClick={handleElementSplitContext}>
- <Scissors className="h-4 w-4 mr-2" />
- Split at playhead
- </ContextMenuItem>
+ {(!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>
+ )}
+
<ContextMenuItem onClick={handleElementCopyContext}>
<Copy className="h-4 w-4 mr-2" />
- Copy element
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Copy ${selectedElements.length} elements`
+ : "Copy element"}
</ContextMenuItem>
+
<ContextMenuItem onClick={handleToggleElementContext}>
- {hasAudio ? (
+ {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" />
) : (
@@ -353,21 +335,29 @@ export function TimelineElement({
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
- {hasAudio
- ? isMuted
- ? "Unmute"
- : "Mute"
- : element.hidden
- ? "Show"
- : "Hide"}{" "}
- {element.type === "text" ? "text" : "clip"}
+ {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>
- <ContextMenuItem onClick={handleElementDuplicateContext}>
- <Copy className="h-4 w-4 mr-2" />
- Duplicate {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- {element.type === "media" && (
+
+ {!isMultipleSelected && (
+ <ContextMenuItem onClick={handleElementDuplicateContext}>
+ <Copy className="h-4 w-4 mr-2" />
+ Duplicate {element.type === "text" ? "text" : "clip"}
+ </ContextMenuItem>
+ )}
+
+ {!isMultipleSelected && element.type === "media" && (
<>
<ContextMenuItem onClick={handleRevealInMedia}>
<Search className="h-4 w-4 mr-2" />
@@ -379,13 +369,17 @@ export function TimelineElement({
</ContextMenuItem>
</>
)}
+
<ContextMenuSeparator />
+
<ContextMenuItem
onClick={handleElementDeleteContext}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
- Delete {element.type === "text" ? "text" : "clip"}
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedElements.length} elements`
+ : `Delete ${element.type === "text" ? "text" : "clip"}`}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx
new file mode 100644
index 00000000..1351a457
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-marker.tsx
@@ -0,0 +1,67 @@
+"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>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
new file mode 100644
index 00000000..80c6ce08
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -0,0 +1,397 @@
+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 {
+ 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";
+import { ScenesView } from "../scenes-view";
+
+export function TimelineToolbar({
+ zoomLevel,
+ setZoomLevel,
+}: {
+ zoomLevel: number;
+ setZoomLevel: (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 handleSplitSelected = () => {
+ splitSelected(currentTime);
+ };
+
+ 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 = () => {
+ deleteSelected();
+ };
+
+ 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 (
+ <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>
+
+ <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>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 42c78aa4..7cfde931 100644
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
@@ -1147,7 +1147,7 @@ export function TimelineTrackContent({
const handleElementSplit = () => {
const { currentTime } = usePlaybackStore();
- const { splitElement } = useTimelineStore();
+ const { splitSelected } = useTimelineStore();
const splitTime = currentTime;
const effectiveStart = element.startTime;
const effectiveEnd =
@@ -1155,14 +1155,7 @@ export function TimelineTrackContent({
(element.duration - element.trimStart - element.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- const secondElementId = splitElement(
- track.id,
- element.id,
- splitTime
- );
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
+ splitSelected(splitTime, track.id, element.id);
} else {
toast.error("Playhead must be within element to split");
}
@@ -1182,8 +1175,8 @@ export function TimelineTrackContent({
};
const handleElementDelete = () => {
- const { removeElementFromTrack } = useTimelineStore.getState();
- removeElementFromTrack(track.id, element.id);
+ const { deleteSelected } = useTimelineStore.getState();
+ deleteSelected(track.id, element.id);
};
return (