@hyperframes/studio 0.8.56 → 0.8.57
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-c23wnIfc.js +464 -0
- package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-DFlILHJe.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-CxR7F2YQ.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-BlkJ_Axe.js → index-gbOCDCuZ.js} +194 -194
- package/dist/index.d.ts +95 -9
- package/dist/index.html +2 -2
- package/dist/index.js +15573 -14835
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -0
- package/src/components/EditorShell.tsx +3 -1
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +0 -15
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/SnapToolbar.test.tsx +25 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.tsx +4 -16
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/previewReadOnly.test.tsx +1 -0
- package/src/hooks/useCompositionDimensions.test.tsx +38 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -572
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { act, createElement } from "react";
|
|
3
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
4
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
6
5
|
import { usePlayerStore } from "../store/playerStore";
|
|
7
6
|
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
8
7
|
import { TimelineOverlays, resolveTimelineContextElement } from "./TimelineOverlays";
|
|
8
|
+
import {
|
|
9
|
+
TimelineContextProvider,
|
|
10
|
+
type TimelineContextValue,
|
|
11
|
+
type TimelineOverlaysState,
|
|
12
|
+
} from "./TimelineProvider";
|
|
9
13
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
14
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
10
15
|
|
|
11
16
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
17
|
|
|
13
|
-
const
|
|
18
|
+
const { mount } = createHappyDomRootHarness();
|
|
14
19
|
afterEach(() => {
|
|
15
|
-
for (const root of roots.splice(0)) act(() => root.unmount());
|
|
16
|
-
document.body.innerHTML = "";
|
|
17
20
|
usePlayerStore.setState({ selectedElementId: null, timelineSessionEpoch: 0 });
|
|
18
21
|
});
|
|
19
22
|
|
|
@@ -76,8 +79,7 @@ function renderKeyframeOverlay(options: {
|
|
|
76
79
|
}) {
|
|
77
80
|
const container = document.createElement("div");
|
|
78
81
|
document.body.appendChild(container);
|
|
79
|
-
const root =
|
|
80
|
-
roots.push(root);
|
|
82
|
+
const root = mount(container);
|
|
81
83
|
const elements = [options.currentElement];
|
|
82
84
|
const setKfContextMenu = options.setKfContextMenu ?? vi.fn();
|
|
83
85
|
const onDeleteAllKeyframes = options.onDeleteAllKeyframes ?? vi.fn();
|
|
@@ -90,6 +92,64 @@ function renderKeyframeOverlay(options: {
|
|
|
90
92
|
percentage: 50,
|
|
91
93
|
animationId: "child-position",
|
|
92
94
|
};
|
|
95
|
+
const overlays = {
|
|
96
|
+
elements,
|
|
97
|
+
elementsRef: { current: elements },
|
|
98
|
+
theme: defaultTimelineTheme,
|
|
99
|
+
showShortcutHint: false,
|
|
100
|
+
showPopover: false,
|
|
101
|
+
rangeSelection: null,
|
|
102
|
+
setShowPopover: vi.fn(),
|
|
103
|
+
setRangeSelection: vi.fn(),
|
|
104
|
+
kfContextMenu: menu,
|
|
105
|
+
setKfContextMenu,
|
|
106
|
+
onDeleteKeyframe: vi.fn(),
|
|
107
|
+
onDeleteAllKeyframes,
|
|
108
|
+
onMoveKeyframeToPlayhead: vi.fn(),
|
|
109
|
+
clipContextMenu: null,
|
|
110
|
+
setClipContextMenu: vi.fn(),
|
|
111
|
+
currentTime: 0,
|
|
112
|
+
onSplitElement: vi.fn(),
|
|
113
|
+
pinZoomBeforeEdit: vi.fn(),
|
|
114
|
+
onDeleteElement: vi.fn(),
|
|
115
|
+
gapContextMenu: null,
|
|
116
|
+
onDismissGapContextMenu: vi.fn(),
|
|
117
|
+
onCloseTrackGap: vi.fn(),
|
|
118
|
+
onCloseAllTrackGaps: vi.fn(),
|
|
119
|
+
onHoverGapAction: vi.fn(),
|
|
120
|
+
} satisfies TimelineOverlaysState;
|
|
121
|
+
const contextValue = {
|
|
122
|
+
state: {
|
|
123
|
+
timelineReady: true,
|
|
124
|
+
elements,
|
|
125
|
+
selectedElementId: null,
|
|
126
|
+
sessionEpoch: 0,
|
|
127
|
+
keyframeCache: new Map(),
|
|
128
|
+
canvas: {} as TimelineContextValue["state"]["canvas"],
|
|
129
|
+
overlays,
|
|
130
|
+
},
|
|
131
|
+
actions: {
|
|
132
|
+
renderClipContent: undefined,
|
|
133
|
+
renderClipOverlay: undefined,
|
|
134
|
+
setFocusedEaseSegment: vi.fn(),
|
|
135
|
+
},
|
|
136
|
+
meta: {} as TimelineContextValue["meta"],
|
|
137
|
+
} satisfies TimelineContextValue;
|
|
138
|
+
|
|
139
|
+
function TestTimelineContext({ value }: { value: TimelineContextValue }) {
|
|
140
|
+
const selectedElementId = usePlayerStore((state) => state.selectedElementId);
|
|
141
|
+
const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
|
|
142
|
+
return createElement(
|
|
143
|
+
TimelineContextProvider,
|
|
144
|
+
{
|
|
145
|
+
value: {
|
|
146
|
+
...value,
|
|
147
|
+
state: { ...value.state, selectedElementId, sessionEpoch },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
createElement(TimelineOverlays),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
93
153
|
|
|
94
154
|
act(() => {
|
|
95
155
|
usePlayerStore.setState({
|
|
@@ -97,32 +157,7 @@ function renderKeyframeOverlay(options: {
|
|
|
97
157
|
timelineSessionEpoch: 2,
|
|
98
158
|
});
|
|
99
159
|
root.render(
|
|
100
|
-
createElement(
|
|
101
|
-
elements,
|
|
102
|
-
elementsRef: { current: elements },
|
|
103
|
-
theme: defaultTimelineTheme,
|
|
104
|
-
showShortcutHint: false,
|
|
105
|
-
showPopover: false,
|
|
106
|
-
rangeSelection: null,
|
|
107
|
-
setShowPopover: vi.fn(),
|
|
108
|
-
setRangeSelection: vi.fn(),
|
|
109
|
-
kfContextMenu: menu,
|
|
110
|
-
setKfContextMenu,
|
|
111
|
-
onDeleteKeyframe: vi.fn(),
|
|
112
|
-
onDeleteAllKeyframes,
|
|
113
|
-
onMoveKeyframeToPlayhead: vi.fn(),
|
|
114
|
-
clipContextMenu: null,
|
|
115
|
-
setClipContextMenu: vi.fn(),
|
|
116
|
-
currentTime: 0,
|
|
117
|
-
onSplitElement: vi.fn(),
|
|
118
|
-
pinZoomBeforeEdit: vi.fn(),
|
|
119
|
-
onDeleteElement: vi.fn(),
|
|
120
|
-
gapContextMenu: null,
|
|
121
|
-
onDismissGapContextMenu: vi.fn(),
|
|
122
|
-
onCloseTrackGap: vi.fn(),
|
|
123
|
-
onCloseAllTrackGaps: vi.fn(),
|
|
124
|
-
onHoverGapAction: vi.fn(),
|
|
125
|
-
}),
|
|
160
|
+
createElement(TestTimelineContext, { value: contextValue }, createElement(TimelineOverlays)),
|
|
126
161
|
);
|
|
127
162
|
});
|
|
128
163
|
return { setKfContextMenu, onDeleteAllKeyframes };
|
|
@@ -1,67 +1,13 @@
|
|
|
1
|
-
import { useEffect
|
|
1
|
+
import { useEffect } from "react";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
4
|
-
import type { TimelineTheme } from "./timelineTheme";
|
|
5
|
-
import type { TimelineRangeSelection } from "./timelineEditing";
|
|
6
|
-
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
7
3
|
import { EditPopover } from "./EditModal";
|
|
8
|
-
import {
|
|
9
|
-
KeyframeDiamondContextMenu,
|
|
10
|
-
type KeyframeDiamondContextMenuState,
|
|
11
|
-
} from "./KeyframeDiamondContextMenu";
|
|
4
|
+
import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu";
|
|
12
5
|
import { ClipContextMenu } from "./ClipContextMenu";
|
|
13
6
|
import { TrackGapContextMenu } from "./TrackGapContextMenu";
|
|
14
7
|
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
|
15
8
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
16
9
|
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
|
17
|
-
|
|
18
|
-
export interface ClipContextMenuState {
|
|
19
|
-
x: number;
|
|
20
|
-
y: number;
|
|
21
|
-
element: TimelineElement;
|
|
22
|
-
sessionEpoch: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Resolved model for the empty-lane-space (track gap) context menu. */
|
|
26
|
-
interface TrackGapContextMenuState {
|
|
27
|
-
x: number;
|
|
28
|
-
y: number;
|
|
29
|
-
gapWidth: number | null;
|
|
30
|
-
canCloseGap: boolean;
|
|
31
|
-
canCloseAllGaps: boolean;
|
|
32
|
-
hasAnyGaps: boolean;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface TimelineOverlaysProps {
|
|
36
|
-
elements: readonly TimelineElement[];
|
|
37
|
-
elementsRef: MutableRefObject<readonly TimelineElement[]>;
|
|
38
|
-
theme: TimelineTheme;
|
|
39
|
-
showShortcutHint: boolean;
|
|
40
|
-
showPopover: boolean;
|
|
41
|
-
rangeSelection: TimelineRangeSelection | null;
|
|
42
|
-
setShowPopover: (value: boolean) => void;
|
|
43
|
-
setRangeSelection: (value: TimelineRangeSelection | null) => void;
|
|
44
|
-
kfContextMenu: KeyframeDiamondContextMenuState | null;
|
|
45
|
-
setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
|
|
46
|
-
onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
|
|
47
|
-
onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
|
|
48
|
-
onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
|
|
49
|
-
clipContextMenu: ClipContextMenuState | null;
|
|
50
|
-
setClipContextMenu: (value: ClipContextMenuState | null) => void;
|
|
51
|
-
currentTime: number;
|
|
52
|
-
onSplitElement: TimelineEditCallbacks["onSplitElement"];
|
|
53
|
-
pinZoomBeforeEdit: () => void;
|
|
54
|
-
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
55
|
-
onCopyClip?: () => boolean;
|
|
56
|
-
onPasteClip?: () => Promise<void>;
|
|
57
|
-
onDuplicateClip?: () => Promise<boolean>;
|
|
58
|
-
canPasteClip?: () => boolean;
|
|
59
|
-
gapContextMenu: TrackGapContextMenuState | null;
|
|
60
|
-
onDismissGapContextMenu: () => void;
|
|
61
|
-
onCloseTrackGap: () => void;
|
|
62
|
-
onCloseAllTrackGaps: () => void;
|
|
63
|
-
onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
|
|
64
|
-
}
|
|
10
|
+
import { useTimelineContext } from "./TimelineProvider";
|
|
65
11
|
|
|
66
12
|
interface TimelineContextTargetInput {
|
|
67
13
|
capturedElement: TimelineElement;
|
|
@@ -85,56 +31,43 @@ export function resolveTimelineContextElement({
|
|
|
85
31
|
return elements.find((element) => (element.key ?? element.id) === identity) ?? null;
|
|
86
32
|
}
|
|
87
33
|
|
|
88
|
-
function readTimelineContextElement(
|
|
89
|
-
capturedElement: TimelineElement,
|
|
90
|
-
targetSessionEpoch: number | undefined,
|
|
91
|
-
elements: readonly TimelineElement[],
|
|
92
|
-
): TimelineElement | null {
|
|
93
|
-
const state = usePlayerStore.getState();
|
|
94
|
-
return resolveTimelineContextElement({
|
|
95
|
-
capturedElement,
|
|
96
|
-
targetSessionEpoch,
|
|
97
|
-
sessionEpoch: state.timelineSessionEpoch,
|
|
98
|
-
selectedElementId: state.selectedElementId,
|
|
99
|
-
elements,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
34
|
// The timeline's floating overlays, rendered as siblings above the scroll area:
|
|
104
35
|
// the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
|
|
105
36
|
// and the clip context menu.
|
|
106
|
-
export function TimelineOverlays({
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
37
|
+
export function TimelineOverlays() {
|
|
38
|
+
const { state, actions } = useTimelineContext();
|
|
39
|
+
const overlayProps = state.overlays;
|
|
40
|
+
const {
|
|
41
|
+
elements,
|
|
42
|
+
elementsRef,
|
|
43
|
+
theme,
|
|
44
|
+
showShortcutHint,
|
|
45
|
+
showPopover,
|
|
46
|
+
rangeSelection,
|
|
47
|
+
setShowPopover,
|
|
48
|
+
setRangeSelection,
|
|
49
|
+
kfContextMenu,
|
|
50
|
+
setKfContextMenu,
|
|
51
|
+
onDeleteKeyframe,
|
|
52
|
+
onDeleteAllKeyframes,
|
|
53
|
+
onMoveKeyframeToPlayhead,
|
|
54
|
+
clipContextMenu,
|
|
55
|
+
setClipContextMenu,
|
|
56
|
+
currentTime,
|
|
57
|
+
onSplitElement,
|
|
58
|
+
pinZoomBeforeEdit,
|
|
59
|
+
onDeleteElement,
|
|
60
|
+
onCopyClip,
|
|
61
|
+
onPasteClip,
|
|
62
|
+
onDuplicateClip,
|
|
63
|
+
canPasteClip,
|
|
64
|
+
gapContextMenu,
|
|
65
|
+
onDismissGapContextMenu,
|
|
66
|
+
onCloseTrackGap,
|
|
67
|
+
onCloseAllTrackGaps,
|
|
68
|
+
onHoverGapAction,
|
|
69
|
+
} = overlayProps;
|
|
70
|
+
const { selectedElementId, sessionEpoch, keyframeCache } = state;
|
|
138
71
|
const kfTargetSessionEpoch = kfContextMenu?.sessionEpoch;
|
|
139
72
|
const clipTargetSessionEpoch = clipContextMenu?.sessionEpoch;
|
|
140
73
|
const keyframeElement = kfContextMenu
|
|
@@ -156,7 +89,13 @@ export function TimelineOverlays({
|
|
|
156
89
|
})
|
|
157
90
|
: null;
|
|
158
91
|
const readCurrentElement = (element: TimelineElement, targetSessionEpoch: number | undefined) =>
|
|
159
|
-
|
|
92
|
+
resolveTimelineContextElement({
|
|
93
|
+
capturedElement: element,
|
|
94
|
+
targetSessionEpoch,
|
|
95
|
+
sessionEpoch,
|
|
96
|
+
selectedElementId,
|
|
97
|
+
elements: elementsRef.current,
|
|
98
|
+
});
|
|
160
99
|
|
|
161
100
|
useEffect(() => {
|
|
162
101
|
if (kfContextMenu && !keyframeElement) setKfContextMenu(null);
|
|
@@ -217,7 +156,7 @@ export function TimelineOverlays({
|
|
|
217
156
|
) {
|
|
218
157
|
return;
|
|
219
158
|
}
|
|
220
|
-
|
|
159
|
+
actions.setFocusedEaseSegment({
|
|
221
160
|
animationId: keyframe.animationId,
|
|
222
161
|
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
|
223
162
|
tweenPercentage: keyframe.tweenPercentage,
|
|
@@ -228,7 +167,7 @@ export function TimelineOverlays({
|
|
|
228
167
|
: undefined
|
|
229
168
|
}
|
|
230
169
|
onCopyProperties={(elementId, keyframe) => {
|
|
231
|
-
const entry =
|
|
170
|
+
const entry = keyframeCache.get(elementId);
|
|
232
171
|
// Tolerance match on clip-%, the same basis the cache is keyed on —
|
|
233
172
|
// an exact float compare misses a keyframe the menu just opened over.
|
|
234
173
|
const kf = entry?.keyframes.find(
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
type ComponentProps,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type MouseEvent,
|
|
7
|
+
type PointerEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type RefCallback,
|
|
10
|
+
type UIEvent,
|
|
11
|
+
} from "react";
|
|
12
|
+
import type { TimelineEmptyState } from "./TimelineEmptyState";
|
|
13
|
+
import type { TimelineProps } from "./TimelineTypes";
|
|
14
|
+
import type { TimelineRangeSelection } from "./timelineEditing";
|
|
15
|
+
import type { TimelineDropPlacement } from "./timelineCallbacks";
|
|
16
|
+
import type { Rect } from "../../utils/marqueeGeometry";
|
|
17
|
+
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
18
|
+
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
19
|
+
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
|
|
20
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
21
|
+
import type { KeyframeCacheEntry } from "../store/keyframeSlice";
|
|
22
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
23
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
24
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
25
|
+
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
26
|
+
import { useTimelineProviderState } from "./useTimelineProviderState";
|
|
27
|
+
|
|
28
|
+
export type TimelineCanvasState = Omit<
|
|
29
|
+
TimelineLaneBaseProps,
|
|
30
|
+
| "setRangeSelection"
|
|
31
|
+
| "setResizingClip"
|
|
32
|
+
| "setDraggedClip"
|
|
33
|
+
| "renderClipContent"
|
|
34
|
+
| "renderClipOverlay"
|
|
35
|
+
> & {
|
|
36
|
+
major: number[];
|
|
37
|
+
minor: number[];
|
|
38
|
+
totalH: number;
|
|
39
|
+
effectiveDuration: number;
|
|
40
|
+
majorTickInterval: number;
|
|
41
|
+
rangeSelection: TimelineRangeSelection | null;
|
|
42
|
+
marqueeRect: Rect | null;
|
|
43
|
+
resizingClip: ResizingClipState | null;
|
|
44
|
+
isScrubbing: boolean;
|
|
45
|
+
playheadRef: React.RefObject<HTMLDivElement | null>;
|
|
46
|
+
laneGapStrips: TimelineLaneGapStrips[];
|
|
47
|
+
dropPreview: TimelineDropPlacement | null;
|
|
48
|
+
setRangeSelection: (value: TimelineRangeSelection | null) => void;
|
|
49
|
+
setResizingClip: (value: ResizingClipState | null) => void;
|
|
50
|
+
setDraggedClip: (value: TimelineLaneBaseProps["draggedClip"]) => void;
|
|
51
|
+
beatDragging: boolean;
|
|
52
|
+
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
|
53
|
+
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface ClipContextMenuState {
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
element: TimelineElement;
|
|
60
|
+
sessionEpoch: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TrackGapContextMenuState {
|
|
64
|
+
x: number;
|
|
65
|
+
y: number;
|
|
66
|
+
gapWidth: number | null;
|
|
67
|
+
canCloseGap: boolean;
|
|
68
|
+
canCloseAllGaps: boolean;
|
|
69
|
+
hasAnyGaps: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TimelineOverlaysState {
|
|
73
|
+
elements: readonly TimelineElement[];
|
|
74
|
+
elementsRef: { current: readonly TimelineElement[] };
|
|
75
|
+
theme: TimelineTheme;
|
|
76
|
+
showShortcutHint: boolean;
|
|
77
|
+
showPopover: boolean;
|
|
78
|
+
rangeSelection: TimelineRangeSelection | null;
|
|
79
|
+
setShowPopover: (value: boolean) => void;
|
|
80
|
+
setRangeSelection: (value: TimelineRangeSelection | null) => void;
|
|
81
|
+
kfContextMenu: KeyframeDiamondContextMenuState | null;
|
|
82
|
+
setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
|
|
83
|
+
onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
|
|
84
|
+
onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
|
|
85
|
+
onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
|
|
86
|
+
clipContextMenu: ClipContextMenuState | null;
|
|
87
|
+
setClipContextMenu: (value: ClipContextMenuState | null) => void;
|
|
88
|
+
currentTime: number;
|
|
89
|
+
onSplitElement: TimelineEditCallbacks["onSplitElement"];
|
|
90
|
+
pinZoomBeforeEdit: () => void;
|
|
91
|
+
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
92
|
+
onCopyClip?: () => boolean;
|
|
93
|
+
onPasteClip?: () => Promise<void>;
|
|
94
|
+
onDuplicateClip?: () => Promise<boolean>;
|
|
95
|
+
canPasteClip?: () => boolean;
|
|
96
|
+
gapContextMenu: TrackGapContextMenuState | null;
|
|
97
|
+
onDismissGapContextMenu: () => void;
|
|
98
|
+
onCloseTrackGap: () => void;
|
|
99
|
+
onCloseAllTrackGaps: () => void;
|
|
100
|
+
onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface TimelineContainerProps {
|
|
104
|
+
ref: RefCallback<HTMLDivElement>;
|
|
105
|
+
"aria-label": string;
|
|
106
|
+
"data-timeline-element-count": number;
|
|
107
|
+
className: string;
|
|
108
|
+
onMouseMove: (event: MouseEvent<HTMLDivElement>) => void;
|
|
109
|
+
onMouseLeave: () => void;
|
|
110
|
+
style: CSSProperties;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface TimelineViewportProps {
|
|
114
|
+
ref: RefCallback<HTMLDivElement>;
|
|
115
|
+
"data-timeline-scroll-viewport": boolean;
|
|
116
|
+
"data-timeline-auto-scroll-left-inset": number;
|
|
117
|
+
tabIndex: number;
|
|
118
|
+
className: string;
|
|
119
|
+
onScroll: (event: UIEvent<HTMLDivElement>) => void;
|
|
120
|
+
onFocus: (event: React.FocusEvent<HTMLDivElement>) => void;
|
|
121
|
+
onBlur: (event: React.FocusEvent<HTMLDivElement>) => void;
|
|
122
|
+
onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
|
|
123
|
+
onDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
|
|
124
|
+
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
|
|
125
|
+
onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
|
|
126
|
+
onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
|
127
|
+
onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
|
128
|
+
onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
|
|
129
|
+
onLostPointerCapture: (event: PointerEvent<HTMLDivElement>) => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface TimelineState {
|
|
133
|
+
timelineReady: boolean;
|
|
134
|
+
elements: readonly TimelineElement[];
|
|
135
|
+
selectedElementId: string | null;
|
|
136
|
+
sessionEpoch: number;
|
|
137
|
+
keyframeCache: Map<string, KeyframeCacheEntry>;
|
|
138
|
+
canvas: TimelineCanvasState;
|
|
139
|
+
overlays: TimelineOverlaysState;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface TimelineActions {
|
|
143
|
+
renderClipContent: TimelineLaneBaseProps["renderClipContent"];
|
|
144
|
+
renderClipOverlay: TimelineLaneBaseProps["renderClipOverlay"];
|
|
145
|
+
setFocusedEaseSegment: (target: {
|
|
146
|
+
animationId: string;
|
|
147
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
148
|
+
tweenPercentage: number;
|
|
149
|
+
elementId: string;
|
|
150
|
+
}) => void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface TimelineMeta {
|
|
154
|
+
emptyState: ComponentProps<typeof TimelineEmptyState>;
|
|
155
|
+
containerProps: TimelineContainerProps;
|
|
156
|
+
viewportProps: TimelineViewportProps;
|
|
157
|
+
razorGuide: ReactNode;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface TimelineContextValue {
|
|
161
|
+
state: TimelineState;
|
|
162
|
+
actions: TimelineActions;
|
|
163
|
+
meta: TimelineMeta;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
|
167
|
+
|
|
168
|
+
export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
|
169
|
+
const value = useTimelineProviderState(props);
|
|
170
|
+
return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function TimelineContextProvider({
|
|
174
|
+
value,
|
|
175
|
+
children,
|
|
176
|
+
}: {
|
|
177
|
+
value: TimelineContextValue;
|
|
178
|
+
children: ReactNode;
|
|
179
|
+
}) {
|
|
180
|
+
return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function useTimelineContext(): TimelineContextValue {
|
|
184
|
+
const value = useTimelineContextOptional();
|
|
185
|
+
if (value === null) throw new Error("useTimelineContext must be used inside TimelineProvider");
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function useTimelineContextOptional(): TimelineContextValue | null {
|
|
190
|
+
return useContext(TimelineContext);
|
|
191
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { act } from "react";
|
|
2
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
+
import { afterEach } from "vitest";
|
|
4
|
+
|
|
5
|
+
export function createHappyDomRootHarness() {
|
|
6
|
+
const roots: Root[] = [];
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
for (const root of roots.splice(0)) act(() => root.unmount());
|
|
9
|
+
document.body.innerHTML = "";
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
mount(host: HTMLElement) {
|
|
13
|
+
const root = createRoot(host);
|
|
14
|
+
roots.push(root);
|
|
15
|
+
return root;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
buildTimelineMeta,
|
|
6
|
+
resolveRenderClipContent,
|
|
7
|
+
resolveResizingElementIds,
|
|
8
|
+
shouldIgnoreTimelinePointerDown,
|
|
9
|
+
type TimelineMetaBuilderInputs,
|
|
10
|
+
} from "./timelineProviderStateBuilders";
|
|
11
|
+
|
|
12
|
+
function inputs(overrides: Partial<TimelineMetaBuilderInputs> = {}): TimelineMetaBuilderInputs {
|
|
13
|
+
return {
|
|
14
|
+
emptyState: {} as TimelineMetaBuilderInputs["emptyState"],
|
|
15
|
+
container: {
|
|
16
|
+
ref: vi.fn(),
|
|
17
|
+
"aria-label": "Timeline track view",
|
|
18
|
+
"data-timeline-element-count": 2,
|
|
19
|
+
isDragOver: false,
|
|
20
|
+
activeTool: "select",
|
|
21
|
+
shiftHeld: false,
|
|
22
|
+
accentClass: "ring",
|
|
23
|
+
onMouseMove: vi.fn(),
|
|
24
|
+
onMouseLeave: vi.fn(),
|
|
25
|
+
style: {},
|
|
26
|
+
},
|
|
27
|
+
viewport: {
|
|
28
|
+
ref: vi.fn(),
|
|
29
|
+
tabIndex: -1,
|
|
30
|
+
labelMode: false,
|
|
31
|
+
zoomMode: "fit",
|
|
32
|
+
onScroll: vi.fn(),
|
|
33
|
+
onFocus: vi.fn(),
|
|
34
|
+
onBlur: vi.fn(),
|
|
35
|
+
onDragOver: vi.fn(),
|
|
36
|
+
onDragLeave: vi.fn(),
|
|
37
|
+
onDrop: vi.fn(),
|
|
38
|
+
onPointerDown: vi.fn(),
|
|
39
|
+
onPointerMove: vi.fn(),
|
|
40
|
+
onPointerUp: vi.fn(),
|
|
41
|
+
onPointerCancel: vi.fn(),
|
|
42
|
+
onLostPointerCapture: vi.fn(),
|
|
43
|
+
},
|
|
44
|
+
elementCount: 2,
|
|
45
|
+
labelColumnWidth: 120,
|
|
46
|
+
razorGuide: null,
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("buildTimelineMeta", () => {
|
|
52
|
+
it("uses the razor branch for the container class", () => {
|
|
53
|
+
const meta = buildTimelineMeta(
|
|
54
|
+
inputs({
|
|
55
|
+
container: {
|
|
56
|
+
...inputs().container,
|
|
57
|
+
activeTool: "razor",
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
expect(meta.containerProps.className).toContain("cursor-crosshair");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("uses the shift branch for the container class", () => {
|
|
65
|
+
const meta = buildTimelineMeta(
|
|
66
|
+
inputs({ container: { ...inputs().container, shiftHeld: true } }),
|
|
67
|
+
);
|
|
68
|
+
expect(meta.containerProps.className).toContain("cursor-crosshair");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("uses the default container class without an accent", () => {
|
|
72
|
+
const meta = buildTimelineMeta(inputs());
|
|
73
|
+
expect(meta.containerProps.className).toContain("cursor-default");
|
|
74
|
+
expect(meta.containerProps.className).not.toContain("ring");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("uses the label-column inset only in label mode", () => {
|
|
78
|
+
const fit = buildTimelineMeta(inputs());
|
|
79
|
+
const labels = buildTimelineMeta(
|
|
80
|
+
inputs({ viewport: { ...inputs().viewport, labelMode: true } }),
|
|
81
|
+
);
|
|
82
|
+
expect(fit.viewportProps["data-timeline-auto-scroll-left-inset"]).toBe(0);
|
|
83
|
+
expect(labels.viewportProps["data-timeline-auto-scroll-left-inset"]).toBe(120);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("timeline provider branch helpers", () => {
|
|
88
|
+
it("resolves virtualized clip content", () => {
|
|
89
|
+
const render = () => null;
|
|
90
|
+
expect(resolveRenderClipContent(true, true, render)).toBeUndefined();
|
|
91
|
+
expect(resolveRenderClipContent(false, true, render)).toBe(render);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("resolves resizing element ids", () => {
|
|
95
|
+
expect(resolveResizingElementIds(null)).toBeUndefined();
|
|
96
|
+
expect(resolveResizingElementIds({ element: { id: "clip" } } as never)).toEqual(["clip"]);
|
|
97
|
+
expect(resolveResizingElementIds({ groupPreview: [{ key: "group::clip" }] } as never)).toEqual([
|
|
98
|
+
"group::clip",
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("ignores pointer downs on interactive descendants only", () => {
|
|
103
|
+
const button = document.createElement("button");
|
|
104
|
+
const div = document.createElement("div");
|
|
105
|
+
expect(shouldIgnoreTimelinePointerDown(button)).toBe(true);
|
|
106
|
+
expect(shouldIgnoreTimelinePointerDown(div)).toBe(false);
|
|
107
|
+
expect(shouldIgnoreTimelinePointerDown(null)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|