@hyperframes/studio 0.8.7 → 0.8.9
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-bDcU464i.js → hyperframes-player-OP68oEBG.js} +1 -1
- package/dist/assets/{index-CUXnyoIa.js → index-57oMsXQN.js} +111 -111
- package/dist/assets/{index-MSOSP1-J.js → index-BIdEpMGS.js} +1 -1
- package/dist/assets/{index-FkAs3zRu.js → index-D1Sa69m2.js} +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.html +1 -1
- package/dist/index.js +40 -35
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +1 -1
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +1 -5
- package/src/components/editor/TimelineFxPopover.test.tsx +89 -0
- package/src/components/editor/TimelineFxPopover.tsx +49 -15
- package/src/hooks/timelineTrackVisibility.test.ts +31 -0
- package/src/hooks/timelineTrackVisibility.ts +20 -4
- package/src/player/components/AutomationSelectionMenu.tsx +1 -1
- package/src/player/components/ClipContextMenu.tsx +1 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +1 -1
- package/src/player/components/Timeline.test.ts +1 -1
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.test.tsx +76 -0
- package/src/player/components/TimelineGroupHeader.tsx +8 -4
- package/src/player/components/TimelineLanes.test.tsx +3 -1
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +17 -10
- package/src/player/components/TrackGapContextMenu.tsx +1 -1
- package/src/player/components/timelineCallbacks.ts +14 -1
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +4 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/core": "0.8.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
50
|
+
"@hyperframes/core": "0.8.9",
|
|
51
|
+
"@hyperframes/parsers": "0.8.9",
|
|
52
|
+
"@hyperframes/sdk": "0.8.9",
|
|
53
|
+
"@hyperframes/studio-server": "0.8.9",
|
|
54
|
+
"@hyperframes/player": "0.8.9"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vite": "^6.4.2",
|
|
69
69
|
"vitest": "^3.2.4",
|
|
70
70
|
"zustand": "^5.0.0",
|
|
71
|
-
"@hyperframes/producer": "0.8.
|
|
71
|
+
"@hyperframes/producer": "0.8.9"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"react": "19",
|
|
@@ -92,7 +92,7 @@ describe("CanvasContextMenu — handler gating", () => {
|
|
|
92
92
|
|
|
93
93
|
// No menu opened at all — no buttons, no dead-end items, no DOM mutation.
|
|
94
94
|
expect(menuButtons()).toHaveLength(0);
|
|
95
|
-
expect(document.body.querySelector(".fixed.z
|
|
95
|
+
expect(document.body.querySelector(".fixed.z-\\[200\\]")).toBeNull();
|
|
96
96
|
expect(el.style.zIndex).toBe("3");
|
|
97
97
|
});
|
|
98
98
|
|
|
@@ -212,7 +212,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
212
212
|
return createPortal(
|
|
213
213
|
<div
|
|
214
214
|
ref={menuRef}
|
|
215
|
-
className="fixed z-
|
|
215
|
+
className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
216
216
|
style={{ left: adjustedX, top: adjustedY }}
|
|
217
217
|
onPointerDown={stopBubble}
|
|
218
218
|
onMouseDown={stopBubble}
|
|
@@ -14,7 +14,6 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
|
|
14
14
|
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
|
15
15
|
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
|
16
16
|
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
|
|
17
|
-
import { isCanaryEnabled } from "../../telemetry/canary";
|
|
18
17
|
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
|
|
19
18
|
import { useVolumeAutomation } from "./useVolumeAutomation";
|
|
20
19
|
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
|
|
@@ -428,10 +427,7 @@ export function PropertyPanelFlat({
|
|
|
428
427
|
});
|
|
429
428
|
}
|
|
430
429
|
}
|
|
431
|
-
|
|
432
|
-
// and render still honour a `data-fx-chain` already on an element, so a
|
|
433
|
-
// composition written through the skill does not go silently dry off-cohort.
|
|
434
|
-
if (sections.audioFx && isCanaryEnabled("audio-fx-rack")) {
|
|
430
|
+
if (sections.audioFx) {
|
|
435
431
|
groups.push({
|
|
436
432
|
id: "audio-fx",
|
|
437
433
|
title: "Audio FX",
|
|
@@ -10,6 +10,28 @@ import { TimelineFxPopover } from "./TimelineFxPopover.js";
|
|
|
10
10
|
const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] };
|
|
11
11
|
const RECT = { left: 0, top: 0, right: 0, bottom: 0 } as DOMRect;
|
|
12
12
|
|
|
13
|
+
function rect(top: number, bottom: number): DOMRect {
|
|
14
|
+
return { left: 0, top, right: 0, bottom } as DOMRect;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Pin the viewport height: every expected number below is derived from it, and
|
|
18
|
+
* happy-dom's 768 default is not something this file should silently inherit. */
|
|
19
|
+
function withViewportHeight<T>(value: number, run: () => T): T {
|
|
20
|
+
const previous = window.innerHeight;
|
|
21
|
+
Object.defineProperty(window, "innerHeight", { value, configurable: true });
|
|
22
|
+
try {
|
|
23
|
+
return run();
|
|
24
|
+
} finally {
|
|
25
|
+
Object.defineProperty(window, "innerHeight", { value: previous, configurable: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function dialogOf(host: HTMLElement): HTMLElement {
|
|
30
|
+
const el = host.querySelector('[role="dialog"]');
|
|
31
|
+
if (!el) throw new Error("no dialog");
|
|
32
|
+
return el as HTMLElement;
|
|
33
|
+
}
|
|
34
|
+
|
|
13
35
|
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
|
14
36
|
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
|
15
37
|
}
|
|
@@ -102,6 +124,73 @@ describe("TimelineFxPopover", () => {
|
|
|
102
124
|
expect(onClose).not.toHaveBeenCalled();
|
|
103
125
|
});
|
|
104
126
|
|
|
127
|
+
// These guard the regression that shipped once already: an uncapped
|
|
128
|
+
// popover grew past the gap it opened into, ran under the timeline chrome and
|
|
129
|
+
// took its footer with it, and nothing scrolled.
|
|
130
|
+
it("caps its height to the space below when it opens downward", () => {
|
|
131
|
+
// spaceBelow 738 > spaceAbove 10, so it opens down: 738 - margin(8) - gap(4).
|
|
132
|
+
const dialog = withViewportHeight(768, () =>
|
|
133
|
+
dialogOf(mount({ anchorRect: rect(10, 30) }).host),
|
|
134
|
+
);
|
|
135
|
+
expect(dialog.style.top).toBe("34px");
|
|
136
|
+
expect(dialog.style.maxHeight).toBe("726px");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("caps its height to the space above when it flips upward", () => {
|
|
140
|
+
// spaceBelow 48 < 260 and spaceAbove 700 is larger, so it flips up.
|
|
141
|
+
const dialog = withViewportHeight(768, () =>
|
|
142
|
+
dialogOf(mount({ anchorRect: rect(700, 720) }).host),
|
|
143
|
+
);
|
|
144
|
+
expect(dialog.style.bottom).toBe("72px");
|
|
145
|
+
expect(dialog.style.maxHeight).toBe("688px");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("never caps below a usable minimum, however tight the gap", () => {
|
|
149
|
+
// Both gaps are tiny (80 below / 100 above); the cap must not collapse.
|
|
150
|
+
const dialog = withViewportHeight(200, () =>
|
|
151
|
+
dialogOf(mount({ anchorRect: rect(100, 120) }).host),
|
|
152
|
+
);
|
|
153
|
+
expect(dialog.style.maxHeight).toBe("160px");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("keeps both edges in the viewport when the minimum exceeds the gap", () => {
|
|
157
|
+
// The case the minimum used to lose: at 200px of viewport (roughly 400% zoom
|
|
158
|
+
// on a laptop) neither gap can hold 160px, so honouring the floor has to
|
|
159
|
+
// slide the box in rather than hang its top edge off-screen.
|
|
160
|
+
const dialog = withViewportHeight(200, () =>
|
|
161
|
+
dialogOf(mount({ anchorRect: rect(100, 120) }).host),
|
|
162
|
+
);
|
|
163
|
+
// bottom:32 + maxHeight:160 puts the box at y = 8..168 — a margin on each side.
|
|
164
|
+
expect(dialog.style.bottom).toBe("32px");
|
|
165
|
+
const bottom = Number.parseFloat(dialog.style.bottom);
|
|
166
|
+
const height = Number.parseFloat(dialog.style.maxHeight);
|
|
167
|
+
expect(bottom).toBeGreaterThanOrEqual(8);
|
|
168
|
+
expect(200 - bottom - height).toBeGreaterThanOrEqual(8);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("shrinks below the minimum only when the whole window is shorter", () => {
|
|
172
|
+
// A floor against a tight gap is not a floor against a tight window: 120px of
|
|
173
|
+
// viewport cannot hold 160px, and hanging off the edge is worse than short.
|
|
174
|
+
const dialog = withViewportHeight(120, () =>
|
|
175
|
+
dialogOf(mount({ anchorRect: rect(60, 80) }).host),
|
|
176
|
+
);
|
|
177
|
+
expect(dialog.style.maxHeight).toBe("104px");
|
|
178
|
+
expect(dialog.style.bottom).toBe("8px");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("scrolls the preset list and leaves the footer outside the scroller", () => {
|
|
182
|
+
const { host } = mount();
|
|
183
|
+
const dialog = dialogOf(host);
|
|
184
|
+
const scroller = dialog.querySelector(".overflow-y-auto");
|
|
185
|
+
expect(scroller).toBeTruthy();
|
|
186
|
+
// The footer must be a SIBLING of the scroller, not inside it — otherwise it
|
|
187
|
+
// scrolls away instead of staying put, which is the original bug.
|
|
188
|
+
const footer = byTextButton(host, "Open rack");
|
|
189
|
+
expect(footer).toBeDefined();
|
|
190
|
+
expect(scroller?.contains(footer as Node)).toBe(false);
|
|
191
|
+
expect(dialog.contains(footer as Node)).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
105
194
|
it("the footer opens the rack and closes the popover", () => {
|
|
106
195
|
const { host, onOpenRack, onClose } = mount();
|
|
107
196
|
const openRack = byTextButton(host, "Open rack");
|
|
@@ -17,6 +17,8 @@ import { useFxAudition } from "./useFxAudition.js";
|
|
|
17
17
|
|
|
18
18
|
const POPOVER_WIDTH = 260;
|
|
19
19
|
const VIEWPORT_MARGIN = 8;
|
|
20
|
+
/** Below this the popover is useless anyway; it scrolls instead of vanishing. */
|
|
21
|
+
const MIN_POPOVER_HEIGHT = 160;
|
|
20
22
|
|
|
21
23
|
function clampedStyle(anchorRect: DOMRect): CSSProperties {
|
|
22
24
|
const left = Math.min(
|
|
@@ -24,14 +26,41 @@ function clampedStyle(anchorRect: DOMRect): CSSProperties {
|
|
|
24
26
|
Math.max(VIEWPORT_MARGIN, window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN),
|
|
25
27
|
);
|
|
26
28
|
const spaceBelow = window.innerHeight - anchorRect.bottom;
|
|
27
|
-
|
|
29
|
+
// Named, because the height cap below needs the same quantity the flip does.
|
|
30
|
+
// (`spaceAbove === anchorRect.top` for a viewport-relative rect, so the flip
|
|
31
|
+
// condition itself is unchanged — this is a rename, not a behaviour fix.)
|
|
32
|
+
const spaceAbove = anchorRect.top;
|
|
33
|
+
const openUpward = spaceBelow < 260 && spaceAbove > spaceBelow;
|
|
34
|
+
// Flipping direction alone is not enough: the preset list is taller than either
|
|
35
|
+
// gap on a short window, so the popover ran off the top or the bottom and its
|
|
36
|
+
// footer ("+ effect" / "Open rack") went with it. Cap to whatever the chosen
|
|
37
|
+
// side actually has and let the list scroll inside that.
|
|
38
|
+
const available = (openUpward ? spaceAbove : spaceBelow) - VIEWPORT_MARGIN - 4;
|
|
39
|
+
// The minimum is a floor against a tight GAP, not against a tight window: keep
|
|
40
|
+
// a usable list when the gap is smaller than 160px, but never ask for more
|
|
41
|
+
// height than the viewport itself can hold.
|
|
42
|
+
const height = Math.min(
|
|
43
|
+
Math.max(MIN_POPOVER_HEIGHT, available),
|
|
44
|
+
window.innerHeight - VIEWPORT_MARGIN * 2,
|
|
45
|
+
);
|
|
46
|
+
// A floor larger than the gap would hang the box off the edge it opened away
|
|
47
|
+
// from — reachable at high browser zoom, where both gaps fall under ~172px.
|
|
48
|
+
// Slide it back in-bounds the way `left` is already clamped, rather than
|
|
49
|
+
// shrinking below the floor: the offset that keeps BOTH edges inside is
|
|
50
|
+
// `innerHeight - height - VIEWPORT_MARGIN`, from either side.
|
|
51
|
+
const inset = (desired: number) =>
|
|
52
|
+
Math.max(
|
|
53
|
+
VIEWPORT_MARGIN,
|
|
54
|
+
Math.min(desired, Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)),
|
|
55
|
+
);
|
|
28
56
|
return {
|
|
29
57
|
position: "fixed",
|
|
30
58
|
left,
|
|
31
59
|
width: POPOVER_WIDTH,
|
|
60
|
+
maxHeight: height,
|
|
32
61
|
...(openUpward
|
|
33
|
-
? { bottom: window.innerHeight - anchorRect.top + 4 }
|
|
34
|
-
: { top: anchorRect.bottom + 4 }),
|
|
62
|
+
? { bottom: inset(window.innerHeight - anchorRect.top + 4) }
|
|
63
|
+
: { top: inset(anchorRect.bottom + 4) }),
|
|
35
64
|
};
|
|
36
65
|
}
|
|
37
66
|
|
|
@@ -96,22 +125,27 @@ export function TimelineFxPopover({
|
|
|
96
125
|
ref={rootRef}
|
|
97
126
|
role="dialog"
|
|
98
127
|
aria-label="Effects"
|
|
99
|
-
className="z-
|
|
128
|
+
className="z-[200] flex flex-col overflow-hidden rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
|
|
100
129
|
style={clampedStyle(anchorRect)}
|
|
101
130
|
onKeyDown={onKeyDown}
|
|
102
131
|
onPointerDown={(event) => event.stopPropagation()}
|
|
103
132
|
>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
133
|
+
{/* The list scrolls; the footer below stays put. `min-h-0` is load-bearing
|
|
134
|
+
— a flex child defaults to min-height:auto and would refuse to shrink,
|
|
135
|
+
pushing the footer out of the popover instead of scrolling. */}
|
|
136
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
137
|
+
<FxPresetMenu
|
|
138
|
+
trackKind={trackKind}
|
|
139
|
+
onPick={applyPreset}
|
|
140
|
+
onAudition={
|
|
141
|
+
onChainPreview
|
|
142
|
+
? (id) =>
|
|
143
|
+
audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
|
|
144
|
+
: undefined
|
|
145
|
+
}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="mt-2 flex shrink-0 items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
|
|
115
149
|
<button
|
|
116
150
|
type="button"
|
|
117
151
|
className="hover:text-white"
|
|
@@ -223,6 +223,37 @@ describe("toggleTimelineTrackHidden", () => {
|
|
|
223
223
|
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 1");
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
+
// The header derives its row from the GROUP-AWARE order (a synthetic anchor
|
|
227
|
+
// row, members pulled contiguous); this function's own fallback derives it
|
|
228
|
+
// from ascending element-bearing keys. Once a group exists those disagree, so
|
|
229
|
+
// the same click announced "Mute track 2" and recorded "Mute track 1". The
|
|
230
|
+
// clicked control passes the row it showed, and that is what gets recorded.
|
|
231
|
+
it("records the display row the clicked control announced, not its own derivation", async () => {
|
|
232
|
+
const files = new Map([
|
|
233
|
+
["index.html", `<div id="voiceover" data-start="0" data-duration="2"></div>`],
|
|
234
|
+
]);
|
|
235
|
+
stubProjectFiles(files);
|
|
236
|
+
|
|
237
|
+
const recordEdit = vi.fn();
|
|
238
|
+
|
|
239
|
+
await toggleTimelineTrackHidden({
|
|
240
|
+
projectId: "project-1",
|
|
241
|
+
activeCompPath: "index.html",
|
|
242
|
+
timelineElements: [element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" })],
|
|
243
|
+
track: 0,
|
|
244
|
+
hidden: true,
|
|
245
|
+
// Row 2 in the header: the group's anchor row sits above this member.
|
|
246
|
+
displayNumber: 2,
|
|
247
|
+
previewIframe: null,
|
|
248
|
+
writeProjectFile: async () => {},
|
|
249
|
+
recordEdit,
|
|
250
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
251
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 2");
|
|
255
|
+
});
|
|
256
|
+
|
|
226
257
|
it("labels unmuting an audio-only track back on", async () => {
|
|
227
258
|
const files = new Map([
|
|
228
259
|
["index.html", `<div id="voiceover" data-start="0" data-duration="2" data-hidden=""></div>`],
|
|
@@ -32,6 +32,13 @@ interface ToggleTimelineTrackHiddenInput {
|
|
|
32
32
|
timelineElements: readonly TimelineElement[];
|
|
33
33
|
track: number;
|
|
34
34
|
hidden: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The display row the clicked control announced, when the caller has one.
|
|
37
|
+
* Absent (a keyboard path or a programmatic call), the row is derived here
|
|
38
|
+
* from ascending element-bearing keys — correct whenever no group has
|
|
39
|
+
* reordered the header.
|
|
40
|
+
*/
|
|
41
|
+
displayNumber?: number | null;
|
|
35
42
|
previewIframe: HTMLIFrameElement | null;
|
|
36
43
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
37
44
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
@@ -209,6 +216,7 @@ export async function toggleTimelineTrackHidden({
|
|
|
209
216
|
timelineElements,
|
|
210
217
|
track,
|
|
211
218
|
hidden,
|
|
219
|
+
displayNumber,
|
|
212
220
|
previewIframe,
|
|
213
221
|
writeProjectFile,
|
|
214
222
|
recordEdit,
|
|
@@ -216,9 +224,12 @@ export async function toggleTimelineTrackHidden({
|
|
|
216
224
|
pendingTimelineEditPathRef,
|
|
217
225
|
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
|
|
218
226
|
// `track` is the fractional sort key the callback needs; the history entry is
|
|
219
|
-
// read by a human, so it gets the display row instead
|
|
227
|
+
// read by a human, so it gets the display row instead — the one the clicked
|
|
228
|
+
// control announced, when the caller passed it. Deriving it again here would
|
|
229
|
+
// use ascending element-bearing keys, which stop matching the header as soon
|
|
230
|
+
// as an audio group reorders the rows and inserts an anchor.
|
|
220
231
|
const suffix = trackDisplaySuffix(
|
|
221
|
-
trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
|
232
|
+
displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
|
222
233
|
);
|
|
223
234
|
const trackElements = timelineElements.filter((element) => element.track === track);
|
|
224
235
|
const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
|
|
@@ -397,14 +408,18 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
397
408
|
pendingTimelineEditPathRef,
|
|
398
409
|
isRecordingRef,
|
|
399
410
|
forceReloadSdkSession,
|
|
400
|
-
}: UseTimelineTrackVisibilityEditingInput): (
|
|
411
|
+
}: UseTimelineTrackVisibilityEditingInput): (
|
|
412
|
+
track: number,
|
|
413
|
+
hidden: boolean,
|
|
414
|
+
displayNumber?: number | null,
|
|
415
|
+
) => Promise<void> {
|
|
401
416
|
// Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
|
|
402
417
|
// virtual sub-comp children carry their own (display.track + idx) track numbers,
|
|
403
418
|
// so filtering the raw store list by a virtual track number would hide the wrong
|
|
404
419
|
// outer-scene sibling sharing that index.
|
|
405
420
|
const expandedElements = useExpandedTimelineElements();
|
|
406
421
|
return useCallback(
|
|
407
|
-
async (track: number, hidden: boolean) => {
|
|
422
|
+
async (track: number, hidden: boolean, displayNumber?: number | null) => {
|
|
408
423
|
if (isRecordingRef?.current) {
|
|
409
424
|
showToast("Cannot edit timeline while recording", "error");
|
|
410
425
|
return;
|
|
@@ -418,6 +433,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
418
433
|
timelineElements: expandedElements,
|
|
419
434
|
track,
|
|
420
435
|
hidden,
|
|
436
|
+
displayNumber,
|
|
421
437
|
previewIframe: previewIframeRef.current,
|
|
422
438
|
writeProjectFile,
|
|
423
439
|
recordEdit,
|
|
@@ -41,7 +41,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
|
|
|
41
41
|
return createPortal(
|
|
42
42
|
<div
|
|
43
43
|
ref={menuRef}
|
|
44
|
-
className="hf-automation-menu fixed z-
|
|
44
|
+
className="hf-automation-menu fixed z-[200] min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
|
|
45
45
|
style={{ left: adjustedX, top: adjustedY }}
|
|
46
46
|
>
|
|
47
47
|
{AUTOMATION_SHAPES.map((shape) => (
|
|
@@ -48,7 +48,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
48
48
|
ref={menuRef}
|
|
49
49
|
role="menu"
|
|
50
50
|
aria-label="Clip actions"
|
|
51
|
-
className="fixed z-
|
|
51
|
+
className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
52
52
|
style={{ left: adjustedX, top: adjustedY }}
|
|
53
53
|
>
|
|
54
54
|
{splitLabel && (
|
|
@@ -96,7 +96,7 @@ export function KeyframeDiamondContextMenu({
|
|
|
96
96
|
ref={menuRef}
|
|
97
97
|
role="menu"
|
|
98
98
|
aria-label="Keyframe actions"
|
|
99
|
-
className="fixed z-
|
|
99
|
+
className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
|
|
100
100
|
style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
|
|
101
101
|
>
|
|
102
102
|
{onMoveToPlayhead && (
|
|
@@ -392,7 +392,7 @@ describe("Timeline provider boundary", () => {
|
|
|
392
392
|
const row = button.parentElement?.parentElement;
|
|
393
393
|
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
|
|
394
394
|
const trackContent = row?.children.item(1);
|
|
395
|
-
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
|
|
395
|
+
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false, 1);
|
|
396
396
|
expect(trackContent).toBeInstanceOf(HTMLElement);
|
|
397
397
|
if (!(trackContent instanceof HTMLElement)) {
|
|
398
398
|
throw new Error("Expected track content element");
|
|
@@ -79,7 +79,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
|
|
|
79
79
|
<div
|
|
80
80
|
role="dialog"
|
|
81
81
|
aria-label="Group these clips to add effects"
|
|
82
|
-
className="z-
|
|
82
|
+
className="z-[200] w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
|
|
83
83
|
style={{ position: "fixed", left: anchorRect.left, top: anchorRect.bottom + 4 }}
|
|
84
84
|
onPointerDown={(event) => event.stopPropagation()}
|
|
85
85
|
>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TimelineGroupHeader } from "./TimelineGroupHeader";
|
|
7
|
+
import { defaultTimelineTheme } from "./timelineTheme";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
function renderHeader(
|
|
12
|
+
overrides: Partial<React.ComponentProps<typeof TimelineGroupHeader>> = {},
|
|
13
|
+
): HTMLElement {
|
|
14
|
+
const host = document.createElement("div");
|
|
15
|
+
document.body.append(host);
|
|
16
|
+
act(() => {
|
|
17
|
+
createRoot(host).render(
|
|
18
|
+
<TimelineGroupHeader
|
|
19
|
+
label="Voiceover"
|
|
20
|
+
memberCount={5}
|
|
21
|
+
isExpanded={false}
|
|
22
|
+
onToggleExpanded={vi.fn()}
|
|
23
|
+
laneCount={0}
|
|
24
|
+
isLaneOpen={false}
|
|
25
|
+
onToggleLanes={vi.fn()}
|
|
26
|
+
hidden={false}
|
|
27
|
+
onToggleHidden={vi.fn()}
|
|
28
|
+
isSoloed={false}
|
|
29
|
+
isHalfLitSolo={false}
|
|
30
|
+
onToggleSolo={vi.fn()}
|
|
31
|
+
onFxChainChange={vi.fn()}
|
|
32
|
+
onOpenFxRack={vi.fn()}
|
|
33
|
+
columnWidth={220}
|
|
34
|
+
theme={defaultTimelineTheme}
|
|
35
|
+
{...overrides}
|
|
36
|
+
/>,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
return host;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The structural disclosure — the caret, not the `∿` lane toggle. */
|
|
43
|
+
function caret(host: HTMLElement): HTMLButtonElement {
|
|
44
|
+
const el = Array.from(host.querySelectorAll("button")).find((b) =>
|
|
45
|
+
b.getAttribute("aria-label")?.includes("tracks"),
|
|
46
|
+
);
|
|
47
|
+
if (!el) throw new Error("no disclosure caret");
|
|
48
|
+
return el as HTMLButtonElement;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
document.body.innerHTML = "";
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("TimelineGroupHeader disclosure caret", () => {
|
|
56
|
+
// Guards a regression that shipped once: the caret silently reverted to a
|
|
57
|
+
// smaller, non-mono, rotated glyph unlike the property panel's carets.
|
|
58
|
+
it("swaps the glyph rather than rotating one", () => {
|
|
59
|
+
expect(caret(renderHeader({ isExpanded: false })).textContent).toContain("▸");
|
|
60
|
+
expect(caret(renderHeader({ isExpanded: true })).textContent).toContain("▾");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not rotate the glyph", () => {
|
|
64
|
+
const glyph = caret(renderHeader({ isExpanded: true })).querySelector("span");
|
|
65
|
+
expect(glyph?.getAttribute("style") ?? "").not.toContain("rotate");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("keeps the caret mono at 13px, not back down at the 11px the row inherits", () => {
|
|
69
|
+
// Only this component's own className — the panel carets are not read here,
|
|
70
|
+
// and this does not claim to pin a match against them.
|
|
71
|
+
const className = caret(renderHeader()).className;
|
|
72
|
+
expect(className).toContain("font-mono");
|
|
73
|
+
expect(className).toContain("text-[13px]");
|
|
74
|
+
expect(className).not.toContain("text-[11px]");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -77,7 +77,11 @@ export function TimelineGroupHeader({
|
|
|
77
77
|
aria-expanded={isExpanded}
|
|
78
78
|
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
|
|
79
79
|
title={`${isExpanded ? "Hide" : "Show"} tracks`}
|
|
80
|
-
|
|
80
|
+
// Mono, like both of the property panel's disclosure carets. The size is
|
|
81
|
+
// this row's own call and not a match to either of them: the node-body
|
|
82
|
+
// caret sits under `text-[9px]`, and `hf-fx-preset-run-caret` has no
|
|
83
|
+
// size rule at all, so there is no measured target to match.
|
|
84
|
+
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 focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
81
85
|
isExpanded ? "text-white" : "text-white/55 hover:text-white"
|
|
82
86
|
}`}
|
|
83
87
|
onPointerDown={(event) => event.stopPropagation()}
|
|
@@ -86,9 +90,9 @@ export function TimelineGroupHeader({
|
|
|
86
90
|
onToggleExpanded();
|
|
87
91
|
}}
|
|
88
92
|
>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
</span>
|
|
93
|
+
{/* Swapped, not rotated: both panel carets swap, and a rotated ▸ sits
|
|
94
|
+
off-centre in its box because the glyph is not square. */}
|
|
95
|
+
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
|
|
92
96
|
</button>
|
|
93
97
|
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
|
94
98
|
▤
|
|
@@ -203,7 +203,9 @@ describe("TimelineLanes track numbering", () => {
|
|
|
203
203
|
const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
|
|
204
204
|
act(() => second?.click());
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
// Third argument is the display row the button announced, so the undo entry
|
|
207
|
+
// records the same row the user just read off it.
|
|
208
|
+
expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true, 2);
|
|
207
209
|
act(() => view.root.unmount());
|
|
208
210
|
});
|
|
209
211
|
|
|
@@ -204,7 +204,8 @@ describe("TimelineTrackHeader", () => {
|
|
|
204
204
|
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
|
|
205
205
|
|
|
206
206
|
act(() => eye?.click());
|
|
207
|
-
|
|
207
|
+
// The real fractional key acts; the display row rides along for the label.
|
|
208
|
+
expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true, 1);
|
|
208
209
|
act(() => view.root.unmount());
|
|
209
210
|
});
|
|
210
211
|
|