lots of stuff

This commit is contained in:
Maze Winther
2026-01-23 15:40:01 +01:00
parent b1701e1b0e
commit c6deceaa89
292 changed files with 28825 additions and 26457 deletions
@@ -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]);
}
+240 -240
View File
@@ -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,
]);
}
+110 -110
View File
@@ -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,
]);
}
+176 -176
View File
@@ -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,
};
}
+151 -151
View File
@@ -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,
};
}
+31 -15
View File
@@ -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;
}
+75 -75
View File
@@ -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,
},
};
}
+23 -23
View File
@@ -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 };
}
+43 -43
View File
@@ -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;
}
+19
View File
@@ -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;
}
+14 -14
View File
@@ -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]);
}
+24 -24
View File
@@ -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 };
}
+131 -131
View File
@@ -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,
};
}