Added synced scrolling (#386)

Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
This commit is contained in:
mrdrogdrog 2020-08-19 23:01:38 +02:00 committed by GitHub
parent 164b5436ae
commit 73007ef597
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 413 additions and 38 deletions

View file

@ -1,12 +1,14 @@
import React, { useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Dropdown } from 'react-bootstrap'
import useResizeObserver from 'use-resize-observer'
import { TocAst } from '../../../external-types/markdown-it-toc-done-right/interface'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../../common/show-if/show-if'
import { MarkdownRenderer } from '../../markdown-renderer/markdown-renderer'
import { YAMLMetaData } from '../yaml-metadata/yaml-metadata'
import { LineMarkerPosition, MarkdownRenderer } from '../../markdown-renderer/markdown-renderer'
import { ScrollProps, ScrollState } from '../scroll/scroll-props'
import { findLineMarks } from '../scroll/utils'
import { TableOfContents } from '../table-of-contents/table-of-contents'
import { YAMLMetaData } from '../yaml-metadata/yaml-metadata'
interface DocumentRenderPaneProps {
content: string
@ -15,15 +17,69 @@ interface DocumentRenderPaneProps {
wide?: boolean
}
export const DocumentRenderPane: React.FC<DocumentRenderPaneProps> = ({ content, onMetadataChange, onFirstHeadingChange, wide }) => {
export const DocumentRenderPane: React.FC<DocumentRenderPaneProps & ScrollProps> = ({ content, onMetadataChange, onFirstHeadingChange, wide, scrollState, onScroll, onMakeScrollSource }) => {
const [tocAst, setTocAst] = useState<TocAst>()
const renderer = useRef<HTMLDivElement>(null)
const { width } = useResizeObserver({ ref: renderer })
const lastScrollPosition = useRef<number>()
const [lineMarks, setLineMarks] = useState<LineMarkerPosition[]>()
const realWidth = width || 0
useEffect(() => {
if (!renderer.current || !lineMarks || !scrollState) {
return
}
const { lastMarkBefore, firstMarkAfter } = findLineMarks(lineMarks, scrollState.firstLineInView)
const positionBefore = lastMarkBefore ? lastMarkBefore.position : 0
const positionAfter = firstMarkAfter ? firstMarkAfter.position : renderer.current.offsetHeight
const lastMarkBeforeLine = lastMarkBefore ? lastMarkBefore.line : 1
const firstMarkAfterLine = firstMarkAfter ? firstMarkAfter.line : content.split('\n').length
const lineCount = firstMarkAfterLine - lastMarkBeforeLine
const blockHeight = positionAfter - positionBefore
const lineHeight = blockHeight / lineCount
const position = positionBefore + (scrollState.firstLineInView - lastMarkBeforeLine) * lineHeight + scrollState.scrolledPercentage / 100 * lineHeight
const correctedPosition = Math.floor(position)
if (correctedPosition !== lastScrollPosition.current) {
lastScrollPosition.current = correctedPosition
renderer.current.scrollTo({
top: correctedPosition
})
}
}, [content, lineMarks, scrollState])
const userScroll = useCallback(() => {
if (!renderer.current || !lineMarks || !onScroll) {
return
}
const resyncedScroll = Math.ceil(renderer.current.scrollTop) === lastScrollPosition.current
if (resyncedScroll) {
return
}
const scrollTop = renderer.current.scrollTop
const beforeLineMark = lineMarks
.filter(lineMark => lineMark.position <= scrollTop)
.reduce((prevLineMark, currentLineMark) =>
prevLineMark.line >= currentLineMark.line ? prevLineMark : currentLineMark)
const afterLineMark = lineMarks
.filter(lineMark => lineMark.position > scrollTop)
.reduce((prevLineMark, currentLineMark) =>
prevLineMark.line < currentLineMark.line ? prevLineMark : currentLineMark)
const blockHeight = afterLineMark.position - beforeLineMark.position
const distanceToBefore = scrollTop - beforeLineMark.position
const percentageRaw = (distanceToBefore / blockHeight)
const percentage = Math.floor(percentageRaw * 100)
const newScrollState: ScrollState = { firstLineInView: beforeLineMark.line, scrolledPercentage: percentage }
onScroll(newScrollState)
}, [lineMarks, onScroll])
return (
<div className={'bg-light flex-fill pb-5 flex-row d-flex w-100 h-100 overflow-y-scroll'} ref={renderer}>
<div className={'bg-light flex-fill pb-5 flex-row d-flex w-100 h-100 overflow-y-scroll'}
ref={renderer} onScroll={userScroll} onMouseEnter={onMakeScrollSource} >
<div className={'col-md'}/>
<MarkdownRenderer
className={'flex-fill'}
@ -32,11 +88,12 @@ export const DocumentRenderPane: React.FC<DocumentRenderPaneProps> = ({ content,
onTocChange={(tocAst) => setTocAst(tocAst)}
onMetaDataChange={onMetadataChange}
onFirstHeadingChange={onFirstHeadingChange}
onLineMarkerPositionChanged={setLineMarks}
/>
<div className={'col-md'}>
<ShowIf condition={realWidth >= 1280 && !!tocAst}>
<TableOfContents ast={tocAst as TocAst} sticky={true}/>
<TableOfContents ast={tocAst as TocAst} className={'position-fixed'}/>
</ShowIf>
<ShowIf condition={realWidth < 1280 && !!tocAst}>
<div className={'markdown-toc-sidebar-button'}>

View file

@ -1,4 +1,4 @@
import { Editor, EditorChange, EditorConfiguration } from 'codemirror'
import { Editor, EditorChange, EditorConfiguration, ScrollInfo } from 'codemirror'
import 'codemirror/addon/comment/comment'
import 'codemirror/addon/dialog/dialog'
import 'codemirror/addon/display/autorefresh'
@ -20,10 +20,11 @@ import 'codemirror/keymap/sublime'
import 'codemirror/keymap/emacs'
import 'codemirror/keymap/vim'
import 'codemirror/mode/gfm/gfm'
import React, { useCallback, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
import { useTranslation } from 'react-i18next'
import './editor-pane.scss'
import { ScrollProps, ScrollState } from '../scroll/scroll-props'
import { generateEmojiHints, emojiWordRegex, findWordAtCursor } from './hints/emoji'
import { defaultKeyMap } from './key-map'
import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar'
@ -48,7 +49,7 @@ const onChange = (editor: Editor) => {
}
}
export const EditorPane: React.FC<EditorPaneProps> = ({ onContentChange, content }) => {
export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentChange, content, scrollState, onScroll, onMakeScrollSource }) => {
const { t } = useTranslation()
const [editor, setEditor] = useState<Editor>()
const [statusBarInfo, setStatusBarInfo] = useState<StatusBarInfo>(defaultState)
@ -59,6 +60,41 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ onContentChange, content
indentWithTabs: false
})
const lastScrollPosition = useRef<number>()
const onEditorScroll = useCallback((editor: Editor, data: ScrollInfo) => {
if (!editor || !onScroll) {
return
}
const scrollEventValue = data.top as number
const line = editor.lineAtHeight(scrollEventValue, 'local')
const startYOfLine = editor.heightAtLine(line, 'local')
const lineInfo = editor.lineInfo(line)
if (lineInfo === null) {
return
}
const heightOfLine = (lineInfo.handle as { height: number }).height
const percentageRaw = (Math.max(scrollEventValue - startYOfLine, 0)) / heightOfLine
const percentage = Math.floor(percentageRaw * 100)
const newScrollState: ScrollState = { firstLineInView: line + 1, scrolledPercentage: percentage }
onScroll(newScrollState)
}, [onScroll])
useEffect(() => {
if (!editor || !scrollState) {
return
}
const startYOfLine = editor.heightAtLine(scrollState.firstLineInView - 1, 'div')
const heightOfLine = (editor.lineInfo(scrollState.firstLineInView - 1).handle as { height: number }).height
const newPositionRaw = startYOfLine + (heightOfLine * scrollState.scrolledPercentage / 100)
const newPosition = Math.floor(newPositionRaw)
if (newPosition !== lastScrollPosition.current) {
lastScrollPosition.current = newPosition
editor.scrollTo(0, newPosition)
}
}, [editor, scrollState])
const onBeforeChange = useCallback((editor: Editor, data: EditorChange, value: string) => {
onContentChange(value)
}, [onContentChange])
@ -100,7 +136,7 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ onContentChange, content
}), [t, editorPreferences])
return (
<div className={'d-flex flex-column h-100'}>
<div className={'d-flex flex-column h-100'} onMouseEnter={onMakeScrollSource}>
<ToolBar
editor={editor}
onPreferencesChange={config => setEditorPreferences(config)}
@ -114,6 +150,7 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ onContentChange, content
onCursorActivity={onCursorActivity}
editorDidMount={onEditorDidMount}
onBeforeChange={onBeforeChange}
onScroll={onEditorScroll}
/>
<StatusBar {...statusBarInfo} />
</div>

View file

@ -11,6 +11,7 @@ import { Splitter } from './splitter/splitter'
import { MotdBanner } from '../common/motd-banner/motd-banner'
import { DocumentBar } from './document-bar/document-bar'
import { editorTestContent } from './editorTestContent'
import { DualScrollState, ScrollState } from './scroll/scroll-props'
import { YAMLMetaData } from './yaml-metadata/yaml-metadata'
import { AppBar } from './app-bar/app-bar'
import { EditorMode } from './app-bar/editor-view-mode'
@ -19,6 +20,11 @@ export interface EditorPathParams {
id: string
}
export enum ScrollSource {
EDITOR,
RENDERER
}
export const Editor: React.FC = () => {
const { t } = useTranslation()
const untitledNote = t('editor.untitledNote')
@ -29,6 +35,12 @@ export const Editor: React.FC = () => {
const [documentTitle, setDocumentTitle] = useState(untitledNote)
const noteMetadata = useRef<YAMLMetaData>()
const firstHeading = useRef<string>()
const scrollSource = useRef<ScrollSource>(ScrollSource.EDITOR)
const [scrollState, setScrollState] = useState<DualScrollState>(() => ({
editorScrollState: { firstLineInView: 1, scrolledPercentage: 0 },
rendererScrollState: { firstLineInView: 1, scrolledPercentage: 0 }
}))
const updateDocumentTitle = useCallback(() => {
if (noteMetadata.current?.title && noteMetadata.current?.title !== '') {
@ -60,6 +72,18 @@ export const Editor: React.FC = () => {
}
}, [editorMode, firstDraw, isWide])
const onEditorScroll = useCallback((newScrollState: ScrollState) => {
if (scrollSource.current === ScrollSource.EDITOR) {
setScrollState((old) => ({ rendererScrollState: newScrollState, editorScrollState: old.editorScrollState }))
}
}, [])
const onMarkdownRendererScroll = useCallback((newScrollState: ScrollState) => {
if (scrollSource.current === ScrollSource.RENDERER) {
setScrollState((old) => ({ editorScrollState: newScrollState, rendererScrollState: old.rendererScrollState }))
}
}, [])
return (
<Fragment>
<MotdBanner/>
@ -72,16 +96,22 @@ export const Editor: React.FC = () => {
left={
<EditorPane
onContentChange={content => setMarkdownContent(content)}
content={markdownContent}/>
content={markdownContent}
scrollState={scrollState.editorScrollState}
onScroll={onEditorScroll}
onMakeScrollSource={() => { scrollSource.current = ScrollSource.EDITOR }}
/>
}
showRight={editorMode === EditorMode.PREVIEW || (editorMode === EditorMode.BOTH)}
right={
<DocumentRenderPane
content={markdownContent}
wide={editorMode === EditorMode.PREVIEW}
scrollState={scrollState.rendererScrollState}
onScroll={onMarkdownRendererScroll}
onMetadataChange={onMetadataChange}
onFirstHeadingChange={onFirstHeadingChange}/>
}
onFirstHeadingChange={onFirstHeadingChange}
onMakeScrollSource={() => { scrollSource.current = ScrollSource.RENDERER }}/>}
containerClassName={'overflow-hidden'}/>
</div>
</Fragment>

View file

@ -0,0 +1,55 @@
import MarkdownIt from 'markdown-it/lib'
import Token from 'markdown-it/lib/token'
export const lineNumberMarker: () => MarkdownIt.PluginSimple = () => {
const endMarkers: number[] = []
return (md: MarkdownIt) => {
// add codimd_linemarker token before each opening or self-closing level-0 tag
md.core.ruler.push('line_number_marker', (state) => {
for (let i = 0; i < state.tokens.length; i++) {
const token = state.tokens[i]
if (token.level !== 0) {
continue
}
if (token.nesting !== -1) {
if (!token.map) {
continue
}
const lineNumber = token.map[0] + 1
if (token.nesting === 1) {
endMarkers.push(token.map[1] + 1)
}
const tokenBefore = state.tokens[i - 1]
if (tokenBefore === undefined || tokenBefore.type !== 'codimd_linemarker' || tokenBefore.tag !== 'codimd-linemarker' ||
tokenBefore.attrGet('data-linenumber') !== `${lineNumber}`) {
const startToken = new Token('codimd_linemarker', 'codimd-linemarker', 0)
startToken.attrPush(['data-linenumber', `${lineNumber}`])
state.tokens.splice(i, 0, startToken)
i += 1
}
} else {
const lineNumber = endMarkers.pop()
if (lineNumber) {
const startToken = new Token('codimd_linemarker', 'codimd-linemarker', 0)
startToken.attrPush(['data-linenumber', `${lineNumber}`])
state.tokens.splice(i + 1, 0, startToken)
i += 1
}
}
}
return true
})
// render codimd_linemarker token to <codimd-linemarker></codimd-linemarker>
md.renderer.rules.codimd_linemarker = (tokens: Token[], index: number): string => {
const lineNumber = tokens[index].attrGet('data-linenumber')
if (!lineNumber) {
// don't render broken linemarkers without a linenumber
return ''
}
// noinspection CheckTagEmptyBody
return `<codimd-linemarker data-linenumber='${lineNumber}'></codimd-linemarker>`
}
}
}

View file

@ -0,0 +1,15 @@
export interface ScrollProps {
scrollState?: ScrollState
onScroll?: (scrollState: ScrollState) => void
onMakeScrollSource?: () => void
}
export interface ScrollState {
firstLineInView: number
scrolledPercentage: number
}
export interface DualScrollState {
editorScrollState: ScrollState
rendererScrollState: ScrollState
}

View file

@ -0,0 +1,25 @@
import { LineMarkerPosition } from '../../markdown-renderer/markdown-renderer'
export const findLineMarks = (lineMarks: LineMarkerPosition[], lineNumber: number): { lastMarkBefore: LineMarkerPosition | undefined, firstMarkAfter: LineMarkerPosition | undefined } => {
let lastMarkBefore
let firstMarkAfter
for (let i = 0; i < lineMarks.length; i++) {
const currentMark = lineMarks[i]
if (!currentMark) {
continue
}
if (currentMark.line <= lineNumber) {
lastMarkBefore = currentMark
}
if (currentMark.line > lineNumber) {
firstMarkAfter = currentMark
}
if (!!firstMarkAfter && !!lastMarkBefore) {
break
}
}
return {
lastMarkBefore,
firstMarkAfter
}
}

View file

@ -54,7 +54,7 @@ export const Splitter: React.FC<SplitterProps> = ({ containerClassName, left, ri
</div>
</ShowIf>
<ShowIf condition={showRight}>
<div className='splitter right'>
<div className={'splitter right'}>
{right}
</div>
</ShowIf>

View file

@ -6,7 +6,7 @@ import './table-of-contents.scss'
export interface TableOfContentsProps {
ast: TocAst
maxDepth?: number
sticky?: boolean
className?: string
}
export const slugify = (content:string) => {
@ -51,11 +51,11 @@ const convertLevel = (toc: TocAst, levelsToShowUnderThis: number, headerCounts:
}
}
export const TableOfContents: React.FC<TableOfContentsProps> = ({ ast, maxDepth = 3, sticky }) => {
export const TableOfContents: React.FC<TableOfContentsProps> = ({ ast, maxDepth = 3, className }) => {
const tocTree = useMemo(() => convertLevel(ast, maxDepth, new Map<string, number>(), false), [ast, maxDepth])
return (
<div className={`markdown-toc ${sticky ? 'sticky' : ''}`}>
<div className={`markdown-toc ${className ?? ''}`}>
{tocTree}
</div>
)

View file

@ -21,11 +21,12 @@ import subscript from 'markdown-it-sub'
import superscript from 'markdown-it-sup'
import taskList from 'markdown-it-task-lists'
import toc from 'markdown-it-toc-done-right'
import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert } from 'react-bootstrap'
import ReactHtmlParser, { convertNodeToElement, Transform } from 'react-html-parser'
import { Trans } from 'react-i18next'
import MathJaxReact from 'react-mathjax'
import useResizeObserver from 'use-resize-observer'
import { useSelector } from 'react-redux'
import { TocAst } from '../../external-types/markdown-it-toc-done-right/interface'
import { ApplicationState } from '../../redux'
@ -65,6 +66,12 @@ import { QuoteOptionsReplacer } from './replace-components/quote-options/quote-o
import { TocReplacer } from './replace-components/toc/toc-replacer'
import { VimeoReplacer } from './replace-components/vimeo/vimeo-replacer'
import { YoutubeReplacer } from './replace-components/youtube/youtube-replacer'
import { lineNumberMarker } from '../editor/markdown-renderer/markdown-it-plugins/line-number-marker'
export interface LineMarkerPosition {
line: number
position: number
}
export interface MarkdownRendererProps {
content: string
@ -73,6 +80,7 @@ export interface MarkdownRendererProps {
onTocChange?: (ast: TocAst) => void
onMetaDataChange?: (yamlMetaData: YAMLMetaData | undefined) => void
onFirstHeadingChange?: (firstHeading: string | undefined) => void
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void
}
const markdownItTwitterEmojis = Object.keys((emojiData as unknown as Data).emojis)
@ -102,14 +110,41 @@ const forkAwesomeIconMap = Object.keys(ForkAwesomeIcons)
return reduceObject
}, {} as { [key: string]: string })
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, onMetaDataChange, onFirstHeadingChange, onTocChange, className, wide }) => {
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, onMetaDataChange, onFirstHeadingChange, onTocChange, className, wide, onLineMarkerPositionChanged }) => {
const [tocAst, setTocAst] = useState<TocAst>()
const [lastTocAst, setLastTocAst] = useState<TocAst>()
const lastTocAst = useRef<TocAst>()
const [yamlError, setYamlError] = useState(false)
const rawMetaRef = useRef<RawYAMLMetadata>()
const oldMetaRef = useRef<RawYAMLMetadata>()
const firstHeadingRef = useRef<string>()
const oldFirstHeadingRef = useRef<string>()
const documentElement = useRef<HTMLDivElement>(null)
const lastLineMarkerPositions = useRef<LineMarkerPosition[]>()
const calculateLineMarkerPositions = useCallback(() => {
if (documentElement.current && onLineMarkerPositionChanged) {
const lineMarkers: NodeListOf<HTMLDivElement> = documentElement.current.querySelectorAll('codimd-linemarker')
const lineMarkerPositions: LineMarkerPosition[] = Array.from(lineMarkers).map((marker) => {
return {
line: Number(marker.getAttribute('data-linenumber')),
position: marker.offsetTop
} as LineMarkerPosition
})
if (!equal(lineMarkerPositions, lastLineMarkerPositions.current)) {
lastLineMarkerPositions.current = lineMarkerPositions
onLineMarkerPositionChanged(lineMarkerPositions)
}
}
}, [onLineMarkerPositionChanged])
useEffect(() => {
calculateLineMarkerPositions()
}, [calculateLineMarkerPositions])
useResizeObserver({
ref: documentElement,
onResize: () => calculateLineMarkerPositions()
})
useEffect(() => {
if (onMetaDataChange && !equal(oldMetaRef.current, rawMetaRef.current)) {
@ -239,6 +274,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, onM
slugify: slugify
})
md.use(linkifyExtra)
md.use(lineNumberMarker())
if (process.env.NODE_ENV !== 'production') {
md.use(MarkdownItParserDebugger)
}
@ -251,11 +287,11 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, onM
}, [onMetaDataChange, onFirstHeadingChange, plantumlServer])
useEffect(() => {
if (onTocChange && tocAst && !equal(tocAst, lastTocAst)) {
if (onTocChange && tocAst && !equal(tocAst, lastTocAst.current)) {
lastTocAst.current = tocAst
onTocChange(tocAst)
setLastTocAst(tocAst)
}
}, [tocAst, onTocChange, lastTocAst])
}, [tocAst, onTocChange])
const tryToReplaceNode = (node: DomElement, index: number, allReplacers: ComponentReplacer[], nodeConverter: SubNodeConverter) => {
return allReplacers
@ -291,17 +327,19 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, onM
}, [content, markdownIt, onMetaDataChange])
return (
<div className={`markdown-body ${className || ''} d-flex flex-column align-items-center ${wide ? 'wider' : ''}`}>
<ShowIf condition={yamlError}>
<Alert variant='warning' dir='auto'>
<Trans i18nKey='editor.invalidYaml'>
<InternalLink text='yaml-metadata' href='/n/yaml-metadata' className='text-dark'/>
</Trans>
</Alert>
</ShowIf>
<MathJaxReact.Provider>
{result}
</MathJaxReact.Provider>
<div className={'bg-light flex-fill pb-5'}>
<div className={`markdown-body ${className || ''} d-flex flex-column align-items-center ${wide ? 'wider' : ''}`} ref={documentElement}>
<ShowIf condition={yamlError}>
<Alert variant='warning' dir='auto'>
<Trans i18nKey='editor.invalidYaml'>
<InternalLink text='yaml-metadata' href='/n/yaml-metadata' className='text-dark'/>
</Trans>
</Alert>
</ShowIf>
<MathJaxReact.Provider>
{result}
</MathJaxReact.Provider>
</div>
</div>
)
}