diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
index d052dcb10..8ad47dda4 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
@@ -32,6 +32,7 @@ import {
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
+import { usePresenterStore } from '@/docs/doc-presenter/stores';
import { useAuth } from '@/features/auth';
import { useFocusStore, useResponsiveStore } from '@/stores';
@@ -82,14 +83,6 @@ const ModalExport =
)
: null;
-const PresenterOverlay = dynamic(
- () =>
- import('@/docs/doc-presenter').then((mod) => ({
- default: mod.PresenterOverlay,
- })),
- { ssr: false },
-);
-
interface DocToolBoxProps {
doc: Doc;
}
@@ -108,11 +101,13 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalHistoryOpen, setIsModalHistoryOpen] = useState(false);
const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
- const [isPresenterOpen, setIsPresenterOpen] = useState(false);
const { restoreFocus, addLastFocus } = useFocusStore();
const { isMobile } = useResponsiveStore();
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({
onSuccess: (data) => {
void router.push(`/docs/${data.id}`);
@@ -148,9 +143,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
label: t('Present'),
icon: ,
callback: () => {
- requestAnimationFrame(() => {
- setIsPresenterOpen(true);
- });
+ openPresenter(0);
},
isHidden: Boolean(doc.deleted_at) || isMobile,
testId: `docs-actions-present-${doc.id}`,
@@ -315,14 +308,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
doc={doc}
/>
)}
- {isPresenterOpen && (
- {
- setIsPresenterOpen(false);
- }}
- />
- )}
>
);
};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterRoot.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterRoot.tsx
new file mode 100644
index 000000000..1a44e0c73
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterRoot.tsx
@@ -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: () => },
+);
+
+/**
+ * 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 ;
+ }
+
+ return (
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/index.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/index.ts
index 0ad4b13a3..fb08387dd 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-presenter/index.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/index.ts
@@ -1 +1,2 @@
-export { PresenterOverlay } from './components/PresenterOverlay';
+export { PresenterRoot } from './components/PresenterRoot';
+export * from './stores';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/index.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/index.ts
new file mode 100644
index 000000000..4bcaa1bd4
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/index.ts
@@ -0,0 +1 @@
+export * from './usePresenterStore';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/usePresenterStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/usePresenterStore.tsx
new file mode 100644
index 000000000..eb39baefa
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/usePresenterStore.tsx
@@ -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((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 });
+ },
+}));
diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
index 3d4d7193d..5eb293367 100644
--- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
+++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
@@ -18,6 +18,7 @@ import {
useTrans,
} from '@/docs/doc-management/';
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
+import { PresenterRoot } from '@/features/docs/doc-presenter';
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
import { DocEditorSkeleton, useSkeletonStore } from '@/features/skeletons';
import { MainLayout } from '@/layouts';
@@ -68,6 +69,7 @@ export function DocLayout() {
+
>
);