diff --git a/packages/studio/src/player/components/TimelineFxButton.test.tsx b/packages/studio/src/player/components/TimelineFxButton.test.tsx index 077f33dbf3..2d037ceb98 100644 --- a/packages/studio/src/player/components/TimelineFxButton.test.tsx +++ b/packages/studio/src/player/components/TimelineFxButton.test.tsx @@ -68,6 +68,37 @@ describe("TimelineFxButton", () => { expect(document.querySelector('[role="dialog"]')).toBeTruthy(); }); + // The reported symptom was "the grouping button did nothing". The dialog WAS + // opening — it positioned at `anchorRect.bottom + 4` with no flip and no + // clamp, and this button lives in a track header at the bottom of the studio + // window, so it opened past the viewport edge. The test below it passed + // throughout: happy-dom reports an all-zero rect for an unlaid-out button, + // which lands the dialog at top:4 — on screen, and nothing like the app. + it("flips the group dialog above the anchor when there is no room below", () => { + const host = mount(); + const fx = byTextButton(host, "FX"); + // A track header near the bottom edge of the (1024x768) window. + fx!.getBoundingClientRect = () => + ({ left: 300, top: 760, right: 320, bottom: 776, width: 20, height: 16 }) as DOMRect; + act(() => fx?.click()); + const dialog = document.querySelector('[role="dialog"]') as HTMLElement; + expect(dialog).toBeTruthy(); + // Above the anchor, and fully inside the viewport: 644 + 112 === 756. + expect(dialog.style.top).toBe("644px"); + }); + + it("keeps the group dialog inside the right edge of the window", () => { + const host = mount(); + const fx = byTextButton(host, "FX"); + // Hard against the right edge: 224px wide + a 12px margin has to fit. + fx!.getBoundingClientRect = () => + ({ left: 1010, top: 100, right: 1024, bottom: 116, width: 14, height: 16 }) as DOMRect; + act(() => fx?.click()); + const dialog = document.querySelector('[role="dialog"]') as HTMLElement; + expect(dialog.style.left).toBe("788px"); + expect(dialog.style.top).toBe("120px"); + }); + it("group-pointer variant offers Group instead of a popover", () => { const onGroupClips = vi.fn(); const host = mount(); diff --git a/packages/studio/src/player/components/TimelineFxButton.tsx b/packages/studio/src/player/components/TimelineFxButton.tsx index b874ecb18c..65070df804 100644 --- a/packages/studio/src/player/components/TimelineFxButton.tsx +++ b/packages/studio/src/player/components/TimelineFxButton.tsx @@ -18,6 +18,24 @@ import { } from "@hyperframes/core/audio-fx"; import type { HfAudioNameKind } from "@hyperframes/core/audio-carve"; import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js"; +import { resolveFloatingPanelPosition } from "../../components/editor/floatingPanel.js"; + +// Estimated, like FORMAT_PANEL_SIZE in RenderQueue: `w-56` is exact, and only +// the flip decision uses the height — the clamp keeps the dialog on screen +// either way. +const GROUP_DIALOG_SIZE = { width: 224, height: 112 }; + +/** Where the grouping dialog goes: flipped above the anchor when there is no + * room below, and clamped so neither edge leaves the viewport. */ +function groupDialogPosition(anchorRect: DOMRect): { left: number; top: number } { + const { left, top } = resolveFloatingPanelPosition( + anchorRect, + { width: window.innerWidth, height: window.innerHeight }, + GROUP_DIALOG_SIZE, + { offset: 4 }, + ); + return { left, top }; +} function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain { if (!raw) return { version: 1, nodes: [] }; @@ -80,7 +98,12 @@ export function TimelineFxButton(props: TimelineFxButtonProps) { role="dialog" aria-label="Group these clips to add effects" className="z-[200] w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl" - style={{ position: "fixed", left: anchorRect.left, top: anchorRect.bottom + 4 }} + // Was `top: anchorRect.bottom + 4` with no flip and no clamp. This + // button lives in a track header at the BOTTOM of the studio + // window, so the dialog opened past the viewport edge and the + // click read as doing nothing at all. Same helper the other body + // portals position with. + style={{ position: "fixed", ...groupDialogPosition(anchorRect) }} onPointerDown={(event) => event.stopPropagation()} >

Group these clips to add effects to all of them.