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 { 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)
@@ -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<Reveal>()
const [revealStatus, setRevealStatus] = useState<REVEAL_STATUS>(REVEAL_STATUS.NOT_INITIALISED)
const currentSlideState = useRef<SlideState>(initialSlideState)
const printInProgress = useRef<boolean>(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
}
@@ -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<boolean>(false)
const [newLinesAreBreaks, setNewLinesAreBreaks] = useState<boolean>(true)
const [slideOptions, setSlideOptions] = useState<RevealOptions>()
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
])
@@ -18,6 +18,12 @@ import React, { useMemo, useRef } from 'react'
export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps, Pick<ScrollProps, 'scrollState'> {
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<SlideshowMarkdownRendererProps> = ({
printMode = false,
...props
}) => <SlideshowMarkdownRendererContent key={printMode ? 'print' : 'preview'} printMode={printMode} {...props} />
const SlideshowMarkdownRendererContent: React.FC<SlideshowMarkdownRendererContentProps> = ({
markdownContentLines,
baseUrl,
newLinesAreBreaks,
scrollState,
slideOptions
slideOptions,
printMode,
onPrintModeConsumed
}) => {
const markdownBodyRef = useRef<HTMLDivElement>(null)
@@ -47,11 +60,11 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps>
() => (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 ? (
<MarkdownToReact
markdownContentLines={markdownContentLines}
markdownRenderExtensions={extensions}
@@ -61,7 +74,7 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps>
) : (
<LoadingSlide />
),
[extensions, markdownContentLines, newLinesAreBreaks, revealStatus]
[extensions, markdownContentLines, newLinesAreBreaks, printMode, revealStatus]
)
return (
+1
View File
@@ -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
@@ -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)
})
})