@hyperframes/studio 0.8.116 → 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-mGYvPVpw.js → hyperframes-player-CnpIuNRl.js} +1 -1
- package/dist/assets/{index-DYNaXPVf.js → index-B4SbAADe.js} +196 -194
- package/dist/assets/{index-Dr3hNpvS.js → index-BZ23-h1_.js} +1 -1
- package/dist/assets/{index-BWHt0-ir.js → index-FeRU6G3G.js} +1 -1
- package/dist/index.d.ts +29 -19
- package/dist/index.html +1 -1
- package/dist/index.js +2366 -2305
- 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/DomEditOverlay.tsx +1 -0
- 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/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/manualOffsetDrag.ts +3 -3
- package/src/components/editor/plainTranslate.ts +5 -3
- package/src/components/editor/propertyPanelHelpers.ts +2 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
- package/src/components/editor/rotationDraft.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
- package/src/components/editor/usePanelHiddenToggle.ts +44 -0
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
- package/src/hooks/gsapScriptCommitTypes.ts +1 -1
- package/src/hooks/plainRotation.ts +5 -1
- package/src/hooks/timelineTrackVisibility.ts +7 -17
- package/src/hooks/useDomEditPersist.test.tsx +335 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
- package/src/hooks/useDomGeometryCommit.ts +6 -9
- package/src/hooks/useFreezeFrame.test.ts +39 -3
- package/src/hooks/useFreezeFrame.ts +4 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +25 -0
- package/src/hooks/useGsapAwareEditing.ts +12 -8
- package/src/hooks/useGsapAwareGroupMove.ts +2 -2
- package/src/hooks/useGsapScriptCommits.test.tsx +69 -1
- package/src/hooks/useGsapScriptCommits.ts +9 -7
- package/src/hooks/usePersistentEditHistory.test.ts +25 -2
- package/src/hooks/usePersistentEditHistory.ts +3 -9
- 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 +51 -2
- package/src/utils/gsapSoftReload.ts +11 -21
- package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
- package/src/utils/softReloadTargets.ts +4 -3
- package/src/utils/studioFileHistory.ts +1 -0
- 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/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
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
|
|
@@ -94,6 +94,7 @@ export interface DomEditOverlayProps {
|
|
|
94
94
|
next: { width: number; height: number },
|
|
95
95
|
offset?: { x: number; y: number },
|
|
96
96
|
restore?: () => void,
|
|
97
|
+
route?: { plainTranslate: boolean },
|
|
97
98
|
) => Promise<unknown> | void;
|
|
98
99
|
onRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<unknown> | void;
|
|
99
100
|
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
@@ -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
|
|
@@ -275,6 +275,7 @@ export type UseDomEditOverlayGesturesOptions = {
|
|
|
275
275
|
n: { width: number; height: number },
|
|
276
276
|
offset?: { x: number; y: number },
|
|
277
277
|
restore?: () => void,
|
|
278
|
+
route?: { plainTranslate: boolean },
|
|
278
279
|
) => Promise<unknown> | void
|
|
279
280
|
>;
|
|
280
281
|
onRotationCommitRef: RefObject<
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
type UseDomEditOverlayGesturesOptions,
|
|
33
33
|
} from "./domEditOverlayGestures";
|
|
34
34
|
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
|
|
35
|
-
import {
|
|
35
|
+
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
36
36
|
import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
|
|
37
37
|
import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
|
|
38
38
|
|
|
@@ -234,7 +234,7 @@ export function startGesture(
|
|
|
234
234
|
try {
|
|
235
235
|
// Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
|
|
236
236
|
// move, never asks GSAP: reading a property makes it bake the CSS into its transform.
|
|
237
|
-
const plain = !!pathOffsetMember?.plainTranslate ||
|
|
237
|
+
const plain = !!pathOffsetMember?.plainTranslate || editsPlainCss(sel.element, "rotate");
|
|
238
238
|
const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
|
|
239
239
|
const rotation = { angle: readRotationBase(sel.element, plain) };
|
|
240
240
|
const overlayBounds = overlayEl?.getBoundingClientRect();
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type StudioPathOffsetSnapshot,
|
|
12
12
|
} from "./manualEdits";
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
|
-
import {
|
|
14
|
+
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
15
|
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
16
|
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
|
|
17
17
|
|
|
@@ -318,8 +318,8 @@ export function createManualOffsetDragMember(input: {
|
|
|
318
318
|
rect: ManualOffsetDragRect;
|
|
319
319
|
gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
|
|
320
320
|
}): ManualOffsetDragMemberResult {
|
|
321
|
-
const
|
|
322
|
-
|
|
321
|
+
const plainTranslate =
|
|
322
|
+
!!input.gesture && editsPlainCss(input.element, input.gesture === "resize" ? "resize" : "move");
|
|
323
323
|
// The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
|
|
324
324
|
const initialOffset = plainTranslate
|
|
325
325
|
? readTranslatePx(input.element)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { roundTo3 } from "../../utils/rounding";
|
|
2
|
-
import {
|
|
2
|
+
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
3
|
import { readCssRotation } from "../../hooks/draggedGsapPosition";
|
|
4
4
|
import { readStudioPathOffset, readStudioRotation, styleUsesStudioOffset } from "./manualEditsDom";
|
|
5
5
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
@@ -142,10 +142,12 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
/** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
|
|
145
|
-
export function readMoveOffset(el: HTMLElement, plainTranslate =
|
|
145
|
+
export function readMoveOffset(el: HTMLElement, plainTranslate = editsPlainCss(el, "move")): Point {
|
|
146
146
|
return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
export function readShownRotation(el: HTMLElement): { angle: number } {
|
|
150
|
-
return
|
|
150
|
+
return editsPlainCss(el, "rotate")
|
|
151
|
+
? { angle: roundTo3(readCssRotation(el)) }
|
|
152
|
+
: readStudioRotation(el);
|
|
151
153
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
playbackStartAttributeForElement,
|
|
10
10
|
readPlaybackStartAttributes,
|
|
11
11
|
} from "../../player/lib/timelineElementHelpers";
|
|
12
|
-
import {
|
|
12
|
+
import { editsPlainCss, GSAP_TRANSFORM_KEYS } from "../../hooks/gsapRuntimeKeyframes";
|
|
13
13
|
|
|
14
14
|
export type {
|
|
15
15
|
BackgroundRemovalProgress,
|
|
@@ -496,7 +496,7 @@ export function readGsapRuntimeValuesForPanel(
|
|
|
496
496
|
const el = findPreviewNode(iframe.contentDocument, element);
|
|
497
497
|
if (!el) return null;
|
|
498
498
|
const propKeys = collectPanelPropKeys(gsapAnimations);
|
|
499
|
-
const readsTransform =
|
|
499
|
+
const readsTransform = !editsPlainCss(el, "move");
|
|
500
500
|
const result: Record<string, number> = {};
|
|
501
501
|
for (const prop of propKeys) {
|
|
502
502
|
if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
2
2
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
3
3
|
import { readStudioBoxSize } from "./manualEdits";
|
|
4
4
|
import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
@@ -75,7 +75,7 @@ export function createTransformCommitHandlers({
|
|
|
75
75
|
)
|
|
76
76
|
)
|
|
77
77
|
return;
|
|
78
|
-
const plainTranslate =
|
|
78
|
+
const plainTranslate = editsPlainCss(element.element, "move");
|
|
79
79
|
const current = readMoveOffset(element.element, plainTranslate);
|
|
80
80
|
if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
|
|
81
81
|
return void showToast?.(UNREADABLE_TRANSLATE);
|
|
@@ -51,7 +51,8 @@ export interface CssRotationTarget {
|
|
|
51
51
|
inline: boolean;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export type
|
|
54
|
+
export type PressChoseGsap = null;
|
|
55
|
+
export type RotationCommit = { angle: number; plain?: CssRotationTarget | PressChoseGsap };
|
|
55
56
|
|
|
56
57
|
const TRANSLATE = /^translate(?:3d|X|Y|Z)?\(/i;
|
|
57
58
|
const OWN_TURN = /^rotate\(\s*-?[\d.]+(?:e[+-]?\d+)?deg\s*\)$/i;
|
|
@@ -442,7 +442,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
442
442
|
}
|
|
443
443
|
// Hold the final angle while the commit lands.
|
|
444
444
|
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
|
|
445
|
-
const commit =
|
|
445
|
+
const commit = { ...finalRotation, plain: g.plainRotation };
|
|
446
446
|
void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
|
|
447
447
|
.then((result) => trackPreviewEditResult("rotate", "drag", result))
|
|
448
448
|
.catch((error) => {
|
|
@@ -536,7 +536,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
536
536
|
if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
|
|
537
537
|
};
|
|
538
538
|
void Promise.resolve(
|
|
539
|
-
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
|
|
539
|
+
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member),
|
|
540
540
|
)
|
|
541
541
|
.then((result) => trackPreviewEditResult("resize", "drag", result))
|
|
542
542
|
.catch((error) => {
|