fix(web): portal overlays into fullscreenElement so they render in voice fullscreen

requestFullscreen() on the voice container puts only its descendants in the
browser's top layer; overlays portaled to document.body were rendered outside
that layer and stayed invisible — most visibly the right-click context menu on
stream tiles and voice user panels.

Add usePortalContainer() hook returning document.fullscreenElement ?? document.body
and re-rendering on fullscreenchange. Migrate every overlay reachable during a
call: ContextMenuRenderer (desktop, submenu, mobile sheet), Tooltip,
ConfirmDialog, ConnectionInfoPopover, ScreenShareSettingsPopover, and
ScreenSharePicker (which previously rendered inline at App root).
This commit is contained in:
Jannis Braun
2026-04-29 23:32:14 +02:00
parent 1ed70a90b1
commit e0861597bc
9 changed files with 67 additions and 9 deletions
@@ -1,5 +1,6 @@
import React, { useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { usePortalContainer } from '../../hooks/usePortalContainer';
interface ConfirmDialogProps {
isOpen: boolean;
@@ -38,6 +39,8 @@ export function ConfirmDialog({
}
}, [isOpen, handleKeyDown]);
const portalContainer = usePortalContainer();
if (!isOpen) return null;
const isDanger = variant === 'danger';
@@ -74,6 +77,6 @@ export function ConfirmDialog({
</div>
</div>
</div>,
document.body,
portalContainer,
);
}
@@ -9,6 +9,7 @@ import {
type ContextMenuSubmenu,
} from '../../stores/contextMenuStore';
import { useUIStore } from '../../stores/uiStore';
import { usePortalContainer } from '../../hooks/usePortalContainer';
// ── Desktop item button ──────────────────────────────────────────────────────
@@ -83,6 +84,7 @@ interface SubmenuFlyoutProps {
function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close }: SubmenuFlyoutProps) {
const flyoutRef = useRef<HTMLDivElement>(null);
const portalContainer = usePortalContainer();
const filteredChildren = filterMenuItems(submenu.children);
useLayoutEffect(() => {
@@ -125,7 +127,7 @@ function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close
<DesktopLeafItem key={child.key} item={child} close={close} />
))}
</div>,
document.body,
portalContainer,
);
}
@@ -281,6 +283,7 @@ interface DesktopMenuProps {
function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const portalContainer = usePortalContainer();
// Viewport-aware positioning via direct DOM mutation
useLayoutEffect(() => {
@@ -411,7 +414,7 @@ function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) {
))}
</div>
</>,
document.body,
portalContainer,
);
}
@@ -463,6 +466,7 @@ interface MobileMenuProps {
function MobileMenu({ items, close }: MobileMenuProps) {
const [submenuStack, setSubmenuStack] = useState<ContextMenuSubmenu | null>(null);
const portalContainer = usePortalContainer();
// Dismiss on scroll/resize
useEffect(() => {
@@ -527,7 +531,7 @@ function MobileMenu({ items, close }: MobileMenuProps) {
</div>
</div>
</>,
document.body,
portalContainer,
);
}
+3 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { usePortalContainer } from '../../hooks/usePortalContainer';
import { useUIStore } from '../../stores/uiStore';
interface TooltipProps {
@@ -19,6 +20,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const anchorRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
const portalContainer = usePortalContainer();
const { style } = useFloatingPosition(anchorRef, floatingRef, {
placement: position,
@@ -52,7 +54,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
>
{content}
</div>,
document.body,
portalContainer,
)}
</div>
);
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { usePortalContainer } from '../../hooks/usePortalContainer';
interface ConnectionInfoPopoverProps {
open: boolean;
@@ -118,6 +119,7 @@ function VideoTrackRow({ track }: { track: VideoTrackStat }) {
export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const portalContainer = usePortalContainer();
const stats = useTrackStats(open);
const { style } = useFloatingPosition(anchorRef, popoverRef, {
@@ -211,6 +213,6 @@ export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionIn
)}
</div>
</div>,
document.body,
portalContainer,
);
}
@@ -1,7 +1,9 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { create } from 'zustand';
import { getElectronAPI } from '../../platform/platform';
import { useVoiceStore } from '../../stores/voiceStore';
import { usePortalContainer } from '../../hooks/usePortalContainer';
// ---------------------------------------------------------------------------
// Zustand micro-store — bridges the event-driven API to React state
@@ -40,6 +42,7 @@ export function ScreenSharePicker() {
const [search, setSearch] = useState('');
const shareAudio = useVoiceStore((s) => s.screenShareConfig.shareAudio);
const setScreenShareConfig = useVoiceStore((s) => s.setScreenShareConfig);
const portalContainer = usePortalContainer();
// Register listener for sources from main process (once on mount)
useEffect(() => {
@@ -100,7 +103,7 @@ export function ScreenSharePicker() {
const activeSources = activeTab === 'screens' ? screens : windows;
return (
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center animate-fade-in">
{/* Backdrop */}
<div
@@ -211,7 +214,8 @@ export function ScreenSharePicker() {
</div>
</div>
</div>
</div>
</div>,
portalContainer,
);
}
@@ -5,6 +5,7 @@ import type { ScreenShareConfig } from '../../stores/voiceStore';
import { useSettingsStore } from '../../stores/settingsStore';
import { buildScreenShareOptions } from '../../utils/screenShare';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { usePortalContainer } from '../../hooks/usePortalContainer';
import { Toggle } from '../ui/Toggle';
import { isElectron } from '../../platform/platform';
import { RESOLUTION_LABELS } from '@backspace/shared/src/constants';
@@ -46,6 +47,7 @@ function formatKbps(kbps: number): string {
export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenShareSettingsPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const portalContainer = usePortalContainer();
const config = useVoiceStore((s) => s.screenShareConfig);
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
const hwOverdrive = useVoiceStore((s) => s.hwOverdrive);
@@ -296,6 +298,6 @@ export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenS
</span>
</div>
</div>,
document.body,
portalContainer,
);
}
@@ -0,0 +1,31 @@
import { useSyncExternalStore } from 'react';
// When an element enters browser fullscreen via the Fullscreen API, the user
// agent renders only that element and its descendants. Anything portaled to
// `document.body` (the conventional overlay target) is rendered outside the
// fullscreen layer and is therefore invisible while fullscreen is active.
//
// Overlays that need to remain visible across fullscreen transitions (context
// menus, tooltips, popovers, modals, the screen-share picker) must portal into
// `document.fullscreenElement` while it is non-null and into `document.body`
// otherwise. This hook provides a reactive container that reflects the current
// fullscreen state and re-renders subscribers on every `fullscreenchange`.
function subscribe(onChange: () => void): () => void {
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}
function getSnapshot(): Element {
return document.fullscreenElement ?? document.body;
}
function getServerSnapshot(): Element {
// Vite SPA — never invoked, but useSyncExternalStore requires a server snapshot
// for type completeness. Return body so SSR-style renders never crash.
return document.body;
}
export function usePortalContainer(): Element {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}