Merge branch 'dev' into ripple-editing

This commit is contained in:
Maze Winther
2026-03-01 01:32:09 +01:00
138 changed files with 8791 additions and 1617 deletions
@@ -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,
};
}
+47
View File
@@ -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]);
}
+14 -2
View File
@@ -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,
+10 -1
View File
@@ -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[] };