This commit is contained in:
Maze Winther
2026-01-15 11:05:17 +01:00
parent c19f085e48
commit deef784b76
182 changed files with 7787 additions and 18046 deletions
@@ -3,12 +3,12 @@ import {
useCallback,
useEffect,
useRef,
type MouseEvent as ReactMouseEvent,
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/use-element-selection";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import type {
@@ -44,7 +44,7 @@ export function useElementInteraction({
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const tracks = editor.timeline.sortedTracks;
const tracks = editor.timeline.getTracks();
const {
isSelected,
select,
@@ -81,13 +81,13 @@ export function useElementInteraction({
setDragState(initialDragState);
}, []);
// Mouse move: update drag time
// mouse move: update drag time
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const handleMouseMove = ({ clientX }: MouseEvent) => {
if (!timelineRef.current) return;
lastMouseXRef.current = e.clientX;
lastMouseXRef.current = clientX;
if (dragState.elementId && dragState.trackId) {
const alreadySelected = isSelected({
@@ -103,16 +103,21 @@ export function useElementInteraction({
}
const rect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseX = clientX - rect.left;
const mouseTime = Math.max(
0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
const activeProject = editor.project.getActive();
if (!activeProject) return;
const fps = activeProject.settings.fps;
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps });
setDragState((prev) => ({ ...prev, currentTime: snappedTime }));
setDragState((previousDragState) => ({
...previousDragState,
currentTime: snappedTime,
}));
};
document.addEventListener("mousemove", handleMouseMove);
@@ -129,11 +134,11 @@ export function useElementInteraction({
timelineRef,
]);
// Mouse up: resolve drop
// mouse up: resolve drop
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseUp = (e: MouseEvent) => {
const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
if (!dragState.elementId || !dragState.trackId) return;
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
@@ -143,9 +148,14 @@ export function useElementInteraction({
return;
}
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
if (!sourceTrack) {
endDrag();
onSnapPointChange?.(null);
return;
}
const movingElement = sourceTrack?.elements.find(
(el) => el.id === dragState.elementId,
({ id }) => id === dragState.elementId,
);
if (!movingElement) {
@@ -158,8 +168,8 @@ export function useElementInteraction({
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const mouseX = e.clientX - containerRect.left;
const mouseY = e.clientY - containerRect.top;
const mouseX = clientX - containerRect.left;
const mouseY = clientY - containerRect.top;
const dropTarget = computeDropTarget({
elementType: movingElement.type,
@@ -173,12 +183,18 @@ export function useElementInteraction({
zoomLevel,
});
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
const activeProject = editor.project.getActive();
if (!activeProject) {
endDrag();
onSnapPointChange?.(null);
return;
}
const fps = activeProject.settings.fps;
const snappedTime = snapTimeToFrame({ time: dropTarget.xPosition, fps });
if (dropTarget.isNewTrack) {
const newTrackId = editor.timeline.addTrack({
type: sourceTrack!.type,
type: sourceTrack.type,
index: dropTarget.trackIndex,
});
editor.timeline.moveElement({
@@ -221,20 +237,20 @@ export function useElementInteraction({
const handleElementMouseDown = useCallback(
({
e,
event,
element,
track,
}: {
e: React.MouseEvent;
event: ReactMouseEvent;
element: TimelineElement;
track: TimelineTrack;
}) => {
mouseDownLocationRef.current = { x: e.clientX, y: e.clientY };
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
const isRightClick = e.button === 2;
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
const isRightClick = event.button === 2;
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
// right-click: select if not already selected
// right-click
if (isRightClick) {
const alreadySelected = isSelected({
trackId: track.id,
@@ -244,7 +260,7 @@ export function useElementInteraction({
handleSelectionClick({
trackId: track.id,
elementId: element.id,
isMultiKey: isMultiSelect,
isMultiKey: false,
});
}
return;
@@ -261,16 +277,16 @@ export function useElementInteraction({
// start drag
const elementRect = (
e.currentTarget as HTMLElement
event.currentTarget as HTMLElement
).getBoundingClientRect();
const clickOffsetX = e.clientX - elementRect.left;
const clickOffsetX = event.clientX - elementRect.left;
const clickOffsetTime =
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
startDrag({
elementId: element.id,
trackId: track.id,
startMouseX: e.clientX,
startMouseX: event.clientX,
startElementTime: element.startTime,
clickOffsetTime,
});
@@ -280,20 +296,20 @@ export function useElementInteraction({
const handleElementClick = useCallback(
({
e,
event,
element,
track,
}: {
e: React.MouseEvent;
event: ReactMouseEvent;
element: TimelineElement;
track: TimelineTrack;
}) => {
e.stopPropagation();
event.stopPropagation();
// was it a drag or a click?
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(e.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(e.clientY - mouseDownLocationRef.current.y);
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
mouseDownLocationRef.current = null;
return;
@@ -301,7 +317,7 @@ export function useElementInteraction({
}
// modifier keys already handled in mousedown
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
// single click: select if not selected
const alreadySelected = isSelected({
@@ -1,12 +1,10 @@
import { useState, useEffect } from "react";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { EditorCore } from "@/core";
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
import { UpdateElementStartTimeCommand } from "@/lib/commands/timeline/element/update-element-start-time";
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
import type { MediaFile } from "@/types/assets";
export interface ResizeState {
elementId: string;
@@ -29,21 +27,20 @@ export function useTimelineElementResize({
track,
zoomLevel,
}: UseTimelineElementResizeProps) {
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd);
const [currentStartTime, setCurrentStartTime] = useState(element.startTime);
const [currentDuration, setCurrentDuration] = useState(element.duration);
const editor = EditorCore.getInstance();
const mediaFiles = editor.media.getMediaFiles();
const activeProject = editor.project.getActive();
useEffect(() => {
if (!resizing) return;
const handleDocumentMouseMove = (e: MouseEvent) => {
updateTrimFromMouseMove({ clientX: e.clientX });
const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
updateTrimFromMouseMove({ clientX });
};
const handleDocumentMouseUp = () => {
@@ -88,23 +85,10 @@ export function useTimelineElementResize({
};
const canExtendElementDuration = () => {
if (element.type === "text") {
if (element.type === "text" || element.type === "image") {
return true;
}
if (element.type === "media") {
const mediaFile = mediaFiles.find(
(file: MediaFile) => file.id === element.mediaId,
);
if (!mediaFile) return false;
if (mediaFile.type === "image") {
return true;
}
return false;
}
return false;
};
@@ -114,14 +98,17 @@ export function useTimelineElementResize({
const deltaX = clientX - resizing.startX;
const deltaTime = deltaX / (50 * zoomLevel);
const projectFps = activeProject?.fps || DEFAULT_FPS;
const projectFps = activeProject.settings.fps;
if (resizing.side === "left") {
const maxAllowed =
resizing.initialDuration - resizing.initialTrimEnd - 0.1;
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const maxAllowed = sourceDuration - resizing.initialTrimEnd - 0.1;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0) {
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
fps: projectFps,
@@ -131,10 +118,15 @@ export function useTimelineElementResize({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
} else {
setCurrentDuration(newDuration);
} else if (calculated < 0) {
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
const maxExtension = resizing.initialStartTime;
@@ -152,59 +144,63 @@ export function useTimelineElementResize({
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
} else {
const newTrimStart = 0;
const trimDelta = newTrimStart - resizing.initialTrimStart;
const trimDelta = 0 - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
setCurrentTrimStart(newTrimStart);
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
}
}
} else {
const calculated = resizing.initialTrimEnd - deltaTime;
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
if (calculated < 0) {
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(calculated);
const extensionNeeded = Math.abs(newTrimEnd);
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionNeeded,
fps: projectFps,
});
const newTrimEnd = 0;
setCurrentDuration(newDuration);
setCurrentTrimEnd(newTrimEnd);
setCurrentTrimEnd(0);
} else {
const extensionToLimit = resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionToLimit,
fps: projectFps,
});
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
}
} else {
const currentEndTime =
resizing.initialStartTime +
resizing.initialDuration -
resizing.initialTrimStart -
resizing.initialTrimEnd;
const desiredEndTime = currentEndTime + deltaTime;
const snappedEndTime = snapTimeToFrame({
time: desiredEndTime,
const maxTrimEnd = sourceDuration - resizing.initialTrimStart - 0.1;
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
});
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
const newTrimEnd = Math.max(
0,
resizing.initialDuration -
resizing.initialTrimStart -
(snappedEndTime - resizing.initialStartTime),
);
const maxTrimEnd =
resizing.initialDuration - resizing.initialTrimStart - 0.1;
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
}
}
};
@@ -212,8 +208,6 @@ export function useTimelineElementResize({
const handleResizeEnd = () => {
if (!resizing) return;
const editor = EditorCore.getInstance();
const trimStartChanged = currentTrimStart !== resizing.initialTrimStart;
const trimEndChanged = currentTrimEnd !== resizing.initialTrimEnd;
const startTimeChanged = currentStartTime !== resizing.initialStartTime;
@@ -0,0 +1,62 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import type { TimelineTrack } from "@/types/timeline";
import { useState, useEffect } from "react";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
}
interface SnapIndicatorPosition {
leftPosition: number;
topPosition: number;
height: number;
}
export function useSnapIndicatorPosition({
snapPoint,
zoomLevel,
tracks,
timelineRef,
trackLabelsRef,
tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
const [scrollLeft, setScrollLeft] = useState(0);
useEffect(() => {
const tracksViewport = tracksScrollRef.current;
if (!tracksViewport) return;
const handleScroll = () => {
setScrollLeft(tracksViewport.scrollLeft);
};
setScrollLeft(tracksViewport.scrollLeft);
tracksViewport.addEventListener("scroll", handleScroll);
return () => tracksViewport.removeEventListener("scroll", handleScroll);
}, [tracksScrollRef]);
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
const trackLabelsWidth =
tracks.length > 0 && trackLabelsRef?.current
? trackLabelsRef.current.offsetWidth
: 0;
const timelinePosition =
(snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
return {
leftPosition,
topPosition: 0,
height: totalHeight,
};
}
@@ -1,17 +1,17 @@
import { useState, useCallback, type RefObject } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { processMediaAssets } from "@/lib/media-processing-utils";
import { toast } from "sonner";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { buildTextElement } from "@/lib/timeline/element-utils";
import {
buildTextElement,
buildStickerElement,
} from "@/lib/timeline/element-utils";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getAssetDragData, hasAssetDragData } from "@/lib/asset-drag";
import type { TrackType, DropTarget } from "@/types/timeline";
import type { MediaAssetDragData } from "@/types/assets";
type DragElementType = "video" | "image" | "audio" | "text";
import { getDragData, hasDragData } from "@/lib/drag-data";
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
import type { MediaDragData, StickerDragData } from "@/types/drag";
interface UseTimelineDragDropProps {
containerRef: RefObject<HTMLDivElement | null>;
@@ -27,34 +27,30 @@ export function useTimelineDragDrop({
const editor = useEditor();
const [isDragOver, setIsDragOver] = useState(false);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [dragElementType, setDragElementType] =
useState<DragElementType | null>(null);
const [dragElementType, setElementType] = useState<ElementType | null>(null);
const tracks = editor.timeline.sortedTracks;
const currentTime = editor.playback.currentTime;
const mediaFiles = editor.media.mediaFiles;
const activeProject = editor.project.activeProject;
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const activeProject = editor.project.getActive();
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
const projectFps = activeProject?.fps ?? DEFAULT_FPS;
const projectFps = activeProject.settings.fps;
return snapTimeToFrame({ time, fps: projectFps });
},
[activeProject?.fps],
[activeProject.settings.fps],
);
const getDragElementType = useCallback(
({
dataTransfer,
}: {
dataTransfer: DataTransfer;
}): DragElementType | null => {
const dragData = getAssetDragData({ dataTransfer });
const getElementType = useCallback(
({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
const dragData = getDragData({ dataTransfer });
if (!dragData) return null;
if (dragData.type === "text") return "text";
if (dragData.type === "sticker") return "sticker";
if (dragData.type === "media") {
return dragData.mediaType as DragElementType;
return dragData.mediaType as ElementType;
}
return null;
},
@@ -66,24 +62,24 @@ export function useTimelineDragDrop({
elementType,
mediaId,
}: {
elementType: DragElementType;
elementType: ElementType;
mediaId?: string;
}): number => {
if (elementType === "text") {
if (elementType === "text" || elementType === "sticker") {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
if (mediaId) {
const media = mediaFiles.find((m) => m.id === mediaId);
const media = mediaAssets.find((m) => m.id === mediaId);
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
},
[mediaFiles],
[mediaAssets],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
const hasFiles = e.dataTransfer.types.includes("Files");
if (!hasAsset && !hasFiles) return;
setIsDragOver(true);
@@ -98,9 +94,9 @@ export function useTimelineDragDrop({
const hasFiles = e.dataTransfer.types.includes("Files");
const isExternal =
hasFiles && !hasAssetDragData({ dataTransfer: e.dataTransfer });
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
let elementType = getDragElementType({ dataTransfer: e.dataTransfer });
let elementType = getElementType({ dataTransfer: e.dataTransfer });
// external drops default to video until determined on drop
if (!elementType && hasFiles) {
@@ -109,9 +105,9 @@ export function useTimelineDragDrop({
if (!elementType) return;
setDragElementType(elementType);
setElementType(elementType);
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
const dragData = getDragData({ dataTransfer: e.dataTransfer });
const duration = getElementDuration({
elementType,
mediaId: dragData?.type === "media" ? dragData.id : undefined,
@@ -142,7 +138,7 @@ export function useTimelineDragDrop({
tracks,
currentTime,
zoomLevel,
getDragElementType,
getElementType,
getElementDuration,
getSnappedTime,
],
@@ -162,7 +158,7 @@ export function useTimelineDragDrop({
) {
setIsDragOver(false);
setDropTarget(null);
setDragElementType(null);
setElementType(null);
}
}
},
@@ -192,9 +188,7 @@ export function useTimelineDragDrop({
const element = buildTextElement({
raw: {
id: "",
name: dragData.name ?? "",
type: "text",
content: dragData.content ?? "",
},
startTime: target.xPosition,
@@ -205,19 +199,44 @@ export function useTimelineDragDrop({
[editor.timeline, tracks],
);
const executeMediaDrop = useCallback(
const executeStickerDrop = useCallback(
({
target,
dragData,
}: {
target: DropTarget;
dragData: MediaAssetDragData;
dragData: StickerDragData;
}) => {
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
if (!mediaItem) return;
let trackId: string;
if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: "sticker",
index: target.trackIndex,
});
} else {
const track = tracks[target.trackIndex];
if (!track) return;
trackId = track.id;
}
const element = buildStickerElement({
iconName: dragData.iconName,
startTime: target.xPosition,
});
editor.timeline.addElementToTrack({ trackId, element });
},
[editor.timeline, tracks],
);
const executeMediaDrop = useCallback(
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
if (!mediaAsset) return;
const trackType: TrackType =
dragData.mediaType === "audio" ? "audio" : "media";
dragData.mediaType === "audio" ? "audio" : "video";
let trackId: string;
if (target.isNewTrack) {
@@ -232,15 +251,16 @@ export function useTimelineDragDrop({
}
const duration =
mediaItem.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
if (dragData.mediaType === "audio") {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "audio",
mediaId: mediaItem.id,
name: mediaItem.name,
sourceType: "upload",
mediaId: mediaAsset.id,
name: mediaAsset.name,
duration,
startTime: target.xPosition,
trimStart: 0,
@@ -255,12 +275,21 @@ export function useTimelineDragDrop({
trackId,
element: {
type: "video",
mediaId: mediaItem.id,
name: mediaItem.name,
mediaId: mediaAsset.id,
name: mediaAsset.name,
duration,
startTime: target.xPosition,
trimStart: 0,
trimEnd: 0,
transform: {
scale: 1,
position: {
x: 0,
y: 0,
},
rotate: 0,
},
opacity: 1,
},
});
} else {
@@ -268,38 +297,47 @@ export function useTimelineDragDrop({
trackId,
element: {
type: "image",
mediaId: mediaItem.id,
name: mediaItem.name,
mediaId: mediaAsset.id,
name: mediaAsset.name,
duration,
startTime: target.xPosition,
trimStart: 0,
trimEnd: 0,
transform: {
scale: 1,
position: {
x: 0,
y: 0,
},
rotate: 0,
},
opacity: 1,
},
});
}
},
[editor.timeline, mediaFiles, tracks],
[editor.timeline, mediaAssets, tracks],
);
const executeFileDrop = useCallback(
async ({ files }: { files: File[] }) => {
if (!activeProject) return;
const processedItems = await processMediaFiles({ files });
const processedAssets = await processMediaAssets({ files });
for (const item of processedItems) {
await editor.media.addMediaFile({
projectId: activeProject.id,
file: item,
for (const asset of processedAssets) {
await editor.media.addMediaAsset({
projectId: activeProject.metadata.id,
asset,
});
const added = editor.media.mediaFiles.find(
(m) => m.name === item.name && m.url === item.url,
);
const added = editor.media
.getAssets()
.find((m) => m.name === asset.name && m.url === asset.url);
if (added) {
const trackType: TrackType =
added.type === "audio" ? "audio" : "media";
added.type === "audio" ? "audio" : "video";
const trackId = editor.timeline.addTrack({
type: trackType,
index: 0,
@@ -313,6 +351,7 @@ export function useTimelineDragDrop({
trackId,
element: {
type: "audio",
sourceType: "upload",
mediaId: added.id,
name: added.name,
duration,
@@ -335,6 +374,15 @@ export function useTimelineDragDrop({
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
transform: {
scale: 1,
position: {
x: 0,
y: 0,
},
rotate: 0,
},
opacity: 1,
},
});
} else {
@@ -348,6 +396,15 @@ export function useTimelineDragDrop({
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
transform: {
scale: 1,
position: {
x: 0,
y: 0,
},
rotate: 0,
},
opacity: 1,
},
});
}
@@ -364,22 +421,24 @@ export function useTimelineDragDrop({
const currentTarget = dropTarget;
setIsDragOver(false);
setDropTarget(null);
setDragElementType(null);
setElementType(null);
if (!currentTarget) return;
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasAsset && !hasFiles) return;
try {
if (hasAsset) {
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
const dragData = getDragData({ dataTransfer: e.dataTransfer });
if (!dragData) return;
if (dragData.type === "text") {
executeTextDrop({ target: currentTarget, dragData });
} else if (dragData.type === "sticker") {
executeStickerDrop({ target: currentTarget, dragData });
} else {
executeMediaDrop({ target: currentTarget, dragData });
}
@@ -391,7 +450,13 @@ export function useTimelineDragDrop({
toast.error("Failed to process drop");
}
},
[dropTarget, executeTextDrop, executeMediaDrop, executeFileDrop],
[
dropTarget,
executeTextDrop,
executeStickerDrop,
executeMediaDrop,
executeFileDrop,
],
);
return {
@@ -1,12 +1,11 @@
import { useCallback, useRef } from "react";
import { useProjectStore } from "@/stores/project-store";
import type { RefObject } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { useEditor } from "../use-editor";
interface UseTimelineInteractionsProps {
playheadRef: RefObject<HTMLDivElement>;
tracksContainerRef: RefObject<HTMLDivElement>;
rulerScrollRef: RefObject<HTMLDivElement>;
tracksScrollRef: RefObject<HTMLDivElement>;
zoomLevel: number;
@@ -19,7 +18,6 @@ interface UseTimelineInteractionsProps {
export function useTimelineInteractions({
playheadRef,
tracksContainerRef,
rulerScrollRef,
tracksScrollRef,
zoomLevel,
@@ -29,7 +27,9 @@ export function useTimelineInteractions({
clearSelectedElements,
seek,
}: UseTimelineInteractionsProps) {
const { activeProject } = useProjectStore();
const editor = useEditor();
const activeProject = editor.project.getActive();
const mouseTrackingRef = useRef({
isMouseDown: false,
downX: 0,
@@ -111,7 +111,7 @@ export function useTimelineInteractions({
),
);
const projectFps = activeProject?.fps || 30;
const projectFps = activeProject?.settings.fps || 30;
const time = snapTimeToFrame({ time: rawTime, fps: projectFps });
seek(time);
},
@@ -121,7 +121,7 @@ export function useTimelineInteractions({
seek,
rulerScrollRef,
tracksScrollRef,
activeProject?.fps,
activeProject?.settings.fps,
],
);
@@ -1,15 +1,11 @@
import { snapTimeToFrame } from "@/lib/time-utils";
import { useProjectStore } from "@/stores/project-store";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { usePlaybackStore } from "@/stores/playback-store";
import { useState, useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
currentTime: number;
duration: number;
zoomLevel: number;
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
@@ -17,15 +13,18 @@ interface UseTimelinePlayheadProps {
}
export function useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
}: UseTimelinePlayheadProps) {
const editor = useEditor();
const activeProject = editor.project.getActive();
const seek = (time: number) => editor.playback.seek({ time });
const currentTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
// Playhead scrubbing state
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubTime, setScrubTime] = useState<number | null>(null);
@@ -38,62 +37,66 @@ export function useTimelinePlayhead({
const playheadPosition =
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
// --- Playhead Scrubbing Handlers ---
const handlePlayheadMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation(); // Prevent ruler drag from triggering
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation(); // prevent ruler drag from triggering
setIsScrubbing(true);
handleScrub(e);
handleScrub(event);
},
[duration, zoomLevel],
);
// Ruler mouse down handler
const handleRulerMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only handle left mouse button
if (e.button !== 0) return;
(event: React.MouseEvent) => {
// only handle left mouse button
if (event.button !== 0) return;
// Don't interfere if clicking on the playhead itself
if (playheadRef?.current?.contains(e.target as Node)) return;
// don't interfere if clicking on the playhead itself
if (playheadRef?.current?.contains(event.target as Node)) return;
e.preventDefault();
event.preventDefault();
setIsDraggingRuler(true);
setHasDraggedRuler(false);
// Start scrubbing immediately
// start scrubbing immediately
setIsScrubbing(true);
handleScrub(e);
handleScrub(event);
},
[duration, zoomLevel],
);
const handleScrub = useCallback(
(e: MouseEvent | React.MouseEvent) => {
(event: MouseEvent | React.MouseEvent) => {
const ruler = rulerRef.current;
if (!ruler) return;
const rect = ruler.getBoundingClientRect();
const rawX = e.clientX - rect.left;
const rawX = event.clientX - rect.left;
// Get the timeline content width based on duration and zoom
const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
// get the timeline content width based on duration and zoom
const timelineContentWidth =
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
// Constrain x to be within the timeline content bounds
// constrain x to be within the timeline content bounds
const x = Math.max(0, Math.min(timelineContentWidth, rawX));
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
// Use frame snapping for playhead scrubbing
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
const time = snapTimeToFrame({ time: rawTime, fps: projectFps });
const rawTime = Math.max(
0,
Math.min(
duration,
x / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
),
);
// use frame snapping for playhead scrubbing
const fps = activeProject.settings.fps;
const time = snapTimeToFrame({ time: rawTime, fps });
// Debug logging
// debug logging
if (rawX < 0 || x !== rawX) {
console.log(
"PLAYHEAD DEBUG:",
JSON.stringify({
mouseX: e.clientX,
mouseX: event.clientX,
rulerLeft: rect.left,
rawX,
constrainedX: x,
@@ -102,7 +105,7 @@ export function useTimelinePlayhead({
finalTime: time,
duration,
zoomLevel,
playheadPx: time * 50 * zoomLevel,
playheadPx: time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
}),
);
}
@@ -110,10 +113,10 @@ export function useTimelinePlayhead({
setScrubTime(time);
seek(time); // update video preview in real time
// Store mouse position for auto-scrolling
lastMouseXRef.current = e.clientX;
// store mouse position for auto-scrolling
lastMouseXRef.current = event.clientX;
},
[duration, zoomLevel, seek, rulerRef],
[duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
);
useEdgeAutoScroll({
@@ -121,32 +124,31 @@ export function useTimelinePlayhead({
getMouseClientX: () => lastMouseXRef.current,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * 50 * zoomLevel,
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
});
// Mouse move/up event handlers
useEffect(() => {
if (!isScrubbing) return;
const onMouseMove = (e: MouseEvent) => {
handleScrub(e);
// Mark that we've dragged if ruler drag is active
const onMouseMove = (event: MouseEvent) => {
handleScrub(event);
// mark that we've dragged if ruler drag is active
if (isDraggingRuler) {
setHasDraggedRuler(true);
}
};
const onMouseUp = (e: MouseEvent) => {
const onMouseUp = (event: MouseEvent) => {
setIsScrubbing(false);
if (scrubTime !== null) seek(scrubTime); // finalize seek
setScrubTime(null);
// Handle ruler click vs drag
// handle ruler click vs drag
if (isDraggingRuler) {
setIsDraggingRuler(false);
// If we didn't drag, treat it as a click-to-seek
// if we didn't drag, treat it as a click-to-seek
if (!hasDraggedRuler) {
handleScrub(e);
handleScrub(event);
}
setHasDraggedRuler(false);
}
@@ -155,12 +157,9 @@ export function useTimelinePlayhead({
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
// Edge auto-scroll is handled by useEdgeAutoScroll
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
// nothing to cleanup for edge auto scroll
};
}, [
isScrubbing,
@@ -172,29 +171,27 @@ export function useTimelinePlayhead({
// edge auto scroll hook is independent
]);
// --- Playhead auto-scroll effect (only during playback) ---
useEffect(() => {
const { isPlaying } = usePlaybackStore.getState();
// Only auto-scroll during playback, not during manual interactions
if (!isPlaying || isScrubbing) return;
// only auto-scroll during playback, not during manual interactions
if (!editor.playback.getIsPlaying() || isScrubbing) return;
const rulerViewport = rulerScrollRef.current;
const tracksViewport = tracksScrollRef.current;
if (!rulerViewport || !tracksViewport) return;
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
const playheadPx =
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const viewportWidth = rulerViewport.clientWidth;
const scrollMin = 0;
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
// Only auto-scroll if playhead is completely out of view (no buffer)
// only auto-scroll if playhead is completely out of view (no buffer)
const needsScroll =
playheadPx < rulerViewport.scrollLeft ||
playheadPx > rulerViewport.scrollLeft + viewportWidth;
if (needsScroll) {
// Center the playhead in the viewport
// center the playhead in the viewport
const desiredScroll = Math.max(
scrollMin,
Math.min(scrollMax, playheadPx - viewportWidth / 2),
@@ -44,9 +44,7 @@ export function useTimelineSnapping({
if (element.id === excludeElementId) return;
const elementStart = element.startTime;
const elementEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
const elementEnd = element.startTime + element.duration;
snapPoints.push(
{
@@ -1,4 +1,11 @@
import { useState, useCallback, useEffect, RefObject } from "react";
import {
useState,
useCallback,
useEffect,
type RefObject,
type WheelEvent as ReactWheelEvent,
} from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
@@ -8,7 +15,7 @@ interface UseTimelineZoomProps {
interface UseTimelineZoomReturn {
zoomLevel: number;
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
handleWheel: (e: React.WheelEvent) => void;
handleWheel: (event: ReactWheelEvent) => void;
}
export function useTimelineZoom({
@@ -17,31 +24,46 @@ export function useTimelineZoom({
}: UseTimelineZoomProps): UseTimelineZoomReturn {
const [zoomLevel, setZoomLevel] = useState(1);
const handleWheel = useCallback((e: React.WheelEvent) => {
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
const handleWheel = useCallback((event: ReactWheelEvent) => {
const isZoomGesture = event.ctrlKey || event.metaKey;
const isHorizontalScrollGesture =
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
// allow scrollarea to handle horizontal scroll
if (isHorizontalScrollGesture) {
return;
}
// For horizontal scrolling (when shift is held or horizontal wheel movement),
// let the event bubble up to allow ScrollArea to handle it
else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
// Don't prevent default - let ScrollArea handle horizontal scrolling
// pinch-zoom (ctrl/meta + wheel)
if (isZoomGesture) {
event.preventDefault();
const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
setZoomLevel((prev) =>
Math.max(
TIMELINE_CONSTANTS.ZOOM_MIN,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
),
);
// for horizontal scrolling (when shift is held or horizontal wheel movement),
// let the event bubble up to allow ScrollArea to handle it
return;
}
// Otherwise, allow normal scrolling
}, []);
// Prevent browser zooming in/out when in timeline
// prevent browser zoom in the timeline
useEffect(() => {
const preventZoom = (e: WheelEvent) => {
const preventZoom = ({
ctrlKey,
metaKey,
target,
preventDefault,
}: WheelEvent) => {
if (
isInTimeline &&
(e.ctrlKey || e.metaKey) &&
containerRef.current?.contains(e.target as Node)
(ctrlKey || metaKey) &&
containerRef.current?.contains(target as Node)
) {
e.preventDefault();
preventDefault();
}
};