codebase overhaul (#697)

This commit is contained in:
Maze
2026-01-31 00:20:04 +01:00
committed by GitHub
parent 0173db9944
commit 7bf0984698
469 changed files with 36184 additions and 32931 deletions
@@ -0,0 +1,619 @@
import {
useState,
useCallback,
useEffect,
useRef,
type MouseEvent as ReactMouseEvent,
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { generateUUID } from "@/utils/id";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import type {
DropTarget,
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>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
snappingEnabled: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
const initialDragState: ElementDragState = {
isDragging: false,
elementId: null,
trackId: null,
startMouseX: 0,
startMouseY: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
currentMouseY: 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 getVerticalDragDirection({
startMouseY,
currentMouseY,
}: {
startMouseY: number;
currentMouseY: number;
}): "up" | "down" | null {
if (currentMouseY < startMouseY) return "up";
if (currentMouseY > startMouseY) return "down";
return null;
}
function getDragDropTarget({
clientX,
clientY,
elementId,
trackId,
tracks,
tracksContainerRef,
tracksScrollRef,
headerRef,
zoomLevel,
snappedTime,
verticalDragDirection,
}: {
clientX: number;
clientY: number;
elementId: string;
trackId: string;
tracks: TimelineTrack[];
tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
zoomLevel: number;
snappedTime: number;
verticalDragDirection?: "up" | "down" | null;
}): DropTarget | null {
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
const scrollContainer = tracksScrollRef.current;
if (!containerRect || !scrollContainer) return null;
const sourceTrack = tracks.find(({ id }) => id === trackId);
const movingElement = sourceTrack?.elements.find(
({ id }) => id === elementId,
);
if (!movingElement) return null;
const elementDuration = movingElement.duration;
const scrollLeft = scrollContainer.scrollLeft;
const scrollTop = scrollContainer.scrollTop;
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0;
const mouseX = clientX - scrollContainerRect.left + scrollLeft;
const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight;
return computeDropTarget({
elementType: movingElement.type,
mouseX,
mouseY,
tracks,
playheadTime: snappedTime,
isExternalDrop: false,
elementDuration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
startTimeOverride: snappedTime,
excludeElementId: movingElement.id,
verticalDragDirection,
});
}
interface StartDragParams
extends Omit<
ElementDragState,
"isDragging" | "currentTime" | "currentMouseY"
> {
initialCurrentTime: number;
initialCurrentMouseY: number;
}
export function useElementInteraction({
zoomLevel,
timelineRef,
tracksContainerRef,
tracksScrollRef,
headerRef,
snappingEnabled,
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
const { snapElementEdge } = useTimelineSnapping();
const {
isElementSelected,
selectElement,
handleElementClick: handleSelectionClick,
} = useElementSelection();
const [dragState, setDragState] =
useState<ElementDragState>(initialDragState);
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingDragState | null>(null);
const lastMouseXRef = useRef(0);
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
const startDrag = useCallback(
({
elementId,
trackId,
startMouseX,
startMouseY,
startElementTime,
clickOffsetTime,
initialCurrentTime,
initialCurrentMouseY,
}: StartDragParams) => {
setDragState({
isDragging: true,
elementId,
trackId,
startMouseX,
startMouseY,
startElementTime,
clickOffsetTime,
currentTime: initialCurrentTime,
currentMouseY: initialCurrentMouseY,
});
},
[],
);
const endDrag = useCallback(() => {
setDragState(initialDragState);
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;
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,
initialCurrentMouseY: clientY,
});
startedDragThisEvent = true;
pendingDragRef.current = null;
setIsPendingDrag(false);
} else {
return;
}
}
if (startedDragThisEvent) {
return;
}
if (dragState.elementId && dragState.trackId) {
const alreadySelected = isElementSelected({
trackId: dragState.trackId,
elementId: dragState.elementId,
});
if (!alreadySelected) {
selectElement({
trackId: dragState.trackId,
elementId: dragState.elementId,
});
}
}
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 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,
elementId: dragState.elementId,
trackId: dragState.trackId,
tracks,
tracksContainerRef,
tracksScrollRef,
headerRef,
zoomLevel,
snappedTime,
verticalDragDirection,
});
setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null);
}
};
document.addEventListener("mousemove", handleMouseMove);
return () => document.removeEventListener("mousemove", handleMouseMove);
}, [
dragState.isDragging,
dragState.clickOffsetTime,
dragState.elementId,
dragState.startMouseY,
dragState.trackId,
zoomLevel,
isElementSelected,
selectElement,
editor.project,
timelineRef,
tracksScrollRef,
tracksContainerRef,
headerRef,
tracks,
isPendingDrag,
startDrag,
getDragSnapResult,
onSnapPointChange,
]);
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
if (!dragState.elementId || !dragState.trackId) return;
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 = getDragDropTarget({
clientX,
clientY,
elementId: dragState.elementId,
trackId: dragState.trackId,
tracks,
tracksContainerRef,
tracksScrollRef,
headerRef,
zoomLevel,
snappedTime: dragState.currentTime,
verticalDragDirection: getVerticalDragDirection({
startMouseY: dragState.startMouseY,
currentMouseY: clientY,
}),
});
if (!dropTarget) {
endDrag();
onSnapPointChange?.(null);
return;
}
const snappedTime = dragState.currentTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
if (!sourceTrack) {
endDrag();
onSnapPointChange?.(null);
return;
}
if (dropTarget.isNewTrack) {
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];
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.startMouseY,
dragState.trackId,
dragState.currentTime,
zoomLevel,
tracks,
endDrag,
onSnapPointChange,
editor.timeline,
tracksContainerRef,
tracksScrollRef,
headerRef,
]);
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,
element,
track,
}: {
event: ReactMouseEvent;
element: TimelineElement;
track: TimelineTrack;
}) => {
const isRightClick = event.button === 2;
// right-click: don't stop propagation so ContextMenu can open
if (isRightClick) {
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
if (!alreadySelected) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
isMultiKey: false,
});
}
return;
}
// left-click: stop propagation for drag operations
event.stopPropagation();
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
// multi-select: toggle selection
if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
isMultiKey: true,
});
}
// start drag
const clickOffsetTime = getClickOffsetTime({
clientX: event.clientX,
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
});
pendingDragRef.current = {
elementId: element.id,
trackId: track.id,
startMouseX: event.clientX,
startMouseY: event.clientY,
startElementTime: element.startTime,
clickOffsetTime,
};
setIsPendingDrag(true);
},
[zoomLevel, isElementSelected, handleSelectionClick],
);
const handleElementClick = useCallback(
({
event,
element,
track,
}: {
event: ReactMouseEvent;
element: TimelineElement;
track: TimelineTrack;
}) => {
event.stopPropagation();
// was it a drag or a click?
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
mouseDownLocationRef.current = null;
return;
}
}
// modifier keys already handled in mousedown
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
// single click: select if not selected
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
if (!alreadySelected) {
selectElement({ trackId: track.id, elementId: element.id });
}
},
[isElementSelected, selectElement],
);
return {
dragState,
dragDropTarget,
handleElementMouseDown,
handleElementClick,
lastMouseXRef,
};
}
@@ -0,0 +1,331 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { snapTimeToFrame } from "@/lib/time";
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;
side: "left" | "right";
startX: number;
initialTrimStart: number;
initialTrimEnd: number;
initialStartTime: number;
initialDuration: number;
}
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);
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);
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);
currentTrimStartRef.current = element.trimStart;
currentTrimEndRef.current = element.trimEnd;
currentStartTimeRef.current = element.startTime;
currentDurationRef.current = element.duration;
onResizeStateChange?.({ isResizing: true });
};
const canExtendElementDuration = useCallback(() => {
if (element.type === "text" || element.type === "image") {
return true;
}
return false;
}, [element.type]);
const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => {
if (!resizing) return;
const deltaX = clientX - resizing.startX;
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 - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
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);
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);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
const trimDelta = 0 - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: baseDuration + extensionNeeded,
fps: projectFps,
});
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
const extensionToLimit = resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionToLimit,
fps: projectFps,
});
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
}
} else {
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
});
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
currentTrimEndRef.current = finalTrimEnd;
currentDurationRef.current = newDuration;
}
}
}, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]);
const handleResizeEnd = useCallback(() => {
if (!resizing) return;
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) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
trimEnd: finalTrimEnd,
});
}
if (startTimeChanged) {
editor.timeline.updateElementStartTime({
elements: [{ trackId: track.id, elementId: element.id }],
startTime: finalStartTime,
});
}
if (durationChanged) {
editor.timeline.updateElementDuration({
trackId: track.id,
elementId: element.id,
duration: finalDuration,
});
}
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
}, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]);
useEffect(() => {
if (!resizing) return;
const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
updateTrimFromMouseMove({ clientX });
};
const handleDocumentMouseUp = () => {
handleResizeEnd();
};
document.addEventListener("mousemove", handleDocumentMouseMove);
document.addEventListener("mouseup", handleDocumentMouseUp);
return () => {
document.removeEventListener("mousemove", handleDocumentMouseMove);
document.removeEventListener("mouseup", handleDocumentMouseUp);
};
}, [resizing, handleResizeEnd, updateTrimFromMouseMove]);
return {
resizing,
isResizing: resizing !== null,
handleResizeStart,
currentTrimStart,
currentTrimEnd,
currentStartTime,
currentDuration,
};
}
@@ -0,0 +1,114 @@
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
type ElementRef = { trackId: string; elementId: string };
export function useElementSelection() {
const editor = useEditor();
const selectedElements = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedElements(),
);
const isElementSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
selectedElements.some(
(element) =>
element.trackId === trackId && element.elementId === elementId,
),
[selectedElements],
);
const selectElement = useCallback(
({ trackId, elementId }: ElementRef) => {
editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
},
[editor],
);
const addElementToSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
const alreadySelected = selectedElements.some(
(element) =>
element.trackId === trackId && element.elementId === elementId,
);
if (alreadySelected) return;
editor.selection.setSelectedElements({
elements: [...selectedElements, { trackId, elementId }],
});
},
[selectedElements, editor],
);
const removeElementFromSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
editor.selection.setSelectedElements({
elements: selectedElements.filter(
(element) =>
!(element.trackId === trackId && element.elementId === elementId),
),
});
},
[selectedElements, editor],
);
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(() => {
editor.selection.clearSelection();
}, [editor]);
const setElementSelection = useCallback(
({ elements }: { elements: ElementRef[] }) => {
editor.selection.setSelectedElements({ elements });
},
[editor],
);
/**
* 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,
};
}