From 096968c2227cb0615c9b208c85e1db40130dbb40 Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 28 Jan 2026 18:23:50 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20replace=20side?= =?UTF-8?q?=20panel=20context=20with=20valtio=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit centralize refs/triggers in layout store to keep focus behavior --- .../rooms/livekit/components/SidePanel.tsx | 11 +- .../livekit/contexts/SidePanelContext.tsx | 29 ---- .../livekit/contexts/sidePanelContextValue.ts | 20 --- .../rooms/livekit/hooks/useSidePanel.ts | 78 ++++++++++ .../rooms/livekit/hooks/useSidePanelRef.ts | 9 +- .../livekit/hooks/useSidePanelTriggers.ts | 18 +-- .../rooms/livekit/prefabs/VideoConference.tsx | 138 +++++++++--------- .../rooms/livekit/types/sidePanelTypes.ts | 9 ++ src/frontend/src/stores/layout.ts | 25 +++- 9 files changed, 193 insertions(+), 144 deletions(-) delete mode 100644 src/frontend/src/features/rooms/livekit/contexts/SidePanelContext.tsx delete mode 100644 src/frontend/src/features/rooms/livekit/contexts/sidePanelContextValue.ts create mode 100644 src/frontend/src/features/rooms/livekit/types/sidePanelTypes.ts diff --git a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx index 4fbb0171..af62af9d 100644 --- a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx +++ b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx @@ -189,12 +189,11 @@ const SidePanelContent = () => { onBack={() => (layoutStore.activeSubPanelId = null)} panelRef={panelRef} > - {/* keepAlive preserves focus restoration + state (e.g. scroll/input) across panels; - revisit if memory becomes a concern */} - + {/* keepAlive stays only for Info to reduce memory footprint */} + - + @@ -203,10 +202,10 @@ const SidePanelContent = () => { - + - + diff --git a/src/frontend/src/features/rooms/livekit/contexts/SidePanelContext.tsx b/src/frontend/src/features/rooms/livekit/contexts/SidePanelContext.tsx deleted file mode 100644 index ed9ac512..00000000 --- a/src/frontend/src/features/rooms/livekit/contexts/SidePanelContext.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useRef, ReactNode } from 'react' -import { SidePanelContext, SidePanelTriggerKey } from './sidePanelContextValue' - -export const SidePanelProvider = ({ children }: { children: ReactNode }) => { - const panelRef = useRef(null) - const triggersRef = useRef>({ - participants: null, - tools: null, - info: null, - admin: null, - options: null, - effects: null, - cameraMenu: null, - }) - - const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => { - triggersRef.current[key] = el - } - - const getTrigger = (key: SidePanelTriggerKey) => { - return triggersRef.current[key] ?? null - } - - return ( - - {children} - - ) -} diff --git a/src/frontend/src/features/rooms/livekit/contexts/sidePanelContextValue.ts b/src/frontend/src/features/rooms/livekit/contexts/sidePanelContextValue.ts deleted file mode 100644 index 25d9249f..00000000 --- a/src/frontend/src/features/rooms/livekit/contexts/sidePanelContextValue.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createContext } from 'react' - -export type SidePanelTriggerKey = - | 'participants' - | 'tools' - | 'info' - | 'admin' - | 'options' - | 'effects' - | 'cameraMenu' - -export type SidePanelContextValue = { - panelRef: React.RefObject - setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => void - getTrigger: (key: SidePanelTriggerKey) => HTMLElement | null -} - -export const SidePanelContext = createContext( - null -) diff --git a/src/frontend/src/features/rooms/livekit/hooks/useSidePanel.ts b/src/frontend/src/features/rooms/livekit/hooks/useSidePanel.ts index 96b125e9..689cf830 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useSidePanel.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useSidePanel.ts @@ -1,5 +1,7 @@ import { useSnapshot } from 'valtio' import { layoutStore } from '@/stores/layout' +import { useEffect, useRef } from 'react' +import type { SidePanelTriggerKey } from '../types/sidePanelTypes' export enum PanelId { PARTICIPANTS = 'participants', @@ -19,6 +21,35 @@ export const useSidePanel = () => { const layoutSnap = useSnapshot(layoutStore) const activePanelId = layoutSnap.activePanelId const activeSubPanelId = layoutSnap.activeSubPanelId + const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null) + const prevPanelIdRef = useRef(activePanelId) + + const resolveTrigger = (panelId: PanelId, activeEl: HTMLElement | null) => { + if (activeEl?.tagName === 'DIV') { + if (panelId === PanelId.TOOLS || panelId === PanelId.EFFECTS) { + return layoutStore.sidePanelTriggers.options ?? activeEl + } + } + const triggerKeyByPanel: Partial> = { + [PanelId.PARTICIPANTS]: 'participants', + [PanelId.TOOLS]: 'tools', + [PanelId.INFO]: 'info', + [PanelId.ADMIN]: 'admin', + [PanelId.EFFECTS]: 'effects', + } + const triggerKey = triggerKeyByPanel[panelId] + return triggerKey + ? layoutStore.sidePanelTriggers[triggerKey] ?? activeEl + : activeEl + } + + const storeLastTrigger = (panelId: PanelId) => { + const activeEl = document.activeElement as HTMLElement | null + layoutStore.lastSidePanelTriggerRef.current = resolveTrigger( + panelId, + activeEl + ) + } const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS const isEffectsOpen = activePanelId == PanelId.EFFECTS @@ -32,45 +63,92 @@ export const useSidePanel = () => { const isSubPanelOpen = !!activeSubPanelId const toggleAdmin = () => { + if (!isAdminOpen) storeLastTrigger(PanelId.ADMIN) layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null } const toggleParticipants = () => { + if (!isParticipantsOpen) storeLastTrigger(PanelId.PARTICIPANTS) layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null } const toggleChat = () => { + if (!isChatOpen) storeLastTrigger(PanelId.CHAT) layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null } const toggleEffects = () => { + if (!isEffectsOpen) storeLastTrigger(PanelId.EFFECTS) layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null } const toggleTools = () => { + if (!isToolsOpen) storeLastTrigger(PanelId.TOOLS) layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null } const toggleInfo = () => { + if (!isInfoOpen) storeLastTrigger(PanelId.INFO) layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null } const openTranscript = () => { + storeLastTrigger(PanelId.TOOLS) layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT layoutStore.activePanelId = PanelId.TOOLS } const openScreenRecording = () => { + storeLastTrigger(PanelId.TOOLS) layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING layoutStore.activePanelId = PanelId.TOOLS } + useEffect(() => { + const handleKeyDown = () => { + lastInteractionRef.current = 'keyboard' + } + const handleMouseDown = () => { + lastInteractionRef.current = 'mouse' + } + + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('mousedown', handleMouseDown) + + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('mousedown', handleMouseDown) + } + }, []) + + useEffect(() => { + const wasOpen = prevPanelIdRef.current + + if (wasOpen && !activePanelId) { + const trigger = layoutStore.lastSidePanelTriggerRef.current + if (trigger && document.contains(trigger)) { + trigger.focus({ preventScroll: true }) + if (lastInteractionRef.current === 'keyboard') { + trigger.setAttribute('data-restore-focus-visible', '') + const handleBlur = () => { + if (document.contains(trigger)) { + trigger.removeAttribute('data-restore-focus-visible') + } + } + trigger.addEventListener('blur', handleBlur, { once: true }) + } + } + } + + prevPanelIdRef.current = activePanelId + }, [activePanelId]) + return { activePanelId, activeSubPanelId, diff --git a/src/frontend/src/features/rooms/livekit/hooks/useSidePanelRef.ts b/src/frontend/src/features/rooms/livekit/hooks/useSidePanelRef.ts index cbdcab5d..f1ca61de 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useSidePanelRef.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useSidePanelRef.ts @@ -1,10 +1,5 @@ -import { useContext } from 'react' -import { SidePanelContext } from '../contexts/sidePanelContextValue' +import { layoutStore } from '@/stores/layout' export const useSidePanelRef = () => { - const context = useContext(SidePanelContext) - if (!context) { - throw new Error('useSidePanelRef must be used within SidePanelProvider') - } - return context.panelRef + return layoutStore.sidePanelRef } diff --git a/src/frontend/src/features/rooms/livekit/hooks/useSidePanelTriggers.ts b/src/frontend/src/features/rooms/livekit/hooks/useSidePanelTriggers.ts index 9a2b9562..dbc07843 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useSidePanelTriggers.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useSidePanelTriggers.ts @@ -1,15 +1,13 @@ -import { useContext } from 'react' -import { SidePanelContext } from '../contexts/sidePanelContextValue' +import { layoutStore } from '@/stores/layout' +import type { SidePanelTriggerKey } from '../types/sidePanelTypes' export const useSidePanelTriggers = () => { - const context = useContext(SidePanelContext) - if (!context) { - throw new Error( - 'useSidePanelTriggers must be used within SidePanelProvider' - ) - } return { - setTrigger: context.setTrigger, - getTrigger: context.getTrigger, + setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => { + layoutStore.sidePanelTriggers[key] = el + }, + getTrigger: (key: SidePanelTriggerKey) => { + return layoutStore.sidePanelTriggers[key] ?? null + }, } } diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 3e24a48f..77858a7c 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -27,7 +27,6 @@ import { FocusLayout } from '../components/FocusLayout' import { ParticipantTile } from '../components/ParticipantTile' import { SidePanel } from '../components/SidePanel' import { useSidePanel } from '../hooks/useSidePanel' -import { SidePanelProvider } from '../contexts/SidePanelContext' import { RecordingProvider } from '@/features/recording' import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal' import { useConnectionObserver } from '../hooks/useConnectionObserver' @@ -260,77 +259,74 @@ export function VideoConference({ ...props }: VideoConferenceProps) { value={layoutContext} // onPinChange={handleFocusStateChange} > - - setIsShareErrorVisible(false)} - /> - -
- -
- {!focusTrack ? ( -
- + setIsShareErrorVisible(false)} + /> + +
+ +
+ {!focusTrack ? ( +
+ + + +
+ ) : ( +
+ + - -
- ) : ( -
- - - - - {focusTrack && } - -
- )} -
-
- - -
- { - console.error(e) - if ( - e.source == Track.Source.ScreenShare && - e.error.toString() == - 'NotAllowedError: Permission denied by system' - ) { - setIsShareErrorVisible(true) - } - }} - /> - - + + {focusTrack && } + +
+ )} +
+
+ + +
+ { + console.error(e) + if ( + e.source == Track.Source.ScreenShare && + e.error.toString() == 'NotAllowedError: Permission denied by system' + ) { + setIsShareErrorVisible(true) + } + }} + /> + )} diff --git a/src/frontend/src/features/rooms/livekit/types/sidePanelTypes.ts b/src/frontend/src/features/rooms/livekit/types/sidePanelTypes.ts new file mode 100644 index 00000000..e8f82f76 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/types/sidePanelTypes.ts @@ -0,0 +1,9 @@ +export type SidePanelTriggerKey = + | 'participants' + | 'tools' + | 'info' + | 'admin' + | 'options' + | 'effects' + | 'cameraMenu' + diff --git a/src/frontend/src/stores/layout.ts b/src/frontend/src/stores/layout.ts index b04a24b6..dbc2d504 100644 --- a/src/frontend/src/stores/layout.ts +++ b/src/frontend/src/stores/layout.ts @@ -1,8 +1,11 @@ -import { proxy } from 'valtio' +import { createRef } from 'react' +import { proxy, ref } from 'valtio' import { PanelId, SubPanelId, } from '@/features/rooms/livekit/hooks/useSidePanel' +import type { SidePanelTriggerKey } from '@/features/rooms/livekit/types/sidePanelTypes' +import type { MutableRefObject, RefObject } from 'react' type State = { showHeader: boolean @@ -10,12 +13,32 @@ type State = { showSubtitles: boolean activePanelId: PanelId | null activeSubPanelId: SubPanelId | null + sidePanelRef: RefObject + sidePanelTriggers: Record + lastSidePanelTriggerRef: MutableRefObject } +const sidePanelRef = ref(createRef()) +const lastSidePanelTriggerRef = ref({ + current: null, +} as MutableRefObject) +const sidePanelTriggers = ref>({ + participants: null, + tools: null, + info: null, + admin: null, + options: null, + effects: null, + cameraMenu: null, +}) + export const layoutStore = proxy({ showHeader: false, showFooter: false, showSubtitles: false, activePanelId: null, activeSubPanelId: null, + sidePanelRef, + sidePanelTriggers, + lastSidePanelTriggerRef, })