refactor: snapping system

This commit is contained in:
Maze Winther 2026-02-23 10:55:35 +01:00
parent ab64c48c92
commit 4d77e3f2bb
10 changed files with 179 additions and 226 deletions

View File

@ -23,7 +23,7 @@ import { TimelinePlayhead } from "./timeline-playhead";
import { SelectionBox } from "../../selection-box";
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,

View File

@ -1,7 +1,7 @@
"use client";
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/types/timeline";
interface SnapIndicatorProps {

View File

@ -4,7 +4,7 @@ import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import AudioWaveform from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
getTrackClasses,

View File

@ -4,7 +4,7 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-select
import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/types/timeline";

View File

@ -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(() => {

View File

@ -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);
@ -269,8 +270,6 @@ export function useTimelineElementResize({
activeProject.settings.fps,
snappingEnabled,
editor,
findSnapPoints,
snapToNearestPoint,
element.id,
onSnapPointChange,
canExtendElementDuration,

View File

@ -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(() => {

View File

@ -3,7 +3,10 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import {
findSnapPoints,
snapToNearestPoint,
} from "@/lib/timeline/snap-utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@ -28,10 +31,6 @@ export function useTimelinePlayhead({
const isPlaying = editor.playback.getIsPlaying();
const isScrubbing = editor.playback.getIsScrubbing();
const isShiftHeldRef = useShiftKey();
const { snapToNearestPoint, findSnapPoints } = useTimelineSnapping({
enableElementSnapping: true,
enablePlayheadSnapping: false,
});
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
@ -87,6 +86,7 @@ export function useTimelinePlayhead({
tracks,
playheadTime: frameTime,
bookmarks,
enablePlayheadSnapping: false,
});
const snapResult = snapToNearestPoint({
targetTime: frameTime,
@ -110,8 +110,6 @@ export function useTimelinePlayhead({
isShiftHeldRef,
editor.scenes,
editor.timeline,
findSnapPoints,
snapToNearestPoint,
],
);

View File

@ -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,
};
}

View File

@ -0,0 +1,155 @@
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;
}
const DEFAULT_SNAP_THRESHOLD_PX = 10;
export function findSnapPoints({
tracks,
playheadTime,
excludeElementId,
bookmarks = [],
excludeBookmarkTime,
enableElementSnapping = true,
enablePlayheadSnapping = true,
enableBookmarkSnapping = true,
}: {
tracks: Array<TimelineTrack>;
playheadTime: number;
excludeElementId?: string;
bookmarks?: Array<Bookmark>;
excludeBookmarkTime?: number;
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
enableBookmarkSnapping?: boolean;
}): SnapPoint[] {
const snapPoints: SnapPoint[] = [];
if (enableElementSnapping) {
for (const track of tracks) {
for (const element of track.elements) {
if (element.id === excludeElementId) continue;
snapPoints.push(
{
time: element.startTime,
type: "element-start",
elementId: element.id,
trackId: track.id,
},
{
time: element.startTime + element.duration,
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;
}
export function snapToNearestPoint({
targetTime,
snapPoints,
zoomLevel,
snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
}: {
targetTime: number;
snapPoints: Array<SnapPoint>;
zoomLevel: number;
snapThreshold?: 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,
};
}
export function snapElementEdge({
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;
}