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;
|
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 {
|
||||||
|
|||||||
@ -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,13 +19,33 @@ const locationsEqual = (left, right) =>
|
|||||||
(left.hash || '') === (right.hash || '')
|
(left.hash || '') === (right.hash || '')
|
||||||
)
|
)
|
||||||
|
|
||||||
const entryToLocation = (entry, fallbackLocation, tabId) => ({
|
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,
|
pathname: entry?.pathname || fallbackLocation.pathname,
|
||||||
search: entry?.search ?? fallbackLocation.search ?? '',
|
search: entry?.search ?? fallbackLocation.search ?? '',
|
||||||
hash: entry?.hash ?? fallbackLocation.hash ?? '',
|
hash: entry?.hash ?? fallbackLocation.hash ?? '',
|
||||||
state: fallbackLocation.state,
|
state: fallbackLocation.state
|
||||||
key: `tab-${tabId}`
|
})
|
||||||
})
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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' } }}
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
if (!tabActiveStore.getSnapshot()) return
|
||||||
setCurrentObjectType(modelName)
|
setCurrentObjectType(modelName)
|
||||||
if (objectId) {
|
if (objectId) {
|
||||||
setCurrentObject({ _id: objectId, _user: userProfile })
|
setCurrentObject({ _id: objectId, _user: userProfile })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sync()
|
||||||
|
const unsubscribe = tabActiveStore.subscribe(sync)
|
||||||
return () => {
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
if (tabActiveStore.getSnapshot()) {
|
||||||
setCurrentObject(null)
|
setCurrentObject(null)
|
||||||
setCurrentObjectType(null)
|
setCurrentObjectType(null)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
isTabActive,
|
|
||||||
modelName,
|
modelName,
|
||||||
objectId,
|
objectId,
|
||||||
setCurrentObject,
|
setCurrentObject,
|
||||||
setCurrentObjectType,
|
setCurrentObjectType,
|
||||||
|
tabActiveStore,
|
||||||
userProfile
|
userProfile
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
if (!store.getSnapshot()) return
|
||||||
setCurrentObjectTools(tools, ownerIdRef.current)
|
setCurrentObjectTools(tools, ownerIdRef.current)
|
||||||
}, [isTabActive, tools, setCurrentObjectTools])
|
}
|
||||||
|
|
||||||
|
sync()
|
||||||
|
return store.subscribe(sync)
|
||||||
|
}, [setCurrentObjectTools, store, tools])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ownerId = ownerIdRef.current
|
const ownerId = ownerIdRef.current
|
||||||
|
|||||||
@ -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}>
|
||||||
|
<NavigationTabMetaContext.Provider value={metaValue}>
|
||||||
<NavigationTabPreviewsContext.Provider value={previewsValue}>
|
<NavigationTabPreviewsContext.Provider value={previewsValue}>
|
||||||
{children}
|
{children}
|
||||||
</NavigationTabPreviewsContext.Provider>
|
</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
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
if (!store.getSnapshot()) return
|
||||||
setTabPage({ title, modelName })
|
setTabPage({ title, modelName })
|
||||||
}, [isElectron, isTabActive, modelName, setTabPage, title])
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,30 +85,7 @@ export const waitForTabPreviewLayout = async (pane) => {
|
|||||||
return pane.clientWidth >= 8 && pane.clientHeight >= 8
|
return pane.clientWidth >= 8 && pane.clientHeight >= 8
|
||||||
}
|
}
|
||||||
|
|
||||||
const preparePaneForCapture = (pane) => {
|
const resetCloneLayout = (element) => {
|
||||||
const previousInert = pane.inert
|
|
||||||
pane.classList.add(CAPTURE_CLASS)
|
|
||||||
pane.inert = false
|
|
||||||
void pane.offsetHeight
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
pane.classList.remove(CAPTURE_CLASS)
|
|
||||||
pane.inert = previousInert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabPreviewPlugin = {
|
|
||||||
name: 'tab-preview-layout',
|
|
||||||
afterClone(ctx) {
|
|
||||||
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 styles = element.style
|
const styles = element.style
|
||||||
styles.position = 'relative'
|
styles.position = 'relative'
|
||||||
styles.inset = 'auto'
|
styles.inset = 'auto'
|
||||||
@ -122,20 +101,78 @@ const tabPreviewPlugin = {
|
|||||||
styles.translate = 'none'
|
styles.translate = 'none'
|
||||||
styles.transform = 'none'
|
styles.transform = 'none'
|
||||||
styles.overflow = 'hidden'
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabPreviewPlugin = {
|
||||||
|
name: 'tab-preview-layout',
|
||||||
|
afterClone(ctx) {
|
||||||
|
const element = ctx.clone
|
||||||
|
if (!(element instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
const pane = element.classList.contains('dashboard-tab-pane')
|
||||||
|
? element
|
||||||
|
: element.querySelector('.dashboard-tab-pane')
|
||||||
|
|
||||||
|
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) => {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user