From 8a3577d2e7c871e58307fa014c7f5e1446cdd3e3 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 05:52:33 +0200 Subject: [PATCH] review: fixes --- .../element/use-element-interaction.ts | 89 ++++++++------- .../group-move/__tests__/group-move.test.ts | 104 ++++++++++++++++++ 2 files changed, 154 insertions(+), 39 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 98d21d9b..5458efe7 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -191,6 +191,7 @@ export function useElementInteraction({ const [isPendingDrag, setIsPendingDrag] = useState(false); const pendingDragRef = useRef(null); const moveGroupRef = useRef(null); + const newTrackIdsRef = useRef([]); const groupMoveResultRef = useRef(null); const lastMouseXRef = useRef(0); const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); @@ -227,6 +228,7 @@ export function useElementInteraction({ const endDrag = useCallback(() => { moveGroupRef.current = null; + newTrackIdsRef.current = []; groupMoveResultRef.current = null; setDragState(initialDragState); setDragDropTarget(null); @@ -262,7 +264,7 @@ export function useElementInteraction({ target: { kind: "newTracks", anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: group.members.map(() => generateUUID()), + newTrackIds: newTrackIdsRef.current, }, }); } @@ -297,7 +299,7 @@ export function useElementInteraction({ target: { kind: "newTracks", anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: group.members.map(() => generateUUID()), + newTrackIds: newTrackIdsRef.current, }, }); }, @@ -386,18 +388,61 @@ export function useElementInteraction({ } moveGroupRef.current = moveGroup; - groupMoveResultRef.current = null; + newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); const dragTimeOffsets: Record = {}; for (const member of moveGroup.members) { dragTimeOffsets[member.elementId] = member.timeOffset; } + const { + snappedTime: initialSnappedTime, + snapPoint: initialSnapPoint, + } = getDragSnapResult({ + frameSnappedTime: snappedTime, + group: moveGroup, + }); + const verticalDragDirection = getVerticalDragDirection({ + startMouseY: pendingDragRef.current.startMouseY, + currentMouseY: clientY, + }); + const anchorDropTarget = getDragDropTarget({ + clientX, + clientY, + elementId: pendingDragRef.current.elementId, + trackId: pendingDragRef.current.trackId, + tracks: sceneTracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime: initialSnappedTime, + verticalDragDirection, + }); + const nextGroupMoveResult = + anchorDropTarget != null + ? resolveGroupDragMove({ + group: moveGroup, + snappedTime: initialSnappedTime, + dropTarget: anchorDropTarget, + }) + : null; + groupMoveResultRef.current = nextGroupMoveResult; + setDragDropTarget( + anchorDropTarget && + (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { + ...anchorDropTarget, + isNewTrack: true, + } + : null, + ); startDrag({ ...pendingDragRef.current, dragElementIds: moveGroup.members.map((member) => member.elementId), dragTimeOffsets, - initialCurrentTime: snappedTime, + initialCurrentTime: initialSnappedTime, initialCurrentMouseY: clientY, }); + onSnapPointChange?.(initialSnapPoint); startedDragThisEvent = true; pendingDragRef.current = null; setIsPendingDrag(false); @@ -533,28 +578,6 @@ export function useElementInteraction({ } } - const dropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: dragState.elementId, - trackId: dragState.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime: dragState.currentTime, - verticalDragDirection: getVerticalDragDirection({ - startMouseY: dragState.startMouseY, - currentMouseY: clientY, - }), - }); - if (!dropTarget) { - endDrag(); - onSnapPointChange?.(null); - return; - } - const snappedTime = dragState.currentTime; const moveGroup = moveGroupRef.current; if (!moveGroup) { endDrag(); @@ -562,11 +585,7 @@ export function useElementInteraction({ return; } - const groupMoveResult = resolveGroupDragMove({ - group: moveGroup, - snappedTime, - dropTarget, - }); + const groupMoveResult = groupMoveResultRef.current; if (!groupMoveResult) { endDrag(); onSnapPointChange?.(null); @@ -604,18 +623,10 @@ export function useElementInteraction({ dragState.isDragging, dragState.elementId, dragState.startElementTime, - dragState.startMouseY, dragState.trackId, - dragState.currentTime, - zoomLevel, endDrag, onSnapPointChange, editor.timeline, - tracksContainerRef, - tracksScrollRef, - headerRef, - resolveGroupDragMove, - sceneTracks, ]); useEffect(() => { diff --git a/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts b/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts index e6c86e41..9e89c48a 100644 --- a/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts +++ b/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts @@ -11,6 +11,7 @@ import type { import type { Transform } from "@/lib/rendering"; import { buildMoveGroup } from "@/lib/timeline/group-move/build-group"; import { resolveGroupMove } from "@/lib/timeline/group-move/resolve-move"; +import { snapGroupEdges } from "@/lib/timeline/group-move/snap"; function buildTransform(): Transform { return { @@ -284,4 +285,107 @@ describe("group move", () => { }, ]); }); + + test("snapGroupEdges snaps the closest group edge and preserves offsets", () => { + const tracks = buildTracks({ + overlay: [ + buildVideoTrack({ + id: "overlay-video", + elements: [ + buildVideoElement({ id: "video-1", startTime: 5, duration: 2 }), + ], + }), + buildTextTrack({ + id: "overlay-text", + elements: [ + buildTextElement({ id: "text-1", startTime: 8, duration: 3 }), + ], + }), + ], + main: buildVideoTrack({ + id: "main", + elements: [ + buildVideoElement({ id: "video-snap", startTime: 10, duration: 4 }), + ], + }), + }); + const group = buildMoveGroup({ + anchorRef: { trackId: "overlay-video", elementId: "video-1" }, + selectedElements: [ + { trackId: "overlay-video", elementId: "video-1" }, + { trackId: "overlay-text", elementId: "text-1" }, + ], + tracks, + }); + if (!group) { + throw new Error("Expected group"); + } + + const result = snapGroupEdges({ + group, + anchorStartTime: 6, + tracks, + playheadTime: 100, + zoomLevel: 1, + }); + + expect(result).toEqual({ + snappedAnchorStartTime: 7, + snapPoint: { + time: 10, + type: "element-start", + elementId: "video-snap", + trackId: "main", + }, + }); + }); + + test("snapGroupEdges returns the raw anchor time when nothing is within threshold", () => { + const tracks = buildTracks({ + overlay: [ + buildVideoTrack({ + id: "overlay-video", + elements: [ + buildVideoElement({ id: "video-1", startTime: 5, duration: 2 }), + ], + }), + buildTextTrack({ + id: "overlay-text", + elements: [ + buildTextElement({ id: "text-1", startTime: 8, duration: 3 }), + ], + }), + ], + main: buildVideoTrack({ + id: "main", + elements: [ + buildVideoElement({ id: "video-snap", startTime: 100, duration: 4 }), + ], + }), + }); + const group = buildMoveGroup({ + anchorRef: { trackId: "overlay-video", elementId: "video-1" }, + selectedElements: [ + { trackId: "overlay-video", elementId: "video-1" }, + { trackId: "overlay-text", elementId: "text-1" }, + ], + tracks, + }); + if (!group) { + throw new Error("Expected group"); + } + + const result = snapGroupEdges({ + group, + anchorStartTime: 6, + tracks, + playheadTime: 200, + zoomLevel: 1000, + }); + + expect(result).toEqual({ + snappedAnchorStartTime: 6, + snapPoint: null, + }); + }); });