♻️(frontend) drive the presenter from a store and one mount point

Move presenter ownership out of DocToolBox into a top-level
PresenterRoot driven by a zustand store, lazy-load the overlay and paint
a boot cover while the editor boots. Groundwork for the deep-link and
present-from-here features (#2466).
This commit is contained in:
Nathan Panchout
2026-07-20 09:55:20 +02:00
parent 8d2dd5b565
commit 877109be79
6 changed files with 119 additions and 21 deletions
@@ -32,6 +32,7 @@ import {
useDocUtils, useDocUtils,
useDuplicateDoc, useDuplicateDoc,
} from '@/docs/doc-management'; } from '@/docs/doc-management';
import { usePresenterStore } from '@/docs/doc-presenter/stores';
import { useAuth } from '@/features/auth'; import { useAuth } from '@/features/auth';
import { useFocusStore, useResponsiveStore } from '@/stores'; import { useFocusStore, useResponsiveStore } from '@/stores';
@@ -82,14 +83,6 @@ const ModalExport =
) )
: null; : null;
const PresenterOverlay = dynamic(
() =>
import('@/docs/doc-presenter').then((mod) => ({
default: mod.PresenterOverlay,
})),
{ ssr: false },
);
interface DocToolBoxProps { interface DocToolBoxProps {
doc: Doc; doc: Doc;
} }
@@ -108,11 +101,13 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const [isModalShareOpen, setIsModalShareOpen] = useState(false); const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalHistoryOpen, setIsModalHistoryOpen] = useState(false); const [isModalHistoryOpen, setIsModalHistoryOpen] = useState(false);
const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false); const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
const [isPresenterOpen, setIsPresenterOpen] = useState(false);
const { restoreFocus, addLastFocus } = useFocusStore(); const { restoreFocus, addLastFocus } = useFocusStore();
const { isMobile } = useResponsiveStore(); const { isMobile } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id); const copyDocLink = useCopyDocLink(doc.id);
// Deep-link (#2397) and slide/URL sync live in PresenterRoot; here we only
// trigger the manual "Present" action.
const openPresenter = usePresenterStore((state) => state.open);
const { mutate: duplicateDoc } = useDuplicateDoc({ const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: (data) => { onSuccess: (data) => {
void router.push(`/docs/${data.id}`); void router.push(`/docs/${data.id}`);
@@ -148,9 +143,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
label: t('Present'), label: t('Present'),
icon: <Present width={24} height={24} aria-hidden="true" />, icon: <Present width={24} height={24} aria-hidden="true" />,
callback: () => { callback: () => {
requestAnimationFrame(() => { openPresenter(0);
setIsPresenterOpen(true);
});
}, },
isHidden: Boolean(doc.deleted_at) || isMobile, isHidden: Boolean(doc.deleted_at) || isMobile,
testId: `docs-actions-present-${doc.id}`, testId: `docs-actions-present-${doc.id}`,
@@ -315,14 +308,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
doc={doc} doc={doc}
/> />
)} )}
{isPresenterOpen && (
<PresenterOverlay
doc={doc}
onClose={() => {
setIsPresenterOpen(false);
}}
/>
)}
</> </>
); );
}; };
@@ -0,0 +1,86 @@
import dynamic from 'next/dynamic';
import { useCallback, useEffect } from 'react';
import { css } from 'styled-components';
import { Loading } from '@/components';
import { useEditorStore } from '@/docs/doc-editor/stores';
import { useDocStore } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { usePresenterStore } from '../stores';
const coverCss = css`
position: fixed;
inset: 0;
z-index: 1000;
background: white;
`;
const PresenterOverlay = dynamic(
() =>
import('./PresenterOverlay').then((mod) => ({
default: mod.PresenterOverlay,
})),
{ ssr: false, loading: () => <Loading $css={coverCss} /> },
);
/**
* Single mount point for the presenter, rendered high in the tree (doc page).
* Drives the manual "Present" action via `usePresenterStore`, painting a boot
* cover until the editor snapshot is available.
*/
export const PresenterRoot = () => {
const { isMobile } = useResponsiveStore();
const editor = useEditorStore((state) => state.editor);
const { currentDoc } = useDocStore();
const { initialSlideIndex, isOpen, close } = usePresenterStore();
const handleClose = useCallback(() => {
close();
}, [close]);
useEffect(() => {
if (isMobile && isOpen) {
close();
}
}, [isMobile, isOpen, close]);
useEffect(() => {
return () => {
close();
};
}, [close]);
const active = !isMobile && isOpen;
const isBooting = active && (!editor || !currentDoc);
// Let users escape the boot cover if the editor never finishes loading.
useEffect(() => {
if (!isBooting) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isBooting, handleClose]);
if (!active) {
return null;
}
if (!editor || !currentDoc) {
return <Loading $css={coverCss} />;
}
return (
<PresenterOverlay
doc={currentDoc}
initialSlideIndex={initialSlideIndex}
onClose={handleClose}
/>
);
};
@@ -1 +1,2 @@
export { PresenterOverlay } from './components/PresenterOverlay'; export { PresenterRoot } from './components/PresenterRoot';
export * from './stores';
@@ -0,0 +1 @@
export * from './usePresenterStore';
@@ -0,0 +1,23 @@
import { create } from 'zustand';
export interface PresenterState {
isOpen: boolean;
/** 0-based slide to start the presentation on. */
initialSlideIndex: number;
open: (index?: number) => void;
close: () => void;
}
export const usePresenterStore = create<PresenterState>((set) => ({
isOpen: false,
initialSlideIndex: 0,
open: (index = 0) => {
const safeSlideIndex = Number.isFinite(index)
? Math.max(0, Math.trunc(index))
: 0;
set({ isOpen: true, initialSlideIndex: safeSlideIndex });
},
close: () => {
set({ isOpen: false });
},
}));
@@ -18,6 +18,7 @@ import {
useTrans, useTrans,
} from '@/docs/doc-management/'; } from '@/docs/doc-management/';
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth'; import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
import { PresenterRoot } from '@/features/docs/doc-presenter';
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/'; import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
import { DocEditorSkeleton, useSkeletonStore } from '@/features/skeletons'; import { DocEditorSkeleton, useSkeletonStore } from '@/features/skeletons';
import { MainLayout } from '@/layouts'; import { MainLayout } from '@/layouts';
@@ -68,6 +69,7 @@ export function DocLayout() {
<DocFloatingBar /> <DocFloatingBar />
<DocPage id={id} /> <DocPage id={id} />
</MainLayout> </MainLayout>
<PresenterRoot />
</TreeProvider> </TreeProvider>
</> </>
); );