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
@@ -1,16 +1,17 @@
"use client";
import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/use-action-handler";
import { useEditor } from "./use-editor";
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() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const timelineStore = useTimelineStore.getState();
const selectedElements = timelineStore.selectedElements;
const { selectedElements, setElementSelection } = useElementSelection();
const { clipboard, setClipboard, toggleSnapping } = useTimelineStore();
useActionHandler(
"toggle-play",
@@ -191,7 +192,7 @@ export function useEditorActions() {
elementId: element.id,
})),
);
timelineStore.setSelectedElements({ elements: allElements });
setElementSelection({ elements: allElements });
},
undefined,
);
@@ -205,7 +206,7 @@ export function useEditorActions() {
);
useActionHandler(
"toggle-mute-selected",
"toggle-elements-muted-selected",
() => {
editor.timeline.toggleElementsMuted({ elements: selectedElements });
},
@@ -213,7 +214,7 @@ export function useEditorActions() {
);
useActionHandler(
"toggle-visibility-selected",
"toggle-elements-visibility-selected",
() => {
editor.timeline.toggleElementsVisibility({ elements: selectedElements });
},
@@ -244,7 +245,7 @@ export function useEditorActions() {
};
});
timelineStore.setClipboard({ items });
setClipboard({ items });
},
undefined,
);
@@ -252,7 +253,6 @@ export function useEditorActions() {
useActionHandler(
"paste-selected",
() => {
const clipboard = timelineStore.clipboard;
if (!clipboard?.items.length) return;
const currentTime = editor.playback.getCurrentTime();
@@ -266,7 +266,7 @@ export function useEditorActions() {
useActionHandler(
"toggle-snapping",
() => {
timelineStore.toggleSnapping();
toggleSnapping();
},
undefined,
);
@@ -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);
@@ -4,28 +4,29 @@ 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 { selectedElements, setSelectedElements } = useTimelineStore();
const isSelected = useCallback(
const isElementSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
selectedElements.some(
(el) => el.trackId === trackId && el.elementId === elementId,
(element) =>
element.trackId === trackId && element.elementId === elementId,
),
[selectedElements],
);
const select = useCallback(
const selectElement = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({ elements: [{ trackId, elementId }] });
},
[setSelectedElements],
);
const addToSelection = useCallback(
const addElementToSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
const alreadySelected = selectedElements.some(
(el) => el.trackId === trackId && el.elementId === elementId,
(element) =>
element.trackId === trackId && element.elementId === elementId,
);
if (alreadySelected) return;
@@ -36,38 +37,40 @@ export function useElementSelection() {
[selectedElements, setSelectedElements],
);
const removeFromSelection = useCallback(
const removeElementFromSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
setSelectedElements({
elements: selectedElements.filter(
(el) => !(el.trackId === trackId && el.elementId === elementId),
(element) =>
!(element.trackId === trackId && element.elementId === elementId),
),
});
},
[selectedElements, setSelectedElements],
);
const toggleSelection = useCallback(
const toggleElementSelection = useCallback(
({ trackId, elementId }: ElementRef) => {
const alreadySelected = selectedElements.some(
(el) => el.trackId === trackId && el.elementId === elementId,
(element) =>
element.trackId === trackId && element.elementId === elementId,
);
if (alreadySelected) {
removeFromSelection({ trackId, elementId });
removeElementFromSelection({ trackId, elementId });
} else {
addToSelection({ trackId, elementId });
addElementToSelection({ trackId, elementId });
}
},
[selectedElements, addToSelection, removeFromSelection],
[selectedElements, addElementToSelection, removeElementFromSelection],
);
const clearSelection = useCallback(() => {
const clearElementSelection = useCallback(() => {
setSelectedElements({ elements: [] });
}, [setSelectedElements]);
const setSelection = useCallback(
(elements: ElementRef[]) => {
const setElementSelection = useCallback(
({ elements }: { elements: ElementRef[] }) => {
setSelectedElements({ elements });
},
[setSelectedElements],
@@ -85,37 +88,23 @@ export function useElementSelection() {
isMultiKey,
}: ElementRef & { isMultiKey: boolean }) => {
if (isMultiKey) {
toggleSelection({ trackId, elementId });
toggleElementSelection({ trackId, elementId });
} else {
select({ trackId, elementId });
selectElement({ 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],
[toggleElementSelection, selectElement],
);
return {
selectedElements,
isSelected,
select,
setSelection,
addToSelection,
removeFromSelection,
toggleSelection,
clearSelection,
isElementSelected,
selectElement,
setElementSelection,
addElementToSelection,
removeElementFromSelection,
toggleElementSelection,
clearElementSelection,
handleElementClick,
ensureSelected,
};
}
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
import { useEditor } from "./use-editor";
import { useEditor } from "../use-editor";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement>;
@@ -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;
}
-23
View File
@@ -1,23 +0,0 @@
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]);
}
@@ -17,8 +17,7 @@ export interface KeyboardShortcut {
icon?: React.ReactNode;
}
// Convert key binding format to display format
const formatKey = (key: string): string => {
function formatKey({ key }: { key: string }): string {
return key
.replace("ctrl", getPlatformSpecialKey())
.replace("alt", getPlatformAlternateKey())
@@ -34,27 +33,24 @@ const formatKey = (key: string): string => {
.replace("delete", "Delete")
.replace("backspace", "Backspace")
.replace("-", "+");
};
}
export const useKeyboardShortcutsHelp = () => {
export function useKeyboardShortcutsHelp() {
const { keybindings } = useKeybindingsStore();
const shortcuts = useMemo(() => {
const result: KeyboardShortcut[] = [];
// Group keybindings by action
const actionToKeys: Record<string, Array<string>> = {};
const actionToKeys: Record<string, string[]> = {};
for (const [key, action] of Object.entries(keybindings)) {
if (action) {
if (!actionToKeys[action]) {
actionToKeys[action] = [];
}
actionToKeys[action].push(formatKey(key));
actionToKeys[action].push(formatKey({ key }));
}
}
// Convert to shortcuts format
for (const [actionId, keys] of Object.entries(actionToKeys)) {
if (!Object.prototype.hasOwnProperty.call(ACTIONS, actionId)) {
continue;
@@ -71,7 +67,6 @@ export const useKeyboardShortcutsHelp = () => {
});
}
// Sort shortcuts by category first, then by description to ensure consistent ordering
return result.sort((a, b) => {
if (a.category !== b.category) {
return a.category.localeCompare(b.category);
@@ -83,4 +78,4 @@ export const useKeyboardShortcutsHelp = () => {
return {
shortcuts,
};
};
}
-21
View File
@@ -1,21 +0,0 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
-28
View File
@@ -1,28 +0,0 @@
import { useEffect } from 'react';
interface UsePreventScrollOptions {
enabled?: boolean;
element?: HTMLElement;
}
export function usePreventScroll({ enabled = true, element }: UsePreventScrollOptions = {}) {
useEffect(() => {
if (!enabled) return;
const targetElement = element || document.body;
const originalOverflow = targetElement.style.overflow;
const originalPaddingRight = targetElement.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
targetElement.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
targetElement.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
targetElement.style.overflow = originalOverflow;
targetElement.style.paddingRight = originalPaddingRight;
};
}, [enabled, element]);
}
@@ -1,93 +0,0 @@
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useEditor } from "@/hooks/use-editor";
export function useProjectInitialize({ projectId }: { projectId: string }) {
const router = useRouter();
const handledProjectIds = useRef<Set<string>>(new Set());
const isInitializingRef = useRef<boolean>(false);
const editor = useEditor();
const activeProject = editor.project.getActive();
useEffect(() => {
let isCancelled = false;
const initProject = async () => {
if (!projectId) {
return;
}
if (isInitializingRef.current) {
return;
}
if (activeProject?.metadata.id === projectId) {
return;
}
if (editor.project.isInvalidProjectId({ id: projectId })) {
return;
}
if (handledProjectIds.current.has(projectId)) {
return;
}
isInitializingRef.current = true;
handledProjectIds.current.add(projectId);
try {
await editor.project.loadProject({ id: projectId });
if (isCancelled) {
return;
}
isInitializingRef.current = false;
} catch (error) {
if (isCancelled) {
return;
}
const isProjectNotFound =
error instanceof Error &&
(error.message.includes("not found") ||
error.message.includes("does not exist") ||
error.message.includes("Project not found"));
if (isProjectNotFound) {
editor.project.markProjectIdAsInvalid({ id: projectId });
try {
const newProjectId = await editor.project.createNewProject({
name: "Untitled Project",
});
if (isCancelled) {
return;
}
router.replace(`/editor/${newProjectId}`);
} catch (createError) {
console.error("Failed to create new project:", createError);
}
} else {
console.error(
"Project loading failed with recoverable error:",
error,
);
handledProjectIds.current.delete(projectId);
}
isInitializingRef.current = false;
}
};
initProject();
return () => {
isCancelled = true;
isInitializingRef.current = false;
};
}, [projectId, editor, router]);
}
-191
View File
@@ -1,191 +0,0 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "../components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1_000_000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };