import { useCallback, useLayoutEffect, useState, type ComponentType, type ReactNode, } from 'react' import { createPortal } from 'react-dom' import { Box, Dialog, DialogContent, DialogTitle, IconButton, Typography, type SvgIconProps, } from '@mui/material' import { Fullscreen as FullscreenIcon } from '@mui/icons-material' import { FullscreenExit as FullscreenExitIcon } from '@mui/icons-material' import { Close as CloseIcon } from '@mui/icons-material' import { Tooltip } from '../../../components' import { getWidgetStore, useWidget, useWidgetId } from '../../stores' import { DEFAULT_FULLSCREEN_LABELS, type FullScreenLabels } from './labels' import type { FullScreenWidgetState } from './types' import { styles } from './style' export interface FullScreenTriggerProps { labels?: Partial enterIcon?: ComponentType exitIcon?: ComponentType iconProps?: SvgIconProps } export interface FullScreenSlotProps { /** * Render-prop body. Receives the live `isFullScreen` flag so the consumer * can branch per-mode props (e.g., `Widget.Toolbox visibleCount`) without * duplicating the JSX. */ children: (isFullScreen: boolean) => ReactNode /** Header text rendered above the body when the modal is open. */ title?: ReactNode labels?: Partial closeIcon?: ComponentType iconProps?: SvgIconProps } /** * Toggle button placed inside ``. Reads / writes * {@link FullScreenWidgetState.isFullScreen} on the per-widget store. The * matching modal is rendered by {@link FullScreenSlot} (typically inside * ``). */ function FullScreenTrigger({ labels, enterIcon: EnterIcon = FullscreenIcon, exitIcon: ExitIcon = FullscreenExitIcon, iconProps, }: FullScreenTriggerProps) { const id = useWidgetId() const _labels = { ...DEFAULT_FULLSCREEN_LABELS, ...labels } const open = useWidget( id, (s) => s.isFullScreen ?? false, ) const handleToggle = useCallback(() => { getWidgetStore(id).setState({ isFullScreen: !open, } as Partial) }, [id, open]) const triggerLabel = open ? _labels.close : _labels.open const TriggerIcon = open ? ExitIcon : EnterIcon return ( ) } /** * Body wrapper. Renders its render-prop children once, via a single * `createPortal` call whose container is a stable `
` we hold as a ref. * That div is moved imperatively between an inline placeholder and the * `` of an MUI ``. Because both the * React parent (the portal call site) AND the portal container are * identical across the toggle, the subtree keeps its fiber identity and * ECharts (or any other heavy child) stays mounted while only its DOM * parent changes. */ function FullScreenSlot({ children, title, labels, closeIcon: CloseSvg = CloseIcon, iconProps, }: FullScreenSlotProps) { const id = useWidgetId() const _labels = { ...DEFAULT_FULLSCREEN_LABELS, ...labels } const open = useWidget( id, (s) => s.isFullScreen ?? false, ) const [inlineEl, setInlineEl] = useState(null) const [dialogEl, setDialogEl] = useState(null) // Stable, never-changing portal container created once per slot. Held in // state (not a ref) so the React Compiler is happy reading it during // render — it's immutable from React's perspective, only its DOM parent // changes via `appendChild`. Switching `createPortal`'s `container` // argument makes React unmount and remount the children, defeating the // single-mount goal; keeping the container identical and moving the div // imperatively avoids that. const [portalDiv] = useState(() => { if (typeof document === 'undefined') return null const div = document.createElement('div') // `display: contents` keeps the wrapper transparent in flow so it // doesn't introduce a stray block when nested inside the inline slot. div.style.display = 'contents' return div }) const handleClose = useCallback(() => { getWidgetStore(id).setState({ isFullScreen: false, } as Partial) }, [id]) // Reparent the portal div into the active host whenever `open` (or either // host) changes. `useLayoutEffect` runs synchronously after commit (after // refs have been written), so the dialog ref is already live when we read // it. // // Anti-blink: pin a `min-height` on the inline shell that's always live — // not just while fullscreen is open. We re-measure whenever the body is // inline (open=false) so the lock tracks data-driven height changes // (Formula adding rows, Table growing, ECharts re-init…) and survives // every fullscreen toggle. Briefly clearing minHeight before the measure // lets the lock shrink as well as grow; both operations are synchronous // within useLayoutEffect, so the browser never paints the intermediate // "no lock" frame. useLayoutEffect(() => { if (!portalDiv || !inlineEl) return const host = open ? dialogEl : inlineEl if (host && portalDiv.parentNode !== host) { host.appendChild(portalDiv) } if (!open) refreshInlineHeightLock(inlineEl) }, [open, inlineEl, dialogEl, portalDiv]) const titleId = `widget-fullscreen-title-${id}` const rendered = children(open) return ( <> {portalDiv ? createPortal(rendered, portalDiv) : null} theme.zIndex.tooltip + 1 }} > {title} ) } /** * Briefly clear the inline shell's `min-height`, measure its natural * height, then re-pin. Lives outside the component so the React Compiler * doesn't see the DOM mutation as an attempt to modify a `useState` value * — `el` here is just a parameter, not a tracked store value. */ function refreshInlineHeightLock(el: HTMLElement): void { el.style.minHeight = '' const h = el.getBoundingClientRect().height if (h > 0) el.style.minHeight = `${h}px` } /** * Namespace export. Consumers compose with: * * ```tsx * * * * * * {(isFullScreen) => } * * * ``` */ export const FullScreen = { Trigger: FullScreenTrigger, Slot: FullScreenSlot, }