mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'staging' into feat/timeline-return-to-start
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user