feat(renderer): add sync-view of slides in the editor

The slide where the user currently has their cursor in the editor,
will automatically be viewed in the renderer side.
This behaviour is toggable using the existing sync-scroll setting.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-07-15 00:47:19 +02:00
committed by Philip Molares
parent a27607b28b
commit 16e769bd60
8 changed files with 206 additions and 5 deletions
@@ -19,6 +19,9 @@ import React, { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import './print.scss' import './print.scss'
import { usePrintKeyboardShortcut } from './hooks/use-print-keyboard-shortcut' import { usePrintKeyboardShortcut } from './hooks/use-print-keyboard-shortcut'
import { NoteType } from '@hedgedoc/commons'
import { useApplicationState } from '../../hooks/common/use-application-state'
import { buildCursorLineScrollState } from './synced-scroll/cursor-line-scroll-state'
export enum ScrollSource { export enum ScrollSource {
EDITOR = 'editor', EDITOR = 'editor',
@@ -37,6 +40,20 @@ export const EditorPageContent: React.FC = () => {
const [rendererScrollState, onEditorScroll] = useScrollState(scrollSource, ScrollSource.RENDERER) const [rendererScrollState, onEditorScroll] = useScrollState(scrollSource, ScrollSource.RENDERER)
const setRendererToScrollSource = useSetScrollSource(scrollSource, ScrollSource.RENDERER) const setRendererToScrollSource = useSetScrollSource(scrollSource, ScrollSource.RENDERER)
const setEditorToScrollSource = useSetScrollSource(scrollSource, ScrollSource.EDITOR) const setEditorToScrollSource = useSetScrollSource(scrollSource, ScrollSource.EDITOR)
const syncScrollEnabled = useApplicationState((state) => state.editorConfig.syncScroll)
const noteType = useApplicationState((state) => state.noteDetails?.frontmatter.type)
const cursorPosition = useApplicationState((state) => state.noteDetails?.selection.from)
const lineStartIndexes = useApplicationState((state) => state.noteDetails?.markdownContent.lineStartIndexes ?? [])
const cursorScrollState = useMemo(
() => buildCursorLineScrollState(lineStartIndexes, cursorPosition),
[cursorPosition, lineStartIndexes]
)
const rendererPaneScrollState = useMemo(() => {
if (noteType !== NoteType.SLIDE) {
return rendererScrollState
}
return syncScrollEnabled ? cursorScrollState : null
}, [cursorScrollState, noteType, rendererScrollState, syncScrollEnabled])
const leftPane = useMemo( const leftPane = useMemo(
() => ( () => (
@@ -55,10 +72,10 @@ export const EditorPageContent: React.FC = () => {
frameClasses={'h-100 w-100'} frameClasses={'h-100 w-100'}
onMakeScrollSource={setRendererToScrollSource} onMakeScrollSource={setRendererToScrollSource}
onScroll={onMarkdownRendererScroll} onScroll={onMarkdownRendererScroll}
scrollState={rendererScrollState} scrollState={rendererPaneScrollState}
/> />
), ),
[onMarkdownRendererScroll, rendererScrollState, setRendererToScrollSource] [onMarkdownRendererScroll, rendererPaneScrollState, setRendererToScrollSource]
) )
const editorExtensionComponents = useComponentsFromAppExtensions() const editorExtensionComponents = useComponentsFromAppExtensions()
@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { buildCursorLineScrollState } from './cursor-line-scroll-state'
describe('buildCursorLineScrollState', () => {
it('returns null without a cursor position', () => {
expect(buildCursorLineScrollState([0, 6], undefined)).toBeNull()
})
it('returns null without line start indexes', () => {
expect(buildCursorLineScrollState([], 0)).toBeNull()
})
it('returns the first line for a cursor at the start of the document', () => {
expect(buildCursorLineScrollState([0, 6, 12], 0)).toEqual({ firstLineInView: 1, scrolledPercentage: 0 })
})
it('returns the matching line for a cursor inside the document', () => {
expect(buildCursorLineScrollState([0, 6, 12], 8)).toEqual({ firstLineInView: 2, scrolledPercentage: 0 })
})
it('returns the next line for a cursor at the start of that line', () => {
expect(buildCursorLineScrollState([0, 6, 12], 12)).toEqual({ firstLineInView: 3, scrolledPercentage: 0 })
})
})
@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ScrollState } from './scroll-props'
/**
* Uses a middle-based search through the lineStartIndexes to find the line number for a given position.
*
* @param lineStartIndexes The list of line start indexes in the content.
* @param position The position in the content.
* @returns The line number of the position in the content.
*/
const findLineNumberForPosition = (lineStartIndexes: number[], position: number): number => {
let lowerBound = 0
let upperBound = lineStartIndexes.length - 1
while (lowerBound <= upperBound) {
const middle = Math.floor((lowerBound + upperBound) / 2)
const lineStartIndex = lineStartIndexes[middle]
const nextLineStartIndex = lineStartIndexes[middle + 1]
if (lineStartIndex === undefined) {
break
}
if (position < lineStartIndex) {
upperBound = middle - 1
} else if (nextLineStartIndex !== undefined && position >= nextLineStartIndex) {
lowerBound = middle + 1
} else {
return middle + 1
}
}
return 1
}
/**
* Converts the local CodeMirror selection position into a line-based scroll state.
* This is used by slide sync, where the cursor line selects the active slide.
* The scrolledPercentage is always 0 because this is not required for slide syncing.
*
* @param lineStartIndexes The absolute start positions of all lines in the note
* @param cursorPosition The local user's cursor position in the note
*/
export const buildCursorLineScrollState = (
lineStartIndexes: number[],
cursorPosition: number | undefined
): ScrollState | null => {
if (cursorPosition === undefined || lineStartIndexes.length === 0) {
return null
}
return {
firstLineInView: findLineNumberForPosition(lineStartIndexes, cursorPosition),
scrolledPercentage: 0
}
}
@@ -30,10 +30,15 @@ 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.
* @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 = (markdownContentLines: string[], slideOptions?: RevealOptions): REVEAL_STATUS => { export const useReveal = (
markdownContentLines: string[],
slideOptions?: RevealOptions,
targetSlideState?: SlideState
): 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)
@@ -95,5 +100,12 @@ export const useReveal = (markdownContentLines: string[], slideOptions?: RevealO
deck.configure(slideOptions) deck.configure(slideOptions)
}, [deck, revealStatus, slideOptions]) }, [deck, revealStatus, slideOptions])
useEffect(() => {
if (!deck || targetSlideState === undefined || revealStatus !== REVEAL_STATUS.INITIALISED) {
return
}
deck.slide(targetSlideState.indexHorizontal, targetSlideState.indexVertical)
}, [deck, revealStatus, targetSlideState])
return revealStatus return revealStatus
} }
@@ -183,6 +183,7 @@ export const RenderPageContent: React.FC = () => {
markdownContentLines={deferredMarkdownContentLines} markdownContentLines={deferredMarkdownContentLines}
baseUrl={baseConfiguration.baseUrl} baseUrl={baseConfiguration.baseUrl}
newLinesAreBreaks={newLinesAreBreaks} newLinesAreBreaks={newLinesAreBreaks}
scrollState={scrollState}
slideOptions={slideOptions} slideOptions={slideOptions}
/> />
) )
@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { findSlideForLine } from './slide-line-mapping'
describe('findSlideForLine', () => {
const markdownContentLines = [
'# Slide 1',
'',
'---',
'# Slide 2',
'----',
'# Slide 2.1',
'----',
'# Slide 2.2',
'---',
'# Slide 3'
]
it('returns the first slide for the first line', () => {
expect(findSlideForLine(markdownContentLines, 1)).toEqual({ indexHorizontal: 0, indexVertical: 0 })
})
it('returns the next horizontal slide after a horizontal separator', () => {
expect(findSlideForLine(markdownContentLines, 4)).toEqual({ indexHorizontal: 1, indexVertical: 0 })
})
it('returns the next vertical slide after a vertical separator', () => {
expect(findSlideForLine(markdownContentLines, 6)).toEqual({ indexHorizontal: 1, indexVertical: 1 })
})
it('resets the vertical slide index after a horizontal separator', () => {
expect(findSlideForLine(markdownContentLines, 10)).toEqual({ indexHorizontal: 2, indexVertical: 0 })
})
it('returns the first slide for frontmatter lines before the rendered content', () => {
expect(findSlideForLine(markdownContentLines, -1)).toEqual({ indexHorizontal: 0, indexVertical: 0 })
})
})
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { SlideState } from '../../../markdown-renderer/hooks/use-reveal'
const horizontalSlideSeparator = /^\s*---\s*$/
const verticalSlideSeparator = /^\s*----\s*$/
/**
* Maps a markdown source line to the reveal.js slide coordinates that contain it.
*
* @param markdownContentLines The rendered markdown lines without frontmatter
* @param lineNumber The 1-based markdown line number without frontmatter
*/
export const findSlideForLine = (markdownContentLines: string[], lineNumber: number): SlideState => {
let indexHorizontal = 0
let indexVertical = 0
for (let lineIndex = 0; lineIndex < Math.max(0, lineNumber - 1); lineIndex++) {
const line = markdownContentLines[lineIndex]
if (line === undefined) {
break
}
if (verticalSlideSeparator.test(line)) {
indexVertical += 1
} else if (horizontalSlideSeparator.test(line)) {
indexHorizontal += 1
indexVertical = 0
}
}
return { indexHorizontal, indexVertical }
}
@@ -7,14 +7,16 @@ import { RevealMarkdownExtension } from '../../../markdown-renderer/extensions/r
import { useMarkdownExtensions } from '../../../markdown-renderer/hooks/use-markdown-extensions' import { useMarkdownExtensions } from '../../../markdown-renderer/hooks/use-markdown-extensions'
import { REVEAL_STATUS, useReveal } from '../../../markdown-renderer/hooks/use-reveal' import { REVEAL_STATUS, useReveal } from '../../../markdown-renderer/hooks/use-reveal'
import { MarkdownToReact } from '../../../markdown-renderer/markdown-to-react/markdown-to-react' import { MarkdownToReact } from '../../../markdown-renderer/markdown-to-react/markdown-to-react'
import type { ScrollProps } from '../../../editor-page/synced-scroll/scroll-props'
import { RendererType } from '../../window-post-message-communicator/rendering-message' import { RendererType } from '../../window-post-message-communicator/rendering-message'
import type { CommonMarkdownRendererProps } from '../common-markdown-renderer-props' import type { CommonMarkdownRendererProps } from '../common-markdown-renderer-props'
import { LoadingSlide } from './loading-slide' import { LoadingSlide } from './loading-slide'
import { findSlideForLine } from './slide-line-mapping'
import styles from './slideshow.module.scss' import styles from './slideshow.module.scss'
import type { RevealOptions } from 'reveal.js' import type { RevealOptions } from 'reveal.js'
import React, { useMemo, useRef } from 'react' import React, { useMemo, useRef } from 'react'
export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps { export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps, Pick<ScrollProps, 'scrollState'> {
slideOptions?: RevealOptions slideOptions?: RevealOptions
} }
@@ -30,6 +32,7 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps>
markdownContentLines, markdownContentLines,
baseUrl, baseUrl,
newLinesAreBreaks, newLinesAreBreaks,
scrollState,
slideOptions slideOptions
}) => { }) => {
const markdownBodyRef = useRef<HTMLDivElement>(null) const markdownBodyRef = useRef<HTMLDivElement>(null)
@@ -40,7 +43,11 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps>
useMemo(() => [new RevealMarkdownExtension()], []) useMemo(() => [new RevealMarkdownExtension()], [])
) )
const revealStatus = useReveal(markdownContentLines, slideOptions) const targetSlideState = useMemo(
() => (scrollState ? findSlideForLine(markdownContentLines, scrollState.firstLineInView) : undefined),
[markdownContentLines, scrollState]
)
const revealStatus = useReveal(markdownContentLines, slideOptions, targetSlideState)
const slideShowDOM = useMemo( const slideShowDOM = useMemo(
() => () =>