mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'dev' into ripple-editing
This commit is contained in:
@@ -17,14 +17,16 @@ import { snapTimeToFrame } from "@/lib/time";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import {
|
||||
snapElementEdge,
|
||||
type SnapPoint,
|
||||
} from "@/lib/timeline/snap-utils";
|
||||
import type {
|
||||
DropTarget,
|
||||
ElementDragState,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
} from "@/types/timeline";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
|
||||
interface UseElementInteractionProps {
|
||||
zoomLevel: number;
|
||||
@@ -162,7 +164,6 @@ export function useElementInteraction({
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const { snapElementEdge } = useTimelineSnapping();
|
||||
const {
|
||||
isElementSelected,
|
||||
selectElement,
|
||||
@@ -255,14 +256,7 @@ export function useElementInteraction({
|
||||
snapPoint: snapResult.snapPoint,
|
||||
};
|
||||
},
|
||||
[
|
||||
snappingEnabled,
|
||||
editor.playback,
|
||||
snapElementEdge,
|
||||
tracks,
|
||||
zoomLevel,
|
||||
isShiftHeldRef,
|
||||
],
|
||||
[snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -598,9 +592,12 @@ export function useElementInteraction({
|
||||
});
|
||||
if (!alreadySelected) {
|
||||
selectElement({ trackId: track.id, elementId: element.id });
|
||||
return;
|
||||
}
|
||||
|
||||
editor.selection.clearKeyframeSelection();
|
||||
},
|
||||
[isElementSelected, selectElement],
|
||||
[editor.selection, isElementSelected, selectElement],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import {
|
||||
useTimelineSnapping,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
type SnapPoint,
|
||||
} from "@/hooks/timeline/use-timeline-snapping";
|
||||
} from "@/lib/timeline/snap-utils";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
export interface ResizeState {
|
||||
@@ -39,7 +40,7 @@ export function useTimelineElementResize({
|
||||
const activeProject = editor.project.getActive();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
|
||||
|
||||
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
||||
@@ -85,12 +86,8 @@ export function useTimelineElementResize({
|
||||
};
|
||||
|
||||
const canExtendElementDuration = useCallback(() => {
|
||||
if (element.type === "text" || element.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [element.type]);
|
||||
return element.sourceDuration == null;
|
||||
}, [element.sourceDuration]);
|
||||
|
||||
const updateTrimFromMouseMove = useCallback(
|
||||
({ clientX }: { clientX: number }) => {
|
||||
@@ -269,8 +266,6 @@ export function useTimelineElementResize({
|
||||
activeProject.settings.fps,
|
||||
snappingEnabled,
|
||||
editor,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
element.id,
|
||||
onSnapPointChange,
|
||||
canExtendElementDuration,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { SelectedKeyframeRef } from "@/types/animation";
|
||||
|
||||
function getSelectedKeyframeId({
|
||||
keyframe,
|
||||
}: {
|
||||
keyframe: SelectedKeyframeRef;
|
||||
}): string {
|
||||
return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
|
||||
}
|
||||
|
||||
function mergeUniqueKeyframes({
|
||||
keyframes,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}): SelectedKeyframeRef[] {
|
||||
const keyframesById = new Map<string, SelectedKeyframeRef>();
|
||||
for (const keyframe of keyframes) {
|
||||
keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
|
||||
}
|
||||
return [...keyframesById.values()];
|
||||
}
|
||||
|
||||
export function useKeyframeSelection() {
|
||||
const editor = useEditor();
|
||||
const selectedKeyframes = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getSelectedKeyframes(),
|
||||
);
|
||||
const keyframeSelectionAnchor = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getKeyframeSelectionAnchor(),
|
||||
);
|
||||
|
||||
const isKeyframeSelected = useCallback(
|
||||
({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
|
||||
const keyframeId = getSelectedKeyframeId({ keyframe });
|
||||
return selectedKeyframes.some(
|
||||
(selectedKeyframe) =>
|
||||
getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
|
||||
);
|
||||
},
|
||||
[selectedKeyframes],
|
||||
);
|
||||
|
||||
const setKeyframeSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: uniqueKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const addKeyframesToSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const mergedKeyframes = mergeUniqueKeyframes({
|
||||
keyframes: [...selectedKeyframes, ...keyframes],
|
||||
});
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: mergedKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[selectedKeyframes, editor],
|
||||
);
|
||||
|
||||
const removeKeyframesFromSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const keyframeIdsToRemove = new Set(
|
||||
keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
|
||||
);
|
||||
const nextKeyframes = selectedKeyframes.filter(
|
||||
(selectedKeyframe) =>
|
||||
!keyframeIdsToRemove.has(
|
||||
getSelectedKeyframeId({ keyframe: selectedKeyframe }),
|
||||
),
|
||||
);
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: nextKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[selectedKeyframes, editor],
|
||||
);
|
||||
|
||||
const clearKeyframeSelection = useCallback(() => {
|
||||
editor.selection.clearKeyframeSelection();
|
||||
}, [editor]);
|
||||
|
||||
const toggleKeyframeSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
isMultiKey,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
isMultiKey: boolean;
|
||||
}) => {
|
||||
const anchorKeyframe = keyframes[0];
|
||||
if (!isMultiKey) {
|
||||
setKeyframeSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
const areAllKeyframesSelected = keyframes.every((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
if (areAllKeyframesSelected) {
|
||||
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
addKeyframesToSelection({ keyframes, anchorKeyframe });
|
||||
},
|
||||
[
|
||||
setKeyframeSelection,
|
||||
isKeyframeSelected,
|
||||
removeKeyframesFromSelection,
|
||||
addKeyframesToSelection,
|
||||
],
|
||||
);
|
||||
|
||||
const selectKeyframeRange = useCallback(
|
||||
({
|
||||
orderedKeyframes,
|
||||
targetKeyframes,
|
||||
isAdditive,
|
||||
}: {
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
targetKeyframes: SelectedKeyframeRef[];
|
||||
isAdditive: boolean;
|
||||
}) => {
|
||||
if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorKeyframe =
|
||||
keyframeSelectionAnchor ??
|
||||
selectedKeyframes[selectedKeyframes.length - 1] ??
|
||||
targetKeyframes[0];
|
||||
if (!anchorKeyframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetKeyframeIds = new Set(
|
||||
targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
|
||||
);
|
||||
const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
|
||||
const anchorIndex = orderedKeyframes.findIndex(
|
||||
(keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
|
||||
);
|
||||
if (anchorIndex === -1) {
|
||||
if (isAdditive) {
|
||||
addKeyframesToSelection({
|
||||
keyframes: targetKeyframes,
|
||||
anchorKeyframe,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndexes = orderedKeyframes
|
||||
.map((keyframe, index) => ({
|
||||
keyframeId: getSelectedKeyframeId({ keyframe }),
|
||||
index,
|
||||
}))
|
||||
.filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
|
||||
.map(({ index }) => index);
|
||||
if (targetIndexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStart = Math.min(anchorIndex, ...targetIndexes);
|
||||
const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
|
||||
const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
|
||||
|
||||
if (isAdditive) {
|
||||
addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
|
||||
},
|
||||
[
|
||||
keyframeSelectionAnchor,
|
||||
selectedKeyframes,
|
||||
addKeyframesToSelection,
|
||||
setKeyframeSelection,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedKeyframes,
|
||||
keyframeSelectionAnchor,
|
||||
isKeyframeSelected,
|
||||
setKeyframeSelection,
|
||||
addKeyframesToSelection,
|
||||
removeKeyframesFromSelection,
|
||||
clearKeyframeSelection,
|
||||
toggleKeyframeSelection,
|
||||
selectKeyframeRange,
|
||||
};
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import {
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
type SnapPoint,
|
||||
} from "@/lib/timeline/snap-utils";
|
||||
import type { Bookmark } from "@/types/timeline";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
|
||||
export interface BookmarkDragState {
|
||||
isDragging: boolean;
|
||||
@@ -47,8 +50,6 @@ export function useBookmarkDrag({
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
|
||||
|
||||
const [dragState, setDragState] = useState<BookmarkDragState>({
|
||||
isDragging: false,
|
||||
bookmarkTime: null,
|
||||
@@ -112,16 +113,7 @@ export function useBookmarkDrag({
|
||||
snapPoint: result.snapPoint,
|
||||
};
|
||||
},
|
||||
[
|
||||
snappingEnabled,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
tracks,
|
||||
playheadTime,
|
||||
bookmarks,
|
||||
zoomLevel,
|
||||
isShiftHeldRef,
|
||||
],
|
||||
[snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseSelectionBoxProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
headerRef: React.RefObject<HTMLElement | null>;
|
||||
onSelectionComplete: (
|
||||
elements: { trackId: string; elementId: string }[],
|
||||
) => void;
|
||||
@@ -88,6 +89,7 @@ function isRectangleIntersecting({
|
||||
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
headerRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
tracksScrollRef,
|
||||
@@ -131,6 +133,8 @@ export function useSelectionBox({
|
||||
endPos,
|
||||
});
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const timelineHeaderHeight =
|
||||
headerRef.current?.getBoundingClientRect().height ?? 0;
|
||||
const selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
|
||||
for (const [trackIndex, track] of tracks.entries()) {
|
||||
@@ -139,8 +143,9 @@ export function useSelectionBox({
|
||||
trackIndex,
|
||||
});
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const elementTop = trackTop;
|
||||
const elementBottom = trackTop + trackHeight;
|
||||
const elementTop =
|
||||
timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
|
||||
const elementBottom = elementTop + trackHeight;
|
||||
|
||||
for (const element of track.elements) {
|
||||
const elementLeft = element.startTime * pixelsPerSecond;
|
||||
@@ -168,7 +173,14 @@ export function useSelectionBox({
|
||||
}
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
|
||||
[
|
||||
containerRef,
|
||||
headerRef,
|
||||
onSelectionComplete,
|
||||
tracks,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
interface UseSnapIndicatorPositionParams {
|
||||
@@ -50,8 +50,10 @@ export function useSnapIndicatorPosition({
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
|
||||
const timelinePosition =
|
||||
(snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const timelinePosition = timelineTimeToSnappedPixels({
|
||||
time: snapPoint?.time ?? 0,
|
||||
zoomLevel,
|
||||
});
|
||||
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildTextElement,
|
||||
buildStickerElement,
|
||||
buildElementFromMedia,
|
||||
buildEffectElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import type { Command } from "@/lib/commands/base-command";
|
||||
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
||||
@@ -16,7 +17,11 @@ import { BatchCommand } from "@/lib/commands";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getDragData, hasDragData } from "@/lib/drag-data";
|
||||
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
|
||||
import type { MediaDragData, StickerDragData } from "@/types/drag";
|
||||
import type {
|
||||
MediaDragData,
|
||||
StickerDragData,
|
||||
EffectDragData,
|
||||
} from "@/types/drag";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
@@ -54,6 +59,7 @@ export function useTimelineDragDrop({
|
||||
|
||||
if (dragData.type === "text") return "text";
|
||||
if (dragData.type === "sticker") return "sticker";
|
||||
if (dragData.type === "effect") return "effect";
|
||||
if (dragData.type === "media") {
|
||||
return dragData.mediaType;
|
||||
}
|
||||
@@ -70,7 +76,11 @@ export function useTimelineDragDrop({
|
||||
elementType: ElementType;
|
||||
mediaId?: string;
|
||||
}): number => {
|
||||
if (elementType === "text" || elementType === "sticker") {
|
||||
if (
|
||||
elementType === "text" ||
|
||||
elementType === "sticker" ||
|
||||
elementType === "effect"
|
||||
) {
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
if (mediaId) {
|
||||
@@ -124,6 +134,13 @@ export function useTimelineDragDrop({
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||
|
||||
const targetElementTypes =
|
||||
dragData?.type === "effect"
|
||||
? (dragData as EffectDragData).targetElementTypes
|
||||
: dragData?.type === "media"
|
||||
? (dragData as MediaDragData).targetElementTypes
|
||||
: undefined;
|
||||
|
||||
const target = computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
@@ -134,6 +151,7 @@ export function useTimelineDragDrop({
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
targetElementTypes,
|
||||
});
|
||||
|
||||
target.xPosition = getSnappedTime({ time: target.xPosition });
|
||||
@@ -248,6 +266,11 @@ export function useTimelineDragDrop({
|
||||
|
||||
const executeMediaDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
|
||||
if (target.targetElement) {
|
||||
toast.info("Replace media source is coming soon!");
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
|
||||
if (!mediaAsset) return;
|
||||
|
||||
@@ -284,6 +307,42 @@ export function useTimelineDragDrop({
|
||||
[editor.timeline, mediaAssets, tracks],
|
||||
);
|
||||
|
||||
const executeEffectDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => {
|
||||
const effectTrack = tracks.find((t) => t.type === "effect");
|
||||
let trackId: string;
|
||||
|
||||
if (effectTrack && !target.targetElement) {
|
||||
trackId = effectTrack.id;
|
||||
} else if (target.targetElement) {
|
||||
trackId = effectTrack?.id ?? editor.timeline.addTrack({
|
||||
type: "effect",
|
||||
index: 0,
|
||||
});
|
||||
} else if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "effect",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track || track.type !== "effect") return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildEffectElement({
|
||||
effectType: dragData.effectType,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeFileDrop = useCallback(
|
||||
async ({
|
||||
files,
|
||||
@@ -384,6 +443,11 @@ export function useTimelineDragDrop({
|
||||
executeTextDrop({ target: currentTarget, dragData });
|
||||
} else if (dragData.type === "sticker") {
|
||||
executeStickerDrop({ target: currentTarget, dragData });
|
||||
} else if (dragData.type === "effect") {
|
||||
executeEffectDrop({
|
||||
target: currentTarget,
|
||||
dragData: dragData as EffectDragData,
|
||||
});
|
||||
} else {
|
||||
executeMediaDrop({ target: currentTarget, dragData });
|
||||
}
|
||||
@@ -410,6 +474,7 @@ export function useTimelineDragDrop({
|
||||
executeTextDrop,
|
||||
executeStickerDrop,
|
||||
executeMediaDrop,
|
||||
executeEffectDrop,
|
||||
executeFileDrop,
|
||||
containerRef,
|
||||
headerRef,
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import { useEditor } from "../use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import {
|
||||
useTimelineSnapping,
|
||||
type SnapPoint,
|
||||
} from "@/hooks/timeline/use-timeline-snapping";
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
} from "@/lib/timeline/snap-utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
@@ -31,10 +31,6 @@ export function useTimelinePlayhead({
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const isScrubbing = editor.playback.getIsScrubbing();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const { snapToNearestPoint } = useTimelineSnapping({
|
||||
enableElementSnapping: false,
|
||||
enablePlayheadSnapping: false,
|
||||
});
|
||||
|
||||
const seek = useCallback(
|
||||
({ time }: { time: number }) => editor.playback.seek({ time }),
|
||||
@@ -51,7 +47,13 @@ export function useTimelinePlayhead({
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
|
||||
const handleScrub = useCallback(
|
||||
({ event }: { event: MouseEvent | React.MouseEvent }) => {
|
||||
({
|
||||
event,
|
||||
snappingEnabled = true,
|
||||
}: {
|
||||
event: MouseEvent | React.MouseEvent;
|
||||
snappingEnabled?: boolean;
|
||||
}) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rulerRect = ruler.getBoundingClientRect();
|
||||
@@ -80,21 +82,25 @@ export function useTimelinePlayhead({
|
||||
fps: framesPerSecond,
|
||||
});
|
||||
|
||||
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
|
||||
const bookmarkSnapPoints: SnapPoint[] = bookmarks.map((bookmark) => ({
|
||||
time: bookmark.time,
|
||||
type: "bookmark",
|
||||
}));
|
||||
const shouldSnapToBookmark =
|
||||
!isShiftHeldRef.current && bookmarkSnapPoints.length > 0;
|
||||
const snapResult = shouldSnapToBookmark
|
||||
? snapToNearestPoint({
|
||||
targetTime: frameTime,
|
||||
snapPoints: bookmarkSnapPoints,
|
||||
zoomLevel,
|
||||
})
|
||||
: null;
|
||||
const time = snapResult?.snapPoint ? snapResult.snappedTime : frameTime;
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
const time = (() => {
|
||||
if (!shouldSnap) return frameTime;
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const bookmarks =
|
||||
editor.scenes.getActiveScene()?.bookmarks ?? [];
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime: frameTime,
|
||||
bookmarks,
|
||||
enablePlayheadSnapping: false,
|
||||
});
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: frameTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
|
||||
})();
|
||||
|
||||
setScrubTime(time);
|
||||
seek({ time });
|
||||
@@ -109,7 +115,7 @@ export function useTimelinePlayhead({
|
||||
activeProject.settings.fps,
|
||||
isShiftHeldRef,
|
||||
editor.scenes,
|
||||
snapToNearestPoint,
|
||||
editor.timeline,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -133,10 +139,10 @@ export function useTimelinePlayhead({
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
editor.playback.setScrubbing({ isScrubbing: true });
|
||||
handleScrub({ event });
|
||||
},
|
||||
[handleScrub, playheadRef, editor.playback],
|
||||
editor.playback.setScrubbing({ isScrubbing: true });
|
||||
handleScrub({ event, snappingEnabled: false });
|
||||
},
|
||||
[handleScrub, playheadRef, editor.playback],
|
||||
);
|
||||
|
||||
const handlePlayheadMouseDownEvent = useCallback(
|
||||
@@ -184,7 +190,7 @@ export function useTimelinePlayhead({
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub({ event });
|
||||
handleScrub({ event, snappingEnabled: false });
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Bookmark, TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
|
||||
export interface SnapPoint {
|
||||
time: number;
|
||||
type: "element-start" | "element-end" | "playhead" | "bookmark";
|
||||
elementId?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
snappedTime: number;
|
||||
snapPoint: SnapPoint | null;
|
||||
snapDistance: number;
|
||||
}
|
||||
|
||||
export interface UseTimelineSnappingOptions {
|
||||
snapThreshold?: number;
|
||||
enableElementSnapping?: boolean;
|
||||
enablePlayheadSnapping?: boolean;
|
||||
enableBookmarkSnapping?: boolean;
|
||||
}
|
||||
|
||||
export function useTimelineSnapping({
|
||||
snapThreshold = 10,
|
||||
enableElementSnapping = true,
|
||||
enablePlayheadSnapping = true,
|
||||
enableBookmarkSnapping = true,
|
||||
}: UseTimelineSnappingOptions = {}) {
|
||||
const findSnapPoints = useCallback(
|
||||
({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
bookmarks = [],
|
||||
excludeBookmarkTime,
|
||||
}: {
|
||||
tracks: Array<TimelineTrack>;
|
||||
playheadTime: number;
|
||||
excludeElementId?: string;
|
||||
bookmarks?: Array<Bookmark>;
|
||||
excludeBookmarkTime?: number;
|
||||
}): SnapPoint[] => {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
|
||||
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;
|
||||
|
||||
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 (enableBookmarkSnapping) {
|
||||
for (const bookmark of bookmarks) {
|
||||
if (
|
||||
excludeBookmarkTime != null &&
|
||||
Math.abs(bookmark.time - excludeBookmarkTime) <
|
||||
BOOKMARK_TIME_EPSILON
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
snapPoints.push({
|
||||
time: bookmark.time,
|
||||
type: "bookmark",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return snapPoints;
|
||||
},
|
||||
[enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping],
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const snapElementEdge = useCallback(
|
||||
({
|
||||
targetTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
playheadTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
snapToStart = true,
|
||||
bookmarks = [],
|
||||
}: {
|
||||
targetTime: number;
|
||||
elementDuration: number;
|
||||
tracks: Array<TimelineTrack>;
|
||||
playheadTime: number;
|
||||
zoomLevel: number;
|
||||
excludeElementId?: string;
|
||||
snapToStart?: boolean;
|
||||
bookmarks?: Array<Bookmark>;
|
||||
}): SnapResult => {
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
bookmarks,
|
||||
});
|
||||
|
||||
const effectiveTargetTime = snapToStart
|
||||
? targetTime
|
||||
: targetTime + elementDuration;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: effectiveTargetTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
if (!snapToStart && snapResult.snapPoint) {
|
||||
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
|
||||
}
|
||||
|
||||
return snapResult;
|
||||
},
|
||||
[findSnapPoints, snapToNearestPoint],
|
||||
);
|
||||
|
||||
return {
|
||||
snapElementEdge,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { effectPreviewService } from "@/services/renderer/effect-preview";
|
||||
import type { EffectParamValues } from "@/types/effects";
|
||||
|
||||
export function useEffectPreview({
|
||||
effectType,
|
||||
params,
|
||||
canvasRef,
|
||||
isActive,
|
||||
}: {
|
||||
effectType: string;
|
||||
params: EffectParamValues;
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
isActive: boolean;
|
||||
}): void {
|
||||
const requestRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
requestRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const loop = (): void => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
effectPreviewService.renderPreview({
|
||||
effectType,
|
||||
params,
|
||||
targetCanvas: canvas,
|
||||
});
|
||||
}
|
||||
requestRef.current = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
requestRef.current = requestAnimationFrame(loop);
|
||||
|
||||
return () => {
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
}
|
||||
};
|
||||
}, [effectType, params, canvasRef, isActive]);
|
||||
}
|
||||
@@ -4,9 +4,16 @@ import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import type { TextElement, Transform } from "@/types/timeline";
|
||||
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
|
||||
import { hitTest } from "@/lib/preview/hit-test";
|
||||
import { screenToCanvas } from "@/lib/preview/preview-coords";
|
||||
import {
|
||||
screenPixelsToLogicalThreshold,
|
||||
screenToCanvas,
|
||||
} from "@/lib/preview/preview-coords";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapPosition,
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
|
||||
const MIN_DRAG_DISTANCE = 0.5;
|
||||
|
||||
@@ -230,11 +237,16 @@ export function usePreviewInteraction({
|
||||
};
|
||||
|
||||
const shouldSnap = !isShiftHeldRef.current;
|
||||
const snapThreshold = screenPixelsToLogicalThreshold({
|
||||
canvas: canvasRef.current,
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedPosition, activeLines } = shouldSnap
|
||||
? snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize,
|
||||
elementSize: dragStateRef.current.bounds,
|
||||
snapThreshold,
|
||||
})
|
||||
: {
|
||||
snappedPosition: proposedPosition,
|
||||
|
||||
@@ -6,9 +6,13 @@ import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/lib/preview/element-bounds";
|
||||
import { screenToCanvas } from "@/lib/preview/preview-coords";
|
||||
import {
|
||||
screenPixelsToLogicalThreshold,
|
||||
screenToCanvas,
|
||||
} from "@/lib/preview/preview-coords";
|
||||
import {
|
||||
MIN_SCALE,
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
type SnapLine,
|
||||
@@ -228,6 +232,10 @@ export function useTransformHandles({
|
||||
);
|
||||
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
const snapThreshold = screenPixelsToLogicalThreshold({
|
||||
canvas: canvasRef.current,
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const shouldSnap = !isShiftHeldRef.current;
|
||||
const { snappedScale, activeLines } = shouldSnap
|
||||
? snapScale({
|
||||
@@ -236,6 +244,7 @@ export function useTransformHandles({
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
})
|
||||
: { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user