@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
|
@@ -74,6 +74,7 @@ function mountResizeHandler(
|
|
|
74
74
|
size: { width: number; height: number },
|
|
75
75
|
offset?: { x: number; y: number },
|
|
76
76
|
restore?: () => void,
|
|
77
|
+
route?: { plainTranslate: boolean },
|
|
77
78
|
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>)
|
|
78
79
|
| null = null;
|
|
79
80
|
let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
|
|
@@ -234,6 +235,21 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
|
|
|
234
235
|
});
|
|
235
236
|
});
|
|
236
237
|
|
|
238
|
+
describe("useGsapAwareEditing keeps the box route a resize chose at press", () => {
|
|
239
|
+
it.each([
|
|
240
|
+
["the CSS route, on an element GSAP has since taken over", true],
|
|
241
|
+
["the GSAP route, on an element that now looks GSAP-free", false],
|
|
242
|
+
])("%s", async (_, plainTranslate) => {
|
|
243
|
+
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
244
|
+
const h = mountResizeHandler([], [], plainTranslate);
|
|
245
|
+
const size = { width: 300, height: 200 };
|
|
246
|
+
await act(() => h.resize(h.selection, size, undefined, vi.fn(), { plainTranslate }));
|
|
247
|
+
expect(h.fallback).toHaveBeenCalledTimes(plainTranslate ? 1 : 0);
|
|
248
|
+
expect(mocks.resize).toHaveBeenCalledTimes(plainTranslate ? 0 : 1);
|
|
249
|
+
act(() => h.root.unmount());
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
237
253
|
describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => {
|
|
238
254
|
it("writes no member when one CSS-route member has been folded since the press", async () => {
|
|
239
255
|
const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
|
|
@@ -296,6 +312,15 @@ describe("useGsapAwareEditing rotation routing", () => {
|
|
|
296
312
|
act(() => h.root.unmount());
|
|
297
313
|
});
|
|
298
314
|
|
|
315
|
+
it("saves a turn the press sent down the GSAP route there, even once the element looks GSAP-free", async () => {
|
|
316
|
+
vi.mocked(tryGsapRotationIntercept).mockResolvedValue({ status: "persisted" });
|
|
317
|
+
const h = rotate(document.createElement("div"));
|
|
318
|
+
await act(() => h.rotationCommit(h.box, { angle: 55, plain: null }));
|
|
319
|
+
expect(tryGsapRotationIntercept).toHaveBeenCalledTimes(1);
|
|
320
|
+
expect(h.handleDomRotationCommit).not.toHaveBeenCalled();
|
|
321
|
+
act(() => h.root.unmount());
|
|
322
|
+
});
|
|
323
|
+
|
|
299
324
|
it("saves a turn the press drew as CSS by the CSS writer, even once a GSAP tween turns the element", async () => {
|
|
300
325
|
const element = document.createElement("div");
|
|
301
326
|
const h = rotate(element);
|
|
@@ -30,9 +30,7 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
|
|
|
30
30
|
import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures";
|
|
31
31
|
import { runGestureTransaction } from "./gestureTransaction";
|
|
32
32
|
import {
|
|
33
|
-
|
|
34
|
-
gsapWritesPosition,
|
|
35
|
-
gsapWritesRotation,
|
|
33
|
+
editsPlainCss,
|
|
36
34
|
hasNonHoldTweenForElement,
|
|
37
35
|
POSITION_CHANNELS,
|
|
38
36
|
} from "./gsapRuntimeKeyframes";
|
|
@@ -151,7 +149,7 @@ export function useGsapAwareEditing({
|
|
|
151
149
|
modifiers?: MoveCommitOptions,
|
|
152
150
|
) => {
|
|
153
151
|
const writes = observeGsapGesture(gsapCommitMutation);
|
|
154
|
-
if (modifiers?.plainTranslate ??
|
|
152
|
+
if (modifiers?.plainTranslate ?? editsPlainCss(selection.element, "move")) {
|
|
155
153
|
const result = await stageElementPositionOffset(selection, next, true).save();
|
|
156
154
|
return writes.finish(result?.changed === true);
|
|
157
155
|
}
|
|
@@ -207,9 +205,10 @@ export function useGsapAwareEditing({
|
|
|
207
205
|
next: { width: number; height: number },
|
|
208
206
|
offset?: { x: number; y: number },
|
|
209
207
|
restore: () => void = () => undefined,
|
|
208
|
+
route?: { plainTranslate: boolean },
|
|
210
209
|
) => {
|
|
211
210
|
const writes = observeGsapGesture(gsapCommitMutation);
|
|
212
|
-
if (
|
|
211
|
+
if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
|
|
213
212
|
const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
|
|
214
213
|
return writes.finish(result?.changed === true);
|
|
215
214
|
}
|
|
@@ -258,8 +257,13 @@ export function useGsapAwareEditing({
|
|
|
258
257
|
);
|
|
259
258
|
// Saved after the size, under its undo key, so the two are one step.
|
|
260
259
|
await saveMove(dragOutcome, async () => {
|
|
261
|
-
const
|
|
262
|
-
anchorMove = stageElementPositionOffset(
|
|
260
|
+
const plainAfterSettle = editsPlainCss(selection.element, "move");
|
|
261
|
+
anchorMove = stageElementPositionOffset(
|
|
262
|
+
selection,
|
|
263
|
+
offset,
|
|
264
|
+
plainAfterSettle,
|
|
265
|
+
coalesceKey,
|
|
266
|
+
);
|
|
263
267
|
});
|
|
264
268
|
}
|
|
265
269
|
};
|
|
@@ -360,7 +364,7 @@ export function useGsapAwareEditing({
|
|
|
360
364
|
const handleGsapAwareRotationCommit = useCallback(
|
|
361
365
|
async (selection: DomEditSelection, next: RotationCommit) => {
|
|
362
366
|
const writes = observeGsapGesture(gsapCommitMutation);
|
|
363
|
-
if (next.plain
|
|
367
|
+
if (next.plain === undefined ? editsPlainCss(selection.element, "rotate") : next.plain) {
|
|
364
368
|
const result = await handleDomRotationCommit(selection, next);
|
|
365
369
|
return writes.finish(result?.changed === true);
|
|
366
370
|
}
|
|
@@ -5,7 +5,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
5
5
|
import type { CommitMutationCall, CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
6
6
|
import type { UseGsapAwareEditingParams } from "./useGsapAwareEditing";
|
|
7
7
|
import { observeGsapGesture } from "./gsapGestureOutcome";
|
|
8
|
-
import {
|
|
8
|
+
import { editsPlainCss } from "./gsapRuntimeKeyframes";
|
|
9
9
|
import { refuseGsapTakeover } from "./elementOffsetStager";
|
|
10
10
|
import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
|
|
11
11
|
import { assertGsapEditPersisted } from "./gsapEditOutcome";
|
|
@@ -81,7 +81,7 @@ export function useGsapAwareGroupMove({
|
|
|
81
81
|
// write nothing and share one in-flight parse per file, so they run together.
|
|
82
82
|
const preflightResults = await Promise.allSettled(
|
|
83
83
|
updates.map(async ({ selection, next, plainTranslate }) => {
|
|
84
|
-
if (plainTranslate ??
|
|
84
|
+
if (plainTranslate ?? editsPlainCss(selection.element, "move")) {
|
|
85
85
|
refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
|
|
86
86
|
return void offsetMembers.set(selection, true);
|
|
87
87
|
}
|
|
@@ -30,7 +30,11 @@ vi.mock("../utils/studioTelemetry", () => ({
|
|
|
30
30
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
31
31
|
|
|
32
32
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
33
|
-
import type {
|
|
33
|
+
import type {
|
|
34
|
+
CommitMutationCall,
|
|
35
|
+
CommitMutationOptions,
|
|
36
|
+
MutationResult,
|
|
37
|
+
} from "./gsapScriptCommitTypes";
|
|
34
38
|
import { persistSdkSerialize } from "../utils/sdkCutover";
|
|
35
39
|
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
36
40
|
import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
|
|
@@ -211,6 +215,34 @@ describe("applyPreviewSync", () => {
|
|
|
211
215
|
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
212
216
|
});
|
|
213
217
|
|
|
218
|
+
it("carries a deferred write with no instant patch into the final batch render", () => {
|
|
219
|
+
const previewFallbackLatch = { pending: false };
|
|
220
|
+
applySoftReload.mockReturnValue("applied");
|
|
221
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
222
|
+
const group = {
|
|
223
|
+
label: "Move animated layer (group)",
|
|
224
|
+
softReload: true,
|
|
225
|
+
previewFallbackLatch,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
applyPreviewSync(
|
|
229
|
+
FAKE_IFRAME,
|
|
230
|
+
result({ scriptText: "SCRIPT" }),
|
|
231
|
+
{ ...group, deferPreviewSync: true },
|
|
232
|
+
vi.fn(),
|
|
233
|
+
);
|
|
234
|
+
expect(previewFallbackLatch.pending).toBe(true);
|
|
235
|
+
expect(applySoftReload).not.toHaveBeenCalled();
|
|
236
|
+
|
|
237
|
+
applyPreviewSync(
|
|
238
|
+
FAKE_IFRAME,
|
|
239
|
+
result({ scriptText: "SCRIPT" }),
|
|
240
|
+
{ ...group, instantPatch: { selector: "#final", change: { kind: "set", props: { x: 2 } } } },
|
|
241
|
+
vi.fn(),
|
|
242
|
+
);
|
|
243
|
+
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
214
246
|
it("falls back immediately when a deferred patch miss has no final-render latch", () => {
|
|
215
247
|
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
216
248
|
applySoftReload.mockReturnValue("applied");
|
|
@@ -416,6 +448,16 @@ function renderCommitHook(
|
|
|
416
448
|
|
|
417
449
|
const selection: DomEditSelection = { id: "a", selector: "#a" } as DomEditSelection;
|
|
418
450
|
|
|
451
|
+
async function commitBatch(calls: CommitMutationCall[], options: CommitMutationOptions) {
|
|
452
|
+
const deps = renderCommitHook();
|
|
453
|
+
const batch = deps.api.commitMutation.batch;
|
|
454
|
+
if (!batch) throw new Error("batch capability missing");
|
|
455
|
+
await act(async () => {
|
|
456
|
+
await batch(calls, options);
|
|
457
|
+
});
|
|
458
|
+
return deps;
|
|
459
|
+
}
|
|
460
|
+
|
|
419
461
|
function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
|
420
462
|
const body: MutationResult = {
|
|
421
463
|
ok: true,
|
|
@@ -553,6 +595,32 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
553
595
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
554
596
|
});
|
|
555
597
|
|
|
598
|
+
it("batch where a write without an instant patch soft-reloads instead of patching the rest", async () => {
|
|
599
|
+
// A corner resize saves size (no in-place patch) and position (patched) in one batch.
|
|
600
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
601
|
+
applySoftReload.mockReturnValue("applied");
|
|
602
|
+
mockFetchResult({ changed: true });
|
|
603
|
+
const position = { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } };
|
|
604
|
+
const deps = await commitBatch(
|
|
605
|
+
[
|
|
606
|
+
{
|
|
607
|
+
selection,
|
|
608
|
+
mutation: { type: "update-property", property: "width", value: 300 },
|
|
609
|
+
options: { label: "Resize layer" },
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
selection,
|
|
613
|
+
mutation: { type: "update-property", property: "x", value: 10 },
|
|
614
|
+
options: { label: "Resize layer", instantPatch: position },
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
{ label: "Resize layer", softReload: true },
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
621
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
622
|
+
});
|
|
623
|
+
|
|
556
624
|
it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
|
|
557
625
|
// Server contract: gsap-mutations returns scriptText on EVERY response,
|
|
558
626
|
// including changed:false — so the fallback re-runs the identical script
|
|
@@ -268,7 +268,8 @@ export function applyPreviewSync(
|
|
|
268
268
|
nestedFiles?: Map<string, string> | null,
|
|
269
269
|
): void {
|
|
270
270
|
const patches = instantPatchesFor(options);
|
|
271
|
-
|
|
271
|
+
const writtenWithoutPatch = patches.length === 0;
|
|
272
|
+
let needsFallback = options.previewFallbackLatch?.pending === true || writtenWithoutPatch;
|
|
272
273
|
if (patches.length > 0) {
|
|
273
274
|
const deferSeek = options.deferPreviewSync === true;
|
|
274
275
|
const missed = patches.find(
|
|
@@ -410,13 +411,14 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
410
411
|
if (!result) return;
|
|
411
412
|
trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options));
|
|
412
413
|
options.onResult?.(result);
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
const instantPatches = calls
|
|
416
|
-
.
|
|
417
|
-
|
|
414
|
+
// The batch wrote every call, so patch in place only when every call brought a patch; one
|
|
415
|
+
// without (a resize's size write) needs the reload, or the preview keeps the old value.
|
|
416
|
+
const instantPatches = calls.flatMap(({ options: callOptions }) =>
|
|
417
|
+
callOptions.instantPatch ? [callOptions.instantPatch] : [],
|
|
418
|
+
);
|
|
418
419
|
const { instantPatch: _instantPatch, ...batchOptions } = options;
|
|
419
|
-
|
|
420
|
+
const allPatched = instantPatches.length === calls.length;
|
|
421
|
+
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, allPatched ? { ...batchOptions, instantPatches } : batchOptions);
|
|
420
422
|
}, [showToast, finalizeSuccessfulMutation]);
|
|
421
423
|
|
|
422
424
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// fallow-ignore-file code-duplication
|
|
3
3
|
import { act, createElement } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
8
|
import { afterEach, expect, it, vi } from "vitest";
|
|
9
9
|
import {
|
|
10
10
|
createStudioApi,
|
|
@@ -316,6 +316,29 @@ it("refuses a second press whose edit is undone once an edit was made after the
|
|
|
316
316
|
expect(file()).toBe("B");
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
+
it("a file the edit made (a freeze's still) is deleted by its Undo and put back by its Redo", async () => {
|
|
320
|
+
const { dir, hook, save, readFile } = await studio();
|
|
321
|
+
const still = join(dir, "assets", "freeze", "talk.png");
|
|
322
|
+
mkdirSync(dirname(still), { recursive: true });
|
|
323
|
+
writeFileSync(still, "png");
|
|
324
|
+
save("B");
|
|
325
|
+
await act(() =>
|
|
326
|
+
hook().recordEdit({
|
|
327
|
+
label: "Freeze frame",
|
|
328
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
329
|
+
created: ["assets/freeze/talk.png"],
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Freeze frame"));
|
|
333
|
+
|
|
334
|
+
await act(() => hook().undo({ readFile }));
|
|
335
|
+
expect(existsSync(still)).toBe(false);
|
|
336
|
+
|
|
337
|
+
await vi.waitFor(() => expect(hook().canRedo).toBe(true));
|
|
338
|
+
await act(() => hook().redo({ readFile }));
|
|
339
|
+
expect(readFileSync(still, "utf8")).toBe("png");
|
|
340
|
+
});
|
|
341
|
+
|
|
319
342
|
it("a drag's edits under one key undo as one step, even before the drag goes idle", async () => {
|
|
320
343
|
const { hook, file, save, readFile } = await studio();
|
|
321
344
|
const writes: Array<[before: string, after: string]> = [
|
|
@@ -3,13 +3,7 @@ import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server"
|
|
|
3
3
|
import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
|
4
4
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
5
5
|
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
6
|
-
|
|
7
|
-
interface RecordEditInput {
|
|
8
|
-
label: string;
|
|
9
|
-
coalesceKey?: string;
|
|
10
|
-
coalesceMs?: number;
|
|
11
|
-
files: Record<string, { before: string; after: string }>;
|
|
12
|
-
}
|
|
6
|
+
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
13
7
|
|
|
14
8
|
interface ApplyCallbacks {
|
|
15
9
|
readFile: (path: string) => Promise<string>;
|
|
@@ -235,9 +229,9 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
235
229
|
}, [refresh, own]);
|
|
236
230
|
|
|
237
231
|
const recordEdit = useCallback(
|
|
238
|
-
async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => {
|
|
232
|
+
async ({ label, coalesceKey, coalesceMs, files, created = [] }: RecordEditInput) => {
|
|
239
233
|
if (!projectId) return;
|
|
240
|
-
const paths = Object.keys(files);
|
|
234
|
+
const paths = [...Object.keys(files), ...created];
|
|
241
235
|
own.overtake();
|
|
242
236
|
const reply = await post(historyUrl(projectId, "/claim"), {
|
|
243
237
|
label,
|
|
@@ -499,6 +499,54 @@ describe("Timeline provider boundary", () => {
|
|
|
499
499
|
act(() => root.unmount());
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
+
// An agent writes music alternatives muted; the author unmutes one and must be
|
|
503
|
+
// able to mute it again from the same row.
|
|
504
|
+
it("mutes, unmutes and mutes an audio track again from its header", () => {
|
|
505
|
+
const host = createSizedTimelineHost(640);
|
|
506
|
+
usePlayerStore.setState({
|
|
507
|
+
duration: 4,
|
|
508
|
+
timelineReady: true,
|
|
509
|
+
elements: [{ id: "music-b", tag: "audio", start: 0, duration: 4, track: 0, hidden: true }],
|
|
510
|
+
});
|
|
511
|
+
const onToggleTrackHidden = vi.fn((track: number, hidden: boolean) => {
|
|
512
|
+
usePlayerStore.setState({
|
|
513
|
+
elements: usePlayerStore
|
|
514
|
+
.getState()
|
|
515
|
+
.elements.map((el) => (el.track === track ? { ...el, hidden } : el)),
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
const root = createRoot(host);
|
|
519
|
+
act(() => {
|
|
520
|
+
root.render(
|
|
521
|
+
React.createElement(
|
|
522
|
+
TimelineEditProvider,
|
|
523
|
+
{ value: { onToggleTrackHidden } },
|
|
524
|
+
React.createElement(Timeline),
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
act(() => {});
|
|
529
|
+
|
|
530
|
+
const press = (label: string) => {
|
|
531
|
+
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
|
|
532
|
+
if (!button) throw new Error(`Expected a "${label}" button`);
|
|
533
|
+
act(() => button.click());
|
|
534
|
+
};
|
|
535
|
+
press("Unmute track 1");
|
|
536
|
+
press("Mute track 1");
|
|
537
|
+
press("Unmute track 1");
|
|
538
|
+
press("Mute track 1");
|
|
539
|
+
|
|
540
|
+
expect(onToggleTrackHidden.mock.calls.map((call) => call[1])).toEqual([
|
|
541
|
+
false,
|
|
542
|
+
true,
|
|
543
|
+
false,
|
|
544
|
+
true,
|
|
545
|
+
]);
|
|
546
|
+
expect(usePlayerStore.getState().elements[0]?.hidden).toBe(true);
|
|
547
|
+
act(() => root.unmount());
|
|
548
|
+
});
|
|
549
|
+
|
|
502
550
|
it("splits all tracks once when shift-clicking the timeline with the razor", () => {
|
|
503
551
|
const host = createSizedTimelineHost(640);
|
|
504
552
|
usePlayerStore.setState({
|
|
@@ -256,43 +256,35 @@ describe("TimelineTrackHeader", () => {
|
|
|
256
256
|
act(() => view.root.unmount());
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
259
|
+
// `data-hidden` silences an audio track, so its toggle is a mute, offered in
|
|
260
|
+
// both states: an agent writes alternatives muted and the author flips them.
|
|
261
|
+
it.each([
|
|
262
|
+
[false, "Mute track 1"],
|
|
263
|
+
[true, "Unmute track 1"],
|
|
264
|
+
])("offers the audio track toggle when hidden=%s", (isTrackHidden, label) => {
|
|
263
265
|
const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
|
|
264
266
|
const view = renderHeader({
|
|
265
267
|
keyframeClip: audio,
|
|
266
268
|
trackElements: [audio],
|
|
267
269
|
isAudioTrack: true,
|
|
270
|
+
isTrackHidden,
|
|
268
271
|
animations: [],
|
|
269
272
|
});
|
|
270
|
-
|
|
271
|
-
b.getAttribute("aria-label"),
|
|
272
|
-
);
|
|
273
|
-
expect(labels.some((l) => l && /^(Hide|Show) track/.test(l))).toBe(false);
|
|
274
|
-
expect(labels).not.toContain("Mute");
|
|
273
|
+
expect(view.host.querySelector(`button[aria-label="${label}"]`)).not.toBeNull();
|
|
275
274
|
act(() => view.root.unmount());
|
|
276
275
|
});
|
|
277
276
|
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// meant a track hidden by "Hide all" (or by hand, or before that rule existed)
|
|
282
|
-
// was silent with no control anywhere to bring it back.
|
|
283
|
-
it("offers the eye on an audio track that is already hidden, so it can be restored", () => {
|
|
284
|
-
const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
|
|
277
|
+
// Same rule as the undo entry: a track with any visual clip hides, it does not mute.
|
|
278
|
+
it("offers the eye on a track mixing audio and a visual clip", () => {
|
|
279
|
+
const audio: TimelineElement = { ...ELEMENT, id: "vo", tag: "audio" };
|
|
285
280
|
const view = renderHeader({
|
|
286
281
|
keyframeClip: audio,
|
|
287
|
-
trackElements: [audio],
|
|
282
|
+
trackElements: [audio, ELEMENT],
|
|
283
|
+
clipCount: 2,
|
|
288
284
|
isAudioTrack: true,
|
|
289
|
-
isTrackHidden: true,
|
|
290
285
|
animations: [],
|
|
291
286
|
});
|
|
292
|
-
|
|
293
|
-
b.getAttribute("aria-label"),
|
|
294
|
-
);
|
|
295
|
-
expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true);
|
|
287
|
+
expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
|
|
296
288
|
act(() => view.root.unmount());
|
|
297
289
|
});
|
|
298
290
|
|
|
@@ -12,6 +12,7 @@ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditConte
|
|
|
12
12
|
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
13
13
|
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
|
|
14
14
|
import { runtimeAudioId } from "../lib/timelineElementHelpers";
|
|
15
|
+
import { isAudioOnlyTrack } from "../../utils/timelineInspector";
|
|
15
16
|
import { TimelineFxButton } from "./TimelineFxButton";
|
|
16
17
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
17
18
|
import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData";
|
|
@@ -203,6 +204,7 @@ export function TimelineTrackHeader({
|
|
|
203
204
|
// disclosability swapped it for the keyframe-layer row (a `◇`, no indent) the
|
|
204
205
|
// moment an envelope appeared.
|
|
205
206
|
const isKeyframeLayer = !!keyframeClip && disclosable && !isAudioTrack;
|
|
207
|
+
const isAudioOnly = isAudioOnlyTrack(trackElements);
|
|
206
208
|
// What the lane disclosure calls this row. A row of several clips is named
|
|
207
209
|
// for the TRACK, not for whichever is selected — the lanes are the track's,
|
|
208
210
|
// shared per property, so "Narration 2 lanes" read as if they were that one
|
|
@@ -312,6 +314,7 @@ export function TimelineTrackHeader({
|
|
|
312
314
|
showTrackLabel={showTrackLabel}
|
|
313
315
|
isTrackHidden={isTrackHidden}
|
|
314
316
|
isAudioTrack={isAudioTrack}
|
|
317
|
+
isAudioOnly={isAudioOnly}
|
|
315
318
|
onToggleTrackHidden={onToggleTrackHidden}
|
|
316
319
|
// On the control line rather than a third row of its own.
|
|
317
320
|
trailing={
|
|
@@ -383,8 +386,7 @@ export function TimelineTrackHeader({
|
|
|
383
386
|
hidden={isTrackHidden}
|
|
384
387
|
trackNumber={trackNumber}
|
|
385
388
|
trackDisplayNumber={trackDisplayNumber}
|
|
386
|
-
|
|
387
|
-
visible={!isAudioTrack || isTrackHidden}
|
|
389
|
+
asMute={isAudioOnly}
|
|
388
390
|
onToggle={onToggleTrackHidden}
|
|
389
391
|
/>
|
|
390
392
|
</LayerDisclosureRow>
|
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
* The wording ran behind the `audio-track-mute` canary at 0%, so it had never
|
|
7
|
-
* rendered in any suite: it returned "Muted" / "Mute", which named the CURRENT
|
|
8
|
-
* state rather than the action, and dropped the track suffix so every audio row
|
|
9
|
-
* shared one accessible name. Pinned here because the label, the icon and the
|
|
10
|
-
* callback's arguments are the whole identity of this control.
|
|
11
|
-
*
|
|
12
|
-
* The mute presentation itself is gone (`remove mute and solo from tracks and
|
|
13
|
-
* groups`): the control is the visibility eye it always was, and an audio row
|
|
14
|
-
* renders it only when already hidden, which is the way back out.
|
|
4
|
+
* The track toggle's accessible contract: the label names the action, carries
|
|
5
|
+
* the display row, and reads mute on an audio-only track.
|
|
15
6
|
*/
|
|
16
7
|
|
|
17
8
|
import React, { act } from "react";
|
|
@@ -27,7 +18,7 @@ afterEach(() => {
|
|
|
27
18
|
|
|
28
19
|
function renderButton(props: {
|
|
29
20
|
hidden: boolean;
|
|
30
|
-
|
|
21
|
+
asMute?: boolean;
|
|
31
22
|
trackDisplayNumber: number | null;
|
|
32
23
|
}): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
|
|
33
24
|
const host = document.createElement("div");
|
|
@@ -40,7 +31,7 @@ function renderButton(props: {
|
|
|
40
31
|
hidden: props.hidden,
|
|
41
32
|
trackNumber: 7,
|
|
42
33
|
trackDisplayNumber: props.trackDisplayNumber,
|
|
43
|
-
|
|
34
|
+
asMute: props.asMute ?? false,
|
|
44
35
|
onToggle,
|
|
45
36
|
}),
|
|
46
37
|
),
|
|
@@ -80,13 +71,13 @@ describe("VisibilityButton", () => {
|
|
|
80
71
|
view.unmount();
|
|
81
72
|
});
|
|
82
73
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
expect(
|
|
89
|
-
|
|
74
|
+
it("names a mute on an audio-only track", () => {
|
|
75
|
+
const audible = renderButton({ hidden: false, asMute: true, trackDisplayNumber: 2 });
|
|
76
|
+
expect(labelOf(audible.host)).toBe("Mute track 2");
|
|
77
|
+
audible.unmount();
|
|
78
|
+
const muted = renderButton({ hidden: true, asMute: true, trackDisplayNumber: 2 });
|
|
79
|
+
expect(labelOf(muted.host)).toBe("Unmute track 2");
|
|
80
|
+
muted.unmount();
|
|
90
81
|
});
|
|
91
82
|
});
|
|
92
83
|
|
|
@@ -104,6 +95,7 @@ describe("PlainTrackHeader", () => {
|
|
|
104
95
|
clipCount: 1,
|
|
105
96
|
isTrackHidden: false,
|
|
106
97
|
isAudioTrack: true,
|
|
98
|
+
isAudioOnly: true,
|
|
107
99
|
onToggleTrackHidden: vi.fn(),
|
|
108
100
|
showTrackLabel: true,
|
|
109
101
|
...overrides,
|
|
@@ -113,26 +105,20 @@ describe("PlainTrackHeader", () => {
|
|
|
113
105
|
return { host, unmount: () => act(() => root.unmount()) };
|
|
114
106
|
}
|
|
115
107
|
|
|
116
|
-
|
|
117
|
-
// affordance sitting in the eye's slot.
|
|
118
|
-
it("withholds the eye from an audible audio track", () => {
|
|
108
|
+
it("offers a mute on an audible audio track", () => {
|
|
119
109
|
const view = renderHeader();
|
|
120
|
-
expect(view.host.
|
|
110
|
+
expect(labelOf(view.host)).toBe("Mute track 1");
|
|
121
111
|
view.unmount();
|
|
122
112
|
});
|
|
123
113
|
|
|
124
|
-
|
|
125
|
-
// silences the clip in preview and drops it from the render, and nothing else
|
|
126
|
-
// writes it, so a track hidden by hand or by "Hide all" was silent with no
|
|
127
|
-
// control anywhere to restore it.
|
|
128
|
-
it("offers it back once the audio track is hidden", () => {
|
|
114
|
+
it("offers the unmute on a muted audio track", () => {
|
|
129
115
|
const view = renderHeader({ isTrackHidden: true });
|
|
130
|
-
expect(labelOf(view.host)).toBe("
|
|
116
|
+
expect(labelOf(view.host)).toBe("Unmute track 1");
|
|
131
117
|
view.unmount();
|
|
132
118
|
});
|
|
133
119
|
|
|
134
120
|
it("keeps the eye on a visual track", () => {
|
|
135
|
-
const view = renderHeader({ isAudioTrack: false });
|
|
121
|
+
const view = renderHeader({ isAudioTrack: false, isAudioOnly: false });
|
|
136
122
|
expect(labelOf(view.host)).toBe("Hide track 1");
|
|
137
123
|
view.unmount();
|
|
138
124
|
});
|