@hyperframes/studio 0.8.137 → 0.8.139
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-D_Mh6hbx.js → hyperframes-player-CckOoCJ8.js} +1 -1
- package/dist/assets/{index-CHEctGzc.js → index-BmIPrb8I.js} +1 -1
- package/dist/assets/{index-B3lJsA-e.js → index-Br1Whc0f.js} +189 -189
- package/dist/assets/{index-CC_E-Cbl.css → index-CVd4O6eH.css} +1 -1
- package/dist/assets/{index-rtccfg9b.js → index-DsEkE5_G.js} +1 -1
- package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
- package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
- package/dist/chunk-TFLU2MGI.js.map +1 -0
- package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
- package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.html +2 -2
- package/dist/index.js +867 -499
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +11 -11
- package/src/components/editor/DomEditOverlay.tsx +9 -5
- package/src/components/editor/LayersPanel.test.ts +25 -0
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/PropertyPanel.test.tsx +600 -748
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
- package/src/components/editor/domEditOverlayGeometry.ts +7 -7
- package/src/components/editor/domEditOverlayGestures.ts +18 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
- package/src/components/editor/dragUndoPaint.test.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
- package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +7 -7
- package/src/components/editor/manualEditsTypes.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +23 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -0
- package/src/components/editor/previewGestureStarts.ts +284 -0
- package/src/components/editor/rotateInlineSave.test.ts +1 -0
- package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
- package/src/components/nle/NLEContext.test.ts +26 -0
- package/src/components/nle/NLEContext.tsx +8 -3
- package/src/hooks/draggedGsapPosition.ts +8 -0
- package/src/hooks/editMoment.ts +13 -0
- package/src/hooks/gsapDragCommit.ts +2 -1
- package/src/hooks/gsapDragPositionCommit.ts +19 -7
- package/src/hooks/gsapPositionDetection.ts +5 -3
- package/src/hooks/gsapResizeIntercept.ts +11 -4
- package/src/hooks/gsapRuntimeBridge.ts +31 -11
- package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
- package/src/hooks/gsapValueAtPlayhead.ts +12 -5
- package/src/hooks/timelineEditingHelpers.test.ts +22 -0
- package/src/hooks/timelineEditingHelpers.ts +18 -2
- package/src/hooks/useConsoleErrorCapture.test.tsx +87 -0
- package/src/hooks/useConsoleErrorCapture.ts +18 -22
- package/src/hooks/useDomSelection.test.ts +21 -2
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +2 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
- package/src/hooks/useGsapAwareEditing.ts +148 -136
- package/src/hooks/useGsapAwareGroupMove.ts +126 -113
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
- package/src/hooks/usePreviewPersistence.ts +1 -4
- package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
- package/src/hooks/useTimelineDeleteOps.ts +9 -10
- package/src/index.ts +1 -0
- package/src/player/hooks/useShadowPreviewReload.ts +6 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
- package/src/player/hooks/useTimelinePlayer.ts +8 -5
- package/src/player/lib/thumbnailScheduler.test.ts +20 -1
- package/src/player/lib/thumbnailScheduler.ts +1 -1
- package/src/player/lib/timelineElementHelpers.ts +0 -11
- package/src/player/previewReloading.test.ts +48 -0
- package/src/player/previewReloading.ts +48 -0
- package/src/styles/studio.css +9 -0
- package/src/utils/gsapUndoRestore.browser.test.ts +21 -12
- package/src/utils/rootDuration.ts +5 -4
- package/src/utils/studioPendingEdits.test.ts +27 -0
- package/src/utils/studioPendingEdits.ts +9 -0
- package/src/utils/timelineAssetDrop.test.ts +13 -4
- package/src/utils/timelineAssetDrop.ts +6 -2
- package/dist/chunk-TDWGXBUT.js.map +0 -1
- /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
- /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
- /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
|
@@ -5,6 +5,9 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
|
7
7
|
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
|
|
8
|
+
import { PropertyPanel } from "./PropertyPanel";
|
|
9
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
10
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
11
|
|
|
9
12
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
13
|
|
|
@@ -17,10 +20,35 @@ vi.mock("../../contexts/StudioContext", async () => {
|
|
|
17
20
|
return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
|
|
18
21
|
});
|
|
19
22
|
|
|
23
|
+
// The panel is imported once: re-importing it per test made the first test pay a cold transform.
|
|
24
|
+
const panel = vi.hoisted(() => ({
|
|
25
|
+
flat: false,
|
|
26
|
+
actions: null as null | {
|
|
27
|
+
refreshDomEditSelectionFromPreview: unknown;
|
|
28
|
+
domEditSelectionRef: unknown;
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("./manualEditingAvailability", async (importOriginal) => ({
|
|
32
|
+
...(await importOriginal<typeof import("./manualEditingAvailability")>()),
|
|
33
|
+
get STUDIO_FLAT_INSPECTOR_ENABLED() {
|
|
34
|
+
return panel.flat;
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
vi.mock("../../contexts/DomEditContext", async (importOriginal) => {
|
|
38
|
+
const actual = await importOriginal<typeof import("../../contexts/DomEditContext")>();
|
|
39
|
+
return {
|
|
40
|
+
...actual,
|
|
41
|
+
useDomEditActionsContextOptional: () =>
|
|
42
|
+
panel.actions ?? actual.useDomEditActionsContextOptional(),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const initialPlayerState = usePlayerStore.getState();
|
|
47
|
+
|
|
20
48
|
afterEach(() => {
|
|
21
49
|
document.body.innerHTML = "";
|
|
22
|
-
|
|
23
|
-
|
|
50
|
+
panel.actions = null;
|
|
51
|
+
usePlayerStore.setState(initialPlayerState, true);
|
|
24
52
|
});
|
|
25
53
|
|
|
26
54
|
function baseElement(): NonNullable<PropertyPanelProps["element"]> {
|
|
@@ -244,39 +272,17 @@ async function renderPanel(
|
|
|
244
272
|
selectionRef?: { current: unknown };
|
|
245
273
|
} = {},
|
|
246
274
|
) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
});
|
|
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");
|
|
275
|
+
panel.flat = flatEnabled;
|
|
276
|
+
const { refreshSelection: refresh, selectionRef } = options;
|
|
277
|
+
panel.actions =
|
|
278
|
+
refresh && selectionRef
|
|
279
|
+
? { refreshDomEditSelectionFromPreview: refresh, domEditSelectionRef: selectionRef }
|
|
280
|
+
: null;
|
|
273
281
|
if (options.currentTime !== undefined) {
|
|
274
282
|
usePlayerStore.getState().setCurrentTime(options.currentTime);
|
|
275
283
|
}
|
|
276
284
|
if (options.selectedElementId)
|
|
277
285
|
usePlayerStore.setState({ selectedElementId: options.selectedElementId });
|
|
278
|
-
const { PropertyPanel } = await import("./PropertyPanel");
|
|
279
|
-
const { TimelineEditProvider } = await import("../../contexts/TimelineEditContext");
|
|
280
286
|
const host = document.createElement("div");
|
|
281
287
|
document.body.append(host);
|
|
282
288
|
const root = createRoot(host);
|
|
@@ -304,15 +310,6 @@ async function renderPanel(
|
|
|
304
310
|
return { host, root, render };
|
|
305
311
|
}
|
|
306
312
|
|
|
307
|
-
// renderPanel resetModules()+dynamic-imports PropertyPanel (needed for a fresh
|
|
308
|
-
// flag read); transforming the full section graph uncached can exceed the 5s
|
|
309
|
-
// default under heavy parallel full-suite load, so give these a wider margin.
|
|
310
|
-
// 20s itself has now been observed timing out in CI's full-monorepo run (the
|
|
311
|
-
// same suite passes in well under 2s standalone) — widened again rather than
|
|
312
|
-
// re-tuned down to a number that will just need doing again next time CI adds
|
|
313
|
-
// load.
|
|
314
|
-
const RENDER_TIMEOUT_MS = 45_000;
|
|
315
|
-
|
|
316
313
|
// Find the collapsed accordion row whose title matches and click it open.
|
|
317
314
|
function openFlatGroup(host: HTMLElement, title: string) {
|
|
318
315
|
const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find((el) =>
|
|
@@ -326,229 +323,181 @@ const openGroupText = (host: HTMLElement) =>
|
|
|
326
323
|
host.querySelector('[data-flat-group-open="true"]')?.textContent ?? "";
|
|
327
324
|
|
|
328
325
|
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED off", () => {
|
|
329
|
-
it(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
act(() => root.unmount());
|
|
336
|
-
},
|
|
337
|
-
RENDER_TIMEOUT_MS,
|
|
338
|
-
);
|
|
326
|
+
it("renders the legacy header, not the flat header", async () => {
|
|
327
|
+
const { host, root } = await renderPanel(false);
|
|
328
|
+
expect(host.querySelector('[data-flat-header-icon="true"]')).toBeNull();
|
|
329
|
+
expect(host.textContent).toContain("Mono Label");
|
|
330
|
+
act(() => root.unmount());
|
|
331
|
+
});
|
|
339
332
|
});
|
|
340
333
|
|
|
341
334
|
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
|
|
342
|
-
it(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
act(() => root.unmount());
|
|
350
|
-
},
|
|
351
|
-
RENDER_TIMEOUT_MS,
|
|
352
|
-
);
|
|
335
|
+
it("renders the flat header, the Text group open by default, and the flat footer", async () => {
|
|
336
|
+
const { host, root } = await renderPanel(true);
|
|
337
|
+
expect(host.querySelector('[data-flat-header-icon="true"]')).not.toBeNull();
|
|
338
|
+
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
|
|
339
|
+
expect(host.textContent).toContain("Ask agent about this element");
|
|
340
|
+
act(() => root.unmount());
|
|
341
|
+
});
|
|
353
342
|
|
|
354
|
-
it(
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
act(() => root.unmount());
|
|
370
|
-
},
|
|
371
|
-
RENDER_TIMEOUT_MS,
|
|
372
|
-
);
|
|
343
|
+
it("collapses the Text group on caret click and can reopen it", async () => {
|
|
344
|
+
const { host, root } = await renderPanel(true);
|
|
345
|
+
const collapseButton = host.querySelector<HTMLButtonElement>(
|
|
346
|
+
'[data-flat-group-open="true"] button[title="Collapse"]',
|
|
347
|
+
);
|
|
348
|
+
act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
349
|
+
expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
|
|
350
|
+
const collapsedRow = host.querySelector<HTMLButtonElement>(
|
|
351
|
+
'[data-flat-group-collapsed="true"]',
|
|
352
|
+
);
|
|
353
|
+
expect(collapsedRow).not.toBeNull();
|
|
354
|
+
act(() => collapsedRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
355
|
+
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
|
|
356
|
+
act(() => root.unmount());
|
|
357
|
+
});
|
|
373
358
|
|
|
374
|
-
it(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
act(() => root.unmount());
|
|
391
|
-
},
|
|
392
|
-
RENDER_TIMEOUT_MS,
|
|
393
|
-
);
|
|
359
|
+
it("renders no Text group at all for a non-text element (bug 1)", async () => {
|
|
360
|
+
// nonTextElement() inherits canEditStyles: true from baseElement(), so
|
|
361
|
+
// the Style group (Task 10) renders and opens by default here — the
|
|
362
|
+
// invariant under test is narrower than "no flat group at all": no
|
|
363
|
+
// group titled "Text" may appear, open or collapsed.
|
|
364
|
+
const { host, root } = await renderPanel(true, nonTextElement());
|
|
365
|
+
const openTitle = host.querySelector(
|
|
366
|
+
'[data-flat-group-open="true"] .text-panel-text-0',
|
|
367
|
+
)?.textContent;
|
|
368
|
+
const collapsedTitles = Array.from(
|
|
369
|
+
host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
|
|
370
|
+
).map((el) => el.textContent);
|
|
371
|
+
expect(openTitle).not.toBe("Text");
|
|
372
|
+
expect(collapsedTitles).not.toContain("Text");
|
|
373
|
+
act(() => root.unmount());
|
|
374
|
+
});
|
|
394
375
|
|
|
395
|
-
it(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
act(() => root.unmount());
|
|
408
|
-
},
|
|
409
|
-
RENDER_TIMEOUT_MS,
|
|
410
|
-
);
|
|
376
|
+
it("renders exactly one Text heading for a multi-field text element (bug 2)", async () => {
|
|
377
|
+
const { host, root } = await renderPanel(true, multiFieldTextElement());
|
|
378
|
+
// The FlatGroup's own "Text" heading is the only one that should exist —
|
|
379
|
+
// the legacy TextSection's internal Section heading (data-panel-section
|
|
380
|
+
// ="text") must never appear, since the flat multi-field path no longer
|
|
381
|
+
// delegates to that component at all.
|
|
382
|
+
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
|
|
383
|
+
expect(host.querySelector('[data-panel-section="text"]')).toBeNull();
|
|
384
|
+
// Content from the flat multi-field layer list must render.
|
|
385
|
+
expect(host.textContent).toContain("Text layers");
|
|
386
|
+
act(() => root.unmount());
|
|
387
|
+
});
|
|
411
388
|
});
|
|
412
389
|
|
|
413
390
|
describe("PropertyPanel — Style group (flag on)", () => {
|
|
414
|
-
it(
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
act(() => root.unmount());
|
|
421
|
-
},
|
|
422
|
-
RENDER_TIMEOUT_MS,
|
|
423
|
-
);
|
|
391
|
+
it("renders the Style group for a style-editable, non-text element", async () => {
|
|
392
|
+
const { host, root } = await renderPanel(true, styleOnlyElement());
|
|
393
|
+
expect(host.textContent).toContain("Style");
|
|
394
|
+
expect(host.textContent).toContain("Fill");
|
|
395
|
+
act(() => root.unmount());
|
|
396
|
+
});
|
|
424
397
|
|
|
425
|
-
it(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
act(() => root.unmount());
|
|
441
|
-
},
|
|
442
|
-
RENDER_TIMEOUT_MS,
|
|
443
|
-
);
|
|
398
|
+
it("one-open accordion: opening Style closes Text", async () => {
|
|
399
|
+
// baseElement() is text-editable and has capabilities.canEditStyles:
|
|
400
|
+
// true, so both the Text and Style groups render for it.
|
|
401
|
+
const { host, root } = await renderPanel(true);
|
|
402
|
+
const textGroup = () => host.querySelector('[data-flat-group-open="true"]');
|
|
403
|
+
expect(textGroup()?.textContent).toContain("Text");
|
|
404
|
+
const styleCollapsedRow = Array.from(
|
|
405
|
+
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
|
|
406
|
+
).find((el) => el.textContent?.includes("Style"));
|
|
407
|
+
if (!styleCollapsedRow) throw new Error("expected a collapsed Style row");
|
|
408
|
+
act(() => styleCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
409
|
+
expect(textGroup()?.textContent).not.toContain("Text");
|
|
410
|
+
expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Style");
|
|
411
|
+
act(() => root.unmount());
|
|
412
|
+
});
|
|
444
413
|
});
|
|
445
414
|
|
|
446
415
|
describe("PropertyPanel — Layout group (Plan 3a)", () => {
|
|
447
|
-
it(
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
act(() => root.unmount());
|
|
465
|
-
},
|
|
466
|
-
RENDER_TIMEOUT_MS,
|
|
467
|
-
);
|
|
416
|
+
it("always renders the Layout group, and opening it closes whichever other group was open", async () => {
|
|
417
|
+
const { host, root } = await renderPanel(true);
|
|
418
|
+
// Text group is open by default for the base text-editable fixture.
|
|
419
|
+
expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Text");
|
|
420
|
+
|
|
421
|
+
const layoutCollapsedRow = Array.from(
|
|
422
|
+
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
|
|
423
|
+
).find((el) => el.textContent?.includes("Layout"));
|
|
424
|
+
if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
|
|
425
|
+
act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
426
|
+
|
|
427
|
+
const openGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
428
|
+
expect(openGroup?.textContent).toContain("Layout");
|
|
429
|
+
expect(openGroup?.textContent).toContain("X");
|
|
430
|
+
expect(openGroup?.textContent).not.toContain("Ask agent"); // sanity: not matching the footer
|
|
431
|
+
act(() => root.unmount());
|
|
432
|
+
});
|
|
468
433
|
|
|
469
|
-
it(
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
act(() => root.unmount());
|
|
488
|
-
},
|
|
489
|
-
RENDER_TIMEOUT_MS,
|
|
490
|
-
);
|
|
434
|
+
it("renders Flex exactly once on the flat path (flat Layout only, legacy suppressed)", async () => {
|
|
435
|
+
const { host, root } = await renderPanel(true, flexElement());
|
|
436
|
+
const layoutCollapsedRow = Array.from(
|
|
437
|
+
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
|
|
438
|
+
).find((el) => el.textContent?.includes("Layout"));
|
|
439
|
+
if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
|
|
440
|
+
act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
441
|
+
|
|
442
|
+
// The legacy StyleSections Flex `Section` (data-panel-section="flex") must
|
|
443
|
+
// NOT render on the flat path — the only two Flex renderers are the legacy
|
|
444
|
+
// Section and the flat LayoutFlexBlock, so its absence + the flat block's
|
|
445
|
+
// presence proves Flex renders exactly once (not twice, not zero).
|
|
446
|
+
expect(host.querySelector('[data-panel-section="flex"]')).toBeNull();
|
|
447
|
+
const openGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
448
|
+
expect(openGroup?.textContent).toContain("Layout");
|
|
449
|
+
expect(openGroup?.textContent).toContain("Flex");
|
|
450
|
+
act(() => root.unmount());
|
|
451
|
+
});
|
|
491
452
|
});
|
|
492
453
|
|
|
493
454
|
describe("PropertyPanel — Motion group (Plan 3b)", () => {
|
|
494
|
-
it(
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
act(() => root.unmount());
|
|
516
|
-
},
|
|
517
|
-
RENDER_TIMEOUT_MS,
|
|
518
|
-
);
|
|
455
|
+
it("renders the Motion group with Timing, and opening it closes the previously open group (4-way exclusivity)", async () => {
|
|
456
|
+
const { host, root } = await renderPanel(true, animatedElement());
|
|
457
|
+
// Text is open by default for the text-editable fixture.
|
|
458
|
+
expect(openGroupText(host)).toContain("Text");
|
|
459
|
+
|
|
460
|
+
openFlatGroup(host, "Motion");
|
|
461
|
+
const openGroup = openGroupText(host);
|
|
462
|
+
expect(openGroup).toContain("Motion");
|
|
463
|
+
// FlatTimingRow (Start/End/Duration) renders inside the Motion group.
|
|
464
|
+
expect(openGroup).toContain("Start");
|
|
465
|
+
expect(openGroup).toContain("Duration");
|
|
466
|
+
// One-open accordion: opening Motion closed the Text group.
|
|
467
|
+
expect(openGroup).not.toContain("Text");
|
|
468
|
+
|
|
469
|
+
// Reverse direction: opening Layout closes Motion.
|
|
470
|
+
openFlatGroup(host, "Layout");
|
|
471
|
+
const openAfter = openGroupText(host);
|
|
472
|
+
expect(openAfter).toContain("Layout");
|
|
473
|
+
expect(openAfter).not.toContain("Motion");
|
|
474
|
+
act(() => root.unmount());
|
|
475
|
+
});
|
|
519
476
|
|
|
520
|
-
it(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
act(() => root.unmount());
|
|
532
|
-
},
|
|
533
|
-
RENDER_TIMEOUT_MS,
|
|
534
|
-
);
|
|
477
|
+
it("hides the effect list (showEffects off) when the GSAP edit handlers are absent", async () => {
|
|
478
|
+
// None of the five required edit handlers are supplied here, so the
|
|
479
|
+
// effect list stays closed — only the Timing row shows.
|
|
480
|
+
const { host, root } = await renderPanel(true, animatedElement());
|
|
481
|
+
openFlatGroup(host, "Motion");
|
|
482
|
+
const openGroup = openGroupText(host);
|
|
483
|
+
expect(openGroup).toContain("Motion");
|
|
484
|
+
expect(openGroup).toContain("Duration"); // Timing still shows
|
|
485
|
+
expect(openGroup).not.toContain("Add effect"); // effects gated off
|
|
486
|
+
act(() => root.unmount());
|
|
487
|
+
});
|
|
535
488
|
|
|
536
|
-
it(
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
act(() => root.unmount());
|
|
549
|
-
},
|
|
550
|
-
RENDER_TIMEOUT_MS,
|
|
551
|
-
);
|
|
489
|
+
it("shows the effect list (showEffects on) when the flag and all five handlers are present", async () => {
|
|
490
|
+
const { host, root } = await renderPanel(true, animatedElement(), {
|
|
491
|
+
onUpdateGsapProperty: vi.fn(),
|
|
492
|
+
onUpdateGsapMeta: vi.fn(),
|
|
493
|
+
onDeleteGsapAnimation: vi.fn(),
|
|
494
|
+
onAddGsapProperty: vi.fn(),
|
|
495
|
+
onAddGsapAnimation: vi.fn(),
|
|
496
|
+
});
|
|
497
|
+
openFlatGroup(host, "Motion");
|
|
498
|
+
expect(openGroupText(host)).toContain("Add effect");
|
|
499
|
+
act(() => root.unmount());
|
|
500
|
+
});
|
|
552
501
|
});
|
|
553
502
|
|
|
554
503
|
// Whole-plan coherence fix: Layout's keyframe-seek basis and Motion's Timing
|
|
@@ -557,69 +506,55 @@ describe("PropertyPanel — Motion group (Plan 3b)", () => {
|
|
|
557
506
|
// to a naive `duration ?? 1` while Motion correctly inferred the range from
|
|
558
507
|
// the tween (position 2, duration 3 -> start 2 / duration 3 / end 5).
|
|
559
508
|
describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan coherence fix)", () => {
|
|
560
|
-
it(
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
act(() => root.unmount());
|
|
576
|
-
},
|
|
577
|
-
RENDER_TIMEOUT_MS,
|
|
578
|
-
);
|
|
509
|
+
it("Motion's Timing row shows the inferred start/end/duration for an element with animations but no explicit duration", async () => {
|
|
510
|
+
const { host, root } = await renderPanel(true, inferredMotionElement(), {
|
|
511
|
+
gsapAnimations: [INFERRED_TIMING_ANIMATION],
|
|
512
|
+
});
|
|
513
|
+
openFlatGroup(host, "Motion");
|
|
514
|
+
const motionGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
515
|
+
if (!motionGroup) throw new Error("expected the Motion group to be open");
|
|
516
|
+
expect(motionGroup.textContent).toContain("Inferred");
|
|
517
|
+
const inputs = motionGroup.querySelectorAll<HTMLInputElement>("input");
|
|
518
|
+
// FlatTimingRow renders Start, End, Duration in that order.
|
|
519
|
+
expect(inputs[0]?.value).toBe("2.00s");
|
|
520
|
+
expect(inputs[1]?.value).toBe("5.00s");
|
|
521
|
+
expect(inputs[2]?.value).toBe("3.00s");
|
|
522
|
+
act(() => root.unmount());
|
|
523
|
+
});
|
|
579
524
|
|
|
580
|
-
it(
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
true,
|
|
594
|
-
inferredMotionElement(),
|
|
595
|
-
{ gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
|
|
596
|
-
{ currentTime: 2 },
|
|
597
|
-
);
|
|
598
|
-
openFlatGroup(host, "Layout");
|
|
599
|
-
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
600
|
-
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
|
525
|
+
it("Layout's X-row keyframe gutter seeks to the SAME absolute time Motion's Timing row shows as the midpoint (50% of an inferred 2s-5s range = 3.5s)", async () => {
|
|
526
|
+
const onSeekToTime = vi.fn();
|
|
527
|
+
// The playhead sits at the clip's start (t=2, the 0% keyframe): the store's t=0 default
|
|
528
|
+
// resolves to -66.7%, outside that keyframe's window.
|
|
529
|
+
const { host, root } = await renderPanel(
|
|
530
|
+
true,
|
|
531
|
+
inferredMotionElement(),
|
|
532
|
+
{ gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
|
|
533
|
+
{ currentTime: 2 },
|
|
534
|
+
);
|
|
535
|
+
openFlatGroup(host, "Layout");
|
|
536
|
+
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
537
|
+
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
|
601
538
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
539
|
+
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
|
|
540
|
+
(el) => el.querySelector("span")?.textContent === "X",
|
|
541
|
+
);
|
|
542
|
+
if (!xRow) throw new Error("expected an X row");
|
|
543
|
+
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
|
544
|
+
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
|
545
|
+
// At currentPct=0 (playhead on the 0% keyframe) the prev arrow is
|
|
546
|
+
// disabled (no earlier keyframe) and the next arrow seeks to the 50%
|
|
547
|
+
// keyframe — exactly the case the coherence bug affected.
|
|
548
|
+
const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
|
|
549
|
+
(b) => b.title === "Next keyframe" && !b.disabled,
|
|
550
|
+
);
|
|
551
|
+
if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
|
|
552
|
+
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
616
553
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
RENDER_TIMEOUT_MS,
|
|
622
|
-
);
|
|
554
|
+
// Same basis as the Timing row: start 2 + 50% * duration 3 = 3.5.
|
|
555
|
+
expect(onSeekToTime).toHaveBeenCalledWith(3.5);
|
|
556
|
+
act(() => root.unmount());
|
|
557
|
+
});
|
|
623
558
|
});
|
|
624
559
|
|
|
625
560
|
// Follow-up fix (review of 684ec4e87): the seek-basis fix above corrected
|
|
@@ -631,80 +566,67 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe
|
|
|
631
566
|
// absolute time of the 50% keyframe (2 + 0.5*3 = 3.5) and confirm its diamond
|
|
632
567
|
// renders "active" (title="Remove x keyframe"), not "inactive"/"ghost".
|
|
633
568
|
describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up fix)", () => {
|
|
634
|
-
it(
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
645
|
-
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
|
569
|
+
it("lights the X-row keyframe diamond as active when the playhead is seeked to that keyframe's real absolute time (inferred 2s-5s range, 50% keyframe = 3.5s)", async () => {
|
|
570
|
+
const { host, root } = await renderPanel(
|
|
571
|
+
true,
|
|
572
|
+
inferredMotionElement(),
|
|
573
|
+
{ gsapAnimations: [INFERRED_TIMING_ANIMATION] },
|
|
574
|
+
{ currentTime: 3.5 },
|
|
575
|
+
);
|
|
576
|
+
openFlatGroup(host, "Layout");
|
|
577
|
+
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
578
|
+
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
|
646
579
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
// and the diamond stayed "inactive" even though the playhead was
|
|
663
|
-
// exactly on that keyframe's real time.
|
|
664
|
-
expect(diamond.title).toBe("Remove x keyframe");
|
|
665
|
-
act(() => root.unmount());
|
|
666
|
-
},
|
|
667
|
-
RENDER_TIMEOUT_MS,
|
|
668
|
-
);
|
|
580
|
+
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
|
|
581
|
+
(el) => el.querySelector("span")?.textContent === "X",
|
|
582
|
+
);
|
|
583
|
+
if (!xRow) throw new Error("expected an X row");
|
|
584
|
+
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
|
585
|
+
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
|
586
|
+
// The diamond is the only gutter button that reports a pressed state;
|
|
587
|
+
// the prev/next arrows carry titles too, so `button[title]` is ambiguous.
|
|
588
|
+
const diamond = gutter.querySelector<HTMLButtonElement>("button[aria-pressed]");
|
|
589
|
+
if (!diamond) throw new Error("expected a keyframe diamond button");
|
|
590
|
+
// "Remove" means active: on the naive 0..1 basis t=3.5 read as 350% and the diamond stayed
|
|
591
|
+
// "Add" although the playhead sat on that keyframe.
|
|
592
|
+
expect(diamond.title).toBe("Remove x keyframe");
|
|
593
|
+
act(() => root.unmount());
|
|
594
|
+
});
|
|
669
595
|
|
|
670
|
-
it(
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
682
|
-
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
|
596
|
+
it("prev/next arrows re-center on the current keyframe once currentPct agrees with the corrected seek basis", async () => {
|
|
597
|
+
const onSeekToTime = vi.fn();
|
|
598
|
+
const { host, root } = await renderPanel(
|
|
599
|
+
true,
|
|
600
|
+
inferredMotionElement(),
|
|
601
|
+
{ gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
|
|
602
|
+
{ currentTime: 3.5 },
|
|
603
|
+
);
|
|
604
|
+
openFlatGroup(host, "Layout");
|
|
605
|
+
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
|
606
|
+
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
|
683
607
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
RENDER_TIMEOUT_MS,
|
|
707
|
-
);
|
|
608
|
+
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
|
|
609
|
+
(el) => el.querySelector("span")?.textContent === "X",
|
|
610
|
+
);
|
|
611
|
+
if (!xRow) throw new Error("expected an X row");
|
|
612
|
+
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
|
613
|
+
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
|
614
|
+
const buttons = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button"));
|
|
615
|
+
const [prevArrow, , nextArrow] = buttons;
|
|
616
|
+
if (!prevArrow || !nextArrow) throw new Error("expected prev/next arrow buttons");
|
|
617
|
+
// At the 50% keyframe (t=3.5), prev should target the 0% keyframe
|
|
618
|
+
// (absolute t=2) and next should target the 100% keyframe (absolute
|
|
619
|
+
// t=5) — both only resolvable once currentPct agrees with elStart=2/
|
|
620
|
+
// elDuration=3, the same basis the seek fix already uses.
|
|
621
|
+
expect(prevArrow.disabled).toBe(false);
|
|
622
|
+
act(() => prevArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
623
|
+
expect(onSeekToTime).toHaveBeenLastCalledWith(2);
|
|
624
|
+
|
|
625
|
+
expect(nextArrow.disabled).toBe(false);
|
|
626
|
+
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
627
|
+
expect(onSeekToTime).toHaveBeenLastCalledWith(5);
|
|
628
|
+
act(() => root.unmount());
|
|
629
|
+
});
|
|
708
630
|
});
|
|
709
631
|
|
|
710
632
|
// Media fixtures (Plan 4 Task 7): the three tag values resolveEditingSections
|
|
@@ -760,126 +682,97 @@ function flatGroupTitles(host: HTMLElement): string[] {
|
|
|
760
682
|
}
|
|
761
683
|
|
|
762
684
|
describe("PropertyPanel — Grade group (flag on)", () => {
|
|
763
|
-
it(
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
act(() => root.unmount());
|
|
777
|
-
},
|
|
778
|
-
RENDER_TIMEOUT_MS,
|
|
779
|
-
);
|
|
685
|
+
it("renders the Grade group with its accessory for a grade-editable (video) element", async () => {
|
|
686
|
+
const { host, root } = await renderPanel(true, {
|
|
687
|
+
...baseElement(),
|
|
688
|
+
tagName: "video",
|
|
689
|
+
textFields: [],
|
|
690
|
+
});
|
|
691
|
+
const gradeCollapsedOrOpen =
|
|
692
|
+
host.querySelector('[data-flat-group-collapsed="true"]') ||
|
|
693
|
+
host.querySelector('[data-flat-group-open="true"]');
|
|
694
|
+
expect(host.textContent).toContain("Grade");
|
|
695
|
+
expect(gradeCollapsedOrOpen).not.toBeNull();
|
|
696
|
+
act(() => root.unmount());
|
|
697
|
+
});
|
|
780
698
|
|
|
781
|
-
it(
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
"stroke",
|
|
807
|
-
"effects",
|
|
808
|
-
"clip",
|
|
809
|
-
"transparency",
|
|
810
|
-
"fill",
|
|
811
|
-
"color-grading",
|
|
812
|
-
]) {
|
|
813
|
-
expect(host.querySelector(`[data-panel-section="${slug}"]`)).toBeNull();
|
|
814
|
-
}
|
|
815
|
-
act(() => root.unmount());
|
|
816
|
-
},
|
|
817
|
-
RENDER_TIMEOUT_MS,
|
|
818
|
-
);
|
|
699
|
+
it("does not render the legacy Style/Grade Section duplicates for a style-and-grade-editable element", async () => {
|
|
700
|
+
// A <video> with no text fields is both style- and grade-editable: the shape that also
|
|
701
|
+
// mounted the legacy ColorGradingSection and StyleSections below the flat groups.
|
|
702
|
+
const { host, root } = await renderPanel(true, {
|
|
703
|
+
...baseElement(),
|
|
704
|
+
tagName: "video",
|
|
705
|
+
textFields: [],
|
|
706
|
+
});
|
|
707
|
+
expect(host.textContent).toContain("Style");
|
|
708
|
+
expect(host.textContent).toContain("Grade");
|
|
709
|
+
// Each legacy Section renders `<section data-panel-section="<slug>">`; text alone cannot tell
|
|
710
|
+
// it from a flat row of the same name ("Fill" is both), so match that shape.
|
|
711
|
+
for (const slug of [
|
|
712
|
+
"radius",
|
|
713
|
+
"stroke",
|
|
714
|
+
"effects",
|
|
715
|
+
"clip",
|
|
716
|
+
"transparency",
|
|
717
|
+
"fill",
|
|
718
|
+
"color-grading",
|
|
719
|
+
]) {
|
|
720
|
+
expect(host.querySelector(`[data-panel-section="${slug}"]`)).toBeNull();
|
|
721
|
+
}
|
|
722
|
+
act(() => root.unmount());
|
|
723
|
+
});
|
|
819
724
|
});
|
|
820
725
|
|
|
821
726
|
describe("PropertyPanel — Media group (Plan 4)", () => {
|
|
822
|
-
it(
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
act(() => root.unmount());
|
|
835
|
-
},
|
|
836
|
-
RENDER_TIMEOUT_MS,
|
|
837
|
-
);
|
|
727
|
+
it("renders the flat Media group and not the legacy MediaSection, for a video element", async () => {
|
|
728
|
+
const { host, root } = await renderPanel(true, videoElement() as never);
|
|
729
|
+
// A Media FlatGroup exists (open or collapsed).
|
|
730
|
+
expect(flatGroupTitles(host)).toContain("Media");
|
|
731
|
+
// The legacy MediaSection renders its rows inside a `Section` whose
|
|
732
|
+
// data-panel-section slug is the media title ("video"/"image"/"audio").
|
|
733
|
+
// On the flat path it's fully replaced, so none of those may appear.
|
|
734
|
+
expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
|
|
735
|
+
expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
|
|
736
|
+
expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
|
|
737
|
+
act(() => root.unmount());
|
|
738
|
+
});
|
|
838
739
|
|
|
839
|
-
it(
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
act(() => root.unmount());
|
|
859
|
-
},
|
|
860
|
-
RENDER_TIMEOUT_MS,
|
|
861
|
-
);
|
|
740
|
+
it("one-open accordion: opening Media closes whichever other group was open, and vice versa (5-way exclusivity)", async () => {
|
|
741
|
+
// videoElement() has canEditStyles: true and no text fields, so Style is
|
|
742
|
+
// the default-open group; Layout and Media render collapsed alongside it.
|
|
743
|
+
const { host, root } = await renderPanel(true, videoElement() as never);
|
|
744
|
+
expect(openGroupText(host)).toContain("Style");
|
|
745
|
+
|
|
746
|
+
// Opening Media closes Style.
|
|
747
|
+
openFlatGroup(host, "Media");
|
|
748
|
+
const afterMedia = openGroupText(host);
|
|
749
|
+
expect(afterMedia).toContain("Media");
|
|
750
|
+
expect(afterMedia).not.toContain("Style");
|
|
751
|
+
|
|
752
|
+
// Reverse direction: opening Layout closes Media — same shared openGroupId.
|
|
753
|
+
openFlatGroup(host, "Layout");
|
|
754
|
+
const afterLayout = openGroupText(host);
|
|
755
|
+
expect(afterLayout).toContain("Layout");
|
|
756
|
+
expect(afterLayout).not.toContain("Media");
|
|
757
|
+
act(() => root.unmount());
|
|
758
|
+
});
|
|
862
759
|
|
|
863
|
-
it(
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const { host, root } = await renderPanel(true, fixture() as never);
|
|
868
|
-
expect(flatGroupTitles(host)).toContain("Media");
|
|
869
|
-
act(() => root.unmount());
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// baseElement() is a plain <div> with text — sections.media is false, so
|
|
873
|
-
// no Media group (flat or legacy) may render.
|
|
874
|
-
const { host, root } = await renderPanel(true);
|
|
875
|
-
expect(flatGroupTitles(host)).not.toContain("Media");
|
|
876
|
-
expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
|
|
877
|
-
expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
|
|
878
|
-
expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
|
|
760
|
+
it("gates the Media group exactly like the legacy MediaSection: present for video/img/audio, absent for a plain div/text element", async () => {
|
|
761
|
+
for (const fixture of [videoElement, imageElement, audioElement]) {
|
|
762
|
+
const { host, root } = await renderPanel(true, fixture() as never);
|
|
763
|
+
expect(flatGroupTitles(host)).toContain("Media");
|
|
879
764
|
act(() => root.unmount());
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// baseElement() is a plain <div> with text — sections.media is false, so
|
|
768
|
+
// no Media group (flat or legacy) may render.
|
|
769
|
+
const { host, root } = await renderPanel(true);
|
|
770
|
+
expect(flatGroupTitles(host)).not.toContain("Media");
|
|
771
|
+
expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
|
|
772
|
+
expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
|
|
773
|
+
expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
|
|
774
|
+
act(() => root.unmount());
|
|
775
|
+
});
|
|
883
776
|
});
|
|
884
777
|
|
|
885
778
|
// design_handoff scrollable-open-section: collapsed headers before/after the
|
|
@@ -891,135 +784,118 @@ describe("PropertyPanel — Media group (Plan 4)", () => {
|
|
|
891
784
|
// renders as an open header + scrollable body, grade/effects/media render as fixed
|
|
892
785
|
// collapsed headers after it — in exactly that DOM order, nothing sticky.
|
|
893
786
|
describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)", () => {
|
|
894
|
-
it(
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
act(() => root.unmount());
|
|
939
|
-
},
|
|
940
|
-
RENDER_TIMEOUT_MS,
|
|
941
|
-
);
|
|
787
|
+
it("renders before-open headers, the open group (header + scrollable body), then after-open headers, in that exact order, with nothing sticky", async () => {
|
|
788
|
+
const { host, root } = await renderPanel(true, sixGroupElement());
|
|
789
|
+
// sixGroupElement() opens Text by default; open Motion (index 3) to
|
|
790
|
+
// match the worked example.
|
|
791
|
+
openFlatGroup(host, "Motion");
|
|
792
|
+
expect(openGroupText(host)).toContain("Motion");
|
|
793
|
+
|
|
794
|
+
const body = host.querySelector('[data-flat-panel-body="true"]');
|
|
795
|
+
if (!body) throw new Error("expected the flat panel body container");
|
|
796
|
+
|
|
797
|
+
// Titles in DOM order: each child is either a collapsed header button
|
|
798
|
+
// (before/after the open group) or the open-group wrapper div.
|
|
799
|
+
const titles = Array.from(body.children).map((child) => {
|
|
800
|
+
if (child.matches('[data-flat-group-collapsed="true"]')) return child.textContent ?? "";
|
|
801
|
+
if (child.matches('[data-flat-group-open="true"]')) return child.textContent ?? "";
|
|
802
|
+
return null;
|
|
803
|
+
});
|
|
804
|
+
// Filter to just the group entries (drop any non-group nulls).
|
|
805
|
+
const groupTitles = titles.filter((t): t is string => t !== null);
|
|
806
|
+
expect(groupTitles).toHaveLength(7);
|
|
807
|
+
expect(groupTitles[0]).toContain("Text");
|
|
808
|
+
expect(groupTitles[1]).toContain("Style");
|
|
809
|
+
expect(groupTitles[2]).toContain("Layout");
|
|
810
|
+
expect(groupTitles[3]).toContain("Motion");
|
|
811
|
+
expect(groupTitles[4]).toContain("Grade");
|
|
812
|
+
expect(groupTitles[5]).toContain("Effects");
|
|
813
|
+
expect(groupTitles[6]).toContain("Media");
|
|
814
|
+
|
|
815
|
+
// The open group (Motion, index 3) is the one wrapped in
|
|
816
|
+
// data-flat-group-open, sitting between the before/after collapsed
|
|
817
|
+
// headers — and it must contain a dedicated scrollable body.
|
|
818
|
+
const openWrapper = host.querySelector('[data-flat-group-open="true"]');
|
|
819
|
+
if (!openWrapper) throw new Error("expected the open-group wrapper");
|
|
820
|
+
expect(openWrapper.textContent).toContain("Motion");
|
|
821
|
+
expect(openWrapper.querySelector(".overflow-y-auto")).not.toBeNull();
|
|
822
|
+
|
|
823
|
+
// Nothing anywhere in the panel body carries inline sticky positioning
|
|
824
|
+
// — the entire sticky-stacking mechanism is gone.
|
|
825
|
+
const stickyEls = Array.from(body.querySelectorAll<HTMLElement>("[style]")).filter(
|
|
826
|
+
(el) => el.style.position === "sticky",
|
|
827
|
+
);
|
|
828
|
+
expect(stickyEls).toHaveLength(0);
|
|
829
|
+
act(() => root.unmount());
|
|
830
|
+
});
|
|
942
831
|
|
|
943
|
-
it(
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
952
|
-
expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
|
|
832
|
+
it("renders every group as a plain collapsed header with no scrollable middle region when nothing is open", async () => {
|
|
833
|
+
const { host, root } = await renderPanel(true, sixGroupElement());
|
|
834
|
+
// Collapse the default-open Text group so openGroupId becomes "".
|
|
835
|
+
const collapseButton = host.querySelector<HTMLButtonElement>(
|
|
836
|
+
'[data-flat-group-open="true"] button[title="Collapse"]',
|
|
837
|
+
);
|
|
838
|
+
act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
839
|
+
expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
|
|
953
840
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
RENDER_TIMEOUT_MS,
|
|
975
|
-
);
|
|
841
|
+
const collapsedRows = Array.from(
|
|
842
|
+
host.querySelectorAll<HTMLButtonElement>('[data-flat-group-collapsed="true"]'),
|
|
843
|
+
);
|
|
844
|
+
expect(collapsedRows).toHaveLength(7);
|
|
845
|
+
const titlesInOrder = collapsedRows.map((el) => el.textContent ?? "");
|
|
846
|
+
expect(titlesInOrder[0]).toContain("Text");
|
|
847
|
+
expect(titlesInOrder[1]).toContain("Style");
|
|
848
|
+
expect(titlesInOrder[2]).toContain("Layout");
|
|
849
|
+
expect(titlesInOrder[3]).toContain("Motion");
|
|
850
|
+
expect(titlesInOrder[4]).toContain("Grade");
|
|
851
|
+
expect(titlesInOrder[5]).toContain("Effects");
|
|
852
|
+
expect(titlesInOrder[6]).toContain("Media");
|
|
853
|
+
|
|
854
|
+
const body = host.querySelector('[data-flat-panel-body="true"]');
|
|
855
|
+
expect(body?.querySelector(".overflow-y-auto")).toBeNull();
|
|
856
|
+
for (const row of collapsedRows) {
|
|
857
|
+
expect(row.style.position).toBe("");
|
|
858
|
+
}
|
|
859
|
+
act(() => root.unmount());
|
|
860
|
+
});
|
|
976
861
|
});
|
|
977
862
|
|
|
978
863
|
describe("PropertyPanel — flat group entrance animation scoping (fix round)", () => {
|
|
979
|
-
it(
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
// Text — the group actually clicked open — must animate too.
|
|
1015
|
-
const openWrapper = host.querySelector('[data-flat-group-open="true"]');
|
|
1016
|
-
if (!openWrapper) throw new Error("expected the open-group wrapper");
|
|
1017
|
-
expect(openWrapper.querySelector(".hf-flat-group-enter")).not.toBeNull();
|
|
1018
|
-
|
|
1019
|
-
act(() => root.unmount());
|
|
1020
|
-
},
|
|
1021
|
-
RENDER_TIMEOUT_MS,
|
|
1022
|
-
);
|
|
864
|
+
it("animates only the opening group and the implicitly-closed group on a non-adjacent toggle, never untouched siblings", async () => {
|
|
865
|
+
const { host, root } = await renderPanel(true, sixGroupElement());
|
|
866
|
+
// Text opens by default; jumping to Motion and back skips Style and Layout both ways, so they
|
|
867
|
+
// shift position in the open slices without being toggled: what justToggledIds guards.
|
|
868
|
+
openFlatGroup(host, "Motion");
|
|
869
|
+
expect(openGroupText(host)).toContain("Motion");
|
|
870
|
+
openFlatGroup(host, "Text");
|
|
871
|
+
expect(openGroupText(host)).toContain("Text");
|
|
872
|
+
|
|
873
|
+
const collapsedRowByTitle = (title: string) => {
|
|
874
|
+
const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find(
|
|
875
|
+
(el) => el.textContent?.includes(title),
|
|
876
|
+
);
|
|
877
|
+
if (!row) throw new Error(`expected a collapsed ${title} row`);
|
|
878
|
+
return row;
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// Untouched, non-adjacent siblings must NOT receive the entrance class,
|
|
882
|
+
// even though they shifted position in the collapsed-header list.
|
|
883
|
+
expect(collapsedRowByTitle("Style").classList.contains("hf-flat-group-enter")).toBe(false);
|
|
884
|
+
expect(collapsedRowByTitle("Layout").classList.contains("hf-flat-group-enter")).toBe(false);
|
|
885
|
+
expect(collapsedRowByTitle("Grade").classList.contains("hf-flat-group-enter")).toBe(false);
|
|
886
|
+
expect(collapsedRowByTitle("Media").classList.contains("hf-flat-group-enter")).toBe(false);
|
|
887
|
+
|
|
888
|
+
// Motion — open a moment ago, just implicitly closed by the click on
|
|
889
|
+
// Text — must still play its own collapse-entrance animation (Finding 1).
|
|
890
|
+
expect(collapsedRowByTitle("Motion").classList.contains("hf-flat-group-enter")).toBe(true);
|
|
891
|
+
|
|
892
|
+
// Text — the group actually clicked open — must animate too.
|
|
893
|
+
const openWrapper = host.querySelector('[data-flat-group-open="true"]');
|
|
894
|
+
if (!openWrapper) throw new Error("expected the open-group wrapper");
|
|
895
|
+
expect(openWrapper.querySelector(".hf-flat-group-enter")).not.toBeNull();
|
|
896
|
+
|
|
897
|
+
act(() => root.unmount());
|
|
898
|
+
});
|
|
1023
899
|
});
|
|
1024
900
|
|
|
1025
901
|
describe("PropertyPanel — Motion is for things that move", () => {
|
|
@@ -1033,148 +909,124 @@ describe("PropertyPanel — Motion is for things that move", () => {
|
|
|
1033
909
|
return element;
|
|
1034
910
|
},
|
|
1035
911
|
],
|
|
1036
|
-
])(
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
act(() => root.unmount());
|
|
1051
|
-
},
|
|
1052
|
-
RENDER_TIMEOUT_MS,
|
|
1053
|
-
);
|
|
912
|
+
])("recognizes %s through the shared audio predicate", async (_label, makeElement) => {
|
|
913
|
+
const fixture = {
|
|
914
|
+
...audioClipElement(),
|
|
915
|
+
element: makeElement(),
|
|
916
|
+
tagName: "div",
|
|
917
|
+
};
|
|
918
|
+
const { host, root } = await renderPanel(true, fixture);
|
|
919
|
+
const titles = Array.from(
|
|
920
|
+
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
|
|
921
|
+
).map((node) => node.textContent ?? "");
|
|
922
|
+
expect(titles.some((title) => title.includes("Motion"))).toBe(false);
|
|
923
|
+
expect(titles.some((title) => title.includes("Timing"))).toBe(true);
|
|
924
|
+
act(() => root.unmount());
|
|
925
|
+
});
|
|
1054
926
|
|
|
1055
|
-
it(
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
act(() => root.unmount());
|
|
1069
|
-
},
|
|
1070
|
-
RENDER_TIMEOUT_MS,
|
|
1071
|
-
);
|
|
927
|
+
it("calls the section Timing on an audio clip, and offers no tween editor", async () => {
|
|
928
|
+
const { host, root } = await renderPanel(true, audioClipElement());
|
|
929
|
+
const titles = Array.from(
|
|
930
|
+
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
|
|
931
|
+
).map((el) => el.textContent ?? "");
|
|
932
|
+
// The clip's placement survives — it is still a clip on a track.
|
|
933
|
+
expect(titles.some((t) => t.includes("Timing"))).toBe(true);
|
|
934
|
+
// "Motion" named the tween editor, which an <audio> element has no
|
|
935
|
+
// transform, opacity or box for. Showing it was the panel gating on
|
|
936
|
+
// handler presence rather than on the element.
|
|
937
|
+
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
|
|
938
|
+
act(() => root.unmount());
|
|
939
|
+
});
|
|
1072
940
|
|
|
1073
|
-
it(
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
act(() => root.unmount());
|
|
1086
|
-
},
|
|
1087
|
-
RENDER_TIMEOUT_MS,
|
|
1088
|
-
);
|
|
941
|
+
it("offers a mute on an audible audio clip and toggles it through the timeline", async () => {
|
|
942
|
+
const onToggleElementHidden = vi.fn();
|
|
943
|
+
const { host, root } = await renderPanel(
|
|
944
|
+
true,
|
|
945
|
+
audioClipElement(),
|
|
946
|
+
{ onToggleElementHidden },
|
|
947
|
+
{ selectedElementId: "vo-1" },
|
|
948
|
+
);
|
|
949
|
+
act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
|
|
950
|
+
expect(onToggleElementHidden).toHaveBeenCalledWith("vo-1", true);
|
|
951
|
+
act(() => root.unmount());
|
|
952
|
+
});
|
|
1089
953
|
|
|
1090
954
|
// A bus has no timeline row, so the panel is its only mute; it must go both ways.
|
|
1091
|
-
it(
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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());
|
|
955
|
+
it("mutes, unmutes and mutes an audio group from the panel header", async () => {
|
|
956
|
+
const bus = (hidden: boolean) => {
|
|
957
|
+
const element = audioBusElement();
|
|
958
|
+
return {
|
|
959
|
+
...element,
|
|
960
|
+
dataAttributes: { ...element.dataAttributes, ...(hidden ? { hidden: "" } : {}) },
|
|
1122
961
|
};
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
962
|
+
};
|
|
963
|
+
let hidden = true;
|
|
964
|
+
const onSetAudioGroupAttributeQuiet = vi.fn(
|
|
965
|
+
async (_id: string, _attr: string, value: string | null, _label: string) => {
|
|
966
|
+
hidden = value !== null;
|
|
967
|
+
},
|
|
968
|
+
);
|
|
969
|
+
// The panel re-reads its selection after the write; nothing else re-renders it here.
|
|
970
|
+
const { host, root, render } = await renderPanel(
|
|
971
|
+
true,
|
|
972
|
+
bus(true),
|
|
973
|
+
{},
|
|
974
|
+
{
|
|
975
|
+
timelineEdit: { onSetAudioGroupAttributeQuiet },
|
|
976
|
+
selectionRef: { current: bus(true) },
|
|
977
|
+
refreshSelection: () => render(bus(hidden)),
|
|
978
|
+
},
|
|
979
|
+
);
|
|
980
|
+
const press = async (label: string) => {
|
|
981
|
+
const button = host.querySelector<HTMLElement>(`button[aria-label="${label}"]`);
|
|
982
|
+
if (!button) throw new Error(`Expected a "${label}" button`);
|
|
983
|
+
await act(async () => button.click());
|
|
984
|
+
};
|
|
985
|
+
await press("Unmute element");
|
|
986
|
+
await press("Mute element");
|
|
987
|
+
await press("Unmute element");
|
|
988
|
+
expect(onSetAudioGroupAttributeQuiet.mock.calls).toEqual([
|
|
989
|
+
["voiceover", "data-hidden", null, "Unmute element"],
|
|
990
|
+
["voiceover", "data-hidden", "", "Mute element"],
|
|
991
|
+
["voiceover", "data-hidden", null, "Unmute element"],
|
|
992
|
+
]);
|
|
993
|
+
act(() => root.unmount());
|
|
994
|
+
});
|
|
1135
995
|
|
|
1136
|
-
it(
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
{},
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
act(() => root.unmount());
|
|
1161
|
-
},
|
|
1162
|
-
RENDER_TIMEOUT_MS,
|
|
1163
|
-
);
|
|
996
|
+
it("re-reads the current selection, not the muted bus, once the write lands", async () => {
|
|
997
|
+
let land = () => {};
|
|
998
|
+
const onSetAudioGroupAttributeQuiet = vi.fn(
|
|
999
|
+
() => new Promise<void>((resolve) => (land = resolve)),
|
|
1000
|
+
);
|
|
1001
|
+
const selectionRef: { current: unknown } = { current: audioBusElement() };
|
|
1002
|
+
const refreshSelection = vi.fn();
|
|
1003
|
+
const { host, root } = await renderPanel(
|
|
1004
|
+
true,
|
|
1005
|
+
audioBusElement(),
|
|
1006
|
+
{},
|
|
1007
|
+
{
|
|
1008
|
+
timelineEdit: { onSetAudioGroupAttributeQuiet },
|
|
1009
|
+
selectionRef,
|
|
1010
|
+
refreshSelection,
|
|
1011
|
+
},
|
|
1012
|
+
);
|
|
1013
|
+
act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
|
|
1014
|
+
const next = audioClipElement();
|
|
1015
|
+
selectionRef.current = next;
|
|
1016
|
+
await act(async () => land());
|
|
1017
|
+
expect(refreshSelection.mock.calls).toEqual([[next]]);
|
|
1018
|
+
act(() => root.unmount());
|
|
1019
|
+
});
|
|
1164
1020
|
|
|
1165
|
-
it(
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
act(() => root.unmount());
|
|
1177
|
-
},
|
|
1178
|
-
RENDER_TIMEOUT_MS,
|
|
1179
|
-
);
|
|
1021
|
+
it("offers a bus neither — it has no clip range to edit", async () => {
|
|
1022
|
+
const { host, root } = await renderPanel(true, audioBusElement());
|
|
1023
|
+
const titles = Array.from(
|
|
1024
|
+
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
|
|
1025
|
+
).map((el) => el.textContent ?? "");
|
|
1026
|
+
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
|
|
1027
|
+
expect(titles.some((t) => t.includes("Timing"))).toBe(false);
|
|
1028
|
+
// It is still a mixer bus: the reason to select one at all.
|
|
1029
|
+
expect(titles.some((t) => t.includes("Audio FX"))).toBe(true);
|
|
1030
|
+
act(() => root.unmount());
|
|
1031
|
+
});
|
|
1180
1032
|
});
|