Refactor Dashboard Tab Functionality and Enhance Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
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:
parent
0aee5b410f
commit
877db10c09
@ -3647,7 +3647,8 @@ body.objectKanbanColumnResizing * {
|
||||
overflow: visible;
|
||||
margin: 5px 0 0 0;
|
||||
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;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@ -3739,7 +3740,7 @@ body.objectKanbanColumnResizing * {
|
||||
|
||||
.dashboard-tab-item-outline {
|
||||
position: absolute;
|
||||
inset: 0 0 20px 0;
|
||||
inset: 0 0 21px 0;
|
||||
border: 1px solid var(--color-header-border);
|
||||
border-bottom: none;
|
||||
border-radius: 12px 12px 0 0;
|
||||
@ -4047,38 +4048,84 @@ body.objectKanbanColumnResizing * {
|
||||
.dashboard-tab-pane-inactive {
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
|
||||
/* Preview render: show and size the pane, but keep it off-screen. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Preview render: keep layout, but park the pane off-screen. */
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transform: translate(-100%, 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 {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dashboard-tab-preview {
|
||||
position: relative;
|
||||
width: 320px;
|
||||
background: var(--tab-preview-render-background);
|
||||
background: var(--layout-header-bg);
|
||||
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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.dashboard-tab-preview-svg {
|
||||
@ -4111,7 +4158,7 @@ body.objectKanbanColumnResizing * {
|
||||
.dashboard-tab-preview-stack:has(.dashboard-tab-preview-loading):not(
|
||||
:has(.dashboard-tab-preview-svg-current)
|
||||
) {
|
||||
min-height: 120px;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.dashboard-tab-preview-svg-previous.is-revealing {
|
||||
|
||||
@ -5,6 +5,7 @@ import { Outlet, Routes, UNSAFE_LocationContext, useLocation } from 'react-route
|
||||
import { TableStateProvider } from '../context/TableStateContext'
|
||||
import {
|
||||
NavigationTabActiveContext,
|
||||
createNavigationTabActiveStore,
|
||||
useNavigationTabs
|
||||
} from '../context/NavigationTabsContext'
|
||||
import { wrapDashboardChildRoutes } from './DashboardChildRoutes'
|
||||
@ -18,14 +19,34 @@ const locationsEqual = (left, right) =>
|
||||
(left.hash || '') === (right.hash || '')
|
||||
)
|
||||
|
||||
const entryToLocation = (entry, fallbackLocation, tabId) => ({
|
||||
pathname: entry?.pathname || fallbackLocation.pathname,
|
||||
search: entry?.search ?? fallbackLocation.search ?? '',
|
||||
hash: entry?.hash ?? fallbackLocation.hash ?? '',
|
||||
state: fallbackLocation.state,
|
||||
const toTabLocation = (tabId, source) => ({
|
||||
pathname: source?.pathname || '/',
|
||||
search: source?.search ?? '',
|
||||
hash: source?.hash ?? '',
|
||||
state: source?.state,
|
||||
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) =>
|
||||
tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history?.length - 1]
|
||||
|
||||
@ -41,18 +62,19 @@ const CachedTabRouteLayout = () => (
|
||||
</TableStateProvider>
|
||||
)
|
||||
|
||||
const areLocationsEqual = (left, right) =>
|
||||
left === right ||
|
||||
(locationsEqual(left, right) &&
|
||||
left?.key === right?.key &&
|
||||
left?.state === right?.state)
|
||||
|
||||
const arePanePropsEqual = (prev, next) =>
|
||||
prev.tabId === next.tabId &&
|
||||
prev.isActive === next.isActive &&
|
||||
prev.registerTabPane === next.registerTabPane &&
|
||||
prev.loc === next.loc
|
||||
areLocationsEqual(prev.loc, next.loc)
|
||||
|
||||
const DashboardTabPane = memo(function DashboardTabPane({
|
||||
tabId,
|
||||
isActive,
|
||||
loc,
|
||||
registerTabPane
|
||||
}) {
|
||||
const DashboardTabPaneRoutes = memo(function DashboardTabPaneRoutes({ loc }) {
|
||||
const tabRoutes = useMemo(
|
||||
() => wrapDashboardChildRoutes(<CachedTabRouteLayout />),
|
||||
[]
|
||||
@ -62,6 +84,40 @@ const DashboardTabPane = memo(function DashboardTabPane({
|
||||
[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 (
|
||||
<div
|
||||
ref={(element) => registerTabPane(tabId, element)}
|
||||
@ -73,10 +129,8 @@ const DashboardTabPane = memo(function DashboardTabPane({
|
||||
aria-hidden={!isActive}
|
||||
inert={!isActive}
|
||||
>
|
||||
<NavigationTabActiveContext.Provider value={isActive}>
|
||||
<UNSAFE_LocationContext.Provider value={locationContextValue}>
|
||||
<Routes location={loc}>{tabRoutes}</Routes>
|
||||
</UNSAFE_LocationContext.Provider>
|
||||
<NavigationTabActiveContext.Provider value={storeRef.current}>
|
||||
<DashboardTabPaneRoutes loc={loc} />
|
||||
</NavigationTabActiveContext.Provider>
|
||||
</div>
|
||||
)
|
||||
@ -99,7 +153,7 @@ DashboardTabPane.propTypes = {
|
||||
|
||||
const DashboardTabPanes = () => {
|
||||
const location = useLocation()
|
||||
const { tabs, activeTabId, isElectron, registerTabPane } = useNavigationTabs()
|
||||
const { tabs, activeTabId, registerTabPane } = useNavigationTabs()
|
||||
const frozenRef = useRef(new Map())
|
||||
const prevActiveIdRef = useRef(activeTabId)
|
||||
const lastLocationRef = useRef(location)
|
||||
@ -109,14 +163,28 @@ const DashboardTabPanes = () => {
|
||||
const switchedTabs = Boolean(prevActiveId && activeTabId && prevActiveId !== activeTabId)
|
||||
|
||||
if (switchedTabs) {
|
||||
frozenRef.current.set(prevActiveId, lastLocationRef.current)
|
||||
frozenRef.current.set(
|
||||
prevActiveId,
|
||||
reuseTabLocation(
|
||||
prevActiveId,
|
||||
frozenRef.current.get(prevActiveId),
|
||||
lastLocationRef.current
|
||||
)
|
||||
)
|
||||
pendingRestoreRef.current = true
|
||||
}
|
||||
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId)
|
||||
if (activeTab && tabMatchesLocation(activeTab, location)) {
|
||||
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()]) {
|
||||
@ -125,11 +193,6 @@ const DashboardTabPanes = () => {
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isElectron) return
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}, [activeTabId, isElectron])
|
||||
|
||||
prevActiveIdRef.current = activeTabId
|
||||
lastLocationRef.current = location
|
||||
|
||||
@ -137,23 +200,17 @@ const DashboardTabPanes = () => {
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
const stackedTabs = activeTab
|
||||
? [...tabs.filter((tab) => tab.id !== activeTabId), activeTab]
|
||||
: tabs
|
||||
|
||||
return (
|
||||
<div className='dashboard-tab-panes'>
|
||||
{stackedTabs.map((tab) => {
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId
|
||||
const loc =
|
||||
isActive && !pendingRestoreRef.current
|
||||
? location
|
||||
? reuseTabLocation(tab.id, frozenRef.current.get(tab.id), location)
|
||||
: frozenRef.current.get(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 (
|
||||
<DashboardTabPane
|
||||
|
||||
@ -2,27 +2,38 @@ import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { useThemeContext } from '../context/ThemeContext'
|
||||
|
||||
const DashboardTabPreview = ({ src, loading = false }) => {
|
||||
const lastSrcRef = useRef(src || null)
|
||||
const [overlaySrc, setOverlaySrc] = useState(src || null)
|
||||
const DashboardTabPreview = ({ src, srcTheme, loading = false }) => {
|
||||
const { isDarkMode } = useThemeContext()
|
||||
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)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (srcTheme !== currentTheme) {
|
||||
lastSrcRef.current = null
|
||||
setOverlaySrc(null)
|
||||
setRevealing(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
const locked = lastSrcRef.current || src || null
|
||||
const locked = lastSrcRef.current || usableSrc || null
|
||||
lastSrcRef.current = locked
|
||||
setOverlaySrc(locked)
|
||||
setRevealing(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (src) {
|
||||
lastSrcRef.current = src
|
||||
if (usableSrc) {
|
||||
lastSrcRef.current = usableSrc
|
||||
}
|
||||
|
||||
setRevealing(true)
|
||||
}, [loading, src])
|
||||
}, [currentTheme, loading, srcTheme, usableSrc])
|
||||
|
||||
const handleOverlayTransitionEnd = (event) => {
|
||||
if (event.propertyName !== 'opacity' || !revealing) return
|
||||
@ -31,11 +42,15 @@ const DashboardTabPreview = ({ src, loading = false }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='dashboard-tab-preview'>
|
||||
<div
|
||||
className={classNames('dashboard-tab-preview', {
|
||||
'dark-mode': isDarkMode
|
||||
})}
|
||||
>
|
||||
<div className='dashboard-tab-preview-stack'>
|
||||
{src ? (
|
||||
{usableSrc ? (
|
||||
<img
|
||||
src={src}
|
||||
src={usableSrc}
|
||||
alt=''
|
||||
className='dashboard-tab-preview-svg dashboard-tab-preview-svg-current'
|
||||
/>
|
||||
@ -67,6 +82,7 @@ const DashboardTabPreview = ({ src, loading = false }) => {
|
||||
|
||||
DashboardTabPreview.propTypes = {
|
||||
src: PropTypes.string,
|
||||
srcTheme: PropTypes.oneOf(['dark', 'light']),
|
||||
loading: PropTypes.bool
|
||||
}
|
||||
|
||||
|
||||
@ -80,7 +80,11 @@ const DashboardTabItem = ({
|
||||
onClose
|
||||
}) => {
|
||||
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 previewTargetRef = useRef(null)
|
||||
const model = tab.modelName ? getModelByName(tab.modelName) : null
|
||||
@ -156,7 +160,11 @@ const DashboardTabItem = ({
|
||||
open={isDragging ? false : previewOpen}
|
||||
onOpenChange={handlePreviewOpenChange}
|
||||
content={
|
||||
<DashboardTabPreview src={previewSrc} loading={previewCapturing} />
|
||||
<DashboardTabPreview
|
||||
src={previewSrc}
|
||||
srcTheme={previewTheme}
|
||||
loading={previewCapturing}
|
||||
/>
|
||||
}
|
||||
classNames={{ root: 'dashboard-tab-preview-popover' }}
|
||||
styles={{ body: { padding: 0, overflow: 'hidden' } }}
|
||||
|
||||
@ -7,7 +7,7 @@ import { getModelByName } from '../../../database/ObjectModels'
|
||||
import { getObjectIdFromSearch } from '../../../utils/modelActions'
|
||||
import { useActions } from '../context/ActionsContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
|
||||
import { NavigationTabActiveContext } from '../context/NavigationTabsContext'
|
||||
import { useObjectNavigationTabPage } from '../hooks/useObjectNavigationTabPage'
|
||||
|
||||
const ModelPage = ({ modelName, pageName }) => {
|
||||
@ -17,25 +17,33 @@ const ModelPage = ({ modelName, pageName }) => {
|
||||
const { setCurrentObject, setCurrentObjectType } = useActions()
|
||||
const { userProfile } = useContext(AuthContext)
|
||||
const objectId = getObjectIdFromSearch(modelName, location.search)
|
||||
const isTabActive = useIsNavigationTabActive()
|
||||
const tabActiveStore = useContext(NavigationTabActiveContext)
|
||||
useObjectNavigationTabPage({ modelName, pageName, objectId })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTabActive) return undefined
|
||||
setCurrentObjectType(modelName)
|
||||
if (objectId) {
|
||||
setCurrentObject({ _id: objectId, _user: userProfile })
|
||||
const sync = () => {
|
||||
if (!tabActiveStore.getSnapshot()) return
|
||||
setCurrentObjectType(modelName)
|
||||
if (objectId) {
|
||||
setCurrentObject({ _id: objectId, _user: userProfile })
|
||||
}
|
||||
}
|
||||
|
||||
sync()
|
||||
const unsubscribe = tabActiveStore.subscribe(sync)
|
||||
return () => {
|
||||
setCurrentObject(null)
|
||||
setCurrentObjectType(null)
|
||||
unsubscribe()
|
||||
if (tabActiveStore.getSnapshot()) {
|
||||
setCurrentObject(null)
|
||||
setCurrentObjectType(null)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isTabActive,
|
||||
modelName,
|
||||
objectId,
|
||||
setCurrentObject,
|
||||
setCurrentObjectType,
|
||||
tabActiveStore,
|
||||
userProfile
|
||||
])
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useIsNavigationTabActive } from './NavigationTabsContext'
|
||||
import { NavigationTabActiveContext } from './NavigationTabsContext'
|
||||
|
||||
const DashboardObjectToolsContext = createContext()
|
||||
|
||||
@ -60,7 +60,7 @@ export const useDashboardObjectToolsContext = () => {
|
||||
export const useDashboardObjectTools = (tools) => {
|
||||
const { setCurrentObjectTools, clearCurrentObjectTools } =
|
||||
useDashboardObjectToolsContext()
|
||||
const isTabActive = useIsNavigationTabActive()
|
||||
const store = useContext(NavigationTabActiveContext)
|
||||
const ownerIdRef = useRef(null)
|
||||
|
||||
if (ownerIdRef.current == null) {
|
||||
@ -68,9 +68,14 @@ export const useDashboardObjectTools = (tools) => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTabActive) return
|
||||
setCurrentObjectTools(tools, ownerIdRef.current)
|
||||
}, [isTabActive, tools, setCurrentObjectTools])
|
||||
const sync = () => {
|
||||
if (!store.getSnapshot()) return
|
||||
setCurrentObjectTools(tools, ownerIdRef.current)
|
||||
}
|
||||
|
||||
sync()
|
||||
return store.subscribe(sync)
|
||||
}, [setCurrentObjectTools, store, tools])
|
||||
|
||||
useEffect(() => {
|
||||
const ownerId = ownerIdRef.current
|
||||
|
||||
@ -5,22 +5,51 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
useState,
|
||||
useSyncExternalStore
|
||||
} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ElectronContext } from './ElectronContext'
|
||||
import { getDesktopWindowId } from '../../../electrobun-bridge.js'
|
||||
import { captureTabPaneImage } from './tabPreviewCapture'
|
||||
import { useThemeContext } from './ThemeContext'
|
||||
|
||||
const NavigationTabsContext = createContext()
|
||||
const NavigationTabMetaContext = createContext({
|
||||
setTabPage: () => {},
|
||||
isElectron: false
|
||||
})
|
||||
const NavigationTabPreviewsContext = createContext({
|
||||
tabPreviews: {},
|
||||
tabPreviewCapturing: {}
|
||||
})
|
||||
|
||||
// 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 = () =>
|
||||
`tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
@ -75,6 +104,7 @@ export const NavigationTabsProvider = ({ children }) => {
|
||||
completeTabDrop,
|
||||
cancelTabDrag
|
||||
} = useContext(ElectronContext)
|
||||
const { isDarkMode } = useThemeContext()
|
||||
|
||||
const [tabs, setTabs] = useState([])
|
||||
const [activeTabId, setActiveTabId] = useState(null)
|
||||
@ -88,8 +118,10 @@ export const NavigationTabsProvider = ({ children }) => {
|
||||
const tabPaneElsRef = useRef(new Map())
|
||||
const captureQueueRef = useRef(Promise.resolve())
|
||||
const captureInFlightRef = useRef(new Map())
|
||||
const previewThemeRef = useRef(isDarkMode ? 'dark' : 'light')
|
||||
tabsRef.current = tabs
|
||||
activeTabIdRef.current = activeTabId
|
||||
previewThemeRef.current = isDarkMode ? 'dark' : 'light'
|
||||
|
||||
const restoreToEntry = useCallback(
|
||||
(entry) => {
|
||||
@ -300,11 +332,14 @@ export const NavigationTabsProvider = ({ children }) => {
|
||||
try {
|
||||
const dataUrl = await captureTabPaneImage(pane)
|
||||
if (dataUrl) {
|
||||
setTabPreviews((current) =>
|
||||
current[tabId] === dataUrl
|
||||
? current
|
||||
: { ...current, [tabId]: dataUrl }
|
||||
)
|
||||
const theme = previewThemeRef.current
|
||||
setTabPreviews((current) => {
|
||||
const existing = current[tabId]
|
||||
if (existing?.src === dataUrl && existing?.theme === theme) {
|
||||
return current
|
||||
}
|
||||
return { ...current, [tabId]: { src: dataUrl, theme } }
|
||||
})
|
||||
}
|
||||
return dataUrl
|
||||
} catch (error) {
|
||||
@ -591,12 +626,21 @@ export const NavigationTabsProvider = ({ children }) => {
|
||||
}),
|
||||
[tabPreviewCapturing, tabPreviews]
|
||||
)
|
||||
const metaValue = useMemo(
|
||||
() => ({
|
||||
setTabPage,
|
||||
isElectron
|
||||
}),
|
||||
[isElectron, setTabPage]
|
||||
)
|
||||
|
||||
return (
|
||||
<NavigationTabsContext.Provider value={value}>
|
||||
<NavigationTabPreviewsContext.Provider value={previewsValue}>
|
||||
{children}
|
||||
</NavigationTabPreviewsContext.Provider>
|
||||
<NavigationTabMetaContext.Provider value={metaValue}>
|
||||
<NavigationTabPreviewsContext.Provider value={previewsValue}>
|
||||
{children}
|
||||
</NavigationTabPreviewsContext.Provider>
|
||||
</NavigationTabMetaContext.Provider>
|
||||
</NavigationTabsContext.Provider>
|
||||
)
|
||||
}
|
||||
@ -617,26 +661,41 @@ export const useTabPreview = (tabId) => {
|
||||
const { tabPreviews, tabPreviewCapturing } = useContext(
|
||||
NavigationTabPreviewsContext
|
||||
)
|
||||
const preview = tabId ? tabPreviews[tabId] : null
|
||||
return {
|
||||
src: tabId ? tabPreviews[tabId] || null : null,
|
||||
src: preview?.src || null,
|
||||
theme: preview?.theme || null,
|
||||
capturing: Boolean(tabId && tabPreviewCapturing[tabId])
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useNavigationTabPage = ({ title, modelName } = {}) => {
|
||||
const { setTabPage, isElectron } = useNavigationTabs()
|
||||
const isTabActive = useContext(NavigationTabActiveContext)
|
||||
const { setTabPage, isElectron } = useContext(NavigationTabMetaContext)
|
||||
const store = useContext(NavigationTabActiveContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron || !title || !isTabActive) return
|
||||
setTabPage({ title, modelName })
|
||||
}, [isElectron, isTabActive, modelName, setTabPage, title])
|
||||
if (!isElectron || !title) return undefined
|
||||
|
||||
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
|
||||
export const useIsNavigationTabActive = () =>
|
||||
useContext(NavigationTabActiveContext)
|
||||
export const useIsNavigationTabActive = () => {
|
||||
const store = useContext(NavigationTabActiveContext)
|
||||
return useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
NavigationTabsProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
|
||||
@ -177,7 +177,7 @@ export const ThemeProvider = ({ children }) => {
|
||||
)
|
||||
root.style.setProperty(
|
||||
'--tab-preview-render-background',
|
||||
isDarkMode ? '#000000' : '#ffffff'
|
||||
isDarkMode ? '#000000' : '#E9E9E9'
|
||||
)
|
||||
root.style.setProperty('--color-text', isDarkMode ? '#ffffff' : '#000000')
|
||||
root.style.setProperty(
|
||||
|
||||
@ -2,6 +2,8 @@ import { snapdom } from '@zumer/snapdom'
|
||||
|
||||
const FONT_TIMEOUT_MS = 180
|
||||
const CAPTURE_CLASS = 'dashboard-tab-pane-capturing'
|
||||
const CAPTURE_FRAME_CLASS = 'dashboard-tab-preview-capture-frame'
|
||||
const CAPTURE_PADDING_PX = 24
|
||||
|
||||
const PREVIEW_EXCLUDE = [
|
||||
'.ant-popover',
|
||||
@ -83,15 +85,56 @@ export const waitForTabPreviewLayout = async (pane) => {
|
||||
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 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.inert = false
|
||||
void frame.offsetHeight
|
||||
void pane.offsetHeight
|
||||
|
||||
return () => {
|
||||
pane.classList.remove(CAPTURE_CLASS)
|
||||
pane.inert = previousInert
|
||||
return {
|
||||
captureTarget: pane.parentNode === frame ? frame : pane,
|
||||
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
|
||||
if (!(element instanceof HTMLElement)) return
|
||||
|
||||
element.classList.remove(
|
||||
'dashboard-tab-pane-inactive',
|
||||
CAPTURE_CLASS
|
||||
)
|
||||
element.classList.add('dashboard-tab-pane-active')
|
||||
const pane = element.classList.contains('dashboard-tab-pane')
|
||||
? element
|
||||
: element.querySelector('.dashboard-tab-pane')
|
||||
|
||||
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'
|
||||
resetCloneLayout(element)
|
||||
|
||||
if (!(pane instanceof HTMLElement)) return
|
||||
|
||||
pane.classList.remove('dashboard-tab-pane-inactive', CAPTURE_CLASS)
|
||||
pane.classList.add('dashboard-tab-pane-active')
|
||||
resetCloneLayout(pane)
|
||||
pane.style.flex = '1 1 auto'
|
||||
pane.style.width = '100%'
|
||||
pane.style.height = 'auto'
|
||||
pane.style.minHeight = '0'
|
||||
}
|
||||
}
|
||||
|
||||
export const captureTabPaneImage = async (pane) => {
|
||||
if (!pane) return null
|
||||
|
||||
const restorePane = preparePaneForCapture(pane)
|
||||
const { captureTarget, restore } = preparePaneForCapture(pane)
|
||||
|
||||
try {
|
||||
const ready = await waitForTabPreviewLayout(pane)
|
||||
if (!ready) return null
|
||||
|
||||
const backgroundColor = getPreviewBackgroundColor(pane)
|
||||
const jpg = await snapdom.toJpg(pane, {
|
||||
const jpg = await snapdom.toJpg(captureTarget, {
|
||||
backgroundColor,
|
||||
scale: 1,
|
||||
embedFonts: 'auto',
|
||||
@ -152,6 +189,6 @@ export const captureTabPaneImage = async (pane) => {
|
||||
console.error('Failed to capture tab preview', error)
|
||||
return null
|
||||
} finally {
|
||||
restorePane()
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user