@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
|
@@ -20,30 +20,23 @@ interface UseTimelineRowVirtualizationInput {
|
|
|
20
20
|
viewport: TimelineScrollViewportSnapshot;
|
|
21
21
|
rowGeometry: TimelineRowGeometry;
|
|
22
22
|
sessionEpoch: number;
|
|
23
|
-
elements: TimelineElement[];
|
|
23
|
+
elements: readonly TimelineElement[];
|
|
24
24
|
selectedElementId: string | null;
|
|
25
|
-
|
|
25
|
+
focusedRowKey?: number;
|
|
26
26
|
draggedRowKey?: number;
|
|
27
|
-
|
|
27
|
+
resizingElementIds?: readonly string[];
|
|
28
28
|
clipContextMenuRowKey?: number;
|
|
29
29
|
keyframeContextMenuRowKey?: number;
|
|
30
30
|
lastScrollLeftRef: RefObject<number>;
|
|
31
31
|
syncScrollViewport: (element: HTMLDivElement, isScrolling?: boolean) => void;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
readonly rowKey?: number;
|
|
36
|
-
readonly elementId?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function getTimelineDomFocusPin(target: EventTarget | null): TimelineDomFocusPin | undefined {
|
|
34
|
+
function getFocusedTimelineRowKey(target: EventTarget | null): number | undefined {
|
|
40
35
|
if (!(target instanceof Element)) return undefined;
|
|
41
36
|
const value = target.closest<HTMLElement>("[data-timeline-row-key]")?.dataset.timelineRowKey;
|
|
42
|
-
|
|
43
|
-
const rowKey =
|
|
44
|
-
|
|
45
|
-
const elementId = target.closest<HTMLElement>("[data-el-id]")?.dataset.elId;
|
|
46
|
-
return rowKey === undefined && elementId === undefined ? undefined : { rowKey, elementId };
|
|
37
|
+
if (value === undefined) return undefined;
|
|
38
|
+
const rowKey = Number(value);
|
|
39
|
+
return Number.isFinite(rowKey) ? rowKey : undefined;
|
|
47
40
|
}
|
|
48
41
|
|
|
49
42
|
export function useTimelineRowVirtualization({
|
|
@@ -53,45 +46,50 @@ export function useTimelineRowVirtualization({
|
|
|
53
46
|
sessionEpoch,
|
|
54
47
|
elements,
|
|
55
48
|
selectedElementId,
|
|
56
|
-
|
|
49
|
+
focusedRowKey,
|
|
57
50
|
draggedRowKey,
|
|
58
|
-
|
|
51
|
+
resizingElementIds,
|
|
59
52
|
clipContextMenuRowKey,
|
|
60
53
|
keyframeContextMenuRowKey,
|
|
61
54
|
lastScrollLeftRef,
|
|
62
55
|
syncScrollViewport,
|
|
63
56
|
}: UseTimelineRowVirtualizationInput) {
|
|
64
57
|
const enabled = STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED;
|
|
65
|
-
const [
|
|
58
|
+
const [domFocusedRowKey, setDomFocusedRowKey] = useState<number>();
|
|
66
59
|
const onTimelineFocus = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
|
|
67
|
-
|
|
60
|
+
setDomFocusedRowKey(getFocusedTimelineRowKey(event.target));
|
|
68
61
|
}, []);
|
|
69
62
|
const onTimelineBlur = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
|
|
70
|
-
|
|
63
|
+
setDomFocusedRowKey(getFocusedTimelineRowKey(event.relatedTarget));
|
|
71
64
|
}, []);
|
|
72
|
-
const
|
|
65
|
+
const selectedIdentity = useMemo(
|
|
73
66
|
() => resolveTimelineFocusIdentity(elements, selectedElementId),
|
|
74
67
|
[elements, selectedElementId],
|
|
75
68
|
);
|
|
76
|
-
const
|
|
77
|
-
() =>
|
|
78
|
-
|
|
69
|
+
const resizingRowKeys = useMemo(
|
|
70
|
+
() =>
|
|
71
|
+
resizingElementIds
|
|
72
|
+
?.map((elementId) => resolveTimelineFocusIdentity(elements, elementId)?.rowKey)
|
|
73
|
+
.filter((rowKey): rowKey is number => rowKey !== undefined) ?? [],
|
|
74
|
+
[elements, resizingElementIds],
|
|
79
75
|
);
|
|
80
76
|
const pinnedRowKeys = useMemo(
|
|
81
77
|
() =>
|
|
82
78
|
[
|
|
83
79
|
draggedRowKey,
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
...resizingRowKeys,
|
|
81
|
+
selectedIdentity?.rowKey,
|
|
82
|
+
focusedRowKey,
|
|
86
83
|
clipContextMenuRowKey,
|
|
87
84
|
keyframeContextMenuRowKey,
|
|
88
85
|
].filter((rowKey): rowKey is number => rowKey !== undefined),
|
|
89
86
|
[
|
|
90
87
|
clipContextMenuRowKey,
|
|
91
88
|
draggedRowKey,
|
|
89
|
+
focusedRowKey,
|
|
92
90
|
keyframeContextMenuRowKey,
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
resizingRowKeys,
|
|
92
|
+
selectedIdentity,
|
|
95
93
|
],
|
|
96
94
|
);
|
|
97
95
|
const virtualRows = useTimelineVirtualRows({
|
|
@@ -101,7 +99,7 @@ export function useTimelineRowVirtualization({
|
|
|
101
99
|
rowGeometry,
|
|
102
100
|
sessionEpoch,
|
|
103
101
|
pinnedRowKeys,
|
|
104
|
-
focusedRowKey:
|
|
102
|
+
focusedRowKey: domFocusedRowKey ?? focusedRowKey,
|
|
105
103
|
});
|
|
106
104
|
|
|
107
105
|
const previousLayoutRef = useRef(rowGeometry);
|
|
@@ -134,7 +132,6 @@ export function useTimelineRowVirtualization({
|
|
|
134
132
|
return {
|
|
135
133
|
enabled,
|
|
136
134
|
virtualRows,
|
|
137
|
-
focusedElementId: domFocusPin?.elementId,
|
|
138
135
|
timelineFocusProps: { onFocus: onTimelineFocus, onBlur: onTimelineBlur },
|
|
139
136
|
};
|
|
140
137
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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, vi } from "vitest";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { useTrackGapMenu } from "./useTrackGapMenu";
|
|
8
|
+
|
|
9
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const lane: TimelineElement[] = [
|
|
12
|
+
{ id: "first", tag: "div", start: 0, duration: 1, track: 0 },
|
|
13
|
+
{ id: "second", tag: "div", start: 3, duration: 1, track: 0 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
usePlayerStore.getState().reset();
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("useTrackGapMenu", () => {
|
|
22
|
+
it("does not commit an anchor captured in a previous project session", () => {
|
|
23
|
+
usePlayerStore.setState({ elements: lane, timelineSessionEpoch: 1 });
|
|
24
|
+
const host = document.createElement("div");
|
|
25
|
+
document.body.append(host);
|
|
26
|
+
const root = createRoot(host);
|
|
27
|
+
const onMoveElement = vi.fn();
|
|
28
|
+
const onMoveElements = vi.fn();
|
|
29
|
+
let api: ReturnType<typeof useTrackGapMenu> | null = null;
|
|
30
|
+
|
|
31
|
+
function getApi(): ReturnType<typeof useTrackGapMenu> {
|
|
32
|
+
if (!api) throw new Error("gap menu harness did not render");
|
|
33
|
+
return api;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Probe() {
|
|
37
|
+
api = useTrackGapMenu({
|
|
38
|
+
tracks: [[0, lane]],
|
|
39
|
+
expandedElementsRef: { current: lane },
|
|
40
|
+
trackOrderRef: { current: [0] },
|
|
41
|
+
onMoveElement,
|
|
42
|
+
onMoveElements,
|
|
43
|
+
});
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
act(() => root.render(<Probe />));
|
|
48
|
+
act(() => getApi().openGapMenu({ x: 10, y: 20, track: 0, time: 2 }));
|
|
49
|
+
expect(getApi().gapMenuModel).not.toBeNull();
|
|
50
|
+
const staleCloseTrackGap = getApi().closeTrackGap;
|
|
51
|
+
const staleCloseAllTrackGaps = getApi().closeAllTrackGaps;
|
|
52
|
+
|
|
53
|
+
act(() => {
|
|
54
|
+
usePlayerStore.setState({ timelineSessionEpoch: 2 });
|
|
55
|
+
staleCloseTrackGap();
|
|
56
|
+
staleCloseAllTrackGaps();
|
|
57
|
+
});
|
|
58
|
+
expect(getApi().gapMenuModel).toBeNull();
|
|
59
|
+
|
|
60
|
+
expect(onMoveElement).not.toHaveBeenCalled();
|
|
61
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
62
|
+
act(() => root.unmount());
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useMemo, useState, type MutableRefObject } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { DragCommitDeps } from "./timelineClipDragCommit";
|
|
4
4
|
import {
|
|
@@ -21,6 +21,7 @@ interface TrackGapMenuAnchor {
|
|
|
21
21
|
y: number;
|
|
22
22
|
track: number;
|
|
23
23
|
time: number;
|
|
24
|
+
sessionEpoch: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Gap strips to paint on one lane while a menu row is hovered. */
|
|
@@ -32,9 +33,9 @@ export interface TrackGapHighlight {
|
|
|
32
33
|
/**
|
|
33
34
|
* Track-gap context menu (right-click on empty lane space) — state, the
|
|
34
35
|
* derived menu model, and the two commit actions. Extracted from Timeline.tsx
|
|
35
|
-
* as a cohesive unit (600-line studio cap)
|
|
36
|
+
* as a cohesive unit (600-line studio cap).
|
|
36
37
|
*
|
|
37
|
-
* Only the ANCHOR (and the hovered row) is state; the menu model (gap under
|
|
38
|
+
* Only the session-stamped ANCHOR (and the hovered row) is state; the menu model (gap under
|
|
38
39
|
* the pointer, compaction, movability) derives from live `tracks` so an open
|
|
39
40
|
* menu reflects concurrent edits. `gapHighlight` — the strips TimelineCanvas
|
|
40
41
|
* paints while "Close gap" / "Close all gaps" is hovered — derives the same
|
|
@@ -55,12 +56,16 @@ export function useTrackGapMenu({
|
|
|
55
56
|
onMoveElements: DragCommitDeps["onMoveElements"];
|
|
56
57
|
}) {
|
|
57
58
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
59
|
+
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
58
60
|
const [gapContextMenu, setGapContextMenu] = useState<TrackGapMenuAnchor | null>(null);
|
|
59
61
|
const [hoveredGapAction, setHoveredGapAction] = useState<"close-gap" | "close-all" | null>(null);
|
|
60
62
|
|
|
61
63
|
const gapMenuLaneElements = useMemo(
|
|
62
|
-
() =>
|
|
63
|
-
|
|
64
|
+
() =>
|
|
65
|
+
gapContextMenu?.sessionEpoch === sessionEpoch
|
|
66
|
+
? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? [])
|
|
67
|
+
: null,
|
|
68
|
+
[gapContextMenu, sessionEpoch, tracks],
|
|
64
69
|
);
|
|
65
70
|
const gapMenuModel = useMemo(() => {
|
|
66
71
|
if (!gapContextMenu || !gapMenuLaneElements) return null;
|
|
@@ -99,7 +104,12 @@ export function useTrackGapMenu({
|
|
|
99
104
|
}, [gapContextMenu, gapMenuLaneElements, hoveredGapAction]);
|
|
100
105
|
|
|
101
106
|
const closeTrackGap = useCallback(() => {
|
|
102
|
-
if (
|
|
107
|
+
if (
|
|
108
|
+
!gapContextMenu ||
|
|
109
|
+
!gapMenuLaneElements ||
|
|
110
|
+
gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
|
|
111
|
+
)
|
|
112
|
+
return;
|
|
103
113
|
commitCloseTrackGap(gapMenuLaneElements, gapContextMenu.time, {
|
|
104
114
|
elements: expandedElementsRef.current,
|
|
105
115
|
trackOrder: trackOrderRef.current,
|
|
@@ -117,7 +127,12 @@ export function useTrackGapMenu({
|
|
|
117
127
|
onMoveElements,
|
|
118
128
|
]);
|
|
119
129
|
const closeAllTrackGaps = useCallback(() => {
|
|
120
|
-
if (
|
|
130
|
+
if (
|
|
131
|
+
!gapContextMenu ||
|
|
132
|
+
!gapMenuLaneElements ||
|
|
133
|
+
gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
|
|
134
|
+
)
|
|
135
|
+
return;
|
|
121
136
|
commitCloseAllTrackGaps(gapMenuLaneElements, {
|
|
122
137
|
elements: expandedElementsRef.current,
|
|
123
138
|
trackOrder: trackOrderRef.current,
|
|
@@ -126,6 +141,7 @@ export function useTrackGapMenu({
|
|
|
126
141
|
onMoveElements,
|
|
127
142
|
});
|
|
128
143
|
}, [
|
|
144
|
+
gapContextMenu,
|
|
129
145
|
gapMenuLaneElements,
|
|
130
146
|
expandedElementsRef,
|
|
131
147
|
trackOrderRef,
|
|
@@ -134,15 +150,22 @@ export function useTrackGapMenu({
|
|
|
134
150
|
onMoveElements,
|
|
135
151
|
]);
|
|
136
152
|
|
|
137
|
-
const openGapMenu = useCallback((anchor: TrackGapMenuAnchor) => {
|
|
153
|
+
const openGapMenu = useCallback((anchor: Omit<TrackGapMenuAnchor, "sessionEpoch">) => {
|
|
138
154
|
setHoveredGapAction(null);
|
|
139
|
-
setGapContextMenu(
|
|
155
|
+
setGapContextMenu({
|
|
156
|
+
...anchor,
|
|
157
|
+
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
|
158
|
+
});
|
|
140
159
|
}, []);
|
|
141
160
|
const dismissGapMenu = useCallback(() => {
|
|
142
161
|
setHoveredGapAction(null);
|
|
143
162
|
setGapContextMenu(null);
|
|
144
163
|
}, []);
|
|
145
164
|
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (gapContextMenu && gapContextMenu.sessionEpoch !== sessionEpoch) dismissGapMenu();
|
|
167
|
+
}, [dismissGapMenu, gapContextMenu, sessionEpoch]);
|
|
168
|
+
|
|
146
169
|
return {
|
|
147
170
|
gapMenuModel,
|
|
148
171
|
gapHighlight,
|
|
@@ -22,6 +22,34 @@ export interface KeyframeCacheEntry {
|
|
|
22
22
|
easeEach?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface FocusedEaseSegment {
|
|
26
|
+
animationId: string;
|
|
27
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
28
|
+
tweenPercentage: number;
|
|
29
|
+
elementId: string;
|
|
30
|
+
projectId: string | null;
|
|
31
|
+
sessionEpoch: number;
|
|
32
|
+
nonce: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type FocusedEaseSegmentTarget = Omit<FocusedEaseSegment, "projectId" | "sessionEpoch" | "nonce">;
|
|
36
|
+
|
|
37
|
+
interface TimelineSessionIdentity {
|
|
38
|
+
timelineProjectId: string | null;
|
|
39
|
+
timelineSessionEpoch: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isFocusedEaseRequestCurrent(
|
|
43
|
+
request: FocusedEaseSegment,
|
|
44
|
+
state: TimelineSessionIdentity & { selectedElementId: string | null },
|
|
45
|
+
): boolean {
|
|
46
|
+
return (
|
|
47
|
+
request.projectId === state.timelineProjectId &&
|
|
48
|
+
request.sessionEpoch === state.timelineSessionEpoch &&
|
|
49
|
+
request.elementId === state.selectedElementId
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
export interface KeyframeSlice {
|
|
26
54
|
/** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */
|
|
27
55
|
selectedKeyframes: Set<string>;
|
|
@@ -35,22 +63,14 @@ export interface KeyframeSlice {
|
|
|
35
63
|
/** Union-expand clips (keyframed clips are expanded by default on load). */
|
|
36
64
|
expandClips: (ids: readonly string[]) => void;
|
|
37
65
|
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
setFocusedEaseSegment: (
|
|
47
|
-
target: {
|
|
48
|
-
animationId: string;
|
|
49
|
-
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
50
|
-
tweenPercentage: number;
|
|
51
|
-
elementId: string;
|
|
52
|
-
} | null,
|
|
53
|
-
) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Project/session/element-scoped request. Its nonce is monotonic across store
|
|
68
|
+
* resets so a stale consumer can never collide with a later request.
|
|
69
|
+
*/
|
|
70
|
+
focusedEaseSegment: FocusedEaseSegment | null;
|
|
71
|
+
focusedEaseRequestNonce: number;
|
|
72
|
+
setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void;
|
|
73
|
+
clearFocusedEaseSegment: (nonce: number) => void;
|
|
54
74
|
|
|
55
75
|
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
|
56
76
|
keyframeCache: Map<string, KeyframeCacheEntry>;
|
|
@@ -60,7 +80,10 @@ export interface KeyframeSlice {
|
|
|
60
80
|
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
export function createKeyframeSlice(
|
|
83
|
+
export function createKeyframeSlice(
|
|
84
|
+
set: StoreApi<KeyframeSlice>["setState"],
|
|
85
|
+
getTimelineSessionIdentity: () => TimelineSessionIdentity,
|
|
86
|
+
): KeyframeSlice {
|
|
64
87
|
return {
|
|
65
88
|
selectedKeyframes: new Set(),
|
|
66
89
|
toggleSelectedKeyframe: (key) =>
|
|
@@ -97,7 +120,25 @@ export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): K
|
|
|
97
120
|
}),
|
|
98
121
|
|
|
99
122
|
focusedEaseSegment: null,
|
|
100
|
-
|
|
123
|
+
focusedEaseRequestNonce: 0,
|
|
124
|
+
setFocusedEaseSegment: (target) =>
|
|
125
|
+
set((state) => {
|
|
126
|
+
const nonce = state.focusedEaseRequestNonce + 1;
|
|
127
|
+
const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity();
|
|
128
|
+
return {
|
|
129
|
+
focusedEaseRequestNonce: nonce,
|
|
130
|
+
focusedEaseSegment: {
|
|
131
|
+
...target,
|
|
132
|
+
projectId: timelineProjectId,
|
|
133
|
+
sessionEpoch: timelineSessionEpoch,
|
|
134
|
+
nonce,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}),
|
|
138
|
+
clearFocusedEaseSegment: (nonce) =>
|
|
139
|
+
set((state) =>
|
|
140
|
+
state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state,
|
|
141
|
+
),
|
|
101
142
|
|
|
102
143
|
keyframeCache: new Map(),
|
|
103
144
|
setKeyframeCache: (elementId, data) =>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// ponytail: Playback RAF updates the playhead/time display without per-frame React renders.
|
|
2
|
+
type TimeListener = (time: number) => void;
|
|
3
|
+
const timeListeners = new Set<TimeListener>();
|
|
4
|
+
|
|
5
|
+
export const liveTime = {
|
|
6
|
+
notify: (time: number) => timeListeners.forEach((listener) => listener(time)),
|
|
7
|
+
subscribe: (listener: TimeListener) => {
|
|
8
|
+
timeListeners.add(listener);
|
|
9
|
+
return () => timeListeners.delete(listener);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
@@ -53,6 +53,82 @@ describe("usePlayerStore", () => {
|
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
describe("focused ease requests", () => {
|
|
57
|
+
it("stamps the current project session and only lets its nonce clear it", () => {
|
|
58
|
+
const store = usePlayerStore.getState();
|
|
59
|
+
store.beginTimelineSession("project-a");
|
|
60
|
+
store.setSelectedElementId("index.html#hero");
|
|
61
|
+
store.setFocusedEaseSegment({
|
|
62
|
+
elementId: "index.html#hero",
|
|
63
|
+
animationId: "animation-a",
|
|
64
|
+
tweenPercentage: 50,
|
|
65
|
+
});
|
|
66
|
+
const first = usePlayerStore.getState().focusedEaseSegment;
|
|
67
|
+
if (!first) throw new Error("expected focused ease request");
|
|
68
|
+
expect(first.projectId).toBe("project-a");
|
|
69
|
+
expect(first.sessionEpoch).toBeGreaterThan(0);
|
|
70
|
+
expect(first.nonce).toBeGreaterThan(0);
|
|
71
|
+
|
|
72
|
+
store.setFocusedEaseSegment({
|
|
73
|
+
elementId: "index.html#hero",
|
|
74
|
+
animationId: "animation-a",
|
|
75
|
+
tweenPercentage: 75,
|
|
76
|
+
});
|
|
77
|
+
const second = usePlayerStore.getState().focusedEaseSegment;
|
|
78
|
+
if (!second) throw new Error("expected replacement request");
|
|
79
|
+
expect(second.nonce).toBe(first.nonce + 1);
|
|
80
|
+
|
|
81
|
+
store.clearFocusedEaseSegment(first.nonce);
|
|
82
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBe(second);
|
|
83
|
+
store.clearFocusedEaseSegment(second.nonce);
|
|
84
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("clears a pending request when the project session changes", () => {
|
|
88
|
+
const store = usePlayerStore.getState();
|
|
89
|
+
store.beginTimelineSession("project-a");
|
|
90
|
+
store.setFocusedEaseSegment({
|
|
91
|
+
elementId: "index.html#hero",
|
|
92
|
+
animationId: "animation-a",
|
|
93
|
+
tweenPercentage: 50,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
store.beginTimelineSession("project-b");
|
|
97
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not revive an old request after selecting away and back", () => {
|
|
101
|
+
const store = usePlayerStore.getState();
|
|
102
|
+
store.setSelectedElementId("index.html#a");
|
|
103
|
+
store.setFocusedEaseSegment({
|
|
104
|
+
elementId: "index.html#a",
|
|
105
|
+
animationId: "animation-a",
|
|
106
|
+
tweenPercentage: 50,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
store.setSelectedElementId("index.html#b");
|
|
110
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
111
|
+
store.setSelectedElementId("index.html#a");
|
|
112
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("invalidates on a genuine selection-anchor change but not a same-anchor echo", () => {
|
|
116
|
+
const store = usePlayerStore.getState();
|
|
117
|
+
store.setSelection(new Set(["index.html#a", "index.html#b"]), "index.html#a");
|
|
118
|
+
store.setFocusedEaseSegment({
|
|
119
|
+
elementId: "index.html#a",
|
|
120
|
+
animationId: "animation-a",
|
|
121
|
+
tweenPercentage: 50,
|
|
122
|
+
});
|
|
123
|
+
const request = usePlayerStore.getState().focusedEaseSegment;
|
|
124
|
+
|
|
125
|
+
store.setSelectionAnchor("index.html#a");
|
|
126
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBe(request);
|
|
127
|
+
store.setSelectionAnchor("index.html#b");
|
|
128
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
56
132
|
describe("setIsPlaying", () => {
|
|
57
133
|
it("sets isPlaying to true", () => {
|
|
58
134
|
usePlayerStore.getState().setIsPlaying(true);
|
|
@@ -416,29 +492,36 @@ describe("usePlayerStore", () => {
|
|
|
416
492
|
});
|
|
417
493
|
});
|
|
418
494
|
|
|
419
|
-
describe("
|
|
420
|
-
it("
|
|
421
|
-
|
|
422
|
-
usePlayerStore.getState().
|
|
423
|
-
expect(usePlayerStore.getState().
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
usePlayerStore.getState().
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
usePlayerStore.getState().
|
|
441
|
-
|
|
495
|
+
describe("timelineFocus", () => {
|
|
496
|
+
it("stamps project scope and carries the requested logical id", () => {
|
|
497
|
+
usePlayerStore.getState().beginTimelineSession("project-a");
|
|
498
|
+
usePlayerStore.getState().requestTimelineFocus("clip:el-1");
|
|
499
|
+
expect(usePlayerStore.getState().timelineFocus).toMatchObject({
|
|
500
|
+
id: "clip:el-1",
|
|
501
|
+
projectId: "project-a",
|
|
502
|
+
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
|
503
|
+
});
|
|
504
|
+
const store = usePlayerStore.getState();
|
|
505
|
+
store.requestTimelineFocus("clip:el-1");
|
|
506
|
+
const first = usePlayerStore.getState().timelineFocus;
|
|
507
|
+
if (!first) throw new Error("expected timeline focus request");
|
|
508
|
+
store.clearTimelineFocus(first.nonce);
|
|
509
|
+
store.reset();
|
|
510
|
+
store.requestTimelineFocus("clip:el-1");
|
|
511
|
+
const second = usePlayerStore.getState().timelineFocus;
|
|
512
|
+
expect(second?.nonce).toBe(first.nonce + 1);
|
|
513
|
+
|
|
514
|
+
store.beginTimelineSession("project-a");
|
|
515
|
+
store.requestTimelineFocus("clip:el-1");
|
|
516
|
+
const stale = usePlayerStore.getState().timelineFocus;
|
|
517
|
+
if (!stale) throw new Error("expected timeline focus request");
|
|
518
|
+
store.requestTimelineFocus("clip:el-2");
|
|
519
|
+
const replacement = usePlayerStore.getState().timelineFocus;
|
|
520
|
+
if (!replacement) throw new Error("expected replacement timeline focus request");
|
|
521
|
+
store.clearTimelineFocus(stale.nonce);
|
|
522
|
+
expect(usePlayerStore.getState().timelineFocus).toBe(replacement);
|
|
523
|
+
store.beginTimelineSession("project-b");
|
|
524
|
+
expect(usePlayerStore.getState().timelineFocus).toBeNull();
|
|
442
525
|
});
|
|
443
526
|
});
|
|
444
527
|
|