Refactor Dashboard Tab Functionality and Enhance Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Introduced a new store for managing active tab states, improving the responsiveness of tab interactions.
- Updated DashboardTabPanes and DashboardTabPreview components to utilize the new active state management, enhancing user experience.
- Enhanced CSS styles for dashboard tab previews and panes, ensuring better layout handling and visual consistency across themes.
- Improved tab preview capture logic to support dynamic theme changes, refining the overall rendering process.
- Adjusted various components to streamline the integration of tab previews and active state management.
This commit is contained in:
Tom Butcher 2026-09-18 23:45:50 +01:00
parent 0aee5b410f
commit 877db10c09
9 changed files with 350 additions and 113 deletions

View File

@ -3647,7 +3647,8 @@ body.objectKanbanColumnResizing * {
overflow: visible; overflow: visible;
margin: 5px 0 0 0; margin: 5px 0 0 0;
padding: 0 10px 0 10px; padding: 0 10px 0 10px;
border: 1px solid var(--color-list-view-tabs-border); border: 1px solid
color-mix(in srgb, var(--color-header-border) 55%, transparent);
border-radius: 12px; border-radius: 12px;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
@ -3739,7 +3740,7 @@ body.objectKanbanColumnResizing * {
.dashboard-tab-item-outline { .dashboard-tab-item-outline {
position: absolute; position: absolute;
inset: 0 0 20px 0; inset: 0 0 21px 0;
border: 1px solid var(--color-header-border); border: 1px solid var(--color-header-border);
border-bottom: none; border-bottom: none;
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
@ -4047,38 +4048,84 @@ body.objectKanbanColumnResizing * {
.dashboard-tab-pane-inactive { .dashboard-tab-pane-inactive {
z-index: 0; z-index: 0;
overflow: hidden; overflow: hidden;
display: none;
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
pointer-events: none;
} }
.dashboard-tab-pane-inactive.dashboard-tab-pane-capturing { .dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
/* Preview render: show and size the pane, but keep it off-screen. */ /* Preview render: keep layout, but park the pane off-screen. */
display: flex;
flex-direction: column;
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
pointer-events: none;
transform: translate(-100%, 0); transform: translate(-100%, 0);
z-index: 0; z-index: 0;
} }
.dashboard-tab-preview-capture-frame {
position: absolute;
inset: 0;
z-index: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 24px;
background: var(--tab-preview-render-background);
transform: translate(-100%, 0);
pointer-events: none;
}
.dashboard-tab-preview-capture-frame > .dashboard-tab-pane {
position: relative;
inset: auto;
flex: 1 1 auto;
width: 100%;
height: auto;
min-height: 0;
transform: none;
}
.dashboard-tab-preview-capture-frame
> .dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
transform: none;
}
.dashboard-tab-preview-popover {
padding-top: 6px;
}
.dashboard-tab-preview-popover .ant-popover-inner { .dashboard-tab-preview-popover .ant-popover-inner {
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
background: transparent;
} }
.dashboard-tab-preview { .dashboard-tab-preview {
position: relative; position: relative;
width: 320px; width: 320px;
background: var(--tab-preview-render-background); background: var(--layout-header-bg);
overflow: hidden; overflow: hidden;
padding: 8px; padding: 6px;
border: 1px solid transparent;
border-radius: 12px;
}
.dark-mode.dashboard-tab-preview {
border: 1px solid var(--color-header-border);
}
.dark-mode.dashboard-tab-preview:before {
content: '';
position: absolute;
inset: 1px;
background: var(--tab-preview-render-background);
} }
.dashboard-tab-preview-stack { .dashboard-tab-preview-stack {
position: relative; position: relative;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
border-radius: 7px;
} }
.dashboard-tab-preview-svg { .dashboard-tab-preview-svg {
@ -4111,7 +4158,7 @@ body.objectKanbanColumnResizing * {
.dashboard-tab-preview-stack:has(.dashboard-tab-preview-loading):not( .dashboard-tab-preview-stack:has(.dashboard-tab-preview-loading):not(
:has(.dashboard-tab-preview-svg-current) :has(.dashboard-tab-preview-svg-current)
) { ) {
min-height: 120px; min-height: 160px;
} }
.dashboard-tab-preview-svg-previous.is-revealing { .dashboard-tab-preview-svg-previous.is-revealing {

View File

@ -5,6 +5,7 @@ import { Outlet, Routes, UNSAFE_LocationContext, useLocation } from 'react-route
import { TableStateProvider } from '../context/TableStateContext' import { TableStateProvider } from '../context/TableStateContext'
import { import {
NavigationTabActiveContext, NavigationTabActiveContext,
createNavigationTabActiveStore,
useNavigationTabs useNavigationTabs
} from '../context/NavigationTabsContext' } from '../context/NavigationTabsContext'
import { wrapDashboardChildRoutes } from './DashboardChildRoutes' import { wrapDashboardChildRoutes } from './DashboardChildRoutes'
@ -18,14 +19,34 @@ const locationsEqual = (left, right) =>
(left.hash || '') === (right.hash || '') (left.hash || '') === (right.hash || '')
) )
const entryToLocation = (entry, fallbackLocation, tabId) => ({ const toTabLocation = (tabId, source) => ({
pathname: entry?.pathname || fallbackLocation.pathname, pathname: source?.pathname || '/',
search: entry?.search ?? fallbackLocation.search ?? '', search: source?.search ?? '',
hash: entry?.hash ?? fallbackLocation.hash ?? '', hash: source?.hash ?? '',
state: fallbackLocation.state, state: source?.state,
key: `tab-${tabId}` key: `tab-${tabId}`
}) })
const reuseTabLocation = (tabId, previous, next) => {
if (
previous &&
locationsEqual(previous, next) &&
previous.key === `tab-${tabId}` &&
previous.state === next.state
) {
return previous
}
return toTabLocation(tabId, next)
}
const entryToLocation = (entry, fallbackLocation, tabId) =>
toTabLocation(tabId, {
pathname: entry?.pathname || fallbackLocation.pathname,
search: entry?.search ?? fallbackLocation.search ?? '',
hash: entry?.hash ?? fallbackLocation.hash ?? '',
state: fallbackLocation.state
})
const getTabCurrentEntry = (tab) => const getTabCurrentEntry = (tab) =>
tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history?.length - 1] tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history?.length - 1]
@ -41,18 +62,19 @@ const CachedTabRouteLayout = () => (
</TableStateProvider> </TableStateProvider>
) )
const areLocationsEqual = (left, right) =>
left === right ||
(locationsEqual(left, right) &&
left?.key === right?.key &&
left?.state === right?.state)
const arePanePropsEqual = (prev, next) => const arePanePropsEqual = (prev, next) =>
prev.tabId === next.tabId && prev.tabId === next.tabId &&
prev.isActive === next.isActive && prev.isActive === next.isActive &&
prev.registerTabPane === next.registerTabPane && prev.registerTabPane === next.registerTabPane &&
prev.loc === next.loc areLocationsEqual(prev.loc, next.loc)
const DashboardTabPane = memo(function DashboardTabPane({ const DashboardTabPaneRoutes = memo(function DashboardTabPaneRoutes({ loc }) {
tabId,
isActive,
loc,
registerTabPane
}) {
const tabRoutes = useMemo( const tabRoutes = useMemo(
() => wrapDashboardChildRoutes(<CachedTabRouteLayout />), () => wrapDashboardChildRoutes(<CachedTabRouteLayout />),
[] []
@ -62,6 +84,40 @@ const DashboardTabPane = memo(function DashboardTabPane({
[loc] [loc]
) )
return (
<UNSAFE_LocationContext.Provider value={locationContextValue}>
<Routes location={loc}>{tabRoutes}</Routes>
</UNSAFE_LocationContext.Provider>
)
}, (prev, next) => areLocationsEqual(prev.loc, next.loc))
DashboardTabPaneRoutes.displayName = 'DashboardTabPaneRoutes'
DashboardTabPaneRoutes.propTypes = {
loc: PropTypes.shape({
pathname: PropTypes.string,
search: PropTypes.string,
hash: PropTypes.string,
key: PropTypes.string,
state: PropTypes.any
}).isRequired
}
const DashboardTabPane = memo(function DashboardTabPane({
tabId,
isActive,
loc,
registerTabPane
}) {
const storeRef = useRef(null)
if (storeRef.current == null) {
storeRef.current = createNavigationTabActiveStore(isActive)
}
useLayoutEffect(() => {
storeRef.current.setActive(isActive)
}, [isActive])
return ( return (
<div <div
ref={(element) => registerTabPane(tabId, element)} ref={(element) => registerTabPane(tabId, element)}
@ -73,10 +129,8 @@ const DashboardTabPane = memo(function DashboardTabPane({
aria-hidden={!isActive} aria-hidden={!isActive}
inert={!isActive} inert={!isActive}
> >
<NavigationTabActiveContext.Provider value={isActive}> <NavigationTabActiveContext.Provider value={storeRef.current}>
<UNSAFE_LocationContext.Provider value={locationContextValue}> <DashboardTabPaneRoutes loc={loc} />
<Routes location={loc}>{tabRoutes}</Routes>
</UNSAFE_LocationContext.Provider>
</NavigationTabActiveContext.Provider> </NavigationTabActiveContext.Provider>
</div> </div>
) )
@ -99,7 +153,7 @@ DashboardTabPane.propTypes = {
const DashboardTabPanes = () => { const DashboardTabPanes = () => {
const location = useLocation() const location = useLocation()
const { tabs, activeTabId, isElectron, registerTabPane } = useNavigationTabs() const { tabs, activeTabId, registerTabPane } = useNavigationTabs()
const frozenRef = useRef(new Map()) const frozenRef = useRef(new Map())
const prevActiveIdRef = useRef(activeTabId) const prevActiveIdRef = useRef(activeTabId)
const lastLocationRef = useRef(location) const lastLocationRef = useRef(location)
@ -109,14 +163,28 @@ const DashboardTabPanes = () => {
const switchedTabs = Boolean(prevActiveId && activeTabId && prevActiveId !== activeTabId) const switchedTabs = Boolean(prevActiveId && activeTabId && prevActiveId !== activeTabId)
if (switchedTabs) { if (switchedTabs) {
frozenRef.current.set(prevActiveId, lastLocationRef.current) frozenRef.current.set(
prevActiveId,
reuseTabLocation(
prevActiveId,
frozenRef.current.get(prevActiveId),
lastLocationRef.current
)
)
pendingRestoreRef.current = true pendingRestoreRef.current = true
} }
const activeTab = tabs.find((tab) => tab.id === activeTabId) const activeTab = tabs.find((tab) => tab.id === activeTabId)
if (activeTab && tabMatchesLocation(activeTab, location)) { if (activeTab && tabMatchesLocation(activeTab, location)) {
pendingRestoreRef.current = false pendingRestoreRef.current = false
frozenRef.current.set(activeTabId, location) frozenRef.current.set(
activeTabId,
reuseTabLocation(
activeTabId,
frozenRef.current.get(activeTabId),
location
)
)
} }
for (const tabId of [...frozenRef.current.keys()]) { for (const tabId of [...frozenRef.current.keys()]) {
@ -125,11 +193,6 @@ const DashboardTabPanes = () => {
} }
} }
useLayoutEffect(() => {
if (!isElectron) return
window.dispatchEvent(new Event('resize'))
}, [activeTabId, isElectron])
prevActiveIdRef.current = activeTabId prevActiveIdRef.current = activeTabId
lastLocationRef.current = location lastLocationRef.current = location
@ -137,23 +200,17 @@ const DashboardTabPanes = () => {
return <Outlet /> return <Outlet />
} }
const stackedTabs = activeTab
? [...tabs.filter((tab) => tab.id !== activeTabId), activeTab]
: tabs
return ( return (
<div className='dashboard-tab-panes'> <div className='dashboard-tab-panes'>
{stackedTabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab.id === activeTabId const isActive = tab.id === activeTabId
const loc = const loc =
isActive && !pendingRestoreRef.current isActive && !pendingRestoreRef.current
? location ? reuseTabLocation(tab.id, frozenRef.current.get(tab.id), location)
: frozenRef.current.get(tab.id) || : frozenRef.current.get(tab.id) ||
entryToLocation(getTabCurrentEntry(tab), location, tab.id) entryToLocation(getTabCurrentEntry(tab), location, tab.id)
if (!isActive && !frozenRef.current.has(tab.id)) { frozenRef.current.set(tab.id, loc)
frozenRef.current.set(tab.id, loc)
}
return ( return (
<DashboardTabPane <DashboardTabPane

View File

@ -2,27 +2,38 @@ import { useLayoutEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import classNames from 'classnames' import classNames from 'classnames'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import { useThemeContext } from '../context/ThemeContext'
const DashboardTabPreview = ({ src, loading = false }) => { const DashboardTabPreview = ({ src, srcTheme, loading = false }) => {
const lastSrcRef = useRef(src || null) const { isDarkMode } = useThemeContext()
const [overlaySrc, setOverlaySrc] = useState(src || null) const currentTheme = isDarkMode ? 'dark' : 'light'
const usableSrc = src && srcTheme === currentTheme ? src : null
const lastSrcRef = useRef(usableSrc)
const [overlaySrc, setOverlaySrc] = useState(usableSrc)
const [revealing, setRevealing] = useState(false) const [revealing, setRevealing] = useState(false)
useLayoutEffect(() => { useLayoutEffect(() => {
if (srcTheme !== currentTheme) {
lastSrcRef.current = null
setOverlaySrc(null)
setRevealing(false)
return
}
if (loading) { if (loading) {
const locked = lastSrcRef.current || src || null const locked = lastSrcRef.current || usableSrc || null
lastSrcRef.current = locked lastSrcRef.current = locked
setOverlaySrc(locked) setOverlaySrc(locked)
setRevealing(false) setRevealing(false)
return return
} }
if (src) { if (usableSrc) {
lastSrcRef.current = src lastSrcRef.current = usableSrc
} }
setRevealing(true) setRevealing(true)
}, [loading, src]) }, [currentTheme, loading, srcTheme, usableSrc])
const handleOverlayTransitionEnd = (event) => { const handleOverlayTransitionEnd = (event) => {
if (event.propertyName !== 'opacity' || !revealing) return if (event.propertyName !== 'opacity' || !revealing) return
@ -31,11 +42,15 @@ const DashboardTabPreview = ({ src, loading = false }) => {
} }
return ( return (
<div className='dashboard-tab-preview'> <div
className={classNames('dashboard-tab-preview', {
'dark-mode': isDarkMode
})}
>
<div className='dashboard-tab-preview-stack'> <div className='dashboard-tab-preview-stack'>
{src ? ( {usableSrc ? (
<img <img
src={src} src={usableSrc}
alt='' alt=''
className='dashboard-tab-preview-svg dashboard-tab-preview-svg-current' className='dashboard-tab-preview-svg dashboard-tab-preview-svg-current'
/> />
@ -67,6 +82,7 @@ const DashboardTabPreview = ({ src, loading = false }) => {
DashboardTabPreview.propTypes = { DashboardTabPreview.propTypes = {
src: PropTypes.string, src: PropTypes.string,
srcTheme: PropTypes.oneOf(['dark', 'light']),
loading: PropTypes.bool loading: PropTypes.bool
} }

View File

@ -80,7 +80,11 @@ const DashboardTabItem = ({
onClose onClose
}) => { }) => {
const { captureTabPreview } = useNavigationTabs() const { captureTabPreview } = useNavigationTabs()
const { src: previewSrc, capturing: previewCapturing } = useTabPreview(tab.id) const {
src: previewSrc,
theme: previewTheme,
capturing: previewCapturing
} = useTabPreview(tab.id)
const [previewOpen, setPreviewOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false)
const previewTargetRef = useRef(null) const previewTargetRef = useRef(null)
const model = tab.modelName ? getModelByName(tab.modelName) : null const model = tab.modelName ? getModelByName(tab.modelName) : null
@ -156,7 +160,11 @@ const DashboardTabItem = ({
open={isDragging ? false : previewOpen} open={isDragging ? false : previewOpen}
onOpenChange={handlePreviewOpenChange} onOpenChange={handlePreviewOpenChange}
content={ content={
<DashboardTabPreview src={previewSrc} loading={previewCapturing} /> <DashboardTabPreview
src={previewSrc}
srcTheme={previewTheme}
loading={previewCapturing}
/>
} }
classNames={{ root: 'dashboard-tab-preview-popover' }} classNames={{ root: 'dashboard-tab-preview-popover' }}
styles={{ body: { padding: 0, overflow: 'hidden' } }} styles={{ body: { padding: 0, overflow: 'hidden' } }}

View File

@ -7,7 +7,7 @@ import { getModelByName } from '../../../database/ObjectModels'
import { getObjectIdFromSearch } from '../../../utils/modelActions' import { getObjectIdFromSearch } from '../../../utils/modelActions'
import { useActions } from '../context/ActionsContext' import { useActions } from '../context/ActionsContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext' import { NavigationTabActiveContext } from '../context/NavigationTabsContext'
import { useObjectNavigationTabPage } from '../hooks/useObjectNavigationTabPage' import { useObjectNavigationTabPage } from '../hooks/useObjectNavigationTabPage'
const ModelPage = ({ modelName, pageName }) => { const ModelPage = ({ modelName, pageName }) => {
@ -17,25 +17,33 @@ const ModelPage = ({ modelName, pageName }) => {
const { setCurrentObject, setCurrentObjectType } = useActions() const { setCurrentObject, setCurrentObjectType } = useActions()
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const objectId = getObjectIdFromSearch(modelName, location.search) const objectId = getObjectIdFromSearch(modelName, location.search)
const isTabActive = useIsNavigationTabActive() const tabActiveStore = useContext(NavigationTabActiveContext)
useObjectNavigationTabPage({ modelName, pageName, objectId }) useObjectNavigationTabPage({ modelName, pageName, objectId })
useEffect(() => { useEffect(() => {
if (!isTabActive) return undefined const sync = () => {
setCurrentObjectType(modelName) if (!tabActiveStore.getSnapshot()) return
if (objectId) { setCurrentObjectType(modelName)
setCurrentObject({ _id: objectId, _user: userProfile }) if (objectId) {
setCurrentObject({ _id: objectId, _user: userProfile })
}
} }
sync()
const unsubscribe = tabActiveStore.subscribe(sync)
return () => { return () => {
setCurrentObject(null) unsubscribe()
setCurrentObjectType(null) if (tabActiveStore.getSnapshot()) {
setCurrentObject(null)
setCurrentObjectType(null)
}
} }
}, [ }, [
isTabActive,
modelName, modelName,
objectId, objectId,
setCurrentObject, setCurrentObject,
setCurrentObjectType, setCurrentObjectType,
tabActiveStore,
userProfile userProfile
]) ])

View File

@ -7,7 +7,7 @@ import {
useState useState
} from 'react' } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useIsNavigationTabActive } from './NavigationTabsContext' import { NavigationTabActiveContext } from './NavigationTabsContext'
const DashboardObjectToolsContext = createContext() const DashboardObjectToolsContext = createContext()
@ -60,7 +60,7 @@ export const useDashboardObjectToolsContext = () => {
export const useDashboardObjectTools = (tools) => { export const useDashboardObjectTools = (tools) => {
const { setCurrentObjectTools, clearCurrentObjectTools } = const { setCurrentObjectTools, clearCurrentObjectTools } =
useDashboardObjectToolsContext() useDashboardObjectToolsContext()
const isTabActive = useIsNavigationTabActive() const store = useContext(NavigationTabActiveContext)
const ownerIdRef = useRef(null) const ownerIdRef = useRef(null)
if (ownerIdRef.current == null) { if (ownerIdRef.current == null) {
@ -68,9 +68,14 @@ export const useDashboardObjectTools = (tools) => {
} }
useEffect(() => { useEffect(() => {
if (!isTabActive) return const sync = () => {
setCurrentObjectTools(tools, ownerIdRef.current) if (!store.getSnapshot()) return
}, [isTabActive, tools, setCurrentObjectTools]) setCurrentObjectTools(tools, ownerIdRef.current)
}
sync()
return store.subscribe(sync)
}, [setCurrentObjectTools, store, tools])
useEffect(() => { useEffect(() => {
const ownerId = ownerIdRef.current const ownerId = ownerIdRef.current

View File

@ -5,22 +5,51 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState useState,
useSyncExternalStore
} from 'react' } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { ElectronContext } from './ElectronContext' import { ElectronContext } from './ElectronContext'
import { getDesktopWindowId } from '../../../electrobun-bridge.js' import { getDesktopWindowId } from '../../../electrobun-bridge.js'
import { captureTabPaneImage } from './tabPreviewCapture' import { captureTabPaneImage } from './tabPreviewCapture'
import { useThemeContext } from './ThemeContext'
const NavigationTabsContext = createContext() const NavigationTabsContext = createContext()
const NavigationTabMetaContext = createContext({
setTabPage: () => {},
isElectron: false
})
const NavigationTabPreviewsContext = createContext({ const NavigationTabPreviewsContext = createContext({
tabPreviews: {}, tabPreviews: {},
tabPreviewCapturing: {} tabPreviewCapturing: {}
}) })
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const NavigationTabActiveContext = createContext(true) export const createNavigationTabActiveStore = (initial = true) => {
let isActive = Boolean(initial)
const listeners = new Set()
return {
getSnapshot: () => isActive,
setActive: (next) => {
const value = Boolean(next)
if (isActive === value) return
isActive = value
listeners.forEach((listener) => listener())
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
}
}
const defaultNavigationTabActiveStore = createNavigationTabActiveStore(true)
// eslint-disable-next-line react-refresh/only-export-components
export const NavigationTabActiveContext = createContext(
defaultNavigationTabActiveStore
)
const createTabId = () => const createTabId = () =>
`tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
@ -75,6 +104,7 @@ export const NavigationTabsProvider = ({ children }) => {
completeTabDrop, completeTabDrop,
cancelTabDrag cancelTabDrag
} = useContext(ElectronContext) } = useContext(ElectronContext)
const { isDarkMode } = useThemeContext()
const [tabs, setTabs] = useState([]) const [tabs, setTabs] = useState([])
const [activeTabId, setActiveTabId] = useState(null) const [activeTabId, setActiveTabId] = useState(null)
@ -88,8 +118,10 @@ export const NavigationTabsProvider = ({ children }) => {
const tabPaneElsRef = useRef(new Map()) const tabPaneElsRef = useRef(new Map())
const captureQueueRef = useRef(Promise.resolve()) const captureQueueRef = useRef(Promise.resolve())
const captureInFlightRef = useRef(new Map()) const captureInFlightRef = useRef(new Map())
const previewThemeRef = useRef(isDarkMode ? 'dark' : 'light')
tabsRef.current = tabs tabsRef.current = tabs
activeTabIdRef.current = activeTabId activeTabIdRef.current = activeTabId
previewThemeRef.current = isDarkMode ? 'dark' : 'light'
const restoreToEntry = useCallback( const restoreToEntry = useCallback(
(entry) => { (entry) => {
@ -300,11 +332,14 @@ export const NavigationTabsProvider = ({ children }) => {
try { try {
const dataUrl = await captureTabPaneImage(pane) const dataUrl = await captureTabPaneImage(pane)
if (dataUrl) { if (dataUrl) {
setTabPreviews((current) => const theme = previewThemeRef.current
current[tabId] === dataUrl setTabPreviews((current) => {
? current const existing = current[tabId]
: { ...current, [tabId]: dataUrl } if (existing?.src === dataUrl && existing?.theme === theme) {
) return current
}
return { ...current, [tabId]: { src: dataUrl, theme } }
})
} }
return dataUrl return dataUrl
} catch (error) { } catch (error) {
@ -591,12 +626,21 @@ export const NavigationTabsProvider = ({ children }) => {
}), }),
[tabPreviewCapturing, tabPreviews] [tabPreviewCapturing, tabPreviews]
) )
const metaValue = useMemo(
() => ({
setTabPage,
isElectron
}),
[isElectron, setTabPage]
)
return ( return (
<NavigationTabsContext.Provider value={value}> <NavigationTabsContext.Provider value={value}>
<NavigationTabPreviewsContext.Provider value={previewsValue}> <NavigationTabMetaContext.Provider value={metaValue}>
{children} <NavigationTabPreviewsContext.Provider value={previewsValue}>
</NavigationTabPreviewsContext.Provider> {children}
</NavigationTabPreviewsContext.Provider>
</NavigationTabMetaContext.Provider>
</NavigationTabsContext.Provider> </NavigationTabsContext.Provider>
) )
} }
@ -617,26 +661,41 @@ export const useTabPreview = (tabId) => {
const { tabPreviews, tabPreviewCapturing } = useContext( const { tabPreviews, tabPreviewCapturing } = useContext(
NavigationTabPreviewsContext NavigationTabPreviewsContext
) )
const preview = tabId ? tabPreviews[tabId] : null
return { return {
src: tabId ? tabPreviews[tabId] || null : null, src: preview?.src || null,
theme: preview?.theme || null,
capturing: Boolean(tabId && tabPreviewCapturing[tabId]) capturing: Boolean(tabId && tabPreviewCapturing[tabId])
} }
} }
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const useNavigationTabPage = ({ title, modelName } = {}) => { export const useNavigationTabPage = ({ title, modelName } = {}) => {
const { setTabPage, isElectron } = useNavigationTabs() const { setTabPage, isElectron } = useContext(NavigationTabMetaContext)
const isTabActive = useContext(NavigationTabActiveContext) const store = useContext(NavigationTabActiveContext)
useEffect(() => { useEffect(() => {
if (!isElectron || !title || !isTabActive) return if (!isElectron || !title) return undefined
setTabPage({ title, modelName })
}, [isElectron, isTabActive, modelName, setTabPage, title]) const sync = () => {
if (!store.getSnapshot()) return
setTabPage({ title, modelName })
}
sync()
return store.subscribe(sync)
}, [isElectron, modelName, setTabPage, store, title])
} }
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const useIsNavigationTabActive = () => export const useIsNavigationTabActive = () => {
useContext(NavigationTabActiveContext) const store = useContext(NavigationTabActiveContext)
return useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
)
}
NavigationTabsProvider.propTypes = { NavigationTabsProvider.propTypes = {
children: PropTypes.node.isRequired children: PropTypes.node.isRequired

View File

@ -177,7 +177,7 @@ export const ThemeProvider = ({ children }) => {
) )
root.style.setProperty( root.style.setProperty(
'--tab-preview-render-background', '--tab-preview-render-background',
isDarkMode ? '#000000' : '#ffffff' isDarkMode ? '#000000' : '#E9E9E9'
) )
root.style.setProperty('--color-text', isDarkMode ? '#ffffff' : '#000000') root.style.setProperty('--color-text', isDarkMode ? '#ffffff' : '#000000')
root.style.setProperty( root.style.setProperty(

View File

@ -2,6 +2,8 @@ import { snapdom } from '@zumer/snapdom'
const FONT_TIMEOUT_MS = 180 const FONT_TIMEOUT_MS = 180
const CAPTURE_CLASS = 'dashboard-tab-pane-capturing' const CAPTURE_CLASS = 'dashboard-tab-pane-capturing'
const CAPTURE_FRAME_CLASS = 'dashboard-tab-preview-capture-frame'
const CAPTURE_PADDING_PX = 24
const PREVIEW_EXCLUDE = [ const PREVIEW_EXCLUDE = [
'.ant-popover', '.ant-popover',
@ -83,15 +85,56 @@ export const waitForTabPreviewLayout = async (pane) => {
return pane.clientWidth >= 8 && pane.clientHeight >= 8 return pane.clientWidth >= 8 && pane.clientHeight >= 8
} }
const resetCloneLayout = (element) => {
const styles = element.style
styles.position = 'relative'
styles.inset = 'auto'
styles.left = '0'
styles.top = '0'
styles.right = 'auto'
styles.bottom = 'auto'
styles.zIndex = '2'
styles.display = 'flex'
styles.flexDirection = 'column'
styles.opacity = '1'
styles.visibility = 'visible'
styles.translate = 'none'
styles.transform = 'none'
styles.overflow = 'hidden'
}
const preparePaneForCapture = (pane) => { const preparePaneForCapture = (pane) => {
const previousInert = pane.inert const previousInert = pane.inert
const parent = pane.parentNode
const nextSibling = pane.nextSibling
const frame = document.createElement('div')
frame.className = CAPTURE_FRAME_CLASS
frame.style.padding = `${CAPTURE_PADDING_PX}px`
if (parent) {
parent.insertBefore(frame, pane)
frame.appendChild(pane)
}
pane.classList.add(CAPTURE_CLASS) pane.classList.add(CAPTURE_CLASS)
pane.inert = false pane.inert = false
void frame.offsetHeight
void pane.offsetHeight void pane.offsetHeight
return () => { return {
pane.classList.remove(CAPTURE_CLASS) captureTarget: pane.parentNode === frame ? frame : pane,
pane.inert = previousInert restore: () => {
if (pane.parentNode === frame) {
if (nextSibling && nextSibling.parentNode === parent) {
parent.insertBefore(pane, nextSibling)
} else if (parent) {
parent.appendChild(pane)
}
}
frame.remove()
pane.classList.remove(CAPTURE_CLASS)
pane.inert = previousInert
}
} }
} }
@ -101,41 +144,35 @@ const tabPreviewPlugin = {
const element = ctx.clone const element = ctx.clone
if (!(element instanceof HTMLElement)) return if (!(element instanceof HTMLElement)) return
element.classList.remove( const pane = element.classList.contains('dashboard-tab-pane')
'dashboard-tab-pane-inactive', ? element
CAPTURE_CLASS : element.querySelector('.dashboard-tab-pane')
)
element.classList.add('dashboard-tab-pane-active')
const styles = element.style resetCloneLayout(element)
styles.position = 'relative'
styles.inset = 'auto' if (!(pane instanceof HTMLElement)) return
styles.left = '0'
styles.top = '0' pane.classList.remove('dashboard-tab-pane-inactive', CAPTURE_CLASS)
styles.right = 'auto' pane.classList.add('dashboard-tab-pane-active')
styles.bottom = 'auto' resetCloneLayout(pane)
styles.zIndex = '2' pane.style.flex = '1 1 auto'
styles.display = 'flex' pane.style.width = '100%'
styles.flexDirection = 'column' pane.style.height = 'auto'
styles.opacity = '1' pane.style.minHeight = '0'
styles.visibility = 'visible'
styles.translate = 'none'
styles.transform = 'none'
styles.overflow = 'hidden'
} }
} }
export const captureTabPaneImage = async (pane) => { export const captureTabPaneImage = async (pane) => {
if (!pane) return null if (!pane) return null
const restorePane = preparePaneForCapture(pane) const { captureTarget, restore } = preparePaneForCapture(pane)
try { try {
const ready = await waitForTabPreviewLayout(pane) const ready = await waitForTabPreviewLayout(pane)
if (!ready) return null if (!ready) return null
const backgroundColor = getPreviewBackgroundColor(pane) const backgroundColor = getPreviewBackgroundColor(pane)
const jpg = await snapdom.toJpg(pane, { const jpg = await snapdom.toJpg(captureTarget, {
backgroundColor, backgroundColor,
scale: 1, scale: 1,
embedFonts: 'auto', embedFonts: 'auto',
@ -152,6 +189,6 @@ export const captureTabPaneImage = async (pane) => {
console.error('Failed to capture tab preview', error) console.error('Failed to capture tab preview', error)
return null return null
} finally { } finally {
restorePane() restore()
} }
} }