@hyperframes/studio 0.8.58 → 0.8.60
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-Di7GyCOP.js} +3 -3
- package/dist/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
- package/dist/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
- package/dist/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
- package/dist/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
- package/dist/index.d.ts +549 -13
- package/dist/index.html +2 -2
- package/dist/index.js +23133 -21941
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +22 -8
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- 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/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +395 -0
- package/src/components/nle/AudioMeterStrip.tsx +430 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +16 -0
- package/src/player/components/AudioWaveform.tsx +16 -7
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +28 -71
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +25 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -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/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/VideoThumbnail.tsx +3 -5
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +12 -11
- 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/hex-baseline.json +4 -23
- package/src/styles/studio.css +43 -47
- package/src/styles/theme.css +109 -1
- package/src/styles/theme.test.ts +9 -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
|
@@ -55,21 +55,24 @@ function GroupNameButton({
|
|
|
55
55
|
// No `flex-1`: the row's control group owns the slack now (`ml-auto`), so
|
|
56
56
|
// claiming it here would push the controls off the right edge — and the
|
|
57
57
|
// count with them, since it rides inside this button.
|
|
58
|
-
className="flex h-6 min-w-0 items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-left text-[11px] text-
|
|
58
|
+
className="flex h-6 min-w-0 items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-left text-[11px] text-[var(--timeline-text-solid)] hover:text-[var(--timeline-accent)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
|
|
59
59
|
onPointerDown={(event) => event.stopPropagation()}
|
|
60
60
|
onClick={(event) => {
|
|
61
61
|
event.stopPropagation();
|
|
62
62
|
onOpenFxRack();
|
|
63
63
|
}}
|
|
64
64
|
>
|
|
65
|
-
<span
|
|
65
|
+
<span
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
className="shrink-0 text-[12px] leading-none text-[var(--timeline-track-label)]"
|
|
68
|
+
>
|
|
66
69
|
▤
|
|
67
70
|
</span>
|
|
68
71
|
<span title={label} className="min-w-0 truncate text-left font-medium leading-tight">
|
|
69
72
|
{label}
|
|
70
73
|
</span>
|
|
71
74
|
<span
|
|
72
|
-
className="shrink-0 rounded-full bg-
|
|
75
|
+
className="shrink-0 rounded-full bg-[var(--timeline-tick-major)] px-1 text-[9px] leading-[14px] tabular-nums text-[var(--timeline-handle)]"
|
|
73
76
|
aria-hidden="true"
|
|
74
77
|
title={`${memberCount} tracks`}
|
|
75
78
|
>
|
|
@@ -103,7 +106,7 @@ export function TimelineGroupHeader({
|
|
|
103
106
|
style={{
|
|
104
107
|
width: columnWidth,
|
|
105
108
|
height: TRACK_H,
|
|
106
|
-
color: "
|
|
109
|
+
color: "var(--timeline-text-solid)",
|
|
107
110
|
background: theme.gutterBackground,
|
|
108
111
|
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
109
112
|
}}
|
|
@@ -119,8 +122,10 @@ export function TimelineGroupHeader({
|
|
|
119
122
|
// 13px mono, matching the property panel's preset-run caret
|
|
120
123
|
// (`hf-fx-preset-run-caret`) — the same disclosure, so the same glyph
|
|
121
124
|
// at the same size rather than a smaller one unique to this row.
|
|
122
|
-
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[
|
|
123
|
-
isExpanded
|
|
125
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
|
|
126
|
+
isExpanded
|
|
127
|
+
? "text-[var(--timeline-text-solid)]"
|
|
128
|
+
: "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
|
|
124
129
|
}`}
|
|
125
130
|
onPointerDown={(event) => event.stopPropagation()}
|
|
126
131
|
onClick={(event) => {
|
|
@@ -157,8 +162,10 @@ export function TimelineGroupHeader({
|
|
|
157
162
|
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
|
|
158
163
|
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
|
|
159
164
|
// Anchored right, matching every other header's lane toggle.
|
|
160
|
-
className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[
|
|
161
|
-
isLaneOpen
|
|
165
|
+
className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
|
|
166
|
+
isLaneOpen
|
|
167
|
+
? "text-[var(--timeline-accent)]"
|
|
168
|
+
: "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
|
|
162
169
|
}`}
|
|
163
170
|
onPointerDown={(event) => event.stopPropagation()}
|
|
164
171
|
onClick={(event) => {
|
|
@@ -167,7 +174,9 @@ export function TimelineGroupHeader({
|
|
|
167
174
|
}}
|
|
168
175
|
>
|
|
169
176
|
<span aria-hidden="true">∿</span>
|
|
170
|
-
<span className="text-[9px] tabular-nums text-
|
|
177
|
+
<span className="text-[9px] tabular-nums text-[var(--timeline-handle)]">
|
|
178
|
+
{laneCount}
|
|
179
|
+
</span>
|
|
171
180
|
</button>
|
|
172
181
|
)}
|
|
173
182
|
</div>
|
|
@@ -64,7 +64,7 @@ export function TimelineGroupLaneLabels({
|
|
|
64
64
|
key={lane.target}
|
|
65
65
|
data-group-lane-label={lane.target}
|
|
66
66
|
aria-label={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
|
|
67
|
-
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-
|
|
67
|
+
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-[var(--timeline-text-secondary)] hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
|
|
68
68
|
style={{
|
|
69
69
|
top: top + index * AUTOMATION_LANE_H,
|
|
70
70
|
width: columnWidth,
|
|
@@ -79,16 +79,23 @@ export function TimelineGroupLaneLabels({
|
|
|
79
79
|
onReveal?.(lane.target);
|
|
80
80
|
}}
|
|
81
81
|
>
|
|
82
|
-
<span
|
|
82
|
+
<span
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
className="shrink-0 text-[11px] text-[var(--timeline-text-dim)]"
|
|
85
|
+
>
|
|
83
86
|
▤
|
|
84
87
|
</span>
|
|
85
88
|
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight">
|
|
86
|
-
<span className="truncate font-mono text-[9px] text-
|
|
89
|
+
<span className="truncate font-mono text-[9px] text-[var(--timeline-text-bright)]">
|
|
90
|
+
{parts.name}
|
|
91
|
+
</span>
|
|
87
92
|
{parts.param ? (
|
|
88
|
-
<span className="truncate font-mono text-[9px] text-
|
|
93
|
+
<span className="truncate font-mono text-[9px] text-[var(--timeline-text-dim)]">
|
|
94
|
+
{parts.param}
|
|
95
|
+
</span>
|
|
89
96
|
) : null}
|
|
90
97
|
</span>
|
|
91
|
-
<span className="shrink-0 font-mono text-[9px] tabular-nums text-
|
|
98
|
+
<span className="shrink-0 font-mono text-[9px] tabular-nums text-[var(--timeline-handle)]">
|
|
92
99
|
{value.toFixed(2)}
|
|
93
100
|
</span>
|
|
94
101
|
</button>
|
|
@@ -21,7 +21,7 @@ import { usePlayerStore } from "../store/playerStore";
|
|
|
21
21
|
|
|
22
22
|
/** Accent rail on a group-owned lane — the same green the member rail uses, so
|
|
23
23
|
* "this belongs to the group" reads the same in both places (groups doc §5). */
|
|
24
|
-
const GROUP_LANE_ACCENT = "
|
|
24
|
+
const GROUP_LANE_ACCENT = "var(--timeline-accent)";
|
|
25
25
|
|
|
26
26
|
interface TimelineGroupRowProps {
|
|
27
27
|
index: number;
|
|
@@ -432,6 +432,20 @@ describe("TimelineLanes selection", () => {
|
|
|
432
432
|
});
|
|
433
433
|
|
|
434
434
|
describe("TimelineLanes clip thumbnails", () => {
|
|
435
|
+
it("keeps thumbnail content inside a selected clip", () => {
|
|
436
|
+
const selected = element("clip-a", TRACK_A);
|
|
437
|
+
const view = renderLanes({
|
|
438
|
+
elements: [selected],
|
|
439
|
+
selectedElementIds: new Set([selected.id]),
|
|
440
|
+
renderClipContent: () => <div className="absolute inset-0 bg-neutral-900" data-thumbnail />,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const clip = view.host.querySelector('[data-el-id="clip-a"]');
|
|
444
|
+
expect(clip?.classList.contains("is-selected")).toBe(true);
|
|
445
|
+
expect(clip?.querySelector("[data-thumbnail]")).not.toBeNull();
|
|
446
|
+
act(() => view.root.unmount());
|
|
447
|
+
});
|
|
448
|
+
|
|
435
449
|
it("asks for the same frames at rest, hovered and selected", () => {
|
|
436
450
|
const rich: unknown[] = [];
|
|
437
451
|
const renderClipContent = vi.fn(
|
|
@@ -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
|
+
});
|