@hyperframes/studio 0.7.90 → 0.7.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -1,13 +1,475 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
type MouseEvent as ReactMouseEvent,
|
|
4
|
+
type PointerEvent as ReactPointerEvent,
|
|
5
|
+
} from "react";
|
|
6
|
+
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
|
7
|
+
import {
|
|
8
|
+
KEYFRAME_DRAG_THRESHOLD_PX,
|
|
9
|
+
previewClipPct,
|
|
10
|
+
resolveKeyframeDrag,
|
|
11
|
+
} from "../../components/editor/keyframeDrag";
|
|
2
12
|
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
|
13
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
3
14
|
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
|
4
15
|
import { usePlayerStore } from "../store/playerStore";
|
|
5
16
|
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
17
|
+
import {
|
|
18
|
+
applyTimelineHorizontalAutoScrollStep,
|
|
19
|
+
resolveTimelineAutoScrollLoopAction,
|
|
20
|
+
} from "./timelineEditing";
|
|
6
21
|
import {
|
|
7
22
|
timelineKeyframeSelectionKey,
|
|
8
23
|
type TimelineKeyframeTarget,
|
|
9
24
|
} from "./timelineKeyframeIdentity";
|
|
10
25
|
|
|
26
|
+
interface TimelineRetimeKeyframe {
|
|
27
|
+
percentage: number;
|
|
28
|
+
tweenPercentage?: number;
|
|
29
|
+
propertyGroup?: string;
|
|
30
|
+
animationId?: string;
|
|
31
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TimelineKeyframeRetimeInput {
|
|
35
|
+
event: ReactPointerEvent<HTMLElement>;
|
|
36
|
+
elementId: string;
|
|
37
|
+
keyframeKey: string;
|
|
38
|
+
target: TimelineKeyframeTarget;
|
|
39
|
+
keyframes: readonly TimelineRetimeKeyframe[];
|
|
40
|
+
clipWidthPx: number;
|
|
41
|
+
draggedIndex: number;
|
|
42
|
+
sortedClipPercentages: readonly number[];
|
|
43
|
+
onMove: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
|
|
44
|
+
onSelect: (target: TimelineKeyframeTarget, additive: boolean) => void;
|
|
45
|
+
suppressNextClick: () => void;
|
|
46
|
+
/**
|
|
47
|
+
* Selection key of a cache keyframe, used to retire a pending entry once the
|
|
48
|
+
* authoritative cache reflects THAT keyframe at its destination. Without it a
|
|
49
|
+
* bare "some keyframe is near that %" test retires the entry whenever an
|
|
50
|
+
* unrelated sibling happens to sit there, which is easy to hit on an evenly
|
|
51
|
+
* spaced row.
|
|
52
|
+
*/
|
|
53
|
+
keyframeKeyOf: (keyframe: TimelineRetimeKeyframe) => string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface PendingTimelineKeyframeRetime {
|
|
57
|
+
elementId: string;
|
|
58
|
+
clipPercentage: number;
|
|
59
|
+
tweenPercentage: number;
|
|
60
|
+
destinationKeyframeKey: string;
|
|
61
|
+
sessionEpoch: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface TimelineKeyframeRetimePreview {
|
|
65
|
+
keyframeKey: string;
|
|
66
|
+
clipPercentage: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TimelineKeyframeRetimeActor extends TimelineKeyframeRetimeInput {
|
|
70
|
+
phase: "active" | "committing" | "cancelled" | "complete";
|
|
71
|
+
pointerId: number | null;
|
|
72
|
+
pointerDownX: number;
|
|
73
|
+
lastClientX: number;
|
|
74
|
+
lastClientY: number;
|
|
75
|
+
originScrollLeft: number;
|
|
76
|
+
fromClipPercentage: number;
|
|
77
|
+
moved: boolean;
|
|
78
|
+
sessionEpoch: number;
|
|
79
|
+
sourceWasPresent: boolean;
|
|
80
|
+
scrollRaf: number;
|
|
81
|
+
unsubscribeStore: (() => void) | null;
|
|
82
|
+
teardownListeners: (() => void) | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface TimelineKeyframeRetimeCoordinator {
|
|
86
|
+
actor: TimelineKeyframeRetimeActor | null;
|
|
87
|
+
pending: Map<string, PendingTimelineKeyframeRetime>;
|
|
88
|
+
preview: TimelineKeyframeRetimePreview | null;
|
|
89
|
+
previewListeners: Set<(preview: TimelineKeyframeRetimePreview | null) => void>;
|
|
90
|
+
/**
|
|
91
|
+
* The most recent retime dispatched through this viewport, whichever diamond
|
|
92
|
+
* it came from. Selection is viewport-wide, so "is my revert still relevant"
|
|
93
|
+
* is a viewport-wide question, not a per-keyframe one.
|
|
94
|
+
*/
|
|
95
|
+
latest: PendingTimelineKeyframeRetime | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type TimelineRetimePointerEvent = Pick<
|
|
99
|
+
PointerEvent,
|
|
100
|
+
"clientX" | "clientY" | "pointerId" | "shiftKey"
|
|
101
|
+
>;
|
|
102
|
+
|
|
103
|
+
const keyframeRetimeCoordinators = new WeakMap<EventTarget, TimelineKeyframeRetimeCoordinator>();
|
|
104
|
+
|
|
105
|
+
function getRetimeOwner(target: HTMLElement): EventTarget {
|
|
106
|
+
return target.closest<HTMLElement>("[data-timeline-scroll-viewport]") ?? target.ownerDocument;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getRetimeCoordinator(owner: EventTarget): TimelineKeyframeRetimeCoordinator {
|
|
110
|
+
const existing = keyframeRetimeCoordinators.get(owner);
|
|
111
|
+
if (existing) return existing;
|
|
112
|
+
const coordinator: TimelineKeyframeRetimeCoordinator = {
|
|
113
|
+
actor: null,
|
|
114
|
+
pending: new Map(),
|
|
115
|
+
preview: null,
|
|
116
|
+
previewListeners: new Set(),
|
|
117
|
+
latest: null,
|
|
118
|
+
};
|
|
119
|
+
keyframeRetimeCoordinators.set(owner, coordinator);
|
|
120
|
+
return coordinator;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stablePointerId(pointerId: number): number | null {
|
|
124
|
+
return Number.isFinite(pointerId) ? pointerId : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The retime destinations already dispatched from `source`'s viewport but not
|
|
129
|
+
* yet reflected in the keyframe cache. A renderer clamping a drag against its
|
|
130
|
+
* neighbours has to compose these in, or a second drag can cross a neighbour
|
|
131
|
+
* that already moved past it.
|
|
132
|
+
*/
|
|
133
|
+
export function readPendingTimelineKeyframeRetimes(
|
|
134
|
+
source: HTMLElement | null | undefined,
|
|
135
|
+
): ReadonlyMap<string, { clipPercentage: number; tweenPercentage: number }> {
|
|
136
|
+
if (!source) return EMPTY_PENDING_RETIMES;
|
|
137
|
+
return keyframeRetimeCoordinators.get(getRetimeOwner(source))?.pending ?? EMPTY_PENDING_RETIMES;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const EMPTY_PENDING_RETIMES: ReadonlyMap<
|
|
141
|
+
string,
|
|
142
|
+
{ clipPercentage: number; tweenPercentage: number }
|
|
143
|
+
> = new Map();
|
|
144
|
+
|
|
145
|
+
function publishRetimePreview(
|
|
146
|
+
coordinator: TimelineKeyframeRetimeCoordinator,
|
|
147
|
+
preview: TimelineKeyframeRetimePreview | null,
|
|
148
|
+
): void {
|
|
149
|
+
coordinator.preview = preview;
|
|
150
|
+
for (const listener of coordinator.previewListeners) listener(preview);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function subscribeTimelineKeyframeRetimePreview(
|
|
154
|
+
source: HTMLElement,
|
|
155
|
+
listener: (preview: TimelineKeyframeRetimePreview | null) => void,
|
|
156
|
+
): () => void {
|
|
157
|
+
const coordinator = getRetimeCoordinator(getRetimeOwner(source));
|
|
158
|
+
coordinator.previewListeners.add(listener);
|
|
159
|
+
listener(coordinator.preview);
|
|
160
|
+
return () => coordinator.previewListeners.delete(listener);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveRetimeTweenPercentage(
|
|
164
|
+
actor: TimelineKeyframeRetimeActor,
|
|
165
|
+
toClipPercentage: number,
|
|
166
|
+
): number {
|
|
167
|
+
const animationKeyframes =
|
|
168
|
+
actor.target.animationId === undefined
|
|
169
|
+
? actor.keyframes
|
|
170
|
+
: actor.keyframes.filter((keyframe) => keyframe.animationId === actor.target.animationId);
|
|
171
|
+
const tweenPercentages = animationKeyframes
|
|
172
|
+
.map((keyframe) => keyframe.tweenPercentage)
|
|
173
|
+
.filter((value): value is number => typeof value === "number");
|
|
174
|
+
const mapped = clipToTweenPercentage(animationKeyframes, toClipPercentage);
|
|
175
|
+
if (tweenPercentages.length === 0) return mapped;
|
|
176
|
+
return Math.max(Math.min(...tweenPercentages), Math.min(Math.max(...tweenPercentages), mapped));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface TimelineKeyframeRetimeHandle {
|
|
180
|
+
update: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
181
|
+
commit: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
182
|
+
cancel: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Starts a keyframe retime on the stable timeline viewport. The row/button is
|
|
187
|
+
* only an entry point: window listeners own the gesture through virtualization.
|
|
188
|
+
*/
|
|
189
|
+
export function beginTimelineKeyframeRetime(
|
|
190
|
+
input: TimelineKeyframeRetimeInput,
|
|
191
|
+
): TimelineKeyframeRetimeHandle {
|
|
192
|
+
const source = input.event.currentTarget;
|
|
193
|
+
const owner = getRetimeOwner(source);
|
|
194
|
+
const viewport = owner instanceof HTMLElement ? owner : null;
|
|
195
|
+
const coordinator = getRetimeCoordinator(owner);
|
|
196
|
+
const sessionEpoch = usePlayerStore.getState().timelineSessionEpoch;
|
|
197
|
+
|
|
198
|
+
const cancel = (actor: TimelineKeyframeRetimeActor) => {
|
|
199
|
+
if (actor.phase !== "active") return;
|
|
200
|
+
actor.phase = "cancelled";
|
|
201
|
+
publishRetimePreview(coordinator, null);
|
|
202
|
+
if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
|
|
203
|
+
actor.unsubscribeStore?.();
|
|
204
|
+
actor.teardownListeners?.();
|
|
205
|
+
if (viewport && actor.pointerId !== null) {
|
|
206
|
+
try {
|
|
207
|
+
viewport.releasePointerCapture(actor.pointerId);
|
|
208
|
+
} catch {
|
|
209
|
+
// Window listeners remain the native fallback when capture is unavailable.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (coordinator.actor === actor) {
|
|
213
|
+
coordinator.actor = null;
|
|
214
|
+
}
|
|
215
|
+
actor.phase = "complete";
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (coordinator.actor) cancel(coordinator.actor);
|
|
219
|
+
|
|
220
|
+
for (const [key, pending] of coordinator.pending) {
|
|
221
|
+
if (pending.sessionEpoch !== sessionEpoch) coordinator.pending.delete(key);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const [key, pending] of coordinator.pending) {
|
|
225
|
+
// Tolerance, not equality: cache writers round clip %s, so an exact check
|
|
226
|
+
// would leak an entry after every successful retime.
|
|
227
|
+
if (
|
|
228
|
+
pending.elementId === input.elementId &&
|
|
229
|
+
input.keyframes.some(
|
|
230
|
+
(keyframe) =>
|
|
231
|
+
input.keyframeKeyOf(keyframe) === pending.destinationKeyframeKey &&
|
|
232
|
+
Math.abs(keyframe.percentage - pending.clipPercentage) < 0.2,
|
|
233
|
+
)
|
|
234
|
+
) {
|
|
235
|
+
coordinator.pending.delete(key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pending = coordinator.pending.get(input.keyframeKey);
|
|
240
|
+
const actor: TimelineKeyframeRetimeActor = {
|
|
241
|
+
...input,
|
|
242
|
+
phase: "active",
|
|
243
|
+
pointerId: stablePointerId(input.event.pointerId),
|
|
244
|
+
pointerDownX: input.event.clientX,
|
|
245
|
+
lastClientX: input.event.clientX,
|
|
246
|
+
lastClientY: input.event.clientY,
|
|
247
|
+
originScrollLeft: viewport?.scrollLeft ?? 0,
|
|
248
|
+
fromClipPercentage: pending?.clipPercentage ?? input.target.percentage,
|
|
249
|
+
moved: false,
|
|
250
|
+
sessionEpoch,
|
|
251
|
+
sourceWasPresent: usePlayerStore
|
|
252
|
+
.getState()
|
|
253
|
+
.elements.some((element) => (element.key ?? element.id) === input.elementId),
|
|
254
|
+
scrollRaf: 0,
|
|
255
|
+
unsubscribeStore: null,
|
|
256
|
+
teardownListeners: null,
|
|
257
|
+
};
|
|
258
|
+
coordinator.actor = actor;
|
|
259
|
+
|
|
260
|
+
const matchesPointer = (event: TimelineRetimePointerEvent) =>
|
|
261
|
+
actor.pointerId === null || event.pointerId === actor.pointerId;
|
|
262
|
+
const pointerXWithScroll = () =>
|
|
263
|
+
actor.lastClientX + (viewport?.scrollLeft ?? 0) - actor.originScrollLeft;
|
|
264
|
+
const publishPreview = () => {
|
|
265
|
+
publishRetimePreview(coordinator, {
|
|
266
|
+
keyframeKey: actor.keyframeKey,
|
|
267
|
+
clipPercentage: previewClipPct({
|
|
268
|
+
pointerDownX: actor.pointerDownX,
|
|
269
|
+
pointerMoveX: pointerXWithScroll(),
|
|
270
|
+
clipWidthPx: actor.clipWidthPx,
|
|
271
|
+
draggedClipPct: actor.fromClipPercentage,
|
|
272
|
+
draggedIndex: actor.draggedIndex,
|
|
273
|
+
sortedClipPcts: actor.sortedClipPercentages,
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
const stopAutoScroll = () => {
|
|
278
|
+
if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
|
|
279
|
+
actor.scrollRaf = 0;
|
|
280
|
+
};
|
|
281
|
+
const stepAutoScroll = () => {
|
|
282
|
+
actor.scrollRaf = 0;
|
|
283
|
+
if (
|
|
284
|
+
actor.phase !== "active" ||
|
|
285
|
+
!viewport ||
|
|
286
|
+
!applyTimelineHorizontalAutoScrollStep(viewport, actor.lastClientX)
|
|
287
|
+
) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
publishPreview();
|
|
291
|
+
actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
|
|
292
|
+
};
|
|
293
|
+
const syncAutoScroll = () => {
|
|
294
|
+
if (!viewport || !actor.moved) return;
|
|
295
|
+
const action = resolveTimelineAutoScrollLoopAction(
|
|
296
|
+
viewport,
|
|
297
|
+
actor.lastClientX,
|
|
298
|
+
actor.lastClientY,
|
|
299
|
+
actor.scrollRaf !== 0,
|
|
300
|
+
);
|
|
301
|
+
if (action === "stop") stopAutoScroll();
|
|
302
|
+
else if (action === "start") actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
|
|
303
|
+
};
|
|
304
|
+
const teardown = () => {
|
|
305
|
+
stopAutoScroll();
|
|
306
|
+
actor.unsubscribeStore?.();
|
|
307
|
+
actor.unsubscribeStore = null;
|
|
308
|
+
actor.teardownListeners?.();
|
|
309
|
+
actor.teardownListeners = null;
|
|
310
|
+
};
|
|
311
|
+
const releaseCapture = () => {
|
|
312
|
+
if (!viewport || actor.pointerId === null) return;
|
|
313
|
+
try {
|
|
314
|
+
viewport.releasePointerCapture(actor.pointerId);
|
|
315
|
+
} catch {
|
|
316
|
+
// Capture may already have been released by the browser.
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const claimActorForCommit = (event: TimelineRetimePointerEvent): boolean => {
|
|
321
|
+
if (actor.phase !== "active" || !matchesPointer(event)) return false;
|
|
322
|
+
if (actor.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch) {
|
|
323
|
+
cancel(actor);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
actor.phase = "committing";
|
|
327
|
+
actor.lastClientX = event.clientX;
|
|
328
|
+
actor.lastClientY = event.clientY;
|
|
329
|
+
teardown();
|
|
330
|
+
releaseCapture();
|
|
331
|
+
publishRetimePreview(coordinator, null);
|
|
332
|
+
if (coordinator.actor === actor) {
|
|
333
|
+
coordinator.actor = null;
|
|
334
|
+
}
|
|
335
|
+
actor.suppressNextClick();
|
|
336
|
+
return true;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const commitMove = (toClipPercentage: number) => {
|
|
340
|
+
const newTweenPercentage = resolveRetimeTweenPercentage(actor, toClipPercentage);
|
|
341
|
+
const pendingBefore = coordinator.pending.get(actor.keyframeKey);
|
|
342
|
+
const fromTarget = pendingBefore
|
|
343
|
+
? {
|
|
344
|
+
...actor.target,
|
|
345
|
+
percentage: pendingBefore.clipPercentage,
|
|
346
|
+
tweenPercentage: pendingBefore.tweenPercentage,
|
|
347
|
+
}
|
|
348
|
+
: actor.target;
|
|
349
|
+
const nextPending = {
|
|
350
|
+
elementId: actor.elementId,
|
|
351
|
+
clipPercentage: toClipPercentage,
|
|
352
|
+
tweenPercentage: newTweenPercentage,
|
|
353
|
+
destinationKeyframeKey: timelineKeyframeSelectionKey(actor.elementId, {
|
|
354
|
+
...actor.target,
|
|
355
|
+
percentage: toClipPercentage,
|
|
356
|
+
tweenPercentage: newTweenPercentage,
|
|
357
|
+
}),
|
|
358
|
+
sessionEpoch: actor.sessionEpoch,
|
|
359
|
+
};
|
|
360
|
+
coordinator.pending.set(actor.keyframeKey, nextPending);
|
|
361
|
+
coordinator.latest = nextPending;
|
|
362
|
+
const clearPending = () => {
|
|
363
|
+
if (coordinator.pending.get(actor.keyframeKey) === nextPending) {
|
|
364
|
+
coordinator.pending.delete(actor.keyframeKey);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
// A rejected drop (the destination time is already occupied) snaps the
|
|
368
|
+
// diamond back to its source position, so the pending entry AND the
|
|
369
|
+
// selection have to revert with it: parking on the ghost drop position
|
|
370
|
+
// strands the playhead + selection on a keyframe that is not there.
|
|
371
|
+
const revertRetime = () => {
|
|
372
|
+
// Only the newest gesture owns the selection. A rejected first drag
|
|
373
|
+
// whose commit settles after a second one started would otherwise park
|
|
374
|
+
// the selection back on ITS source keyframe, undoing a retime the user
|
|
375
|
+
// has already made and moving the playhead with it.
|
|
376
|
+
const isLatest = coordinator.latest === nextPending;
|
|
377
|
+
clearPending();
|
|
378
|
+
if (isLatest) actor.onSelect(fromTarget, false);
|
|
379
|
+
};
|
|
380
|
+
void actor.onMove(fromTarget, toClipPercentage).then((committed) => {
|
|
381
|
+
if (!committed) revertRetime();
|
|
382
|
+
}, revertRetime);
|
|
383
|
+
actor.onSelect(
|
|
384
|
+
{
|
|
385
|
+
...actor.target,
|
|
386
|
+
percentage: toClipPercentage,
|
|
387
|
+
tweenPercentage: newTweenPercentage,
|
|
388
|
+
},
|
|
389
|
+
false,
|
|
390
|
+
);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const finishCommit = (event: TimelineRetimePointerEvent) => {
|
|
394
|
+
if (!claimActorForCommit(event)) return;
|
|
395
|
+
const result = resolveKeyframeDrag({
|
|
396
|
+
pointerDownX: actor.pointerDownX,
|
|
397
|
+
pointerUpX: pointerXWithScroll(),
|
|
398
|
+
clipWidthPx: actor.clipWidthPx,
|
|
399
|
+
draggedClipPct: actor.fromClipPercentage,
|
|
400
|
+
draggedIndex: actor.draggedIndex,
|
|
401
|
+
sortedClipPcts: actor.sortedClipPercentages,
|
|
402
|
+
});
|
|
403
|
+
if (result.kind === "move" && result.toClipPct !== undefined) {
|
|
404
|
+
commitMove(result.toClipPct);
|
|
405
|
+
} else {
|
|
406
|
+
actor.onSelect(actor.target, event.shiftKey);
|
|
407
|
+
}
|
|
408
|
+
actor.phase = "complete";
|
|
409
|
+
};
|
|
410
|
+
const onPointerMove = (event: TimelineRetimePointerEvent) => {
|
|
411
|
+
if (actor.phase !== "active" || !matchesPointer(event)) return;
|
|
412
|
+
actor.lastClientX = event.clientX;
|
|
413
|
+
actor.lastClientY = event.clientY;
|
|
414
|
+
if (
|
|
415
|
+
!actor.moved &&
|
|
416
|
+
Math.abs(pointerXWithScroll() - actor.pointerDownX) >= KEYFRAME_DRAG_THRESHOLD_PX
|
|
417
|
+
) {
|
|
418
|
+
actor.moved = true;
|
|
419
|
+
}
|
|
420
|
+
if (actor.moved) publishPreview();
|
|
421
|
+
syncAutoScroll();
|
|
422
|
+
};
|
|
423
|
+
const onPointerUp = (event: TimelineRetimePointerEvent) => finishCommit(event);
|
|
424
|
+
const onPointerCancel = (event: TimelineRetimePointerEvent) => {
|
|
425
|
+
if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
|
|
426
|
+
};
|
|
427
|
+
const onLostPointerCapture = (event: PointerEvent) => {
|
|
428
|
+
if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
|
|
429
|
+
};
|
|
430
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
431
|
+
if (event.key === "Escape") cancel(actor);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
window.addEventListener("pointermove", onPointerMove, true);
|
|
435
|
+
window.addEventListener("pointerup", onPointerUp, true);
|
|
436
|
+
window.addEventListener("pointercancel", onPointerCancel, true);
|
|
437
|
+
window.addEventListener("keydown", onKeyDown);
|
|
438
|
+
viewport?.addEventListener("lostpointercapture", onLostPointerCapture);
|
|
439
|
+
actor.teardownListeners = () => {
|
|
440
|
+
window.removeEventListener("pointermove", onPointerMove, true);
|
|
441
|
+
window.removeEventListener("pointerup", onPointerUp, true);
|
|
442
|
+
window.removeEventListener("pointercancel", onPointerCancel, true);
|
|
443
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
444
|
+
viewport?.removeEventListener("lostpointercapture", onLostPointerCapture);
|
|
445
|
+
};
|
|
446
|
+
actor.unsubscribeStore = usePlayerStore.subscribe((state) => {
|
|
447
|
+
const sourceStillPresent = state.elements.some(
|
|
448
|
+
(element) => (element.key ?? element.id) === actor.elementId,
|
|
449
|
+
);
|
|
450
|
+
if (state.timelineSessionEpoch !== actor.sessionEpoch) {
|
|
451
|
+
coordinator.pending.clear();
|
|
452
|
+
coordinator.latest = null;
|
|
453
|
+
cancel(actor);
|
|
454
|
+
} else if (actor.sourceWasPresent && !sourceStillPresent) {
|
|
455
|
+
for (const [key, pending] of coordinator.pending) {
|
|
456
|
+
if (pending.elementId === actor.elementId) coordinator.pending.delete(key);
|
|
457
|
+
}
|
|
458
|
+
if (coordinator.latest?.elementId === actor.elementId) coordinator.latest = null;
|
|
459
|
+
cancel(actor);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
if (viewport && actor.pointerId !== null) {
|
|
464
|
+
try {
|
|
465
|
+
viewport.setPointerCapture(actor.pointerId);
|
|
466
|
+
} catch {
|
|
467
|
+
// Window listeners are the native fallback when capture is unavailable.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return { update: onPointerMove, commit: onPointerUp, cancel: onPointerCancel };
|
|
471
|
+
}
|
|
472
|
+
|
|
11
473
|
interface UseTimelineKeyframeHandlersInput {
|
|
12
474
|
expandedElements: TimelineElement[];
|
|
13
475
|
keyframeCache: Map<string, KeyframeCacheEntry>;
|
|
@@ -88,12 +550,13 @@ export function useTimelineKeyframeHandlers({
|
|
|
88
550
|
setKfContextMenu({
|
|
89
551
|
x: e.clientX + 4,
|
|
90
552
|
y: e.clientY + 2,
|
|
553
|
+
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
|
554
|
+
element: el,
|
|
91
555
|
elementId: elId,
|
|
92
556
|
percentage: target.percentage,
|
|
93
557
|
tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
|
|
94
558
|
propertyGroup: target.propertyGroup,
|
|
95
559
|
animationId: target.animationId,
|
|
96
|
-
element: el,
|
|
97
560
|
currentEase: kf?.ease ?? kfData?.ease,
|
|
98
561
|
});
|
|
99
562
|
},
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
|
3
|
+
|
|
4
|
+
export function useTimelineLaneMoveRefresh(): () => void {
|
|
5
|
+
const setRefreshKey = useStudioPlaybackContextOptional()?.setRefreshKey;
|
|
6
|
+
return useCallback(() => setRefreshKey?.((key) => key + 1), [setRefreshKey]);
|
|
7
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
5
|
+
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
6
|
+
import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
|
|
9
|
+
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
|
|
10
|
+
|
|
11
|
+
interface TimelineLogicalFocusInput {
|
|
12
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
13
|
+
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
|
|
14
|
+
layout: { displayTrackOrder: readonly number[]; rowGeometry: TimelineRowGeometry };
|
|
15
|
+
laneCounts: ReadonlyMap<string, number>;
|
|
16
|
+
selectedElementId: string | null;
|
|
17
|
+
selectedElementIds: ReadonlySet<string>;
|
|
18
|
+
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
|
|
19
|
+
elements: readonly TimelineElement[];
|
|
20
|
+
pixelsPerSecond: number;
|
|
21
|
+
contentOrigin: number;
|
|
22
|
+
allowHorizontal: boolean;
|
|
23
|
+
viewport: TimelineScrollViewportSnapshot;
|
|
24
|
+
sessionEpoch: number;
|
|
25
|
+
draggedRowKey?: number;
|
|
26
|
+
resizingElementIds?: readonly string[];
|
|
27
|
+
clipContextMenuRowKey?: number;
|
|
28
|
+
keyframeContextMenuRowKey?: number;
|
|
29
|
+
lastScrollLeftRef: RefObject<number>;
|
|
30
|
+
syncScrollViewport: (element: HTMLDivElement) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
|
34
|
+
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
|
|
35
|
+
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
|
36
|
+
const logicalRows = useTimelineLogicalRows({
|
|
37
|
+
tracks: input.tracks,
|
|
38
|
+
displayTrackOrder: input.layout.displayTrackOrder,
|
|
39
|
+
laneCounts: input.laneCounts,
|
|
40
|
+
selectedElementId: input.selectedElementId,
|
|
41
|
+
selectedElementIds: input.selectedElementIds,
|
|
42
|
+
expandedClipIds,
|
|
43
|
+
gsapAnimations: input.gsapAnimations,
|
|
44
|
+
});
|
|
45
|
+
const focus = useTimelineFocusCoordinator({
|
|
46
|
+
scrollRef: input.scrollRef,
|
|
47
|
+
logicalRows,
|
|
48
|
+
elements: input.elements,
|
|
49
|
+
rowGeometry: input.layout.rowGeometry,
|
|
50
|
+
pixelsPerSecond: input.pixelsPerSecond,
|
|
51
|
+
contentOrigin: input.contentOrigin,
|
|
52
|
+
allowHorizontal: input.allowHorizontal,
|
|
53
|
+
viewportVersion: input.viewport,
|
|
54
|
+
projectId,
|
|
55
|
+
sessionEpoch: input.sessionEpoch,
|
|
56
|
+
syncScrollViewport: input.syncScrollViewport,
|
|
57
|
+
});
|
|
58
|
+
const rows = useTimelineRowVirtualization({
|
|
59
|
+
scrollRef: input.scrollRef,
|
|
60
|
+
viewport: input.viewport,
|
|
61
|
+
rowGeometry: input.layout.rowGeometry,
|
|
62
|
+
sessionEpoch: input.sessionEpoch,
|
|
63
|
+
elements: input.elements,
|
|
64
|
+
selectedElementId: input.selectedElementId,
|
|
65
|
+
focusedRowKey: focus.focusedRowKey,
|
|
66
|
+
draggedRowKey: input.draggedRowKey,
|
|
67
|
+
resizingElementIds: input.resizingElementIds,
|
|
68
|
+
clipContextMenuRowKey: input.clipContextMenuRowKey,
|
|
69
|
+
keyframeContextMenuRowKey: input.keyframeContextMenuRowKey,
|
|
70
|
+
lastScrollLeftRef: input.lastScrollLeftRef,
|
|
71
|
+
syncScrollViewport: input.syncScrollViewport,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
logicalRows,
|
|
75
|
+
...focus,
|
|
76
|
+
rowVirtualizationActive: rows.enabled,
|
|
77
|
+
virtualRows: rows.virtualRows,
|
|
78
|
+
timelineFocusProps: rows.timelineFocusProps,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
9
|
+
import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
|
|
10
|
+
|
|
11
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
12
|
+
|
|
13
|
+
const tracks = Array.from(
|
|
14
|
+
{ length: 1_000 },
|
|
15
|
+
(_, track) =>
|
|
16
|
+
[
|
|
17
|
+
track,
|
|
18
|
+
[{ id: `clip-${track}`, tag: "div", track, start: track, duration: 1 }],
|
|
19
|
+
] as const satisfies readonly [number, readonly TimelineElement[]],
|
|
20
|
+
);
|
|
21
|
+
const displayTrackOrder = tracks.map(([track]) => track);
|
|
22
|
+
const laneCounts = new Map<string, number>();
|
|
23
|
+
const selectedElementIds = new Set<string>();
|
|
24
|
+
const expandedClipIds = new Set<string>();
|
|
25
|
+
const gsapAnimations = new Map();
|
|
26
|
+
|
|
27
|
+
function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]> }) {
|
|
28
|
+
usePlayerStore((state) => state.requestedSeekTime);
|
|
29
|
+
const logicalRows = useTimelineLogicalRows({
|
|
30
|
+
tracks,
|
|
31
|
+
displayTrackOrder,
|
|
32
|
+
laneCounts,
|
|
33
|
+
selectedElementId: null,
|
|
34
|
+
selectedElementIds,
|
|
35
|
+
expandedClipIds,
|
|
36
|
+
gsapAnimations,
|
|
37
|
+
});
|
|
38
|
+
snapshots.push(logicalRows);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => usePlayerStore.getState().reset());
|
|
43
|
+
|
|
44
|
+
describe("useTimelineLogicalRows", () => {
|
|
45
|
+
it("preserves the dense logical model across an unrelated store update", () => {
|
|
46
|
+
const host = document.createElement("div");
|
|
47
|
+
const root = createRoot(host);
|
|
48
|
+
const snapshots: Array<readonly TimelineLogicalRow[]> = [];
|
|
49
|
+
act(() => root.render(<Harness snapshots={snapshots} />));
|
|
50
|
+
const first = snapshots.at(-1);
|
|
51
|
+
|
|
52
|
+
act(() => usePlayerStore.setState({ requestedSeekTime: 1 }));
|
|
53
|
+
|
|
54
|
+
expect(snapshots.at(-1)).toBe(first);
|
|
55
|
+
act(() => root.unmount());
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
|
|
5
|
+
|
|
6
|
+
interface TimelineLogicalRowsInput {
|
|
7
|
+
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
|
|
8
|
+
displayTrackOrder: readonly number[];
|
|
9
|
+
laneCounts: ReadonlyMap<string, number>;
|
|
10
|
+
selectedElementId: string | null;
|
|
11
|
+
selectedElementIds: ReadonlySet<string>;
|
|
12
|
+
expandedClipIds: ReadonlySet<string>;
|
|
13
|
+
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Shared by rendering and focus coordination; stable input refs preserve memo identity. */
|
|
17
|
+
export function useTimelineLogicalRows({
|
|
18
|
+
tracks,
|
|
19
|
+
displayTrackOrder,
|
|
20
|
+
laneCounts,
|
|
21
|
+
selectedElementId,
|
|
22
|
+
selectedElementIds,
|
|
23
|
+
expandedClipIds,
|
|
24
|
+
gsapAnimations,
|
|
25
|
+
}: TimelineLogicalRowsInput) {
|
|
26
|
+
return useMemo(
|
|
27
|
+
() =>
|
|
28
|
+
buildTimelineLogicalRows({
|
|
29
|
+
tracks,
|
|
30
|
+
displayTrackOrder,
|
|
31
|
+
laneCounts,
|
|
32
|
+
selectedElementId,
|
|
33
|
+
selectedElementIds,
|
|
34
|
+
expandedClipIds,
|
|
35
|
+
gsapAnimations,
|
|
36
|
+
}),
|
|
37
|
+
[
|
|
38
|
+
displayTrackOrder,
|
|
39
|
+
expandedClipIds,
|
|
40
|
+
gsapAnimations,
|
|
41
|
+
laneCounts,
|
|
42
|
+
selectedElementId,
|
|
43
|
+
selectedElementIds,
|
|
44
|
+
tracks,
|
|
45
|
+
],
|
|
46
|
+
);
|
|
47
|
+
}
|