diff --git a/packages/studio/src/components/ui/Tooltip.tsx b/packages/studio/src/components/ui/Tooltip.tsx index 247ee547a0..70d312395f 100644 --- a/packages/studio/src/components/ui/Tooltip.tsx +++ b/packages/studio/src/components/ui/Tooltip.tsx @@ -14,6 +14,7 @@ interface TooltipProps { children: ReactElement<{ "aria-describedby"?: string }>; /** Hover delay in ms. */ delay?: number; + disabled?: boolean; side?: "top" | "bottom" | "left" | "right"; } @@ -21,16 +22,22 @@ interface TooltipProps { const SIDE_OFFSET = 6; const VIEWPORT_MARGIN = 8; -export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) { +export function Tooltip({ + label, + children, + delay = 400, + side = "top", + disabled = false, +}: TooltipProps) { const [open, setOpen] = useState(false); const boxRef = useRef(null); const tooltipId = useId(); return ( - + }> {cloneElement(children, { - "aria-describedby": open ? tooltipId : children.props["aria-describedby"], + "aria-describedby": open && !disabled ? tooltipId : children.props["aria-describedby"], })} diff --git a/packages/studio/src/player/components/ClipPeakMarks.test.tsx b/packages/studio/src/player/components/ClipPeakMarks.test.tsx index 9c16f3b012..4adfd1f4f5 100644 --- a/packages/studio/src/player/components/ClipPeakMarks.test.tsx +++ b/packages/studio/src/player/components/ClipPeakMarks.test.tsx @@ -3,6 +3,9 @@ import { act } from "react"; import { describe, expect, it, vi } from "vitest"; import { createHappyDomRootHarness } from "./testRootHarness"; import { ClipPeakMarks } from "./ClipPeakMarks"; +import { TimelineClip } from "./TimelineClip"; + +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); const harness = createHappyDomRootHarness(); @@ -13,29 +16,70 @@ async function render(url: string, bins: number[], gain: number) { ); const host = document.createElement("div"); document.body.appendChild(host); - await act(async () => { - harness.mount(host).render( - + const root = harness.mount(host); + const view = (volume: number) => ( + {}} + onHoverEnd={() => {}} + onClick={() => {}} + onDoubleClick={() => {}} + > + wave - , - ); - }); + + + ); + await act(async () => root.render(view(gain))); await act(async () => {}); vi.unstubAllGlobals(); - return host; + return { host, setGain: async (value: number) => act(async () => root.render(view(value))) }; } describe("ClipPeakMarks", () => { - it("paints red marks and the peak on a clip that redlines", async () => { - const host = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1); + it("uses a plain warning and describes the focused clip with the numeric peak", async () => { + const { host } = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1); expect(host.textContent).toContain("wave"); expect(host.querySelector("[data-testid=clip-peak-marks]")?.textContent).toContain( - "▲ peaks −0.2 dBFS", + "▲ Too loud", ); + expect(host.querySelector("[data-peak-text]")?.textContent).not.toContain("dBFS"); + const clip = host.querySelector("button")!; + await act(async () => clip.focus()); + await vi.waitFor(() => + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe( + "Peaks −0.2 dBFS at this volume; export lowers the whole mix", + ), + ); + expect(clip.getAttribute("aria-describedby")).toBe( + document.querySelector('[role="tooltip"]')?.id, + ); + expect(host.querySelectorAll("button")).toHaveLength(1); + }); + + it("removes the focused warning when volume becomes quiet without replacing the clip", async () => { + const { host, setGain } = await render("/api/projects/p/peaks/changing.mp4", [1, 1], 1); + const clip = host.querySelector("button")!; + await act(async () => clip.focus()); + await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull()); + await setGain(0.25); + await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull()); + expect(clip.getAttribute("aria-describedby")).toBeNull(); + expect(document.activeElement).toBe(clip); + expect(host.querySelector("button")).toBe(clip); + expect(host.querySelector("[data-peak-badge]")).toBeNull(); }); it("paints nothing on a quiet clip", async () => { - const host = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1); + const { host } = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1); expect(host.querySelector("[data-testid=clip-peak-marks]")).toBeNull(); }); }); diff --git a/packages/studio/src/player/components/ClipPeakMarks.tsx b/packages/studio/src/player/components/ClipPeakMarks.tsx index 73d5bc6c80..1d37186718 100644 --- a/packages/studio/src/player/components/ClipPeakMarks.tsx +++ b/packages/studio/src/player/components/ClipPeakMarks.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useContext, useEffect, useState, type ReactNode } from "react"; import { clipPeakRuns, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns"; import { loadPeakMap } from "./clipPeakMap"; +import { ClipPeakDescriptionContext } from "./ClipPeakTooltip"; const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`; @@ -30,15 +31,20 @@ export function ClipPeakMarks({ const { runs, peakDbfs } = map ? clipPeakRuns(map, sourceWindow, gain) : { runs: [], peakDbfs: null }; + const detail = + runs.length > 0 && peakDbfs !== null + ? `Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix` + : null; + const setDetail = useContext(ClipPeakDescriptionContext); + useEffect(() => { + setDetail?.(detail); + return () => setDetail?.(null); + }, [detail, setDetail]); return ( -
+
{children} - {runs.length > 0 && peakDbfs !== null && ( -
+ {detail !== null && ( +
{runs.map((run) => (
))} - ▲ peaks {dbText(peakDbfs)} + ▲ Too loud
)} diff --git a/packages/studio/src/player/components/ClipPeakTooltip.tsx b/packages/studio/src/player/components/ClipPeakTooltip.tsx new file mode 100644 index 0000000000..86124a80c1 --- /dev/null +++ b/packages/studio/src/player/components/ClipPeakTooltip.tsx @@ -0,0 +1,21 @@ +import { cloneElement, createContext, useState, type ReactElement } from "react"; +import { Tooltip } from "../../components/ui/Tooltip"; + +export const ClipPeakDescriptionContext = createContext<((detail: string | null) => void) | null>( + null, +); + +export function ClipPeakTooltip({ + children, +}: { + children: ReactElement<{ title?: string; "aria-describedby"?: string }>; +}) { + const [detail, setDetail] = useState(null); + return ( + + + {cloneElement(children, { title: detail === null ? children.props.title : undefined })} + + + ); +} diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 43275fb645..d8702d8a64 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -87,11 +87,8 @@ describe("timeline viewport geometry", () => { }); }); -/** The track's content row: a clip's parent, past its `display: contents` wrapper. */ function trackContentOf(clip: HTMLElement | null | undefined): HTMLElement | null { - let row = clip?.parentElement ?? null; - while (row?.style.display === "contents") row = row.parentElement; - return row; + return clip?.closest('[role="gridcell"]') ?? null; } function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) { diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index 0766bd76ab..29aed434a9 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -16,6 +16,7 @@ import { ClipBadges } from "./ClipBadges"; import { linkLabelColor } from "./linkLabelColor"; import { OutOfSyncBadge } from "./OutOfSyncBadge"; import { clipSpeedSuffix } from "./clipToolAttrs"; +import { ClipPeakTooltip } from "./ClipPeakTooltip"; interface TimelineClipProps { el: TimelineElement; @@ -128,7 +129,7 @@ export const TimelineClip = memo(function TimelineClip({ ...passengerStyle, }; - return ( + const clip = (