mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
stuff
This commit is contained in:
@@ -3,12 +3,12 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/use-element-selection";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import type {
|
||||
@@ -44,7 +44,7 @@ export function useElementInteraction({
|
||||
onSnapPointChange,
|
||||
}: UseElementInteractionProps) {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.sortedTracks;
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const {
|
||||
isSelected,
|
||||
select,
|
||||
@@ -81,13 +81,13 @@ export function useElementInteraction({
|
||||
setDragState(initialDragState);
|
||||
}, []);
|
||||
|
||||
// Mouse move: update drag time
|
||||
// mouse move: update drag time
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const handleMouseMove = ({ clientX }: MouseEvent) => {
|
||||
if (!timelineRef.current) return;
|
||||
lastMouseXRef.current = e.clientX;
|
||||
lastMouseXRef.current = clientX;
|
||||
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const alreadySelected = isSelected({
|
||||
@@ -103,16 +103,21 @@ export function useElementInteraction({
|
||||
}
|
||||
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseX = clientX - rect.left;
|
||||
const mouseTime = Math.max(
|
||||
0,
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
|
||||
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
const fps = activeProject.settings.fps;
|
||||
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps });
|
||||
setDragState((prev) => ({ ...prev, currentTime: snappedTime }));
|
||||
setDragState((previousDragState) => ({
|
||||
...previousDragState,
|
||||
currentTime: snappedTime,
|
||||
}));
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
@@ -129,11 +134,11 @@ export function useElementInteraction({
|
||||
timelineRef,
|
||||
]);
|
||||
|
||||
// Mouse up: resolve drop
|
||||
// mouse up: resolve drop
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
|
||||
if (!dragState.elementId || !dragState.trackId) return;
|
||||
|
||||
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
|
||||
@@ -143,9 +148,14 @@ export function useElementInteraction({
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
|
||||
if (!sourceTrack) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(el) => el.id === dragState.elementId,
|
||||
({ id }) => id === dragState.elementId,
|
||||
);
|
||||
|
||||
if (!movingElement) {
|
||||
@@ -158,8 +168,8 @@ export function useElementInteraction({
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const mouseX = e.clientX - containerRect.left;
|
||||
const mouseY = e.clientY - containerRect.top;
|
||||
const mouseX = clientX - containerRect.left;
|
||||
const mouseY = clientY - containerRect.top;
|
||||
|
||||
const dropTarget = computeDropTarget({
|
||||
elementType: movingElement.type,
|
||||
@@ -173,12 +183,18 @@ export function useElementInteraction({
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
const fps = activeProject.settings.fps;
|
||||
const snappedTime = snapTimeToFrame({ time: dropTarget.xPosition, fps });
|
||||
|
||||
if (dropTarget.isNewTrack) {
|
||||
const newTrackId = editor.timeline.addTrack({
|
||||
type: sourceTrack!.type,
|
||||
type: sourceTrack.type,
|
||||
index: dropTarget.trackIndex,
|
||||
});
|
||||
editor.timeline.moveElement({
|
||||
@@ -221,20 +237,20 @@ export function useElementInteraction({
|
||||
|
||||
const handleElementMouseDown = useCallback(
|
||||
({
|
||||
e,
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
mouseDownLocationRef.current = { x: e.clientX, y: e.clientY };
|
||||
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const isRightClick = e.button === 2;
|
||||
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||
const isRightClick = event.button === 2;
|
||||
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
|
||||
|
||||
// right-click: select if not already selected
|
||||
// right-click
|
||||
if (isRightClick) {
|
||||
const alreadySelected = isSelected({
|
||||
trackId: track.id,
|
||||
@@ -244,7 +260,7 @@ export function useElementInteraction({
|
||||
handleSelectionClick({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
isMultiKey: isMultiSelect,
|
||||
isMultiKey: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -261,16 +277,16 @@ export function useElementInteraction({
|
||||
|
||||
// start drag
|
||||
const elementRect = (
|
||||
e.currentTarget as HTMLElement
|
||||
event.currentTarget as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - elementRect.left;
|
||||
const clickOffsetX = event.clientX - elementRect.left;
|
||||
const clickOffsetTime =
|
||||
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
|
||||
startDrag({
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseX: event.clientX,
|
||||
startElementTime: element.startTime,
|
||||
clickOffsetTime,
|
||||
});
|
||||
@@ -280,20 +296,20 @@ export function useElementInteraction({
|
||||
|
||||
const handleElementClick = useCallback(
|
||||
({
|
||||
e,
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
e.stopPropagation();
|
||||
event.stopPropagation();
|
||||
|
||||
// was it a drag or a click?
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(e.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseDownLocationRef.current.y);
|
||||
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
mouseDownLocationRef.current = null;
|
||||
return;
|
||||
@@ -301,7 +317,7 @@ export function useElementInteraction({
|
||||
}
|
||||
|
||||
// modifier keys already handled in mousedown
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
// single click: select if not selected
|
||||
const alreadySelected = isSelected({
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { EditorCore } from "@/core";
|
||||
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
|
||||
import { UpdateElementStartTimeCommand } from "@/lib/commands/timeline/element/update-element-start-time";
|
||||
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
|
||||
export interface ResizeState {
|
||||
elementId: string;
|
||||
@@ -29,21 +27,20 @@ export function useTimelineElementResize({
|
||||
track,
|
||||
zoomLevel,
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
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 editor = EditorCore.getInstance();
|
||||
const mediaFiles = editor.media.getMediaFiles();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleDocumentMouseMove = (e: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX: e.clientX });
|
||||
const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX });
|
||||
};
|
||||
|
||||
const handleDocumentMouseUp = () => {
|
||||
@@ -88,23 +85,10 @@ export function useTimelineElementResize({
|
||||
};
|
||||
|
||||
const canExtendElementDuration = () => {
|
||||
if (element.type === "text") {
|
||||
if (element.type === "text" || element.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.type === "media") {
|
||||
const mediaFile = mediaFiles.find(
|
||||
(file: MediaFile) => file.id === element.mediaId,
|
||||
);
|
||||
if (!mediaFile) return false;
|
||||
|
||||
if (mediaFile.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -114,14 +98,17 @@ export function useTimelineElementResize({
|
||||
const deltaX = clientX - resizing.startX;
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
const projectFps = activeProject.settings.fps;
|
||||
|
||||
if (resizing.side === "left") {
|
||||
const maxAllowed =
|
||||
resizing.initialDuration - resizing.initialTrimEnd - 0.1;
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const maxAllowed = sourceDuration - resizing.initialTrimEnd - 0.1;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
|
||||
if (calculated >= 0) {
|
||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
fps: projectFps,
|
||||
@@ -131,10 +118,15 @@ export function useTimelineElementResize({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
} else {
|
||||
setCurrentDuration(newDuration);
|
||||
} else if (calculated < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = resizing.initialStartTime;
|
||||
@@ -152,59 +144,63 @@ export function useTimelineElementResize({
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
} else {
|
||||
const newTrimStart = 0;
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const trimDelta = 0 - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const calculated = resizing.initialTrimEnd - deltaTime;
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
|
||||
|
||||
if (calculated < 0) {
|
||||
if (newTrimEnd < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionNeeded = Math.abs(calculated);
|
||||
const extensionNeeded = Math.abs(newTrimEnd);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + extensionNeeded,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newTrimEnd = 0;
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(newTrimEnd);
|
||||
setCurrentTrimEnd(0);
|
||||
} else {
|
||||
const extensionToLimit = resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + extensionToLimit,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
}
|
||||
} else {
|
||||
const currentEndTime =
|
||||
resizing.initialStartTime +
|
||||
resizing.initialDuration -
|
||||
resizing.initialTrimStart -
|
||||
resizing.initialTrimEnd;
|
||||
const desiredEndTime = currentEndTime + deltaTime;
|
||||
|
||||
const snappedEndTime = snapTimeToFrame({
|
||||
time: desiredEndTime,
|
||||
const maxTrimEnd = sourceDuration - resizing.initialTrimStart - 0.1;
|
||||
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,
|
||||
});
|
||||
|
||||
const newTrimEnd = Math.max(
|
||||
0,
|
||||
resizing.initialDuration -
|
||||
resizing.initialTrimStart -
|
||||
(snappedEndTime - resizing.initialStartTime),
|
||||
);
|
||||
|
||||
const maxTrimEnd =
|
||||
resizing.initialDuration - resizing.initialTrimStart - 0.1;
|
||||
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
|
||||
|
||||
setCurrentTrimEnd(finalTrimEnd);
|
||||
setCurrentDuration(newDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -212,8 +208,6 @@ export function useTimelineElementResize({
|
||||
const handleResizeEnd = () => {
|
||||
if (!resizing) return;
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const trimStartChanged = currentTrimStart !== resizing.initialTrimStart;
|
||||
const trimEndChanged = currentTrimEnd !== resizing.initialTrimEnd;
|
||||
const startTimeChanged = currentStartTime !== resizing.initialStartTime;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
interface SnapIndicatorPosition {
|
||||
leftPosition: number;
|
||||
topPosition: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useSnapIndicatorPosition({
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
|
||||
if (!tracksViewport) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
};
|
||||
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
|
||||
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 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;
|
||||
|
||||
return {
|
||||
leftPosition,
|
||||
topPosition: 0,
|
||||
height: totalHeight,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState, useCallback, type RefObject } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { processMediaAssets } from "@/lib/media-processing-utils";
|
||||
import { toast } from "sonner";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { buildTextElement } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
buildTextElement,
|
||||
buildStickerElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getAssetDragData, hasAssetDragData } from "@/lib/asset-drag";
|
||||
import type { TrackType, DropTarget } from "@/types/timeline";
|
||||
import type { MediaAssetDragData } from "@/types/assets";
|
||||
|
||||
type DragElementType = "video" | "image" | "audio" | "text";
|
||||
import { getDragData, hasDragData } from "@/lib/drag-data";
|
||||
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
|
||||
import type { MediaDragData, StickerDragData } from "@/types/drag";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
@@ -27,34 +27,30 @@ export function useTimelineDragDrop({
|
||||
const editor = useEditor();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [dragElementType, setDragElementType] =
|
||||
useState<DragElementType | null>(null);
|
||||
const [dragElementType, setElementType] = useState<ElementType | null>(null);
|
||||
|
||||
const tracks = editor.timeline.sortedTracks;
|
||||
const currentTime = editor.playback.currentTime;
|
||||
const mediaFiles = editor.media.mediaFiles;
|
||||
const activeProject = editor.project.activeProject;
|
||||
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?.fps ?? DEFAULT_FPS;
|
||||
const projectFps = activeProject.settings.fps;
|
||||
return snapTimeToFrame({ time, fps: projectFps });
|
||||
},
|
||||
[activeProject?.fps],
|
||||
[activeProject.settings.fps],
|
||||
);
|
||||
|
||||
const getDragElementType = useCallback(
|
||||
({
|
||||
dataTransfer,
|
||||
}: {
|
||||
dataTransfer: DataTransfer;
|
||||
}): DragElementType | null => {
|
||||
const dragData = getAssetDragData({ dataTransfer });
|
||||
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 as DragElementType;
|
||||
return dragData.mediaType as ElementType;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -66,24 +62,24 @@ export function useTimelineDragDrop({
|
||||
elementType,
|
||||
mediaId,
|
||||
}: {
|
||||
elementType: DragElementType;
|
||||
elementType: ElementType;
|
||||
mediaId?: string;
|
||||
}): number => {
|
||||
if (elementType === "text") {
|
||||
if (elementType === "text" || elementType === "sticker") {
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
if (mediaId) {
|
||||
const media = mediaFiles.find((m) => m.id === mediaId);
|
||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
||||
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
},
|
||||
[mediaFiles],
|
||||
[mediaAssets],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
setIsDragOver(true);
|
||||
@@ -98,9 +94,9 @@ export function useTimelineDragDrop({
|
||||
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
const isExternal =
|
||||
hasFiles && !hasAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
|
||||
|
||||
let elementType = getDragElementType({ dataTransfer: e.dataTransfer });
|
||||
let elementType = getElementType({ dataTransfer: e.dataTransfer });
|
||||
|
||||
// external drops default to video until determined on drop
|
||||
if (!elementType && hasFiles) {
|
||||
@@ -109,9 +105,9 @@ export function useTimelineDragDrop({
|
||||
|
||||
if (!elementType) return;
|
||||
|
||||
setDragElementType(elementType);
|
||||
setElementType(elementType);
|
||||
|
||||
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
||||
const duration = getElementDuration({
|
||||
elementType,
|
||||
mediaId: dragData?.type === "media" ? dragData.id : undefined,
|
||||
@@ -142,7 +138,7 @@ export function useTimelineDragDrop({
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
getDragElementType,
|
||||
getElementType,
|
||||
getElementDuration,
|
||||
getSnappedTime,
|
||||
],
|
||||
@@ -162,7 +158,7 @@ export function useTimelineDragDrop({
|
||||
) {
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setDragElementType(null);
|
||||
setElementType(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -192,9 +188,7 @@ export function useTimelineDragDrop({
|
||||
|
||||
const element = buildTextElement({
|
||||
raw: {
|
||||
id: "",
|
||||
name: dragData.name ?? "",
|
||||
type: "text",
|
||||
content: dragData.content ?? "",
|
||||
},
|
||||
startTime: target.xPosition,
|
||||
@@ -205,19 +199,44 @@ export function useTimelineDragDrop({
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeMediaDrop = useCallback(
|
||||
const executeStickerDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: MediaAssetDragData;
|
||||
dragData: StickerDragData;
|
||||
}) => {
|
||||
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
|
||||
if (!mediaItem) return;
|
||||
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;
|
||||
}
|
||||
|
||||
const element = buildStickerElement({
|
||||
iconName: dragData.iconName,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
editor.timeline.addElementToTrack({ 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 trackType: TrackType =
|
||||
dragData.mediaType === "audio" ? "audio" : "media";
|
||||
dragData.mediaType === "audio" ? "audio" : "video";
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
@@ -232,15 +251,16 @@ export function useTimelineDragDrop({
|
||||
}
|
||||
|
||||
const duration =
|
||||
mediaItem.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
if (dragData.mediaType === "audio") {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId,
|
||||
element: {
|
||||
type: "audio",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
sourceType: "upload",
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
@@ -255,12 +275,21 @@ export function useTimelineDragDrop({
|
||||
trackId,
|
||||
element: {
|
||||
type: "video",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
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 {
|
||||
@@ -268,38 +297,47 @@ export function useTimelineDragDrop({
|
||||
trackId,
|
||||
element: {
|
||||
type: "image",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
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, mediaFiles, tracks],
|
||||
[editor.timeline, mediaAssets, tracks],
|
||||
);
|
||||
|
||||
const executeFileDrop = useCallback(
|
||||
async ({ files }: { files: File[] }) => {
|
||||
if (!activeProject) return;
|
||||
|
||||
const processedItems = await processMediaFiles({ files });
|
||||
const processedAssets = await processMediaAssets({ files });
|
||||
|
||||
for (const item of processedItems) {
|
||||
await editor.media.addMediaFile({
|
||||
projectId: activeProject.id,
|
||||
file: item,
|
||||
for (const asset of processedAssets) {
|
||||
await editor.media.addMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
asset,
|
||||
});
|
||||
|
||||
const added = editor.media.mediaFiles.find(
|
||||
(m) => m.name === item.name && m.url === item.url,
|
||||
);
|
||||
const added = editor.media
|
||||
.getAssets()
|
||||
.find((m) => m.name === asset.name && m.url === asset.url);
|
||||
|
||||
if (added) {
|
||||
const trackType: TrackType =
|
||||
added.type === "audio" ? "audio" : "media";
|
||||
added.type === "audio" ? "audio" : "video";
|
||||
const trackId = editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: 0,
|
||||
@@ -313,6 +351,7 @@ export function useTimelineDragDrop({
|
||||
trackId,
|
||||
element: {
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
@@ -335,6 +374,15 @@ export function useTimelineDragDrop({
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -348,6 +396,15 @@ export function useTimelineDragDrop({
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -364,22 +421,24 @@ export function useTimelineDragDrop({
|
||||
const currentTarget = dropTarget;
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setDragElementType(null);
|
||||
setElementType(null);
|
||||
|
||||
if (!currentTarget) return;
|
||||
|
||||
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
|
||||
try {
|
||||
if (hasAsset) {
|
||||
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
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 });
|
||||
}
|
||||
@@ -391,7 +450,13 @@ export function useTimelineDragDrop({
|
||||
toast.error("Failed to process drop");
|
||||
}
|
||||
},
|
||||
[dropTarget, executeTextDrop, executeMediaDrop, executeFileDrop],
|
||||
[
|
||||
dropTarget,
|
||||
executeTextDrop,
|
||||
executeStickerDrop,
|
||||
executeMediaDrop,
|
||||
executeFileDrop,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import type { RefObject } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseTimelineInteractionsProps {
|
||||
playheadRef: RefObject<HTMLDivElement>;
|
||||
tracksContainerRef: RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement>;
|
||||
zoomLevel: number;
|
||||
@@ -19,7 +18,6 @@ interface UseTimelineInteractionsProps {
|
||||
|
||||
export function useTimelineInteractions({
|
||||
playheadRef,
|
||||
tracksContainerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
@@ -29,7 +27,9 @@ export function useTimelineInteractions({
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineInteractionsProps) {
|
||||
const { activeProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
@@ -111,7 +111,7 @@ export function useTimelineInteractions({
|
||||
),
|
||||
);
|
||||
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
const projectFps = activeProject?.settings.fps || 30;
|
||||
const time = snapTimeToFrame({ time: rawTime, fps: projectFps });
|
||||
seek(time);
|
||||
},
|
||||
@@ -121,7 +121,7 @@ export function useTimelineInteractions({
|
||||
seek,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
activeProject?.fps,
|
||||
activeProject?.settings.fps,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
|
||||
import { useEditor } from "../use-editor";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -17,15 +13,18 @@ interface UseTimelinePlayheadProps {
|
||||
}
|
||||
|
||||
export function useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
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();
|
||||
|
||||
// Playhead scrubbing state
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
@@ -38,62 +37,66 @@ export function useTimelinePlayhead({
|
||||
const playheadPosition =
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
|
||||
// --- Playhead Scrubbing Handlers ---
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ruler drag from triggering
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // prevent ruler drag from triggering
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
handleScrub(event);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
|
||||
// Ruler mouse down handler
|
||||
const handleRulerMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only handle left mouse button
|
||||
if (e.button !== 0) return;
|
||||
(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(e.target as Node)) return;
|
||||
// don't interfere if clicking on the playhead itself
|
||||
if (playheadRef?.current?.contains(event.target as Node)) return;
|
||||
|
||||
e.preventDefault();
|
||||
event.preventDefault();
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
// Start scrubbing immediately
|
||||
// start scrubbing immediately
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
handleScrub(event);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
|
||||
const handleScrub = useCallback(
|
||||
(e: MouseEvent | React.MouseEvent) => {
|
||||
(event: MouseEvent | React.MouseEvent) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rect = ruler.getBoundingClientRect();
|
||||
const rawX = e.clientX - rect.left;
|
||||
const rawX = event.clientX - rect.left;
|
||||
|
||||
// Get the timeline content width based on duration and zoom
|
||||
const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
|
||||
// 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
|
||||
// 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 / (50 * zoomLevel)));
|
||||
// Use frame snapping for playhead scrubbing
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
const time = snapTimeToFrame({ time: rawTime, fps: projectFps });
|
||||
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 = snapTimeToFrame({ time: rawTime, fps });
|
||||
|
||||
// Debug logging
|
||||
// debug logging
|
||||
if (rawX < 0 || x !== rawX) {
|
||||
console.log(
|
||||
"PLAYHEAD DEBUG:",
|
||||
JSON.stringify({
|
||||
mouseX: e.clientX,
|
||||
mouseX: event.clientX,
|
||||
rulerLeft: rect.left,
|
||||
rawX,
|
||||
constrainedX: x,
|
||||
@@ -102,7 +105,7 @@ export function useTimelinePlayhead({
|
||||
finalTime: time,
|
||||
duration,
|
||||
zoomLevel,
|
||||
playheadPx: time * 50 * zoomLevel,
|
||||
playheadPx: time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -110,10 +113,10 @@ export function useTimelinePlayhead({
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
|
||||
// Store mouse position for auto-scrolling
|
||||
lastMouseXRef.current = e.clientX;
|
||||
// store mouse position for auto-scrolling
|
||||
lastMouseXRef.current = event.clientX;
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef],
|
||||
[duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
|
||||
);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
@@ -121,32 +124,31 @@ export function useTimelinePlayhead({
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * 50 * zoomLevel,
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
// Mouse move/up event handlers
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
handleScrub(e);
|
||||
// Mark that we've dragged if ruler drag is active
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
handleScrub(event);
|
||||
// mark that we've dragged if ruler drag is active
|
||||
if (isDraggingRuler) {
|
||||
setHasDraggedRuler(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
const onMouseUp = (event: MouseEvent) => {
|
||||
setIsScrubbing(false);
|
||||
if (scrubTime !== null) seek(scrubTime); // finalize seek
|
||||
setScrubTime(null);
|
||||
|
||||
// Handle ruler click vs drag
|
||||
// handle ruler click vs drag
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
// If we didn't drag, treat it as a click-to-seek
|
||||
// if we didn't drag, treat it as a click-to-seek
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub(e);
|
||||
handleScrub(event);
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
@@ -155,12 +157,9 @@ export function useTimelinePlayhead({
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
// Edge auto-scroll is handled by useEdgeAutoScroll
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
// nothing to cleanup for edge auto scroll
|
||||
};
|
||||
}, [
|
||||
isScrubbing,
|
||||
@@ -172,29 +171,27 @@ export function useTimelinePlayhead({
|
||||
// edge auto scroll hook is independent
|
||||
]);
|
||||
|
||||
// --- Playhead auto-scroll effect (only during playback) ---
|
||||
useEffect(() => {
|
||||
const { isPlaying } = usePlaybackStore.getState();
|
||||
|
||||
// Only auto-scroll during playback, not during manual interactions
|
||||
if (!isPlaying || isScrubbing) return;
|
||||
// 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 playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
|
||||
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)
|
||||
// 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
|
||||
// center the playhead in the viewport
|
||||
const desiredScroll = Math.max(
|
||||
scrollMin,
|
||||
Math.min(scrollMax, playheadPx - viewportWidth / 2),
|
||||
|
||||
@@ -44,9 +44,7 @@ export function useTimelineSnapping({
|
||||
if (element.id === excludeElementId) return;
|
||||
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
const elementEnd = element.startTime + element.duration;
|
||||
|
||||
snapPoints.push(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useState, useCallback, useEffect, RefObject } from "react";
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type RefObject,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
@@ -8,7 +15,7 @@ interface UseTimelineZoomProps {
|
||||
interface UseTimelineZoomReturn {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleWheel: (event: ReactWheelEvent) => void;
|
||||
}
|
||||
|
||||
export function useTimelineZoom({
|
||||
@@ -17,31 +24,46 @@ export function useTimelineZoom({
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
||||
const handleWheel = useCallback((event: ReactWheelEvent) => {
|
||||
const isZoomGesture = event.ctrlKey || event.metaKey;
|
||||
const isHorizontalScrollGesture =
|
||||
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
||||
|
||||
// allow scrollarea to handle horizontal scroll
|
||||
if (isHorizontalScrollGesture) {
|
||||
return;
|
||||
}
|
||||
// For horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
// Don't prevent default - let ScrollArea handle horizontal scrolling
|
||||
|
||||
// pinch-zoom (ctrl/meta + wheel)
|
||||
if (isZoomGesture) {
|
||||
event.preventDefault();
|
||||
const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
|
||||
setZoomLevel((prev) =>
|
||||
Math.max(
|
||||
TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
|
||||
),
|
||||
);
|
||||
// for horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
return;
|
||||
}
|
||||
// Otherwise, allow normal scrolling
|
||||
}, []);
|
||||
|
||||
// Prevent browser zooming in/out when in timeline
|
||||
// prevent browser zoom in the timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (e: WheelEvent) => {
|
||||
const preventZoom = ({
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
target,
|
||||
preventDefault,
|
||||
}: WheelEvent) => {
|
||||
if (
|
||||
isInTimeline &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
containerRef.current?.contains(e.target as Node)
|
||||
(ctrlKey || metaKey) &&
|
||||
containerRef.current?.contains(target as Node)
|
||||
) {
|
||||
e.preventDefault();
|
||||
preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
TAction,
|
||||
TActionFunc,
|
||||
TActionHandlerOptions,
|
||||
TInvocationTrigger,
|
||||
bindAction,
|
||||
unbindAction,
|
||||
} from "@/lib/actions";
|
||||
|
||||
export function useActionHandler<A extends TAction>(
|
||||
action: A,
|
||||
handler: TActionFunc<A>,
|
||||
isActive: TActionHandlerOptions,
|
||||
) {
|
||||
const handlerRef = useRef(handler);
|
||||
const [isBound, setIsBound] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
const stableHandler = useCallback(
|
||||
(args: any, trigger?: TInvocationTrigger) => {
|
||||
(handlerRef.current as any)(args, trigger);
|
||||
},
|
||||
[],
|
||||
) as TActionFunc<A>;
|
||||
|
||||
useEffect(() => {
|
||||
const shouldBind =
|
||||
isActive === undefined ||
|
||||
(typeof isActive === "boolean" ? isActive : isActive.current);
|
||||
|
||||
if (shouldBind && !isBound) {
|
||||
bindAction(action, stableHandler);
|
||||
setIsBound(true);
|
||||
} else if (!shouldBind && isBound) {
|
||||
unbindAction(action, stableHandler);
|
||||
setIsBound(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (isBound) {
|
||||
unbindAction(action, stableHandler);
|
||||
setIsBound(false);
|
||||
}
|
||||
};
|
||||
}, [action, stableHandler, isActive, isBound]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && typeof isActive === "object" && "current" in isActive) {
|
||||
const interval = setInterval(() => {
|
||||
const shouldBind = isActive.current;
|
||||
if (shouldBind !== isBound) {
|
||||
if (shouldBind) {
|
||||
bindAction(action, stableHandler);
|
||||
} else {
|
||||
unbindAction(action, stableHandler);
|
||||
}
|
||||
setIsBound(shouldBind);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [action, stableHandler, isActive, isBound]);
|
||||
}
|
||||
@@ -1,33 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { useActionHandler } from "@/hooks/use-action-handler";
|
||||
import { useEditor } from "./use-editor";
|
||||
import { PasteCommand } from "@/lib/commands/timeline/clipboard/paste";
|
||||
import { toast } from "sonner";
|
||||
import { useActionHandler } from "@/constants/action-constants";
|
||||
|
||||
export function useEditorActions() {
|
||||
const {
|
||||
tracks,
|
||||
selectedElements,
|
||||
setSelectedElements,
|
||||
deleteSelected,
|
||||
splitSelected,
|
||||
addElementToTrack,
|
||||
toggleSnapping,
|
||||
undo,
|
||||
redo,
|
||||
} = useTimelineStore();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const selectedElements = timelineStore.selectedElements;
|
||||
|
||||
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
// Playback actions
|
||||
useActionHandler(
|
||||
"toggle-play",
|
||||
() => {
|
||||
toggle();
|
||||
editor.playback.toggle();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -35,10 +23,10 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"stop-playback",
|
||||
() => {
|
||||
if (isPlaying) {
|
||||
toggle();
|
||||
if (editor.playback.getIsPlaying()) {
|
||||
editor.playback.toggle();
|
||||
}
|
||||
seek(0);
|
||||
editor.playback.seek({ time: 0 });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -47,7 +35,12 @@ export function useEditorActions() {
|
||||
"seek-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
seek(Math.min(duration, currentTime + seconds));
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -56,7 +49,9 @@ export function useEditorActions() {
|
||||
"seek-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
seek(Math.max(0, currentTime - seconds));
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -64,8 +59,13 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"frame-step-forward",
|
||||
() => {
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
seek(Math.min(duration, currentTime + 1 / projectFps));
|
||||
const fps = activeProject.settings.fps;
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + 1 / fps,
|
||||
),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -73,8 +73,10 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"frame-step-backward",
|
||||
() => {
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
seek(Math.max(0, currentTime - 1 / projectFps));
|
||||
const fps = activeProject.settings.fps;
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -83,7 +85,12 @@ export function useEditorActions() {
|
||||
"jump-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
seek(Math.min(duration, currentTime + seconds));
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -92,7 +99,9 @@ export function useEditorActions() {
|
||||
"jump-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
seek(Math.max(0, currentTime - seconds));
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -100,7 +109,7 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"goto-start",
|
||||
() => {
|
||||
seek(0);
|
||||
editor.playback.seek({ time: 0 });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -108,35 +117,21 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"goto-end",
|
||||
() => {
|
||||
seek(duration);
|
||||
editor.playback.seek({ time: editor.timeline.getTotalDuration() });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Timeline editing actions
|
||||
useActionHandler(
|
||||
"split-element",
|
||||
"split-selected",
|
||||
() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to split");
|
||||
return;
|
||||
}
|
||||
const splitElementIds = editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
});
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t: any) => t.id === trackId);
|
||||
const element = track?.elements.find((el: any) => el.id === elementId);
|
||||
|
||||
if (element) {
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
||||
splitSelected(currentTime, trackId, elementId);
|
||||
} else {
|
||||
toast.error("Playhead must be within selected element");
|
||||
}
|
||||
if (splitElementIds.length === 0) {
|
||||
toast.error("Playhead must be positioned over the selected element(s)");
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
@@ -148,7 +143,9 @@ export function useEditorActions() {
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
deleteSelected();
|
||||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -156,13 +153,13 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"select-all",
|
||||
() => {
|
||||
const allElements = tracks.flatMap((track: any) =>
|
||||
track.elements.map((element: any) => ({
|
||||
const allElements = editor.timeline.getTracks().flatMap((track) =>
|
||||
track.elements.map((element) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
})),
|
||||
);
|
||||
setSelectedElements(allElements);
|
||||
timelineStore.setSelectedElements({ elements: allElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -170,27 +167,31 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"duplicate-selected",
|
||||
() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to duplicate");
|
||||
return;
|
||||
}
|
||||
editor.timeline.duplicateElements({ elements: selectedElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t: any) => t.id === trackId);
|
||||
const element = track?.elements.find((el: any) => el.id === elementId);
|
||||
useActionHandler(
|
||||
"toggle-mute-selected",
|
||||
() => {
|
||||
editor.timeline.toggleElementsMuted({ elements: selectedElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (element) {
|
||||
const newStartTime =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1;
|
||||
const { id, ...elementWithoutId } = element;
|
||||
useActionHandler(
|
||||
"toggle-visibility-selected",
|
||||
() => {
|
||||
editor.timeline.toggleElementsVisibility({ elements: selectedElements });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
addElementToTrack(trackId, {
|
||||
...elementWithoutId,
|
||||
startTime: newStartTime,
|
||||
});
|
||||
}
|
||||
useActionHandler(
|
||||
"toggle-bookmark",
|
||||
() => {
|
||||
editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -199,7 +200,19 @@ export function useEditorActions() {
|
||||
"copy-selected",
|
||||
() => {
|
||||
if (selectedElements.length === 0) return;
|
||||
useTimelineStore.getState().copySelected();
|
||||
|
||||
const results = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
});
|
||||
const items = results.map(({ track, element }) => {
|
||||
const { id, ...elementWithoutId } = element;
|
||||
return {
|
||||
trackType: track.type,
|
||||
element: elementWithoutId,
|
||||
};
|
||||
});
|
||||
|
||||
timelineStore.setClipboard({ items });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -207,7 +220,13 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"paste-selected",
|
||||
() => {
|
||||
useTimelineStore.getState().pasteAtTime(currentTime);
|
||||
const clipboard = timelineStore.clipboard;
|
||||
if (!clipboard?.items.length) return;
|
||||
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
editor.command.execute({
|
||||
command: new PasteCommand(currentTime, clipboard.items),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -215,16 +234,15 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"toggle-snapping",
|
||||
() => {
|
||||
toggleSnapping();
|
||||
timelineStore.toggleSnapping();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// History actions
|
||||
useActionHandler(
|
||||
"undo",
|
||||
() => {
|
||||
undo();
|
||||
editor.command.undo();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -232,7 +250,7 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"redo",
|
||||
() => {
|
||||
redo();
|
||||
editor.command.redo();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ export function useEditor(): EditorCore {
|
||||
const unsubscribers = [
|
||||
editor.playback.subscribe(forceUpdate),
|
||||
editor.timeline.subscribe(forceUpdate),
|
||||
editor.scene.subscribe(forceUpdate),
|
||||
editor.scenes.subscribe(forceUpdate),
|
||||
editor.project.subscribe(forceUpdate),
|
||||
editor.media.subscribe(forceUpdate),
|
||||
editor.renderer.subscribe(forceUpdate),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { hasAssetDragData } from "@/lib/asset-drag";
|
||||
import { hasDragData } from "@/lib/drag-data";
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
accept?: string;
|
||||
@@ -8,9 +8,7 @@ interface UseFileUploadOptions {
|
||||
}
|
||||
|
||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
return (
|
||||
!hasAssetDragData({ dataTransfer }) && dataTransfer.types.includes("Files")
|
||||
);
|
||||
return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files");
|
||||
}
|
||||
|
||||
export function useFileUpload({
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { ActionWithOptionalArgs } from "@/constants/actions-constants";
|
||||
|
||||
export interface KeybindingConflictInfo {
|
||||
key: string;
|
||||
actions: ActionWithOptionalArgs[];
|
||||
isConflict: boolean;
|
||||
}
|
||||
|
||||
export const useKeybindingConflicts = () => {
|
||||
const { keybindings } = useKeybindingsStore();
|
||||
|
||||
const conflicts = useMemo(() => {
|
||||
const keyToActions: Record<string, ActionWithOptionalArgs[]> = {};
|
||||
const conflictList: KeybindingConflictInfo[] = [];
|
||||
|
||||
// Group actions by key
|
||||
Object.entries(keybindings).forEach(([key, action]) => {
|
||||
if (!keyToActions[key]) {
|
||||
keyToActions[key] = [];
|
||||
}
|
||||
keyToActions[key].push(action);
|
||||
});
|
||||
|
||||
// Find conflicts
|
||||
Object.entries(keyToActions).forEach(([key, actions]) => {
|
||||
const uniqueActions = [...new Set(actions)];
|
||||
conflictList.push({
|
||||
key,
|
||||
actions: uniqueActions,
|
||||
isConflict: uniqueActions.length > 1,
|
||||
});
|
||||
});
|
||||
|
||||
return conflictList.filter((item) => item.isConflict);
|
||||
}, [keybindings]);
|
||||
|
||||
const hasConflicts = conflicts.length > 0;
|
||||
|
||||
const getConflictsForKey = (key: string): KeybindingConflictInfo | null => {
|
||||
return conflicts.find((conflict) => conflict.key === key) || null;
|
||||
};
|
||||
|
||||
const getConflictsForAction = (
|
||||
action: ActionWithOptionalArgs,
|
||||
): KeybindingConflictInfo[] => {
|
||||
return conflicts.filter((conflict) => conflict.actions.includes(action));
|
||||
};
|
||||
|
||||
return {
|
||||
conflicts,
|
||||
hasConflicts,
|
||||
getConflictsForKey,
|
||||
getConflictsForAction,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { invokeAction } from "../constants/actions-constants";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,77 +2,21 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { Action } from "@/constants/action-constants";
|
||||
import { getPlatformAlternateKey, getPlatformSpecialKey } from "@/lib/keyboard-utils";
|
||||
import { ACTIONS, type TAction } from "@/lib/actions";
|
||||
import {
|
||||
getPlatformAlternateKey,
|
||||
getPlatformSpecialKey,
|
||||
} from "@/lib/keyboard-utils";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string;
|
||||
keys: string[];
|
||||
description: string;
|
||||
category: string;
|
||||
action: Action;
|
||||
action: TAction;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Map actions to their descriptions and categories
|
||||
const actionDescriptions: Record<
|
||||
Action,
|
||||
{ description: string; category: string }
|
||||
> = {
|
||||
"toggle-play": { description: "Play/Pause", category: "Playback" },
|
||||
"stop-playback": { description: "Stop playback", category: "Playback" },
|
||||
"seek-forward": {
|
||||
description: "Seek forward 1 second",
|
||||
category: "Playback",
|
||||
},
|
||||
"seek-backward": {
|
||||
description: "Seek backward 1 second",
|
||||
category: "Playback",
|
||||
},
|
||||
"frame-step-forward": {
|
||||
description: "Frame step forward",
|
||||
category: "Navigation",
|
||||
},
|
||||
"frame-step-backward": {
|
||||
description: "Frame step backward",
|
||||
category: "Navigation",
|
||||
},
|
||||
"jump-forward": {
|
||||
description: "Jump forward 5 seconds",
|
||||
category: "Navigation",
|
||||
},
|
||||
"jump-backward": {
|
||||
description: "Jump backward 5 seconds",
|
||||
category: "Navigation",
|
||||
},
|
||||
"goto-start": { description: "Go to timeline start", category: "Navigation" },
|
||||
"goto-end": { description: "Go to timeline end", category: "Navigation" },
|
||||
"split-element": {
|
||||
description: "Split element at playhead",
|
||||
category: "Editing",
|
||||
},
|
||||
"delete-selected": {
|
||||
description: "Delete selected elements",
|
||||
category: "Editing",
|
||||
},
|
||||
"select-all": { description: "Select all elements", category: "Selection" },
|
||||
"duplicate-selected": {
|
||||
description: "Duplicate selected element",
|
||||
category: "Selection",
|
||||
},
|
||||
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
|
||||
undo: { description: "Undo", category: "History" },
|
||||
redo: { description: "Redo", category: "History" },
|
||||
"copy-selected": {
|
||||
description: "Copy selected elements",
|
||||
category: "Editing",
|
||||
},
|
||||
"paste-selected": {
|
||||
description: "Paste elements at playhead",
|
||||
category: "Editing",
|
||||
},
|
||||
};
|
||||
|
||||
// Convert key binding format to display format
|
||||
const formatKey = (key: string): string => {
|
||||
return key
|
||||
@@ -99,30 +43,33 @@ export const useKeyboardShortcutsHelp = () => {
|
||||
const result: KeyboardShortcut[] = [];
|
||||
|
||||
// Group keybindings by action
|
||||
const actionToKeys: Record<Action, string[]> = {} as any;
|
||||
const actionToKeys: Record<string, Array<string>> = {};
|
||||
|
||||
Object.entries(keybindings).forEach(([key, action]) => {
|
||||
for (const [key, action] of Object.entries(keybindings)) {
|
||||
if (action) {
|
||||
if (!actionToKeys[action]) {
|
||||
actionToKeys[action] = [];
|
||||
}
|
||||
actionToKeys[action].push(formatKey(key));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to shortcuts format
|
||||
Object.entries(actionToKeys).forEach(([action, keys]) => {
|
||||
const actionInfo = actionDescriptions[action as Action];
|
||||
if (actionInfo) {
|
||||
result.push({
|
||||
id: action,
|
||||
keys,
|
||||
description: actionInfo.description,
|
||||
category: actionInfo.category,
|
||||
action: action as Action,
|
||||
});
|
||||
for (const [actionId, keys] of Object.entries(actionToKeys)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(ACTIONS, actionId)) {
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
const action = actionId as TAction;
|
||||
const actionDef = ACTIONS[action];
|
||||
result.push({
|
||||
id: actionId,
|
||||
keys,
|
||||
description: actionDef.description,
|
||||
category: actionDef.category,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort shortcuts by category first, then by description to ensure consistent ordering
|
||||
return result.sort((a, b) => {
|
||||
|
||||
+12
-22
@@ -1,18 +1,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
export function useProjectInitialization({ projectId }: { projectId: string }) {
|
||||
const {
|
||||
activeProject,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
} = useProjectStore();
|
||||
export function useProjectInitialize({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const handledProjectIds = useRef<Set<string>>(new Set());
|
||||
const isInitializingRef = useRef<boolean>(false);
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
@@ -26,11 +21,11 @@ export function useProjectInitialization({ projectId }: { projectId: string }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeProject?.id === projectId) {
|
||||
if (activeProject?.metadata.id === projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInvalidProjectId(projectId)) {
|
||||
if (editor.project.isInvalidProjectId({ id: projectId })) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,7 +37,7 @@ export function useProjectInitialization({ projectId }: { projectId: string }) {
|
||||
handledProjectIds.current.add(projectId);
|
||||
|
||||
try {
|
||||
await loadProject(projectId);
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
@@ -61,10 +56,12 @@ export function useProjectInitialization({ projectId }: { projectId: string }) {
|
||||
error.message.includes("Project not found"));
|
||||
|
||||
if (isProjectNotFound) {
|
||||
markProjectIdAsInvalid(projectId);
|
||||
editor.project.markProjectIdAsInvalid({ id: projectId });
|
||||
|
||||
try {
|
||||
const newProjectId = await createNewProject("Untitled Project");
|
||||
const newProjectId = await editor.project.createNewProject({
|
||||
name: "Untitled Project",
|
||||
});
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
@@ -92,12 +89,5 @@ export function useProjectInitialization({ projectId }: { projectId: string }) {
|
||||
isCancelled = true;
|
||||
isInitializingRef.current = false;
|
||||
};
|
||||
}, [
|
||||
projectId,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
router,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
]);
|
||||
}, [projectId, editor, router]);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSoundsStore } from "@/stores/sounds-store";
|
||||
|
||||
/**
|
||||
* Custom hook for searching sound effects with race condition protection.
|
||||
* Uses global Zustand store to persist search state across tab switches.
|
||||
* - Debounced search (300ms)
|
||||
* - Race condition protection with cleanup
|
||||
* - Proper error handling
|
||||
*/
|
||||
|
||||
export function useSoundSearch(query: string, commercialOnly: boolean) {
|
||||
export function useSoundSearch({
|
||||
query,
|
||||
commercialOnly,
|
||||
}: {
|
||||
query: string;
|
||||
commercialOnly: boolean;
|
||||
}) {
|
||||
const {
|
||||
searchResults,
|
||||
isSearching,
|
||||
@@ -32,12 +30,11 @@ export function useSoundSearch(query: string, commercialOnly: boolean) {
|
||||
resetPagination,
|
||||
} = useSoundsStore();
|
||||
|
||||
// Load more function for infinite scroll
|
||||
const loadMore = async () => {
|
||||
if (isLoadingMore || !hasNextPage) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
setLoadingMore({ loading: true });
|
||||
const nextPage = currentPage + 1;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
@@ -51,42 +48,41 @@ export function useSoundSearch(query: string, commercialOnly: boolean) {
|
||||
|
||||
searchParams.set("commercial_only", commercialOnly.toString());
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?${searchParams.toString()}`
|
||||
`/api/sounds/search?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Append to appropriate array based on whether we have a query
|
||||
if (query.trim()) {
|
||||
appendSearchResults(data.results);
|
||||
} else {
|
||||
appendTopSounds(data.results);
|
||||
}
|
||||
|
||||
setCurrentPage(nextPage);
|
||||
setHasNextPage(!!data.next);
|
||||
setCurrentPage({ page: nextPage });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount(data.count);
|
||||
} else {
|
||||
setSearchError(`Load more failed: ${response.status}`);
|
||||
setSearchError({ error: `Load more failed: ${response.status}` });
|
||||
}
|
||||
} catch (err) {
|
||||
setSearchError(err instanceof Error ? err.message : "Load more failed");
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Load more failed",
|
||||
});
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
setLoadingMore({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setSearchError(null);
|
||||
setLastSearchQuery("");
|
||||
// Don't reset pagination here - top sounds pagination is managed by prefetcher
|
||||
setSearchResults({ results: [] });
|
||||
setSearchError({ error: null });
|
||||
setLastSearchQuery({ query: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already searched for this query and have results, don't search again
|
||||
if (query === lastSearchQuery && searchResults.length > 0) {
|
||||
return;
|
||||
}
|
||||
@@ -95,33 +91,35 @@ export function useSoundSearch(query: string, commercialOnly: boolean) {
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
setSearching(true);
|
||||
setSearchError(null);
|
||||
setSearching({ searching: true });
|
||||
setSearchError({ error: null });
|
||||
resetPagination();
|
||||
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`
|
||||
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
|
||||
);
|
||||
|
||||
if (!ignore) {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSearchResults(data.results);
|
||||
setLastSearchQuery(query);
|
||||
setHasNextPage(!!data.next);
|
||||
setTotalCount(data.count);
|
||||
setCurrentPage(1);
|
||||
setSearchResults({ results: data.results });
|
||||
setLastSearchQuery({ query: query });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount({ count: data.count });
|
||||
setCurrentPage({ page: 1 });
|
||||
} else {
|
||||
setSearchError(`Search failed: ${response.status}`);
|
||||
setSearchError({ error: `Search failed: ${response.status}` });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!ignore) {
|
||||
setSearchError(err instanceof Error ? err.message : "Search failed");
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Search failed",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setSearching(false);
|
||||
setSearching({ searching: false });
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
Reference in New Issue
Block a user