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 a194f5b2e0..7c9dead1e6 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 @@ -1,7 +1,19 @@ -import { Page, expect, test } from '@playwright/test'; - -import { createDoc, goToGridDoc, mockedDocument } from './utils-common'; -import { openSuggestionMenu, writeInEditor } from './utils-editor'; +import path from 'path'; + +import { Locator, Page, expect, test } from '@playwright/test'; +import { PDFParse } from 'pdf-parse'; + +import { + createDoc, + goToGridDoc, + mockedDocument, + saveContent, +} from './utils-common'; +import { + openSuggestionMenu, + tryFocusEditorContent, + writeInEditor, +} from './utils-editor'; const openPresenter = async (page: Page) => { await page.getByLabel('Open the document options').click(); @@ -17,6 +29,37 @@ const insertDivider = async (page: Page) => { await suggestionMenu.getByText('Divider', { exact: true }).click(); }; +const insertImageInCurrentBlock = async (page: Page) => { + const fileChooserPromise = page.waitForEvent('filechooser'); + + await tryFocusEditorContent({ page }); + await page.keyboard.type('/'); + await page + .locator('.bn-suggestion-menu') + .getByText('Resizable image with caption', { exact: true }) + .click(); + await page.getByText('Upload image').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( + path.join(__dirname, 'assets/logo-suite-numerique.png'), + ); + + const image = page + .locator('.--docs--editor-container img.bn-visual-media') + .first(); + await expect(image).toBeVisible({ timeout: 10000 }); + return image; +}; + +const getDocIdFromUrl = (page: Page) => { + const id = /\/docs\/([^/?]+)/.exec(page.url())?.[1]; + if (!id) { + throw new Error(`Could not extract doc id from URL: ${page.url()}`); + } + return id; +}; + const writeMultiSlideDoc = async (page: Page) => { const editor = await writeInEditor({ page, text: 'Slide one' }); await editor.press('Enter'); @@ -29,11 +72,49 @@ const writeMultiSlideDoc = async (page: Page) => { await writeInEditor({ page, text: 'Slide three' }); }; +const stubPrintDialog = async (page: Page) => { + await page.evaluate(() => { + window.print = () => undefined; + }); +}; + +const finishStubbedPrintDialog = async (page: Page) => { + await page.evaluate(() => { + window.dispatchEvent(new Event('afterprint')); + }); +}; + +const openPresenterActions = async (page: Page, overlay: Locator) => { + await overlay.getByRole('button', { name: 'More options' }).click(); + await expect( + page.getByRole('menuitem', { name: 'Copy link to slide' }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Download PDF' }), + ).toBeVisible(); +}; + +const copyCurrentPresenterSlideLink = async (page: Page, overlay: Locator) => { + await openPresenterActions(page, overlay); + await page.getByRole('menuitem', { name: 'Copy link to slide' }).click(); +}; + +const exportPresenterPdf = async (page: Page, overlay: Locator) => { + await openPresenterActions(page, overlay); + await page.getByRole('menuitem', { name: 'Download PDF' }).click(); +}; + test.beforeEach(async ({ page }) => { await page.goto('/'); }); test.describe('Presenter Mode', () => { + // Step out of the suite-wide fullyParallel mode: these tests create and edit + // docs that race the editor/persistence layer under heavy parallelism. Run + // them sequentially within the file (each test is still fully independent — + // no shared state — so this is `default`, not `serial`). + test.describe.configure({ mode: 'default' }); + test('opens the presenter overlay from the doc options menu and closes with Escape', async ({ page, browserName, @@ -46,6 +127,13 @@ test.describe('Presenter Mode', () => { await expect( overlay.getByRole('toolbar', { name: 'Presenter controls' }), ).toBeVisible(); + await expect(overlay.getByText(/presenter-open/)).toBeVisible(); + await expect( + overlay.getByText( + 'You can use the divider to tell Docs where to split your slides', + ), + ).toBeVisible(); + await overlay.getByRole('button', { name: 'Next slide' }).click(); await expect(overlay.getByText('Hello presenter')).toBeVisible(); // The presenter calls requestFullscreen on open. usePresenterShortcuts @@ -92,19 +180,19 @@ test.describe('Presenter Mode', () => { const overlay = await openPresenter(page); - // The visible "1 / 3" counter is decorative (aria-hidden); the position is + // The visible "1 / 4" counter is decorative (aria-hidden); the position is // announced through a polite live region for screen readers instead. // react-aria/live-announcer creates a global role="log" div on document.body // (outside the dialog), so we query from `page`, not `overlay`. const liveRegion = page.locator( '[data-live-announcer="true"] [aria-live="polite"]', ); - // The announcement includes the slide title extracted from the first - // text block (getSlideTitle), so assert the full message. - await expect(liveRegion).toContainText('Slide 1 of 3: Slide one'); + // The title slide uses the document title, then content slides use the + // first text block (getSlideTitle). + await expect(liveRegion).toContainText('Slide 1 of 4:'); await overlay.getByRole('button', { name: 'Next slide' }).click(); - await expect(liveRegion).toContainText('Slide 2 of 3: Slide two'); + await expect(liveRegion).toContainText('Slide 2 of 4: Slide one'); // Each slide advertises a localized role description for screen readers. await expect(overlay.getByRole('group').first()).toHaveAttribute( @@ -113,7 +201,7 @@ test.describe('Presenter Mode', () => { ); }); - test('renders a single-slide doc with counter 1/1 and disabled nav buttons', async ({ + test('renders a content-only doc after the generated title slide', async ({ page, browserName, }) => { @@ -122,19 +210,58 @@ test.describe('Presenter Mode', () => { const overlay = await openPresenter(page); - await expect(overlay.getByText('1 / 1')).toBeVisible(); + await expect(overlay.getByText('1 / 2')).toBeVisible(); await expect( overlay.getByRole('button', { name: 'Previous slide' }), ).toBeDisabled(); await expect( overlay.getByRole('button', { name: 'Next slide' }), - ).toBeDisabled(); + ).toBeEnabled(); + + await overlay.getByRole('button', { name: 'Next slide' }).click(); + await expect(overlay.getByText('2 / 2')).toBeVisible(); await expect(overlay.getByText('Slide A')).toBeVisible(); + await expect( + overlay.getByRole('button', { name: 'Next slide' }), + ).toBeDisabled(); await overlay.getByRole('button', { name: 'Close presenter' }).click(); await expect(overlay).toBeHidden(); }); + test('does not show selected-node chrome when the first slide block is an image', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-image-first', browserName, 1); + await insertImageInCurrentBlock(page); + + const overlay = await openPresenter(page); + await overlay.getByRole('button', { name: 'Next slide' }).click(); + const presenterImage = overlay.locator('img.bn-visual-media').first(); + await expect(presenterImage).toBeAttached({ timeout: 10000 }); + + const outline = await presenterImage.evaluate((img) => { + const blockContent = img.closest('.bn-block-content'); + blockContent?.classList.add('ProseMirror-selectednode'); + + const outlinedElement = + (blockContent?.firstElementChild as HTMLElement | null) ?? + (img as HTMLElement); + const style = getComputedStyle(outlinedElement); + + return { + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }; + }); + + expect(outline).toEqual({ + outlineStyle: 'none', + outlineWidth: '0px', + }); + }); + test('navigates between slides via the floating bar buttons', async ({ page, browserName, @@ -147,23 +274,27 @@ test.describe('Presenter Mode', () => { const prev = overlay.getByRole('button', { name: 'Previous slide' }); const next = overlay.getByRole('button', { name: 'Next slide' }); - await expect(overlay.getByText('1 / 3')).toBeVisible(); - await expect(overlay.getByText('Slide one')).toBeVisible(); + await expect(overlay.getByText('1 / 4')).toBeVisible(); + await expect(overlay.getByText(/presenter-nav-bar/)).toBeVisible(); await expect(prev).toBeDisabled(); await expect(next).toBeEnabled(); await next.click(); - await expect(overlay.getByText('2 / 3')).toBeVisible(); + await expect(overlay.getByText('2 / 4')).toBeVisible(); + await expect(overlay.getByText('Slide one')).toBeVisible(); + + await next.click(); + await expect(overlay.getByText('3 / 4')).toBeVisible(); await expect(overlay.getByText('Slide two')).toBeVisible(); await next.click(); - await expect(overlay.getByText('3 / 3')).toBeVisible(); + await expect(overlay.getByText('4 / 4')).toBeVisible(); await expect(overlay.getByText('Slide three')).toBeVisible(); await expect(next).toBeDisabled(); await expect(prev).toBeEnabled(); await prev.click(); - await expect(overlay.getByText('2 / 3')).toBeVisible(); + await expect(overlay.getByText('3 / 4')).toBeVisible(); await expect(overlay.getByText('Slide two')).toBeVisible(); }); @@ -176,20 +307,20 @@ test.describe('Presenter Mode', () => { const overlay = await openPresenter(page); - await expect(overlay.getByText('1 / 3')).toBeVisible(); + await expect(overlay.getByText('1 / 4')).toBeVisible(); await page.keyboard.press('ArrowRight'); - await expect(overlay.getByText('2 / 3')).toBeVisible(); + await expect(overlay.getByText('2 / 4')).toBeVisible(); await page.keyboard.press('End'); - await expect(overlay.getByText('3 / 3')).toBeVisible(); + await expect(overlay.getByText('4 / 4')).toBeVisible(); await page.keyboard.press('Home'); - await expect(overlay.getByText('1 / 3')).toBeVisible(); + await expect(overlay.getByText('1 / 4')).toBeVisible(); - // ArrowLeft on the first slide is clamped — counter stays at 1 / 3. + // ArrowLeft on the first slide is clamped — counter stays at 1 / 4. await page.keyboard.press('ArrowLeft'); - await expect(overlay.getByText('1 / 3')).toBeVisible(); + await expect(overlay.getByText('1 / 4')).toBeVisible(); }); test('scales each slide to fit the viewport (outer width = 900 × scale)', async ({ @@ -251,7 +382,11 @@ test.describe('Presenter Mode', () => { } const overlay = await openPresenter(page); - const slide = overlay.getByRole('group').filter({ hasNotText: '' }).first(); + await overlay.getByRole('button', { name: 'Next slide' }).click(); + const slide = overlay + .getByRole('group') + .filter({ hasText: 'TOP MARKER' }) + .first(); await expect(slide).toBeVisible(); // The first block ('TOP MARKER') must be at y=0 of the slide wrapper @@ -278,9 +413,326 @@ test.describe('Presenter Mode', () => { topVisible.clientHeight ?? 0, ); }); + + 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); + + // Reveal the block side menu for the "Slide two" paragraph, then trigger its + // drag-handle "Present" action. + 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' }).click(); + + // "Slide two" is the 2nd content slide; with the generated title slide + // first that is slide 3 of 4. + 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('deep-link ?view=present&slide=3 opens the presenter at content slide 2', async ({ + page, + browserName, + }) => { + const [docTitle] = await createDoc( + page, + 'presenter-deeplink', + browserName, + 1, + ); + await writeMultiSlideDoc(page); + const docId = getDocIdFromUrl(page); + + // Ensure the typed content is persisted (awaits the PATCH /content/) before + // we reload the page through the deep-link, instead of a fixed sleep. + await saveContent(page, docTitle); + await page.goto(`/docs/${docId}/?view=present&slide=3`); + + const overlay = page.getByRole('dialog', { name: 'Presenter mode' }); + await expect(overlay).toBeVisible({ timeout: 15000 }); + await expect(overlay.getByText('3 / 4')).toBeVisible(); + await expect(overlay.getByText('Slide two')).toBeVisible(); + }); + + test('deep-link with an out-of-range slide clamps to the last slide and rewrites the URL', async ({ + page, + browserName, + }) => { + const [docTitle] = await createDoc( + page, + 'presenter-deeplink-oob', + browserName, + 1, + ); + await writeMultiSlideDoc(page); + const docId = getDocIdFromUrl(page); + + await saveContent(page, docTitle); + await page.goto(`/docs/${docId}/?view=present&slide=99`); + + const overlay = page.getByRole('dialog', { name: 'Presenter mode' }); + await expect(overlay).toBeVisible({ timeout: 15000 }); + await expect(overlay.getByText('4 / 4')).toBeVisible(); + // The address bar self-corrects to the real slide. + await expect.poll(() => page.url()).toContain('slide=4'); + }); + + test('deep-link with an invalid slide param falls back to the first slide', async ({ + page, + browserName, + }) => { + const [docTitle] = await createDoc( + page, + 'presenter-deeplink-invalid', + browserName, + 1, + ); + await writeMultiSlideDoc(page); + const docId = getDocIdFromUrl(page); + + await saveContent(page, docTitle); + await page.goto(`/docs/${docId}/?view=present&slide=abc`); + + const overlay = page.getByRole('dialog', { name: 'Presenter mode' }); + await expect(overlay).toBeVisible({ timeout: 15000 }); + await expect(overlay.getByText('1 / 4')).toBeVisible(); + }); + + test('shows a confirmation toast when copying the current-slide link', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-copy-link', browserName, 1); + await writeMultiSlideDoc(page); + + const overlay = await openPresenter(page); + await overlay.getByRole('button', { name: 'Next slide' }).click(); + await expect(overlay.getByText('2 / 4')).toBeVisible(); + + await copyCurrentPresenterSlideLink(page, overlay); + await expect(page.getByText('Link Copied !')).toBeVisible(); + }); + + test('copies a deep-link pointing to the current slide', async ({ + page, + browserName, + context, + }) => { + // Clipboard read-back is only reliable on Chromium with granted permissions. + test.skip( + browserName !== 'chromium', + 'Clipboard read-back is Chromium-only', + ); + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + await createDoc(page, 'presenter-copy-link-content', browserName, 1); + await writeMultiSlideDoc(page); + const docId = getDocIdFromUrl(page); + + const overlay = await openPresenter(page); + await overlay.getByRole('button', { name: 'Next slide' }).click(); + await expect(overlay.getByText('2 / 4')).toBeVisible(); + + await copyCurrentPresenterSlideLink(page, overlay); + await expect(page.getByText('Link Copied !')).toBeVisible(); + + const clipboard = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboard).toContain(`/docs/${docId}/?view=present&slide=2`); + }); + + test('syncs the slide to the URL while navigating', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-url-sync', browserName, 1); + await writeMultiSlideDoc(page); + + const overlay = await openPresenter(page); + // Opening writes slide=1 to the address bar. + await expect.poll(() => page.url()).toContain('view=present'); + await expect.poll(() => page.url()).toContain('slide=1'); + + await overlay.getByRole('button', { name: 'Next slide' }).click(); + await expect(overlay.getByText('2 / 4')).toBeVisible(); + await expect.poll(() => page.url()).toContain('slide=2'); + }); + + test('strips the present query params when closing', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-url-strip', browserName, 1); + await writeInEditor({ page, text: 'Slide A' }); + + const overlay = await openPresenter(page); + await expect.poll(() => page.url()).toContain('view=present'); + + await overlay.getByRole('button', { name: 'Close presenter' }).click(); + await expect(overlay).toBeHidden(); + await expect.poll(() => page.url()).not.toContain('view=present'); + await expect.poll(() => page.url()).not.toContain('slide='); + // The editor is still mounted (shallow routing, no full reload). + await expect(page.locator('.--docs--editor-container')).toBeVisible(); + }); + + test('exports every presenter slide through browser print as landscape PDF pages', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'page.pdf() is only available on Chromium', + ); + + await createDoc(page, 'presenter-print-pdf', browserName, 1); + await writeMultiSlideDoc(page); + + const overlay = await openPresenter(page); + await stubPrintDialog(page); + + await exportPresenterPdf(page, overlay); + + await expect(page.locator('#presenter-print-styles')).toBeAttached(); + await expect(page.locator('[data-presenter-print-page]')).toHaveCount(4); + + await page.emulateMedia({ media: 'print' }); + const pdfBuffer = await page.pdf({ + printBackground: true, + preferCSSPageSize: true, + scale: 1, + }); + await page.emulateMedia({ media: 'screen' }); + + const pdfParse = new PDFParse({ data: pdfBuffer }); + const pdfInfo = await pdfParse.getInfo({ parsePageInfo: true }); + const pdfText = await pdfParse.getText(); + await pdfParse.destroy(); + + expect(pdfInfo.total).toBe(4); + expect(pdfInfo.pages).toHaveLength(4); + pdfInfo.pages.forEach((pdfPage) => { + expect(pdfPage.width).toBeGreaterThan(pdfPage.height); + }); + expect(pdfText.text).toContain('Slide one'); + expect(pdfText.text).toContain('Slide two'); + expect(pdfText.text).toContain('Slide three'); + + await finishStubbedPrintDialog(page); + await expect(page.locator('#presenter-print-styles')).not.toBeAttached({ + timeout: 5000, + }); + }); + + test('renders file blocks as printable links in presenter export', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-print-file-link', browserName, 1); + await writeInEditor({ page, text: 'Slide with a file' }); + + const fileChooserPromise = page.waitForEvent('filechooser'); + const responseCheckPromise = page.waitForResponse( + (response) => + response.url().includes('media-check') && response.status() === 200, + ); + + await openSuggestionMenu({ + page, + suggestion: 'Embedded file', + }); + await page.getByText('Upload file').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test.html')); + await responseCheckPromise; + + await expect( + page.locator('.bn-block-content[data-name="test.html"]'), + ).toBeVisible(); + + const overlay = await openPresenter(page); + await stubPrintDialog(page); + + await exportPresenterPdf(page, overlay); + + const printFileBlock = page.locator( + '#presenter-print-root [data-content-type="file"][data-name="test.html"]', + ); + await expect(printFileBlock).toBeAttached(); + + const printLinkText = await printFileBlock.evaluate((el) => { + const shadowRoot = el.firstElementChild?.shadowRoot; + return shadowRoot?.textContent ?? ''; + }); + + expect(printLinkText).toContain('test.html'); + + await finishStubbedPrintDialog(page); + await expect(page.locator('#presenter-print-styles')).not.toBeAttached({ + timeout: 5000, + }); + }); + + test('clips a tall content slide to a single printed page after the title page', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'page.pdf() is only available on Chromium', + ); + + await createDoc(page, 'presenter-print-tall', browserName, 1); + + const editor = await writeInEditor({ page, text: 'TOP PRINT MARKER' }); + for (let i = 0; i < 45; i += 1) { + await editor.press('Enter'); + await editor.pressSequentially(`Printed filler line ${i}`); + } + + const overlay = await openPresenter(page); + await stubPrintDialog(page); + + await exportPresenterPdf(page, overlay); + + await expect(page.locator('#presenter-print-styles')).toBeAttached(); + await expect(page.locator('[data-presenter-print-page]')).toHaveCount(2); + + await page.emulateMedia({ media: 'print' }); + const pdfBuffer = await page.pdf({ + printBackground: true, + preferCSSPageSize: true, + scale: 1, + }); + await page.emulateMedia({ media: 'screen' }); + + const pdfParse = new PDFParse({ data: pdfBuffer }); + const pdfInfo = await pdfParse.getInfo({ parsePageInfo: true }); + const pdfText = await pdfParse.getText(); + await pdfParse.destroy(); + + expect(pdfInfo.total).toBe(2); + expect(pdfInfo.pages[0].width).toBeGreaterThan(pdfInfo.pages[0].height); + expect(pdfText.text).toContain('TOP PRINT MARKER'); + + await finishStubbedPrintDialog(page); + await expect(page.locator('#presenter-print-styles')).not.toBeAttached({ + timeout: 5000, + }); + }); }); test.describe('Presenter Mode mobile', () => { + test.describe.configure({ mode: 'default' }); test.use({ viewport: { width: 500, height: 1200 } }); test.beforeEach(async ({ page }) => { @@ -310,4 +762,26 @@ test.describe('Presenter Mode mobile', () => { await page.getByLabel('Open the document options').click(); await expect(page.getByRole('menuitem', { name: 'Present' })).toBeHidden(); }); + + test('ignores a ?view=present deep-link on small mobile viewports', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-mobile-deeplink', browserName, 1, true); + await writeInEditor({ page, text: 'Slide A' }); + const docId = getDocIdFromUrl(page); + + // No persistence wait needed: this asserts the deep-link is ignored on + // mobile, which does not depend on the typed content being saved. + await page.goto(`/docs/${docId}/?view=present&slide=1`); + + // The editor loads normally... + await expect(page.locator('.--docs--editor-container')).toBeVisible({ + timeout: 15000, + }); + // ...but the presenter is never auto-opened on mobile. + await expect( + page.getByRole('dialog', { name: 'Presenter mode' }), + ).toBeHidden(); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 24b2c0b88a..005007e3dc 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -180,6 +180,12 @@ export const createDoc = async ( expect(responseUpdateDoc.ok()).toBeTruthy(); } + // Wait for the editor to be mounted before handing control back, so callers + // that immediately type or open features don't race the editor load. + await expect( + page.locator('.--docs--editor-container .ProseMirror'), + ).toBeVisible({ timeout: 10000 }); + return randomDocs; }; 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 5c9b2b1e29..e47ea82d4a 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 @@ -51,6 +51,7 @@ import { DocsBlockNoteEditor } from '../types'; import { randomColor, sanitizeColor } from '../utils'; import BlockNoteAI from './AI'; +import { BlockNoteSideMenu } from './BlockNoteSideMenu'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { CalloutBlock, PdfBlock, UploadLoaderBlock } from './custom-blocks'; @@ -292,6 +293,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { editor={editor} formattingToolbar={false} slashMenu={false} + sideMenu={false} theme="light" comments={false} aria-label={t('Document editor')} @@ -303,6 +305,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { )} + {showComments && } {showComments && !isCommentSideBarOpen && } {threadsSidebarTarget && diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSideMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSideMenu.tsx new file mode 100644 index 0000000000..ce658b5a4a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSideMenu.tsx @@ -0,0 +1,86 @@ +import { SideMenuExtension } from '@blocknote/core/extensions'; +import { + BlockColorsItem, + RemoveBlockItem, + SideMenu, + SideMenuController, + TableColumnHeaderItem, + TableRowHeaderItem, + useBlockNoteEditor, + useComponentsContext, + useDictionary, + useExtensionState, +} from '@blocknote/react'; +import { useTranslation } from 'react-i18next'; + +import { getContentSlideIndexForBlock } from '@/docs/doc-presenter/hooks/useSlides'; +import { usePresenterStore } from '@/docs/doc-presenter/stores'; +import type { PresenterBlock } from '@/docs/doc-presenter/types'; +import { useResponsiveStore } from '@/stores'; + +import type { DocsBlockNoteEditor } from '../types'; + +const PresentBlockItem = () => { + const { t } = useTranslation(); + const Components = useComponentsContext(); + const editor: DocsBlockNoteEditor = useBlockNoteEditor(); + const block = useExtensionState(SideMenuExtension, { + editor, + selector: (state) => state?.block, + }); + const openPresenter = usePresenterStore((state) => state.open); + const { isMobile } = useResponsiveStore(); + + // Hidden on mobile (no presenter there) and until a block is targeted + // (no drag handle hovered yet). + if (Components === undefined || block === undefined || isMobile) { + return null; + } + + return ( + { + const contentSlideIndex = getContentSlideIndexForBlock( + editor.document as PresenterBlock[], + block.id, + ); + + // Overlay slide 0 is the generated title slide; content slides start + // at index 1, hence the +1 on the 0-based content-slide index. + openPresenter(contentSlideIndex + 1); + }} + > + {t('Present')} + + ); +}; + +const DocsDragHandleMenu = () => { + const Components = useComponentsContext(); + const dict = useDictionary(); + + if (Components === undefined) { + return null; + } + + return ( + + {dict.drag_handle.delete_menuitem} + + {dict.drag_handle.colors_menuitem} + + {dict.drag_handle.header_row_menuitem} + + + {dict.drag_handle.header_column_menuitem} + + + ); +}; + +const DocsSideMenu = () => ; + +export const BlockNoteSideMenu = () => ( + +); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx index fff7b6a248..858759854a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx @@ -152,14 +152,32 @@ export const DocsEditorStyle = createGlobalStyle` /** * Divider */ - [data-content-type='divider'] hr { - background: #d3d2cf; - margin: 1rem 0; + .bn-block-content[data-content-type='divider'] { + display: flex; + align-items: center; + justify-content: center; + height: 28px; + padding: 0; + } + .bn-block-content[data-content-type='divider'] hr { + flex: 1 1 100%; width: 100%; - border: 1px solid #d3d2cf; + height: 1px; + margin: 0; + border: 0; + border-radius: 11px; + background: var(--c--contextuals--border--surface--primary); + } + .bn-block-content[data-content-type='divider'].ProseMirror-selectednode hr { + height: 9.5px; + outline: 0 !important; + box-shadow: none; + border: 4px solid + var(--c--contextuals--border--semantic--brand--tertiary); + background: var(--c--contextuals--border--semantic--brand--secondary); } .bn-side-menu[data-block-type='divider'] { - height: 38px; + height: 28px; } /** diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsPrint.test.ts b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsPrint.test.ts new file mode 100644 index 0000000000..7421e58af8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsPrint.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from 'vitest'; + +import { wrapInterlinksWithAnchor, wrapMediaWithLink } from '../utils_print'; + +describe('print DOM helpers', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('wraps media links only inside the provided root', () => { + document.body.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+ `; + + const root = document.getElementById('root'); + if (!root) { + throw new Error('missing root'); + } + + const cleanup = wrapMediaWithLink(root); + + const inside = document.getElementById('inside'); + const outside = document.getElementById('outside'); + + expect(inside?.firstElementChild?.shadowRoot?.textContent).toContain( + 'inside.mp3', + ); + expect(outside?.firstElementChild?.shadowRoot).toBeNull(); + + cleanup(); + + expect(inside?.firstElementChild?.shadowRoot).toBeNull(); + }); + + test('wraps interlinks only inside the provided root', () => { + document.body.innerHTML = ` +
+ outside +
+ inside +
+
+ `; + + const root = document.getElementById('root'); + if (!root) { + throw new Error('missing root'); + } + + const cleanup = wrapInterlinksWithAnchor(root); + + expect(root.querySelector('a[data-print-link]')?.textContent).toBe( + 'inside', + ); + expect(document.querySelectorAll('a[data-print-link]')).toHaveLength(1); + + cleanup(); + + expect(document.querySelectorAll('a[data-print-link]')).toHaveLength(0); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/utils_print.ts b/src/frontend/apps/impress/src/features/docs/doc-export/utils_print.ts index 82a5ef65c4..ccb26d3c74 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/utils_print.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/utils_print.ts @@ -175,7 +175,7 @@ function appendPrintOnlyStyles() { /** * Wraps media elements with links to their source URLs for printing. */ -function wrapMediaWithLink() { +export function wrapMediaWithLink(root: ParentNode = document) { const createdShadowWrapper: HTMLElement[] = []; const prependLink = ( @@ -222,7 +222,7 @@ function wrapMediaWithLink() { createdShadowWrapper.push(shadowWrapper); }; - document + root .querySelectorAll( '[data-content-type="pdf"], [data-content-type="file"], [data-content-type="audio"], [data-content-type="video"]', ) @@ -251,14 +251,14 @@ function wrapMediaWithLink() { * Wraps interlink inline content with anchor tags for printing, * so they appear as clickable links in the printed PDF. */ -function wrapInterlinksWithAnchor() { +export function wrapInterlinksWithAnchor(root: ParentNode = document) { const wrappedElements: Array<{ el: Element; anchor: HTMLAnchorElement; parent: Node; }> = []; - document + root .querySelectorAll('.--docs--interlinking-link-inline-content[data-href]') .forEach((el) => { const href = el.getAttribute('data-href'); 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 d052dcb101..8ad47dda48 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: