diff --git a/packages/studio/src/components/editor/floatingPanel.test.ts b/packages/studio/src/components/editor/floatingPanel.test.ts index 98145d693..ee2c20810 100644 --- a/packages/studio/src/components/editor/floatingPanel.test.ts +++ b/packages/studio/src/components/editor/floatingPanel.test.ts @@ -1,5 +1,24 @@ 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", () => { it("places the panel below the anchor when there is space", () => { diff --git a/packages/studio/src/components/editor/floatingPanel.ts b/packages/studio/src/components/editor/floatingPanel.ts index 695d32274..368df9cd7 100644 --- a/packages/studio/src/components/editor/floatingPanel.ts +++ b/packages/studio/src/components/editor/floatingPanel.ts @@ -22,6 +22,21 @@ function clamp(value: number, min: number, max: number): number { 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( anchor: FloatingRect, viewport: FloatingSize, diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 46f1958bc..1c7adc9d8 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -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 { RenderQueueItem } from "./RenderQueueItem"; import { Button } from "../ui/Button"; +import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel"; import type { RenderJob, ResolutionPreset } from "./useRenderQueue"; import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings"; 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 // real button (focusable, labelled), the panel is tied to it via // 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. +// 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" }) { const [open, setOpen] = useState(false); + const [position, setPosition] = useState(null); + const triggerRef = useRef(null); const timeoutRef = useRef>(undefined); const panelId = useId(); @@ -151,6 +161,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) { 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(() => { if (!open) return; const onKeyDown = (e: KeyboardEvent) => { @@ -163,7 +189,7 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) { const info = FORMAT_INFO[format]; return ( -
+
- {open && ( - - )} + {open && + createPortal( + , + document.body, + )}
); } diff --git a/packages/studio/src/components/ui/Tooltip.tsx b/packages/studio/src/components/ui/Tooltip.tsx index 66615b96b..f1f717bb4 100644 --- a/packages/studio/src/components/ui/Tooltip.tsx +++ b/packages/studio/src/components/ui/Tooltip.tsx @@ -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 { clampCentredLeft } from "../editor/floatingPanel"; interface TooltipProps { label: string; @@ -19,6 +28,8 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side); const timerRef = useRef | null>(null); const triggerRef = useRef(null); + const bubbleRef = useRef(null); + const [bubbleWidth, setBubbleWidth] = useState(0); // WCAG 4.1.2: programmatically associate the bubble with its trigger. const tooltipId = useId(); @@ -40,13 +51,11 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP ) { nextSide = "top"; } - const x = Math.min( - Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN), - window.innerWidth - VIEWPORT_MARGIN, - ); setResolvedSide(nextSide); 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, }); setVisible(true); @@ -61,6 +70,12 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP 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. useEffect(() => { if (!visible) return; @@ -71,6 +86,8 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP return () => document.removeEventListener("keydown", onKeyDown); }, [visible, hide]); + const clampedX = clampCentredLeft(pos.x, bubbleWidth, window.innerWidth, VIEWPORT_MARGIN); + return ( <>