summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/timeline
diff options
context:
space:
mode:
authorryu <ryumacodes@gmail.com>2025-08-13 14:01:07 +0500
committerGitHub <noreply@github.com>2025-08-13 14:01:07 +0500
commit0ae09c9df5591e2d18991a9f98647826997fe740 (patch)
tree69738461bd269ad418bc82c47e64a2670cee7f19 /apps/web/src/components/editor/timeline
parente71788014d137a74f10420601451ddfbb84701bb (diff)
parentf7fddff1fc8359fc056a2a90488121980fb8ead2 (diff)
downloadopencut-0ae09c9df5591e2d18991a9f98647826997fe740.tar.gz
Merge branch 'staging' into feat/timeline-return-to-start
Diffstat (limited to 'apps/web/src/components/editor/timeline')
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx147
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx25
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx10
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx51
4 files changed, 188 insertions, 45 deletions
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index bff0a90a..669d556c 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -55,7 +55,7 @@ import { SelectionBox } from "../selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "../snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
-import type { DragData, TimelineTrack } from "@/types/timeline";
+import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
import {
getTrackHeight,
getCumulativeHeightBefore,
@@ -169,11 +169,32 @@ export function Timeline() {
const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
// Only track mouse down on timeline background areas (not elements)
const target = e.target as HTMLElement;
+ console.log(
+ JSON.stringify({
+ debug_mousedown: "START",
+ target_class: target.className,
+ target_parent_class: target.parentElement?.className,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ timeStamp: e.timeStamp,
+ })
+ );
+
const isTimelineBackground =
!target.closest(".timeline-element") &&
!playheadRef.current?.contains(target) &&
!target.closest("[data-track-labels]");
+ console.log(
+ JSON.stringify({
+ debug_mousedown: "CHECK",
+ isTimelineBackground,
+ hasTimelineElement: !!target.closest(".timeline-element"),
+ hasPlayhead: !!playheadRef.current?.contains(target),
+ hasTrackLabels: !!target.closest("[data-track-labels]"),
+ })
+ );
+
if (isTimelineBackground) {
mouseTrackingRef.current = {
isMouseDown: true,
@@ -181,12 +202,38 @@ export function Timeline() {
downY: e.clientY,
downTime: e.timeStamp,
};
+ console.log(
+ JSON.stringify({
+ debug_mousedown: "TRACKED",
+ mouseTracking: mouseTrackingRef.current,
+ })
+ );
+ } else {
+ console.log(
+ JSON.stringify({
+ debug_mousedown: "IGNORED - not timeline background",
+ })
+ );
}
}, []);
// Timeline content click to seek handler
const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => {
+ console.log(
+ JSON.stringify({
+ debug_click: "START",
+ target: (e.target as HTMLElement).className,
+ target_parent: (e.target as HTMLElement).parentElement?.className,
+ mouseTracking: mouseTrackingRef.current,
+ isSelecting,
+ justFinishedSelecting,
+ clickX: e.clientX,
+ clickY: e.clientY,
+ timeStamp: e.timeStamp,
+ })
+ );
+
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
// Reset mouse tracking
@@ -201,8 +248,8 @@ export function Timeline() {
if (!isMouseDown) {
console.log(
JSON.stringify({
- ignoredClickWithoutMouseDown: true,
- timeStamp: e.timeStamp,
+ debug_click: "REJECTED - no mousedown",
+ mouseTracking: mouseTrackingRef.current,
})
);
return;
@@ -216,11 +263,10 @@ export function Timeline() {
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) {
console.log(
JSON.stringify({
- ignoredDragNotClick: true,
+ debug_click: "REJECTED - movement too large",
deltaX,
deltaY,
deltaTime,
- timeStamp: e.timeStamp,
})
);
return;
@@ -228,27 +274,54 @@ export function Timeline() {
// Don't seek if this was a selection box operation
if (isSelecting || justFinishedSelecting) {
+ console.log(
+ JSON.stringify({
+ debug_click: "REJECTED - selection operation",
+ isSelecting,
+ justFinishedSelecting,
+ })
+ );
return;
}
// Don't seek if clicking on timeline elements, but still deselect
if ((e.target as HTMLElement).closest(".timeline-element")) {
+ console.log(
+ JSON.stringify({
+ debug_click: "REJECTED - clicked timeline element",
+ })
+ );
return;
}
// Don't seek if clicking on playhead
if (playheadRef.current?.contains(e.target as Node)) {
+ console.log(
+ JSON.stringify({
+ debug_click: "REJECTED - clicked playhead",
+ })
+ );
return;
}
// Don't seek if clicking on track labels
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
+ console.log(
+ JSON.stringify({
+ debug_click: "REJECTED - clicked track labels",
+ })
+ );
clearSelectedElements();
return;
}
// Clear selected elements when clicking empty timeline area
- console.log(JSON.stringify({ clearingSelectedElements: true }));
+ console.log(
+ JSON.stringify({
+ debug_click: "PROCEEDING - clearing elements",
+ clearingSelectedElements: true,
+ })
+ );
clearSelectedElements();
// Determine if we're clicking in ruler or tracks area
@@ -256,24 +329,39 @@ export function Timeline() {
"[data-ruler-area]"
);
+ console.log(
+ JSON.stringify({
+ debug_click: "CALCULATING POSITION",
+ isRulerClick,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ target_element: (e.target as HTMLElement).tagName,
+ target_class: (e.target as HTMLElement).className,
+ })
+ );
+
let mouseX: number;
let scrollLeft = 0;
if (isRulerClick) {
// Calculate based on ruler position
- const rulerContent = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerContent) return;
+ const rulerContent = rulerScrollRef.current;
+ if (!rulerContent) {
+ console.log(
+ JSON.stringify({
+ debug_click: "ERROR - no ruler container found",
+ })
+ );
+ return;
+ }
const rect = rulerContent.getBoundingClientRect();
mouseX = e.clientX - rect.left;
scrollLeft = rulerContent.scrollLeft;
} else {
- // Calculate based on tracks content position
- const tracksContent = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!tracksContent) return;
+ const tracksContent = tracksScrollRef.current;
+ if (!tracksContent) {
+ return;
+ }
const rect = tracksContent.getBoundingClientRect();
mouseX = e.clientX - rect.left;
scrollLeft = tracksContent.scrollLeft;
@@ -291,7 +379,6 @@ export function Timeline() {
// Use frame snapping for timeline clicking
const projectFps = activeProject?.fps || 30;
const time = snapTimeToFrame(rawTime, projectFps);
-
seek(time);
},
[
@@ -405,7 +492,21 @@ export function Timeline() {
item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
- useTimelineStore.getState().addMediaToNewTrack(addedItem);
+ const trackType: TrackType =
+ addedItem.type === "audio" ? "audio" : "media";
+ const targetTrackId = useTimelineStore
+ .getState()
+ .insertTrackAt(trackType, 0);
+
+ useTimelineStore.getState().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: addedItem.id,
+ name: addedItem.name,
+ duration: addedItem.duration || 5,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
}
}
} catch (error) {
@@ -428,15 +529,9 @@ export function Timeline() {
// --- Scroll synchronization effect ---
useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ const trackLabelsViewport = trackLabelsScrollRef.current;
if (!rulerViewport || !tracksViewport) return;
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index b26a14bb..f2623161 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -50,6 +50,7 @@ export function TimelineElement({
replaceElementMedia,
rippleEditingEnabled,
toggleElementHidden,
+ toggleElementMuted,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -57,7 +58,7 @@ export function TimelineElement({
element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId)
: null;
- const isAudio = mediaItem?.type === "audio";
+ const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
useTimelineElementResize({
@@ -124,8 +125,12 @@ export function TimelineElement({
}
};
- const handleToggleElementHidden = (e: React.MouseEvent) => {
+ const handleToggleElementContext = (e: React.MouseEvent) => {
e.stopPropagation();
+ if (hasAudio && element.type === "media") {
+ toggleElementMuted(track.id, element.id);
+ return;
+ }
toggleElementHidden(track.id, element.id);
};
@@ -246,6 +251,8 @@ export function TimelineElement({
}
};
+ const isMuted = element.type === "media" ? element.muted === true : false;
+
return (
<ContextMenu>
<ContextMenuTrigger asChild>
@@ -279,9 +286,9 @@ export function TimelineElement({
{renderElementContent()}
</div>
- {element.hidden && (
+ {(hasAudio ? isMuted : element.hidden) && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
- {isAudio ? (
+ {hasAudio ? (
<VolumeX className="h-6 w-6 text-white" />
) : (
<EyeOff className="h-6 w-6 text-white" />
@@ -309,9 +316,9 @@ export function TimelineElement({
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
- <ContextMenuItem onClick={handleToggleElementHidden}>
- {isAudio ? (
- element.hidden ? (
+ <ContextMenuItem onClick={handleToggleElementContext}>
+ {hasAudio ? (
+ isMuted ? (
<Volume2 className="h-4 w-4 mr-2" />
) : (
<VolumeX className="h-4 w-4 mr-2" />
@@ -322,8 +329,8 @@ export function TimelineElement({
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
- {isAudio
- ? element.hidden
+ {hasAudio
+ ? isMuted
? "Unmute"
: "Mute"
: element.hidden
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
index 248c6176..48c60596 100644
--- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
@@ -51,9 +51,7 @@ export function TimelinePlayhead({
// Track scroll position to lock playhead to frame
useEffect(() => {
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current;
if (!tracksViewport) return;
@@ -86,9 +84,7 @@ export function TimelinePlayhead({
// Get the timeline content width and viewport width for right boundary
const timelineContentWidth =
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current;
const viewportWidth = tracksViewport?.clientWidth || 1000;
// Constrain playhead to never appear outside the timeline area
@@ -126,7 +122,7 @@ export function TimelinePlayhead({
return (
<div
ref={playheadRef}
- className="absolute pointer-events-auto z-150"
+ className="absolute pointer-events-auto z-40"
style={{
left: `${leftPosition}px`,
top: 0,
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 1982d03e..ff4797fb 100644
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
@@ -4,11 +4,10 @@ import { useRef, useState, useEffect } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { toast } from "sonner";
+import { processMediaFiles } from "@/lib/media-processing";
import { TimelineElement } from "./timeline-element";
import {
TimelineTrack,
- sortTracksByOrder,
- ensureMainTrack,
getMainTrack,
canElementGoOnTrack,
} from "@/types/timeline";
@@ -16,6 +15,7 @@ import { usePlaybackStore } from "@/stores/playback-store";
import type {
TimelineElement as TimelineElementType,
DragData,
+ TrackType,
} from "@/types/timeline";
import {
snapTimeToFrame,
@@ -689,8 +689,9 @@ export function TimelineTrackContent({
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item"
);
+ const hasFiles = e.dataTransfer.files?.length > 0;
- if (!hasTimelineElement && !hasMediaItem) return;
+ if (!hasTimelineElement && !hasMediaItem && !hasFiles) return;
const trackContainer = e.currentTarget.querySelector(
".track-elements-container"
@@ -1050,6 +1051,50 @@ export function TimelineTrackContent({
trimEnd: 0,
});
}
+ } else if (hasFiles) {
+ // External file drops
+ const { activeProject } = useProjectStore.getState();
+ const { addMediaItem } = useMediaStore.getState();
+ const { addElementToTrack } = useTimelineStore.getState();
+
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ // Process and add files to new timeline tracks at playhead position
+ processMediaFiles(e.dataTransfer.files)
+ .then(async (processedItems) => {
+ for (const processedItem of processedItems) {
+ await addMediaItem(activeProject.id, processedItem);
+ const currentMediaItems = useMediaStore.getState().mediaItems;
+ const addedItem = currentMediaItems.find(
+ (item) =>
+ item.name === processedItem.name &&
+ item.url === processedItem.url
+ );
+
+ if (addedItem) {
+ const trackType: TrackType =
+ addedItem.type === "audio" ? "audio" : "media";
+ const targetTrackId = insertTrackAt(trackType, 0);
+
+ addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: addedItem.id,
+ name: addedItem.name,
+ duration: addedItem.duration || 5,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+ }
+ })
+ .catch((error) => {
+ console.error("Error processing external files:", error);
+ toast.error("Failed to process dropped files");
+ });
}
} catch (error) {
console.error("Error handling drop:", error);