diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 28260a2752..c4da46f5e3 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -29,7 +29,6 @@ import { import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; import { useTrackGapMenu } from "./useTrackGapMenu"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; -import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext"; import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry"; import { @@ -45,6 +44,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization"; import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; +import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh"; export { shouldAutoScrollTimeline, @@ -106,11 +106,7 @@ export const Timeline = memo(function Timeline({ onSplitElement: onSplitElementOverride, }); const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); - const playbackContext = useStudioPlaybackContextOptional(); - const setRefreshKey = playbackContext?.setRefreshKey; - const refreshAfterLaneMove = useCallback(() => { - setRefreshKey?.((key) => key + 1); - }, [setRefreshKey]); + const refreshAfterLaneMove = useTimelineLaneMoveRefresh(); useMusicBeatAnalysis(); const rawElements = usePlayerStore((s) => s.elements); const expandedElements = useExpandedTimelineElements(); @@ -129,14 +125,8 @@ export const Timeline = memo(function Timeline({ const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest); const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); const gsapAnimations = usePlayerStore((s) => s.gsapAnimations); - const hasKeyframedClips = useMemo( - () => hasKeyframedTimelineClips(gsapAnimations), - [gsapAnimations], - ); - const labelMode = hasKeyframedClips; - // Without the label column the pre-t=0 breathing room is still TRACKS_LEFT_PAD - // (dropping it would jam clip 0 against the gutter on every non-keyframed - // composition); in label mode the 232px label column already provides it. + const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]); + // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter. const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD; const contentGutter = labelMode ? GUTTER : 0; const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); @@ -236,7 +226,6 @@ export const Timeline = memo(function Timeline({ setResizingClip, blockedClipRef, suppressClickRef, - syncClipDragAutoScroll, } = useTimelineClipDrag({ scrollRef, ppsRef, @@ -253,6 +242,7 @@ export const Timeline = memo(function Timeline({ readZIndex: zSyncEnabled ? readClipZIndex : undefined, onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined, refreshAfterLaneMove, + sessionEpoch, }); const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } = @@ -269,6 +259,9 @@ export const Timeline = memo(function Timeline({ onCompositionDrop: pinnedOnCompositionDrop, }); const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry); + const resizingElementIds = + resizingClip?.groupPreview?.map((change) => change.key) ?? + (resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined); const { recordTimelineScroll } = useTimelinePerformanceTelemetry({ totalClipCount: expandedElements.length, totalRowCount: displayLayout.displayTrackOrder.length, @@ -289,7 +282,7 @@ export const Timeline = memo(function Timeline({ selectedElementId, revealElementId: clipRevealRequest?.elementId ?? null, draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined, - resizingRowKey: resizingClip?.element.track, + resizingElementIds, clipContextMenuRowKey: clipContextMenu?.element.track, keyframeContextMenuRowKey: kfContextMenu?.element.track, lastScrollLeftRef, @@ -332,7 +325,7 @@ export const Timeline = memo(function Timeline({ duration: displayDuration, selectedElementId: selectedElementId ?? undefined, draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined, - resizingElementId: resizingClip ? getTimelineElementIdentity(resizingClip.element) : undefined, + resizingElementIds, revealElementId: clipRevealRequest?.elementId, focusedEaseElementId: focusedEaseSegment?.elementId, clipContextMenuElementId: clipContextMenu @@ -468,6 +461,7 @@ export const Timeline = memo(function Timeline({ >
s.beatDragging); const draggedElement = draggedClip?.element ?? null; const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null; - const activeDraggedElement = - draggedClip?.started === true && draggedElement && draggedElementIdentity - ? getRenderedTimelineElement({ - element: draggedElement, - draggedElementId: draggedElementIdentity, - previewStart: draggedClip.previewStart, - previewTrack: draggedClip.previewTrack, - }) - : null; // The drag ghost follows the cursor freely (both axes) — CapCut-style. The // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex), // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it. @@ -94,22 +82,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas selectedKeys: selectedElementIds, } : null; - const activeDraggedPosition = - draggedClip?.started === true && activeDraggedElement && scrollRef.current - ? { - left: - draggedClip.pointerClientX - - scrollRef.current.getBoundingClientRect().left + - scrollRef.current.scrollLeft - - draggedClip.pointerOffsetX, - top: - draggedClip.pointerClientY - - scrollRef.current.getBoundingClientRect().top + - scrollRef.current.scrollTop - - draggedClip.pointerOffsetY, - } - : null; - return (
)} - {/* Drag ghost */} - {activeDraggedElement && activeDraggedPosition && ( -
- {}} - onHoverEnd={() => {}} - onResizeStart={() => {}} - onClick={() => {}} - onDoubleClick={() => {}} - > - {renderClipChildren( - activeDraggedElement, - props.getTrackStyle(activeDraggedElement.tag), - props.renderClipContent, - props.renderClipOverlay, - )} - -
- )} + {/* Marquee (rubber-band) multi-select rectangle — mirrors the canvas MarqueeOverlay look: semi-transparent accent fill + dashed border. */} diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index e471a2f1a9..40410b565f 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -12,6 +12,7 @@ interface TimelineClipProps { isSelected: boolean; isHovered: boolean; isDragging?: boolean; + isGestureActor?: boolean; isActive?: boolean; hasCustomContent: boolean; capabilities: TimelineEditCapabilities; @@ -36,6 +37,7 @@ export const TimelineClip = memo(function TimelineClip({ isSelected, isHovered, isDragging = false, + isGestureActor = false, isActive = false, hasCustomContent, capabilities, @@ -85,13 +87,14 @@ export const TimelineClip = memo(function TimelineClip({ return (
document.body.replaceChildren()); + +describe("TimelineGestureOverlay", () => { + it("keeps the drag actor mounted without a source-row node", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const scroll = { + scrollLeft: 500, + scrollTop: 300, + getBoundingClientRect: () => ({ left: 50, top: 40 }), + } as HTMLDivElement; + act(() => { + root.render( + , + ); + }); + const actor = host.querySelector('[data-timeline-gesture-actor="hero"]'); + expect(actor?.style.left).toBe("780px"); + expect(actor?.style.top).toBe("490px"); + expect(actor?.querySelector(".timeline-clip")).not.toBeNull(); + expect(actor?.querySelector("[data-el-id]")).toBeNull(); + expect(actor?.querySelector("[data-clip]")).toBeNull(); + expect(host.querySelector("[data-source-row]")).toBeNull(); + act(() => root.unmount()); + }); + + it("keeps the stable overlay host after terminal cleanup", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.querySelector("[data-timeline-gesture-overlay]")).not.toBeNull(); + expect(host.querySelector("[data-timeline-gesture-actor]")).toBeNull(); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/TimelineGestureOverlay.tsx b/packages/studio/src/player/components/TimelineGestureOverlay.tsx new file mode 100644 index 0000000000..500d2c0ea2 --- /dev/null +++ b/packages/studio/src/player/components/TimelineGestureOverlay.tsx @@ -0,0 +1,96 @@ +import { memo, type ReactNode } from "react"; +import type { TimelineElement } from "../store/playerStore"; +import type { TimelineTheme } from "./timelineTheme"; +import { getRenderedTimelineElement } from "./timelineTheme"; +import { TimelineClip } from "./TimelineClip"; +import { getTimelineEditCapabilities } from "./timelineEditing"; +import { renderClipChildren } from "./timelineClipChildren"; +import { getTimelineDragOverlayPosition } from "./timelineClipDragPreview"; +import type { DraggedClipState } from "./timelineClipDragTypes"; +import type { TrackVisualStyle } from "./timelineIcons"; +import { isTimelineClipActive } from "./useTimelineActiveClips"; + +interface TimelineGestureOverlayProps { + drag: DraggedClipState | null; + scrollRef: React.RefObject; + pixelsPerSecond: number; + rowHeight: number; + selectedElementId: string | null; + currentTime: number; + theme: TimelineTheme; + getTrackStyle: (tag: string) => TrackVisualStyle; + renderClipContent?: ( + element: TimelineElement, + style: { clip: string; label: string }, + ) => ReactNode; + renderClipOverlay?: (element: TimelineElement) => ReactNode; +} + +/** Stable canvas child that owns the live drag actor independently of source rows. */ +export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({ + drag, + scrollRef, + pixelsPerSecond, + rowHeight, + selectedElementId, + currentTime, + theme, + getTrackStyle, + renderClipContent, + renderClipOverlay, +}: TimelineGestureOverlayProps) { + const element = + drag?.started === true + ? getRenderedTimelineElement({ + element: drag.element, + draggedElementId: drag.element.key ?? drag.element.id, + previewStart: drag.previewStart, + previewTrack: drag.previewTrack, + }) + : null; + const position = drag ? getTimelineDragOverlayPosition(drag, scrollRef.current) : null; + return ( +
+ {element && position && ( +
+ {}} + onHoverEnd={() => {}} + onResizeStart={() => {}} + onClick={() => {}} + onDoubleClick={() => {}} + > + {renderClipChildren( + element, + getTrackStyle(element.tag), + renderClipContent, + renderClipOverlay, + )} + +
+ )} +
+ ); +}); diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index 39565398d6..ac68b6fb0b 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -116,7 +116,6 @@ function renderLanes(options: RenderLanesOptions = {}): { setResizingClip={vi.fn()} setDraggedClip={vi.fn()} setSelectedElementId={vi.fn()} - syncClipDragAutoScroll={vi.fn()} shiftClickClipRef={createRef()} getPreviewElement={(el) => el} getTrackStyle={getTrackStyle} diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 954131587e..bd4de63b88 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -76,7 +76,6 @@ export function TimelineLanes({ setResizingClip, setDraggedClip, setSelectedElementId, - syncClipDragAutoScroll, shiftClickClipRef, getPreviewElement, getTrackStyle, @@ -350,6 +349,7 @@ export function TimelineLanes({ setShowPopover(false); setRangeSelection(null); setResizingClip({ + pointerId: e.pointerId, element: el, edge, originClientX: e.clientX, @@ -388,6 +388,7 @@ export function TimelineLanes({ (blockedIntent !== "move" && onResizeElement)) ) { blockedClipRef.current = { + pointerId: e.pointerId, element: el, intent: blockedIntent, originClientX: e.clientX, @@ -401,6 +402,7 @@ export function TimelineLanes({ setShowPopover(false); setRangeSelection(null); setDraggedClip({ + pointerId: e.pointerId, element: el, originClientX: e.clientX, originClientY: e.clientY, @@ -418,7 +420,6 @@ export function TimelineLanes({ snapType: null, started: false, }); - syncClipDragAutoScroll(e.clientX, e.clientY); } } onClick={ diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts index b52b984e39..74b7c2e085 100644 --- a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts +++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts @@ -21,6 +21,7 @@ describe("timeline clip drag gesture lifecycle", () => { track: 0, }; const drag: DraggedClipState = { + pointerId: 0, element, originClientX: 0, originClientY: 0, @@ -49,23 +50,36 @@ describe("timeline clip drag gesture lifecycle", () => { })); const onMoveElement = vi.fn(); const stopAutoScroll = vi.fn(); + const cancelGestureRef = { current: () => false }; const dispose = mountTimelineClipDragGestureLifecycle({ + lifecycleRef: { + current: { + kind: "drag", + phase: "active", + pointerId: null, + sessionEpoch: 0, + }, + }, + sessionEpochRef: { current: 0 }, + cancelGestureRef, + scrollRef: { current: null }, draggedClipRef, resizingClipRef, blockedClipRef: { current: null }, groupResizeRef: { current: null }, suppressClickRef: { current: false }, + gestureSelectedKeysRef: { current: new Set() }, elementsRef: { current: [element] }, trackOrderRef: { current: [0] }, - setDraggedClip, - setResizingClip: () => {}, + setDraggedClipState: setDraggedClip, + setResizingClipState: () => {}, setShowPopover: () => {}, setRangeSelectionRef: { current: null }, applyResizePointerRef: { current: () => {} }, syncClipDragAutoScrollRef: { current: () => {} }, stopClipDragAutoScrollRef: { current: stopAutoScroll }, updateDraggedClipPreviewRef: { current: updateDraggedClipPreview }, - restoreGroupResizeMembers: () => {}, + publishDraggedClip: setDraggedClip, updateElement: vi.fn(), onMoveElementRef: { current: onMoveElement }, onMoveElementsRef: { current: undefined }, @@ -93,5 +107,6 @@ describe("timeline clip drag gesture lifecycle", () => { expect(stopAutoScroll).toHaveBeenCalledTimes(1); dispose(); + expect(cancelGestureRef.current()).toBe(false); }); }); diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts index c9fc2b42b0..cfbb387159 100644 --- a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts +++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts @@ -14,20 +14,44 @@ import { } from "./timelineOptimisticRevision"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import type { StackingPatch } from "./timelineStackingSync"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import type { TimelineElement, usePlayerStore } from "../store/playerStore"; + +export type TimelineGestureKind = "drag" | "resize"; +type TimelineGesturePhase = "active" | "committing" | "cancelled" | "complete"; + +export interface TimelineGestureLifecycle { + kind: TimelineGestureKind | null; + phase: TimelineGesturePhase; + pointerId: number | null; + sessionEpoch: number; +} + +interface TimelineGestureCommit { + kind: TimelineGestureKind; + drag: DraggedClipState | null; + resize: ResizingClipState | null; + groupResize: TimelineGroupResizeSession | null; +} type UpdateElement = ReturnType["updateElement"]; interface TimelineClipDragGestureLifecycleInput { + lifecycleRef: RefObject; + sessionEpochRef: RefObject; + cancelGestureRef: RefObject< + (options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean + >; + scrollRef: RefObject; draggedClipRef: RefObject; resizingClipRef: RefObject; blockedClipRef: RefObject; groupResizeRef: RefObject; suppressClickRef: RefObject; + gestureSelectedKeysRef: RefObject>; elementsRef: RefObject; trackOrderRef: RefObject; - setDraggedClip: Dispatch>; - setResizingClip: Dispatch>; + setDraggedClipState: Dispatch>; + setResizingClipState: Dispatch>; setShowPopover: (show: boolean) => void; setRangeSelectionRef: RefObject<((selection: null) => void) | null>; applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>; @@ -36,7 +60,7 @@ interface TimelineClipDragGestureLifecycleInput { updateDraggedClipPreviewRef: RefObject< (drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState >; - restoreGroupResizeMembers: (session: TimelineGroupResizeSession, all?: boolean) => void; + publishDraggedClip: (next: DraggedClipState | null) => void; updateElement: UpdateElement; onMoveElementRef: RefObject; onMoveElementsRef: RefObject; @@ -55,22 +79,27 @@ interface TimelineClipDragGestureLifecycleInput { export function mountTimelineClipDragGestureLifecycle({ // The explicit destructuring mirrors the single call site's dependency object by design. // fallow-ignore-next-line code-duplication + lifecycleRef, + sessionEpochRef, + cancelGestureRef, + scrollRef, draggedClipRef, resizingClipRef, blockedClipRef, groupResizeRef, suppressClickRef, + gestureSelectedKeysRef, elementsRef, trackOrderRef, - setDraggedClip, - setResizingClip, + setDraggedClipState, + setResizingClipState, setShowPopover, setRangeSelectionRef, applyResizePointerRef, syncClipDragAutoScrollRef, stopClipDragAutoScrollRef, updateDraggedClipPreviewRef, - restoreGroupResizeMembers, + publishDraggedClip, updateElement, onMoveElementRef, onMoveElementsRef, @@ -87,9 +116,65 @@ export function mountTimelineClipDragGestureLifecycle({ }); }; + const pointerMatchesGesture = (event: PointerEvent): boolean => { + const pointerId = lifecycleRef.current.pointerId; + return pointerId === null || event.pointerId === pointerId; + }; + + const releasePointerCapture = (pointerId: number | null) => { + if (pointerId === null) return; + const scroll = scrollRef.current; + try { + if (scroll?.hasPointerCapture(pointerId)) scroll.releasePointerCapture(pointerId); + } catch { + // Window listeners are authoritative when native capture is unavailable. + } + }; + + const capturePointer = (pointerId: number | null) => { + if (pointerId === null) return; + try { + scrollRef.current?.setPointerCapture(pointerId); + } catch { + // Window listeners remain authoritative when native capture is unavailable. + } + }; + + const clearGestureProjection = (updateReact: boolean) => { + draggedClipRef.current = null; + resizingClipRef.current = null; + groupResizeRef.current = null; + if (updateReact) { + setDraggedClipState(null); + setResizingClipState(null); + } + }; + + const cancelGesture = ({ + updateReact = true, + suppressClick = false, + }: { + updateReact?: boolean; + suppressClick?: boolean; + } = {}): boolean => { + const lifecycle = lifecycleRef.current; + if (lifecycle.phase !== "active") return false; + lifecycle.phase = "cancelled"; + stopClipDragAutoScrollRef.current(); + clearGestureProjection(updateReact); + releasePointerCapture(lifecycle.pointerId); + if (suppressClick) suppressClickRef.current = true; + lifecycle.kind = null; + lifecycle.pointerId = null; + lifecycle.phase = "complete"; + return true; + }; + cancelGestureRef.current = cancelGesture; + const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => { const distance = Math.abs(event.clientX - resize.originClientX); if (!resize.started && distance < 2) return; + if (!resize.started) capturePointer(resize.pointerId); setShowPopover(false); setRangeSelectionRef.current?.(null); applyResizePointerRef.current(resize, event.clientX); @@ -106,6 +191,7 @@ export function mountTimelineClipDragGestureLifecycle({ if (!blocked.started) { blocked.started = true; blockedClipRef.current = blocked; + capturePointer(blocked.pointerId); suppressClickRef.current = true; setShowPopover(false); setRangeSelectionRef.current?.(null); @@ -119,34 +205,33 @@ export function mountTimelineClipDragGestureLifecycle({ event.clientY - drag.originClientY, ); if (!drag.started && distance < 4) return; + if (!drag.started) capturePointer(drag.pointerId); setShowPopover(false); setRangeSelectionRef.current?.(null); - setDraggedClip((previous) => - previous - ? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY) - : previous, - ); + publishDraggedClip(updateDraggedClipPreviewRef.current(drag, event.clientX, event.clientY)); syncClipDragAutoScrollRef.current(event.clientX, event.clientY); }; const handleWindowPointerMove = (event: PointerEvent) => { const resize = resizingClipRef.current; - if (resize) return handleResizePointerMove(event, resize); + if (resize) { + if (!pointerMatchesGesture(event)) return; + return handleResizePointerMove(event, resize); + } const blocked = blockedClipRef.current; - if (blocked) return handleBlockedPointerMove(event, blocked); + if (blocked) { + if (blocked.pointerId !== event.pointerId) return; + return handleBlockedPointerMove(event, blocked); + } const drag = draggedClipRef.current; - if (drag) handleDragPointerMove(event, drag); + if (drag && pointerMatchesGesture(event)) handleDragPointerMove(event, drag); }; - const commitResizePointerUp = (resize: ResizingClipState) => { - resizingClipRef.current = null; - setResizingClip(null); - const groupSession = groupResizeRef.current; - groupResizeRef.current = null; - if (!resize.started) { - if (groupSession) restoreGroupResizeMembers(groupSession); - return; - } + const commitResizePointerUp = ( + resize: ResizingClipState, + groupSession: TimelineGroupResizeSession | null, + ) => { + if (!resize.started) return; suppressClickRef.current = true; clearSuppressedClick(); if (groupSession) { @@ -193,8 +278,6 @@ export function mountTimelineClipDragGestureLifecycle({ }; const commitDragPointerUp = (drag: DraggedClipState) => { - draggedClipRef.current = null; - setDraggedClip(null); if (!drag.started) return; suppressClickRef.current = true; clearSuppressedClick(); @@ -204,25 +287,96 @@ export function mountTimelineClipDragGestureLifecycle({ updateElement, onMoveElement: onMoveElementRef.current, onMoveElements: onMoveElementsRef.current, - selectedKeys: usePlayerStore.getState().selectedElementIds, + selectedKeys: gestureSelectedKeysRef.current, readZIndex: readZIndexRef.current, onStackingPatches: onStackingPatchesRef.current, refreshAfterLaneMove: refreshAfterLaneMoveRef.current, }); }; - const handleWindowPointerUp = () => { + /** Group gestures commit atomically: one missing member cancels the whole resize. */ + const gestureSourcesStillExist = (): boolean => { + const gestureElements = groupResizeRef.current?.members.map((member) => member.element) ?? [ + resizingClipRef.current?.element ?? draggedClipRef.current?.element, + ]; + const liveKeys = new Set(elementsRef.current.map((element) => element.key ?? element.id)); + return gestureElements.every( + (element) => element === undefined || liveKeys.has(element.key ?? element.id), + ); + }; + + const claimActiveGesture = (event: PointerEvent): TimelineGestureCommit | "ignored" | null => { + const lifecycle = lifecycleRef.current; + if (lifecycle.phase !== "active") return null; + if (!pointerMatchesGesture(event)) return "ignored"; + if (lifecycle.sessionEpoch !== sessionEpochRef.current) { + cancelGesture(); + return "ignored"; + } + if (!gestureSourcesStillExist()) { + cancelGesture(); + return "ignored"; + } + lifecycle.phase = "committing"; + const claimed = lifecycle.kind + ? { + kind: lifecycle.kind, + drag: draggedClipRef.current, + resize: resizingClipRef.current, + groupResize: groupResizeRef.current, + } + : "ignored"; stopClipDragAutoScrollRef.current(); - const resize = resizingClipRef.current; - if (resize) return commitResizePointerUp(resize); - const blocked = blockedClipRef.current; - if (blocked) return finishBlockedPointerUp(blocked); - const drag = draggedClipRef.current; - if (!drag) { - if (suppressClickRef.current) clearSuppressedClick(); + clearGestureProjection(true); + releasePointerCapture(lifecycle.pointerId); + if (claimed === "ignored") lifecycle.phase = "complete"; + return claimed; + }; + + const commitClaimedGesture = (gesture: TimelineGestureCommit) => { + try { + if (gesture.kind === "resize" && gesture.resize) { + commitResizePointerUp(gesture.resize, gesture.groupResize); + } else if (gesture.kind === "drag" && gesture.drag) { + commitDragPointerUp(gesture.drag); + } + } finally { + const lifecycle = lifecycleRef.current; + lifecycle.kind = null; + lifecycle.pointerId = null; + lifecycle.phase = "complete"; + } + }; + + const handleWindowPointerUp = (event: PointerEvent) => { + const claimed = claimActiveGesture(event); + if (claimed === "ignored") return; + if (claimed) { + commitClaimedGesture(claimed); return; } - commitDragPointerUp(drag); + const blocked = blockedClipRef.current; + if (blocked) { + if (blocked.pointerId !== event.pointerId) return; + return finishBlockedPointerUp(blocked); + } + if (suppressClickRef.current) clearSuppressedClick(); + }; + + const handleWindowPointerCancel = (event: PointerEvent) => { + if (lifecycleRef.current.phase === "active" && pointerMatchesGesture(event)) { + if (cancelGesture({ suppressClick: true })) clearSuppressedClick(); + } + const blocked = blockedClipRef.current; + if (blocked && blocked.pointerId !== event.pointerId) return; + blockedClipRef.current = null; + if (suppressClickRef.current) clearSuppressedClick(); + }; + + const handleLostPointerCapture = (event: PointerEvent) => { + if (lifecycleRef.current.phase === "active" && pointerMatchesGesture(event)) { + if (cancelGesture({ suppressClick: true })) clearSuppressedClick(); + } }; const handleWindowKeyDown = (event: KeyboardEvent) => { @@ -235,27 +389,22 @@ export function mountTimelineClipDragGestureLifecycle({ if (!decision.cancel) return; event.preventDefault(); event.stopPropagation(); - stopClipDragAutoScrollRef.current(); - draggedClipRef.current = null; - setDraggedClip(null); - resizingClipRef.current = null; - setResizingClip(null); - const groupSession = groupResizeRef.current; - groupResizeRef.current = null; - if (groupSession) restoreGroupResizeMembers(groupSession); blockedClipRef.current = null; - if (decision.suppressClick) suppressClickRef.current = true; + cancelGesture({ suppressClick: decision.suppressClick }); }; window.addEventListener("pointermove", handleWindowPointerMove); window.addEventListener("pointerup", handleWindowPointerUp); - window.addEventListener("pointercancel", handleWindowPointerUp); + window.addEventListener("pointercancel", handleWindowPointerCancel); + window.addEventListener("lostpointercapture", handleLostPointerCapture); window.addEventListener("keydown", handleWindowKeyDown, true); return () => { - stopClipDragAutoScrollRef.current(); + cancelGesture({ updateReact: false }); + cancelGestureRef.current = () => false; window.removeEventListener("pointermove", handleWindowPointerMove); window.removeEventListener("pointerup", handleWindowPointerUp); - window.removeEventListener("pointercancel", handleWindowPointerUp); + window.removeEventListener("pointercancel", handleWindowPointerCancel); + window.removeEventListener("lostpointercapture", handleLostPointerCapture); window.removeEventListener("keydown", handleWindowKeyDown, true); }; } diff --git a/packages/studio/src/player/components/timelineClipDragPreview.test.ts b/packages/studio/src/player/components/timelineClipDragPreview.test.ts index c71e9e73f6..d14fb1f7bb 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.test.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.test.ts @@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore"; import { computeDragPreview, computeResizePreview, + getTimelineDragOverlayPosition, type DragPreviewContext, } from "./timelineClipDragPreview"; import type { DraggedClipState } from "./timelineClipDragTypes"; @@ -84,6 +85,7 @@ function horizontalDrag( const originClientX = 800; const originClientY = yForRow(grabRowFloat); const drag: DraggedClipState = { + pointerId: 0, element, originClientX, originClientY, @@ -129,6 +131,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse const originClientX = 800; const originClientY = yForRow(0.5); const drag: DraggedClipState = { + pointerId: 0, element: moodboard, originClientX, originClientY, @@ -170,6 +173,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)]; const clientY = RULER_H + TRACKS_TOP_PAD + 30; const drag: DraggedClipState = { + pointerId: 0, element: dragged, originClientX: 0, originClientY: clientY, @@ -221,3 +225,32 @@ describe("computeResizePreview — composition source continuity", () => { }); }); }); + +describe("getTimelineDragOverlayPosition", () => { + it("keeps the gesture actor under the pointer across two-axis autoscroll", () => { + const { drag } = horizontalDrag(moodboard, 0.5, 2); + const scroll = { + scrollLeft: 500, + scrollTop: 300, + getBoundingClientRect: () => ({ left: 20, top: 40 }), + } as Pick; + expect( + getTimelineDragOverlayPosition( + { + ...drag, + pointerClientX: 900, + pointerClientY: 700, + pointerOffsetX: 25, + pointerOffsetY: 10, + }, + scroll, + ), + ).toEqual({ left: 1_355, top: 950 }); + }); + + it("does not mount an actor before threshold or without the stable viewport", () => { + const { drag } = horizontalDrag(moodboard, 0.5, 2); + expect(getTimelineDragOverlayPosition({ ...drag, started: false }, fakeScroll())).toBeNull(); + expect(getTimelineDragOverlayPosition(drag, null)).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index 982f42fbff..935878205d 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -45,6 +45,19 @@ export interface DragPreviewContext { audioTracks?: ReadonlySet; } +/** Content-space position for the stable viewport drag actor. */ +export function getTimelineDragOverlayPosition( + drag: DraggedClipState, + scroll: Pick | null, +): { left: number; top: number } | null { + if (!drag.started || !scroll) return null; + const rect = scroll.getBoundingClientRect(); + return { + left: drag.pointerClientX - rect.left + scroll.scrollLeft - drag.pointerOffsetX, + top: drag.pointerClientY - rect.top + scroll.scrollTop - drag.pointerOffsetY, + }; +} + /** * Max start a drag may reach. Allow dragging past the current content into the * rendered timeline extent (the viewport-fill keeps that ≥ the viewport width). @@ -328,29 +341,22 @@ export function computeResizePreview( /** * Apply a rigid group-resize preview: fold the grabbed clip's raw delta into the - * session, preview every non-grabbed member through the store (`updateElement`), - * and set the grabbed clip's preview state (it renders from resizingClip state, so - * its store value stays pristine until commit — like the single-clip path). + * session and publish a coordinator-owned projection. Canonical elements stay + * pristine until the exactly-once commit. */ export function previewGroupResize( session: TimelineGroupResizeSession, next: ResizePreviewResult, - grabbedKey: string, - updateElement: ( - key: string, - patch: { start: number; duration: number; playbackStart?: number }, + setResizeState: ( + v: ResizePreviewResult & { groupPreview: TimelineGroupResizeSession["changes"] }, ) => void, - setResizeState: (v: ResizePreviewResult) => void, ): void { const grabbedChange = applyTimelineGroupResizePreview(session, next); - for (const c of session.changes) { - if (c.key === grabbedKey) continue; - updateElement(c.key, { start: c.start, duration: c.duration, playbackStart: c.playbackStart }); - } setResizeState({ originScrollLeft: next.originScrollLeft, previewStart: grabbedChange?.start ?? next.previewStart, previewDuration: grabbedChange?.duration ?? next.previewDuration, previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart, + groupPreview: session.changes, }); } diff --git a/packages/studio/src/player/components/timelineClipDragTypes.ts b/packages/studio/src/player/components/timelineClipDragTypes.ts index 8ac36c9a68..251a299f6d 100644 --- a/packages/studio/src/player/components/timelineClipDragTypes.ts +++ b/packages/studio/src/player/components/timelineClipDragTypes.ts @@ -4,6 +4,8 @@ import type { BlockedTimelineEditIntent } from "./timelineEditing"; /* ── Shared clip-drag state types ───────────────────────────────── */ export interface DraggedClipState { + /** Pointer captured by the stable viewport coordinator for this gesture. */ + pointerId: number; element: TimelineElement; originClientX: number; originClientY: number; @@ -39,6 +41,8 @@ export interface DraggedClipState { } export interface ResizingClipState { + /** Pointer captured by the stable viewport coordinator for this gesture. */ + pointerId: number; element: TimelineElement; edge: "start" | "end"; originClientX: number; @@ -53,10 +57,18 @@ export interface ResizingClipState { previewStart: number; previewDuration: number; previewPlaybackStart?: number; + /** Coordinator-owned group projection; canonical elements change only on commit. */ + groupPreview?: readonly { + key: string; + start: number; + duration: number; + playbackStart?: number; + }[]; started: boolean; } export interface BlockedClipState { + pointerId: number; element: TimelineElement; intent: BlockedTimelineEditIntent; originClientX: number; diff --git a/packages/studio/src/player/components/timelineLaneProps.ts b/packages/studio/src/player/components/timelineLaneProps.ts index 23fb4f6f68..b16a03b3b3 100644 --- a/packages/studio/src/player/components/timelineLaneProps.ts +++ b/packages/studio/src/player/components/timelineLaneProps.ts @@ -55,7 +55,6 @@ export interface TimelineLaneBaseProps { setResizingClip: (v: ResizingClipState | null) => void; setDraggedClip: (v: DraggedClipState | null) => void; setSelectedElementId: (id: string | null) => void; - syncClipDragAutoScroll: (x: number, y: number) => void; shiftClickClipRef: React.RefObject<{ element: TimelineElement; anchorX: number; diff --git a/packages/studio/src/player/components/timelineViewModel.ts b/packages/studio/src/player/components/timelineViewModel.ts index ace13a4d1c..3eec25cb79 100644 --- a/packages/studio/src/player/components/timelineViewModel.ts +++ b/packages/studio/src/player/components/timelineViewModel.ts @@ -29,10 +29,10 @@ export function getTimelinePreviewElement( element: TimelineElement, resizingClip: ResizingClipState | null, ): TimelineElement { - if ( - resizingClip && - getTimelineElementIdentity(resizingClip.element) === getTimelineElementIdentity(element) - ) { + const elementIdentity = getTimelineElementIdentity(element); + const groupPreview = resizingClip?.groupPreview?.find((change) => change.key === elementIdentity); + if (groupPreview) return { ...element, ...groupPreview }; + if (resizingClip && getTimelineElementIdentity(resizingClip.element) === elementIdentity) { return { ...element, start: resizingClip.previewStart, diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx index 33ebd8ff47..43568db6da 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx @@ -4,7 +4,7 @@ import React, { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { TimelineElement } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore"; -import type { ResizingClipState } from "./useTimelineClipDrag"; +import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { mountReactHarness } from "../../hooks/domSelectionTestHarness"; @@ -38,38 +38,77 @@ function renderResizeHarness( usePlayerStore.getState().setSelectedElementIds(new Set(selected)); const scroll = document.createElement("div"); + const setPointerCapture = vi.fn(); + scroll.setPointerCapture = setPointerCapture; document.body.append(scroll); const onResizeElement = vi.fn(); + const onMoveElement = vi.fn(); + const onBlockedEditAttempt = vi.fn(); const onResizeElements = vi.fn().mockResolvedValue(undefined); let setResizingClip: ((s: ResizingClipState | null) => void) | null = null; + let setDraggedClip: ((s: DraggedClipState | null) => void) | null = null; + let resizingClip: ResizingClipState | null = null; + let blockedClipRef: React.RefObject | null = null; + let epoch = 0; - function Harness() { + function Harness({ sessionEpoch }: { sessionEpoch: number }) { const hook = useTimelineClipDrag({ scrollRef: { current: scroll }, ppsRef: { current: 100 }, durationRef: { current: 100 }, trackOrderRef: { current: [0, 1] }, onResizeElement, + onMoveElement, + onBlockedEditAttempt, onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements, setShowPopover: vi.fn(), setRangeSelectionRef: { current: vi.fn() }, + sessionEpoch, }); setResizingClip = hook.setResizingClip; + setDraggedClip = hook.setDraggedClip; + resizingClip = hook.resizingClip; + blockedClipRef = hook.blockedClipRef; return null; } - const root = mountReactHarness(); + const root = mountReactHarness(); const apply = setResizingClip!; + const dispatchPointer = (type: string, clientX: number, pointerId = 0) => { + const event = new MouseEvent(type, { bubbles: true, clientX, clientY: 0 }); + Object.defineProperty(event, "pointerId", { value: pointerId }); + window.dispatchEvent(event); + }; return { onResizeElement, + onMoveElement, + onBlockedEditAttempt, onResizeElements, + setPointerCapture, storeById(id: string) { return usePlayerStore.getState().elements.find((e) => e.id === id)!; }, - startResize(element: TimelineElement, edge: "start" | "end") { + getResizeProjection() { + return resizingClip?.groupPreview ?? []; + }, + getBlockedClip() { + return blockedClipRef?.current ?? null; + }, + startBlocked(element: TimelineElement, pointerId = 0) { + blockedClipRef!.current = { + pointerId, + element, + intent: "move", + originClientX: 0, + originClientY: 0, + started: false, + }; + }, + startResize(element: TimelineElement, edge: "start" | "end", pointerId = 0) { act(() => { apply({ + pointerId, element, edge, originClientX: 0, @@ -80,16 +119,54 @@ function renderResizeHarness( }); }); }, - movePointer(clientX: number) { + startDrag(element: TimelineElement, pointerId = 0) { + act(() => + setDraggedClip?.({ + pointerId, + element, + originClientX: 0, + originClientY: 0, + originScrollLeft: 0, + originScrollTop: 0, + pointerClientX: 0, + pointerClientY: 0, + pointerOffsetX: 0, + pointerOffsetY: 0, + previewStart: element.start, + previewTrack: element.track, + insertRow: null, + snapTime: null, + snapType: null, + started: false, + }), + ); + }, + movePointer(clientX: number, pointerId = 0) { act(() => { - window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY: 0 })); + dispatchPointer("pointermove", clientX, pointerId); + }); + }, + async dropPointer(pointerId = 0) { + await act(async () => { + dispatchPointer("pointerup", 0, pointerId); }); }, - async dropPointer() { + async moveAndDropPointer(clientX: number, pointerId = 0) { await act(async () => { - window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + dispatchPointer("pointermove", clientX, pointerId); + dispatchPointer("pointerup", clientX, pointerId); }); }, + cancelPointer(pointerId = 0) { + act(() => dispatchPointer("pointercancel", 0, pointerId)); + }, + losePointerCapture(pointerId = 0) { + act(() => dispatchPointer("lostpointercapture", 0, pointerId)); + }, + setSessionEpoch(next: number) { + epoch = next; + act(() => root.render()); + }, pressEscape() { act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); @@ -161,14 +238,131 @@ describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => { }); }); +describe("useTimelineClipDrag — gesture lifecycle", () => { + it("does not capture a stationary click before the drag threshold", () => { + const a = el("a", { duration: 2 }); + const h = renderResizeHarness([a], []); + h.startDrag(a, 7); + expect(h.setPointerCapture).not.toHaveBeenCalled(); + + h.movePointer(3, 7); + expect(h.setPointerCapture).not.toHaveBeenCalled(); + + h.movePointer(5, 7); + expect(h.setPointerCapture).toHaveBeenCalledOnce(); + expect(h.setPointerCapture).toHaveBeenCalledWith(7); + h.unmount(); + }); + + it("commits the final drag position exactly once", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const a = el("a", { duration: 2 }); + const h = renderResizeHarness([a], []); + h.startDrag(a, 7); + await h.moveAndDropPointer(50, 7); + await h.dropPointer(7); + expect(h.onMoveElement).toHaveBeenCalledTimes(1); + expect(h.onMoveElement).toHaveBeenCalledWith(a, expect.objectContaining({ start: 0.5 })); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("only single-clip onMoveElement wired"), + ); + warnSpy.mockRestore(); + h.unmount(); + }); + + it("ignores a foreign pointer cancellation during a blocked gesture", async () => { + const a = el("a", { duration: 2 }); + const h = renderResizeHarness([a], []); + h.startBlocked(a, 7); + h.movePointer(5, 7); + expect(h.onBlockedEditAttempt).toHaveBeenCalledOnce(); + + h.cancelPointer(8); + expect(h.getBlockedClip()?.pointerId).toBe(7); + + await h.dropPointer(7); + expect(h.getBlockedClip()).toBeNull(); + h.unmount(); + }); + + it("commits the final same-turn pointer move exactly once", async () => { + const a = el("a", { duration: 2 }); + const h = renderResizeHarness([a], []); + h.startResize(a, "end", 7); + await h.moveAndDropPointer(75, 7); + await h.dropPointer(7); + expect(h.onResizeElement).toHaveBeenCalledTimes(1); + expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.75 })); + h.unmount(); + }); + + it("ignores another pointer and cancels without committing", async () => { + const a = el("a", { duration: 2 }); + const h = renderResizeHarness([a], []); + h.startResize(a, "end", 7); + h.movePointer(50, 8); + await h.dropPointer(8); + expect(h.onResizeElement).not.toHaveBeenCalled(); + h.cancelPointer(7); + await h.dropPointer(7); + expect(h.onResizeElement).not.toHaveBeenCalled(); + expect(h.storeById("a").duration).toBe(2); + h.unmount(); + }); + + it("cancels an active projection when the project epoch changes", () => { + const a = el("a", { duration: 2 }); + const h = renderResizeHarness([a], []); + h.startResize(a, "end", 7); + h.movePointer(50, 7); + h.setSessionEpoch(1); + expect(h.getResizeProjection()).toHaveLength(0); + expect(h.onResizeElement).not.toHaveBeenCalled(); + expect(h.storeById("a").duration).toBe(2); + h.unmount(); + }); + + it("does not commit a stale clip deleted during the gesture", async () => { + const a = el("a", { duration: 2 }); + const h = renderResizeHarness([a], []); + h.startResize(a, "end", 7); + h.movePointer(50, 7); + act(() => usePlayerStore.getState().setElements([])); + await h.dropPointer(7); + expect(h.onResizeElement).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().elements).toEqual([]); + h.unmount(); + }); + + it("treats lost capture and unmount as cancellation", async () => { + const a = el("a", { duration: 2 }); + const lost = renderResizeHarness([a], []); + lost.startResize(a, "end", 7); + lost.movePointer(50, 7); + lost.losePointerCapture(7); + await lost.dropPointer(7); + expect(lost.onResizeElement).not.toHaveBeenCalled(); + lost.unmount(); + + const unmounted = renderResizeHarness([a], []); + unmounted.startResize(a, "end", 9); + unmounted.movePointer(50, 9); + unmounted.unmount(); + expect(unmounted.onResizeElement).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().elements[0]?.duration).toBe(2); + }); +}); + describe("useTimelineClipDrag — multi-select group resize (restored)", () => { - it("previews the non-grabbed member through the store, grabbed stays out until commit", () => { + it("previews every member without mutating canonical elements", () => { const { h } = startGroupResize(); // grabbed asks +0.5 - // Non-grabbed member is previewed in the store; the grabbed clip renders from - // resizingClip state, so its store value is still the original until commit. - expect(h.storeById("b").duration).toBe(3.5); + expect(h.getResizeProjection()).toEqual([ + expect.objectContaining({ key: "a", duration: 2.5 }), + expect.objectContaining({ key: "b", duration: 3.5 }), + ]); expect(h.storeById("a").duration).toBe(2); + expect(h.storeById("b").duration).toBe(3); h.unmount(); }); @@ -186,6 +380,7 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => { { coalesceKey: expect.stringMatching(/^clip-group-resize:/) }, ); expect(h.storeById("a").duration).toBe(2.5); + expect(h.getResizeProjection()).toHaveLength(0); expect(h.storeById("b").duration).toBe(3.5); h.unmount(); }); @@ -213,7 +408,10 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => { }); h.startResize(a, "end"); h.movePointer(50); - expect(h.storeById("b").duration).toBe(3.5); + expect(h.getResizeProjection()).toEqual( + expect.arrayContaining([expect.objectContaining({ key: "b", duration: 3.5 })]), + ); + expect(h.storeById("b").duration).toBe(3); await h.dropPointer(); expect(h.storeById("a").duration).toBe(2); @@ -229,12 +427,13 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => { h.unmount(); }); - it("Escape rolls back the previewed non-grabbed member and persists nothing", () => { + it("Escape discards the projection and persists nothing", () => { const { h } = startGroupResize(); - expect(h.storeById("b").duration).toBe(3.5); // previewed + expect(h.getResizeProjection()).toHaveLength(2); h.pressEscape(); - expect(h.storeById("b").duration).toBe(3); // restored + expect(h.getResizeProjection()).toHaveLength(0); + expect(h.storeById("b").duration).toBe(3); expect(h.onResizeElement).not.toHaveBeenCalled(); h.unmount(); }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 86ce7173e2..ea44dda075 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -1,4 +1,4 @@ -import { useRef, useState, useCallback, useMemo } from "react"; +import { useRef, useState, useCallback, useMemo, useEffect } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; import { applyTimelineAutoScrollStep, @@ -25,9 +25,13 @@ import type { ResizingClipState, BlockedClipState, } from "./timelineClipDragTypes"; -import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle"; import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; import type { TimelineRowGeometry } from "./timelineLayout"; +import { + mountTimelineClipDragGestureLifecycle, + type TimelineGestureKind, + type TimelineGestureLifecycle, +} from "./timelineClipDragGestureLifecycle"; export type { DraggedClipState, @@ -74,6 +78,7 @@ interface UseTimelineClipDragInput { readZIndex?: (element: TimelineElement) => number; onStackingPatches?: (patches: StackingPatch[]) => Promise | void; refreshAfterLaneMove?: () => void; + sessionEpoch?: number; } export function useTimelineClipDrag({ @@ -92,6 +97,7 @@ export function useTimelineClipDrag({ readZIndex, onStackingPatches, refreshAfterLaneMove, + sessionEpoch = 0, }: UseTimelineClipDragInput) { const updateElement = usePlayerStore((s) => s.updateElement); const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES); @@ -162,41 +168,72 @@ export function useTimelineClipDrag({ [], ); - const [draggedClip, setDraggedClip] = useState(null); + const [draggedClip, setDraggedClipState] = useState(null); const draggedClipRef = useRef(null); - draggedClipRef.current = draggedClip; + const publishDraggedClip = useCallback((next: DraggedClipState | null) => { + draggedClipRef.current = next; + setDraggedClipState(next); + }, []); - const [resizingClip, setResizingClip] = useState(null); + const [resizingClip, setResizingClipState] = useState(null); const resizingClipRef = useRef(null); - resizingClipRef.current = resizingClip; + const publishResizingClip = useCallback((next: ResizingClipState | null) => { + resizingClipRef.current = next; + setResizingClipState(next); + }, []); + + const lifecycleRef = useRef({ + kind: null, + phase: "complete", + pointerId: null, + sessionEpoch, + }); + const sessionEpochRef = useRef(sessionEpoch); + sessionEpochRef.current = sessionEpoch; + const gestureSelectedKeysRef = useRef>(new Set()); + const cancelGestureRef = useRef< + (options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean + >(() => false); + const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => { + if (lifecycleRef.current.phase === "active") cancelGestureRef.current(); + lifecycleRef.current = { + kind, + phase: "active", + pointerId, + sessionEpoch: sessionEpochRef.current, + }; + gestureSelectedKeysRef.current = new Set(usePlayerStore.getState().selectedElementIds); + }, []); + const setDraggedClip = useCallback( + (next: DraggedClipState | null) => { + if (!next) { + cancelGestureRef.current(); + return; + } + beginGesture("drag", next.pointerId); + publishDraggedClip(next); + }, + [beginGesture, publishDraggedClip], + ); + const setResizingClip = useCallback( + (next: ResizingClipState | null) => { + if (!next) { + cancelGestureRef.current(); + return; + } + beginGesture("resize", next.pointerId); + publishResizingClip(next); + }, + [beginGesture, publishResizingClip], + ); const blockedClipRef = useRef(null); const suppressClickRef = useRef(false); - // Active multi-select group-resize session (restored from main 36413da7f): set - // lazily on the first resize pointermove when the grabbed clip is part of a - // capability-clean multi-selection (null ⇒ single-clip resize). Holds the - // pre-gesture snapshot so the non-grabbed members (previewed through the store) - // roll back on escape / cancel / failed persist. + // Active multi-select group-resize session, created lazily on first movement. + // It owns a projection only; canonical store timing changes at commit. const groupResizeRef = useRef(null); - // Restore the non-grabbed group members to their pre-gesture timing (the - // grabbed clip renders from resizingClip state, so it is never written during - // preview). `all` also restores the grabbed clip after a committed persist fails. - const restoreGroupResizeMembers = useCallback( - (session: TimelineGroupResizeSession, all = false) => { - for (const m of session.members) { - if (!all && m.key === session.grabbedKey) continue; - updateElement(m.key, { - start: m.start, - duration: m.duration, - playbackStart: m.playbackStart, - }); - } - }, - [updateElement], - ); - const onMoveElementRef = useRef(onMoveElement); onMoveElementRef.current = onMoveElement; const onMoveElementsRef = useRef(onMoveElements); @@ -237,7 +274,7 @@ export function useTimelineClipDrag({ trackOrder: trackOrderRef.current, rowHeights: rowGeometryRef?.current.rowHeights, elements: elementsRef.current, - selectedKeys: usePlayerStore.getState().selectedElementIds, + selectedKeys: gestureSelectedKeysRef.current, buildSnapTargets, audioTracks: dragAudioTracksRef.current, }); @@ -256,18 +293,19 @@ export function useTimelineClipDrag({ buildSnapTargets, }); const setResizeState = (v: ResizePreviewResult) => - setResizingClip((prev) => (prev ? { ...prev, started: true, ...v } : prev)); + publishResizingClip( + resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null, + ); // Group resize: a capability-clean multi-selection resizes rigidly by one // shared, member-clamped delta (legacy main 36413da7f). The grabbed clip - // drives the raw delta and renders from resizingClip state; non-grabbed - // members preview through the store (their store value stays pristine). + // drives the raw delta; every member renders from the coordinator projection. const grabbedKey = resize.element.key ?? resize.element.id; let session = groupResizeRef.current; if (!session || session.grabbedKey !== grabbedKey || session.edge !== resize.edge) { const members = buildTimelineGroupResizeMembers( elementsRef.current, - usePlayerStore.getState().selectedElementIds, + gestureSelectedKeysRef.current, grabbedKey, resize.edge, ); @@ -287,9 +325,9 @@ export function useTimelineClipDrag({ setResizeState(next); return; } - previewGroupResize(session, next, grabbedKey, updateElement, setResizeState); + previewGroupResize(session, next, setResizeState); }, - [scrollRef, ppsRef, buildSnapTargets, updateElement], + [scrollRef, ppsRef, buildSnapTargets, publishResizingClip], ); const applyResizePointerRef = useRef(applyResizePointer); applyResizePointerRef.current = applyResizePointer; @@ -300,9 +338,7 @@ export function useTimelineClipDrag({ cancelAnimationFrame(clipDragScrollRaf.current); clipDragScrollRaf.current = 0; } - // Gesture teardown: drop the frozen-per-gesture perf caches so the next drag - // rebuilds them against fresh store state (see snapTargetsCacheRef). Does NOT - // touch groupResizeRef — commit reads it after this runs. + // Gesture teardown: drop frozen caches so the next gesture reads fresh state. snapTargetsCacheRef.current.clear(); dragAudioTracksRef.current = null; }, []); @@ -317,16 +353,14 @@ export function useTimelineClipDrag({ if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return; if (drag) { - setDraggedClip((prev) => - prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev, - ); + publishDraggedClip(updateDraggedClipPreview(drag, pointer.clientX, pointer.clientY)); } else if (resize) { // Re-run the trim preview so the edge keeps tracking while the content // scrolls under the stationary pointer (scroll-compensated pointer x). applyResizePointerRef.current(resize, pointer.clientX); } clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll); - }, [scrollRef, updateDraggedClipPreview]); + }, [publishDraggedClip, scrollRef, updateDraggedClipPreview]); const syncClipDragAutoScroll = useCallback( (clientX: number, clientY: number) => { @@ -354,35 +388,44 @@ export function useTimelineClipDrag({ const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll); stopClipDragAutoScrollRef.current = stopClipDragAutoScroll; - useMountEffect(() => { - return mountTimelineClipDragGestureLifecycle({ - draggedClipRef, - resizingClipRef, - blockedClipRef, - groupResizeRef, - suppressClickRef, - elementsRef, - trackOrderRef, - setDraggedClip, - setResizingClip, - setShowPopover, - setRangeSelectionRef, - applyResizePointerRef, - syncClipDragAutoScrollRef, - stopClipDragAutoScrollRef, - updateDraggedClipPreviewRef, - restoreGroupResizeMembers, - updateElement, - onMoveElementRef, - onMoveElementsRef, - onResizeElementRef, - onResizeElementsRef, - onBlockedEditAttemptRef, - readZIndexRef, + useMountEffect(() => + mountTimelineClipDragGestureLifecycle({ onStackingPatchesRef, refreshAfterLaneMoveRef, - }); - }); + readZIndexRef, + onBlockedEditAttemptRef, + onResizeElementsRef, + onResizeElementRef, + onMoveElementsRef, + onMoveElementRef, + updateElement, + publishDraggedClip, + updateDraggedClipPreviewRef, + stopClipDragAutoScrollRef, + syncClipDragAutoScrollRef, + applyResizePointerRef, + setRangeSelectionRef, + setShowPopover, + setResizingClipState, + setDraggedClipState, + trackOrderRef, + elementsRef, + gestureSelectedKeysRef, + suppressClickRef, + groupResizeRef, + blockedClipRef, + resizingClipRef, + draggedClipRef, + scrollRef, + cancelGestureRef, + sessionEpochRef, + lifecycleRef, + }), + ); + + useEffect(() => { + cancelGestureRef.current(); + }, [sessionEpoch]); return { draggedClip, @@ -391,7 +434,6 @@ export function useTimelineClipDrag({ setResizingClip, blockedClipRef, suppressClickRef, - syncClipDragAutoScroll, stopClipDragAutoScroll, }; } diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts index d0850bbe2c..e5bf746f5e 100644 --- a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts @@ -14,7 +14,7 @@ interface UseTimelineClipRenderWindowInput { duration: number; selectedElementId?: string; draggedElementId?: string; - resizingElementId?: string; + resizingElementIds?: readonly string[]; revealElementId?: string; focusedEaseElementId?: string; clipContextMenuElementId?: string; @@ -36,7 +36,7 @@ export function useTimelineClipRenderWindow({ duration, selectedElementId, draggedElementId, - resizingElementId, + resizingElementIds, revealElementId, focusedEaseElementId, clipContextMenuElementId, @@ -60,7 +60,7 @@ export function useTimelineClipRenderWindow({ [ selectedElementId, draggedElementId, - resizingElementId, + ...(resizingElementIds ?? []), revealElementId, focusedEaseElementId, clipContextMenuElementId, @@ -74,7 +74,7 @@ export function useTimelineClipRenderWindow({ focusedEaseElementId, focusedElementId, keyframeContextMenuElementId, - resizingElementId, + resizingElementIds, revealElementId, selectedElementId, ], diff --git a/packages/studio/src/player/components/useTimelineLaneMoveRefresh.ts b/packages/studio/src/player/components/useTimelineLaneMoveRefresh.ts new file mode 100644 index 0000000000..3c9fc23154 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineLaneMoveRefresh.ts @@ -0,0 +1,7 @@ +import { useCallback } from "react"; +import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext"; + +export function useTimelineLaneMoveRefresh(): () => void { + const setRefreshKey = useStudioPlaybackContextOptional()?.setRefreshKey; + return useCallback(() => setRefreshKey?.((key) => key + 1), [setRefreshKey]); +} diff --git a/packages/studio/src/player/components/useTimelineRowVirtualization.ts b/packages/studio/src/player/components/useTimelineRowVirtualization.ts index 97c39d4507..23297d435f 100644 --- a/packages/studio/src/player/components/useTimelineRowVirtualization.ts +++ b/packages/studio/src/player/components/useTimelineRowVirtualization.ts @@ -24,7 +24,7 @@ interface UseTimelineRowVirtualizationInput { selectedElementId: string | null; revealElementId: string | null; draggedRowKey?: number; - resizingRowKey?: number; + resizingElementIds?: readonly string[]; clipContextMenuRowKey?: number; keyframeContextMenuRowKey?: number; lastScrollLeftRef: RefObject; @@ -55,7 +55,7 @@ export function useTimelineRowVirtualization({ selectedElementId, revealElementId, draggedRowKey, - resizingRowKey, + resizingElementIds, clipContextMenuRowKey, keyframeContextMenuRowKey, lastScrollLeftRef, @@ -77,11 +77,18 @@ export function useTimelineRowVirtualization({ () => resolveTimelineFocusIdentity(elements, revealElementId), [elements, revealElementId], ); + const resizingRowKeys = useMemo( + () => + resizingElementIds + ?.map((elementId) => resolveTimelineFocusIdentity(elements, elementId)?.rowKey) + .filter((rowKey): rowKey is number => rowKey !== undefined) ?? [], + [elements, resizingElementIds], + ); const pinnedRowKeys = useMemo( () => [ draggedRowKey, - resizingRowKey, + ...resizingRowKeys, revealIdentity?.rowKey, clipContextMenuRowKey, keyframeContextMenuRowKey, @@ -90,7 +97,7 @@ export function useTimelineRowVirtualization({ clipContextMenuRowKey, draggedRowKey, keyframeContextMenuRowKey, - resizingRowKey, + resizingRowKeys, revealIdentity, ], );