@hyperframes/studio 0.8.58 → 0.8.59
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-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
- package/dist/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
- package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/index.d.ts +548 -8
- package/dist/index.html +2 -2
- package/dist/index.js +21596 -20725
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.tsx +19 -0
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
- package/src/components/nle/AudioMeterStrip.tsx +213 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +16 -0
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineCanvas.tsx +10 -56
- package/src/player/components/TimelineClip.tsx +14 -1
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/theme.css +4 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
|
@@ -4,7 +4,7 @@ import { EditPopover } from "./EditModal";
|
|
|
4
4
|
import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu";
|
|
5
5
|
import { ClipContextMenu } from "./ClipContextMenu";
|
|
6
6
|
import { TrackGapContextMenu } from "./TrackGapContextMenu";
|
|
7
|
-
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
|
7
|
+
import { TimelineShortcutHint as TimelineShortcutHintImpl } from "./TimelineShortcutHint";
|
|
8
8
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
9
9
|
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
|
10
10
|
import { useTimelineContext } from "./TimelineProvider";
|
|
@@ -17,7 +17,6 @@ interface TimelineContextTargetInput {
|
|
|
17
17
|
elements: readonly TimelineElement[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** The captured project session and current selection jointly own a context target. */
|
|
21
20
|
export function resolveTimelineContextElement({
|
|
22
21
|
capturedElement,
|
|
23
22
|
targetSessionEpoch,
|
|
@@ -31,192 +30,192 @@ export function resolveTimelineContextElement({
|
|
|
31
30
|
return elements.find((element) => (element.key ?? element.id) === identity) ?? null;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
export function TimelineShortcutHintOverlay() {
|
|
34
|
+
const { state } = useTimelineContext();
|
|
35
|
+
const { showShortcutHint, showPopover, rangeSelection, theme } = state.overlays;
|
|
36
|
+
if (!showShortcutHint || showPopover || rangeSelection) return null;
|
|
37
|
+
return <TimelineShortcutHintImpl theme={theme} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function TimelineEditPopoverOverlay() {
|
|
41
|
+
const { state } = useTimelineContext();
|
|
42
|
+
const { showPopover, rangeSelection, setShowPopover, setRangeSelection } = state.overlays;
|
|
43
|
+
if (!showPopover || !rangeSelection) return null;
|
|
44
|
+
return (
|
|
45
|
+
<EditPopover
|
|
46
|
+
rangeStart={rangeSelection.start}
|
|
47
|
+
rangeEnd={rangeSelection.end}
|
|
48
|
+
anchorX={rangeSelection.anchorX}
|
|
49
|
+
anchorY={rangeSelection.anchorY}
|
|
50
|
+
onClose={() => {
|
|
51
|
+
setShowPopover(false);
|
|
52
|
+
setRangeSelection(null);
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function TimelineKeyframeMenuOverlay() {
|
|
38
59
|
const { state, actions } = useTimelineContext();
|
|
39
|
-
const
|
|
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;
|
|
60
|
+
const overlay = state.overlays;
|
|
61
|
+
const { kfContextMenu, setKfContextMenu } = overlay;
|
|
70
62
|
const { selectedElementId, sessionEpoch, keyframeCache } = state;
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const keyframeElement = kfContextMenu
|
|
63
|
+
const targetEpoch = kfContextMenu?.sessionEpoch;
|
|
64
|
+
const element = kfContextMenu
|
|
74
65
|
? resolveTimelineContextElement({
|
|
75
66
|
capturedElement: kfContextMenu.element,
|
|
76
|
-
targetSessionEpoch:
|
|
67
|
+
targetSessionEpoch: targetEpoch,
|
|
77
68
|
sessionEpoch,
|
|
78
69
|
selectedElementId,
|
|
79
|
-
elements,
|
|
70
|
+
elements: overlay.elements,
|
|
80
71
|
})
|
|
81
72
|
: null;
|
|
82
|
-
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (kfContextMenu && !element) setKfContextMenu(null);
|
|
75
|
+
}, [element, kfContextMenu, setKfContextMenu]);
|
|
76
|
+
if (!kfContextMenu || !element) return null;
|
|
77
|
+
const readCurrentElement = () =>
|
|
78
|
+
resolveTimelineContextElement({
|
|
79
|
+
capturedElement: element,
|
|
80
|
+
targetSessionEpoch: targetEpoch,
|
|
81
|
+
sessionEpoch,
|
|
82
|
+
selectedElementId,
|
|
83
|
+
elements: overlay.elementsRef.current,
|
|
84
|
+
});
|
|
85
|
+
const menu = kfContextMenu;
|
|
86
|
+
return (
|
|
87
|
+
<KeyframeDiamondContextMenu
|
|
88
|
+
state={{ ...menu, element }}
|
|
89
|
+
onClose={() => setKfContextMenu(null)}
|
|
90
|
+
onDelete={(...args) => {
|
|
91
|
+
if (readCurrentElement()) overlay.onDeleteKeyframe?.(...args);
|
|
92
|
+
}}
|
|
93
|
+
onDeleteAll={(_element, animationId) => {
|
|
94
|
+
const current = readCurrentElement();
|
|
95
|
+
if (current) overlay.onDeleteAllKeyframes?.(current, animationId);
|
|
96
|
+
}}
|
|
97
|
+
onMoveToPlayhead={
|
|
98
|
+
overlay.onMoveKeyframeToPlayhead
|
|
99
|
+
? (_element, ...args) => {
|
|
100
|
+
const current = readCurrentElement();
|
|
101
|
+
if (current) overlay.onMoveKeyframeToPlayhead?.(current, ...args);
|
|
102
|
+
}
|
|
103
|
+
: undefined
|
|
104
|
+
}
|
|
105
|
+
onEditEase={
|
|
106
|
+
// Routed to the same focused-ease-segment path a segment click takes,
|
|
107
|
+
// so the menu advertises the editor that exists rather than growing a
|
|
108
|
+
// second one. Offered only for a keyframe that names a tween to focus.
|
|
109
|
+
menu.animationId !== undefined && menu.tweenPercentage !== undefined
|
|
110
|
+
? (elementId, keyframe) => {
|
|
111
|
+
if (keyframe.animationId === undefined || keyframe.tweenPercentage === undefined)
|
|
112
|
+
return;
|
|
113
|
+
actions.setFocusedEaseSegment({
|
|
114
|
+
animationId: keyframe.animationId,
|
|
115
|
+
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
|
116
|
+
tweenPercentage: keyframe.tweenPercentage,
|
|
117
|
+
elementId,
|
|
118
|
+
});
|
|
119
|
+
trackStudioSegmentEaseEdit({ action: "open" });
|
|
120
|
+
}
|
|
121
|
+
: undefined
|
|
122
|
+
}
|
|
123
|
+
onCopyProperties={(elementId, keyframe) => {
|
|
124
|
+
const entry = keyframeCache.get(elementId);
|
|
125
|
+
// Match the existing keyframe lookup tolerance so copied properties
|
|
126
|
+
// follow the same nearby-keyframe selection as the editor.
|
|
127
|
+
const keyframeValue = entry?.keyframes.find(
|
|
128
|
+
(item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
|
|
129
|
+
);
|
|
130
|
+
if (!keyframeValue) return false;
|
|
131
|
+
return copyTextToClipboard(JSON.stringify(keyframeValue.properties, null, 2));
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function TimelineClipMenuOverlay() {
|
|
138
|
+
const { state } = useTimelineContext();
|
|
139
|
+
const overlay = state.overlays;
|
|
140
|
+
const { clipContextMenu, setClipContextMenu } = overlay;
|
|
141
|
+
const { selectedElementId, sessionEpoch } = state;
|
|
142
|
+
const targetEpoch = clipContextMenu?.sessionEpoch;
|
|
143
|
+
const element = clipContextMenu
|
|
83
144
|
? resolveTimelineContextElement({
|
|
84
145
|
capturedElement: clipContextMenu.element,
|
|
85
|
-
targetSessionEpoch:
|
|
146
|
+
targetSessionEpoch: targetEpoch,
|
|
86
147
|
sessionEpoch,
|
|
87
148
|
selectedElementId,
|
|
88
|
-
elements,
|
|
149
|
+
elements: overlay.elements,
|
|
89
150
|
})
|
|
90
151
|
: null;
|
|
91
|
-
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (clipContextMenu && !element) setClipContextMenu(null);
|
|
154
|
+
}, [element, clipContextMenu, setClipContextMenu]);
|
|
155
|
+
if (!clipContextMenu || !element) return null;
|
|
156
|
+
const readCurrentElement = () =>
|
|
92
157
|
resolveTimelineContextElement({
|
|
93
158
|
capturedElement: element,
|
|
94
|
-
targetSessionEpoch,
|
|
159
|
+
targetSessionEpoch: targetEpoch,
|
|
95
160
|
sessionEpoch,
|
|
96
161
|
selectedElementId,
|
|
97
|
-
elements: elementsRef.current,
|
|
162
|
+
elements: overlay.elementsRef.current,
|
|
98
163
|
});
|
|
164
|
+
const menu = clipContextMenu;
|
|
165
|
+
return (
|
|
166
|
+
<ClipContextMenu
|
|
167
|
+
x={menu.x}
|
|
168
|
+
y={menu.y}
|
|
169
|
+
element={element}
|
|
170
|
+
currentTime={overlay.currentTime}
|
|
171
|
+
onClose={() => setClipContextMenu(null)}
|
|
172
|
+
onSplit={(_element, time) => {
|
|
173
|
+
const current = readCurrentElement();
|
|
174
|
+
if (current) overlay.onSplitElement?.(current, time);
|
|
175
|
+
}}
|
|
176
|
+
onDelete={() => {
|
|
177
|
+
const current = readCurrentElement();
|
|
178
|
+
if (!current) return;
|
|
179
|
+
overlay.pinZoomBeforeEdit();
|
|
180
|
+
overlay.onDeleteElement?.(current);
|
|
181
|
+
}}
|
|
182
|
+
onCopy={overlay.onCopyClip}
|
|
183
|
+
onPaste={overlay.onPasteClip}
|
|
184
|
+
onDuplicate={overlay.onDuplicateClip}
|
|
185
|
+
canPaste={overlay.canPasteClip?.() ?? false}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
99
189
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
190
|
+
export function TimelineGapMenuOverlay() {
|
|
191
|
+
const { state } = useTimelineContext();
|
|
192
|
+
const menu = state.overlays.gapContextMenu;
|
|
193
|
+
const overlay = state.overlays;
|
|
194
|
+
if (!menu) return null;
|
|
195
|
+
return (
|
|
196
|
+
<TrackGapContextMenu
|
|
197
|
+
x={menu.x}
|
|
198
|
+
y={menu.y}
|
|
199
|
+
gapWidth={menu.gapWidth}
|
|
200
|
+
canCloseGap={menu.canCloseGap}
|
|
201
|
+
canCloseAllGaps={menu.canCloseAllGaps}
|
|
202
|
+
hasAnyGaps={menu.hasAnyGaps}
|
|
203
|
+
onClose={overlay.onDismissGapContextMenu}
|
|
204
|
+
onCloseGap={overlay.onCloseTrackGap}
|
|
205
|
+
onCloseAllGaps={overlay.onCloseAllTrackGaps}
|
|
206
|
+
onHoverAction={overlay.onHoverGapAction}
|
|
207
|
+
/>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
107
210
|
|
|
211
|
+
export function TimelineOverlays() {
|
|
108
212
|
return (
|
|
109
213
|
<>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<EditPopover
|
|
116
|
-
rangeStart={rangeSelection.start}
|
|
117
|
-
rangeEnd={rangeSelection.end}
|
|
118
|
-
anchorX={rangeSelection.anchorX}
|
|
119
|
-
anchorY={rangeSelection.anchorY}
|
|
120
|
-
onClose={() => {
|
|
121
|
-
setShowPopover(false);
|
|
122
|
-
setRangeSelection(null);
|
|
123
|
-
}}
|
|
124
|
-
/>
|
|
125
|
-
)}
|
|
126
|
-
|
|
127
|
-
{kfContextMenu && keyframeElement && (
|
|
128
|
-
<KeyframeDiamondContextMenu
|
|
129
|
-
state={{ ...kfContextMenu, element: keyframeElement }}
|
|
130
|
-
onClose={() => setKfContextMenu(null)}
|
|
131
|
-
onDelete={(...args) => {
|
|
132
|
-
if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return;
|
|
133
|
-
onDeleteKeyframe?.(...args);
|
|
134
|
-
}}
|
|
135
|
-
onDeleteAll={(_element, animationId) => {
|
|
136
|
-
const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
|
|
137
|
-
if (element) onDeleteAllKeyframes?.(element, animationId);
|
|
138
|
-
}}
|
|
139
|
-
onMoveToPlayhead={
|
|
140
|
-
onMoveKeyframeToPlayhead
|
|
141
|
-
? (_element, ...args) => {
|
|
142
|
-
const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
|
|
143
|
-
if (element) onMoveKeyframeToPlayhead(element, ...args);
|
|
144
|
-
}
|
|
145
|
-
: undefined
|
|
146
|
-
}
|
|
147
|
-
// Routed to the same focused-ease-segment path a segment click takes,
|
|
148
|
-
// so the menu advertises the editor that exists rather than growing a
|
|
149
|
-
// second one. Offered only for a keyframe that names a tween to focus.
|
|
150
|
-
onEditEase={
|
|
151
|
-
kfContextMenu.animationId !== undefined && kfContextMenu.tweenPercentage !== undefined
|
|
152
|
-
? (elementId, keyframe) => {
|
|
153
|
-
if (
|
|
154
|
-
keyframe.animationId === undefined ||
|
|
155
|
-
keyframe.tweenPercentage === undefined
|
|
156
|
-
) {
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
actions.setFocusedEaseSegment({
|
|
160
|
-
animationId: keyframe.animationId,
|
|
161
|
-
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
|
162
|
-
tweenPercentage: keyframe.tweenPercentage,
|
|
163
|
-
elementId,
|
|
164
|
-
});
|
|
165
|
-
trackStudioSegmentEaseEdit({ action: "open" });
|
|
166
|
-
}
|
|
167
|
-
: undefined
|
|
168
|
-
}
|
|
169
|
-
onCopyProperties={(elementId, keyframe) => {
|
|
170
|
-
const entry = keyframeCache.get(elementId);
|
|
171
|
-
// Tolerance match on clip-%, the same basis the cache is keyed on —
|
|
172
|
-
// an exact float compare misses a keyframe the menu just opened over.
|
|
173
|
-
const kf = entry?.keyframes.find(
|
|
174
|
-
(item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
|
|
175
|
-
);
|
|
176
|
-
if (!kf) return false;
|
|
177
|
-
return copyTextToClipboard(JSON.stringify(kf.properties, null, 2));
|
|
178
|
-
}}
|
|
179
|
-
/>
|
|
180
|
-
)}
|
|
181
|
-
|
|
182
|
-
{clipContextMenu && clipElement && (
|
|
183
|
-
<ClipContextMenu
|
|
184
|
-
x={clipContextMenu.x}
|
|
185
|
-
y={clipContextMenu.y}
|
|
186
|
-
element={clipElement}
|
|
187
|
-
currentTime={currentTime}
|
|
188
|
-
onClose={() => setClipContextMenu(null)}
|
|
189
|
-
onSplit={(_element, time) => {
|
|
190
|
-
const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
|
|
191
|
-
if (element) onSplitElement?.(element, time);
|
|
192
|
-
}}
|
|
193
|
-
onDelete={() => {
|
|
194
|
-
const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
|
|
195
|
-
if (!element) return;
|
|
196
|
-
pinZoomBeforeEdit();
|
|
197
|
-
onDeleteElement?.(element);
|
|
198
|
-
}}
|
|
199
|
-
onCopy={onCopyClip}
|
|
200
|
-
onPaste={onPasteClip}
|
|
201
|
-
onDuplicate={onDuplicateClip}
|
|
202
|
-
canPaste={canPasteClip?.() ?? false}
|
|
203
|
-
/>
|
|
204
|
-
)}
|
|
205
|
-
|
|
206
|
-
{gapContextMenu && (
|
|
207
|
-
<TrackGapContextMenu
|
|
208
|
-
x={gapContextMenu.x}
|
|
209
|
-
y={gapContextMenu.y}
|
|
210
|
-
gapWidth={gapContextMenu.gapWidth}
|
|
211
|
-
canCloseGap={gapContextMenu.canCloseGap}
|
|
212
|
-
canCloseAllGaps={gapContextMenu.canCloseAllGaps}
|
|
213
|
-
hasAnyGaps={gapContextMenu.hasAnyGaps}
|
|
214
|
-
onClose={onDismissGapContextMenu}
|
|
215
|
-
onCloseGap={onCloseTrackGap}
|
|
216
|
-
onCloseAllGaps={onCloseAllTrackGaps}
|
|
217
|
-
onHoverAction={onHoverGapAction}
|
|
218
|
-
/>
|
|
219
|
-
)}
|
|
214
|
+
<TimelineShortcutHintOverlay />
|
|
215
|
+
<TimelineEditPopoverOverlay />
|
|
216
|
+
<TimelineKeyframeMenuOverlay />
|
|
217
|
+
<TimelineClipMenuOverlay />
|
|
218
|
+
<TimelineGapMenuOverlay />
|
|
220
219
|
</>
|
|
221
220
|
);
|
|
222
221
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { TimelineContextValue } from "./TimelineProvider";
|
|
5
|
+
import { TimelineContextProvider, useTimelineContext } from "./TimelineProvider";
|
|
6
|
+
import { TimelineClipMenu, TimelineLanes, TimelinePlayhead, TimelineRuler } from "./TimelineParts";
|
|
7
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
8
|
+
|
|
9
|
+
vi.mock("./TimelineRuler", () => ({
|
|
10
|
+
TimelineRuler: () => <div data-testid="timeline-ruler" />,
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("./TimelineLanes", () => ({
|
|
13
|
+
TimelineLanes: ({ renderClipContent }: { renderClipContent?: unknown }) => {
|
|
14
|
+
const { state } = useTimelineContext();
|
|
15
|
+
return (
|
|
16
|
+
<div data-testid="timeline-clip">
|
|
17
|
+
{state.elements[0]?.id}
|
|
18
|
+
{renderClipContent ? <span data-testid="lane-render-bridge" /> : null}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { mount } = createHappyDomRootHarness();
|
|
25
|
+
|
|
26
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
document.body.innerHTML = "";
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("Timeline parts composition", () => {
|
|
33
|
+
it("reads a shared provider context when composed without Timeline or Frame", () => {
|
|
34
|
+
const host = document.createElement("div");
|
|
35
|
+
document.body.append(host);
|
|
36
|
+
const root = mount(host);
|
|
37
|
+
const onSplitElement = vi.fn();
|
|
38
|
+
const element = { id: "clip-a", key: "clip-a", tag: "video", start: 0, duration: 2, track: 0 };
|
|
39
|
+
const value = {
|
|
40
|
+
state: {
|
|
41
|
+
timelineReady: true,
|
|
42
|
+
elements: [element],
|
|
43
|
+
selectedElementId: "clip-a",
|
|
44
|
+
sessionEpoch: 1,
|
|
45
|
+
keyframeCache: new Map(),
|
|
46
|
+
canvas: { isScrubbing: false } as TimelineContextValue["state"]["canvas"],
|
|
47
|
+
overlays: {
|
|
48
|
+
elements: [element],
|
|
49
|
+
elementsRef: { current: [element] },
|
|
50
|
+
theme: {} as TimelineContextValue["state"]["overlays"]["theme"],
|
|
51
|
+
showShortcutHint: false,
|
|
52
|
+
showPopover: false,
|
|
53
|
+
rangeSelection: null,
|
|
54
|
+
setShowPopover: vi.fn(),
|
|
55
|
+
setRangeSelection: vi.fn(),
|
|
56
|
+
kfContextMenu: null,
|
|
57
|
+
setKfContextMenu: vi.fn(),
|
|
58
|
+
onDeleteKeyframe: vi.fn(),
|
|
59
|
+
onDeleteAllKeyframes: vi.fn(),
|
|
60
|
+
onMoveKeyframeToPlayhead: vi.fn(),
|
|
61
|
+
clipContextMenu: { x: 10, y: 10, element, sessionEpoch: 1 },
|
|
62
|
+
setClipContextMenu: vi.fn(),
|
|
63
|
+
currentTime: 1,
|
|
64
|
+
onSplitElement,
|
|
65
|
+
pinZoomBeforeEdit: vi.fn(),
|
|
66
|
+
gapContextMenu: null,
|
|
67
|
+
onDismissGapContextMenu: vi.fn(),
|
|
68
|
+
onCloseTrackGap: vi.fn(),
|
|
69
|
+
onCloseAllTrackGaps: vi.fn(),
|
|
70
|
+
onHoverGapAction: vi.fn(),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
actions: {
|
|
74
|
+
renderClipContent: vi.fn(),
|
|
75
|
+
renderClipOverlay: undefined,
|
|
76
|
+
setFocusedEaseSegment: vi.fn(),
|
|
77
|
+
},
|
|
78
|
+
meta: { razorGuide: null } as TimelineContextValue["meta"],
|
|
79
|
+
} satisfies TimelineContextValue;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
act(() => {
|
|
83
|
+
root.render(
|
|
84
|
+
<TimelineContextProvider value={value}>
|
|
85
|
+
<TimelineRuler />
|
|
86
|
+
<TimelineLanes />
|
|
87
|
+
<TimelinePlayhead />
|
|
88
|
+
<TimelineClipMenu />
|
|
89
|
+
</TimelineContextProvider>,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(host.querySelector("[data-testid='timeline-ruler']")).not.toBeNull();
|
|
94
|
+
expect(host.querySelector("[data-testid='timeline-clip']")?.textContent).toBe("clip-a");
|
|
95
|
+
expect(host.querySelector("[data-testid='lane-render-bridge']")).not.toBeNull();
|
|
96
|
+
expect(document.querySelector("[role='menu'][aria-label='Clip actions']")).not.toBeNull();
|
|
97
|
+
} finally {
|
|
98
|
+
act(() => root.unmount());
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { TimelineCanvas as TimelineCanvasImpl } from "./TimelineCanvas";
|
|
2
|
+
import {
|
|
3
|
+
TimelineClipMenuOverlay as TimelineClipMenuImpl,
|
|
4
|
+
TimelineEditPopoverOverlay as TimelineEditPopoverImpl,
|
|
5
|
+
TimelineGapMenuOverlay as TimelineGapMenuImpl,
|
|
6
|
+
TimelineKeyframeMenuOverlay as TimelineKeyframeMenuImpl,
|
|
7
|
+
TimelineShortcutHintOverlay as TimelineShortcutHintImpl,
|
|
8
|
+
} from "./TimelineOverlays";
|
|
9
|
+
import { TimelineLanes as TimelineLanesImpl } from "./TimelineLanes";
|
|
10
|
+
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
11
|
+
import { useTimelineContext } from "./TimelineProvider";
|
|
12
|
+
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
13
|
+
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
14
|
+
|
|
15
|
+
/** The provider-backed canvas shell. */
|
|
16
|
+
export function TimelineFrame() {
|
|
17
|
+
return <TimelineCanvasImpl />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The provider-backed ruler. */
|
|
21
|
+
export const TimelineRuler = TimelineRulerPart;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The provider-backed lane renderer. TimelineFrame remains the composed Studio
|
|
25
|
+
* canvas; this part is exported for hosts that own the canvas arrangement.
|
|
26
|
+
*/
|
|
27
|
+
export function TimelineLanes() {
|
|
28
|
+
const { state, actions } = useTimelineContext();
|
|
29
|
+
const props = state.canvas;
|
|
30
|
+
return (
|
|
31
|
+
<TimelineLanesImpl
|
|
32
|
+
{...props}
|
|
33
|
+
renderClipContent={actions.renderClipContent}
|
|
34
|
+
renderClipOverlay={actions.renderClipOverlay}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The provider-backed playhead indicator. */
|
|
40
|
+
export function TimelinePlayhead() {
|
|
41
|
+
const { state } = useTimelineContext();
|
|
42
|
+
const props = state.canvas;
|
|
43
|
+
return <PlayheadIndicator scrubbing={props.isScrubbing} />;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The provider-backed razor guide. */
|
|
47
|
+
export function TimelineRazorGuide() {
|
|
48
|
+
const { meta } = useTimelineContext();
|
|
49
|
+
return meta.razorGuide;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The composed overlay surface used by the Studio variant. */
|
|
53
|
+
export { TimelineOverlays } from "./TimelineOverlays";
|
|
54
|
+
|
|
55
|
+
export function TimelineEmptyStatePart() {
|
|
56
|
+
const { meta } = useTimelineContext();
|
|
57
|
+
return <TimelineEmptyState {...meta.emptyState} />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function TimelineShortcutHint() {
|
|
61
|
+
return <TimelineShortcutHintImpl />;
|
|
62
|
+
}
|
|
63
|
+
export function TimelineEditPopover() {
|
|
64
|
+
return <TimelineEditPopoverImpl />;
|
|
65
|
+
}
|
|
66
|
+
export function TimelineClipMenu() {
|
|
67
|
+
return <TimelineClipMenuImpl />;
|
|
68
|
+
}
|
|
69
|
+
export function TimelineKeyframeMenu() {
|
|
70
|
+
return <TimelineKeyframeMenuImpl />;
|
|
71
|
+
}
|
|
72
|
+
export function TimelineGapMenu() {
|
|
73
|
+
return <TimelineGapMenuImpl />;
|
|
74
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { Timeline } from "./Timeline";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import { useTimelineContext } from "./TimelineProvider";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
usePlayerStore.getState().reset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function TimelinePartsVariant() {
|
|
18
|
+
const { meta } = useTimelineContext();
|
|
19
|
+
return React.createElement(
|
|
20
|
+
"div",
|
|
21
|
+
meta.containerProps,
|
|
22
|
+
React.createElement(
|
|
23
|
+
"div",
|
|
24
|
+
meta.viewportProps,
|
|
25
|
+
React.createElement(Timeline.Frame),
|
|
26
|
+
React.createElement(Timeline.RazorGuide),
|
|
27
|
+
),
|
|
28
|
+
React.createElement(Timeline.Overlays),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("Timeline provider boundary", () => {
|
|
33
|
+
it("keeps the composed Timeline markup equal to its provider parts", () => {
|
|
34
|
+
usePlayerStore.setState({
|
|
35
|
+
duration: 4,
|
|
36
|
+
timelineReady: true,
|
|
37
|
+
elements: [{ id: "parity-clip", tag: "div", start: 0, duration: 2, track: 0 }],
|
|
38
|
+
});
|
|
39
|
+
const composedHost = document.createElement("div");
|
|
40
|
+
const partsHost = document.createElement("div");
|
|
41
|
+
document.body.append(composedHost, partsHost);
|
|
42
|
+
const composedRoot = createRoot(composedHost);
|
|
43
|
+
const partsRoot = createRoot(partsHost);
|
|
44
|
+
act(() => {
|
|
45
|
+
composedRoot.render(React.createElement(Timeline));
|
|
46
|
+
partsRoot.render(
|
|
47
|
+
React.createElement(Timeline.Provider, null, React.createElement(TimelinePartsVariant)),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
const normalizeMarkup = (markup: string) =>
|
|
51
|
+
markup.replaceAll(/timeline-lanes_[^"]+/g, "timeline-lanes");
|
|
52
|
+
expect(normalizeMarkup(partsHost.innerHTML)).toBe(normalizeMarkup(composedHost.innerHTML));
|
|
53
|
+
act(() => {
|
|
54
|
+
composedRoot.unmount();
|
|
55
|
+
partsRoot.unmount();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -17,6 +17,8 @@ import type { Rect } from "../../utils/marqueeGeometry";
|
|
|
17
17
|
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
18
18
|
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
19
19
|
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
|
|
20
|
+
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
21
|
+
import type { TimelineSnapTarget } from "./timelineSnapping";
|
|
20
22
|
import type { TimelineElement } from "../store/playerStore";
|
|
21
23
|
import type { KeyframeCacheEntry } from "../store/keyframeSlice";
|
|
22
24
|
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
@@ -24,6 +26,10 @@ import type { TimelineTheme } from "./timelineTheme";
|
|
|
24
26
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
25
27
|
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
26
28
|
import { useTimelineProviderState } from "./useTimelineProviderState";
|
|
29
|
+
import {
|
|
30
|
+
TimelineEditProvider,
|
|
31
|
+
useTimelineEditContextValue,
|
|
32
|
+
} from "../../contexts/TimelineEditContext";
|
|
27
33
|
|
|
28
34
|
export type TimelineCanvasState = Omit<
|
|
29
35
|
TimelineLaneBaseProps,
|
|
@@ -51,6 +57,13 @@ export type TimelineCanvasState = Omit<
|
|
|
51
57
|
beatDragging: boolean;
|
|
52
58
|
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
|
53
59
|
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
|
60
|
+
draggedElement: TimelineElement | null;
|
|
61
|
+
snapGuide: TimelineSnapTarget | null;
|
|
62
|
+
multiDragPreview: MultiDragPreviewInput | null;
|
|
63
|
+
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
64
|
+
onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
65
|
+
onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
|
|
66
|
+
onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
|
|
54
67
|
};
|
|
55
68
|
|
|
56
69
|
export interface ClipContextMenuState {
|
|
@@ -166,6 +179,18 @@ export interface TimelineContextValue {
|
|
|
166
179
|
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
|
167
180
|
|
|
168
181
|
export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
|
182
|
+
const editContext = useTimelineEditContextValue();
|
|
183
|
+
if (!editContext) {
|
|
184
|
+
return (
|
|
185
|
+
<TimelineEditProvider value={props}>
|
|
186
|
+
<TimelineProviderState {...props}>{children}</TimelineProviderState>
|
|
187
|
+
</TimelineEditProvider>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
|
169
194
|
const value = useTimelineProviderState(props);
|
|
170
195
|
return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
|
|
171
196
|
}
|
|
@@ -50,7 +50,7 @@ export function useTimelineRazorInteraction({
|
|
|
50
50
|
return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function
|
|
53
|
+
export function TimelineRazorGuideOverlay({ x }: { x: number }) {
|
|
54
54
|
return (
|
|
55
55
|
<div
|
|
56
56
|
className="absolute top-0 bottom-0 pointer-events-none z-10"
|