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 <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-07-15 01:33:04 +02:00
parent b6825bc703
commit 7a8280e5d8
6 changed files with 121 additions and 15 deletions
@@ -9,6 +9,8 @@ import { useCallback } from 'react'
import { setPrintMode } from '../../../redux/print-mode/methods' import { setPrintMode } from '../../../redux/print-mode/methods'
import { useEditorToRendererCommunicator } from '../render-context/editor-to-renderer-communicator-context-provider' import { useEditorToRendererCommunicator } from '../render-context/editor-to-renderer-communicator-context-provider'
import { CommunicationMessageType } from '../../render-page/window-post-message-communicator/rendering-message' 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 const TIMEOUT_BEFORE_PRINT = 25
@@ -18,9 +20,18 @@ const TIMEOUT_BEFORE_PRINT = 25
export const usePrintIframe = (): (() => void) => { export const usePrintIframe = (): (() => void) => {
const iframeCommunicator = useEditorToRendererCommunicator() const iframeCommunicator = useEditorToRendererCommunicator()
const rendererReady = useIsRendererReady() const rendererReady = useIsRendererReady()
const isSlideDeck = useApplicationState((state) => state.noteDetails?.frontmatter.type === NoteType.SLIDE)
return useCallback(() => { return useCallback(() => {
if (!rendererReady) { if (!rendererReady || !iframeCommunicator) {
return
}
if (isSlideDeck) {
iframeCommunicator.sendMessageToOtherSide({
type: CommunicationMessageType.SET_PRINT_MODE,
printMode: true
})
return return
} }
@@ -39,7 +50,7 @@ export const usePrintIframe = (): (() => void) => {
printMode: false printMode: false
}) })
}, TIMEOUT_BEFORE_PRINT) }, TIMEOUT_BEFORE_PRINT)
}, [rendererReady, iframeCommunicator]) }, [iframeCommunicator, isSlideDeck, rendererReady])
} }
/** /**
@@ -50,6 +61,9 @@ export const usePrintIframe = (): (() => void) => {
export const usePrintSelf = () => { export const usePrintSelf = () => {
return useCallback(() => { return useCallback(() => {
setPrintMode(true) setPrintMode(true)
if (document.querySelector('.reveal')) {
return
}
setTimeout(() => { setTimeout(() => {
window.print() window.print()
setPrintMode(false) setPrintMode(false)
@@ -4,11 +4,18 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Logger } from '../../../utils/logger' 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 Reveal from 'reveal.js'
import type { RevealOptions } from 'reveal.js' import type { RevealOptions } from 'reveal.js'
const log = new Logger('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 { export enum REVEAL_STATUS {
NOT_INITIALISED, NOT_INITIALISED,
@@ -31,17 +38,22 @@ const initialSlideState: SlideState = {
* @param markdownContentLines An array of markdown lines. * @param markdownContentLines An array of markdown lines.
* @param slideOptions The slide options. * @param slideOptions The slide options.
* @param targetSlideState The slide that should be shown. * @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 * @return The current state of reveal.js
* @see https://revealjs.com/ * @see https://revealjs.com/
*/ */
export const useReveal = ( export const useReveal = (
markdownContentLines: string[], markdownContentLines: string[],
slideOptions?: RevealOptions, slideOptions?: RevealOptions,
targetSlideState?: SlideState targetSlideState?: SlideState,
printMode = false,
onPrintModeConsumed?: () => void
): REVEAL_STATUS => { ): REVEAL_STATUS => {
const [deck, setDeck] = useState<Reveal>() const [deck, setDeck] = useState<Reveal>()
const [revealStatus, setRevealStatus] = useState<REVEAL_STATUS>(REVEAL_STATUS.NOT_INITIALISED) const [revealStatus, setRevealStatus] = useState<REVEAL_STATUS>(REVEAL_STATUS.NOT_INITIALISED)
const currentSlideState = useRef<SlideState>(initialSlideState) const currentSlideState = useRef<SlideState>(initialSlideState)
const printInProgress = useRef<boolean>(false)
useEffect(() => { useEffect(() => {
if (revealStatus !== REVEAL_STATUS.NOT_INITIALISED) { if (revealStatus !== REVEAL_STATUS.NOT_INITIALISED) {
@@ -49,10 +61,11 @@ export const useReveal = (
} }
setRevealStatus(REVEAL_STATUS.INITIALISING) setRevealStatus(REVEAL_STATUS.INITIALISING)
log.debug('Initialize with slide options', slideOptions) log.debug('Initialize with slide options', slideOptions)
const revealOptions: RevealOptions = printMode ? { ...slideOptions, view: 'print' } : (slideOptions ?? {})
import(/* webpackChunkName: "reveal" */ 'reveal.js') import(/* webpackChunkName: "reveal" */ 'reveal.js')
.then((revealImport) => { .then((revealImport) => {
const reveal = new revealImport.default({}) const reveal = new revealImport.default(revealOptions)
reveal reveal
.initialize() .initialize()
.then(() => { .then(() => {
@@ -76,20 +89,34 @@ export const useReveal = (
.catch((error: Error) => { .catch((error: Error) => {
log.error('Error while loading reveal.js', error) log.error('Error while loading reveal.js', error)
}) })
}, [revealStatus, slideOptions]) }, [printMode, revealStatus, slideOptions])
useEffect(() => { 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 return
} }
log.debug('Sync deck') log.debug('Sync deck')
deck.sync() deck.sync()
deck.slide(currentSlideState.current.indexHorizontal, currentSlideState.current.indexVertical) deck.slide(currentSlideState.current.indexHorizontal, currentSlideState.current.indexVertical)
}, [markdownContentLines, deck, revealStatus]) }, [markdownContentLines, deck, printMode, revealStatus])
useEffect(() => { useEffect(() => {
if ( if (
!deck || !deck ||
printMode ||
slideOptions === undefined || slideOptions === undefined ||
Object.keys(slideOptions).length === 0 || Object.keys(slideOptions).length === 0 ||
revealStatus !== REVEAL_STATUS.INITIALISED revealStatus !== REVEAL_STATUS.INITIALISED
@@ -98,14 +125,44 @@ export const useReveal = (
} }
log.debug('Apply config', slideOptions) log.debug('Apply config', slideOptions)
deck.configure(slideOptions) deck.configure(slideOptions)
}, [deck, revealStatus, slideOptions]) }, [deck, printMode, revealStatus, slideOptions])
useEffect(() => { useEffect(() => {
if (!deck || targetSlideState === undefined || revealStatus !== REVEAL_STATUS.INITIALISED) { if (!deck || printMode || targetSlideState === undefined || revealStatus !== REVEAL_STATUS.INITIALISED) {
return return
} }
deck.slide(targetSlideState.indexHorizontal, targetSlideState.indexVertical) 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 return revealStatus
} }
@@ -19,6 +19,7 @@ import { EventEmitter2 } from 'eventemitter2'
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { setPrintMode } from '../../redux/print-mode/methods' import { setPrintMode } from '../../redux/print-mode/methods'
import { usePrintSelfKeyboardShortcut } from '../editor-page/hooks/use-print-keyboard-shortcut' 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. * Wraps the markdown rendering in an iframe.
@@ -33,6 +34,9 @@ export const RenderPageContent: React.FC = () => {
const sendScrolling = useRef<boolean>(false) const sendScrolling = useRef<boolean>(false)
const [newLinesAreBreaks, setNewLinesAreBreaks] = useState<boolean>(true) const [newLinesAreBreaks, setNewLinesAreBreaks] = useState<boolean>(true)
const [slideOptions, setSlideOptions] = useState<RevealOptions>() const [slideOptions, setSlideOptions] = useState<RevealOptions>()
const printMode = useApplicationState((state) => state.printMode)
const resetPrintMode = useCallback(() => setPrintMode(false), [])
useRendererReceiveHandler( useRendererReceiveHandler(
CommunicationMessageType.SET_SLIDE_OPTIONS, CommunicationMessageType.SET_SLIDE_OPTIONS,
@@ -189,6 +193,8 @@ export const RenderPageContent: React.FC = () => {
newLinesAreBreaks={newLinesAreBreaks} newLinesAreBreaks={newLinesAreBreaks}
scrollState={scrollState} scrollState={scrollState}
slideOptions={slideOptions} slideOptions={slideOptions}
printMode={printMode}
onPrintModeConsumed={resetPrintMode}
/> />
) )
case RendererType.SIMPLE: case RendererType.SIMPLE:
@@ -210,6 +216,8 @@ export const RenderPageContent: React.FC = () => {
onHeightChange, onHeightChange,
onMakeScrollSource, onMakeScrollSource,
onScroll, onScroll,
printMode,
resetPrintMode,
scrollState, scrollState,
slideOptions slideOptions
]) ])
@@ -18,6 +18,12 @@ import React, { useMemo, useRef } from 'react'
export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps, Pick<ScrollProps, 'scrollState'> { export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps, Pick<ScrollProps, 'scrollState'> {
slideOptions?: RevealOptions 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 * @param newLinesAreBreaks If newlines are rendered as breaks or not
*/ */
export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps> = ({ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps> = ({
printMode = false,
...props
}) => <SlideshowMarkdownRendererContent key={printMode ? 'print' : 'preview'} printMode={printMode} {...props} />
const SlideshowMarkdownRendererContent: React.FC<SlideshowMarkdownRendererContentProps> = ({
markdownContentLines, markdownContentLines,
baseUrl, baseUrl,
newLinesAreBreaks, newLinesAreBreaks,
scrollState, scrollState,
slideOptions slideOptions,
printMode,
onPrintModeConsumed
}) => { }) => {
const markdownBodyRef = useRef<HTMLDivElement>(null) const markdownBodyRef = useRef<HTMLDivElement>(null)
@@ -47,11 +60,11 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps>
() => (scrollState ? findSlideForLine(markdownContentLines, scrollState.firstLineInView) : undefined), () => (scrollState ? findSlideForLine(markdownContentLines, scrollState.firstLineInView) : undefined),
[markdownContentLines, scrollState] [markdownContentLines, scrollState]
) )
const revealStatus = useReveal(markdownContentLines, slideOptions, targetSlideState) const revealStatus = useReveal(markdownContentLines, slideOptions, targetSlideState, printMode, onPrintModeConsumed)
const slideShowDOM = useMemo( const slideShowDOM = useMemo(
() => () =>
revealStatus === REVEAL_STATUS.INITIALISED ? ( revealStatus === REVEAL_STATUS.INITIALISED || printMode ? (
<MarkdownToReact <MarkdownToReact
markdownContentLines={markdownContentLines} markdownContentLines={markdownContentLines}
markdownRenderExtensions={extensions} markdownRenderExtensions={extensions}
@@ -61,7 +74,7 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps>
) : ( ) : (
<LoadingSlide /> <LoadingSlide />
), ),
[extensions, markdownContentLines, newLinesAreBreaks, revealStatus] [extensions, markdownContentLines, newLinesAreBreaks, printMode, revealStatus]
) )
return ( return (
+1
View File
@@ -37,6 +37,7 @@ declare module 'reveal.js' {
transitionSpeed?: string | undefined transitionSpeed?: string | undefined
backgroundTransition?: string | undefined backgroundTransition?: string | undefined
viewDistance?: number | undefined viewDistance?: number | undefined
view?: 'print' | 'reader' | 'scroll' | undefined
// https://github.com/hakimel/reveal.js/#parallax-background // https://github.com/hakimel/reveal.js/#parallax-background
// Parallax background image // Parallax background image
@@ -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)
})
})