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:
@@ -63,6 +63,8 @@ Font: DM Sans (primary) with system fallbacks
|
|||||||
|
|
||||||
**Modal backdrops:** `bg-black/50` — light enough for glass blur to show through.
|
**Modal backdrops:** `bg-black/50` — light enough for glass blur to show through.
|
||||||
|
|
||||||
|
**Portal target — `usePortalContainer()`:** Every overlay (context menu, tooltip, popover, modal, screen-share picker) MUST portal through `usePortalContainer()` (`packages/web/src/hooks/usePortalContainer.ts`) instead of hard-coding `document.body`. The hook returns `document.fullscreenElement ?? document.body` and re-renders subscribers on `fullscreenchange`. Without this, anything portaled while an element (e.g. the voice container in fullscreen mode) is in the browser's Fullscreen API top-layer is rendered outside that layer and is invisible. Components mounted at App root that render with `fixed inset-0` (not just portals) must also portal through this hook for the same reason.
|
||||||
|
|
||||||
### Glass Material Properties
|
### Glass Material Properties
|
||||||
```css
|
```css
|
||||||
.glass {
|
.glass {
|
||||||
|
|||||||
@@ -236,6 +236,14 @@ ScreenShareConfig {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Voice Fullscreen
|
||||||
|
|
||||||
|
The fullscreen toggle in `VoiceControlBar` flips the `voiceFullscreen` flag in `uiStore`; an effect in `MainContent.tsx` calls `voiceContainerRef.current.requestFullscreen()` (and exits via `document.exitFullscreen()` when the flag clears). A second effect listens to `fullscreenchange` and reflects the actual `document.fullscreenElement` back into the store, so pressing Esc or system-level fullscreen-exit keeps state in sync. `voiceChatOpen && !voiceFullscreen` hides the side chat panel while fullscreen is active.
|
||||||
|
|
||||||
|
**Overlay portals:** While fullscreen is active the browser's Fullscreen API renders only descendants of `voiceContainerRef`. Every overlay reachable during a call (context menus on `StreamTile`/`VoiceUser`/`VoiceChannel`, tooltips on the control bar, `ConnectionInfoPopover`, `ScreenShareSettingsPopover`, `ConfirmDialog` invoked from voice context-menu actions, and `ScreenSharePicker`) portals through `usePortalContainer()` so it lands inside the fullscreen element. Adding new overlays that can be opened from inside the call must follow the same contract — see `docs/systems/design-system.md` Surface Material Tiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Audio Processing
|
## Audio Processing
|
||||||
|
|
||||||
| Feature | Default | User Control | Notes |
|
| Feature | Default | User Control | Notes |
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useCallback } from 'react';
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
import { usePortalContainer } from '../../hooks/usePortalContainer';
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -38,6 +39,8 @@ export function ConfirmDialog({
|
|||||||
}
|
}
|
||||||
}, [isOpen, handleKeyDown]);
|
}, [isOpen, handleKeyDown]);
|
||||||
|
|
||||||
|
const portalContainer = usePortalContainer();
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const isDanger = variant === 'danger';
|
const isDanger = variant === 'danger';
|
||||||
@@ -74,6 +77,6 @@ export function ConfirmDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
portalContainer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ContextMenuSubmenu,
|
type ContextMenuSubmenu,
|
||||||
} from '../../stores/contextMenuStore';
|
} from '../../stores/contextMenuStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { usePortalContainer } from '../../hooks/usePortalContainer';
|
||||||
|
|
||||||
// ── Desktop item button ──────────────────────────────────────────────────────
|
// ── Desktop item button ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ interface SubmenuFlyoutProps {
|
|||||||
|
|
||||||
function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close }: SubmenuFlyoutProps) {
|
function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close }: SubmenuFlyoutProps) {
|
||||||
const flyoutRef = useRef<HTMLDivElement>(null);
|
const flyoutRef = useRef<HTMLDivElement>(null);
|
||||||
|
const portalContainer = usePortalContainer();
|
||||||
const filteredChildren = filterMenuItems(submenu.children);
|
const filteredChildren = filterMenuItems(submenu.children);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -125,7 +127,7 @@ function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close
|
|||||||
<DesktopLeafItem key={child.key} item={child} close={close} />
|
<DesktopLeafItem key={child.key} item={child} close={close} />
|
||||||
))}
|
))}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
portalContainer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +283,7 @@ interface DesktopMenuProps {
|
|||||||
|
|
||||||
function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) {
|
function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const portalContainer = usePortalContainer();
|
||||||
|
|
||||||
// Viewport-aware positioning via direct DOM mutation
|
// Viewport-aware positioning via direct DOM mutation
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -411,7 +414,7 @@ function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</>,
|
||||||
document.body,
|
portalContainer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +466,7 @@ interface MobileMenuProps {
|
|||||||
|
|
||||||
function MobileMenu({ items, close }: MobileMenuProps) {
|
function MobileMenu({ items, close }: MobileMenuProps) {
|
||||||
const [submenuStack, setSubmenuStack] = useState<ContextMenuSubmenu | null>(null);
|
const [submenuStack, setSubmenuStack] = useState<ContextMenuSubmenu | null>(null);
|
||||||
|
const portalContainer = usePortalContainer();
|
||||||
|
|
||||||
// Dismiss on scroll/resize
|
// Dismiss on scroll/resize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -527,7 +531,7 @@ function MobileMenu({ items, close }: MobileMenuProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</>,
|
||||||
document.body,
|
portalContainer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
|
import { usePortalContainer } from '../../hooks/usePortalContainer';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
@@ -19,6 +20,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
|
|||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const anchorRef = useRef<HTMLDivElement>(null);
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
const floatingRef = useRef<HTMLDivElement>(null);
|
const floatingRef = useRef<HTMLDivElement>(null);
|
||||||
|
const portalContainer = usePortalContainer();
|
||||||
|
|
||||||
const { style } = useFloatingPosition(anchorRef, floatingRef, {
|
const { style } = useFloatingPosition(anchorRef, floatingRef, {
|
||||||
placement: position,
|
placement: position,
|
||||||
@@ -52,7 +54,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
portalContainer,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats';
|
import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
|
import { usePortalContainer } from '../../hooks/usePortalContainer';
|
||||||
|
|
||||||
interface ConnectionInfoPopoverProps {
|
interface ConnectionInfoPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -118,6 +119,7 @@ function VideoTrackRow({ track }: { track: VideoTrackStat }) {
|
|||||||
|
|
||||||
export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
|
export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const portalContainer = usePortalContainer();
|
||||||
const stats = useTrackStats(open);
|
const stats = useTrackStats(open);
|
||||||
|
|
||||||
const { style } = useFloatingPosition(anchorRef, popoverRef, {
|
const { style } = useFloatingPosition(anchorRef, popoverRef, {
|
||||||
@@ -211,6 +213,6 @@ export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionIn
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
portalContainer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { getElectronAPI } from '../../platform/platform';
|
import { getElectronAPI } from '../../platform/platform';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
import { usePortalContainer } from '../../hooks/usePortalContainer';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Zustand micro-store — bridges the event-driven API to React state
|
// Zustand micro-store — bridges the event-driven API to React state
|
||||||
@@ -40,6 +42,7 @@ export function ScreenSharePicker() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const shareAudio = useVoiceStore((s) => s.screenShareConfig.shareAudio);
|
const shareAudio = useVoiceStore((s) => s.screenShareConfig.shareAudio);
|
||||||
const setScreenShareConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
const setScreenShareConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
||||||
|
const portalContainer = usePortalContainer();
|
||||||
|
|
||||||
// Register listener for sources from main process (once on mount)
|
// Register listener for sources from main process (once on mount)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,7 +103,7 @@ export function ScreenSharePicker() {
|
|||||||
|
|
||||||
const activeSources = activeTab === 'screens' ? screens : windows;
|
const activeSources = activeTab === 'screens' ? screens : windows;
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center animate-fade-in">
|
<div className="fixed inset-0 z-[200] flex items-center justify-center animate-fade-in">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
@@ -211,7 +214,8 @@ export function ScreenSharePicker() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
portalContainer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ScreenShareConfig } from '../../stores/voiceStore';
|
|||||||
import { useSettingsStore } from '../../stores/settingsStore';
|
import { useSettingsStore } from '../../stores/settingsStore';
|
||||||
import { buildScreenShareOptions } from '../../utils/screenShare';
|
import { buildScreenShareOptions } from '../../utils/screenShare';
|
||||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
|
import { usePortalContainer } from '../../hooks/usePortalContainer';
|
||||||
import { Toggle } from '../ui/Toggle';
|
import { Toggle } from '../ui/Toggle';
|
||||||
import { isElectron } from '../../platform/platform';
|
import { isElectron } from '../../platform/platform';
|
||||||
import { RESOLUTION_LABELS } from '@backspace/shared/src/constants';
|
import { RESOLUTION_LABELS } from '@backspace/shared/src/constants';
|
||||||
@@ -46,6 +47,7 @@ function formatKbps(kbps: number): string {
|
|||||||
|
|
||||||
export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenShareSettingsPopoverProps) {
|
export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenShareSettingsPopoverProps) {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const portalContainer = usePortalContainer();
|
||||||
const config = useVoiceStore((s) => s.screenShareConfig);
|
const config = useVoiceStore((s) => s.screenShareConfig);
|
||||||
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
||||||
const hwOverdrive = useVoiceStore((s) => s.hwOverdrive);
|
const hwOverdrive = useVoiceStore((s) => s.hwOverdrive);
|
||||||
@@ -296,6 +298,6 @@ export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenS
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user