workspace: maximize a panel without remounting its content

Maximize was swapping the app into a createPortal(document.body) branch, so React
unmounted + remounted it — losing scroll, playback, and in-flight state on every
maximize/restore. Now it's a pure CSS state toggle on the same element (fixed,
filling the content region), so the app instance is preserved and content stays
exactly as it was. The panel is confined to the layout's z-2 content stacking
context (under the fixed nav Header at z-10), so the maximized box starts below
the header — keeping the panel's own header/Restore visible and the nav reachable.
Dropped the portal + placeholder and the now-unused createPortal import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:38:05 +00:00
co-authored by Claude Opus 4.8
parent 1534bc2ea7
commit eca1f83162
@@ -1,6 +1,5 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { createPortal } from 'react-dom';
import { ArrowLeftRight, ChevronLeft, X, Minus } from 'lucide-react'; import { ArrowLeftRight, ChevronLeft, X, Minus } from 'lucide-react';
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types'; import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
import { useWorkspace } from './WorkspaceContext'; import { useWorkspace } from './WorkspaceContext';
@@ -40,7 +39,15 @@ type PanelContextMenuProps = {
const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelComponentEntry => const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelComponentEntry =>
typeof v === 'object' && v !== null && 'component' in v; typeof v === 'object' && v !== null && 'component' in v;
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => { const PanelContextMenu = ({
panelId,
hasApp,
isLastPanel,
onSplit,
onRemove,
onClearApp,
children,
}: PanelContextMenuProps) => {
const { swapSourceId, setSwapSourceId, maximizedPanelId, setMaximizedPanelId } = useWorkspace(); const { swapSourceId, setSwapSourceId, maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId; const isMaximized = maximizedPanelId === panelId;
@@ -110,7 +117,17 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
); );
}; };
const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId: string; isLastPanel: boolean; onRemove: (panelId: string) => void; onClearApp: () => void }) => { const TrafficLights = ({
panelId,
isLastPanel,
onRemove,
onClearApp,
}: {
panelId: string;
isLastPanel: boolean;
onRemove: (panelId: string) => void;
onClearApp: () => void;
}) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace(); const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId; const isMaximized = maximizedPanelId === panelId;
@@ -135,7 +152,10 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
className="group/btn h-3 w-3 rounded-full bg-[#febc2e] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center" className="group/btn h-3 w-3 rounded-full bg-[#febc2e] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title="Restore" title="Restore"
> >
<Minus className="h-2 w-2 text-[#5f4a00] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} /> <Minus
className="h-2 w-2 text-[#5f4a00] opacity-0 group-hover/btn:opacity-100 transition-opacity"
strokeWidth={3}
/>
</button> </button>
</div> </div>
); );
@@ -149,7 +169,10 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
className="group/btn h-3 w-3 rounded-full bg-[#ff5f57] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center" className="group/btn h-3 w-3 rounded-full bg-[#ff5f57] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title={isLastPanel ? 'Clear app' : 'Close panel'} title={isLastPanel ? 'Clear app' : 'Close panel'}
> >
<X className="h-2 w-2 text-[#4a0002] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} /> <X
className="h-2 w-2 text-[#4a0002] opacity-0 group-hover/btn:opacity-100 transition-opacity"
strokeWidth={3}
/>
</button> </button>
<button <button
type="button" type="button"
@@ -157,7 +180,10 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center" className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title="Maximize" title="Maximize"
> >
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity"> <svg
viewBox="0 0 10 10"
className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity"
>
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" /> <path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
</svg> </svg>
</button> </button>
@@ -178,9 +204,15 @@ const MaximizeButton = ({ panelId }: { panelId: string }) => {
title={isMaximized ? 'Restore' : 'Maximize'} title={isMaximized ? 'Restore' : 'Maximize'}
> >
{isMaximized ? ( {isMaximized ? (
<Minus className="h-2 w-2 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} /> <Minus
className="h-2 w-2 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity"
strokeWidth={3}
/>
) : ( ) : (
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity"> <svg
viewBox="0 0 10 10"
className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity"
>
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" /> <path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
</svg> </svg>
)} )}
@@ -223,7 +255,18 @@ const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children:
// return <div className="absolute inset-0 z-20 rounded-lg bg-duck-dark/10 pointer-events-none" />; // return <div className="absolute inset-0 z-20 rounded-lg bg-duck-dark/10 pointer-events-none" />;
// }; // };
export const PanelSlot = ({ panel, registry, components, interactive, locked, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => { export const PanelSlot = ({
panel,
registry,
components,
interactive,
locked,
noHeader,
isLastPanel,
onSetApp,
onSplit,
onRemove,
}: PanelSlotProps) => {
const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace(); const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const isMaximized = maximizedPanelId === panel.id; const isMaximized = maximizedPanelId === panel.id;
@@ -241,17 +284,23 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
const contextMenu = interactive const contextMenu = interactive
? locked ? locked
? (content: React.ReactNode) => ( ? (content: React.ReactNode) => <MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
<MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
)
: (content: React.ReactNode) => ( : (content: React.ReactNode) => (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}> <PanelContextMenu
panelId={panel.id}
hasApp={!!AppComponent}
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
>
{content} {content}
</PanelContextMenu> </PanelContextMenu>
) )
: (content: React.ReactNode) => <>{content}</>; : (content: React.ReactNode) => <>{content}</>;
const overlays = interactive && !locked ? ( const overlays =
interactive && !locked ? (
<> <>
<SwapOverlay panelId={panel.id} /> <SwapOverlay panelId={panel.id} />
<SwapSourceIndicator panelId={panel.id} /> <SwapSourceIndicator panelId={panel.id} />
@@ -289,24 +338,33 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
} }
// All apps get a chrome header — custom HeaderComponent or default from registry icon+name // All apps get a chrome header — custom HeaderComponent or default from registry icon+name
const DefaultHeader = entry ? () => ( const DefaultHeader = entry
? () => (
<> <>
<entry.icon className="h-3.5 w-3.5 shrink-0" /> <entry.icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">{entry.name}</span> <span className="text-xs font-medium truncate flex-1">{entry.name}</span>
</> </>
) : null; )
: null;
const ResolvedHeader = HeaderComponent ?? DefaultHeader; const ResolvedHeader = HeaderComponent ?? DefaultHeader;
const trafficLights = interactive && !isMobile ? ( const trafficLights =
interactive && !isMobile ? (
locked ? ( locked ? (
<MaximizeButton panelId={panel.id} /> <MaximizeButton panelId={panel.id} />
) : ( ) : (
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} /> <TrafficLights
panelId={panel.id}
isLastPanel={isLastPanel}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
) )
) : null; ) : null;
const mobileBackButton = isMobile && onMobileBack ? ( const mobileBackButton =
isMobile && onMobileBack ? (
<button <button
type="button" type="button"
onClick={onMobileBack} onClick={onMobileBack}
@@ -322,10 +380,7 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
{mobileBackButton} {mobileBackButton}
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />} {ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
{!mobileBackButton && onClose && ( {!mobileBackButton && onClose && (
<button <button onClick={onClose} className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer">
onClick={onClose}
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
)} )}
@@ -337,11 +392,20 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
locked ? ( locked ? (
<MaximizeContextMenu panelId={panel.id}>{headerContent}</MaximizeContextMenu> <MaximizeContextMenu panelId={panel.id}>{headerContent}</MaximizeContextMenu>
) : ( ) : (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}> <PanelContextMenu
panelId={panel.id}
hasApp={!!AppComponent}
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
>
{headerContent} {headerContent}
</PanelContextMenu> </PanelContextMenu>
) )
) : headerContent; ) : (
headerContent
);
const body = ( const body = (
<div className="@container flex-1 min-h-0"> <div className="@container flex-1 min-h-0">
@@ -363,38 +427,31 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
</> </>
); );
if (isMaximized) { // Maximize is a CSS state toggle on THE SAME element — no portal, no remount — so the panel's content
return ( // (scroll position, media playback, in-flight state) is preserved exactly across maximize/restore.
<> // It fills the content REGION (starting below the global Header), not the whole viewport: the panel is
{/* Placeholder to preserve layout space */} // confined to the layout's `absolute z-2` content stacking context, which sits under the fixed nav
<div data-panel-id={panel.id} className="h-full w-full p-1"> // Header (z-10) — so a full-viewport overlay would hide the panel's own header/Restore behind the nav.
<div className="h-full w-full rounded-lg border border-dashed border-white/20" /> // Clearing the header keeps it visible + the nav accessible. (Maximize only happens in the dashboard
</div> // shell; previews/mobile no-op it, so the header offset is always the right reference.)
{/* Maximized overlay — portalled to escape stacking contexts */} const maximizedStyle = { backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)' };
{createPortal( const normalStyle = noHeader
<div className="fixed inset-0 z-50 p-2"> ? { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(0, 0, 0, 0.25)' }
<div : { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' };
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl" const panelStyle = {
style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties} ...(isMaximized ? maximizedStyle : normalStyle),
> ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}),
{inner} } as React.CSSProperties;
</div>
</div>,
document.body,
)}
</>
);
}
const panelStyle = noHeader
? { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(0, 0, 0, 0.25)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) }
: { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) };
return ( return (
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1"> <div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
<div <div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col" className={
style={panelStyle as React.CSSProperties} isMaximized
? 'fixed inset-x-2 bottom-2 top-[56px] z-50 overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl md:top-[68px]'
: 'relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col'
}
style={panelStyle}
> >
{inner} {inner}
</div> </div>