Skip to content

Commit b139069

Browse files
wip
1 parent e747e03 commit b139069

14 files changed

Lines changed: 1093 additions & 0 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,8 @@ db.sqlite3
8282

8383
# Cursor rules
8484
.cursorrules
85+
86+
# Claude
87+
CLAUDE.md
88+
.claude/
89+
openspec/

src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
77
import { css } from 'styled-components';
88

99
import AddLinkSVG from '@/assets/icons/ui-kit/add_link.svg';
10+
import CoPresentSVG from '@/assets/icons/ui-kit/co_present.svg';
1011
import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg';
1112
import DeleteSVG from '@/assets/icons/ui-kit/delete.svg';
1213
import DownloadSVG from '@/assets/icons/ui-kit/download.svg';
@@ -79,6 +80,14 @@ const ModalExport =
7980
)
8081
: null;
8182

83+
const PresenterOverlay = dynamic(
84+
() =>
85+
import('@/docs/doc-presenter').then((mod) => ({
86+
default: mod.PresenterOverlay,
87+
})),
88+
{ ssr: false },
89+
);
90+
8291
interface DocToolBoxProps {
8392
doc: Doc;
8493
}
@@ -93,6 +102,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
93102

94103
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
95104
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
105+
const [isPresenterOpen, setIsPresenterOpen] = useState(false);
96106
const selectHistoryModal = useModal();
97107
const modalShare = useModal();
98108

@@ -176,6 +186,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
176186
showSeparator: true,
177187
show: !emoji && doc.abilities.partial_update && !isTopRoot,
178188
},
189+
{
190+
label: t('Present'),
191+
icon: <CoPresentSVG width={24} height={24} aria-hidden="true" />,
192+
callback: () => {
193+
setIsPresenterOpen(true);
194+
},
195+
show: !doc.deleted_at && !isSmallMobile,
196+
testId: `docs-actions-present-${doc.id}`,
197+
},
179198
{
180199
label: t('Copy link'),
181200
icon: <AddLinkSVG width={24} height={24} aria-hidden="true" />,
@@ -320,6 +339,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
320339
doc={doc}
321340
/>
322341
)}
342+
{isPresenterOpen && (
343+
<PresenterOverlay
344+
doc={doc}
345+
onClose={() => {
346+
setIsPresenterOpen(false);
347+
restoreFocus();
348+
}}
349+
/>
350+
)}
323351
</Box>
324352
);
325353
};
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import {
3+
afterEach,
4+
beforeEach,
5+
describe,
6+
expect,
7+
test,
8+
vi,
9+
} from 'vitest';
10+
11+
import { AppWrapper } from '@/tests/utils';
12+
13+
const requestFullscreen = vi.fn(async () => {});
14+
const exitFullscreen = vi.fn(async () => {});
15+
16+
vi.mock('@/docs/doc-editor/components/BlockNoteEditor', () => ({
17+
blockNoteSchema: {},
18+
}));
19+
20+
vi.mock('@/docs/doc-editor/styles', () => ({
21+
cssEditor: '',
22+
}));
23+
24+
vi.mock('@blocknote/mantine', () => ({
25+
BlockNoteView: ({ editor: _editor }: { editor: unknown }) => (
26+
<div data-testid="blocknote-view" />
27+
),
28+
}));
29+
30+
vi.mock('@blocknote/react', () => ({
31+
useCreateBlockNote: () => ({}),
32+
}));
33+
34+
const editorDocument = [
35+
{ type: 'heading', content: [{ type: 'text', text: 'Slide 1' }] },
36+
{ type: 'divider' },
37+
{ type: 'paragraph', content: [{ type: 'text', text: 'Slide 2 body' }] },
38+
{ type: 'divider' },
39+
{ type: 'paragraph', content: [{ type: 'text', text: 'Slide 3 body' }] },
40+
{ type: 'divider' },
41+
// Empty group between two dividers — must be dropped.
42+
{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] },
43+
];
44+
45+
vi.mock('@/docs/doc-editor/stores', () => ({
46+
useEditorStore: (selector: (s: unknown) => unknown) =>
47+
selector({ editor: { document: editorDocument } }),
48+
}));
49+
50+
import { PresenterOverlay } from '../components/PresenterOverlay';
51+
52+
describe('PresenterOverlay', () => {
53+
beforeEach(() => {
54+
Object.defineProperty(document, 'fullscreenElement', {
55+
configurable: true,
56+
get: () => null,
57+
});
58+
Object.defineProperty(document.documentElement, 'requestFullscreen', {
59+
configurable: true,
60+
value: requestFullscreen,
61+
});
62+
Object.defineProperty(document, 'exitFullscreen', {
63+
configurable: true,
64+
value: exitFullscreen,
65+
});
66+
requestFullscreen.mockClear();
67+
exitFullscreen.mockClear();
68+
});
69+
70+
afterEach(() => {
71+
vi.clearAllMocks();
72+
});
73+
74+
const doc = { id: 'd1', deleted_at: null } as never;
75+
76+
test('renders 3 slides (empty group dropped) and starts at slide 1/3', () => {
77+
render(
78+
<AppWrapper>
79+
<PresenterOverlay doc={doc} onClose={vi.fn()} />
80+
</AppWrapper>,
81+
);
82+
expect(screen.getByText('1 / 3')).toBeInTheDocument();
83+
});
84+
85+
test('ArrowRight navigates to the next slide', () => {
86+
render(
87+
<AppWrapper>
88+
<PresenterOverlay doc={doc} onClose={vi.fn()} />
89+
</AppWrapper>,
90+
);
91+
fireEvent.keyDown(window, { code: 'ArrowRight' });
92+
expect(screen.getByText('2 / 3')).toBeInTheDocument();
93+
});
94+
95+
test('clicking close invokes onClose', () => {
96+
const onClose = vi.fn();
97+
render(
98+
<AppWrapper>
99+
<PresenterOverlay doc={doc} onClose={onClose} />
100+
</AppWrapper>,
101+
);
102+
fireEvent.click(screen.getByRole('button', { name: 'Close presenter' }));
103+
expect(onClose).toHaveBeenCalledTimes(1);
104+
});
105+
106+
test('next is disabled on the last slide', () => {
107+
render(
108+
<AppWrapper>
109+
<PresenterOverlay doc={doc} onClose={vi.fn()} />
110+
</AppWrapper>,
111+
);
112+
fireEvent.keyDown(window, { code: 'End' });
113+
expect(screen.getByText('3 / 3')).toBeInTheDocument();
114+
expect(
115+
(screen.getByRole('button', { name: 'Next slide' }) as HTMLButtonElement)
116+
.disabled,
117+
).toBe(true);
118+
});
119+
120+
test('mounting does NOT auto-enter fullscreen', () => {
121+
render(
122+
<AppWrapper>
123+
<PresenterOverlay doc={doc} onClose={vi.fn()} />
124+
</AppWrapper>,
125+
);
126+
expect(requestFullscreen).not.toHaveBeenCalled();
127+
});
128+
129+
test('clicking the fullscreen toggle calls requestFullscreen on documentElement', () => {
130+
render(
131+
<AppWrapper>
132+
<PresenterOverlay doc={doc} onClose={vi.fn()} />
133+
</AppWrapper>,
134+
);
135+
requestFullscreen.mockClear();
136+
fireEvent.click(
137+
screen.getByRole('button', { name: 'Enter fullscreen' }),
138+
);
139+
expect(requestFullscreen).toHaveBeenCalled();
140+
});
141+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
3+
4+
import { useBrowserFullscreen } from '../hooks/useBrowserFullscreen';
5+
6+
describe('useBrowserFullscreen', () => {
7+
let fullscreenElement: Element | null = null;
8+
const requestFullscreen = vi.fn(async () => {
9+
fullscreenElement = document.documentElement;
10+
document.dispatchEvent(new Event('fullscreenchange'));
11+
});
12+
const exitFullscreen = vi.fn(async () => {
13+
fullscreenElement = null;
14+
document.dispatchEvent(new Event('fullscreenchange'));
15+
});
16+
17+
beforeEach(() => {
18+
fullscreenElement = null;
19+
Object.defineProperty(document, 'fullscreenElement', {
20+
configurable: true,
21+
get: () => fullscreenElement,
22+
});
23+
Object.defineProperty(document.documentElement, 'requestFullscreen', {
24+
configurable: true,
25+
value: requestFullscreen,
26+
});
27+
Object.defineProperty(document, 'exitFullscreen', {
28+
configurable: true,
29+
value: exitFullscreen,
30+
});
31+
requestFullscreen.mockClear();
32+
exitFullscreen.mockClear();
33+
});
34+
35+
afterEach(() => {
36+
fullscreenElement = null;
37+
});
38+
39+
test('initial state reflects current fullscreen state', () => {
40+
const { result } = renderHook(() => useBrowserFullscreen());
41+
expect(result.current.isFullscreen).toBe(false);
42+
});
43+
44+
test('enter() requests fullscreen and updates state', async () => {
45+
const { result } = renderHook(() => useBrowserFullscreen());
46+
await act(async () => {
47+
await result.current.enter();
48+
});
49+
expect(requestFullscreen).toHaveBeenCalledTimes(1);
50+
expect(result.current.isFullscreen).toBe(true);
51+
});
52+
53+
test('enter() is a no-op if already fullscreen', async () => {
54+
fullscreenElement = document.documentElement;
55+
const { result } = renderHook(() => useBrowserFullscreen());
56+
await act(async () => {
57+
await result.current.enter();
58+
});
59+
expect(requestFullscreen).not.toHaveBeenCalled();
60+
});
61+
62+
test('exit() leaves fullscreen and updates state', async () => {
63+
const { result } = renderHook(() => useBrowserFullscreen());
64+
await act(async () => {
65+
await result.current.enter();
66+
});
67+
await act(async () => {
68+
await result.current.exit();
69+
});
70+
expect(exitFullscreen).toHaveBeenCalledTimes(1);
71+
expect(result.current.isFullscreen).toBe(false);
72+
});
73+
74+
test('toggle() flips state', async () => {
75+
const { result } = renderHook(() => useBrowserFullscreen());
76+
await act(async () => {
77+
await result.current.toggle();
78+
});
79+
expect(result.current.isFullscreen).toBe(true);
80+
await act(async () => {
81+
await result.current.toggle();
82+
});
83+
expect(result.current.isFullscreen).toBe(false);
84+
});
85+
86+
test('reacts to external fullscreenchange events', () => {
87+
const { result } = renderHook(() => useBrowserFullscreen());
88+
act(() => {
89+
fullscreenElement = document.documentElement;
90+
document.dispatchEvent(new Event('fullscreenchange'));
91+
});
92+
expect(result.current.isFullscreen).toBe(true);
93+
});
94+
});

0 commit comments

Comments
 (0)