mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(studio): stop popovers and tooltips clipping at panel edges (#2890)
The Renders tab format popover rendered as an in-flow absolute panel inside the right panel, which is overflow-hidden, so it was sliced at the panel edge. Portal it to the body and position it with the shared floating-panel helper instead. The ui/Tooltip bubble clamped only its centre point to the viewport, so a wide bubble near an edge still hung off-screen (the timeline Selection tool tooltip lost 32px on the left). Clamp with the measured bubble width.
This commit is contained in:
@@ -1,5 +1,24 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveFloatingPanelPosition } from "./floatingPanel";
|
import { clampCentredLeft, resolveFloatingPanelPosition } from "./floatingPanel";
|
||||||
|
|
||||||
|
describe("clampCentredLeft", () => {
|
||||||
|
it("leaves a bubble that already fits alone", () => {
|
||||||
|
expect(clampCentredLeft(400, 104, 800, 8)).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pushes a bubble whose left half would leave the viewport", () => {
|
||||||
|
// Trigger centred at x=20 with a 104px bubble would render at left=-32.
|
||||||
|
expect(clampCentredLeft(20, 104, 800, 8)).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pushes a bubble whose right half would leave the viewport", () => {
|
||||||
|
expect(clampCentredLeft(790, 104, 800, 8)).toBe(740);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the left edge visible when the bubble is wider than the viewport", () => {
|
||||||
|
expect(clampCentredLeft(10, 900, 800, 8)).toBe(458);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveFloatingPanelPosition", () => {
|
describe("resolveFloatingPanelPosition", () => {
|
||||||
it("places the panel below the anchor when there is space", () => {
|
it("places the panel below the anchor when there is space", () => {
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ function clamp(value: number, min: number, max: number): number {
|
|||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp the centre point of a centred bubble so the whole bubble stays in the
|
||||||
|
* viewport: clamping the centre alone lets a wide bubble hang off the edge.
|
||||||
|
*/
|
||||||
|
export function clampCentredLeft(
|
||||||
|
centreX: number,
|
||||||
|
bubbleWidth: number,
|
||||||
|
viewportWidth: number,
|
||||||
|
margin: number,
|
||||||
|
): number {
|
||||||
|
const half = bubbleWidth / 2;
|
||||||
|
const min = half + margin;
|
||||||
|
return clamp(centreX, min, Math.max(min, viewportWidth - half - margin));
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveFloatingPanelPosition(
|
export function resolveFloatingPanelPosition(
|
||||||
anchor: FloatingRect,
|
anchor: FloatingRect,
|
||||||
viewport: FloatingSize,
|
viewport: FloatingSize,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { memo, useState, useRef, useEffect, useId } from "react";
|
import { memo, useState, useRef, useEffect, useLayoutEffect, useId } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
|
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
|
||||||
import { RenderQueueItem } from "./RenderQueueItem";
|
import { RenderQueueItem } from "./RenderQueueItem";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
|
import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
|
||||||
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
||||||
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
||||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||||
@@ -132,12 +134,20 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Estimated, like COLOR_PICKER_SIZE in propertyPanelColor: only the flip
|
||||||
|
// decision uses the height, and the clamp keeps the panel on screen either way.
|
||||||
|
const FORMAT_PANEL_SIZE = { width: 208, height: 150 };
|
||||||
|
|
||||||
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a
|
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a
|
||||||
// real button (focusable, labelled), the panel is tied to it via
|
// real button (focusable, labelled), the panel is tied to it via
|
||||||
// aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich
|
// aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich
|
||||||
// for the one-line ui/Tooltip primitive, so this stays a local popover.
|
// for the one-line ui/Tooltip primitive, so this stays a local popover.
|
||||||
|
// It renders in a portal because the right panel is overflow-hidden: an
|
||||||
|
// in-flow absolute panel gets clipped at the panel edge.
|
||||||
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [position, setPosition] = useState<FloatingPosition | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const panelId = useId();
|
const panelId = useId();
|
||||||
|
|
||||||
@@ -151,6 +161,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|||||||
|
|
||||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||||
|
|
||||||
|
// Positioned once on open, so it does not follow panel scroll. The popover
|
||||||
|
// is hover-lived; add a scroll listener only if that ever shows up.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const el = triggerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setPosition(
|
||||||
|
resolveFloatingPanelPosition(
|
||||||
|
el.getBoundingClientRect(),
|
||||||
|
{ width: window.innerWidth, height: window.innerHeight },
|
||||||
|
FORMAT_PANEL_SIZE,
|
||||||
|
{ offset: 6 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -163,7 +189,7 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|||||||
const info = FORMAT_INFO[format];
|
const info = FORMAT_INFO[format];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" onPointerEnter={show} onPointerLeave={hide}>
|
<div ref={triggerRef} className="relative" onPointerEnter={show} onPointerLeave={hide}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="About video formats"
|
aria-label="About video formats"
|
||||||
@@ -190,11 +216,15 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open &&
|
||||||
|
createPortal(
|
||||||
<div
|
<div
|
||||||
id={panelId}
|
id={panelId}
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50"
|
onPointerEnter={show}
|
||||||
|
onPointerLeave={hide}
|
||||||
|
className="fixed w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-[200]"
|
||||||
|
style={{ left: position?.left ?? -9999, top: position?.top ?? -9999 }}
|
||||||
>
|
>
|
||||||
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
|
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
|
||||||
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
|
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
|
||||||
@@ -209,7 +239,8 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react";
|
import {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useId,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { clampCentredLeft } from "../editor/floatingPanel";
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -19,6 +28,8 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|||||||
const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
|
const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [bubbleWidth, setBubbleWidth] = useState(0);
|
||||||
// WCAG 4.1.2: programmatically associate the bubble with its trigger.
|
// WCAG 4.1.2: programmatically associate the bubble with its trigger.
|
||||||
const tooltipId = useId();
|
const tooltipId = useId();
|
||||||
|
|
||||||
@@ -40,13 +51,11 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|||||||
) {
|
) {
|
||||||
nextSide = "top";
|
nextSide = "top";
|
||||||
}
|
}
|
||||||
const x = Math.min(
|
|
||||||
Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN),
|
|
||||||
window.innerWidth - VIEWPORT_MARGIN,
|
|
||||||
);
|
|
||||||
setResolvedSide(nextSide);
|
setResolvedSide(nextSide);
|
||||||
setPos({
|
setPos({
|
||||||
x,
|
// Raw trigger centre; clamped to the viewport at render, once the
|
||||||
|
// bubble's own width is known (see clampedX).
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
|
y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
|
||||||
});
|
});
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
@@ -61,6 +70,12 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|||||||
setVisible(false);
|
setVisible(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Measure before paint so a wide bubble near a viewport edge is clamped in
|
||||||
|
// the same commit it appears in (no visible jump).
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setBubbleWidth(visible ? (bubbleRef.current?.offsetWidth ?? 0) : 0);
|
||||||
|
}, [visible, label]);
|
||||||
|
|
||||||
// WCAG 1.4.13: tooltip content must be dismissible with Escape.
|
// WCAG 1.4.13: tooltip content must be dismissible with Escape.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
@@ -71,6 +86,8 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [visible, hide]);
|
}, [visible, hide]);
|
||||||
|
|
||||||
|
const clampedX = clampCentredLeft(pos.x, bubbleWidth, window.innerWidth, VIEWPORT_MARGIN);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
@@ -89,12 +106,13 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|||||||
<div
|
<div
|
||||||
className="fixed z-[200] pointer-events-none"
|
className="fixed z-[200] pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: pos.x,
|
left: clampedX,
|
||||||
top: pos.y,
|
top: pos.y,
|
||||||
transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
|
transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={bubbleRef}
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
id={tooltipId}
|
id={tooltipId}
|
||||||
className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg"
|
className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg"
|
||||||
|
|||||||
Reference in New Issue
Block a user