summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks')
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts21
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts17
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts229
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts20
-rw-r--r--apps/web/src/hooks/timeline/use-selection-box.ts18
-rw-r--r--apps/web/src/hooks/timeline/use-snap-indicator-position.ts8
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts69
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts64
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-snapping.ts185
-rw-r--r--apps/web/src/hooks/use-effect-preview.ts47
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts16
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts11
12 files changed, 443 insertions, 262 deletions
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index 79f1272c..3831a86f 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -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 {
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 14693fff..2340c9b2 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -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,
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
new file mode 100644
index 00000000..50def392
--- /dev/null
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
index faf28e40..0e7fabd5 100644
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -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(() => {
diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts
index 12c6bf8a..c4f8d6ce 100644
--- a/apps/web/src/hooks/timeline/use-selection-box.ts
+++ b/apps/web/src/hooks/timeline/use-selection-box.ts
@@ -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(() => {
diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
index d78eb04d..f3e63894 100644
--- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
+++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
@@ -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 {
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 632d2c78..ee739298 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -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,
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index 44e12ab1..1426a003 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -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);
}
diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts
deleted file mode 100644
index 5de75448..00000000
--- a/apps/web/src/hooks/timeline/use-timeline-snapping.ts
+++ /dev/null
@@ -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,
- };
-}
diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts
new file mode 100644
index 00000000..a84781e0
--- /dev/null
+++ b/apps/web/src/hooks/use-effect-preview.ts
@@ -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]);
+}
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
index 2bb33f21..d29f2a4b 100644
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -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,
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
index 37ef6d5f..8be6420c 100644
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -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[] };