mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
perf(studio): stabilize virtualized clip gestures (#2704)
This commit is contained in:
@@ -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({
|
||||
>
|
||||
<div
|
||||
ref={setScrollRef}
|
||||
// Stable owner for gestures that must survive virtual row/clip unmounts.
|
||||
data-timeline-scroll-viewport
|
||||
data-timeline-auto-scroll-left-inset={labelMode ? LABEL_COL_W : 0}
|
||||
tabIndex={-1}
|
||||
@@ -541,7 +535,6 @@ export const Timeline = memo(function Timeline({
|
||||
setResizingClip={setResizingClip}
|
||||
setDraggedClip={setDraggedClip}
|
||||
setSelectedElementId={setSelectedElementId}
|
||||
syncClipDragAutoScroll={syncClipDragAutoScroll}
|
||||
shiftClickClipRef={shiftClickClipRef}
|
||||
getPreviewElement={getPreviewElement}
|
||||
getTrackStyle={getTrackStyle}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { memo } from "react";
|
||||
import { TimelineRuler } from "./TimelineRuler";
|
||||
import { PlayheadIndicator } from "./PlayheadIndicator";
|
||||
import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing";
|
||||
import { getRenderedTimelineElement } from "./timelineTheme";
|
||||
import type { TimelineRangeSelection } from "./timelineEditing";
|
||||
import {
|
||||
RULER_H,
|
||||
CLIP_Y,
|
||||
@@ -19,13 +18,11 @@ import type { ResizingClipState } from "./useTimelineClipDrag";
|
||||
import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
import type { Rect } from "../../utils/marqueeGeometry";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineLanes } from "./TimelineLanes";
|
||||
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
||||
import { renderClipChildren } from "./timelineClipChildren";
|
||||
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
|
||||
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
||||
|
||||
interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
||||
major: number[];
|
||||
@@ -65,15 +62,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
const beatDragging = usePlayerStore((s) => 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 (
|
||||
<div
|
||||
className="relative"
|
||||
@@ -235,47 +207,18 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drag ghost */}
|
||||
{activeDraggedElement && activeDraggedPosition && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: activeDraggedPosition.top,
|
||||
left: activeDraggedPosition.left,
|
||||
width: Math.max(activeDraggedElement.duration * props.pps, 4),
|
||||
height: draggedClipHeight,
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<TimelineClip
|
||||
el={{ ...activeDraggedElement, start: 0 }}
|
||||
pps={props.pps}
|
||||
clipY={0}
|
||||
isSelected={
|
||||
props.selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
|
||||
}
|
||||
isHovered={false}
|
||||
isDragging={true}
|
||||
isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
|
||||
hasCustomContent={!!props.renderClipContent}
|
||||
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
|
||||
theme={props.theme}
|
||||
isComposition={!!activeDraggedElement.compositionSrc}
|
||||
onHoverStart={() => {}}
|
||||
onHoverEnd={() => {}}
|
||||
onResizeStart={() => {}}
|
||||
onClick={() => {}}
|
||||
onDoubleClick={() => {}}
|
||||
>
|
||||
{renderClipChildren(
|
||||
activeDraggedElement,
|
||||
props.getTrackStyle(activeDraggedElement.tag),
|
||||
props.renderClipContent,
|
||||
props.renderClipOverlay,
|
||||
)}
|
||||
</TimelineClip>
|
||||
</div>
|
||||
)}
|
||||
<TimelineGestureOverlay
|
||||
drag={draggedClip}
|
||||
scrollRef={scrollRef}
|
||||
pixelsPerSecond={props.pps}
|
||||
rowHeight={draggedClipHeight}
|
||||
selectedElementId={props.selectedElementId}
|
||||
currentTime={props.currentTime}
|
||||
theme={props.theme}
|
||||
getTrackStyle={props.getTrackStyle}
|
||||
renderClipContent={props.renderClipContent}
|
||||
renderClipOverlay={props.renderClipOverlay}
|
||||
/>
|
||||
|
||||
{/* Marquee (rubber-band) multi-select rectangle — mirrors the canvas
|
||||
MarqueeOverlay look: semi-transparent accent fill + dashed border. */}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-clip="true"
|
||||
data-el-id={el.key ?? el.id}
|
||||
data-clip={isGestureActor ? undefined : "true"}
|
||||
data-el-id={isGestureActor ? undefined : (el.key ?? el.id)}
|
||||
data-clip-start={el.start}
|
||||
data-clip-end={el.start + el.duration}
|
||||
data-clip-hidden={el.hidden ? "true" : undefined}
|
||||
data-active={isActive ? "" : undefined}
|
||||
tabIndex={-1}
|
||||
aria-hidden={isGestureActor ? "true" : undefined}
|
||||
tabIndex={isGestureActor ? undefined : -1}
|
||||
className={clipClassName}
|
||||
style={style}
|
||||
title={
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import { getTrackStyle } from "./useTimelineTrackLayout";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const drag: DraggedClipState = {
|
||||
pointerId: 0,
|
||||
element: { id: "hero", tag: "div", start: 2, duration: 3, track: 1 },
|
||||
originClientX: 100,
|
||||
originClientY: 100,
|
||||
originScrollLeft: 0,
|
||||
originScrollTop: 0,
|
||||
pointerClientX: 350,
|
||||
pointerClientY: 240,
|
||||
pointerOffsetX: 20,
|
||||
pointerOffsetY: 10,
|
||||
previewStart: 4,
|
||||
previewTrack: 2,
|
||||
insertRow: null,
|
||||
snapTime: null,
|
||||
snapType: null,
|
||||
started: true,
|
||||
};
|
||||
|
||||
afterEach(() => 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(
|
||||
<TimelineGestureOverlay
|
||||
drag={drag}
|
||||
scrollRef={{ current: scroll }}
|
||||
pixelsPerSecond={100}
|
||||
rowHeight={42}
|
||||
selectedElementId="hero"
|
||||
currentTime={4}
|
||||
theme={defaultTimelineTheme}
|
||||
getTrackStyle={getTrackStyle}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const actor = host.querySelector<HTMLElement>('[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(
|
||||
<TimelineGestureOverlay
|
||||
drag={null}
|
||||
scrollRef={{ current: null }}
|
||||
pixelsPerSecond={100}
|
||||
rowHeight={42}
|
||||
selectedElementId={null}
|
||||
currentTime={0}
|
||||
theme={defaultTimelineTheme}
|
||||
getTrackStyle={getTrackStyle}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(host.querySelector("[data-timeline-gesture-overlay]")).not.toBeNull();
|
||||
expect(host.querySelector("[data-timeline-gesture-actor]")).toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
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 (
|
||||
<div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none">
|
||||
{element && position && (
|
||||
<div
|
||||
data-timeline-gesture-actor={element.key ?? element.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: Math.max(element.duration * pixelsPerSecond, 4),
|
||||
height: rowHeight,
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<TimelineClip
|
||||
el={{ ...element, start: 0 }}
|
||||
pps={pixelsPerSecond}
|
||||
clipY={0}
|
||||
isSelected={selectedElementId === (element.key ?? element.id)}
|
||||
isHovered={false}
|
||||
isDragging
|
||||
isGestureActor
|
||||
isActive={isTimelineClipActive(element, currentTime)}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={getTimelineEditCapabilities(element)}
|
||||
theme={theme}
|
||||
isComposition={!!element.compositionSrc}
|
||||
onHoverStart={() => {}}
|
||||
onHoverEnd={() => {}}
|
||||
onResizeStart={() => {}}
|
||||
onClick={() => {}}
|
||||
onDoubleClick={() => {}}
|
||||
>
|
||||
{renderClipChildren(
|
||||
element,
|
||||
getTrackStyle(element.tag),
|
||||
renderClipContent,
|
||||
renderClipOverlay,
|
||||
)}
|
||||
</TimelineClip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof usePlayerStore.getState>["updateElement"];
|
||||
|
||||
interface TimelineClipDragGestureLifecycleInput {
|
||||
lifecycleRef: RefObject<TimelineGestureLifecycle>;
|
||||
sessionEpochRef: RefObject<number>;
|
||||
cancelGestureRef: RefObject<
|
||||
(options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
|
||||
>;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
draggedClipRef: RefObject<DraggedClipState | null>;
|
||||
resizingClipRef: RefObject<ResizingClipState | null>;
|
||||
blockedClipRef: RefObject<BlockedClipState | null>;
|
||||
groupResizeRef: RefObject<TimelineGroupResizeSession | null>;
|
||||
suppressClickRef: RefObject<boolean>;
|
||||
gestureSelectedKeysRef: RefObject<ReadonlySet<string>>;
|
||||
elementsRef: RefObject<TimelineElement[]>;
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
setDraggedClip: Dispatch<SetStateAction<DraggedClipState | null>>;
|
||||
setResizingClip: Dispatch<SetStateAction<ResizingClipState | null>>;
|
||||
setDraggedClipState: Dispatch<SetStateAction<DraggedClipState | null>>;
|
||||
setResizingClipState: Dispatch<SetStateAction<ResizingClipState | null>>;
|
||||
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<TimelineEditCallbacks["onMoveElement"]>;
|
||||
onMoveElementsRef: RefObject<TimelineEditCallbacks["onMoveElements"]>;
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement, "scrollLeft" | "scrollTop" | "getBoundingClientRect">;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,19 @@ export interface DragPreviewContext {
|
||||
audioTracks?: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
/** Content-space position for the stable viewport drag actor. */
|
||||
export function getTimelineDragOverlayPosition(
|
||||
drag: DraggedClipState,
|
||||
scroll: Pick<HTMLDivElement, "scrollLeft" | "scrollTop" | "getBoundingClientRect"> | 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BlockedClipState | null> | 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(<Harness />);
|
||||
const root = mountReactHarness(<Harness sessionEpoch={epoch} />);
|
||||
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() {
|
||||
async dropPointer(pointerId = 0) {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
|
||||
dispatchPointer("pointerup", 0, pointerId);
|
||||
});
|
||||
},
|
||||
async moveAndDropPointer(clientX: number, pointerId = 0) {
|
||||
await act(async () => {
|
||||
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(<Harness sessionEpoch={epoch} />));
|
||||
},
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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<unknown> | 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<DraggedClipState | null>(null);
|
||||
const [draggedClip, setDraggedClipState] = useState<DraggedClipState | null>(null);
|
||||
const draggedClipRef = useRef<DraggedClipState | null>(null);
|
||||
draggedClipRef.current = draggedClip;
|
||||
const publishDraggedClip = useCallback((next: DraggedClipState | null) => {
|
||||
draggedClipRef.current = next;
|
||||
setDraggedClipState(next);
|
||||
}, []);
|
||||
|
||||
const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
|
||||
const [resizingClip, setResizingClipState] = useState<ResizingClipState | null>(null);
|
||||
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
||||
resizingClipRef.current = resizingClip;
|
||||
const publishResizingClip = useCallback((next: ResizingClipState | null) => {
|
||||
resizingClipRef.current = next;
|
||||
setResizingClipState(next);
|
||||
}, []);
|
||||
|
||||
const lifecycleRef = useRef<TimelineGestureLifecycle>({
|
||||
kind: null,
|
||||
phase: "complete",
|
||||
pointerId: null,
|
||||
sessionEpoch,
|
||||
});
|
||||
const sessionEpochRef = useRef(sessionEpoch);
|
||||
sessionEpochRef.current = sessionEpoch;
|
||||
const gestureSelectedKeysRef = useRef<ReadonlySet<string>>(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<BlockedClipState | null>(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<TimelineGroupResizeSession | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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<number>;
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user