From eca1f83162bf2a7a33c9552365f27d1286710d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 30 Jul 2026 02:38:05 +0000 Subject: [PATCH] workspace: maximize a panel without remounting its content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/Workspace/PanelSlot.tsx | 209 +++++++++++------- 1 file changed, 133 insertions(+), 76 deletions(-) diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx index a3001b38..7ff27161 100644 --- a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx @@ -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" > - + ); @@ -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'} > - + @@ -178,9 +204,15 @@ const MaximizeButton = ({ panelId }: { panelId: string }) => { title={isMaximized ? 'Restore' : 'Maximize'} > {isMaximized ? ( - + ) : ( - + )} @@ -223,7 +255,18 @@ const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children: // return
; // }; -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) => ( - {content} - ) + ? (content: React.ReactNode) => {content} : (content: React.ReactNode) => ( - onSetApp(panel.id, null)}> + onSetApp(panel.id, null)} + > {content} ) : (content: React.ReactNode) => <>{content}; - const overlays = interactive && !locked ? ( - <> - - - - ) : null; + const overlays = + interactive && !locked ? ( + <> + + + + ) : 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.name} - - ) : null; + const DefaultHeader = entry + ? () => ( + <> + + {entry.name} + + ) + : null; const ResolvedHeader = HeaderComponent ?? DefaultHeader; - const trafficLights = interactive && !isMobile ? ( - locked ? ( - - ) : ( - onSetApp(panel.id, null)} /> - ) - ) : null; + const trafficLights = + interactive && !isMobile ? ( + locked ? ( + + ) : ( + onSetApp(panel.id, null)} + /> + ) + ) : null; - const mobileBackButton = isMobile && onMobileBack ? ( - - ) : null; + const mobileBackButton = + isMobile && onMobileBack ? ( + + ) : null; const headerContent = (
{mobileBackButton} {ResolvedHeader && } {!mobileBackButton && onClose && ( - )} @@ -337,11 +392,20 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no locked ? ( {headerContent} ) : ( - onSetApp(panel.id, null)}> + onSetApp(panel.id, null)} + > {headerContent} ) - ) : headerContent; + ) : ( + headerContent + ); const body = (
@@ -363,38 +427,31 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no ); - if (isMaximized) { - return ( - <> - {/* Placeholder to preserve layout space */} -
-
-
- {/* Maximized overlay — portalled to escape stacking contexts */} - {createPortal( -
-
- {inner} -
-
, - 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 (
{inner}