mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
lots of stuff
This commit is contained in:
@@ -1,65 +1,52 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
TAction,
|
||||
TActionFunc,
|
||||
TActionHandlerOptions,
|
||||
TInvocationTrigger,
|
||||
bindAction,
|
||||
unbindAction,
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type {
|
||||
TAction,
|
||||
TActionFunc,
|
||||
TActionHandlerOptions,
|
||||
TArgOfAction,
|
||||
TInvocationTrigger,
|
||||
} from "@/lib/actions";
|
||||
import { bindAction, unbindAction } from "@/lib/actions";
|
||||
|
||||
export function useActionHandler<A extends TAction>(
|
||||
action: A,
|
||||
handler: TActionFunc<A>,
|
||||
isActive: TActionHandlerOptions,
|
||||
action: A,
|
||||
handler: TActionFunc<A>,
|
||||
isActive: TActionHandlerOptions,
|
||||
) {
|
||||
const handlerRef = useRef(handler);
|
||||
const isBoundRef = useRef(false);
|
||||
const handlerRef = useRef<TActionFunc<A>>(handler);
|
||||
const isBoundRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
}, [handler]);
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
const stableHandler = useCallback(
|
||||
(args: any, trigger?: TInvocationTrigger) => {
|
||||
(handlerRef.current as any)(args, trigger);
|
||||
},
|
||||
[],
|
||||
) as TActionFunc<A>;
|
||||
const stableHandler = useCallback(
|
||||
(...parameters: [TArgOfAction<A>, TInvocationTrigger?]) => {
|
||||
(
|
||||
handlerRef.current as (
|
||||
...handlerParameters: [TArgOfAction<A>, TInvocationTrigger?]
|
||||
) => void
|
||||
)(...parameters);
|
||||
},
|
||||
[],
|
||||
) as TActionFunc<A>;
|
||||
|
||||
useEffect(() => {
|
||||
const shouldBind =
|
||||
isActive === undefined ||
|
||||
(typeof isActive === "boolean" ? isActive : isActive.current);
|
||||
useEffect(() => {
|
||||
const shouldBind =
|
||||
isActive === undefined ||
|
||||
(typeof isActive === "boolean" ? isActive : isActive.current);
|
||||
|
||||
if (shouldBind && !isBoundRef.current) {
|
||||
bindAction(action, stableHandler);
|
||||
isBoundRef.current = true;
|
||||
} else if (!shouldBind && isBoundRef.current) {
|
||||
unbindAction(action, stableHandler);
|
||||
isBoundRef.current = false;
|
||||
}
|
||||
if (shouldBind && !isBoundRef.current) {
|
||||
bindAction(action, stableHandler);
|
||||
isBoundRef.current = true;
|
||||
} else if (!shouldBind && isBoundRef.current) {
|
||||
unbindAction(action, stableHandler);
|
||||
isBoundRef.current = false;
|
||||
}
|
||||
|
||||
return () => {
|
||||
unbindAction(action, stableHandler);
|
||||
isBoundRef.current = false;
|
||||
};
|
||||
}, [action, stableHandler, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && typeof isActive === "object" && "current" in isActive) {
|
||||
const interval = setInterval(() => {
|
||||
const shouldBind = isActive.current;
|
||||
if (shouldBind !== isBoundRef.current) {
|
||||
if (shouldBind) {
|
||||
bindAction(action, stableHandler);
|
||||
} else {
|
||||
unbindAction(action, stableHandler);
|
||||
}
|
||||
isBoundRef.current = shouldBind;
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [action, stableHandler, isActive]);
|
||||
return () => {
|
||||
unbindAction(action, stableHandler);
|
||||
isBoundRef.current = false;
|
||||
};
|
||||
}, [action, stableHandler, isActive]);
|
||||
}
|
||||
|
||||
@@ -6,271 +6,271 @@ import { useEditor } from "../use-editor";
|
||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||
|
||||
export function useEditorActions() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { selectedElements, setElementSelection } = useElementSelection();
|
||||
const { clipboard, setClipboard, toggleSnapping } = useTimelineStore();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { selectedElements, setElementSelection } = useElementSelection();
|
||||
const { clipboard, setClipboard, toggleSnapping } = useTimelineStore();
|
||||
|
||||
useActionHandler(
|
||||
"toggle-play",
|
||||
() => {
|
||||
editor.playback.toggle();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"toggle-play",
|
||||
() => {
|
||||
editor.playback.toggle();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"stop-playback",
|
||||
() => {
|
||||
if (editor.playback.getIsPlaying()) {
|
||||
editor.playback.toggle();
|
||||
}
|
||||
editor.playback.seek({ time: 0 });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"stop-playback",
|
||||
() => {
|
||||
if (editor.playback.getIsPlaying()) {
|
||||
editor.playback.toggle();
|
||||
}
|
||||
editor.playback.seek({ time: 0 });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"seek-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"seek-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"seek-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"seek-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"frame-step-forward",
|
||||
() => {
|
||||
const fps = activeProject.settings.fps;
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + 1 / fps,
|
||||
),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"frame-step-forward",
|
||||
() => {
|
||||
const fps = activeProject.settings.fps;
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + 1 / fps,
|
||||
),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"frame-step-backward",
|
||||
() => {
|
||||
const fps = activeProject.settings.fps;
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"frame-step-backward",
|
||||
() => {
|
||||
const fps = activeProject.settings.fps;
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"jump-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"jump-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"jump-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"jump-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"goto-start",
|
||||
() => {
|
||||
editor.playback.seek({ time: 0 });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"goto-start",
|
||||
() => {
|
||||
editor.playback.seek({ time: 0 });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"goto-end",
|
||||
() => {
|
||||
editor.playback.seek({ time: editor.timeline.getTotalDuration() });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"goto-end",
|
||||
() => {
|
||||
editor.playback.seek({ time: editor.timeline.getTotalDuration() });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"split-selected",
|
||||
() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"split-selected",
|
||||
() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"split-selected-left",
|
||||
() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "left",
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"split-selected-left",
|
||||
() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "left",
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"split-selected-right",
|
||||
() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "right",
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"split-selected-right",
|
||||
() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "right",
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"delete-selected",
|
||||
() => {
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"delete-selected",
|
||||
() => {
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"select-all",
|
||||
() => {
|
||||
const allElements = editor.timeline.getTracks().flatMap((track) =>
|
||||
track.elements.map((element) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
})),
|
||||
);
|
||||
setElementSelection({ elements: allElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"select-all",
|
||||
() => {
|
||||
const allElements = editor.timeline.getTracks().flatMap((track) =>
|
||||
track.elements.map((element) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
})),
|
||||
);
|
||||
setElementSelection({ elements: allElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"duplicate-selected",
|
||||
() => {
|
||||
editor.timeline.duplicateElements({ elements: selectedElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"duplicate-selected",
|
||||
() => {
|
||||
editor.timeline.duplicateElements({ elements: selectedElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-elements-muted-selected",
|
||||
() => {
|
||||
editor.timeline.toggleElementsMuted({ elements: selectedElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"toggle-elements-muted-selected",
|
||||
() => {
|
||||
editor.timeline.toggleElementsMuted({ elements: selectedElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-elements-visibility-selected",
|
||||
() => {
|
||||
editor.timeline.toggleElementsVisibility({ elements: selectedElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"toggle-elements-visibility-selected",
|
||||
() => {
|
||||
editor.timeline.toggleElementsVisibility({ elements: selectedElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-bookmark",
|
||||
() => {
|
||||
editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"toggle-bookmark",
|
||||
() => {
|
||||
editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"copy-selected",
|
||||
() => {
|
||||
if (selectedElements.length === 0) return;
|
||||
useActionHandler(
|
||||
"copy-selected",
|
||||
() => {
|
||||
if (selectedElements.length === 0) return;
|
||||
|
||||
const results = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
});
|
||||
const items = results.map(({ track, element }) => {
|
||||
const { id, ...elementWithoutId } = element;
|
||||
return {
|
||||
trackId: track.id,
|
||||
trackType: track.type,
|
||||
element: elementWithoutId,
|
||||
};
|
||||
});
|
||||
const results = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
});
|
||||
const items = results.map(({ track, element }) => {
|
||||
const { id, ...elementWithoutId } = element;
|
||||
return {
|
||||
trackId: track.id,
|
||||
trackType: track.type,
|
||||
element: elementWithoutId,
|
||||
};
|
||||
});
|
||||
|
||||
setClipboard({ items });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
setClipboard({ items });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"paste-selected",
|
||||
() => {
|
||||
if (!clipboard?.items.length) return;
|
||||
useActionHandler(
|
||||
"paste-selected",
|
||||
() => {
|
||||
if (!clipboard?.items.length) return;
|
||||
|
||||
editor.timeline.pasteAtTime({
|
||||
time: editor.playback.getCurrentTime(),
|
||||
clipboardItems: clipboard.items,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
editor.timeline.pasteAtTime({
|
||||
time: editor.playback.getCurrentTime(),
|
||||
clipboardItems: clipboard.items,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-snapping",
|
||||
() => {
|
||||
toggleSnapping();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"toggle-snapping",
|
||||
() => {
|
||||
toggleSnapping();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"undo",
|
||||
() => {
|
||||
editor.command.undo();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"undo",
|
||||
() => {
|
||||
editor.command.undo();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"redo",
|
||||
() => {
|
||||
editor.command.redo();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
useActionHandler(
|
||||
"redo",
|
||||
() => {
|
||||
editor.command.redo();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,331 +1,331 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
useTimelineSnapping,
|
||||
type SnapPoint,
|
||||
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;
|
||||
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;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
onResizeStateChange?: (params: { isResizing: boolean }) => void;
|
||||
}
|
||||
|
||||
export function useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
onResizeStateChange,
|
||||
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 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 [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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX });
|
||||
};
|
||||
const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX });
|
||||
};
|
||||
|
||||
const handleDocumentMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
const handleDocumentMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||
};
|
||||
}, [resizing]);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||
};
|
||||
}, [resizing]);
|
||||
|
||||
const handleResizeStart = ({
|
||||
e,
|
||||
elementId,
|
||||
side,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
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,
|
||||
});
|
||||
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 });
|
||||
};
|
||||
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 = () => {
|
||||
if (element.type === "text" || element.type === "image") {
|
||||
return true;
|
||||
}
|
||||
const canExtendElementDuration = () => {
|
||||
if (element.type === "text" || element.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
return false;
|
||||
};
|
||||
|
||||
const updateTrimFromMouseMove = ({ clientX }: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
const updateTrimFromMouseMove = ({ clientX }: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = clientX - resizing.startX;
|
||||
let deltaTime = deltaX / (50 * zoomLevel);
|
||||
let resizeSnapPoint: SnapPoint | null = null;
|
||||
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);
|
||||
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 (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,
|
||||
});
|
||||
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(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 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;
|
||||
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,
|
||||
});
|
||||
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 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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
setCurrentTrimEnd(finalTrimEnd);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimEndRef.current = finalTrimEnd;
|
||||
currentDurationRef.current = newDuration;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
if (!resizing) return;
|
||||
const handleResizeEnd = () => {
|
||||
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;
|
||||
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 (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 (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,
|
||||
});
|
||||
}
|
||||
if (durationChanged) {
|
||||
editor.timeline.updateElementDuration({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
duration: finalDuration,
|
||||
});
|
||||
}
|
||||
|
||||
setResizing(null);
|
||||
onResizeStateChange?.({ isResizing: false });
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
setResizing(null);
|
||||
onResizeStateChange?.({ isResizing: false });
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
return {
|
||||
resizing,
|
||||
isResizing: resizing !== null,
|
||||
handleResizeStart,
|
||||
currentTrimStart,
|
||||
currentTrimEnd,
|
||||
currentStartTime,
|
||||
currentDuration,
|
||||
};
|
||||
return {
|
||||
resizing,
|
||||
isResizing: resizing !== null,
|
||||
handleResizeStart,
|
||||
currentTrimStart,
|
||||
currentTrimEnd,
|
||||
currentStartTime,
|
||||
currentDuration,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,107 +4,107 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
||||
type ElementRef = { trackId: string; elementId: string };
|
||||
|
||||
export function useElementSelection() {
|
||||
const { selectedElements, setSelectedElements } = useTimelineStore();
|
||||
const { selectedElements, setSelectedElements } = useTimelineStore();
|
||||
|
||||
const isElementSelected = useCallback(
|
||||
({ trackId, elementId }: ElementRef) =>
|
||||
selectedElements.some(
|
||||
(element) =>
|
||||
element.trackId === trackId && element.elementId === elementId,
|
||||
),
|
||||
[selectedElements],
|
||||
);
|
||||
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 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;
|
||||
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],
|
||||
);
|
||||
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 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,
|
||||
);
|
||||
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],
|
||||
);
|
||||
if (alreadySelected) {
|
||||
removeElementFromSelection({ trackId, elementId });
|
||||
} else {
|
||||
addElementToSelection({ trackId, elementId });
|
||||
}
|
||||
},
|
||||
[selectedElements, addElementToSelection, removeElementFromSelection],
|
||||
);
|
||||
|
||||
const clearElementSelection = useCallback(() => {
|
||||
setSelectedElements({ elements: [] });
|
||||
}, [setSelectedElements]);
|
||||
const clearElementSelection = useCallback(() => {
|
||||
setSelectedElements({ elements: [] });
|
||||
}, [setSelectedElements]);
|
||||
|
||||
const setElementSelection = useCallback(
|
||||
({ elements }: { elements: ElementRef[] }) => {
|
||||
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],
|
||||
);
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
return {
|
||||
selectedElements,
|
||||
isElementSelected,
|
||||
selectElement,
|
||||
setElementSelection,
|
||||
addElementToSelection,
|
||||
removeElementFromSelection,
|
||||
toggleElementSelection,
|
||||
clearElementSelection,
|
||||
handleElementClick,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,101 +1,100 @@
|
||||
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;
|
||||
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,
|
||||
isActive,
|
||||
getMouseClientX,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth,
|
||||
edgeThreshold = 100,
|
||||
maxScrollSpeed = 15,
|
||||
}: UseEdgeAutoScrollParams): void {
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
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 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 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);
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const intrinsicContentWidth = rulerViewport.scrollWidth;
|
||||
const effectiveContentWidth = Math.max(
|
||||
contentWidth,
|
||||
intrinsicContentWidth,
|
||||
);
|
||||
const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
|
||||
|
||||
let scrollSpeed = 0;
|
||||
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 (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;
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
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,
|
||||
]);
|
||||
return () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
isActive,
|
||||
getMouseClientX,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth,
|
||||
edgeThreshold,
|
||||
maxScrollSpeed,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,130 +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>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
|
||||
bookmarksScrollRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useScrollSync({
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
bookmarksScrollRef,
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
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 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;
|
||||
};
|
||||
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);
|
||||
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;
|
||||
};
|
||||
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);
|
||||
}
|
||||
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;
|
||||
};
|
||||
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;
|
||||
};
|
||||
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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
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,
|
||||
]);
|
||||
return () => {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
if (bookmarksViewport && handleBookmarksScroll) {
|
||||
bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
bookmarksScrollRef,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,231 +1,231 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback, useEffect, useState } 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;
|
||||
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;
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface SelectionRectangle {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
function getNormalizedRectangle({
|
||||
startPos,
|
||||
endPos,
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
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),
|
||||
};
|
||||
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,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
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 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,
|
||||
};
|
||||
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,
|
||||
});
|
||||
return getNormalizedRectangle({
|
||||
startPos: adjustedStart,
|
||||
endPos: adjustedEnd,
|
||||
});
|
||||
}
|
||||
|
||||
function isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
}: {
|
||||
elementRectangle: SelectionRectangle;
|
||||
selectionRectangle: SelectionRectangle;
|
||||
elementRectangle: SelectionRectangle;
|
||||
selectionRectangle: SelectionRectangle;
|
||||
}): boolean {
|
||||
return !(
|
||||
elementRectangle.right < selectionRectangle.left ||
|
||||
elementRectangle.left > selectionRectangle.right ||
|
||||
elementRectangle.bottom < selectionRectangle.top ||
|
||||
elementRectangle.top > selectionRectangle.bottom
|
||||
);
|
||||
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,
|
||||
containerRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
}: UseSelectionBoxProps) {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
|
||||
null,
|
||||
);
|
||||
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;
|
||||
const handleMouseDown = useCallback(
|
||||
({ clientX, clientY }: React.MouseEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
setSelectionBox({
|
||||
startPos: { x: clientX, y: clientY },
|
||||
currentPos: { x: clientX, y: clientY },
|
||||
isActive: false,
|
||||
});
|
||||
},
|
||||
[isEnabled],
|
||||
);
|
||||
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 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 }[] = [];
|
||||
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 [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;
|
||||
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 elementRectangle = {
|
||||
left: elementLeft,
|
||||
top: elementTop,
|
||||
right: elementRight,
|
||||
bottom: elementBottom,
|
||||
};
|
||||
|
||||
const intersects = isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
});
|
||||
const intersects = isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
});
|
||||
|
||||
if (intersects) {
|
||||
selectedElements.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
|
||||
);
|
||||
if (intersects) {
|
||||
selectedElements.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
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 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,
|
||||
};
|
||||
const newSelectionBox = {
|
||||
...selectionBox,
|
||||
currentPos: { x: clientX, y: clientY },
|
||||
isActive: shouldActivate || selectionBox.isActive,
|
||||
};
|
||||
|
||||
setSelectionBox(newSelectionBox);
|
||||
setSelectionBox(newSelectionBox);
|
||||
|
||||
if (newSelectionBox.isActive) {
|
||||
selectElementsInBox({
|
||||
startPos: newSelectionBox.startPos,
|
||||
endPos: newSelectionBox.currentPos,
|
||||
});
|
||||
}
|
||||
};
|
||||
if (newSelectionBox.isActive) {
|
||||
selectElementsInBox({
|
||||
startPos: newSelectionBox.startPos,
|
||||
endPos: newSelectionBox.currentPos,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setSelectionBox(null);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
setSelectionBox(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [selectionBox, selectElementsInBox]);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [selectionBox, selectElementsInBox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const previousBodyUserSelect = document.body.style.userSelect;
|
||||
const container = containerRef.current;
|
||||
const previousContainerUserSelect = container?.style.userSelect ?? "";
|
||||
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";
|
||||
document.body.style.userSelect = "none";
|
||||
if (container) container.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = previousBodyUserSelect;
|
||||
if (container) container.style.userSelect = previousContainerUserSelect;
|
||||
};
|
||||
}, [selectionBox, containerRef]);
|
||||
return () => {
|
||||
document.body.style.userSelect = previousBodyUserSelect;
|
||||
if (container) container.style.userSelect = previousContainerUserSelect;
|
||||
};
|
||||
}, [selectionBox, containerRef]);
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
handleMouseDown,
|
||||
isSelecting: selectionBox?.isActive || false,
|
||||
};
|
||||
return {
|
||||
selectionBox,
|
||||
handleMouseDown,
|
||||
isSelecting: selectionBox?.isActive || false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface UseSnapIndicatorPositionParams {
|
||||
snapPoint: { time: number } | null;
|
||||
zoomLevel: number;
|
||||
tracks: TimelineTrack[];
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
snapPoint: { time: number } | null;
|
||||
zoomLevel: number;
|
||||
tracks: TimelineTrack[];
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface SnapIndicatorPosition {
|
||||
leftPosition: number;
|
||||
topPosition: number;
|
||||
height: number;
|
||||
leftPosition: number;
|
||||
topPosition: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useSnapIndicatorPosition({
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
|
||||
if (!tracksViewport) return;
|
||||
if (!tracksViewport) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
};
|
||||
const handleScroll = () => {
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
};
|
||||
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
|
||||
tracksViewport.addEventListener("scroll", handleScroll);
|
||||
return () => tracksViewport.removeEventListener("scroll", handleScroll);
|
||||
}, [tracksScrollRef]);
|
||||
tracksViewport.addEventListener("scroll", handleScroll);
|
||||
return () => tracksViewport.removeEventListener("scroll", handleScroll);
|
||||
}, [tracksScrollRef]);
|
||||
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
||||
|
||||
const trackLabelsWidth =
|
||||
tracks.length > 0 && trackLabelsRef?.current
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
const trackLabelsWidth =
|
||||
tracks.length > 0 && trackLabelsRef?.current
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
|
||||
const timelinePosition =
|
||||
(snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
const timelinePosition =
|
||||
(snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
|
||||
return {
|
||||
leftPosition,
|
||||
topPosition: 0,
|
||||
height: totalHeight,
|
||||
};
|
||||
return {
|
||||
leftPosition,
|
||||
topPosition: 0,
|
||||
height: totalHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { toast } from "sonner";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import {
|
||||
buildTextElement,
|
||||
buildStickerElement,
|
||||
buildTextElement,
|
||||
buildStickerElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getDragData, hasDragData } from "@/lib/drag-data";
|
||||
@@ -14,497 +14,497 @@ import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
|
||||
import type { MediaDragData, StickerDragData } from "@/types/drag";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
zoomLevel: number;
|
||||
isSnappingEnabled?: boolean;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
zoomLevel: number;
|
||||
isSnappingEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function useTimelineDragDrop({
|
||||
containerRef,
|
||||
zoomLevel,
|
||||
isSnappingEnabled = true,
|
||||
containerRef,
|
||||
zoomLevel,
|
||||
isSnappingEnabled = true,
|
||||
}: UseTimelineDragDropProps) {
|
||||
const editor = useEditor();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [dragElementType, setElementType] = useState<ElementType | null>(null);
|
||||
const editor = useEditor();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [dragElementType, setElementType] = useState<ElementType | null>(null);
|
||||
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const getSnappedTime = useCallback(
|
||||
({ time }: { time: number }) => {
|
||||
const projectFps = activeProject.settings.fps;
|
||||
return snapTimeToFrame({ time, fps: projectFps });
|
||||
},
|
||||
[activeProject.settings.fps],
|
||||
);
|
||||
const getSnappedTime = useCallback(
|
||||
({ time }: { time: number }) => {
|
||||
const projectFps = activeProject.settings.fps;
|
||||
return snapTimeToFrame({ time, fps: projectFps });
|
||||
},
|
||||
[activeProject.settings.fps],
|
||||
);
|
||||
|
||||
const getElementType = useCallback(
|
||||
({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
|
||||
const dragData = getDragData({ dataTransfer });
|
||||
if (!dragData) return null;
|
||||
const getElementType = useCallback(
|
||||
({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
|
||||
const dragData = getDragData({ dataTransfer });
|
||||
if (!dragData) return null;
|
||||
|
||||
if (dragData.type === "text") return "text";
|
||||
if (dragData.type === "sticker") return "sticker";
|
||||
if (dragData.type === "media") {
|
||||
return dragData.mediaType;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
if (dragData.type === "text") return "text";
|
||||
if (dragData.type === "sticker") return "sticker";
|
||||
if (dragData.type === "media") {
|
||||
return dragData.mediaType;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getElementDuration = useCallback(
|
||||
({
|
||||
elementType,
|
||||
mediaId,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
mediaId?: string;
|
||||
}): number => {
|
||||
if (elementType === "text" || elementType === "sticker") {
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
if (mediaId) {
|
||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
||||
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
},
|
||||
[mediaAssets],
|
||||
);
|
||||
const getElementDuration = useCallback(
|
||||
({
|
||||
elementType,
|
||||
mediaId,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
mediaId?: string;
|
||||
}): number => {
|
||||
if (elementType === "text" || elementType === "sticker") {
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
if (mediaId) {
|
||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
||||
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
},
|
||||
[mediaAssets],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
const isExternal =
|
||||
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
const isExternal =
|
||||
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
|
||||
|
||||
let elementType = getElementType({ dataTransfer: e.dataTransfer });
|
||||
const elementType = getElementType({ dataTransfer: e.dataTransfer });
|
||||
|
||||
if (!elementType && hasFiles && isExternal) {
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
return;
|
||||
}
|
||||
if (!elementType && hasFiles && isExternal) {
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elementType) return;
|
||||
if (!elementType) return;
|
||||
|
||||
setElementType(elementType);
|
||||
setElementType(elementType);
|
||||
|
||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
||||
const duration = getElementDuration({
|
||||
elementType,
|
||||
mediaId: dragData?.type === "media" ? dragData.id : undefined,
|
||||
});
|
||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
||||
const duration = getElementDuration({
|
||||
elementType,
|
||||
mediaId: dragData?.type === "media" ? dragData.id : undefined,
|
||||
});
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const target = computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: isExternal,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
const target = computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: isExternal,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
target.xPosition = getSnappedTime({ time: target.xPosition });
|
||||
target.xPosition = getSnappedTime({ time: target.xPosition });
|
||||
|
||||
setDropTarget(target);
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
[
|
||||
containerRef,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
getElementType,
|
||||
getElementDuration,
|
||||
getSnappedTime,
|
||||
],
|
||||
);
|
||||
setDropTarget(target);
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
[
|
||||
containerRef,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
getElementType,
|
||||
getElementDuration,
|
||||
getSnappedTime,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const { clientX, clientY } = e;
|
||||
if (
|
||||
clientX < rect.left ||
|
||||
clientX > rect.right ||
|
||||
clientY < rect.top ||
|
||||
clientY > rect.bottom
|
||||
) {
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[containerRef],
|
||||
);
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const { clientX, clientY } = e;
|
||||
if (
|
||||
clientX < rect.left ||
|
||||
clientX > rect.right ||
|
||||
clientY < rect.top ||
|
||||
clientY > rect.bottom
|
||||
) {
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[containerRef],
|
||||
);
|
||||
|
||||
const executeTextDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: { name?: string; content?: string };
|
||||
}) => {
|
||||
let trackId: string;
|
||||
const executeTextDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: { name?: string; content?: string };
|
||||
}) => {
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "text",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "text",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildTextElement({
|
||||
raw: {
|
||||
name: dragData.name ?? "",
|
||||
content: dragData.content ?? "",
|
||||
},
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
const element = buildTextElement({
|
||||
raw: {
|
||||
name: dragData.name ?? "",
|
||||
content: dragData.content ?? "",
|
||||
},
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeStickerDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: StickerDragData;
|
||||
}) => {
|
||||
let trackId: string;
|
||||
const executeStickerDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: StickerDragData;
|
||||
}) => {
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "sticker",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "sticker",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildStickerElement({
|
||||
iconName: dragData.iconName,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
const element = buildStickerElement({
|
||||
iconName: dragData.iconName,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeMediaDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
|
||||
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
|
||||
if (!mediaAsset) return;
|
||||
const executeMediaDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
|
||||
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
|
||||
if (!mediaAsset) return;
|
||||
|
||||
const trackType: TrackType =
|
||||
dragData.mediaType === "audio" ? "audio" : "video";
|
||||
let trackId: string;
|
||||
const trackType: TrackType =
|
||||
dragData.mediaType === "audio" ? "audio" : "video";
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const duration =
|
||||
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const duration =
|
||||
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
if (dragData.mediaType === "audio") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
},
|
||||
});
|
||||
} else if (dragData.mediaType === "video") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "video",
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "image",
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[editor.timeline, mediaAssets, tracks],
|
||||
);
|
||||
if (dragData.mediaType === "audio") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
},
|
||||
});
|
||||
} else if (dragData.mediaType === "video") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "video",
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "image",
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[editor.timeline, mediaAssets, tracks],
|
||||
);
|
||||
|
||||
const executeFileDrop = useCallback(
|
||||
async ({
|
||||
files,
|
||||
mouseX,
|
||||
mouseY,
|
||||
}: {
|
||||
files: File[];
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
}) => {
|
||||
if (!activeProject) return;
|
||||
const executeFileDrop = useCallback(
|
||||
async ({
|
||||
files,
|
||||
mouseX,
|
||||
mouseY,
|
||||
}: {
|
||||
files: File[];
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
}) => {
|
||||
if (!activeProject) return;
|
||||
|
||||
const processedAssets = await processMediaAssets({ files });
|
||||
const processedAssets = await processMediaAssets({ files });
|
||||
|
||||
for (const asset of processedAssets) {
|
||||
await editor.media.addMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
asset,
|
||||
});
|
||||
for (const asset of processedAssets) {
|
||||
await editor.media.addMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
asset,
|
||||
});
|
||||
|
||||
const added = editor.media
|
||||
.getAssets()
|
||||
.find((m) => m.name === asset.name && m.url === asset.url);
|
||||
const added = editor.media
|
||||
.getAssets()
|
||||
.find((m) => m.name === asset.name && m.url === asset.url);
|
||||
|
||||
if (added) {
|
||||
const duration =
|
||||
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const currentTracks = editor.timeline.getTracks();
|
||||
const dropTarget = computeDropTarget({
|
||||
elementType: added.type,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks: currentTracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: true,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
if (added) {
|
||||
const duration =
|
||||
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const currentTracks = editor.timeline.getTracks();
|
||||
const dropTarget = computeDropTarget({
|
||||
elementType: added.type,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks: currentTracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: true,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const trackType: TrackType =
|
||||
added.type === "audio" ? "audio" : "video";
|
||||
const trackId = dropTarget.isNewTrack
|
||||
? editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: dropTarget.trackIndex,
|
||||
})
|
||||
: currentTracks[dropTarget.trackIndex]?.id;
|
||||
const trackType: TrackType =
|
||||
added.type === "audio" ? "audio" : "video";
|
||||
const trackId = dropTarget.isNewTrack
|
||||
? editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: dropTarget.trackIndex,
|
||||
})
|
||||
: currentTracks[dropTarget.trackIndex]?.id;
|
||||
|
||||
if (!trackId) return;
|
||||
if (!trackId) return;
|
||||
|
||||
if (added.type === "audio") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 1,
|
||||
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
|
||||
muted: false,
|
||||
},
|
||||
});
|
||||
} else if (added.type === "video") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "video",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "image",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeProject, editor.media, editor.timeline, currentTime, zoomLevel],
|
||||
);
|
||||
if (added.type === "audio") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 1,
|
||||
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
|
||||
muted: false,
|
||||
},
|
||||
});
|
||||
} else if (added.type === "video") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "video",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: {
|
||||
type: "image",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeProject, editor.media, editor.timeline, currentTime, zoomLevel],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
|
||||
const currentTarget = dropTarget;
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
const currentTarget = dropTarget;
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
|
||||
try {
|
||||
if (hasAsset) {
|
||||
if (!currentTarget) return;
|
||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
||||
if (!dragData) return;
|
||||
try {
|
||||
if (hasAsset) {
|
||||
if (!currentTarget) return;
|
||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
||||
if (!dragData) return;
|
||||
|
||||
if (dragData.type === "text") {
|
||||
executeTextDrop({ target: currentTarget, dragData });
|
||||
} else if (dragData.type === "sticker") {
|
||||
executeStickerDrop({ target: currentTarget, dragData });
|
||||
} else {
|
||||
executeMediaDrop({ target: currentTarget, dragData });
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
await executeFileDrop({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
mouseX,
|
||||
mouseY,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to process drop:", err);
|
||||
toast.error("Failed to process drop");
|
||||
}
|
||||
},
|
||||
[
|
||||
dropTarget,
|
||||
executeTextDrop,
|
||||
executeStickerDrop,
|
||||
executeMediaDrop,
|
||||
executeFileDrop,
|
||||
],
|
||||
);
|
||||
if (dragData.type === "text") {
|
||||
executeTextDrop({ target: currentTarget, dragData });
|
||||
} else if (dragData.type === "sticker") {
|
||||
executeStickerDrop({ target: currentTarget, dragData });
|
||||
} else {
|
||||
executeMediaDrop({ target: currentTarget, dragData });
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
await executeFileDrop({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
mouseX,
|
||||
mouseY,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to process drop:", err);
|
||||
toast.error("Failed to process drop");
|
||||
}
|
||||
},
|
||||
[
|
||||
dropTarget,
|
||||
executeTextDrop,
|
||||
executeStickerDrop,
|
||||
executeMediaDrop,
|
||||
executeFileDrop,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dropTarget,
|
||||
dragElementType,
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
return {
|
||||
isDragOver,
|
||||
dropTarget,
|
||||
dragElementType,
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,193 +5,193 @@ import { useEditor } from "../use-editor";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
zoomLevel: number;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
zoomLevel: number;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useTimelinePlayhead({
|
||||
zoomLevel,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
zoomLevel,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: UseTimelinePlayheadProps) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
// Playhead scrubbing state
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
// Playhead scrubbing state
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
|
||||
// Ruler drag detection state
|
||||
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
|
||||
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
|
||||
const lastMouseXRef = useRef<number>(0);
|
||||
// Ruler drag detection state
|
||||
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
|
||||
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
|
||||
const lastMouseXRef = useRef<number>(0);
|
||||
|
||||
const playheadPosition =
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
const playheadPosition =
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // prevent ruler drag from triggering
|
||||
setIsScrubbing(true);
|
||||
handleScrub(event);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // prevent ruler drag from triggering
|
||||
setIsScrubbing(true);
|
||||
handleScrub(event);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
|
||||
const handleRulerMouseDown = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
// only handle left mouse button
|
||||
if (event.button !== 0) return;
|
||||
const handleRulerMouseDown = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
// only handle left mouse button
|
||||
if (event.button !== 0) return;
|
||||
|
||||
// don't interfere if clicking on the playhead itself
|
||||
if (playheadRef?.current?.contains(event.target as Node)) return;
|
||||
// don't interfere if clicking on the playhead itself
|
||||
if (playheadRef?.current?.contains(event.target as Node)) return;
|
||||
|
||||
event.preventDefault();
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
event.preventDefault();
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
// start scrubbing immediately
|
||||
setIsScrubbing(true);
|
||||
handleScrub(event);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
// start scrubbing immediately
|
||||
setIsScrubbing(true);
|
||||
handleScrub(event);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
|
||||
const handleScrub = useCallback(
|
||||
(event: MouseEvent | React.MouseEvent) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rect = ruler.getBoundingClientRect();
|
||||
const rawX = event.clientX - rect.left;
|
||||
const handleScrub = useCallback(
|
||||
(event: MouseEvent | React.MouseEvent) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rect = ruler.getBoundingClientRect();
|
||||
const rawX = event.clientX - rect.left;
|
||||
|
||||
// get the timeline content width based on duration and zoom
|
||||
const timelineContentWidth =
|
||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
// get the timeline content width based on duration and zoom
|
||||
const timelineContentWidth =
|
||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
// constrain x to be within the timeline content bounds
|
||||
const x = Math.max(0, Math.min(timelineContentWidth, rawX));
|
||||
// constrain x to be within the timeline content bounds
|
||||
const x = Math.max(0, Math.min(timelineContentWidth, rawX));
|
||||
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
x / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
// use frame snapping for playhead scrubbing
|
||||
const fps = activeProject.settings.fps;
|
||||
const time = getSnappedSeekTime({ rawTime, duration, fps });
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
x / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
// use frame snapping for playhead scrubbing
|
||||
const fps = activeProject.settings.fps;
|
||||
const time = getSnappedSeekTime({ rawTime, duration, fps });
|
||||
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
|
||||
// store mouse position for auto-scrolling
|
||||
lastMouseXRef.current = event.clientX;
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
|
||||
);
|
||||
// store mouse position for auto-scrolling
|
||||
lastMouseXRef.current = event.clientX;
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
|
||||
);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: isScrubbing,
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
useEdgeAutoScroll({
|
||||
isActive: isScrubbing,
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
handleScrub(event);
|
||||
// mark that we've dragged if ruler drag is active
|
||||
if (isDraggingRuler) {
|
||||
setHasDraggedRuler(true);
|
||||
}
|
||||
};
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
handleScrub(event);
|
||||
// mark that we've dragged if ruler drag is active
|
||||
if (isDraggingRuler) {
|
||||
setHasDraggedRuler(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (event: MouseEvent) => {
|
||||
setIsScrubbing(false);
|
||||
if (scrubTime !== null) seek(scrubTime); // finalize seek
|
||||
setScrubTime(null);
|
||||
const onMouseUp = (event: MouseEvent) => {
|
||||
setIsScrubbing(false);
|
||||
if (scrubTime !== null) seek(scrubTime); // finalize seek
|
||||
setScrubTime(null);
|
||||
|
||||
// handle ruler click vs drag
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
// if we didn't drag, treat it as a click-to-seek
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub(event);
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
};
|
||||
// handle ruler click vs drag
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
// if we didn't drag, treat it as a click-to-seek
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub(event);
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [
|
||||
isScrubbing,
|
||||
scrubTime,
|
||||
seek,
|
||||
handleScrub,
|
||||
isDraggingRuler,
|
||||
hasDraggedRuler,
|
||||
// edge auto scroll hook is independent
|
||||
]);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [
|
||||
isScrubbing,
|
||||
scrubTime,
|
||||
seek,
|
||||
handleScrub,
|
||||
isDraggingRuler,
|
||||
hasDraggedRuler,
|
||||
// edge auto scroll hook is independent
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// only auto-scroll during playback, not during manual interactions
|
||||
if (!editor.playback.getIsPlaying() || isScrubbing) return;
|
||||
useEffect(() => {
|
||||
// only auto-scroll during playback, not during manual interactions
|
||||
if (!editor.playback.getIsPlaying() || isScrubbing) return;
|
||||
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const playheadPx =
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMin = 0;
|
||||
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
|
||||
const playheadPx =
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMin = 0;
|
||||
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
|
||||
|
||||
// only auto-scroll if playhead is completely out of view (no buffer)
|
||||
const needsScroll =
|
||||
playheadPx < rulerViewport.scrollLeft ||
|
||||
playheadPx > rulerViewport.scrollLeft + viewportWidth;
|
||||
// only auto-scroll if playhead is completely out of view (no buffer)
|
||||
const needsScroll =
|
||||
playheadPx < rulerViewport.scrollLeft ||
|
||||
playheadPx > rulerViewport.scrollLeft + viewportWidth;
|
||||
|
||||
if (needsScroll) {
|
||||
// center the playhead in the viewport
|
||||
const desiredScroll = Math.max(
|
||||
scrollMin,
|
||||
Math.min(scrollMax, playheadPx - viewportWidth / 2),
|
||||
);
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
}, [
|
||||
playheadPosition,
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
isScrubbing,
|
||||
]);
|
||||
if (needsScroll) {
|
||||
// center the playhead in the viewport
|
||||
const desiredScroll = Math.max(
|
||||
scrollMin,
|
||||
Math.min(scrollMax, playheadPx - viewportWidth / 2),
|
||||
);
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
}, [
|
||||
playheadPosition,
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
isScrubbing,
|
||||
]);
|
||||
|
||||
return {
|
||||
playheadPosition,
|
||||
handlePlayheadMouseDown,
|
||||
handleRulerMouseDown,
|
||||
isDraggingRuler,
|
||||
};
|
||||
return {
|
||||
playheadPosition,
|
||||
handlePlayheadMouseDown,
|
||||
handleRulerMouseDown,
|
||||
isDraggingRuler,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,185 +5,185 @@ import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseTimelineSeekProps {
|
||||
playheadRef: RefObject<HTMLDivElement>;
|
||||
trackLabelsRef: RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement>;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
isSelecting: boolean;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
playheadRef: RefObject<HTMLDivElement>;
|
||||
trackLabelsRef: RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement>;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
isSelecting: boolean;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
}
|
||||
|
||||
function resetMouseTracking({
|
||||
mouseTrackingRef,
|
||||
mouseTrackingRef,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
}) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
};
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function setMouseTracking({
|
||||
mouseTrackingRef,
|
||||
event,
|
||||
mouseTrackingRef,
|
||||
event,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
event: React.MouseEvent;
|
||||
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,
|
||||
};
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: true,
|
||||
downX: event.clientX,
|
||||
downY: event.clientY,
|
||||
downTime: event.timeStamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimelineSeek({
|
||||
playheadRef,
|
||||
trackLabelsRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
playheadRef,
|
||||
trackLabelsRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineSeekProps) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
|
||||
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const shouldProcessTimelineClick = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
const deltaX = Math.abs(event.clientX - downX);
|
||||
const deltaY = Math.abs(event.clientY - downY);
|
||||
const deltaTime = event.timeStamp - downTime;
|
||||
const isPlayhead = !!playheadRef.current?.contains(target);
|
||||
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
|
||||
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
|
||||
const shouldProcessTimelineClick = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
const deltaX = Math.abs(event.clientX - downX);
|
||||
const deltaY = Math.abs(event.clientY - downY);
|
||||
const deltaTime = event.timeStamp - downTime;
|
||||
const isPlayhead = !!playheadRef.current?.contains(target);
|
||||
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
|
||||
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
|
||||
|
||||
if (!isMouseDown) return false;
|
||||
if (shouldBlockForDrag) return false;
|
||||
if (isSelecting) return false;
|
||||
if (isPlayhead) return false;
|
||||
if (isTrackLabels) {
|
||||
clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
if (!isMouseDown) return false;
|
||||
if (shouldBlockForDrag) return false;
|
||||
if (isSelecting) return false;
|
||||
if (isPlayhead) return false;
|
||||
if (isTrackLabels) {
|
||||
clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
source: "ruler" | "tracks";
|
||||
}) => {
|
||||
const scrollContainer =
|
||||
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
|
||||
const handleTimelineSeek = useCallback(
|
||||
({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
source: "ruler" | "tracks";
|
||||
}) => {
|
||||
const scrollContainer =
|
||||
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
(mouseX + scrollLeft) /
|
||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
(mouseX + scrollLeft) /
|
||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
const projectFps = activeProject?.settings.fps || 30;
|
||||
const time = getSnappedSeekTime({
|
||||
rawTime,
|
||||
duration,
|
||||
fps: projectFps,
|
||||
});
|
||||
seek(time);
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
seek,
|
||||
activeProject?.settings.fps,
|
||||
],
|
||||
);
|
||||
const projectFps = activeProject?.settings.fps || 30;
|
||||
const time = getSnappedSeekTime({
|
||||
rawTime,
|
||||
duration,
|
||||
fps: projectFps,
|
||||
});
|
||||
seek(time);
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
seek,
|
||||
activeProject?.settings.fps,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTracksClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
const handleTracksClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "tracks" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "tracks" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
const handleRulerClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
const handleRulerClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "ruler" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "ruler" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTracksMouseDown,
|
||||
handleTracksClick,
|
||||
handleRulerMouseDown,
|
||||
handleRulerClick,
|
||||
};
|
||||
return {
|
||||
handleTracksMouseDown,
|
||||
handleTracksClick,
|
||||
handleRulerMouseDown,
|
||||
handleRulerClick,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,159 +1,159 @@
|
||||
import { useCallback } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
export interface SnapPoint {
|
||||
time: number;
|
||||
type: "element-start" | "element-end" | "playhead";
|
||||
elementId?: string;
|
||||
trackId?: string;
|
||||
time: number;
|
||||
type: "element-start" | "element-end" | "playhead";
|
||||
elementId?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
snappedTime: number;
|
||||
snapPoint: SnapPoint | null;
|
||||
snapDistance: number;
|
||||
snappedTime: number;
|
||||
snapPoint: SnapPoint | null;
|
||||
snapDistance: number;
|
||||
}
|
||||
|
||||
export interface UseTimelineSnappingOptions {
|
||||
snapThreshold?: number;
|
||||
enableElementSnapping?: boolean;
|
||||
enablePlayheadSnapping?: boolean;
|
||||
snapThreshold?: number;
|
||||
enableElementSnapping?: boolean;
|
||||
enablePlayheadSnapping?: boolean;
|
||||
}
|
||||
|
||||
export function useTimelineSnapping({
|
||||
snapThreshold = 10,
|
||||
enableElementSnapping = true,
|
||||
enablePlayheadSnapping = true,
|
||||
snapThreshold = 10,
|
||||
enableElementSnapping = true,
|
||||
enablePlayheadSnapping = true,
|
||||
}: UseTimelineSnappingOptions = {}) {
|
||||
const findSnapPoints = useCallback(
|
||||
({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
}: {
|
||||
tracks: Array<TimelineTrack>;
|
||||
playheadTime: number;
|
||||
excludeElementId?: string;
|
||||
}): SnapPoint[] => {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
const findSnapPoints = useCallback(
|
||||
({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
}: {
|
||||
tracks: Array<TimelineTrack>;
|
||||
playheadTime: number;
|
||||
excludeElementId?: string;
|
||||
}): SnapPoint[] => {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
|
||||
if (enableElementSnapping) {
|
||||
for (const track of tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (element.id === excludeElementId) continue;
|
||||
if (enableElementSnapping) {
|
||||
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;
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd = element.startTime + element.duration;
|
||||
|
||||
snapPoints.push(
|
||||
{
|
||||
time: elementStart,
|
||||
type: "element-start",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
{
|
||||
time: elementEnd,
|
||||
type: "element-end",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
snapPoints.push(
|
||||
{
|
||||
time: elementStart,
|
||||
type: "element-start",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
{
|
||||
time: elementEnd,
|
||||
type: "element-end",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enablePlayheadSnapping) {
|
||||
snapPoints.push({
|
||||
time: playheadTime,
|
||||
type: "playhead",
|
||||
});
|
||||
}
|
||||
if (enablePlayheadSnapping) {
|
||||
snapPoints.push({
|
||||
time: playheadTime,
|
||||
type: "playhead",
|
||||
});
|
||||
}
|
||||
|
||||
return snapPoints;
|
||||
},
|
||||
[enableElementSnapping, enablePlayheadSnapping],
|
||||
);
|
||||
return snapPoints;
|
||||
},
|
||||
[enableElementSnapping, enablePlayheadSnapping],
|
||||
);
|
||||
|
||||
const snapToNearestPoint = useCallback(
|
||||
({
|
||||
targetTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
}: {
|
||||
targetTime: number;
|
||||
snapPoints: Array<SnapPoint>;
|
||||
zoomLevel: number;
|
||||
}): SnapResult => {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
|
||||
const snapToNearestPoint = useCallback(
|
||||
({
|
||||
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;
|
||||
let closestSnapPoint: SnapPoint | null = null;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (const snapPoint of snapPoints) {
|
||||
const distance = Math.abs(targetTime - snapPoint.time);
|
||||
if (distance < thresholdInSeconds && distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestSnapPoint = 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 ? closestSnapPoint.time : targetTime,
|
||||
snapPoint: closestSnapPoint,
|
||||
snapDistance: closestDistance,
|
||||
};
|
||||
},
|
||||
[snapThreshold],
|
||||
);
|
||||
return {
|
||||
snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
|
||||
snapPoint: closestSnapPoint,
|
||||
snapDistance: closestDistance,
|
||||
};
|
||||
},
|
||||
[snapThreshold],
|
||||
);
|
||||
|
||||
const snapElementEdge = useCallback(
|
||||
({
|
||||
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,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
});
|
||||
const snapElementEdge = useCallback(
|
||||
({
|
||||
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,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
});
|
||||
|
||||
const effectiveTargetTime = snapToStart
|
||||
? targetTime
|
||||
: targetTime + elementDuration;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: effectiveTargetTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
const effectiveTargetTime = snapToStart
|
||||
? targetTime
|
||||
: targetTime + elementDuration;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: effectiveTargetTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
if (!snapToStart && snapResult.snapPoint) {
|
||||
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
|
||||
}
|
||||
if (!snapToStart && snapResult.snapPoint) {
|
||||
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
|
||||
}
|
||||
|
||||
return snapResult;
|
||||
},
|
||||
[findSnapPoints, snapToNearestPoint],
|
||||
);
|
||||
return snapResult;
|
||||
},
|
||||
[findSnapPoints, snapToNearestPoint],
|
||||
);
|
||||
|
||||
return {
|
||||
snapElementEdge,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
};
|
||||
return {
|
||||
snapElementEdge,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type RefObject,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
isInTimeline?: boolean;
|
||||
minZoom?: number;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
isInTimeline?: boolean;
|
||||
minZoom?: number;
|
||||
}
|
||||
|
||||
interface UseTimelineZoomReturn {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
|
||||
handleWheel: (event: ReactWheelEvent) => void;
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
|
||||
handleWheel: (event: ReactWheelEvent) => void;
|
||||
}
|
||||
|
||||
export function useTimelineZoom({
|
||||
containerRef,
|
||||
isInTimeline = false,
|
||||
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||
containerRef,
|
||||
isInTimeline = false,
|
||||
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: ReactWheelEvent) => {
|
||||
const isZoomGesture = event.ctrlKey || event.metaKey;
|
||||
const isHorizontalScrollGesture =
|
||||
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
||||
const handleWheel = useCallback(
|
||||
(event: ReactWheelEvent) => {
|
||||
const isZoomGesture = event.ctrlKey || event.metaKey;
|
||||
const isHorizontalScrollGesture =
|
||||
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
||||
|
||||
if (isHorizontalScrollGesture) {
|
||||
return;
|
||||
}
|
||||
if (isHorizontalScrollGesture) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pinch-zoom (ctrl/meta + wheel)
|
||||
if (isZoomGesture) {
|
||||
const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
|
||||
);
|
||||
return nextZoom;
|
||||
});
|
||||
// for horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
return;
|
||||
}
|
||||
},
|
||||
[minZoom],
|
||||
);
|
||||
// pinch-zoom (ctrl/meta + wheel)
|
||||
if (isZoomGesture) {
|
||||
const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
|
||||
);
|
||||
return nextZoom;
|
||||
});
|
||||
// for horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
return;
|
||||
}
|
||||
},
|
||||
[minZoom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setZoomLevel((prev) => (prev < minZoom ? minZoom : prev));
|
||||
}, [minZoom]);
|
||||
useEffect(() => {
|
||||
setZoomLevel((prev) => (prev < minZoom ? minZoom : prev));
|
||||
}, [minZoom]);
|
||||
|
||||
// prevent browser zoom in the timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (event: WheelEvent) => {
|
||||
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
|
||||
const isInContainer = containerRef.current?.contains(
|
||||
event.target as Node,
|
||||
);
|
||||
const shouldPrevent =
|
||||
isInTimeline && isZoomKeyPressed && Boolean(isInContainer);
|
||||
if (shouldPrevent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
// prevent browser zoom in the timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (event: WheelEvent) => {
|
||||
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
|
||||
const isInContainer = containerRef.current?.contains(
|
||||
event.target as Node,
|
||||
);
|
||||
const shouldPrevent =
|
||||
isInTimeline && isZoomKeyPressed && Boolean(isInContainer);
|
||||
if (shouldPrevent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", preventZoom, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
document.addEventListener("wheel", preventZoom, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom, { capture: true });
|
||||
};
|
||||
}, [isInTimeline, containerRef]);
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom, { capture: true });
|
||||
};
|
||||
}, [isInTimeline, containerRef]);
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel,
|
||||
handleWheel,
|
||||
};
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel,
|
||||
handleWheel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { useEffect, useMemo, useReducer } from "react";
|
||||
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export function useEditor(): EditorCore {
|
||||
const editor = useMemo(() => EditorCore.getInstance(), []);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
const editor = useMemo(() => EditorCore.getInstance(), []);
|
||||
const versionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribers = [
|
||||
editor.playback.subscribe(forceUpdate),
|
||||
editor.timeline.subscribe(forceUpdate),
|
||||
editor.scenes.subscribe(forceUpdate),
|
||||
editor.project.subscribe(forceUpdate),
|
||||
editor.media.subscribe(forceUpdate),
|
||||
editor.renderer.subscribe(forceUpdate),
|
||||
];
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
const handleStoreChange = () => {
|
||||
versionRef.current += 1;
|
||||
onStoreChange();
|
||||
};
|
||||
|
||||
return () => unsubscribers.forEach((unsub) => unsub());
|
||||
}, [editor]);
|
||||
const unsubscribers = [
|
||||
editor.playback.subscribe(handleStoreChange),
|
||||
editor.timeline.subscribe(handleStoreChange),
|
||||
editor.scenes.subscribe(handleStoreChange),
|
||||
editor.project.subscribe(handleStoreChange),
|
||||
editor.media.subscribe(handleStoreChange),
|
||||
editor.renderer.subscribe(handleStoreChange),
|
||||
];
|
||||
|
||||
return editor;
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => versionRef.current, []);
|
||||
|
||||
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
@@ -2,102 +2,102 @@ import { useState, useRef } from "react";
|
||||
import { hasDragData } from "@/lib/drag-data";
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
onFilesSelected?: (files: FileList) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
onFilesSelected?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files");
|
||||
return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files");
|
||||
}
|
||||
|
||||
export function useFileUpload({
|
||||
accept,
|
||||
multiple,
|
||||
onFilesSelected,
|
||||
accept,
|
||||
multiple,
|
||||
onFilesSelected,
|
||||
}: UseFileUploadOptions = {}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function openFilePicker() {
|
||||
if (!inputRef.current) return;
|
||||
function openFilePicker() {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
inputRef.current.accept = accept || "*";
|
||||
inputRef.current.multiple = multiple || false;
|
||||
inputRef.current.click();
|
||||
}
|
||||
inputRef.current.accept = accept || "*";
|
||||
inputRef.current.multiple = multiple || false;
|
||||
inputRef.current.click();
|
||||
}
|
||||
|
||||
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0 && onFilesSelected) {
|
||||
onFilesSelected(files);
|
||||
}
|
||||
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0 && onFilesSelected) {
|
||||
onFilesSelected(files);
|
||||
}
|
||||
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnter(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
function handleDragEnter(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
|
||||
dragCounterRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
}
|
||||
dragCounterRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
}
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
function handleDragLeave(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (onFilesSelected && containsFiles(e.dataTransfer)) {
|
||||
const files = e.dataTransfer.files;
|
||||
const shouldUseMultiple = multiple ?? false;
|
||||
if (onFilesSelected && containsFiles(e.dataTransfer)) {
|
||||
const files = e.dataTransfer.files;
|
||||
const shouldUseMultiple = multiple ?? false;
|
||||
|
||||
if (shouldUseMultiple) {
|
||||
onFilesSelected(files);
|
||||
} else if (files.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(files[0]);
|
||||
onFilesSelected(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldUseMultiple) {
|
||||
onFilesSelected(files);
|
||||
} else if (files.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(files[0]);
|
||||
onFilesSelected(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
openFilePicker,
|
||||
fileInputProps: {
|
||||
ref: inputRef,
|
||||
type: "file",
|
||||
style: { display: "none" },
|
||||
onChange: handleFileChange,
|
||||
},
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
return {
|
||||
isDragOver,
|
||||
openFilePicker,
|
||||
fileInputProps: {
|
||||
ref: inputRef,
|
||||
type: "file",
|
||||
style: { display: "none" },
|
||||
onChange: handleFileChange,
|
||||
},
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
threshold?: number;
|
||||
enabled?: boolean;
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
threshold?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useInfiniteScroll({
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
isLoading,
|
||||
threshold = 200,
|
||||
enabled = true,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
isLoading,
|
||||
threshold = 200,
|
||||
enabled = true,
|
||||
}: UseInfiniteScrollOptions) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
|
||||
|
||||
if (nearBottom && hasMore && !isLoading) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
[onLoadMore, hasMore, isLoading, threshold, enabled],
|
||||
);
|
||||
if (nearBottom && hasMore && !isLoading) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
[onLoadMore, hasMore, isLoading, threshold, enabled],
|
||||
);
|
||||
|
||||
return { scrollAreaRef, handleScroll };
|
||||
return { scrollAreaRef, handleScroll };
|
||||
}
|
||||
|
||||
@@ -8,67 +8,67 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
* the appropriate actions based on keybindings
|
||||
*/
|
||||
export function useKeybindingsListener() {
|
||||
const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
|
||||
useKeybindingsStore();
|
||||
const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
|
||||
useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
// Do not check keybinds if the mode is disabled
|
||||
if (!keybindingsEnabled) return;
|
||||
// ignore key events if user is changing keybindings
|
||||
if (isRecording) return;
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
// Do not check keybinds if the mode is disabled
|
||||
if (!keybindingsEnabled) return;
|
||||
// ignore key events if user is changing keybindings
|
||||
if (isRecording) return;
|
||||
|
||||
const binding = getKeybindingString(ev);
|
||||
if (!binding) return;
|
||||
const binding = getKeybindingString(ev);
|
||||
if (!binding) return;
|
||||
|
||||
const boundAction = keybindings[binding];
|
||||
if (!boundAction) return;
|
||||
const boundAction = keybindings[binding];
|
||||
if (!boundAction) return;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const isTextInput =
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
(activeElement as HTMLElement).isContentEditable);
|
||||
const activeElement = document.activeElement;
|
||||
const isTextInput =
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
(activeElement as HTMLElement).isContentEditable);
|
||||
|
||||
if (isTextInput) return;
|
||||
if (isTextInput) return;
|
||||
|
||||
ev.preventDefault();
|
||||
ev.preventDefault();
|
||||
|
||||
// Handle actions with default arguments
|
||||
let actionArgs: any;
|
||||
// Handle actions with default arguments
|
||||
let actionArgs: any;
|
||||
|
||||
if (boundAction === "seek-forward") {
|
||||
actionArgs = { seconds: 1 };
|
||||
} else if (boundAction === "seek-backward") {
|
||||
actionArgs = { seconds: 1 };
|
||||
} else if (boundAction === "jump-forward") {
|
||||
actionArgs = { seconds: 5 };
|
||||
} else if (boundAction === "jump-backward") {
|
||||
actionArgs = { seconds: 5 };
|
||||
}
|
||||
if (boundAction === "seek-forward") {
|
||||
actionArgs = { seconds: 1 };
|
||||
} else if (boundAction === "seek-backward") {
|
||||
actionArgs = { seconds: 1 };
|
||||
} else if (boundAction === "jump-forward") {
|
||||
actionArgs = { seconds: 5 };
|
||||
} else if (boundAction === "jump-backward") {
|
||||
actionArgs = { seconds: 5 };
|
||||
}
|
||||
|
||||
invokeAction(boundAction, actionArgs, "keypress");
|
||||
};
|
||||
invokeAction(boundAction, actionArgs, "keypress");
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This composable allows for the UI component to be disabled if the component in question is mounted
|
||||
*/
|
||||
export function useKeybindingDisabler() {
|
||||
const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
|
||||
const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
|
||||
|
||||
return {
|
||||
disableKeybindings,
|
||||
enableKeybindings,
|
||||
};
|
||||
return {
|
||||
disableKeybindings,
|
||||
enableKeybindings,
|
||||
};
|
||||
}
|
||||
|
||||
// Export the bindings for backward compatibility
|
||||
|
||||
@@ -4,79 +4,79 @@ import { useMemo } from "react";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { ACTIONS, type TAction } from "@/lib/actions";
|
||||
import {
|
||||
getPlatformAlternateKey,
|
||||
getPlatformSpecialKey,
|
||||
getPlatformAlternateKey,
|
||||
getPlatformSpecialKey,
|
||||
} from "@/utils/platform";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string;
|
||||
keys: string[];
|
||||
description: string;
|
||||
category: string;
|
||||
action: TAction;
|
||||
icon?: React.ReactNode;
|
||||
id: string;
|
||||
keys: string[];
|
||||
description: string;
|
||||
category: string;
|
||||
action: TAction;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
function formatKey({ key }: { key: string }): string {
|
||||
return key
|
||||
.replace("ctrl", getPlatformSpecialKey())
|
||||
.replace("alt", getPlatformAlternateKey())
|
||||
.replace("shift", "Shift")
|
||||
.replace("left", "←")
|
||||
.replace("right", "→")
|
||||
.replace("up", "↑")
|
||||
.replace("down", "↓")
|
||||
.replace("space", "Space")
|
||||
.replace("home", "Home")
|
||||
.replace("enter", "Enter")
|
||||
.replace("end", "End")
|
||||
.replace("delete", "Delete")
|
||||
.replace("backspace", "Backspace")
|
||||
.replace("-", "+");
|
||||
return key
|
||||
.replace("ctrl", getPlatformSpecialKey())
|
||||
.replace("alt", getPlatformAlternateKey())
|
||||
.replace("shift", "Shift")
|
||||
.replace("left", "←")
|
||||
.replace("right", "→")
|
||||
.replace("up", "↑")
|
||||
.replace("down", "↓")
|
||||
.replace("space", "Space")
|
||||
.replace("home", "Home")
|
||||
.replace("enter", "Enter")
|
||||
.replace("end", "End")
|
||||
.replace("delete", "Delete")
|
||||
.replace("backspace", "Backspace")
|
||||
.replace("-", "+");
|
||||
}
|
||||
|
||||
export function useKeyboardShortcutsHelp() {
|
||||
const { keybindings } = useKeybindingsStore();
|
||||
const { keybindings } = useKeybindingsStore();
|
||||
|
||||
const shortcuts = useMemo(() => {
|
||||
const result: KeyboardShortcut[] = [];
|
||||
const actionToKeys: Record<string, string[]> = {};
|
||||
const shortcuts = useMemo(() => {
|
||||
const result: KeyboardShortcut[] = [];
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
for (const [key, action] of Object.entries(keybindings)) {
|
||||
if (action) {
|
||||
if (!actionToKeys[action]) {
|
||||
actionToKeys[action] = [];
|
||||
}
|
||||
actionToKeys[action].push(formatKey({ key }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [actionId, keys] of Object.entries(actionToKeys)) {
|
||||
if (!isAction(actionId)) continue;
|
||||
for (const [actionId, keys] of Object.entries(actionToKeys)) {
|
||||
if (!isAction(actionId)) continue;
|
||||
|
||||
const actionDef = ACTIONS[actionId];
|
||||
result.push({
|
||||
id: actionId,
|
||||
keys,
|
||||
description: actionDef.description,
|
||||
category: actionDef.category,
|
||||
action: actionId,
|
||||
});
|
||||
}
|
||||
const actionDef = ACTIONS[actionId];
|
||||
result.push({
|
||||
id: actionId,
|
||||
keys,
|
||||
description: actionDef.description,
|
||||
category: actionDef.category,
|
||||
action: actionId,
|
||||
});
|
||||
}
|
||||
|
||||
return result.sort((a, b) => {
|
||||
if (a.category !== b.category) {
|
||||
return a.category.localeCompare(b.category);
|
||||
}
|
||||
return a.description.localeCompare(b.description);
|
||||
});
|
||||
}, [keybindings]);
|
||||
return result.sort((a, b) => {
|
||||
if (a.category !== b.category) {
|
||||
return a.category.localeCompare(b.category);
|
||||
}
|
||||
return a.description.localeCompare(b.description);
|
||||
});
|
||||
}, [keybindings]);
|
||||
|
||||
return {
|
||||
shortcuts,
|
||||
};
|
||||
return {
|
||||
shortcuts,
|
||||
};
|
||||
}
|
||||
|
||||
function isAction(id: string): id is TAction {
|
||||
return id in ACTIONS;
|
||||
return id in ACTIONS;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useRafLoop(callback: ({ time }: { time: number }) => void) {
|
||||
const requestRef = useRef<number>(0);
|
||||
const previousTimeRef = useRef<number>(0);
|
||||
const requestRef = useRef<number>(0);
|
||||
const previousTimeRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loop = ({ time }: { time: number }) => {
|
||||
if (previousTimeRef.current !== undefined) {
|
||||
const deltaTime = time - previousTimeRef.current;
|
||||
callback({ time: deltaTime });
|
||||
}
|
||||
previousTimeRef.current = time;
|
||||
requestRef.current = requestAnimationFrame((time) => loop({ time }));
|
||||
};
|
||||
useEffect(() => {
|
||||
const loop = ({ time }: { time: number }) => {
|
||||
if (previousTimeRef.current !== null) {
|
||||
const deltaTime = time - previousTimeRef.current;
|
||||
callback({ time: deltaTime });
|
||||
}
|
||||
previousTimeRef.current = time;
|
||||
requestRef.current = requestAnimationFrame((time) => loop({ time }));
|
||||
};
|
||||
|
||||
requestRef.current = requestAnimationFrame((time) => loop({ time }));
|
||||
return () => cancelAnimationFrame(requestRef.current);
|
||||
}, [callback]);
|
||||
requestRef.current = requestAnimationFrame((time) => loop({ time }));
|
||||
return () => cancelAnimationFrame(requestRef.current);
|
||||
}, [callback]);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export function useRevealItem(
|
||||
highlightId: string | null,
|
||||
onClearHighlight: () => void,
|
||||
highlightDuration = 1000,
|
||||
highlightId: string | null,
|
||||
onClearHighlight: () => void,
|
||||
highlightDuration = 1000,
|
||||
) {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
const registerElement = (id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
elementRefs.current.set(id, element);
|
||||
} else {
|
||||
elementRefs.current.delete(id);
|
||||
}
|
||||
};
|
||||
const registerElement = (id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
elementRefs.current.set(id, element);
|
||||
} else {
|
||||
elementRefs.current.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightId) return;
|
||||
useEffect(() => {
|
||||
if (!highlightId) return;
|
||||
|
||||
setHighlightedId(highlightId);
|
||||
setHighlightedId(highlightId);
|
||||
|
||||
const target = elementRefs.current.get(highlightId);
|
||||
target?.scrollIntoView({ block: "center" });
|
||||
const target = elementRefs.current.get(highlightId);
|
||||
target?.scrollIntoView({ block: "center" });
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setHighlightedId(null);
|
||||
onClearHighlight();
|
||||
}, highlightDuration);
|
||||
const timeout = setTimeout(() => {
|
||||
setHighlightedId(null);
|
||||
onClearHighlight();
|
||||
}, highlightDuration);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [highlightId, onClearHighlight, highlightDuration]);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [highlightId, onClearHighlight, highlightDuration]);
|
||||
|
||||
return { highlightedId, registerElement };
|
||||
return { highlightedId, registerElement };
|
||||
}
|
||||
|
||||
@@ -2,153 +2,153 @@ import { useEffect } from "react";
|
||||
import { useSoundsStore } from "@/stores/sounds-store";
|
||||
|
||||
export function useSoundSearch({
|
||||
query,
|
||||
commercialOnly,
|
||||
query,
|
||||
commercialOnly,
|
||||
}: {
|
||||
query: string;
|
||||
commercialOnly: boolean;
|
||||
query: string;
|
||||
commercialOnly: boolean;
|
||||
}) {
|
||||
const {
|
||||
searchResults,
|
||||
isSearching,
|
||||
searchError,
|
||||
lastSearchQuery,
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
setLoadingMore,
|
||||
appendSearchResults,
|
||||
appendTopSounds,
|
||||
resetPagination,
|
||||
} = useSoundsStore();
|
||||
const {
|
||||
searchResults,
|
||||
isSearching,
|
||||
searchError,
|
||||
lastSearchQuery,
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
setLoadingMore,
|
||||
appendSearchResults,
|
||||
appendTopSounds,
|
||||
resetPagination,
|
||||
} = useSoundsStore();
|
||||
|
||||
const loadMore = async () => {
|
||||
if (isLoadingMore || !hasNextPage) return;
|
||||
const loadMore = async () => {
|
||||
if (isLoadingMore || !hasNextPage) return;
|
||||
|
||||
try {
|
||||
setLoadingMore({ loading: true });
|
||||
const nextPage = currentPage + 1;
|
||||
try {
|
||||
setLoadingMore({ loading: true });
|
||||
const nextPage = currentPage + 1;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
page: nextPage.toString(),
|
||||
type: "effects",
|
||||
});
|
||||
const searchParams = new URLSearchParams({
|
||||
page: nextPage.toString(),
|
||||
type: "effects",
|
||||
});
|
||||
|
||||
if (query.trim()) {
|
||||
searchParams.set("q", query);
|
||||
}
|
||||
if (query.trim()) {
|
||||
searchParams.set("q", query);
|
||||
}
|
||||
|
||||
searchParams.set("commercial_only", commercialOnly.toString());
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?${searchParams.toString()}`,
|
||||
);
|
||||
searchParams.set("commercial_only", commercialOnly.toString());
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (query.trim()) {
|
||||
appendSearchResults(data.results);
|
||||
} else {
|
||||
appendTopSounds(data.results);
|
||||
}
|
||||
if (query.trim()) {
|
||||
appendSearchResults(data.results);
|
||||
} else {
|
||||
appendTopSounds(data.results);
|
||||
}
|
||||
|
||||
setCurrentPage({ page: nextPage });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount(data.count);
|
||||
} else {
|
||||
setSearchError({ error: `Load more failed: ${response.status}` });
|
||||
}
|
||||
} catch (err) {
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Load more failed",
|
||||
});
|
||||
} finally {
|
||||
setLoadingMore({ loading: false });
|
||||
}
|
||||
};
|
||||
setCurrentPage({ page: nextPage });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount(data.count);
|
||||
} else {
|
||||
setSearchError({ error: `Load more failed: ${response.status}` });
|
||||
}
|
||||
} catch (err) {
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Load more failed",
|
||||
});
|
||||
} finally {
|
||||
setLoadingMore({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults({ results: [] });
|
||||
setSearchError({ error: null });
|
||||
setLastSearchQuery({ query: "" });
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults({ results: [] });
|
||||
setSearchError({ error: null });
|
||||
setLastSearchQuery({ query: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (query === lastSearchQuery && searchResults.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (query === lastSearchQuery && searchResults.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ignore = false;
|
||||
let ignore = false;
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
setSearching({ searching: true });
|
||||
setSearchError({ error: null });
|
||||
resetPagination();
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
setSearching({ searching: true });
|
||||
setSearchError({ error: null });
|
||||
resetPagination();
|
||||
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
|
||||
);
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
|
||||
);
|
||||
|
||||
if (!ignore) {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSearchResults({ results: data.results });
|
||||
setLastSearchQuery({ query: query });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount({ count: data.count });
|
||||
setCurrentPage({ page: 1 });
|
||||
} else {
|
||||
setSearchError({ error: `Search failed: ${response.status}` });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!ignore) {
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Search failed",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setSearching({ searching: false });
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
if (!ignore) {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSearchResults({ results: data.results });
|
||||
setLastSearchQuery({ query: query });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount({ count: data.count });
|
||||
setCurrentPage({ page: 1 });
|
||||
} else {
|
||||
setSearchError({ error: `Search failed: ${response.status}` });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!ignore) {
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Search failed",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setSearching({ searching: false });
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
ignore = true;
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
lastSearchQuery,
|
||||
searchResults.length,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
resetPagination,
|
||||
]);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
ignore = true;
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
lastSearchQuery,
|
||||
searchResults.length,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
resetPagination,
|
||||
]);
|
||||
|
||||
return {
|
||||
results: searchResults,
|
||||
isLoading: isSearching,
|
||||
error: searchError,
|
||||
loadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
};
|
||||
return {
|
||||
results: searchResults,
|
||||
isLoading: isSearching,
|
||||
error: searchError,
|
||||
loadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user