@hyperframes/studio 0.8.117 → 0.8.118
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-5fcgSstJ.js → hyperframes-player-CnpIuNRl.js} +1 -1
- package/dist/assets/{index-C5qKdpgl.js → index-B4SbAADe.js} +189 -189
- package/dist/assets/{index-tg2LXUeu.js → index-BZ23-h1_.js} +1 -1
- package/dist/assets/{index-XJSawwYe.js → index-FeRU6G3G.js} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.html +1 -1
- package/dist/index.js +2289 -2241
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/StudioRightPanels.tsx +3 -6
- package/src/components/TimelineToolbar.test.tsx +14 -0
- package/src/components/TimelineToolbar.tsx +4 -1
- package/src/components/editor/PropertyPanel.test.tsx +149 -25
- package/src/components/editor/PropertyPanel.tsx +1 -12
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +2 -3
- package/src/components/editor/PropertyPanelEmptyState.tsx +3 -5
- package/src/components/editor/PropertyPanelFlat.tsx +9 -22
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +7 -4
- package/src/components/editor/PropertyPanelFlatHeader.tsx +5 -3
- package/src/components/editor/usePanelHiddenToggle.ts +44 -0
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/timelineTrackVisibility.ts +7 -17
- package/src/player/components/Timeline.test.ts +48 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +14 -22
- package/src/player/components/TimelineTrackHeader.tsx +4 -2
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +17 -31
- package/src/player/components/TimelineTrackPlainHeader.tsx +9 -32
- package/src/player/components/hiddenToggle.tsx +20 -0
- package/src/utils/gsapSoftReload.test.ts +12 -2
- package/src/utils/gsapSoftReload.ts +4 -10
- package/src/utils/timelineInspector.ts +10 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.118",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.118",
|
|
64
|
+
"@hyperframes/sdk": "0.8.118",
|
|
65
|
+
"@hyperframes/studio-server": "0.8.118",
|
|
66
|
+
"@hyperframes/parsers": "0.8.118",
|
|
67
|
+
"@hyperframes/player": "0.8.118",
|
|
68
|
+
"@hyperframes/shader-transitions": "0.8.118"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.118"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -170,12 +170,9 @@ export const StudioRightPanels = memo(function StudioRightPanels({
|
|
|
170
170
|
);
|
|
171
171
|
const handleHideAllSelected = useCallback(() => {
|
|
172
172
|
// Audio has no visual to hide, and `data-hidden` on an audio element is what
|
|
173
|
-
//
|
|
174
|
-
// timeline
|
|
175
|
-
//
|
|
176
|
-
// write on `audioSelection`; this multi-selection path was the way back to
|
|
177
|
-
// it. Checked here as well as in the panel because the button is not the
|
|
178
|
-
// only caller.
|
|
173
|
+
// mutes it — preview silences it and the render drops it from the mix; the
|
|
174
|
+
// timeline offers that write as a mute. Checked here as well as in the panel
|
|
175
|
+
// because the button is not the only caller.
|
|
179
176
|
if (!canHideSelections(domEditGroupSelections)) {
|
|
180
177
|
showToast("Audio can't be hidden — use the group's own controls", "info");
|
|
181
178
|
return;
|
|
@@ -422,3 +422,17 @@ describe("TimelineToolbar Linked Selection", () => {
|
|
|
422
422
|
act(() => useLinkedClipPreferences.getState().setSyncIndicatorsVisible(true));
|
|
423
423
|
});
|
|
424
424
|
});
|
|
425
|
+
|
|
426
|
+
describe("TimelineToolbar — host right actions", () => {
|
|
427
|
+
it("draws the host's controls immediately before the thumbnails toggle", () => {
|
|
428
|
+
const { host, root } = renderToolbar(undefined, {
|
|
429
|
+
rightActions: <button type="button">Script</button>,
|
|
430
|
+
});
|
|
431
|
+
const buttons = [...host.querySelectorAll("button")];
|
|
432
|
+
const script = buttons.findIndex((b) => b.textContent === "Script");
|
|
433
|
+
const thumbs = buttons.findIndex((b) => b.getAttribute("aria-label")?.includes("thumbnails"));
|
|
434
|
+
expect(script).toBeGreaterThan(-1);
|
|
435
|
+
expect(thumbs).toBe(script + 1);
|
|
436
|
+
act(() => root.unmount());
|
|
437
|
+
});
|
|
438
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useEffect, useRef } from "react";
|
|
1
|
+
import { memo, useEffect, useRef, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
3
|
ArrowsOutLineHorizontal,
|
|
4
4
|
Image,
|
|
@@ -50,6 +50,7 @@ export interface TimelineToolbarProps {
|
|
|
50
50
|
showAddBeat?: boolean;
|
|
51
51
|
/** Hides Add keyframe and auto-record, and turns off auto-record and the K shortcut with them. */
|
|
52
52
|
showKeyframes?: boolean;
|
|
53
|
+
rightActions?: ReactNode;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
interface KeyframeToggleState {
|
|
@@ -149,6 +150,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
|
|
149
150
|
showSelectAroundPlayhead = true,
|
|
150
151
|
showAddBeat = true,
|
|
151
152
|
showKeyframes = true,
|
|
153
|
+
rightActions,
|
|
152
154
|
}: TimelineToolbarProps) {
|
|
153
155
|
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
|
|
154
156
|
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
|
|
@@ -460,6 +462,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
|
|
460
462
|
})()}
|
|
461
463
|
</div>
|
|
462
464
|
<div className="flex items-center gap-0.5">
|
|
465
|
+
{rightActions}
|
|
463
466
|
<Tooltip
|
|
464
467
|
label={
|
|
465
468
|
thumbnailsVisible
|
|
@@ -4,6 +4,7 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
|
7
|
+
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
|
|
7
8
|
|
|
8
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
10
|
|
|
@@ -235,7 +236,13 @@ async function renderPanel(
|
|
|
235
236
|
flatEnabled: boolean,
|
|
236
237
|
elementOverride: NonNullable<PropertyPanelProps["element"]> = baseElement(),
|
|
237
238
|
propsOverride: Partial<PropertyPanelProps> = {},
|
|
238
|
-
|
|
239
|
+
options: {
|
|
240
|
+
currentTime?: number;
|
|
241
|
+
selectedElementId?: string;
|
|
242
|
+
timelineEdit?: TimelineEditCallbacks;
|
|
243
|
+
refreshSelection?: (selection: unknown) => void;
|
|
244
|
+
selectionRef?: { current: unknown };
|
|
245
|
+
} = {},
|
|
239
246
|
) {
|
|
240
247
|
vi.resetModules();
|
|
241
248
|
vi.doMock("./manualEditingAvailability", async () => {
|
|
@@ -244,32 +251,57 @@ async function renderPanel(
|
|
|
244
251
|
);
|
|
245
252
|
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled };
|
|
246
253
|
});
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const {
|
|
252
|
-
|
|
254
|
+
vi.doMock("../../contexts/DomEditContext", async () => {
|
|
255
|
+
const actual = await vi.importActual<typeof import("../../contexts/DomEditContext")>(
|
|
256
|
+
"../../contexts/DomEditContext",
|
|
257
|
+
);
|
|
258
|
+
const { refreshSelection: refresh, selectionRef } = options;
|
|
259
|
+
return refresh && selectionRef
|
|
260
|
+
? {
|
|
261
|
+
...actual,
|
|
262
|
+
useDomEditActionsContextOptional: () => ({
|
|
263
|
+
refreshDomEditSelectionFromPreview: refresh,
|
|
264
|
+
domEditSelectionRef: selectionRef,
|
|
265
|
+
}),
|
|
266
|
+
}
|
|
267
|
+
: actual;
|
|
268
|
+
});
|
|
269
|
+
// Seed the playhead and selection on the same store instance PropertyPanel.tsx
|
|
270
|
+
// reads (module-fresh after the resetModules() above), before it is imported so
|
|
271
|
+
// its first render sees them.
|
|
272
|
+
const { usePlayerStore } = await import("../../player/store/playerStore");
|
|
273
|
+
if (options.currentTime !== undefined) {
|
|
274
|
+
usePlayerStore.getState().setCurrentTime(options.currentTime);
|
|
253
275
|
}
|
|
276
|
+
if (options.selectedElementId)
|
|
277
|
+
usePlayerStore.setState({ selectedElementId: options.selectedElementId });
|
|
254
278
|
const { PropertyPanel } = await import("./PropertyPanel");
|
|
279
|
+
const { TimelineEditProvider } = await import("../../contexts/TimelineEditContext");
|
|
255
280
|
const host = document.createElement("div");
|
|
256
281
|
document.body.append(host);
|
|
257
282
|
const root = createRoot(host);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
283
|
+
const render = (element: NonNullable<PropertyPanelProps["element"]>) => {
|
|
284
|
+
// Only the props the render path touches are supplied; the rest are unused at
|
|
285
|
+
// mount (handlers fire on interaction), so cast a minimal object to the full
|
|
286
|
+
// props shape rather than stubbing all ~15 required fields.
|
|
287
|
+
const props = {
|
|
288
|
+
element,
|
|
289
|
+
assets: [],
|
|
290
|
+
onSetStyle: vi.fn(),
|
|
291
|
+
onSetText: vi.fn(),
|
|
292
|
+
onSetAttributeLive: vi.fn(),
|
|
293
|
+
...propsOverride,
|
|
294
|
+
} as unknown as PropertyPanelProps;
|
|
295
|
+
act(() => {
|
|
296
|
+
root.render(
|
|
297
|
+
<TimelineEditProvider value={options.timelineEdit ?? {}}>
|
|
298
|
+
<PropertyPanel {...props} />
|
|
299
|
+
</TimelineEditProvider>,
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
render(elementOverride);
|
|
304
|
+
return { host, root, render };
|
|
273
305
|
}
|
|
274
306
|
|
|
275
307
|
// renderPanel resetModules()+dynamic-imports PropertyPanel (needed for a fresh
|
|
@@ -561,7 +593,7 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe
|
|
|
561
593
|
true,
|
|
562
594
|
inferredMotionElement(),
|
|
563
595
|
{ gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
|
|
564
|
-
2,
|
|
596
|
+
{ currentTime: 2 },
|
|
565
597
|
);
|
|
566
598
|
openFlatGroup(host, "Layout");
|
|
567
599
|
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
@@ -606,7 +638,7 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f
|
|
|
606
638
|
true,
|
|
607
639
|
inferredMotionElement(),
|
|
608
640
|
{ gsapAnimations: [INFERRED_TIMING_ANIMATION] },
|
|
609
|
-
3.5,
|
|
641
|
+
{ currentTime: 3.5 },
|
|
610
642
|
);
|
|
611
643
|
openFlatGroup(host, "Layout");
|
|
612
644
|
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
@@ -643,7 +675,7 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f
|
|
|
643
675
|
true,
|
|
644
676
|
inferredMotionElement(),
|
|
645
677
|
{ gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
|
|
646
|
-
3.5,
|
|
678
|
+
{ currentTime: 3.5 },
|
|
647
679
|
);
|
|
648
680
|
openFlatGroup(host, "Layout");
|
|
649
681
|
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
@@ -1038,6 +1070,98 @@ describe("PropertyPanel — Motion is for things that move", () => {
|
|
|
1038
1070
|
RENDER_TIMEOUT_MS,
|
|
1039
1071
|
);
|
|
1040
1072
|
|
|
1073
|
+
it(
|
|
1074
|
+
"offers a mute on an audible audio clip and toggles it through the timeline",
|
|
1075
|
+
async () => {
|
|
1076
|
+
const onToggleElementHidden = vi.fn();
|
|
1077
|
+
const { host, root } = await renderPanel(
|
|
1078
|
+
true,
|
|
1079
|
+
audioClipElement(),
|
|
1080
|
+
{ onToggleElementHidden },
|
|
1081
|
+
{ selectedElementId: "vo-1" },
|
|
1082
|
+
);
|
|
1083
|
+
act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
|
|
1084
|
+
expect(onToggleElementHidden).toHaveBeenCalledWith("vo-1", true);
|
|
1085
|
+
act(() => root.unmount());
|
|
1086
|
+
},
|
|
1087
|
+
RENDER_TIMEOUT_MS,
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
// A bus has no timeline row, so the panel is its only mute; it must go both ways.
|
|
1091
|
+
it(
|
|
1092
|
+
"mutes, unmutes and mutes an audio group from the panel header",
|
|
1093
|
+
async () => {
|
|
1094
|
+
const bus = (hidden: boolean) => {
|
|
1095
|
+
const element = audioBusElement();
|
|
1096
|
+
return {
|
|
1097
|
+
...element,
|
|
1098
|
+
dataAttributes: { ...element.dataAttributes, ...(hidden ? { hidden: "" } : {}) },
|
|
1099
|
+
};
|
|
1100
|
+
};
|
|
1101
|
+
let hidden = true;
|
|
1102
|
+
const onSetAudioGroupAttributeQuiet = vi.fn(
|
|
1103
|
+
async (_id: string, _attr: string, value: string | null, _label: string) => {
|
|
1104
|
+
hidden = value !== null;
|
|
1105
|
+
},
|
|
1106
|
+
);
|
|
1107
|
+
// The panel re-reads its selection after the write; nothing else re-renders it here.
|
|
1108
|
+
const { host, root, render } = await renderPanel(
|
|
1109
|
+
true,
|
|
1110
|
+
bus(true),
|
|
1111
|
+
{},
|
|
1112
|
+
{
|
|
1113
|
+
timelineEdit: { onSetAudioGroupAttributeQuiet },
|
|
1114
|
+
selectionRef: { current: bus(true) },
|
|
1115
|
+
refreshSelection: () => render(bus(hidden)),
|
|
1116
|
+
},
|
|
1117
|
+
);
|
|
1118
|
+
const press = async (label: string) => {
|
|
1119
|
+
const button = host.querySelector<HTMLElement>(`button[aria-label="${label}"]`);
|
|
1120
|
+
if (!button) throw new Error(`Expected a "${label}" button`);
|
|
1121
|
+
await act(async () => button.click());
|
|
1122
|
+
};
|
|
1123
|
+
await press("Unmute element");
|
|
1124
|
+
await press("Mute element");
|
|
1125
|
+
await press("Unmute element");
|
|
1126
|
+
expect(onSetAudioGroupAttributeQuiet.mock.calls).toEqual([
|
|
1127
|
+
["voiceover", "data-hidden", null, "Unmute element"],
|
|
1128
|
+
["voiceover", "data-hidden", "", "Mute element"],
|
|
1129
|
+
["voiceover", "data-hidden", null, "Unmute element"],
|
|
1130
|
+
]);
|
|
1131
|
+
act(() => root.unmount());
|
|
1132
|
+
},
|
|
1133
|
+
RENDER_TIMEOUT_MS,
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
it(
|
|
1137
|
+
"re-reads the current selection, not the muted bus, once the write lands",
|
|
1138
|
+
async () => {
|
|
1139
|
+
let land = () => {};
|
|
1140
|
+
const onSetAudioGroupAttributeQuiet = vi.fn(
|
|
1141
|
+
() => new Promise<void>((resolve) => (land = resolve)),
|
|
1142
|
+
);
|
|
1143
|
+
const selectionRef: { current: unknown } = { current: audioBusElement() };
|
|
1144
|
+
const refreshSelection = vi.fn();
|
|
1145
|
+
const { host, root } = await renderPanel(
|
|
1146
|
+
true,
|
|
1147
|
+
audioBusElement(),
|
|
1148
|
+
{},
|
|
1149
|
+
{
|
|
1150
|
+
timelineEdit: { onSetAudioGroupAttributeQuiet },
|
|
1151
|
+
selectionRef,
|
|
1152
|
+
refreshSelection,
|
|
1153
|
+
},
|
|
1154
|
+
);
|
|
1155
|
+
act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
|
|
1156
|
+
const next = audioClipElement();
|
|
1157
|
+
selectionRef.current = next;
|
|
1158
|
+
await act(async () => land());
|
|
1159
|
+
expect(refreshSelection.mock.calls).toEqual([[next]]);
|
|
1160
|
+
act(() => root.unmount());
|
|
1161
|
+
},
|
|
1162
|
+
RENDER_TIMEOUT_MS,
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1041
1165
|
it(
|
|
1042
1166
|
"offers a bus neither — it has no clip range to edit",
|
|
1043
1167
|
async () => {
|
|
@@ -119,18 +119,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
119
119
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
120
120
|
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
|
|
121
121
|
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
|
|
122
|
-
|
|
123
|
-
* An audio element gets no hide control here.
|
|
124
|
-
*
|
|
125
|
-
* On an audio track "hidden" and "muted" are not similar operations, they are
|
|
126
|
-
* the SAME operation with two names (groups doc §2.1) — which is why the
|
|
127
|
-
* timeline's eye became the mute rather than growing a sibling. A second copy
|
|
128
|
-
* in the panel, still called "Hide element", is precisely the thing that step
|
|
129
|
-
* removed: "Two controls that silence a track, sitting next to each other,
|
|
130
|
-
* differing only in a distinction the author cannot see." An
|
|
131
|
-
* `<hf-audio-group>` has no visual to hide at all, and its mute lives on its
|
|
132
|
-
* own row.
|
|
133
|
-
*/
|
|
122
|
+
// Audio gets no visibility toggle in this classic panel; the timeline and the flat panel offer it as a mute.
|
|
134
123
|
const audioSelection = isAudioDomElement(element?.element);
|
|
135
124
|
// Live during playback, the store's when paused — see the hook. Shared with the
|
|
136
125
|
// audio FX panel, which follows the playhead for the same reason: a value the
|
|
@@ -96,9 +96,8 @@ describe("PropertyPanelEmptyState — flat multi-select", () => {
|
|
|
96
96
|
/>,
|
|
97
97
|
);
|
|
98
98
|
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
|
|
99
|
-
// Hiding is visibility, and `data-hidden` on audio is what
|
|
100
|
-
// timeline
|
|
101
|
-
// panel was the way back to the same write.
|
|
99
|
+
// Hiding is visibility, and `data-hidden` on audio is what mutes it; the
|
|
100
|
+
// timeline owns that write as a mute.
|
|
102
101
|
expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
|
|
103
102
|
// The list still names what is selected; only the actions go.
|
|
104
103
|
expect(host.textContent).toContain("2 elements selected");
|
|
@@ -133,11 +133,9 @@ function FlatMultiSelectState({
|
|
|
133
133
|
an empty frame. Grouping is the LAYOUT grouper — a positioned wrapper
|
|
134
134
|
around a bounding box, and an <audio> clip has none (grouping two
|
|
135
135
|
produced a 0x0 div with inline left/top on elements that are never
|
|
136
|
-
laid out). Hiding is visibility, which for audio
|
|
137
|
-
timeline
|
|
138
|
-
|
|
139
|
-
same write. Both handlers refuse it too — they own keyboard paths no
|
|
140
|
-
hidden button can gate. */}
|
|
136
|
+
laid out). Hiding is visibility, which for audio is mute, and the
|
|
137
|
+
timeline owns that as a mute control. Both handlers refuse it too —
|
|
138
|
+
they own keyboard paths no hidden button can gate. */}
|
|
141
139
|
{!hasAudio && (
|
|
142
140
|
<div className="flex gap-2">
|
|
143
141
|
<button
|
|
@@ -9,6 +9,7 @@ import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits
|
|
|
9
9
|
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
|
10
10
|
import { audioFxSummary } from "./audioFxSummary";
|
|
11
11
|
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
|
12
|
+
import { usePanelHiddenToggle } from "./usePanelHiddenToggle";
|
|
12
13
|
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
|
13
14
|
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
|
14
15
|
import { closedGroupHeader, isSelectionHidden } from "./propertyPanelFlatClosedGroup";
|
|
@@ -206,6 +207,12 @@ export function PropertyPanelFlat({
|
|
|
206
207
|
* selected the clip and then appeared to do nothing.
|
|
207
208
|
*/
|
|
208
209
|
const hiddenNow = isSelectionHidden(selectedElementHidden, element);
|
|
210
|
+
const toggleHidden = usePanelHiddenToggle({
|
|
211
|
+
element,
|
|
212
|
+
hidden: hiddenNow,
|
|
213
|
+
selectedElementId,
|
|
214
|
+
onToggleElementHidden,
|
|
215
|
+
});
|
|
209
216
|
|
|
210
217
|
const reveal = useAudioFxRevealSection({
|
|
211
218
|
elementId: element?.id,
|
|
@@ -531,28 +538,8 @@ export function PropertyPanelFlat({
|
|
|
531
538
|
meta={`${sourceLabel} · ${element.tagName}`}
|
|
532
539
|
elementKind={elementKind}
|
|
533
540
|
hidden={hiddenNow}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
// with two names (groups doc §2.1) — which is why the timeline's eye
|
|
537
|
-
// BECAME the mute rather than growing a sibling. A second copy in
|
|
538
|
-
// the panel, still called "Hide element", is exactly what that step
|
|
539
|
-
// set out to remove: "Two controls that silence a track, sitting
|
|
540
|
-
// next to each other, differing only in a distinction the author
|
|
541
|
-
// cannot see." An `<hf-audio-group>` has no visual to hide at all.
|
|
542
|
-
//
|
|
543
|
-
// EXCEPT while it is already hidden — the same door-from-the-inside
|
|
544
|
-
// the timeline's eye keeps for an audio track
|
|
545
|
-
// (`TimelineTrackPlainHeader`). Withholding it unconditionally
|
|
546
|
-
// withheld the only way back: a `data-hidden` group is silent in
|
|
547
|
-
// preview (the bus's mute gain) and absent from the render (every
|
|
548
|
-
// member dropped), and the group header carries no visibility
|
|
549
|
-
// control of its own now that mute and solo are gone. Only
|
|
550
|
-
// hand-editing the HTML brought the audio back.
|
|
551
|
-
onToggleHidden={
|
|
552
|
-
selectedElementId && onToggleElementHidden && (!audioSelection || hiddenNow)
|
|
553
|
-
? () => void onToggleElementHidden(selectedElementId, !hiddenNow)
|
|
554
|
-
: undefined
|
|
555
|
-
}
|
|
541
|
+
asMute={audioSelection}
|
|
542
|
+
onToggleHidden={toggleHidden}
|
|
556
543
|
copied={clipboardCopied}
|
|
557
544
|
onCopy={onCopyElementInfo}
|
|
558
545
|
onClear={onClearSelection}
|
|
@@ -77,10 +77,6 @@ describe("PropertyPanelFlatHeader", () => {
|
|
|
77
77
|
expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull();
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
-
// The panel's hide control is withheld for audio by its caller — on an audio
|
|
81
|
-
// track "hidden" and "muted" are the same operation with two names (groups
|
|
82
|
-
// doc §2.1), and the timeline already carries it, correctly labelled. This
|
|
83
|
-
// pins the header's half of that contract: no handler, no button.
|
|
84
80
|
it("renders no visibility control when its caller withholds the handler", () => {
|
|
85
81
|
const { host } = renderHeader({ onToggleHidden: undefined });
|
|
86
82
|
const labels = Array.from(host.querySelectorAll("button")).map((b) =>
|
|
@@ -90,6 +86,13 @@ describe("PropertyPanelFlatHeader", () => {
|
|
|
90
86
|
expect(labels).not.toContain("Show element");
|
|
91
87
|
});
|
|
92
88
|
|
|
89
|
+
it("names a mute on an audio selection", () => {
|
|
90
|
+
const { host } = renderHeader({ onToggleHidden: vi.fn(), asMute: true });
|
|
91
|
+
expect(host.querySelector('button[aria-label="Mute element"]')).not.toBeNull();
|
|
92
|
+
const { host: muted } = renderHeader({ onToggleHidden: vi.fn(), asMute: true, hidden: true });
|
|
93
|
+
expect(muted.querySelector('button[aria-label="Unmute element"]')).not.toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
93
96
|
it("renders it when the handler is supplied", () => {
|
|
94
97
|
const { host } = renderHeader({ onToggleHidden: vi.fn() });
|
|
95
98
|
const labels = Array.from(host.querySelectorAll("button")).map((b) =>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { HiddenToggleIcon, hiddenToggleVerb } from "../../player/components/hiddenToggle";
|
|
2
2
|
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
3
3
|
import { ClipboardList, Film, Square, Type, X } from "../../icons/SystemIcons";
|
|
4
4
|
|
|
@@ -14,6 +14,7 @@ export function PropertyPanelFlatHeader({
|
|
|
14
14
|
meta,
|
|
15
15
|
elementKind,
|
|
16
16
|
hidden,
|
|
17
|
+
asMute = false,
|
|
17
18
|
onToggleHidden,
|
|
18
19
|
copied,
|
|
19
20
|
onCopy,
|
|
@@ -25,6 +26,7 @@ export function PropertyPanelFlatHeader({
|
|
|
25
26
|
meta: string;
|
|
26
27
|
elementKind: "text" | "media" | "other";
|
|
27
28
|
hidden: boolean;
|
|
29
|
+
asMute?: boolean;
|
|
28
30
|
onToggleHidden?: () => void;
|
|
29
31
|
copied: boolean;
|
|
30
32
|
onCopy: () => void;
|
|
@@ -34,7 +36,7 @@ export function PropertyPanelFlatHeader({
|
|
|
34
36
|
}) {
|
|
35
37
|
const track = useTrackDesignInput();
|
|
36
38
|
const Icon = ICON_BY_KIND[elementKind];
|
|
37
|
-
const visibilityLabel = hidden
|
|
39
|
+
const visibilityLabel = `${hiddenToggleVerb(asMute, hidden)} element`;
|
|
38
40
|
|
|
39
41
|
return (
|
|
40
42
|
<div className="flex items-center gap-2.5 border-b border-panel-hairline px-4 py-3">
|
|
@@ -81,7 +83,7 @@ export function PropertyPanelFlatHeader({
|
|
|
81
83
|
onToggleHidden();
|
|
82
84
|
}}
|
|
83
85
|
>
|
|
84
|
-
|
|
86
|
+
<HiddenToggleIcon asMute={asMute} hidden={hidden} size={13} />
|
|
85
87
|
</button>
|
|
86
88
|
)}
|
|
87
89
|
<button
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
2
|
+
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
3
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
4
|
+
import { hiddenToggleVerb } from "../../player/components/hiddenToggle";
|
|
5
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
6
|
+
|
|
7
|
+
/** The panel header's hide toggle, or undefined when this selection has no writer for it. */
|
|
8
|
+
export function usePanelHiddenToggle({
|
|
9
|
+
element,
|
|
10
|
+
hidden,
|
|
11
|
+
selectedElementId,
|
|
12
|
+
onToggleElementHidden,
|
|
13
|
+
}: {
|
|
14
|
+
element: Pick<DomEditSelection, "id" | "tagName">;
|
|
15
|
+
hidden: boolean;
|
|
16
|
+
selectedElementId: string | null | undefined;
|
|
17
|
+
onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
|
|
18
|
+
}): (() => void) | undefined {
|
|
19
|
+
const { onSetAudioGroupAttributeQuiet } = useTimelineEditContextOptional();
|
|
20
|
+
const domEditActions = useDomEditActionsContextOptional();
|
|
21
|
+
// A bus has no timeline row, so it mutes through the group's own writer.
|
|
22
|
+
const audioGroupId = element.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ? element.id : null;
|
|
23
|
+
const write = audioGroupId
|
|
24
|
+
? onSetAudioGroupAttributeQuiet &&
|
|
25
|
+
(() =>
|
|
26
|
+
onSetAudioGroupAttributeQuiet(
|
|
27
|
+
audioGroupId,
|
|
28
|
+
"data-hidden",
|
|
29
|
+
hidden ? null : "",
|
|
30
|
+
`${hiddenToggleVerb(true, hidden)} element`,
|
|
31
|
+
))
|
|
32
|
+
: selectedElementId && onToggleElementHidden
|
|
33
|
+
? () => onToggleElementHidden(selectedElementId, !hidden)
|
|
34
|
+
: undefined;
|
|
35
|
+
// The label reads the selection snapshot, so re-read whatever is selected once the write lands.
|
|
36
|
+
return (
|
|
37
|
+
write &&
|
|
38
|
+
(() =>
|
|
39
|
+
void Promise.resolve(write()).then(() => {
|
|
40
|
+
const selection = domEditActions?.domEditSelectionRef.current;
|
|
41
|
+
if (selection) void domEditActions?.refreshDomEditSelectionFromPreview(selection);
|
|
42
|
+
}))
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -77,6 +77,7 @@ const SAVES_AN_EDIT = {
|
|
|
77
77
|
handleUnroll: true,
|
|
78
78
|
invalidateGsapCache: false,
|
|
79
79
|
previewIframeRef: false,
|
|
80
|
+
domEditSelectionRef: false,
|
|
80
81
|
commitMutation: true,
|
|
81
82
|
applyMarqueeSelection: false,
|
|
82
83
|
handleUpdateKeyframeEase: true,
|
|
@@ -329,6 +330,7 @@ export function DomEditProvider({
|
|
|
329
330
|
handleUnroll,
|
|
330
331
|
invalidateGsapCache,
|
|
331
332
|
previewIframeRef,
|
|
333
|
+
domEditSelectionRef,
|
|
332
334
|
commitMutation: stableCommitMutation,
|
|
333
335
|
applyMarqueeSelection,
|
|
334
336
|
handleUpdateKeyframeEase,
|
|
@@ -406,6 +408,7 @@ export function DomEditProvider({
|
|
|
406
408
|
handleUnroll,
|
|
407
409
|
invalidateGsapCache,
|
|
408
410
|
previewIframeRef,
|
|
411
|
+
domEditSelectionRef,
|
|
409
412
|
stableCommitMutation,
|
|
410
413
|
applyMarqueeSelection,
|
|
411
414
|
handleUpdateKeyframeEase,
|
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
trackDisplaySuffix,
|
|
9
9
|
} from "../player/components/timelineTrackDisplay";
|
|
10
10
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
11
|
-
import {
|
|
11
|
+
import { isAudioOnlyTrack } from "../utils/timelineInspector";
|
|
12
|
+
import { hiddenToggleVerb } from "../player/components/hiddenToggle";
|
|
12
13
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
13
14
|
import {
|
|
14
15
|
findTimelineElementInIframe,
|
|
@@ -206,14 +207,8 @@ export async function toggleTimelineTrackHidden({
|
|
|
206
207
|
displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
|
207
208
|
);
|
|
208
209
|
const trackElements = timelineElements.filter((element) => element.track === track);
|
|
209
|
-
const
|
|
210
|
-
const label = isAudioOnlyTrack
|
|
211
|
-
? hidden
|
|
212
|
-
? `Mute track${suffix}`
|
|
213
|
-
: `Unmute track${suffix}`
|
|
214
|
-
: hidden
|
|
215
|
-
? `Hide track${suffix}`
|
|
216
|
-
: `Show track${suffix}`;
|
|
210
|
+
const hiddenBefore = !hidden;
|
|
211
|
+
const label = `${hiddenToggleVerb(isAudioOnlyTrack(trackElements), hiddenBefore)} track${suffix}`;
|
|
217
212
|
return setElementsHidden({
|
|
218
213
|
projectId,
|
|
219
214
|
activeCompPath,
|
|
@@ -240,19 +235,14 @@ export async function toggleTimelineElementHidden({
|
|
|
240
235
|
}: ToggleTimelineElementHiddenInput): Promise<string[]> {
|
|
241
236
|
const keys = new Set(typeof elementKey === "string" ? [elementKey] : elementKey);
|
|
242
237
|
const elements = timelineElements.filter((item) => keys.has(item.key ?? item.id));
|
|
238
|
+
const hiddenBefore = !hidden;
|
|
239
|
+
const verb = hiddenToggleVerb(isAudioOnlyTrack(elements), hiddenBefore);
|
|
243
240
|
return setElementsHidden({
|
|
244
241
|
projectId,
|
|
245
242
|
activeCompPath,
|
|
246
243
|
elements,
|
|
247
244
|
hidden,
|
|
248
|
-
label:
|
|
249
|
-
elements.length > 1
|
|
250
|
-
? hidden
|
|
251
|
-
? `Hide ${elements.length} elements`
|
|
252
|
-
: `Show ${elements.length} elements`
|
|
253
|
-
: hidden
|
|
254
|
-
? "Hide element"
|
|
255
|
-
: "Show element",
|
|
245
|
+
label: elements.length > 1 ? `${verb} ${elements.length} elements` : `${verb} element`,
|
|
256
246
|
previewIframe,
|
|
257
247
|
writeProjectFile,
|
|
258
248
|
recordEdit,
|