// src/layout/StatusBar.tsx import { useCallback, useRef, useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react' import type { StatusBarActionManager } from '../plugin/ui/status-bar-action-manager' import type { StatusBarItem } from '../plugin/ui/status-bar-action-types' import { SaveStatusIndicator } from '../sync/SaveStatusIndicator' import type { SaveStatus } from '../sync/sync-contract' import './StatusBar.css' function subscribeOnline(listener: () => void): () => void { window.addEventListener('online', listener) window.addEventListener('offline', listener) return () => { window.removeEventListener('online', listener) window.removeEventListener('offline', listener) } } function isOnline(): boolean { return navigator.onLine } interface StatusBarProps { /** The status bar action manager */ statusBarActions: StatusBarActionManager /** Current save status. When provided, renders a save indicator on the left side. */ saveStatus?: SaveStatus /** Whether the same document is open in another tab. */ isConcurrentTabOpen?: boolean /** Additional React content rendered on the right side before plugin items. */ extraRightItems?: ReactNode /** Additional React content rendered on the left side after the save indicator. */ extraLeftItems?: ReactNode } /** * Status bar component that displays plugin-registered items. * * Subscribes to StatusBarActionManager via useSyncExternalStore and renders * each plugin's HTMLElement by mounting it into a wrapper div via useEffect + ref. * Right-aligned, positioned inside .app-shell-main, above the formatting toolbar. */ export function StatusBar({ statusBarActions, saveStatus, isConcurrentTabOpen, extraRightItems, extraLeftItems }: StatusBarProps) { const online = useSyncExternalStore(subscribeOnline, isOnline, () => true) const subscribe = useCallback( (cb: () => void) => statusBarActions.subscribe(cb), [statusBarActions] ) const getSnapshot = useCallback( () => statusBarActions.getSnapshot(), [statusBarActions] ) const orderedItems = useSyncExternalStore(subscribe, getSnapshot) // Subscribe to visibility setting const isVisible = useSyncExternalStore( subscribe, useCallback(() => statusBarActions.isVisible(), [statusBarActions]) ) const elements = useMemo(() => { return orderedItems.map((item: StatusBarItem) => ( )) }, [orderedItems]) const hasSaveContent = (saveStatus != null && saveStatus.state !== 'idle') || isConcurrentTabOpen === true const hasPluginItems = isVisible && orderedItems.length > 0 const hasExtraRightItems = extraRightItems != null const hasExtraLeftItems = extraLeftItems != null const willRender = hasPluginItems || hasSaveContent || hasExtraRightItems || hasExtraLeftItems || !online if (!willRender) return null return (
{saveStatus && ( )} {!online && saveStatus?.state !== 'saved' && Offline} {hasExtraLeftItems && (
{extraLeftItems}
)}
{hasExtraRightItems && (
{extraRightItems}
)} {hasPluginItems && elements}
) } /** * Mounts a plugin's StatusBarItemEl into a wrapper div via useEffect. * This is necessary because plugins mutate their elements directly via the DOM API. */ function StatusBarItemMount({ item }: { item: StatusBarItem }) { const wrapperRef = useRef(null) useEffect(() => { const wrapper = wrapperRef.current if (!wrapper || !item.el) return wrapper.appendChild(item.el) return () => { // Remove the element from the wrapper on cleanup. // The element still lives in the manager; we're just detaching it from the DOM. if (wrapper.contains(item.el)) { wrapper.removeChild(item.el) } } }, [item.el]) return
}