From 7a8280e5d850940ca4c7c0fb2d7f5e51ad5d91b8 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Wed, 15 Jul 2026 01:33:04 +0200 Subject: [PATCH] feat(slide-mode): add print mode for slides When clicking the print button in the sidebar or executing the print keyboard shortcut, we toggle to reveal.js' print mode in order to print all slides correctly. After printing, the mode is toggled back. Since this might hang under certain circumstances, there is a delay and fallback to always return back to the normal view even if the afterprint event didn't fire correctly. Signed-off-by: Erik Michelson --- .../editor-page/utils/print-iframe.ts | 18 ++++- .../markdown-renderer/hooks/use-reveal.ts | 75 ++++++++++++++++--- .../render-page/render-page-content.tsx | 8 ++ .../slideshow/slideshow-markdown-renderer.tsx | 21 +++++- .../src/external-types/reveal.js/index.d.ts | 1 + frontend/src/redux/print-mode/slice.spec.ts | 13 ++++ 6 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 frontend/src/redux/print-mode/slice.spec.ts diff --git a/frontend/src/components/editor-page/utils/print-iframe.ts b/frontend/src/components/editor-page/utils/print-iframe.ts index 5d1f27d70..02d6ef46c 100644 --- a/frontend/src/components/editor-page/utils/print-iframe.ts +++ b/frontend/src/components/editor-page/utils/print-iframe.ts @@ -9,6 +9,8 @@ import { useCallback } from 'react' import { setPrintMode } from '../../../redux/print-mode/methods' import { useEditorToRendererCommunicator } from '../render-context/editor-to-renderer-communicator-context-provider' import { CommunicationMessageType } from '../../render-page/window-post-message-communicator/rendering-message' +import { useApplicationState } from '../../../hooks/common/use-application-state' +import { NoteType } from '@hedgedoc/commons' const TIMEOUT_BEFORE_PRINT = 25 @@ -18,9 +20,18 @@ const TIMEOUT_BEFORE_PRINT = 25 export const usePrintIframe = (): (() => void) => { const iframeCommunicator = useEditorToRendererCommunicator() const rendererReady = useIsRendererReady() + const isSlideDeck = useApplicationState((state) => state.noteDetails?.frontmatter.type === NoteType.SLIDE) return useCallback(() => { - if (!rendererReady) { + if (!rendererReady || !iframeCommunicator) { + return + } + + if (isSlideDeck) { + iframeCommunicator.sendMessageToOtherSide({ + type: CommunicationMessageType.SET_PRINT_MODE, + printMode: true + }) return } @@ -39,7 +50,7 @@ export const usePrintIframe = (): (() => void) => { printMode: false }) }, TIMEOUT_BEFORE_PRINT) - }, [rendererReady, iframeCommunicator]) + }, [iframeCommunicator, isSlideDeck, rendererReady]) } /** @@ -50,6 +61,9 @@ export const usePrintIframe = (): (() => void) => { export const usePrintSelf = () => { return useCallback(() => { setPrintMode(true) + if (document.querySelector('.reveal')) { + return + } setTimeout(() => { window.print() setPrintMode(false) diff --git a/frontend/src/components/markdown-renderer/hooks/use-reveal.ts b/frontend/src/components/markdown-renderer/hooks/use-reveal.ts index 7588b01b6..477cee1c2 100644 --- a/frontend/src/components/markdown-renderer/hooks/use-reveal.ts +++ b/frontend/src/components/markdown-renderer/hooks/use-reveal.ts @@ -4,11 +4,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { Logger } from '../../../utils/logger' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type Reveal from 'reveal.js' import type { RevealOptions } from 'reveal.js' const log = new Logger('reveal.js') +const printDelay = 100 + +const cleanupRevealPrintMode = (): void => { + document.documentElement.classList.remove('reveal-print', 'print-pdf') + document.body.style.removeProperty('width') + document.body.style.removeProperty('height') +} export enum REVEAL_STATUS { NOT_INITIALISED, @@ -31,17 +38,22 @@ const initialSlideState: SlideState = { * @param markdownContentLines An array of markdown lines. * @param slideOptions The slide options. * @param targetSlideState The slide that should be shown. + * @param printMode Defines if the presentation should be printed. + * @param onPrintModeConsumed Callback to reset print mode after printing. * @return The current state of reveal.js * @see https://revealjs.com/ */ export const useReveal = ( markdownContentLines: string[], slideOptions?: RevealOptions, - targetSlideState?: SlideState + targetSlideState?: SlideState, + printMode = false, + onPrintModeConsumed?: () => void ): REVEAL_STATUS => { const [deck, setDeck] = useState() const [revealStatus, setRevealStatus] = useState(REVEAL_STATUS.NOT_INITIALISED) const currentSlideState = useRef(initialSlideState) + const printInProgress = useRef(false) useEffect(() => { if (revealStatus !== REVEAL_STATUS.NOT_INITIALISED) { @@ -49,10 +61,11 @@ export const useReveal = ( } setRevealStatus(REVEAL_STATUS.INITIALISING) log.debug('Initialize with slide options', slideOptions) + const revealOptions: RevealOptions = printMode ? { ...slideOptions, view: 'print' } : (slideOptions ?? {}) import(/* webpackChunkName: "reveal" */ 'reveal.js') .then((revealImport) => { - const reveal = new revealImport.default({}) + const reveal = new revealImport.default(revealOptions) reveal .initialize() .then(() => { @@ -76,20 +89,34 @@ export const useReveal = ( .catch((error: Error) => { log.error('Error while loading reveal.js', error) }) - }, [revealStatus, slideOptions]) + }, [printMode, revealStatus, slideOptions]) useEffect(() => { - if (!deck || revealStatus !== REVEAL_STATUS.INITIALISED) { + if (!deck) { + return + } + + return () => { + deck.destroy() + if (printMode) { + cleanupRevealPrintMode() + } + } + }, [deck, printMode]) + + useEffect(() => { + if (!deck || printMode || revealStatus !== REVEAL_STATUS.INITIALISED) { return } log.debug('Sync deck') deck.sync() deck.slide(currentSlideState.current.indexHorizontal, currentSlideState.current.indexVertical) - }, [markdownContentLines, deck, revealStatus]) + }, [markdownContentLines, deck, printMode, revealStatus]) useEffect(() => { if ( !deck || + printMode || slideOptions === undefined || Object.keys(slideOptions).length === 0 || revealStatus !== REVEAL_STATUS.INITIALISED @@ -98,14 +125,44 @@ export const useReveal = ( } log.debug('Apply config', slideOptions) deck.configure(slideOptions) - }, [deck, revealStatus, slideOptions]) + }, [deck, printMode, revealStatus, slideOptions]) useEffect(() => { - if (!deck || targetSlideState === undefined || revealStatus !== REVEAL_STATUS.INITIALISED) { + if (!deck || printMode || targetSlideState === undefined || revealStatus !== REVEAL_STATUS.INITIALISED) { return } deck.slide(targetSlideState.indexHorizontal, targetSlideState.indexVertical) - }, [deck, revealStatus, targetSlideState]) + }, [deck, printMode, revealStatus, targetSlideState]) + + const printPresentation = useCallback(async () => { + if (!deck || revealStatus !== REVEAL_STATUS.INITIALISED || printInProgress.current) { + return + } + printInProgress.current = true + + const finishPrint = (): void => { + window.removeEventListener('afterprint', finishPrint) + cleanupRevealPrintMode() + onPrintModeConsumed?.() + printInProgress.current = false + } + + setTimeout(() => { + try { + window.addEventListener('afterprint', finishPrint) + window.print() + } catch (error) { + log.error('Error while printing reveal.js presentation', error) + finishPrint() + } + }, printDelay) + }, [deck, onPrintModeConsumed, revealStatus]) + + useEffect(() => { + if (printMode) { + void printPresentation() + } + }, [printMode, printPresentation]) return revealStatus } diff --git a/frontend/src/components/render-page/render-page-content.tsx b/frontend/src/components/render-page/render-page-content.tsx index e287e42fa..27c84fb0d 100644 --- a/frontend/src/components/render-page/render-page-content.tsx +++ b/frontend/src/components/render-page/render-page-content.tsx @@ -19,6 +19,7 @@ import { EventEmitter2 } from 'eventemitter2' import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { setPrintMode } from '../../redux/print-mode/methods' import { usePrintSelfKeyboardShortcut } from '../editor-page/hooks/use-print-keyboard-shortcut' +import { useApplicationState } from '../../hooks/common/use-application-state' /** * Wraps the markdown rendering in an iframe. @@ -33,6 +34,9 @@ export const RenderPageContent: React.FC = () => { const sendScrolling = useRef(false) const [newLinesAreBreaks, setNewLinesAreBreaks] = useState(true) const [slideOptions, setSlideOptions] = useState() + const printMode = useApplicationState((state) => state.printMode) + + const resetPrintMode = useCallback(() => setPrintMode(false), []) useRendererReceiveHandler( CommunicationMessageType.SET_SLIDE_OPTIONS, @@ -189,6 +193,8 @@ export const RenderPageContent: React.FC = () => { newLinesAreBreaks={newLinesAreBreaks} scrollState={scrollState} slideOptions={slideOptions} + printMode={printMode} + onPrintModeConsumed={resetPrintMode} /> ) case RendererType.SIMPLE: @@ -210,6 +216,8 @@ export const RenderPageContent: React.FC = () => { onHeightChange, onMakeScrollSource, onScroll, + printMode, + resetPrintMode, scrollState, slideOptions ]) diff --git a/frontend/src/components/render-page/renderers/slideshow/slideshow-markdown-renderer.tsx b/frontend/src/components/render-page/renderers/slideshow/slideshow-markdown-renderer.tsx index b54a939fb..97310767b 100644 --- a/frontend/src/components/render-page/renderers/slideshow/slideshow-markdown-renderer.tsx +++ b/frontend/src/components/render-page/renderers/slideshow/slideshow-markdown-renderer.tsx @@ -18,6 +18,12 @@ import React, { useMemo, useRef } from 'react' export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps, Pick { slideOptions?: RevealOptions + printMode?: boolean + onPrintModeConsumed?: () => void +} + +type SlideshowMarkdownRendererContentProps = SlideshowMarkdownRendererProps & { + printMode: boolean } /** @@ -29,11 +35,18 @@ export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererPr * @param newLinesAreBreaks If newlines are rendered as breaks or not */ export const SlideshowMarkdownRenderer: React.FC = ({ + printMode = false, + ...props +}) => + +const SlideshowMarkdownRendererContent: React.FC = ({ markdownContentLines, baseUrl, newLinesAreBreaks, scrollState, - slideOptions + slideOptions, + printMode, + onPrintModeConsumed }) => { const markdownBodyRef = useRef(null) @@ -47,11 +60,11 @@ export const SlideshowMarkdownRenderer: React.FC () => (scrollState ? findSlideForLine(markdownContentLines, scrollState.firstLineInView) : undefined), [markdownContentLines, scrollState] ) - const revealStatus = useReveal(markdownContentLines, slideOptions, targetSlideState) + const revealStatus = useReveal(markdownContentLines, slideOptions, targetSlideState, printMode, onPrintModeConsumed) const slideShowDOM = useMemo( () => - revealStatus === REVEAL_STATUS.INITIALISED ? ( + revealStatus === REVEAL_STATUS.INITIALISED || printMode ? ( ) : ( ), - [extensions, markdownContentLines, newLinesAreBreaks, revealStatus] + [extensions, markdownContentLines, newLinesAreBreaks, printMode, revealStatus] ) return ( diff --git a/frontend/src/external-types/reveal.js/index.d.ts b/frontend/src/external-types/reveal.js/index.d.ts index a40b608e9..f18fb41e4 100644 --- a/frontend/src/external-types/reveal.js/index.d.ts +++ b/frontend/src/external-types/reveal.js/index.d.ts @@ -37,6 +37,7 @@ declare module 'reveal.js' { transitionSpeed?: string | undefined backgroundTransition?: string | undefined viewDistance?: number | undefined + view?: 'print' | 'reader' | 'scroll' | undefined // https://github.com/hakimel/reveal.js/#parallax-background // Parallax background image diff --git a/frontend/src/redux/print-mode/slice.spec.ts b/frontend/src/redux/print-mode/slice.spec.ts new file mode 100644 index 000000000..5d43adaa5 --- /dev/null +++ b/frontend/src/redux/print-mode/slice.spec.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { printModeActionsCreator, printModeReducer } from './slice' + +describe('printModeReducer', () => { + it('updates the print mode', () => { + expect(printModeReducer(false, printModeActionsCreator.setPrintMode(true))).toBe(true) + expect(printModeReducer(true, printModeActionsCreator.setPrintMode(false))).toBe(false) + }) +})