shipping this slop

This commit is contained in:
Maze Winther
2026-01-20 22:14:49 +01:00
parent 7117f17ece
commit 6e4069b42f
97 changed files with 3357 additions and 1858 deletions
@@ -3,8 +3,6 @@
import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { PasteCommand } from "@/lib/commands/timeline/clipboard/paste";
import { toast } from "sonner";
import { useElementSelection } from "../timeline/element/use-element-selection";
export function useEditorActions() {
@@ -126,14 +124,10 @@ export function useEditorActions() {
useActionHandler(
"split-selected",
() => {
const splitElementIds = editor.timeline.splitElements({
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
});
if (splitElementIds.length === 0) {
toast.error("Playhead must be positioned over the selected element(s)");
}
},
undefined,
);
@@ -141,15 +135,11 @@ export function useEditorActions() {
useActionHandler(
"split-selected-left",
() => {
const splitElementIds = editor.timeline.splitElements({
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
retainSide: "left",
});
if (splitElementIds.length === 0) {
toast.error("Playhead must be positioned over the selected element(s)");
}
},
undefined,
);
@@ -157,15 +147,11 @@ export function useEditorActions() {
useActionHandler(
"split-selected-right",
() => {
const splitElementIds = editor.timeline.splitElements({
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
retainSide: "right",
});
if (splitElementIds.length === 0) {
toast.error("Playhead must be positioned over the selected element(s)");
}
},
undefined,
);
@@ -240,6 +226,7 @@ export function useEditorActions() {
const items = results.map(({ track, element }) => {
const { id, ...elementWithoutId } = element;
return {
trackId: track.id,
trackType: track.type,
element: elementWithoutId,
};
@@ -255,9 +242,9 @@ export function useEditorActions() {
() => {
if (!clipboard?.items.length) return;
const currentTime = editor.playback.getCurrentTime();
editor.command.execute({
command: new PasteCommand(currentTime, clipboard.items),
editor.timeline.pasteAtTime({
time: editor.playback.getCurrentTime(),
clipboardItems: clipboard.items,
});
},
undefined,
@@ -12,6 +12,7 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { generateUUID } from "@/lib/utils";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import type {
DropTarget,
ElementDragState,
@@ -27,6 +28,7 @@ interface UseElementInteractionProps {
timelineRef: RefObject<HTMLDivElement | null>;
tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
snappingEnabled: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
@@ -82,8 +84,16 @@ function getClickOffsetTime({
return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
}
function getElementDuration({ element }: { element: TimelineElement }): number {
return element.duration - element.trimStart - element.trimEnd;
function getVerticalDragDirection({
startMouseY,
currentMouseY,
}: {
startMouseY: number;
currentMouseY: number;
}): "up" | "down" | null {
if (currentMouseY < startMouseY) return "up";
if (currentMouseY > startMouseY) return "down";
return null;
}
function getDragDropTarget({
@@ -96,6 +106,7 @@ function getDragDropTarget({
tracksScrollRef,
zoomLevel,
snappedTime,
verticalDragDirection,
}: {
clientX: number;
clientY: number;
@@ -106,6 +117,7 @@ function getDragDropTarget({
tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
snappedTime: number;
verticalDragDirection?: "up" | "down" | null;
}): DropTarget | null {
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
const scrollContainer = tracksScrollRef.current;
@@ -115,7 +127,7 @@ function getDragDropTarget({
const movingElement = sourceTrack?.elements.find(({ id }) => id === elementId);
if (!movingElement) return null;
const elementDuration = getElementDuration({ element: movingElement });
const elementDuration = movingElement.duration;
const scrollLeft = scrollContainer.scrollLeft;
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const mouseX = clientX - scrollContainerRect.left + scrollLeft;
@@ -133,6 +145,7 @@ function getDragDropTarget({
zoomLevel,
startTimeOverride: snappedTime,
excludeElementId: movingElement.id,
verticalDragDirection,
});
}
@@ -147,10 +160,12 @@ export function useElementInteraction({
timelineRef,
tracksContainerRef,
tracksScrollRef,
snappingEnabled,
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
const { snapElementEdge } = useTimelineSnapping();
const {
isElementSelected,
selectElement,
@@ -196,6 +211,55 @@ export function useElementInteraction({
setDragDropTarget(null);
}, []);
const getDragSnapResult = useCallback(
({
frameSnappedTime,
movingElement,
}: {
frameSnappedTime: number;
movingElement: TimelineElement | null | undefined;
}) => {
if (!snappingEnabled || !movingElement) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
const elementDuration = movingElement.duration;
const playheadTime = editor.playback.getCurrentTime();
const startSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
tracks,
playheadTime,
zoomLevel,
excludeElementId: movingElement.id,
snapToStart: true,
});
const endSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
tracks,
playheadTime,
zoomLevel,
excludeElementId: movingElement.id,
snapToStart: false,
});
const snapResult =
startSnap.snapDistance <= endSnap.snapDistance ? startSnap : endSnap;
if (!snapResult.snapPoint) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
return {
snappedTime: snapResult.snappedTime,
snapPoint: snapResult.snapPoint,
};
},
[snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel],
);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
@@ -269,14 +333,28 @@ export function useElementInteraction({
});
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps });
const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps });
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
({ id }) => id === dragState.elementId,
);
const { snappedTime, snapPoint } = getDragSnapResult({
frameSnappedTime,
movingElement,
});
setDragState((previousDragState) => ({
...previousDragState,
currentTime: snappedTime,
currentMouseY: clientY,
}));
onSnapPointChange?.(snapPoint);
if (dragState.elementId && dragState.trackId) {
const verticalDragDirection = getVerticalDragDirection({
startMouseY: dragState.startMouseY,
currentMouseY: clientY,
});
const dropTarget = getDragDropTarget({
clientX,
clientY,
@@ -287,6 +365,7 @@ export function useElementInteraction({
tracksScrollRef,
zoomLevel,
snappedTime,
verticalDragDirection,
});
setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null);
}
@@ -303,12 +382,15 @@ export function useElementInteraction({
isElementSelected,
selectElement,
editor.project,
editor.playback,
timelineRef,
tracksScrollRef,
tracksContainerRef,
tracks,
isPendingDrag,
startDrag,
getDragSnapResult,
onSnapPointChange,
]);
useEffect(() => {
@@ -338,6 +420,10 @@ export function useElementInteraction({
tracksScrollRef,
zoomLevel,
snappedTime: dragState.currentTime,
verticalDragDirection: getVerticalDragDirection({
startMouseY: dragState.startMouseY,
currentMouseY: clientY,
}),
});
if (!dropTarget) {
endDrag();
@@ -2,6 +2,11 @@ import { useState, useEffect, useRef } from "react";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
import { snapTimeToFrame } from "@/lib/time-utils";
import { EditorCore } from "@/core";
import {
useTimelineSnapping,
type SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
import { useTimelineStore } from "@/stores/timeline-store";
export interface ResizeState {
elementId: string;
@@ -17,15 +22,21 @@ interface UseTimelineElementResizeProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
}
export function useTimelineElementResize({
element,
track,
zoomLevel,
onSnapPointChange,
onResizeStateChange,
}: UseTimelineElementResizeProps) {
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
@@ -87,6 +98,7 @@ export function useTimelineElementResize({
currentTrimEndRef.current = element.trimEnd;
currentStartTimeRef.current = element.startTime;
currentDurationRef.current = element.duration;
onResizeStateChange?.({ isResizing: true });
};
const canExtendElementDuration = () => {
@@ -101,16 +113,55 @@ export function useTimelineElementResize({
if (!resizing) return;
const deltaX = clientX - resizing.startX;
const deltaTime = deltaX / (50 * zoomLevel);
let deltaTime = deltaX / (50 * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = activeProject.settings.fps;
const minDurationSeconds = 1 / projectFps;
const canSnap = snappingEnabled;
if (canSnap) {
const tracks = editor.timeline.getTracks();
const playheadTime = editor.playback.getCurrentTime();
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId: element.id,
});
if (resizing.side === "left") {
const targetStartTime = resizing.initialStartTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetStartTime,
snapPoints,
zoomLevel,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - resizing.initialStartTime;
}
} else {
const baseEndTime =
resizing.initialStartTime + resizing.initialDuration;
const targetEndTime = baseEndTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetEndTime,
snapPoints,
zoomLevel,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - baseEndTime;
}
}
}
onSnapPointChange?.(resizeSnapPoint);
if (resizing.side === "left") {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const maxAllowed = sourceDuration - resizing.initialTrimEnd - 0.1;
const maxAllowed =
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0 && calculated <= maxAllowed) {
@@ -183,8 +234,9 @@ export function useTimelineElementResize({
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration = resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionNeeded,
time: baseDuration + extensionNeeded,
fps: projectFps,
});
@@ -205,7 +257,8 @@ export function useTimelineElementResize({
currentTrimEndRef.current = 0;
}
} else {
const maxTrimEnd = sourceDuration - resizing.initialTrimStart - 0.1;
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
@@ -261,6 +314,8 @@ export function useTimelineElementResize({
}
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
};
return {
@@ -208,7 +208,7 @@ export function useSelectionBox({
}, [selectionBox, selectElementsInBox]);
useEffect(() => {
if (!selectionBox?.isActive) return;
if (!selectionBox) return;
const previousBodyUserSelect = document.body.style.userSelect;
const container = containerRef.current;
@@ -221,7 +221,7 @@ export function useSelectionBox({
document.body.style.userSelect = previousBodyUserSelect;
if (container) container.style.userSelect = previousContainerUserSelect;
};
}, [selectionBox?.isActive, containerRef]);
}, [selectionBox, containerRef]);
return {
selectionBox,
@@ -1,6 +1,6 @@
import { useState, useCallback, type RefObject } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media-processing-utils";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
@@ -98,9 +98,10 @@ export function useTimelineDragDrop({
let elementType = getElementType({ dataTransfer: e.dataTransfer });
// external drops default to video until determined on drop
if (!elementType && hasFiles) {
elementType = "video";
if (!elementType && hasFiles && isExternal) {
setDropTarget(null);
setElementType(null);
return;
}
if (!elementType) return;
@@ -194,7 +195,10 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
editor.timeline.addElementToTrack({ trackId, element });
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
},
[editor.timeline, tracks],
);
@@ -225,7 +229,10 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
editor.timeline.addElementToTrack({ trackId, element });
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
},
[editor.timeline, tracks],
);
@@ -254,8 +261,8 @@ export function useTimelineDragDrop({
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
if (dragData.mediaType === "audio") {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "audio",
sourceType: "upload",
@@ -266,13 +273,12 @@ export function useTimelineDragDrop({
trimStart: 0,
trimEnd: 0,
volume: 1,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
muted: false,
},
});
} else if (dragData.mediaType === "video") {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "video",
mediaId: mediaAsset.id,
@@ -293,8 +299,8 @@ export function useTimelineDragDrop({
},
});
} else {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "image",
mediaId: mediaAsset.id,
@@ -320,7 +326,15 @@ export function useTimelineDragDrop({
);
const executeFileDrop = useCallback(
async ({ files }: { files: File[] }) => {
async ({
files,
mouseX,
mouseY,
}: {
files: File[];
mouseX: number;
mouseY: number;
}) => {
if (!activeProject) return;
const processedAssets = await processMediaAssets({ files });
@@ -336,26 +350,42 @@ export function useTimelineDragDrop({
.find((m) => m.name === asset.name && m.url === asset.url);
if (added) {
const trackType: TrackType =
added.type === "audio" ? "audio" : "video";
const trackId = editor.timeline.addTrack({
type: trackType,
index: 0,
});
const duration =
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const dropTarget = computeDropTarget({
elementType: added.type,
mouseX,
mouseY,
tracks: currentTracks,
playheadTime: currentTime,
isExternalDrop: true,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
});
const trackType: TrackType =
added.type === "audio" ? "audio" : "video";
const trackId = dropTarget.isNewTrack
? editor.timeline.addTrack({
type: trackType,
index: dropTarget.trackIndex,
})
: currentTracks[dropTarget.trackIndex]?.id;
if (!trackId) return;
if (added.type === "audio") {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "audio",
sourceType: "upload",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
startTime: dropTarget.xPosition,
trimStart: 0,
trimEnd: 0,
volume: 1,
@@ -364,14 +394,14 @@ export function useTimelineDragDrop({
},
});
} else if (added.type === "video") {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "video",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
startTime: dropTarget.xPosition,
trimStart: 0,
trimEnd: 0,
transform: {
@@ -386,14 +416,14 @@ export function useTimelineDragDrop({
},
});
} else {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "image",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
startTime: dropTarget.xPosition,
trimStart: 0,
trimEnd: 0,
transform: {
@@ -411,27 +441,32 @@ export function useTimelineDragDrop({
}
}
},
[activeProject, editor.media, editor.timeline, currentTime],
[
activeProject,
editor.media,
editor.timeline,
currentTime,
zoomLevel,
],
);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
const currentTarget = dropTarget;
setIsDragOver(false);
setDropTarget(null);
setElementType(null);
if (!currentTarget) return;
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasAsset && !hasFiles) return;
const currentTarget = dropTarget;
setIsDragOver(false);
setDropTarget(null);
setElementType(null);
try {
if (hasAsset) {
if (!currentTarget) return;
const dragData = getDragData({ dataTransfer: e.dataTransfer });
if (!dragData) return;
@@ -443,7 +478,15 @@ export function useTimelineDragDrop({
executeMediaDrop({ target: currentTarget, dragData });
}
} else if (hasFiles) {
await executeFileDrop({ files: Array.from(e.dataTransfer.files) });
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
await executeFileDrop({
files: Array.from(e.dataTransfer.files),
mouseX,
mouseY,
});
}
} catch (err) {
console.error("Failed to process drop:", err);
@@ -89,20 +89,18 @@ export function useTimelineInteractions({
({ event }: { event: React.MouseEvent }) => {
const target = event.target as HTMLElement;
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
if (!isMouseDown) return false;
const deltaX = Math.abs(event.clientX - downX);
const deltaY = Math.abs(event.clientY - downY);
const deltaTime = event.timeStamp - downTime;
const isPlayhead = !!playheadRef.current?.contains(target);
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) return false;
if (!isMouseDown) return false;
if (shouldBlockForDrag) return false;
if (isSelecting) return false;
if (playheadRef.current?.contains(target)) return false;
if (trackLabelsRef.current?.contains(target)) {
if (isPlayhead) return false;
if (isTrackLabels) {
clearSelectedElements();
return false;
}
@@ -134,7 +132,7 @@ export function useTimelineInteractions({
Math.min(
duration,
(mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
),
);
@@ -158,9 +156,10 @@ export function useTimelineInteractions({
const handleTracksClick = useCallback(
(event: React.MouseEvent) => {
const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
if (shouldProcessTimelineClick({ event })) {
if (shouldProcess) {
clearSelectedElements();
handleTimelineSeek({ event, source: "tracks" });
}
@@ -170,9 +169,10 @@ export function useTimelineInteractions({
const handleRulerClick = useCallback(
(event: React.MouseEvent) => {
const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
if (shouldProcessTimelineClick({ event })) {
if (shouldProcess) {
clearSelectedElements();
handleTimelineSeek({ event, source: "ruler" });
}
@@ -10,6 +10,7 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
isInTimeline?: boolean;
minZoom?: number;
}
interface UseTimelineZoomReturn {
@@ -21,6 +22,7 @@ interface UseTimelineZoomReturn {
export function useTimelineZoom({
containerRef,
isInTimeline = false,
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
}: UseTimelineZoomProps): UseTimelineZoomReturn {
const [zoomLevel, setZoomLevel] = useState(1);
@@ -38,7 +40,7 @@ export function useTimelineZoom({
const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
setZoomLevel((prev) => {
const nextZoom = Math.max(
TIMELINE_CONSTANTS.ZOOM_MIN,
minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
);
return nextZoom;
@@ -47,7 +49,11 @@ export function useTimelineZoom({
// let the event bubble up to allow ScrollArea to handle it
return;
}
}, []);
}, [minZoom]);
useEffect(() => {
setZoomLevel((prev) => (prev < minZoom ? minZoom : prev));
}, [minZoom]);
// prevent browser zoom in the timeline
useEffect(() => {