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:
@@ -1,6 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowLeftRight, ChevronLeft, X, Minus } from 'lucide-react';
|
||||
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
@@ -40,7 +39,15 @@ type PanelContextMenuProps = {
|
||||
const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelComponentEntry =>
|
||||
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 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 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"
|
||||
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>
|
||||
</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"
|
||||
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
|
||||
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"
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -178,9 +204,15 @@ const MaximizeButton = ({ panelId }: { panelId: string }) => {
|
||||
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{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" />
|
||||
</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" />;
|
||||
// };
|
||||
|
||||
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 isMaximized = maximizedPanelId === panel.id;
|
||||
|
||||
@@ -241,22 +284,28 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
|
||||
|
||||
const contextMenu = interactive
|
||||
? locked
|
||||
? (content: React.ReactNode) => (
|
||||
<MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
|
||||
)
|
||||
? (content: React.ReactNode) => <MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
|
||||
: (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}
|
||||
</PanelContextMenu>
|
||||
)
|
||||
: (content: React.ReactNode) => <>{content}</>;
|
||||
|
||||
const overlays = interactive && !locked ? (
|
||||
<>
|
||||
<SwapOverlay panelId={panel.id} />
|
||||
<SwapSourceIndicator panelId={panel.id} />
|
||||
</>
|
||||
) : null;
|
||||
const overlays =
|
||||
interactive && !locked ? (
|
||||
<>
|
||||
<SwapOverlay panelId={panel.id} />
|
||||
<SwapSourceIndicator panelId={panel.id} />
|
||||
</>
|
||||
) : null;
|
||||
|
||||
if (!AppComponent) {
|
||||
if (!interactive || locked) {
|
||||
@@ -289,43 +338,49 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
|
||||
}
|
||||
|
||||
// All apps get a chrome header — custom HeaderComponent or default from registry icon+name
|
||||
const DefaultHeader = entry ? () => (
|
||||
<>
|
||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
|
||||
</>
|
||||
) : null;
|
||||
const DefaultHeader = entry
|
||||
? () => (
|
||||
<>
|
||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
|
||||
|
||||
const trafficLights = interactive && !isMobile ? (
|
||||
locked ? (
|
||||
<MaximizeButton panelId={panel.id} />
|
||||
) : (
|
||||
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
|
||||
)
|
||||
) : null;
|
||||
const trafficLights =
|
||||
interactive && !isMobile ? (
|
||||
locked ? (
|
||||
<MaximizeButton panelId={panel.id} />
|
||||
) : (
|
||||
<TrafficLights
|
||||
panelId={panel.id}
|
||||
isLastPanel={isLastPanel}
|
||||
onRemove={onRemove}
|
||||
onClearApp={() => onSetApp(panel.id, null)}
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
|
||||
const mobileBackButton = isMobile && onMobileBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMobileBack}
|
||||
className="flex items-center gap-0.5 text-xs font-medium text-black/70 hover:text-black transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
) : null;
|
||||
const mobileBackButton =
|
||||
isMobile && onMobileBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMobileBack}
|
||||
className="flex items-center gap-0.5 text-xs font-medium text-black/70 hover:text-black transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const headerContent = (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||
{mobileBackButton}
|
||||
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
|
||||
{!mobileBackButton && onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
@@ -337,11 +392,20 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
|
||||
locked ? (
|
||||
<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}
|
||||
</PanelContextMenu>
|
||||
)
|
||||
) : headerContent;
|
||||
) : (
|
||||
headerContent
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="@container flex-1 min-h-0">
|
||||
@@ -363,38 +427,31 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMaximized) {
|
||||
return (
|
||||
<>
|
||||
{/* Placeholder to preserve layout space */}
|
||||
<div data-panel-id={panel.id} className="h-full w-full p-1">
|
||||
<div className="h-full w-full rounded-lg border border-dashed border-white/20" />
|
||||
</div>
|
||||
{/* Maximized overlay — portalled to escape stacking contexts */}
|
||||
{createPortal(
|
||||
<div className="fixed inset-0 z-50 p-2">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl"
|
||||
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}
|
||||
>
|
||||
{inner}
|
||||
</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}` } : {}) };
|
||||
// Maximize is a CSS state toggle on THE SAME element — no portal, no remount — so the panel's content
|
||||
// (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
|
||||
// confined to the layout's `absolute z-2` content stacking context, which sits under the fixed nav
|
||||
// Header (z-10) — so a full-viewport overlay would hide the panel's own header/Restore behind the nav.
|
||||
// Clearing the header keeps it visible + the nav accessible. (Maximize only happens in the dashboard
|
||||
// shell; previews/mobile no-op it, so the header offset is always the right reference.)
|
||||
const maximizedStyle = { backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)' };
|
||||
const normalStyle = noHeader
|
||||
? { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(0, 0, 0, 0.25)' }
|
||||
: { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' };
|
||||
const panelStyle = {
|
||||
...(isMaximized ? maximizedStyle : normalStyle),
|
||||
...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}),
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
|
||||
style={panelStyle as React.CSSProperties}
|
||||
className={
|
||||
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}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user