This commit is contained in:
Maze Winther
2026-01-05 01:35:10 +01:00
parent d5ca991501
commit c19f085e48
95 changed files with 4133 additions and 4196 deletions
@@ -0,0 +1,324 @@
import {
useState,
useCallback,
useEffect,
useRef,
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 {
ElementDragState,
TimelineElement,
TimelineTrack,
} from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
const DRAG_THRESHOLD_PX = 5;
interface UseElementInteractionProps {
zoomLevel: number;
timelineRef: RefObject<HTMLDivElement | null>;
tracksContainerRef: RefObject<HTMLDivElement | null>;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
const initialDragState: ElementDragState = {
isDragging: false,
elementId: null,
trackId: null,
startMouseX: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
};
export function useElementInteraction({
zoomLevel,
timelineRef,
tracksContainerRef,
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const tracks = editor.timeline.sortedTracks;
const {
isSelected,
select,
handleElementClick: handleSelectionClick,
} = useElementSelection();
const [dragState, setDragState] =
useState<ElementDragState>(initialDragState);
const lastMouseXRef = useRef(0);
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
const startDrag = useCallback(
({
elementId,
trackId,
startMouseX,
startElementTime,
clickOffsetTime,
}: Omit<ElementDragState, "isDragging" | "currentTime">) => {
setDragState({
isDragging: true,
elementId,
trackId,
startMouseX,
startElementTime,
clickOffsetTime,
currentTime: startElementTime,
});
},
[],
);
const endDrag = useCallback(() => {
setDragState(initialDragState);
}, []);
// Mouse move: update drag time
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return;
lastMouseXRef.current = e.clientX;
if (dragState.elementId && dragState.trackId) {
const alreadySelected = isSelected({
trackId: dragState.trackId,
elementId: dragState.elementId,
});
if (!alreadySelected) {
select({
trackId: dragState.trackId,
elementId: dragState.elementId,
});
}
}
const rect = timelineRef.current.getBoundingClientRect();
const mouseX = e.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 snappedTime = snapTimeToFrame({ time: adjustedTime, fps });
setDragState((prev) => ({ ...prev, currentTime: snappedTime }));
};
document.addEventListener("mousemove", handleMouseMove);
return () => document.removeEventListener("mousemove", handleMouseMove);
}, [
dragState.isDragging,
dragState.clickOffsetTime,
dragState.elementId,
dragState.trackId,
zoomLevel,
isSelected,
select,
editor.project,
timelineRef,
]);
// Mouse up: resolve drop
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseUp = (e: MouseEvent) => {
if (!dragState.elementId || !dragState.trackId) return;
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
if (!containerRect) {
endDrag();
onSnapPointChange?.(null);
return;
}
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
(el) => el.id === dragState.elementId,
);
if (!movingElement) {
endDrag();
onSnapPointChange?.(null);
return;
}
const elementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const mouseX = e.clientX - containerRect.left;
const mouseY = e.clientY - containerRect.top;
const dropTarget = computeDropTarget({
elementType: movingElement.type,
mouseX,
mouseY,
tracks,
playheadTime: dragState.currentTime,
isExternalDrop: false,
elementDuration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
});
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
const snappedTime = snapTimeToFrame({ time: dropTarget.xPosition, fps });
if (dropTarget.isNewTrack) {
const newTrackId = editor.timeline.addTrack({
type: sourceTrack!.type,
index: dropTarget.trackIndex,
});
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: newTrackId,
elementId: dragState.elementId,
newStartTime: snappedTime,
});
} else {
const targetTrack = tracks[dropTarget.trackIndex];
if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
});
}
}
endDrag();
onSnapPointChange?.(null);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [
dragState.isDragging,
dragState.elementId,
dragState.trackId,
dragState.currentTime,
zoomLevel,
tracks,
endDrag,
onSnapPointChange,
editor.project,
editor.timeline,
tracksContainerRef,
]);
const handleElementMouseDown = useCallback(
({
e,
element,
track,
}: {
e: React.MouseEvent;
element: TimelineElement;
track: TimelineTrack;
}) => {
mouseDownLocationRef.current = { x: e.clientX, y: e.clientY };
const isRightClick = e.button === 2;
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
// right-click: select if not already selected
if (isRightClick) {
const alreadySelected = isSelected({
trackId: track.id,
elementId: element.id,
});
if (!alreadySelected) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
isMultiKey: isMultiSelect,
});
}
return;
}
// multi-select: toggle selection
if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
isMultiKey: true,
});
}
// start drag
const elementRect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
const clickOffsetX = e.clientX - elementRect.left;
const clickOffsetTime =
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
startDrag({
elementId: element.id,
trackId: track.id,
startMouseX: e.clientX,
startElementTime: element.startTime,
clickOffsetTime,
});
},
[zoomLevel, startDrag, isSelected, handleSelectionClick],
);
const handleElementClick = useCallback(
({
e,
element,
track,
}: {
e: React.MouseEvent;
element: TimelineElement;
track: TimelineTrack;
}) => {
e.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);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
mouseDownLocationRef.current = null;
return;
}
}
// modifier keys already handled in mousedown
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
// single click: select if not selected
const alreadySelected = isSelected({
trackId: track.id,
elementId: element.id,
});
if (!alreadySelected) {
select({ trackId: track.id, elementId: element.id });
}
},
[isSelected, select],
);
return {
dragState,
handleElementMouseDown,
handleElementClick,
lastMouseXRef,
};
}
@@ -0,0 +1,261 @@
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;
side: "left" | "right";
startX: number;
initialTrimStart: number;
initialTrimEnd: number;
initialStartTime: number;
initialDuration: number;
}
interface UseTimelineElementResizeProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
}
export function useTimelineElementResize({
element,
track,
zoomLevel,
}: UseTimelineElementResizeProps) {
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 handleDocumentMouseUp = () => {
handleResizeEnd();
};
document.addEventListener("mousemove", handleDocumentMouseMove);
document.addEventListener("mouseup", handleDocumentMouseUp);
return () => {
document.removeEventListener("mousemove", handleDocumentMouseMove);
document.removeEventListener("mouseup", handleDocumentMouseUp);
};
}, [resizing]);
const handleResizeStart = ({
e,
elementId,
side,
}: {
e: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => {
e.stopPropagation();
e.preventDefault();
setResizing({
elementId,
side,
startX: e.clientX,
initialTrimStart: element.trimStart,
initialTrimEnd: element.trimEnd,
initialStartTime: element.startTime,
initialDuration: element.duration,
});
setCurrentTrimStart(element.trimStart);
setCurrentTrimEnd(element.trimEnd);
setCurrentStartTime(element.startTime);
setCurrentDuration(element.duration);
};
const canExtendElementDuration = () => {
if (element.type === "text") {
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;
};
const updateTrimFromMouseMove = ({ clientX }: { clientX: number }) => {
if (!resizing) return;
const deltaX = clientX - resizing.startX;
const deltaTime = deltaX / (50 * zoomLevel);
const projectFps = activeProject?.fps || DEFAULT_FPS;
if (resizing.side === "left") {
const maxAllowed =
resizing.initialDuration - resizing.initialTrimEnd - 0.1;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0) {
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
} else {
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
const maxExtension = resizing.initialStartTime;
const actualExtension = Math.min(extensionAmount, maxExtension);
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
} else {
const newTrimStart = 0;
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
}
}
} else {
const calculated = resizing.initialTrimEnd - deltaTime;
if (calculated < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(calculated);
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionNeeded,
fps: projectFps,
});
const newTrimEnd = 0;
setCurrentDuration(newDuration);
setCurrentTrimEnd(newTrimEnd);
} else {
setCurrentTrimEnd(0);
}
} else {
const currentEndTime =
resizing.initialStartTime +
resizing.initialDuration -
resizing.initialTrimStart -
resizing.initialTrimEnd;
const desiredEndTime = currentEndTime + deltaTime;
const snappedEndTime = snapTimeToFrame({
time: desiredEndTime,
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);
}
}
};
const handleResizeEnd = () => {
if (!resizing) return;
const editor = EditorCore.getInstance();
const trimStartChanged = currentTrimStart !== resizing.initialTrimStart;
const trimEndChanged = currentTrimEnd !== resizing.initialTrimEnd;
const startTimeChanged = currentStartTime !== resizing.initialStartTime;
const durationChanged = currentDuration !== resizing.initialDuration;
if (trimStartChanged || trimEndChanged) {
const trimCommand = new UpdateElementTrimCommand(
track.id,
element.id,
currentTrimStart,
currentTrimEnd,
);
editor.command.execute({ command: trimCommand });
}
if (startTimeChanged) {
const startTimeCommand = new UpdateElementStartTimeCommand(
[{ trackId: track.id, elementId: element.id }],
currentStartTime,
);
editor.command.execute({ command: startTimeCommand });
}
if (durationChanged) {
const durationCommand = new UpdateElementDurationCommand(
track.id,
element.id,
currentDuration,
);
editor.command.execute({ command: durationCommand });
}
setResizing(null);
};
return {
resizing,
isResizing: resizing !== null,
handleResizeStart,
currentTrimStart,
currentTrimEnd,
currentStartTime,
currentDuration,
};
}
@@ -1,409 +1,403 @@
import { useState, useRef, useCallback } from "react";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useState, useCallback, type RefObject } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaFiles } 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 { getMainTrack, canElementGoOnTrack } from "@/lib/timeline/track-utils";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import {
useTimelineSnapping,
SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
import { buildTextElement } 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";
interface UseTimelineDragDropProps {
track?: TimelineTrack;
containerRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
isSnappingEnabled?: boolean;
}
export function useTimelineDragDrop({
track,
containerRef,
zoomLevel,
isSnappingEnabled = true,
}: UseTimelineDragDropProps) {
const editor = useEditor();
const [isDragOver, setIsDragOver] = useState(false);
const [wouldOverlap, setWouldOverlap] = useState(false);
const [dropPositionIndicator, setDropPositionIndicator] = useState<
number | null
>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [dragElementType, setDragElementType] =
useState<DragElementType | null>(null);
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime } = usePlaybackStore();
const {
tracks,
addElementToTrack,
insertTrackAt,
addTrack,
snappingEnabled,
} = useTimelineStore();
const tracks = editor.timeline.sortedTracks;
const currentTime = editor.playback.currentTime;
const mediaFiles = editor.media.mediaFiles;
const activeProject = editor.project.activeProject;
const dragCounterRef = useRef(0);
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
const projectFps = activeProject?.fps ?? DEFAULT_FPS;
return snapTimeToFrame({ time, fps: projectFps });
},
[activeProject?.fps],
);
const { snapElementEdge } = useTimelineSnapping({
snapThreshold: 10,
enableElementSnapping: snappingEnabled,
enablePlayheadSnapping: snappingEnabled,
});
const getDragElementType = useCallback(
({
dataTransfer,
}: {
dataTransfer: DataTransfer;
}): DragElementType | null => {
const dragData = getAssetDragData({ dataTransfer });
if (!dragData) return null;
const getDropSnappedTime = useCallback(
(dropTime: number, elementDuration: number, excludeElementId?: string) => {
const projectFps = activeProject?.fps || DEFAULT_FPS;
let finalTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
if (snappingEnabled) {
const startSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
true,
);
const endSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
false,
);
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
if (bestSnapResult.snapPoint) {
finalTime = bestSnapResult.snappedTime;
}
if (dragData.type === "text") return "text";
if (dragData.type === "media") {
return dragData.mediaType as DragElementType;
}
return finalTime;
return null;
},
[
activeProject?.fps,
snappingEnabled,
snapElementEdge,
tracks,
currentTime,
zoomLevel,
],
[],
);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
const hasFiles = e.dataTransfer.types.includes("Files");
if (!hasMediaItem && !hasFiles) return;
dragCounterRef.current++;
if (!isDragOver) setIsDragOver(true);
const getElementDuration = useCallback(
({
elementType,
mediaId,
}: {
elementType: DragElementType;
mediaId?: string;
}): number => {
if (elementType === "text") {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
if (mediaId) {
const media = mediaFiles.find((m) => m.id === mediaId);
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
},
[isDragOver],
[mediaFiles],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
const hasFiles = e.dataTransfer.types.includes("Files");
if (!hasAsset && !hasFiles) return;
setIsDragOver(true);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
if (!hasMediaItem) return;
if (track) {
const trackContainer =
(e.currentTarget as HTMLElement).closest(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement).querySelector(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement);
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const dropTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
const hasFiles = e.dataTransfer.types.includes("Files");
const isExternal =
hasFiles && !hasAssetDragData({ dataTransfer: e.dataTransfer });
let overlap = false;
try {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (mediaItemData) {
const dragData: DragData = JSON.parse(mediaItemData);
const duration =
dragData.type === "text"
? 5
: mediaFiles.find((m) => m.id === dragData.id)?.duration || 5;
const snappedTime = getDropSnappedTime(dropTime, duration);
const endTime = snappedTime + duration;
let elementType = getDragElementType({ dataTransfer: e.dataTransfer });
overlap = track.elements.some((el) => {
const elEnd =
el.startTime + (el.duration - el.trimStart - el.trimEnd);
return snappedTime < elEnd && endTime > el.startTime;
});
}
} catch (f) {}
setWouldOverlap(overlap);
setDropPositionIndicator(getDropSnappedTime(dropTime, 5));
e.dataTransfer.dropEffect = overlap ? "none" : "copy";
// external drops default to video until determined on drop
if (!elementType && hasFiles) {
elementType = "video";
}
if (!elementType) return;
setDragElementType(elementType);
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
const duration = getElementDuration({
elementType,
mediaId: dragData?.type === "media" ? dragData.id : undefined,
});
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const target = computeDropTarget({
elementType,
mouseX,
mouseY,
tracks,
playheadTime: currentTime,
isExternalDrop: isExternal,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
});
target.xPosition = getSnappedTime({ time: target.xPosition });
setDropTarget(target);
e.dataTransfer.dropEffect = "copy";
},
[track, zoomLevel, mediaFiles, getDropSnappedTime],
[
containerRef,
tracks,
currentTime,
zoomLevel,
getDragElementType,
getElementDuration,
getSnappedTime,
],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current--;
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0;
setIsDragOver(false);
setWouldOverlap(false);
setDropPositionIndicator(null);
}
}, []);
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
const { clientX, clientY } = e;
if (
clientX < rect.left ||
clientX > rect.right ||
clientY < rect.top ||
clientY > rect.bottom
) {
setIsDragOver(false);
setDropTarget(null);
setDragElementType(null);
}
}
},
[containerRef],
);
const executeTextDrop = useCallback(
({
target,
dragData,
}: {
target: DropTarget;
dragData: { name?: string; content?: string };
}) => {
let trackId: string;
if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: "text",
index: target.trackIndex,
});
} else {
const track = tracks[target.trackIndex];
if (!track) return;
trackId = track.id;
}
const element = buildTextElement({
raw: {
id: "",
name: dragData.name ?? "",
type: "text",
content: dragData.content ?? "",
},
startTime: target.xPosition,
});
editor.timeline.addElementToTrack({ trackId, element });
},
[editor.timeline, tracks],
);
const executeMediaDrop = useCallback(
({
target,
dragData,
}: {
target: DropTarget;
dragData: MediaAssetDragData;
}) => {
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
if (!mediaItem) return;
const trackType: TrackType =
dragData.mediaType === "audio" ? "audio" : "media";
let trackId: string;
if (target.isNewTrack) {
trackId = editor.timeline.addTrack({
type: trackType,
index: target.trackIndex,
});
} else {
const track = tracks[target.trackIndex];
if (!track) return;
trackId = track.id;
}
const duration =
mediaItem.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
if (dragData.mediaType === "audio") {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "audio",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: target.xPosition,
trimStart: 0,
trimEnd: 0,
volume: 1,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
muted: false,
},
});
} else if (dragData.mediaType === "video") {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "video",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: target.xPosition,
trimStart: 0,
trimEnd: 0,
},
});
} else {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "image",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: target.xPosition,
trimStart: 0,
trimEnd: 0,
},
});
}
},
[editor.timeline, mediaFiles, tracks],
);
const executeFileDrop = useCallback(
async ({ files }: { files: File[] }) => {
if (!activeProject) return;
const processedItems = await processMediaFiles({ files });
for (const item of processedItems) {
await editor.media.addMediaFile({
projectId: activeProject.id,
file: item,
});
const added = editor.media.mediaFiles.find(
(m) => m.name === item.name && m.url === item.url,
);
if (added) {
const trackType: TrackType =
added.type === "audio" ? "audio" : "media";
const trackId = editor.timeline.addTrack({
type: trackType,
index: 0,
});
const duration =
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
if (added.type === "audio") {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "audio",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
volume: 1,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
muted: false,
},
});
} else if (added.type === "video") {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "video",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
},
});
} else {
editor.timeline.addElementToTrack({
trackId,
element: {
type: "image",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
},
});
}
}
}
},
[activeProject, editor.media, editor.timeline, currentTime],
);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
setWouldOverlap(false);
setDropPositionIndicator(null);
dragCounterRef.current = 0;
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
const currentTarget = dropTarget;
setIsDragOver(false);
setDropTarget(null);
setDragElementType(null);
if (!currentTarget) return;
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasMediaItem && !hasFiles) return;
const trackContainer =
(e.currentTarget as HTMLElement).closest(".track-elements-container") ||
(e.currentTarget as HTMLElement).querySelector(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement);
if (!trackContainer) return;
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const mouseY = e.clientY - rect.top;
const dropTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
const projectFps = activeProject?.fps || DEFAULT_FPS;
const snappedTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
let dropPos: "above" | "on" | "below" = "on";
if (track) {
if (mouseY < 20) dropPos = "above";
else if (mouseY > 40) dropPos = "below";
}
if (!hasAsset && !hasFiles) return;
try {
if (hasMediaItem) {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (!mediaItemData) return;
const dragData: DragData = JSON.parse(mediaItemData);
if (hasAsset) {
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
if (!dragData) return;
if (dragData.type === "text") {
let targetTrackId = track?.id;
let targetTrack = track;
if (!track || track.type !== "text" || dropPos !== "on") {
const mainTrack = getMainTrack({ tracks });
let insertIndex = 0;
if (track) {
const currentIdx = tracks.findIndex((t) => t.id === track.id);
insertIndex = dropPos === "above" ? currentIdx : currentIdx + 1;
} else if (mainTrack) {
insertIndex = tracks.findIndex((t) => t.id === mainTrack.id);
}
targetTrackId = insertTrackAt("text", insertIndex);
targetTrack = useTimelineStore
.getState()
.tracks.find((t) => t.id === targetTrackId);
}
if (!targetTrack || !targetTrackId) return;
const duration = 5;
const finalStart = getDropSnappedTime(dropTime, duration);
const finalEnd = finalStart + duration;
if (
targetTrack.elements.some(
(el) =>
finalStart <
el.startTime + el.duration - el.trimStart - el.trimEnd &&
finalEnd > el.startTime,
)
) {
toast.error("Cannot place element here - overlap detected");
return;
}
addElementToTrack(targetTrackId, {
...DEFAULT_TEXT_ELEMENT,
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
startTime: finalStart,
});
executeTextDrop({ target: currentTarget, dragData });
} else {
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
if (!mediaItem) return;
let targetTrackId = track?.id;
const isVideoOrImage =
dragData.type === "video" || dragData.type === "image";
const isAudio = dragData.type === "audio";
const isCompatible = track
? isVideoOrImage
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: isAudio
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: false
: false;
let targetTrack = track;
if (!track || !isCompatible || dropPos !== "on") {
if (isVideoOrImage) {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
targetTrackId = addTrack("media");
} else if (
mainTrack.elements.length === 0 &&
(!track || dropPos === "on")
) {
targetTrackId = mainTrack.id;
} else {
let idx = track
? tracks.findIndex((t) => t.id === track.id)
: 0;
if (track) idx = dropPos === "above" ? idx : idx + 1;
else idx = tracks.findIndex((t) => t.id === mainTrack.id);
targetTrackId = insertTrackAt("media", idx);
}
} else if (isAudio) {
let idx = track
? tracks.findIndex((t) => t.id === track.id)
: tracks.length;
if (track) idx = dropPos === "above" ? idx : idx + 1;
targetTrackId = insertTrackAt("audio", idx);
}
targetTrack = useTimelineStore
.getState()
.tracks.find((t) => t.id === targetTrackId);
}
if (!targetTrack || !targetTrackId) return;
const duration = mediaItem.duration || 5;
const finalStart = getDropSnappedTime(dropTime, duration);
const finalEnd = finalStart + duration;
if (
targetTrack.elements.some(
(el) =>
finalStart <
el.startTime + el.duration - el.trimStart - el.trimEnd &&
finalEnd > el.startTime,
)
) {
toast.error("Cannot place element here - overlap detected");
return;
}
addElementToTrack(targetTrackId, {
type: "media",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: finalStart,
trimStart: 0,
trimEnd: 0,
});
executeMediaDrop({ target: currentTarget, dragData });
}
} else if (hasFiles) {
if (!activeProject) return;
const processedItems = await processMediaFiles({
files: Array.from(e.dataTransfer.files),
});
for (const item of processedItems) {
await addMediaFile(activeProject.id, item);
const added = useMediaStore
.getState()
.mediaFiles.find(
(m) => m.name === item.name && m.url === item.url,
);
if (added) {
const type: TrackType =
added.type === "audio" ? "audio" : "media";
const tid = insertTrackAt(type, 0);
addElementToTrack(tid, {
type: "media",
mediaId: added.id,
name: added.name,
duration: added.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
await executeFileDrop({ files: Array.from(e.dataTransfer.files) });
}
} catch (err) {
console.error(err);
console.error("Failed to process drop:", err);
toast.error("Failed to process drop");
}
},
[
track,
zoomLevel,
activeProject,
tracks,
mediaFiles,
currentTime,
getDropSnappedTime,
addElementToTrack,
insertTrackAt,
addTrack,
addMediaFile,
],
[dropTarget, executeTextDrop, executeMediaDrop, executeFileDrop],
);
return {
isDragOver,
wouldOverlap,
dropPositionIndicator,
dropTarget,
dragElementType,
dragProps: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
@@ -1,261 +0,0 @@
import { useState, useEffect } from "react";
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useProjectStore } from "@/stores/project-store";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
interface UseTimelineElementResizeProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
}
export function useTimelineElementResize({
element,
track,
zoomLevel,
}: UseTimelineElementResizeProps) {
const [resizing, setResizing] = useState<ResizeState | null>(null);
const { mediaFiles } = useMediaStore();
const {
updateElementStartTime,
updateElementTrim,
updateElementDuration,
pushHistory,
} = useTimelineStore();
// Set up document-level mouse listeners during resize (like proper drag behavior)
useEffect(() => {
if (!resizing) return;
const handleDocumentMouseMove = (e: MouseEvent) => {
updateTrimFromMouseMove({ clientX: e.clientX });
};
const handleDocumentMouseUp = () => {
handleResizeEnd();
};
// Add document-level listeners for proper drag behavior
document.addEventListener("mousemove", handleDocumentMouseMove);
document.addEventListener("mouseup", handleDocumentMouseUp);
return () => {
document.removeEventListener("mousemove", handleDocumentMouseMove);
document.removeEventListener("mouseup", handleDocumentMouseUp);
};
}, [resizing]); // Re-run when resizing state changes
const handleResizeStart = (
e: React.MouseEvent,
elementId: string,
side: "left" | "right",
) => {
e.stopPropagation();
e.preventDefault();
// Push history once at the start of the resize operation
pushHistory();
setResizing({
elementId,
side,
startX: e.clientX,
initialTrimStart: element.trimStart,
initialTrimEnd: element.trimEnd,
});
};
const canExtendElementDuration = () => {
// Text elements can always be extended
if (element.type === "text") {
return true;
}
// Media elements - check the media type
if (element.type === "media") {
const mediaFile = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaFile) return false;
// Images can be extended (static content)
if (mediaFile.type === "image") {
return true;
}
// Videos and audio cannot be extended beyond their natural duration
// (no additional content exists)
return false;
}
return false;
};
const updateTrimFromMouseMove = (e: { clientX: number }) => {
if (!resizing) return;
const deltaX = e.clientX - resizing.startX;
// Reasonable sensitivity for resize operations - similar to timeline scale
const deltaTime = deltaX / (50 * zoomLevel);
// Get project FPS for frame snapping
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
if (resizing.side === "left") {
// Left resize - different behavior for media vs text/image elements
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0) {
// Normal trimming within available content
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: element.startTime + trimDelta,
fps: projectFps,
});
updateElementTrim(
track.id,
element.id,
newTrimStart,
resizing.initialTrimEnd,
false,
);
updateElementStartTime(track.id, element.id, newStartTime, false);
} else {
// Trying to extend beyond trimStart = 0
if (canExtendElementDuration()) {
// Text/Image: extend element to the left by moving startTime and increasing duration
const extensionAmount = Math.abs(calculated);
const maxExtension = element.startTime;
const actualExtension = Math.min(extensionAmount, maxExtension);
const newStartTime = snapTimeToFrame({
time: element.startTime - actualExtension,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: element.duration + actualExtension,
fps: projectFps,
});
// Keep trimStart at 0 and extend the element
updateElementTrim(
track.id,
element.id,
0,
resizing.initialTrimEnd,
false,
);
updateElementDuration(track.id, element.id, newDuration, false);
updateElementStartTime(track.id, element.id, newStartTime, false);
} else {
// Video/Audio: can't extend beyond original content - limit to trimStart = 0
const newTrimStart = 0;
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: element.startTime + trimDelta,
fps: projectFps,
});
updateElementTrim(
track.id,
element.id,
newTrimStart,
resizing.initialTrimEnd,
false,
);
updateElementStartTime(track.id, element.id, newStartTime, false);
}
}
} else {
// Right resize - can extend duration for supported element types
const calculated = resizing.initialTrimEnd - deltaTime;
if (calculated < 0) {
// We're trying to extend beyond original duration
if (canExtendElementDuration()) {
// Extend the duration instead of reducing trimEnd further
const extensionNeeded = Math.abs(calculated);
const newDuration = snapTimeToFrame({
time: element.duration + extensionNeeded,
fps: projectFps,
});
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
// Update duration first, then trim
updateElementDuration(track.id, element.id, newDuration, false);
updateElementTrim(
track.id,
element.id,
resizing.initialTrimStart,
newTrimEnd,
false,
);
} else {
// Can't extend - just set trimEnd to 0 (maximum possible extension)
updateElementTrim(
track.id,
element.id,
resizing.initialTrimStart,
0,
false,
);
}
} else {
// Normal trimming within original duration
// Calculate the desired end time based on mouse movement
const currentEndTime =
element.startTime +
element.duration -
element.trimStart -
element.trimEnd;
const desiredEndTime = currentEndTime + deltaTime;
// Snap the desired end time to frame
const snappedEndTime = snapTimeToFrame({
time: desiredEndTime,
fps: projectFps,
});
// Calculate what trimEnd should be to achieve this snapped end time
const newTrimEnd = Math.max(
0,
element.duration -
element.trimStart -
(snappedEndTime - element.startTime),
);
// Ensure we don't trim more than available content (leave at least 0.1s visible)
const maxTrimEnd = element.duration - element.trimStart - 0.1;
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
updateElementTrim(
track.id,
element.id,
element.trimStart,
finalTrimEnd,
false,
);
}
}
};
const handleResizeEnd = () => {
setResizing(null);
};
return {
resizing,
isResizing: resizing !== null,
handleResizeStart,
// Return empty handlers since we use document listeners now
handleResizeMove: () => {}, // Not used anymore
handleResizeEnd: () => {}, // Not used anymore
};
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useMemo, useReducer } from "react";
import { EditorCore } from "@/core";
export function useEditor(): EditorCore {
const editor = useMemo(() => EditorCore.getInstance(), []);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => {
const unsubscribers = [
editor.playback.subscribe(forceUpdate),
editor.timeline.subscribe(forceUpdate),
editor.scene.subscribe(forceUpdate),
editor.project.subscribe(forceUpdate),
editor.media.subscribe(forceUpdate),
editor.renderer.subscribe(forceUpdate),
];
return () => unsubscribers.forEach((unsub) => unsub());
}, [editor]);
return editor;
}
+121
View File
@@ -0,0 +1,121 @@
import { useCallback } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
type ElementRef = { trackId: string; elementId: string };
export function useElementSelection() {
const selectedElements = useTimelineStore((s) => s.selectedElements);
const setSelectedElements = useTimelineStore((s) => s.setSelectedElements);
const isSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
selectedElements.some(
(el) => el.trackId === trackId && el.elementId === elementId,
),
[selectedElements],
);
const select = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({ elements: [{ trackId, elementId }] });
},
[setSelectedElements],
);
const addToSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
const alreadySelected = selectedElements.some(
(el) => el.trackId === trackId && el.elementId === elementId,
);
if (alreadySelected) return;
setSelectedElements({
elements: [...selectedElements, { trackId, elementId }],
});
},
[selectedElements, setSelectedElements],
);
const removeFromSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({
elements: selectedElements.filter(
(el) => !(el.trackId === trackId && el.elementId === elementId),
),
});
},
[selectedElements, setSelectedElements],
);
const toggleSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
const alreadySelected = selectedElements.some(
(el) => el.trackId === trackId && el.elementId === elementId,
);
if (alreadySelected) {
removeFromSelection({ trackId, elementId });
} else {
addToSelection({ trackId, elementId });
}
},
[selectedElements, addToSelection, removeFromSelection],
);
const clearSelection = useCallback(() => {
setSelectedElements({ elements: [] });
}, [setSelectedElements]);
const setSelection = useCallback(
(elements: ElementRef[]) => {
setSelectedElements({ elements });
},
[setSelectedElements],
);
/**
* Handles click interaction on an element.
* - Regular click: select only this element
* - Multi-key click (Ctrl/Cmd): toggle this element in selection
*/
const handleElementClick = useCallback(
({
trackId,
elementId,
isMultiKey,
}: ElementRef & { isMultiKey: boolean }) => {
if (isMultiKey) {
toggleSelection({ trackId, elementId });
} else {
select({ trackId, elementId });
}
},
[toggleSelection, select],
);
/**
* Ensures element is selected without toggling.
* Used for drag operations where we want to select if not already.
*/
const ensureSelected = useCallback(
({ trackId, elementId }: ElementRef) => {
if (!isSelected({ trackId, elementId })) {
select({ trackId, elementId });
}
},
[isSelected, select],
);
return {
selectedElements,
isSelected,
select,
setSelection,
addToSelection,
removeFromSelection,
toggleSelection,
clearSelection,
handleElementClick,
ensureSelected,
};
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from "react";
interface UseFilePasteOptions {
onFilesPaste: (files: File[]) => void;
}
export function useFilePaste({ onFilesPaste }: UseFilePasteOptions) {
useEffect(() => {
function handlePaste(e: ClipboardEvent) {
if (!e.clipboardData?.files.length) return;
const files = Array.from(e.clipboardData.files);
if (files.length > 0) {
e.preventDefault();
onFilesPaste(files);
}
}
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [onFilesPaste]);
}
+105
View File
@@ -0,0 +1,105 @@
import { useState, useRef } from "react";
import { hasAssetDragData } from "@/lib/asset-drag";
interface UseFileUploadOptions {
accept?: string;
multiple?: boolean;
onFilesSelected?: (files: FileList) => void;
}
function containsFiles(dataTransfer: DataTransfer): boolean {
return (
!hasAssetDragData({ dataTransfer }) && dataTransfer.types.includes("Files")
);
}
export function useFileUpload({
accept,
multiple,
onFilesSelected,
}: UseFileUploadOptions = {}) {
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
function openFilePicker() {
if (!inputRef.current) return;
inputRef.current.accept = accept || "*";
inputRef.current.multiple = multiple || false;
inputRef.current.click();
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.target.files;
if (files && files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
if (event.target) {
event.target.value = "";
}
}
function handleDragEnter(e: React.DragEvent) {
e.preventDefault();
if (!containsFiles(e.dataTransfer)) return;
dragCounterRef.current += 1;
setIsDragOver(true);
}
function handleDragOver(e: React.DragEvent) {
e.preventDefault();
if (!containsFiles(e.dataTransfer)) return;
}
function handleDragLeave(e: React.DragEvent) {
e.preventDefault();
if (!containsFiles(e.dataTransfer)) return;
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
if (onFilesSelected && containsFiles(e.dataTransfer)) {
const files = e.dataTransfer.files;
const shouldUseMultiple = multiple ?? false;
if (shouldUseMultiple) {
onFilesSelected(files);
} else if (files.length > 0) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(files[0]);
onFilesSelected(dataTransfer.files);
}
}
}
return {
isDragOver,
openFilePicker,
fileInputProps: {
ref: inputRef,
type: "file",
style: { display: "none" },
onChange: handleFileChange,
},
dragProps: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}