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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user