fuck malware, fuck you

This commit is contained in:
Maze Winther
2026-01-18 05:44:25 +01:00
parent 0934db2aba
commit f18e9f4b4e
59 changed files with 1850 additions and 2100 deletions
@@ -7,10 +7,11 @@ import {
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/use-element-selection";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
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 type {
ElementDragState,
TimelineElement,
@@ -24,6 +25,7 @@ interface UseElementInteractionProps {
zoomLevel: number;
timelineRef: RefObject<HTMLDivElement | null>;
tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
@@ -32,27 +34,80 @@ const initialDragState: ElementDragState = {
elementId: null,
trackId: null,
startMouseX: 0,
startMouseY: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
};
interface PendingDragState {
elementId: string;
trackId: string;
startMouseX: number;
startMouseY: number;
startElementTime: number;
clickOffsetTime: number;
}
function getMouseTimeFromClientX({
clientX,
containerRect,
zoomLevel,
scrollLeft,
}: {
clientX: number;
containerRect: DOMRect;
zoomLevel: number;
scrollLeft: number;
}): number {
const mouseX = clientX - containerRect.left + scrollLeft;
return Math.max(
0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
);
}
function getClickOffsetTime({
clientX,
elementRect,
zoomLevel,
}: {
clientX: number;
elementRect: DOMRect;
zoomLevel: number;
}): number {
const clickOffsetX = clientX - elementRect.left;
return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
}
function getElementDuration({ element }: { element: TimelineElement }): number {
return element.duration - element.trimStart - element.trimEnd;
}
interface StartDragParams
extends Omit<ElementDragState, "isDragging" | "currentTime"> {
initialCurrentTime: number;
}
export function useElementInteraction({
zoomLevel,
timelineRef,
tracksContainerRef,
tracksScrollRef,
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
const {
isSelected,
select,
isElementSelected,
selectElement,
handleElementClick: handleSelectionClick,
} = useElementSelection();
const [dragState, setDragState] =
useState<ElementDragState>(initialDragState);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingDragState | null>(null);
const lastMouseXRef = useRef(0);
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
@@ -61,17 +116,20 @@ export function useElementInteraction({
elementId,
trackId,
startMouseX,
startMouseY,
startElementTime,
clickOffsetTime,
}: Omit<ElementDragState, "isDragging" | "currentTime">) => {
initialCurrentTime,
}: StartDragParams) => {
setDragState({
isDragging: true,
elementId,
trackId,
startMouseX,
startMouseY,
startElementTime,
clickOffsetTime,
currentTime: startElementTime,
currentTime: initialCurrentTime,
});
},
[],
@@ -81,37 +139,77 @@ export function useElementInteraction({
setDragState(initialDragState);
}, []);
// mouse move: update drag time
useEffect(() => {
if (!dragState.isDragging) return;
if (!dragState.isDragging && !isPendingDrag) return;
const handleMouseMove = ({ clientX }: MouseEvent) => {
if (!timelineRef.current) return;
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
let startedDragThisEvent = false;
const timeline = timelineRef.current;
const scrollContainer = tracksScrollRef.current;
if (!timeline || !scrollContainer) return;
lastMouseXRef.current = clientX;
if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
const activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
const mouseTime = getMouseTimeFromClientX({
clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft,
});
const adjustedTime = Math.max(
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = snapTimeToFrame({
time: adjustedTime,
fps: activeProject.settings.fps,
});
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
});
startedDragThisEvent = true;
pendingDragRef.current = null;
setIsPendingDrag(false);
} else {
return;
}
}
if (startedDragThisEvent) {
return;
}
if (dragState.elementId && dragState.trackId) {
const alreadySelected = isSelected({
const alreadySelected = isElementSelected({
trackId: dragState.trackId,
elementId: dragState.elementId,
});
if (!alreadySelected) {
select({
selectElement({
trackId: dragState.trackId,
elementId: dragState.elementId,
});
}
}
const rect = timelineRef.current.getBoundingClientRect();
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 activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
const mouseTime = getMouseTimeFromClientX({
clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft,
});
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps });
setDragState((previousDragState) => ({
@@ -128,13 +226,15 @@ export function useElementInteraction({
dragState.elementId,
dragState.trackId,
zoomLevel,
isSelected,
select,
isElementSelected,
selectElement,
editor.project,
timelineRef,
tracksScrollRef,
isPendingDrag,
startDrag,
]);
// mouse up: resolve drop
useEffect(() => {
if (!dragState.isDragging) return;
@@ -164,12 +264,24 @@ export function useElementInteraction({
return;
}
const elementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const mouseX = clientX - containerRect.left;
const elementDuration = getElementDuration({ element: movingElement });
const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
const scrollContainerRect =
tracksScrollRef.current?.getBoundingClientRect();
const mouseX = scrollContainerRect
? clientX - scrollContainerRect.left + scrollLeft
: clientX - containerRect.left + scrollLeft;
const mouseY = clientY - containerRect.top;
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
mouseDownLocationRef.current = null;
endDrag();
onSnapPointChange?.(null);
return;
}
}
const dropTarget = computeDropTarget({
elementType: movingElement.type,
@@ -181,27 +293,19 @@ export function useElementInteraction({
elementDuration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
excludeElementId: movingElement.id,
});
const activeProject = editor.project.getActive();
if (!activeProject) {
endDrag();
onSnapPointChange?.(null);
return;
}
const fps = activeProject.settings.fps;
const snappedTime = snapTimeToFrame({ time: dropTarget.xPosition, fps });
const snappedTime = dragState.currentTime;
if (dropTarget.isNewTrack) {
const newTrackId = editor.timeline.addTrack({
type: sourceTrack.type,
index: dropTarget.trackIndex,
});
const newTrackId = generateUUID();
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: newTrackId,
elementId: dragState.elementId,
newStartTime: snappedTime,
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
});
} else {
const targetTrack = tracks[dropTarget.trackIndex];
@@ -230,11 +334,24 @@ export function useElementInteraction({
tracks,
endDrag,
onSnapPointChange,
editor.project,
editor.timeline,
tracksContainerRef,
tracksScrollRef,
]);
useEffect(() => {
if (!isPendingDrag) return;
const handleMouseUp = () => {
pendingDragRef.current = null;
setIsPendingDrag(false);
onSnapPointChange?.(null);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [isPendingDrag, onSnapPointChange]);
const handleElementMouseDown = useCallback(
({
event,
@@ -253,7 +370,7 @@ export function useElementInteraction({
// right-click
if (isRightClick) {
const alreadySelected = isSelected({
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
@@ -277,22 +394,24 @@ export function useElementInteraction({
}
// start drag
const elementRect = (
event.currentTarget as HTMLElement
).getBoundingClientRect();
const clickOffsetX = event.clientX - elementRect.left;
const clickOffsetTime =
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
startDrag({
const clickOffsetTime = getClickOffsetTime({
clientX: event.clientX,
elementRect: (
event.currentTarget as HTMLElement
).getBoundingClientRect(),
zoomLevel,
});
pendingDragRef.current = {
elementId: element.id,
trackId: track.id,
startMouseX: event.clientX,
startMouseY: event.clientY,
startElementTime: element.startTime,
clickOffsetTime,
});
};
setIsPendingDrag(true);
},
[zoomLevel, startDrag, isSelected, handleSelectionClick],
[zoomLevel, isElementSelected, handleSelectionClick],
);
const handleElementClick = useCallback(
@@ -321,15 +440,15 @@ export function useElementInteraction({
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
// single click: select if not selected
const alreadySelected = isSelected({
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
if (!alreadySelected) {
select({ trackId: track.id, elementId: element.id });
selectElement({ trackId: track.id, elementId: element.id });
}
},
[isSelected, select],
[isElementSelected, selectElement],
);
return {
@@ -1,10 +1,7 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
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";
export interface ResizeState {
elementId: string;
@@ -35,6 +32,10 @@ export function useTimelineElementResize({
const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd);
const [currentStartTime, setCurrentStartTime] = useState(element.startTime);
const [currentDuration, setCurrentDuration] = useState(element.duration);
const currentTrimStartRef = useRef(element.trimStart);
const currentTrimEndRef = useRef(element.trimEnd);
const currentStartTimeRef = useRef(element.startTime);
const currentDurationRef = useRef(element.duration);
useEffect(() => {
if (!resizing) return;
@@ -82,6 +83,10 @@ export function useTimelineElementResize({
setCurrentTrimEnd(element.trimEnd);
setCurrentStartTime(element.startTime);
setCurrentDuration(element.duration);
currentTrimStartRef.current = element.trimStart;
currentTrimEndRef.current = element.trimEnd;
currentStartTimeRef.current = element.startTime;
currentDurationRef.current = element.duration;
};
const canExtendElementDuration = () => {
@@ -126,6 +131,9 @@ export function useTimelineElementResize({
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else if (calculated < 0) {
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
@@ -143,6 +151,9 @@ export function useTimelineElementResize({
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
const trimDelta = 0 - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
@@ -157,6 +168,9 @@ export function useTimelineElementResize({
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
@@ -176,6 +190,8 @@ export function useTimelineElementResize({
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
const extensionToLimit = resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
@@ -185,6 +201,8 @@ export function useTimelineElementResize({
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
}
} else {
const maxTrimEnd = sourceDuration - resizing.initialTrimStart - 0.1;
@@ -201,6 +219,8 @@ export function useTimelineElementResize({
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
currentTrimEndRef.current = finalTrimEnd;
currentDurationRef.current = newDuration;
}
}
};
@@ -208,36 +228,36 @@ export function useTimelineElementResize({
const handleResizeEnd = () => {
if (!resizing) return;
const trimStartChanged = currentTrimStart !== resizing.initialTrimStart;
const trimEndChanged = currentTrimEnd !== resizing.initialTrimEnd;
const startTimeChanged = currentStartTime !== resizing.initialStartTime;
const durationChanged = currentDuration !== resizing.initialDuration;
const finalTrimStart = currentTrimStartRef.current;
const finalTrimEnd = currentTrimEndRef.current;
const finalStartTime = currentStartTimeRef.current;
const finalDuration = currentDurationRef.current;
const trimStartChanged = finalTrimStart !== resizing.initialTrimStart;
const trimEndChanged = finalTrimEnd !== resizing.initialTrimEnd;
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
const durationChanged = finalDuration !== resizing.initialDuration;
if (trimStartChanged || trimEndChanged) {
const trimCommand = new UpdateElementTrimCommand(
track.id,
element.id,
currentTrimStart,
currentTrimEnd,
);
editor.command.execute({ command: trimCommand });
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
trimEnd: finalTrimEnd,
});
}
if (startTimeChanged) {
const startTimeCommand = new UpdateElementStartTimeCommand(
[{ trackId: track.id, elementId: element.id }],
currentStartTime,
);
editor.command.execute({ command: startTimeCommand });
editor.timeline.updateElementStartTime({
elements: [{ trackId: track.id, elementId: element.id }],
startTime: finalStartTime,
});
}
if (durationChanged) {
const durationCommand = new UpdateElementDurationCommand(
track.id,
element.id,
currentDuration,
);
editor.command.execute({ command: durationCommand });
editor.timeline.updateElementDuration({
trackId: track.id,
elementId: element.id,
duration: finalDuration,
});
}
setResizing(null);
@@ -0,0 +1,110 @@
import { useCallback } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
type ElementRef = { trackId: string; elementId: string };
export function useElementSelection() {
const { selectedElements, setSelectedElements } = useTimelineStore();
const isElementSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
selectedElements.some(
(element) =>
element.trackId === trackId && element.elementId === elementId,
),
[selectedElements],
);
const selectElement = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({ elements: [{ trackId, elementId }] });
},
[setSelectedElements],
);
const addElementToSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
const alreadySelected = selectedElements.some(
(element) =>
element.trackId === trackId && element.elementId === elementId,
);
if (alreadySelected) return;
setSelectedElements({
elements: [...selectedElements, { trackId, elementId }],
});
},
[selectedElements, setSelectedElements],
);
const removeElementFromSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({
elements: selectedElements.filter(
(element) =>
!(element.trackId === trackId && element.elementId === elementId),
),
});
},
[selectedElements, setSelectedElements],
);
const toggleElementSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
const alreadySelected = selectedElements.some(
(element) =>
element.trackId === trackId && element.elementId === elementId,
);
if (alreadySelected) {
removeElementFromSelection({ trackId, elementId });
} else {
addElementToSelection({ trackId, elementId });
}
},
[selectedElements, addElementToSelection, removeElementFromSelection],
);
const clearElementSelection = useCallback(() => {
setSelectedElements({ elements: [] });
}, [setSelectedElements]);
const setElementSelection = useCallback(
({ elements }: { 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) {
toggleElementSelection({ trackId, elementId });
} else {
selectElement({ trackId, elementId });
}
},
[toggleElementSelection, selectElement],
);
return {
selectedElements,
isElementSelected,
selectElement,
setElementSelection,
addElementToSelection,
removeElementFromSelection,
toggleElementSelection,
clearElementSelection,
handleElementClick,
};
}
@@ -0,0 +1,101 @@
import { useEffect, useRef } from "react";
interface UseEdgeAutoScrollParams {
isActive: boolean;
getMouseClientX: () => number;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
contentWidth: number;
edgeThreshold?: number;
maxScrollSpeed?: number;
}
// Provides smooth edge auto-scrolling for horizontal timeline interactions.
export function useEdgeAutoScroll({
isActive,
getMouseClientX,
rulerScrollRef,
tracksScrollRef,
contentWidth,
edgeThreshold = 100,
maxScrollSpeed = 15,
}: UseEdgeAutoScrollParams): void {
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (!isActive) {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return;
}
const step = () => {
const rulerViewport = rulerScrollRef.current;
const tracksViewport = tracksScrollRef.current;
if (!rulerViewport || !tracksViewport) {
rafRef.current = requestAnimationFrame(step);
return;
}
const viewportRect = rulerViewport.getBoundingClientRect();
const mouseX = getMouseClientX();
const mouseXRelative = mouseX - viewportRect.left;
const viewportWidth = rulerViewport.clientWidth;
const intrinsicContentWidth = rulerViewport.scrollWidth;
const effectiveContentWidth = Math.max(
contentWidth,
intrinsicContentWidth
);
const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
let scrollSpeed = 0;
if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
const edgeDistance = Math.max(0, mouseXRelative);
const intensity = 1 - edgeDistance / edgeThreshold;
scrollSpeed = -maxScrollSpeed * intensity;
} else if (
mouseXRelative > viewportWidth - edgeThreshold &&
rulerViewport.scrollLeft < scrollMax
) {
const edgeDistance = Math.max(
0,
viewportWidth - edgeThreshold - mouseXRelative
);
const intensity = 1 - edgeDistance / edgeThreshold;
scrollSpeed = maxScrollSpeed * intensity;
}
if (scrollSpeed !== 0) {
const newScrollLeft = Math.max(
0,
Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed)
);
rulerViewport.scrollLeft = newScrollLeft;
tracksViewport.scrollLeft = newScrollLeft;
}
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [
isActive,
getMouseClientX,
rulerScrollRef,
tracksScrollRef,
contentWidth,
edgeThreshold,
maxScrollSpeed,
]);
}
@@ -0,0 +1,130 @@
import { useEffect, useRef } from "react";
interface UseScrollSyncProps {
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
bookmarksScrollRef?: React.RefObject<HTMLDivElement>;
}
export function useScrollSync({
rulerScrollRef,
tracksScrollRef,
trackLabelsScrollRef,
bookmarksScrollRef,
}: UseScrollSyncProps) {
const isUpdatingRef = useRef(false);
const lastRulerSync = useRef(0);
const lastTracksSync = useRef(0);
const lastVerticalSync = useRef(0);
const lastBookmarksSync = useRef(0);
useEffect(() => {
const rulerViewport = rulerScrollRef.current;
const tracksViewport = tracksScrollRef.current;
const trackLabelsViewport = trackLabelsScrollRef?.current;
const bookmarksViewport = bookmarksScrollRef?.current;
let handleBookmarksScroll: (() => void) | null = null;
if (!rulerViewport || !tracksViewport) return;
const handleRulerScroll = () => {
const now = Date.now();
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
lastRulerSync.current = now;
isUpdatingRef.current = true;
tracksViewport.scrollLeft = rulerViewport.scrollLeft;
if (bookmarksViewport) {
bookmarksViewport.scrollLeft = rulerViewport.scrollLeft;
}
isUpdatingRef.current = false;
};
const handleTracksScroll = () => {
const now = Date.now();
if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
lastTracksSync.current = now;
isUpdatingRef.current = true;
rulerViewport.scrollLeft = tracksViewport.scrollLeft;
if (bookmarksViewport) {
bookmarksViewport.scrollLeft = tracksViewport.scrollLeft;
}
isUpdatingRef.current = false;
};
rulerViewport.addEventListener("scroll", handleRulerScroll);
tracksViewport.addEventListener("scroll", handleTracksScroll);
if (bookmarksViewport) {
handleBookmarksScroll = () => {
const now = Date.now();
if (isUpdatingRef.current || now - lastBookmarksSync.current < 16)
return;
lastBookmarksSync.current = now;
isUpdatingRef.current = true;
tracksViewport.scrollLeft = bookmarksViewport.scrollLeft;
rulerViewport.scrollLeft = bookmarksViewport.scrollLeft;
isUpdatingRef.current = false;
};
bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
}
if (trackLabelsViewport) {
const handleTrackLabelsScroll = () => {
const now = Date.now();
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
return;
lastVerticalSync.current = now;
isUpdatingRef.current = true;
tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
isUpdatingRef.current = false;
};
const handleTracksVerticalScroll = () => {
const now = Date.now();
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
return;
lastVerticalSync.current = now;
isUpdatingRef.current = true;
trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
isUpdatingRef.current = false;
};
trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
return () => {
rulerViewport.removeEventListener("scroll", handleRulerScroll);
tracksViewport.removeEventListener("scroll", handleTracksScroll);
if (bookmarksViewport && handleBookmarksScroll) {
bookmarksViewport.removeEventListener(
"scroll",
handleBookmarksScroll,
);
}
trackLabelsViewport.removeEventListener(
"scroll",
handleTrackLabelsScroll,
);
tracksViewport.removeEventListener(
"scroll",
handleTracksVerticalScroll,
);
};
}
return () => {
rulerViewport.removeEventListener("scroll", handleRulerScroll);
tracksViewport.removeEventListener("scroll", handleTracksScroll);
if (bookmarksViewport && handleBookmarksScroll) {
bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
}
};
}, [
rulerScrollRef,
tracksScrollRef,
trackLabelsScrollRef,
bookmarksScrollRef,
]);
}
@@ -0,0 +1,231 @@
import { useState, useEffect, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
import { useEditor } from "../use-editor";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement>;
onSelectionComplete: (
elements: { trackId: string; elementId: string }[],
) => void;
isEnabled?: boolean;
tracksScrollRef: React.RefObject<HTMLDivElement>;
zoomLevel: number;
}
interface SelectionBoxState {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
isActive: boolean;
}
interface SelectionRectangle {
left: number;
top: number;
right: number;
bottom: number;
}
function getNormalizedRectangle({
startPos,
endPos,
}: {
startPos: { x: number; y: number };
endPos: { x: number; y: number };
}): SelectionRectangle {
return {
left: Math.min(startPos.x, endPos.x),
top: Math.min(startPos.y, endPos.y),
right: Math.max(startPos.x, endPos.x),
bottom: Math.max(startPos.y, endPos.y),
};
}
function getSelectionRectangleInContent({
container,
scrollContainer,
startPos,
endPos,
}: {
container: HTMLElement;
scrollContainer: HTMLDivElement | null;
startPos: { x: number; y: number };
endPos: { x: number; y: number };
}): SelectionRectangle {
const containerRect = container.getBoundingClientRect();
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
const scrollTop = scrollContainer?.scrollTop ?? 0;
const adjustedStart = {
x: startPos.x - containerRect.left + scrollLeft,
y: startPos.y - containerRect.top + scrollTop,
};
const adjustedEnd = {
x: endPos.x - containerRect.left + scrollLeft,
y: endPos.y - containerRect.top + scrollTop,
};
return getNormalizedRectangle({
startPos: adjustedStart,
endPos: adjustedEnd,
});
}
function isRectangleIntersecting({
elementRectangle,
selectionRectangle,
}: {
elementRectangle: SelectionRectangle;
selectionRectangle: SelectionRectangle;
}): boolean {
return !(
elementRectangle.right < selectionRectangle.left ||
elementRectangle.left > selectionRectangle.right ||
elementRectangle.bottom < selectionRectangle.top ||
elementRectangle.top > selectionRectangle.bottom
);
}
export function useSelectionBox({
containerRef,
onSelectionComplete,
isEnabled = true,
tracksScrollRef,
zoomLevel,
}: UseSelectionBoxProps) {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
null,
);
const handleMouseDown = useCallback(
({ clientX, clientY }: React.MouseEvent) => {
if (!isEnabled) return;
setSelectionBox({
startPos: { x: clientX, y: clientY },
currentPos: { x: clientX, y: clientY },
isActive: false,
});
},
[isEnabled],
);
const selectElementsInBox = useCallback(
({
startPos,
endPos,
}: {
startPos: { x: number; y: number };
endPos: { x: number; y: number };
}) => {
if (!containerRef.current) return;
const container = containerRef.current;
const selectionRectangle = getSelectionRectangleInContent({
container,
scrollContainer: tracksScrollRef.current,
startPos,
endPos,
});
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const selectedElements: { trackId: string; elementId: string }[] = [];
for (const [trackIndex, track] of tracks.entries()) {
const trackTop = getCumulativeHeightBefore({
tracks,
trackIndex,
});
const trackHeight = getTrackHeight({ type: track.type });
const elementTop = trackTop;
const elementBottom = trackTop + trackHeight;
for (const element of track.elements) {
const elementLeft = element.startTime * pixelsPerSecond;
const elementRight = elementLeft + element.duration * pixelsPerSecond;
const elementRectangle = {
left: elementLeft,
top: elementTop,
right: elementRight,
bottom: elementBottom,
};
const intersects = isRectangleIntersecting({
elementRectangle,
selectionRectangle,
});
if (intersects) {
selectedElements.push({
trackId: track.id,
elementId: element.id,
});
}
}
}
onSelectionComplete(selectedElements);
},
[containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
);
useEffect(() => {
if (!selectionBox) return;
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
const shouldActivate = deltaX > 5 || deltaY > 5;
const newSelectionBox = {
...selectionBox,
currentPos: { x: clientX, y: clientY },
isActive: shouldActivate || selectionBox.isActive,
};
setSelectionBox(newSelectionBox);
if (newSelectionBox.isActive) {
selectElementsInBox({
startPos: newSelectionBox.startPos,
endPos: newSelectionBox.currentPos,
});
}
};
const handleMouseUp = () => {
setSelectionBox(null);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [selectionBox, selectElementsInBox]);
useEffect(() => {
if (!selectionBox?.isActive) return;
const previousBodyUserSelect = document.body.style.userSelect;
const container = containerRef.current;
const previousContainerUserSelect = container?.style.userSelect ?? "";
document.body.style.userSelect = "none";
if (container) container.style.userSelect = "none";
return () => {
document.body.style.userSelect = previousBodyUserSelect;
if (container) container.style.userSelect = previousContainerUserSelect;
};
}, [selectionBox?.isActive, containerRef]);
return {
selectionBox,
handleMouseDown,
isSelecting: selectionBox?.isActive || false,
};
}
@@ -1,11 +1,12 @@
import { useCallback, useRef } from "react";
import type { RefObject } from "react";
import type { MutableRefObject, 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>;
trackLabelsRef: RefObject<HTMLDivElement>;
rulerScrollRef: RefObject<HTMLDivElement>;
tracksScrollRef: RefObject<HTMLDivElement>;
zoomLevel: number;
@@ -15,8 +16,47 @@ interface UseTimelineInteractionsProps {
seek: (time: number) => void;
}
function resetMouseTracking({
mouseTrackingRef,
}: {
mouseTrackingRef: MutableRefObject<{
isMouseDown: boolean;
downX: number;
downY: number;
downTime: number;
}>;
}) {
mouseTrackingRef.current = {
isMouseDown: false,
downX: 0,
downY: 0,
downTime: 0,
};
}
function setMouseTracking({
mouseTrackingRef,
event,
}: {
mouseTrackingRef: MutableRefObject<{
isMouseDown: boolean;
downX: number;
downY: number;
downTime: number;
}>;
event: React.MouseEvent;
}) {
mouseTrackingRef.current = {
isMouseDown: true,
downX: event.clientX,
downY: event.clientY,
downTime: event.timeStamp,
};
}
export function useTimelineInteractions({
playheadRef,
trackLabelsRef,
rulerScrollRef,
tracksScrollRef,
zoomLevel,
@@ -35,69 +75,58 @@ export function useTimelineInteractions({
downTime: 0,
});
const handleTimelineMouseDown = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
if (event.button !== 0) return;
setMouseTracking({ mouseTrackingRef, event });
}, []);
const isTimelineBackground =
!target.closest(".timeline-element") &&
!playheadRef.current?.contains(target) &&
!target.closest("[data-track-labels]");
if (isTimelineBackground) {
mouseTrackingRef.current = {
isMouseDown: true,
downX: e.clientX,
downY: e.clientY,
downTime: e.timeStamp,
};
}
},
[playheadRef],
);
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
if (event.button !== 0) return;
setMouseTracking({ mouseTrackingRef, event });
}, []);
const shouldProcessTimelineClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
({ 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(e.clientX - downX);
const deltaY = Math.abs(e.clientY - downY);
const deltaTime = e.timeStamp - downTime;
const deltaX = Math.abs(event.clientX - downX);
const deltaY = Math.abs(event.clientY - downY);
const deltaTime = event.timeStamp - downTime;
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) return false;
if (isSelecting) return false;
if (target.closest(".timeline-element")) return false;
if (playheadRef.current?.contains(target)) return false;
if (target.closest("[data-track-labels]")) {
if (trackLabelsRef.current?.contains(target)) {
clearSelectedElements();
return false;
}
return true;
},
[isSelecting, clearSelectedElements, playheadRef],
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
);
const handleTimelineSeek = useCallback(
(e: React.MouseEvent) => {
const isRulerClick = (e.target as HTMLElement).closest(
"[data-ruler-area]",
);
const scrollContainer = isRulerClick
? rulerScrollRef.current
: tracksScrollRef.current;
({
event,
source,
}: {
event: React.MouseEvent;
source: "ruler" | "tracks";
}) => {
const scrollContainer =
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
if (!scrollContainer) return;
const rect = scrollContainer.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseX = event.clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
const rawTime = Math.max(
@@ -116,32 +145,41 @@ export function useTimelineInteractions({
[
duration,
zoomLevel,
seek,
rulerScrollRef,
tracksScrollRef,
seek,
activeProject?.settings.fps,
],
);
const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => {
mouseTrackingRef.current = {
isMouseDown: false,
downX: 0,
downY: 0,
downTime: 0,
};
const handleTracksClick = useCallback(
(event: React.MouseEvent) => {
resetMouseTracking({ mouseTrackingRef });
if (shouldProcessTimelineClick(e)) {
if (shouldProcessTimelineClick({ event })) {
clearSelectedElements();
handleTimelineSeek(e);
handleTimelineSeek({ event, source: "tracks" });
}
},
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
);
const handleRulerClick = useCallback(
(event: React.MouseEvent) => {
resetMouseTracking({ mouseTrackingRef });
if (shouldProcessTimelineClick({ event })) {
clearSelectedElements();
handleTimelineSeek({ event, source: "ruler" });
}
},
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
);
return {
handleTimelineMouseDown,
handleTimelineContentClick,
handleTracksMouseDown,
handleTracksClick,
handleRulerMouseDown,
handleRulerClick,
};
}
@@ -1,6 +1,6 @@
import { snapTimeToFrame } from "@/lib/time-utils";
import { useState, useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
@@ -16,7 +16,7 @@ export interface SnapResult {
}
export interface UseTimelineSnappingOptions {
snapThreshold?: number; // Distance in pixels to trigger snapping
snapThreshold?: number;
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
}
@@ -27,21 +27,21 @@ export function useTimelineSnapping({
enablePlayheadSnapping = true,
}: UseTimelineSnappingOptions = {}) {
const findSnapPoints = useCallback(
(
tracks: TimelineTrack[],
currentTime: number,
playheadTime: number,
zoomLevel: number,
excludeElementId?: string,
): SnapPoint[] => {
({
tracks,
playheadTime,
excludeElementId,
}: {
tracks: Array<TimelineTrack>;
playheadTime: number;
excludeElementId?: string;
}): SnapPoint[] => {
const snapPoints: SnapPoint[] = [];
// Add element snap points
if (enableElementSnapping) {
tracks.forEach((track) => {
track.elements.forEach((element) => {
// Skip the element being dragged
if (element.id === excludeElementId) return;
for (const track of tracks) {
for (const element of track.elements) {
if (element.id === excludeElementId) continue;
const elementStart = element.startTime;
const elementEnd = element.startTime + element.duration;
@@ -60,11 +60,10 @@ export function useTimelineSnapping({
trackId: track.id,
},
);
});
});
}
}
}
// Add playhead snap point
if (enablePlayheadSnapping) {
snapPoints.push({
time: playheadTime,
@@ -78,24 +77,28 @@ export function useTimelineSnapping({
);
const snapToNearestPoint = useCallback(
(
targetTime: number,
snapPoints: SnapPoint[],
zoomLevel: number,
): SnapResult => {
({
targetTime,
snapPoints,
zoomLevel,
}: {
targetTime: number;
snapPoints: Array<SnapPoint>;
zoomLevel: number;
}): SnapResult => {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
let closestSnapPoint: SnapPoint | null = null;
let closestDistance = Infinity;
snapPoints.forEach((snapPoint) => {
for (const snapPoint of snapPoints) {
const distance = Math.abs(targetTime - snapPoint.time);
if (distance < thresholdInSeconds && distance < closestDistance) {
closestDistance = distance;
closestSnapPoint = snapPoint;
}
});
}
return {
snappedTime: closestSnapPoint
@@ -109,34 +112,38 @@ export function useTimelineSnapping({
);
const snapElementEdge = useCallback(
(
targetTime: number,
elementDuration: number,
tracks: TimelineTrack[],
playheadTime: number,
zoomLevel: number,
excludeElementId?: string,
snapToStart = true, // true for start edge, false for end edge
): SnapResult => {
const snapPoints = findSnapPoints(
({
targetTime,
elementDuration,
tracks,
playheadTime,
zoomLevel,
excludeElementId,
snapToStart = true,
}: {
targetTime: number;
elementDuration: number;
tracks: Array<TimelineTrack>;
playheadTime: number;
zoomLevel: number;
excludeElementId?: string;
snapToStart?: boolean;
}): SnapResult => {
const snapPoints = findSnapPoints({
tracks,
targetTime,
playheadTime,
zoomLevel,
excludeElementId,
);
});
// For end edge snapping, we need to account for element duration
const effectiveTargetTime = snapToStart
? targetTime
: targetTime + elementDuration;
const snapResult = snapToNearestPoint(
effectiveTargetTime,
const snapResult = snapToNearestPoint({
targetTime: effectiveTargetTime,
snapPoints,
zoomLevel,
);
});
// Adjust the snapped time back for end edge
if (!snapToStart && snapResult.snapPoint) {
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
}