diff --git a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts index a6bbb82ad..e7e21f52b 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts @@ -382,6 +382,27 @@ test.describe('Presenter Mode', () => { ); }); + test('opens the presenter at the targeted slide from a block side menu', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-side-menu', browserName, 1); + await writeMultiSlideDoc(page); + + await page + .locator('.bn-block-outer') + .filter({ hasText: 'Slide two' }) + .first() + .hover(); + await page.locator('.bn-side-menu > button').last().click(); + await page.getByRole('menuitem', { name: 'Present from here' }).click(); + + const overlay = page.getByRole('dialog', { name: 'Presenter mode' }); + await expect(overlay).toBeVisible(); + await expect(overlay.getByText('3 / 4')).toBeVisible(); + await expect(overlay.getByText('Slide two')).toBeVisible(); + }); + test('opens presenter deep-links and normalizes slide params', async ({ page, browserName, diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts index c5cd53469..07d3740b1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { getSlideTitle, splitBlocksIntoSlides } from '../hooks/useSlides'; +import { + getContentSlideIndexForBlock, + getSlideTitle, + splitBlocksIntoSlides, +} from '../hooks/useSlides'; import type { PresenterBlock } from '../types'; type TestBlock = PresenterBlock; @@ -23,13 +27,14 @@ const textContent = (text: string) => ({ type: 'text' as const, }); -const para = (text = 'hello'): TestBlock => +const para = (text = 'hello', id?: string): TestBlock => block('paragraph', { content: text === '' ? [] : [textContent(text)], + ...(id ? { id } : {}), }); -const divider = (children: TestBlock[] = []): TestBlock => - block('divider', { children }); +const divider = (children: TestBlock[] = [], id?: string): TestBlock => + block('divider', { children, ...(id ? { id } : {}) }); const image = (): TestBlock => block('image', { props: { url: 'x' } }); @@ -201,6 +206,134 @@ describe('splitBlocksIntoSlides', () => { }); }); +describe('getContentSlideIndexForBlock', () => { + test('returns the slide containing a regular block', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider(), para('b', 'b'), para('c', 'c')], + 'c', + ), + ).toBe(1); + }); + + test('returns the following slide for a divider', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider(undefined, 'divider'), para('b', 'b')], + 'divider', + ), + ).toBe(1); + }); + + test('returns the first non-empty following slide for consecutive dividers', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + divider(undefined, 'first-divider'), + divider(undefined, 'second-divider'), + para('b', 'b'), + ], + 'first-divider', + ), + ).toBe(1); + }); + + test('skips empty-only slides after dividers when mapping divider links', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + divider(undefined, 'first-divider'), + para('', 'empty-after-divider'), + divider(undefined, 'second-divider'), + para('b', 'b'), + ], + 'first-divider', + ), + ).toBe(1); + }); + + test('maps a stripped empty block after a divider to the following content slide', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + divider(undefined, 'divider'), + para('', 'empty-after-divider'), + para('b', 'b'), + ], + 'empty-after-divider', + ), + ).toBe(1); + }); + + test('maps a stripped empty block before a divider to the previous content slide', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + para('', 'empty-before-divider'), + divider(undefined, 'divider'), + para('b', 'b'), + ], + 'empty-before-divider', + ), + ).toBe(0); + }); + + test('maps a stripped empty-only slide to the next rendered content slide', () => { + expect( + getContentSlideIndexForBlock( + [ + para('a', 'a'), + divider(undefined, 'first-divider'), + para('', 'empty-between-dividers'), + divider(undefined, 'second-divider'), + para('b', 'b'), + ], + 'empty-between-dividers', + ), + ).toBe(1); + }); + + test('maps a leading divider to the first content slide', () => { + expect( + getContentSlideIndexForBlock( + [divider(undefined, 'divider'), para('a', 'a')], + 'divider', + ), + ).toBe(0); + }); + + test('maps a trailing divider to the last rendered content slide', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider(undefined, 'divider')], + 'divider', + ), + ).toBe(0); + }); + + test('finds blocks nested under a structural divider', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider([para('nested', 'nested')], 'divider')], + 'nested', + ), + ).toBe(1); + }); + + test('returns the first content slide when the block is missing', () => { + expect( + getContentSlideIndexForBlock( + [para('a', 'a'), divider(), para('b', 'b')], + 'x', + ), + ).toBe(0); + }); +}); + const heading = (text: string): TestBlock => block('heading', { content: [textContent(text)] });