diff --git a/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts index b6f4cb367..c42c2ed57 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { createDoc, goToGridDoc, verifyDocName } from './utils-common'; +import { tryFocusEditorContent } from './utils-editor'; import { createRootSubPage } from './utils-sub-pages'; test.describe('Left panel desktop', () => { @@ -174,4 +175,47 @@ test.describe('Left panel responsive', () => { await verifyDocName(page, docChild); await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport(); }); + + test('checks panel coordination on tablet sizes', async ({ + page, + browserName, + }) => { + await page.setViewportSize({ width: 900, height: 1200 }); + await page.goto('/'); + + await createDoc(page, 'tablet-doc-test', browserName, 1); + + const leftPanel = page.locator('.--docs--resizable-left-panel'); + const rightPanel = page.getByLabel('Table of contents side panel'); + + // Initially, left panel should be visible and right panel should be hidden + await expect(leftPanel).toBeInViewport(); + await expect(rightPanel).not.toBeInViewport(); + await tryFocusEditorContent({ page }); + await page.keyboard.type('# Level 1'); + + // Open right panel, the left panel should hide + await page + .getByRole('button', { name: 'Show the table of contents sidebar' }) + .click(); + await expect(rightPanel).toBeInViewport(); + await expect(leftPanel).toBeHidden(); + + // Open left panel, the right panel should hide + await page.getByRole('button', { name: /Show the side panel/ }).click(); + await expect(leftPanel).toBeInViewport(); + await expect(rightPanel).not.toBeInViewport(); + + // Close the left panel, the right panel should show + await page.getByRole('button', { name: /Hide the side panel/ }).click(); + await expect(leftPanel).toBeHidden(); + await expect(rightPanel).toBeInViewport(); + + // Close right panel, the left panel should stay closed + await page + .getByRole('button', { name: 'Hide the table of contents sidebar' }) + .click(); + await expect(rightPanel).not.toBeInViewport(); + await expect(leftPanel).toBeHidden(); + }); }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index aecb0409e..852b69a4a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -29,7 +29,7 @@ import { useConfig } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; import { Doc } from '@/docs/doc-management'; import { avatarUrlFromName, useAuth } from '@/features/auth'; -import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore'; +import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore'; import { useAnalytics } from '@/libs/Analytics'; import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentSideBar.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentSideBar.tsx index 1b669893f..3d0ea6cbe 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentSideBar.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentSideBar.tsx @@ -8,7 +8,7 @@ import CommentsIcon from '@/assets/icons/ui-kit/bubble-text.svg'; import SortingResolvedSVG from '@/assets/icons/ui-kit/filter-notification.svg'; import SortingOpenSVG from '@/assets/icons/ui-kit/filter_list.svg'; import { Box, ButtonCloseModal, Text } from '@/components/'; -import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore'; +import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore'; import { useCommentSidebarStore } from './useCommentSidebarStore'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/TableContentSideBar.tsx b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/TableContentSideBar.tsx index fe25b9771..6a25fe485 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/TableContentSideBar.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/TableContentSideBar.tsx @@ -8,7 +8,7 @@ import { Box, ButtonCloseModal, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore'; import { useHeadingStore } from '@/docs/doc-editor/stores/useHeadingStore'; -import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore'; +import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore'; import { MAIN_LAYOUT_ID } from '@/layouts/conf'; import { Heading } from './Heading'; diff --git a/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx b/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx index b1feb70ee..3820d76e0 100644 --- a/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx @@ -9,14 +9,22 @@ type TogglePanelArgs = { interface LeftPanelState { isPanelOpen: boolean; isPanelOpenMobile: boolean; + /** + * Depending on the responsive breakpoint, the panel can be auto-closed, and so + * auto-opened later on. + */ + wasAutoClosed: boolean; togglePanel: (args?: TogglePanelArgs) => void; closePanel: (args?: TogglePanelType) => void; + autoClose: () => void; } export const useLeftPanelStore = create((set, get) => ({ isPanelOpen: true, isPanelOpenMobile: false, + wasAutoClosed: false, togglePanel: ({ value, type }: TogglePanelArgs = {}) => { + set({ wasAutoClosed: false }); if (typeof value === 'boolean') { if (type === 'mobile') { set({ isPanelOpenMobile: value }); @@ -42,6 +50,7 @@ export const useLeftPanelStore = create((set, get) => ({ set({ isPanelOpen: !isPanelOpen, isPanelOpenMobile: !isPanelOpenMobile }); }, closePanel: ({ type }: Partial = {}) => { + set({ wasAutoClosed: false }); if (type === 'mobile') { set({ isPanelOpenMobile: false }); return; @@ -52,4 +61,5 @@ export const useLeftPanelStore = create((set, get) => ({ } set({ isPanelOpen: false, isPanelOpenMobile: false }); }, + autoClose: () => set({ isPanelOpen: false, wasAutoClosed: true }), })); diff --git a/src/frontend/apps/impress/src/features/right-panel/components/RightPanel.tsx b/src/frontend/apps/impress/src/features/right-panel/components/RightPanel.tsx index 948e1a111..6243a6bd0 100644 --- a/src/frontend/apps/impress/src/features/right-panel/components/RightPanel.tsx +++ b/src/frontend/apps/impress/src/features/right-panel/components/RightPanel.tsx @@ -9,7 +9,10 @@ import { TableContentSideBar } from '@/features/docs/doc-table-content/component import { HEADER_HEIGHT } from '@/features/header'; import { useResponsiveStore } from '@/stores'; -import { RightPanelView, useRightPanelStore } from './useRightPanelStore'; +import { + RightPanelView, + useRightPanelStore, +} from '../stores/useRightPanelStore'; export const RightPanel = () => { const { t } = useTranslation(); diff --git a/src/frontend/apps/impress/src/features/right-panel/components/useRightPanelStore.tsx b/src/frontend/apps/impress/src/features/right-panel/stores/useRightPanelStore.tsx similarity index 54% rename from src/frontend/apps/impress/src/features/right-panel/components/useRightPanelStore.tsx rename to src/frontend/apps/impress/src/features/right-panel/stores/useRightPanelStore.tsx index 6ffbbbbb8..4f6073587 100644 --- a/src/frontend/apps/impress/src/features/right-panel/components/useRightPanelStore.tsx +++ b/src/frontend/apps/impress/src/features/right-panel/stores/useRightPanelStore.tsx @@ -5,20 +5,37 @@ export type RightPanelView = 'tableContent' | 'comments'; export interface UseRightPanelStore { isPanelOpen: boolean; activePanel: RightPanelView | null; + /** + * Depending on the responsive breakpoint, the panel can be auto-closed, and so + * auto-opened later on. + */ + wasAutoClosed: boolean; setActivePanel: (panel: RightPanelView | null) => void; setIsPanelOpen: (isOpen: boolean) => void; togglePanel: () => void; + autoClose: () => void; } export const useRightPanelStore = create((set) => ({ isPanelOpen: false, activePanel: null, + wasAutoClosed: false, setActivePanel: (activePanel) => - set(() => ({ activePanel, isPanelOpen: activePanel !== null })), + set(() => ({ + activePanel, + isPanelOpen: activePanel !== null, + wasAutoClosed: false, + })), setIsPanelOpen: (isPanelOpen) => set((state) => ({ isPanelOpen, activePanel: isPanelOpen ? state.activePanel : null, + wasAutoClosed: false, })), - togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })), + togglePanel: () => + set((state) => ({ + isPanelOpen: !state.isPanelOpen, + wasAutoClosed: false, + })), + autoClose: () => set({ isPanelOpen: false, wasAutoClosed: true }), })); diff --git a/src/frontend/apps/impress/src/layouts/MainLayout.tsx b/src/frontend/apps/impress/src/layouts/MainLayout.tsx index b3546cd31..9202e851b 100644 --- a/src/frontend/apps/impress/src/layouts/MainLayout.tsx +++ b/src/frontend/apps/impress/src/layouts/MainLayout.tsx @@ -11,6 +11,7 @@ import { DocEditorSkeleton, Skeleton } from '@/features/skeletons'; import { useResponsiveStore } from '@/stores'; import { MAIN_LAYOUT_ID } from './conf'; +import { usePanelCoordination } from './usePanelCoordination'; type MainLayoutProps = { backgroundColor?: 'white' | 'grey'; @@ -56,18 +57,9 @@ export function MainLayoutContent({ if (enableResizablePanel) { return ( - }> - - - {children} - - - - + + {children} + ); } @@ -96,6 +88,32 @@ export function MainLayoutContent({ ); } +interface MainResizableLayoutProps { + backgroundColor: 'white' | 'grey'; +} + +const MainResizableLayout = ({ + children, + backgroundColor, +}: PropsWithChildren) => { + usePanelCoordination(); + + return ( + }> + + + {children} + + + + + ); +}; + type MainContentProps = BoxProps & { backgroundColor: 'white' | 'grey'; }; diff --git a/src/frontend/apps/impress/src/layouts/usePanelCoordination.tsx b/src/frontend/apps/impress/src/layouts/usePanelCoordination.tsx new file mode 100644 index 000000000..50c0c715c --- /dev/null +++ b/src/frontend/apps/impress/src/layouts/usePanelCoordination.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef } from 'react'; + +import { useLeftPanelStore } from '@/features/left-panel'; +import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore'; +import { useResponsiveStore } from '@/stores'; + +/** + * Coordinates left/right panel visibility on tablet breakpoint. + * On tablet, the place is too tight to have both panels open at the same time, so we auto-close + * panels depending on the user actions. + */ +export function usePanelCoordination(): void { + const { screenSize } = useResponsiveStore(); + const { + isPanelOpen: isLeftPanelOpen, + togglePanel: toggleLeftPanel, + autoClose: autoCloseLeft, + } = useLeftPanelStore(); + const { + isPanelOpen: isRightPanelOpen, + setActivePanel: setRightActivePanel, + autoClose: autoCloseRight, + } = useRightPanelStore(); + + const prevScreenSizeRef = useRef(screenSize); + const prevRightPanelOpenRef = useRef(isRightPanelOpen); + + /** + * Case 1 – entering tablet with both panels open → auto-close left. + * Case 2 – leaving tablet → reopen left if it was auto-closed. + * Case 3 – right panel opens on tablet → auto-close left if open. + * Case 4 – right panel closes on tablet → reopen left if auto-closed. + */ + useEffect(() => { + const prevScreenSize = prevScreenSizeRef.current; + const prevRightOpen = prevRightPanelOpenRef.current; + prevScreenSizeRef.current = screenSize; + prevRightPanelOpenRef.current = isRightPanelOpen; + + // Case 1 – entering tablet + if (screenSize === 'tablet' && prevScreenSize !== 'tablet') { + if (isRightPanelOpen && useLeftPanelStore.getState().isPanelOpen) { + autoCloseLeft(); + } + return; + } + + // Case 2 – leaving tablet + if (screenSize !== 'tablet' && prevScreenSize === 'tablet') { + if (useLeftPanelStore.getState().wasAutoClosed) { + toggleLeftPanel({ type: 'desktop', value: true }); + } + return; + } + + // Case 3 – right opens on tablet + if (screenSize === 'tablet' && isRightPanelOpen && !prevRightOpen) { + if (useLeftPanelStore.getState().isPanelOpen) { + autoCloseLeft(); + } + return; + } + + // Case 4 – right closes on tablet + if (screenSize === 'tablet' && !isRightPanelOpen && prevRightOpen) { + if (useLeftPanelStore.getState().wasAutoClosed) { + toggleLeftPanel({ type: 'desktop', value: true }); + } + return; + } + }, [screenSize, isRightPanelOpen, autoCloseLeft, toggleLeftPanel]); + + /** + * Exception – force-open / symmetric restore. + * + * Left opens on tablet (right is open) → close right (save activePanel). + * Left closes on tablet (right was auto-closed) → reopen right. + */ + useEffect(() => { + if (useResponsiveStore.getState().screenSize !== 'tablet') { + return; + } + if (isLeftPanelOpen) { + // User force-opened left: close right and remember which view was active. + if (useRightPanelStore.getState().isPanelOpen) { + autoCloseRight(); + } + } else { + // User closed left: reopen right if it was auto-closed. + const { wasAutoClosed, activePanel: savedPanel } = + useRightPanelStore.getState(); + if (wasAutoClosed && savedPanel) { + setRightActivePanel(savedPanel); + } + } + }, [isLeftPanelOpen, autoCloseRight, setRightActivePanel]); +}