farmcontrol-ui/src/components/Dashboard/context/NavigationTabsContext.jsx
Tom Butcher 877db10c09
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Refactor Dashboard Tab Functionality and Enhance Styles
- 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.
2026-09-18 23:45:50 +01:00

705 lines
19 KiB
JavaScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
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 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)}`
const locationToEntry = (location) => ({
pathname: location?.pathname || '/',
search: location?.search || '',
hash: location?.hash || ''
})
const entryToPath = (entry) => {
if (!entry) return '/'
if (typeof entry === 'string') return entry
return `${entry.pathname || '/'}${entry.search || ''}${entry.hash || ''}`
}
const entriesEqual = (left, right) => entryToPath(left) === entryToPath(right)
const createTabFromLocation = (location, extras = {}) => ({
id: createTabId(),
title: extras.title || 'Farm Control',
modelName: extras.modelName || null,
history: [locationToEntry(location)],
historyIndex: 0
})
const cloneCurrentPageTab = (tab, location) => ({
id: createTabId(),
title: tab?.title || 'Farm Control',
modelName: tab?.modelName || null,
history: [locationToEntry(location)],
historyIndex: 0
})
const getTabCurrentEntry = (tab) =>
tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history.length - 1]
export const NavigationTabsProvider = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const {
isElectron,
getWindowSession,
syncWindowTabs,
createAppWindow,
handleWindowControl,
registerTabHistoryHandler,
onNewTabRequest,
onNewWindowRequest,
onTabMovedAway,
beginTabDrag,
completeTabDrop,
cancelTabDrag
} = useContext(ElectronContext)
const { isDarkMode } = useThemeContext()
const [tabs, setTabs] = useState([])
const [activeTabId, setActiveTabId] = useState(null)
const [hydrated, setHydrated] = useState(!isElectron)
const [tabPreviews, setTabPreviews] = useState({})
const [tabPreviewCapturing, setTabPreviewCapturing] = useState({})
const tabsRef = useRef(tabs)
const activeTabIdRef = useRef(activeTabId)
const previousActiveTabIdRef = useRef(null)
const isRestoringRef = useRef(false)
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) => {
if (!entry) return
isRestoringRef.current = true
navigate(entryToPath(entry), { replace: true })
},
[navigate]
)
const selectTab = useCallback(
(tabId) => {
if (!tabId || tabId === activeTabIdRef.current) return
const nextTab = tabsRef.current.find((tab) => tab.id === tabId)
if (!nextTab) return
const nextEntry = getTabCurrentEntry(nextTab)
setActiveTabId(tabId)
if (entriesEqual(nextEntry, location)) {
return
}
restoreToEntry(nextEntry)
},
[location, restoreToEntry]
)
const addTab = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
const nextTab = cloneCurrentPageTab(currentTab, location)
setTabs((current) => [...current, nextTab])
setActiveTabId(nextTab.id)
}, [location])
const removeTab = useCallback(
(tabId, { closeWindowIfLast = true } = {}) => {
const current = tabsRef.current
const index = current.findIndex((tab) => tab.id === tabId)
if (index === -1) return false
if (current.length <= 1) {
if (closeWindowIfLast) {
handleWindowControl?.('close')
}
return true
}
const remaining = current.filter((tab) => tab.id !== tabId)
const nextActive =
tabId === activeTabIdRef.current
? remaining[Math.max(0, index - 1)]
: remaining.find((tab) => tab.id === activeTabIdRef.current)
setTabs(remaining)
setTabPreviews((currentPreviews) => {
if (!(tabId in currentPreviews)) return currentPreviews
const nextPreviews = { ...currentPreviews }
delete nextPreviews[tabId]
return nextPreviews
})
setTabPreviewCapturing((currentCapturing) => {
if (!(tabId in currentCapturing)) return currentCapturing
const nextCapturing = { ...currentCapturing }
delete nextCapturing[tabId]
return nextCapturing
})
if (nextActive) {
setActiveTabId(nextActive.id)
const nextEntry = getTabCurrentEntry(nextActive)
if (!entriesEqual(nextEntry, location)) {
restoreToEntry(nextEntry)
}
}
return true
},
[handleWindowControl, location, restoreToEntry]
)
const closeTab = useCallback(
(tabId) => {
removeTab(tabId, { closeWindowIfLast: true })
},
[removeTab]
)
const acceptIncomingTab = useCallback(
(incomingTab, targetId, insertBefore = false) => {
if (!incomingTab?.id) return
setTabs((current) => {
if (current.some((tab) => tab.id === incomingTab.id)) {
return current
}
const next = [...current]
const targetIndex = next.findIndex((tab) => tab.id === targetId)
if (targetIndex === -1) {
next.push(incomingTab)
return next
}
next.splice(insertBefore ? targetIndex : targetIndex + 1, 0, incomingTab)
return next
})
setActiveTabId(incomingTab.id)
const nextEntry = getTabCurrentEntry(incomingTab)
if (!entriesEqual(nextEntry, location)) {
restoreToEntry(nextEntry)
}
},
[location, restoreToEntry]
)
const handleTabDragStart = useCallback(
(tabId) => {
const tab = tabsRef.current.find((item) => item.id === tabId)
if (!tab) return
void beginTabDrag?.({
windowId: getDesktopWindowId(),
tab
})
},
[beginTabDrag]
)
const handleTabDragEnd = useCallback(() => {
window.setTimeout(() => {
void cancelTabDrag?.({ windowId: getDesktopWindowId() })
}, 150)
}, [cancelTabDrag])
const handleExternalTabDrop = useCallback(
async (targetId, insertBefore) => {
const result = await completeTabDrop?.({
windowId: getDesktopWindowId(),
beforeTabId: targetId,
insertBefore
})
if (!result?.ok || result.sameWindow || !result.tab) {
return
}
acceptIncomingTab(result.tab, targetId, insertBefore)
},
[acceptIncomingTab, completeTabDrop]
)
const reorderTabs = useCallback((draggedId, targetId, insertBefore) => {
setTabs((current) => {
const fromIndex = current.findIndex(
(tab) => String(tab.id) === String(draggedId)
)
const toIndex = current.findIndex(
(tab) => String(tab.id) === String(targetId)
)
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
return current
}
const next = [...current]
const [moved] = next.splice(fromIndex, 1)
let insertIndex = next.findIndex(
(tab) => String(tab.id) === String(targetId)
)
if (!insertBefore) {
insertIndex += 1
}
next.splice(insertIndex, 0, moved)
return next
})
}, [])
const registerTabPane = useCallback((tabId, element) => {
if (!tabId) return
if (!element) {
tabPaneElsRef.current.delete(tabId)
return
}
tabPaneElsRef.current.set(tabId, element)
}, [])
const captureTabPreview = useCallback((tabId) => {
if (!tabId || tabId === activeTabIdRef.current) {
return Promise.resolve(null)
}
const inFlight = captureInFlightRef.current.get(tabId)
if (inFlight) return inFlight
setTabPreviewCapturing((current) =>
current[tabId] ? current : { ...current, [tabId]: true }
)
const run = async () => {
if (tabId === activeTabIdRef.current) {
return null
}
const pane =
tabPaneElsRef.current.get(tabId) ||
document.querySelector(
`.dashboard-tab-pane[data-tab-id="${CSS.escape(tabId)}"]`
)
if (!pane) return null
try {
const dataUrl = await captureTabPaneImage(pane)
if (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) {
console.error('Failed to capture tab preview', error)
return null
}
}
const next = captureQueueRef.current.then(run, run)
captureInFlightRef.current.set(tabId, next)
captureQueueRef.current = next.then(
() => undefined,
() => undefined
)
next.finally(() => {
if (captureInFlightRef.current.get(tabId) === next) {
captureInFlightRef.current.delete(tabId)
}
setTabPreviewCapturing((current) => {
if (!current[tabId]) return current
const nextCapturing = { ...current }
delete nextCapturing[tabId]
return nextCapturing
})
})
return next
}, [])
useEffect(() => {
const previousId = previousActiveTabIdRef.current
previousActiveTabIdRef.current = activeTabId
if (!hydrated || !previousId || !activeTabId || previousId === activeTabId) {
return
}
if (!tabsRef.current.some((tab) => tab.id === previousId)) return
void captureTabPreview(previousId)
}, [activeTabId, captureTabPreview, hydrated])
const setTabPage = useCallback(({ title, modelName } = {}) => {
const activeId = activeTabIdRef.current
if (!activeId) return
setTabs((current) =>
current.map((tab) => {
if (tab.id !== activeId) return tab
return {
...tab,
title: title || tab.title,
modelName:
modelName === undefined ? tab.modelName : modelName || null
}
})
)
}, [])
const goBack = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
if (!currentTab || currentTab.historyIndex <= 0) return
const nextIndex = currentTab.historyIndex - 1
const nextEntry = currentTab.history[nextIndex]
setTabs((current) =>
current.map((tab) =>
tab.id === currentTab.id ? { ...tab, historyIndex: nextIndex } : tab
)
)
restoreToEntry(nextEntry)
}, [restoreToEntry])
const goForward = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
if (!currentTab) return
if (currentTab.historyIndex >= currentTab.history.length - 1) return
const nextIndex = currentTab.historyIndex + 1
const nextEntry = currentTab.history[nextIndex]
setTabs((current) =>
current.map((tab) =>
tab.id === currentTab.id ? { ...tab, historyIndex: nextIndex } : tab
)
)
restoreToEntry(nextEntry)
}, [restoreToEntry])
const createNewWindow = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
const nextTab = cloneCurrentPageTab(currentTab, location)
void createAppWindow?.({
tabs: [nextTab],
activeTabId: nextTab.id
})
}, [createAppWindow, location])
useEffect(() => {
if (!isElectron) {
setHydrated(true)
return undefined
}
let cancelled = false
const hydrate = async () => {
const deadline = Date.now() + 2000
while (!getDesktopWindowId() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 20))
}
const session = await getWindowSession?.()
if (cancelled) return
if (session?.tabs?.length) {
isRestoringRef.current = true
setTabs(session.tabs)
const nextActiveId =
session.activeTabId &&
session.tabs.some((tab) => tab.id === session.activeTabId)
? session.activeTabId
: session.tabs[0].id
setActiveTabId(nextActiveId)
const activeTab =
session.tabs.find((tab) => tab.id === nextActiveId) ||
session.tabs[0]
const entry = getTabCurrentEntry(activeTab)
if (!entriesEqual(entry, location)) {
restoreToEntry(entry)
} else {
isRestoringRef.current = false
}
} else {
const initialTab = createTabFromLocation(location)
setTabs([initialTab])
setActiveTabId(initialTab.id)
}
setHydrated(true)
}
void hydrate()
return () => {
cancelled = true
}
// Hydrate once per window on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isElectron])
useEffect(() => {
if (!isElectron || !hydrated) return
if (isRestoringRef.current) {
isRestoringRef.current = false
return
}
const entry = locationToEntry(location)
setTabs((current) => {
if (current.length === 0) {
const initialTab = createTabFromLocation(location)
setActiveTabId(initialTab.id)
return [initialTab]
}
return current.map((tab) => {
if (tab.id !== activeTabIdRef.current) return tab
const currentEntry = getTabCurrentEntry(tab)
if (entriesEqual(currentEntry, entry)) return tab
const truncated = tab.history.slice(0, tab.historyIndex + 1)
return {
...tab,
history: [...truncated, entry],
historyIndex: truncated.length
}
})
})
}, [hydrated, isElectron, location])
useEffect(() => {
if (!isElectron || !hydrated || !syncWindowTabs) return undefined
const timeoutId = setTimeout(() => {
void syncWindowTabs({
windowId: getDesktopWindowId(),
activeTabId,
tabs
})
}, 200)
return () => clearTimeout(timeoutId)
}, [activeTabId, hydrated, isElectron, syncWindowTabs, tabs])
useEffect(() => {
if (!isElectron || !registerTabHistoryHandler) return undefined
registerTabHistoryHandler((direction) => {
if (direction === 'back') goBack()
if (direction === 'forward') goForward()
})
return () => registerTabHistoryHandler(null)
}, [goBack, goForward, isElectron, registerTabHistoryHandler])
useEffect(() => {
if (!isElectron || !onNewTabRequest) return undefined
return onNewTabRequest(() => addTab())
}, [addTab, isElectron, onNewTabRequest])
useEffect(() => {
if (!isElectron || !onNewWindowRequest) return undefined
return onNewWindowRequest(() => createNewWindow())
}, [createNewWindow, isElectron, onNewWindowRequest])
useEffect(() => {
if (!isElectron || !onTabMovedAway) return undefined
return onTabMovedAway(({ tabId } = {}) => {
if (!tabId) return
removeTab(tabId, { closeWindowIfLast: true })
})
}, [isElectron, onTabMovedAway, removeTab])
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId) || null,
[activeTabId, tabs]
)
const canGoBack = (activeTab?.historyIndex || 0) > 0
const canGoForward =
(activeTab?.historyIndex || 0) < (activeTab?.history?.length || 1) - 1
const value = useMemo(
() => ({
tabs,
activeTabId,
activeTab,
hydrated,
isElectron,
selectTab,
addTab,
closeTab,
reorderTabs,
acceptIncomingTab,
handleTabDragStart,
handleTabDragEnd,
handleExternalTabDrop,
setTabPage,
goBack,
goForward,
canGoBack,
canGoForward,
createNewWindow,
registerTabPane,
captureTabPreview
}),
[
activeTab,
activeTabId,
addTab,
canGoBack,
canGoForward,
captureTabPreview,
closeTab,
createNewWindow,
goBack,
goForward,
handleExternalTabDrop,
handleTabDragEnd,
handleTabDragStart,
acceptIncomingTab,
hydrated,
isElectron,
registerTabPane,
reorderTabs,
selectTab,
setTabPage,
tabs
]
)
const previewsValue = useMemo(
() => ({
tabPreviews,
tabPreviewCapturing
}),
[tabPreviewCapturing, tabPreviews]
)
const metaValue = useMemo(
() => ({
setTabPage,
isElectron
}),
[isElectron, setTabPage]
)
return (
<NavigationTabsContext.Provider value={value}>
<NavigationTabMetaContext.Provider value={metaValue}>
<NavigationTabPreviewsContext.Provider value={previewsValue}>
{children}
</NavigationTabPreviewsContext.Provider>
</NavigationTabMetaContext.Provider>
</NavigationTabsContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useNavigationTabs = () => {
const context = useContext(NavigationTabsContext)
if (!context) {
throw new Error(
'useNavigationTabs must be used within a NavigationTabsProvider'
)
}
return context
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTabPreview = (tabId) => {
const { tabPreviews, tabPreviewCapturing } = useContext(
NavigationTabPreviewsContext
)
const preview = tabId ? tabPreviews[tabId] : null
return {
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 } = useContext(NavigationTabMetaContext)
const store = useContext(NavigationTabActiveContext)
useEffect(() => {
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 = () => {
const store = useContext(NavigationTabActiveContext)
return useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
)
}
NavigationTabsProvider.propTypes = {
children: PropTypes.node.isRequired
}
export default NavigationTabsContext