From 630a4001812f050ca8b22df28bbefb067b542fc4 Mon Sep 17 00:00:00 2001 From: Kenichi Kobayashi Date: Wed, 3 Nov 2021 21:10:46 +0900 Subject: [PATCH] Desktop: Resolves #2242: Implements Sync-Scroll for Markdown Editor and Viewer (#5512) --- .eslintignore | 9 ++ .gitignore | 9 ++ packages/app-cli/tests/MdToHtml.ts | 14 ++ .../NoteBody/CodeMirror/CodeMirror.tsx | 24 +++- .../NoteBody/CodeMirror/utils/index.ts | 64 +-------- .../CodeMirror/utils/useScrollHandler.ts | 125 ++++++++++++++++++ .../gui/NoteEditor/utils/useMarkupToHtml.ts | 1 + packages/app-desktop/gui/NoteTextViewer.tsx | 12 ++ .../app-desktop/gui/note-viewer/index.html | 106 +++++++++------ .../app-desktop/gui/utils/SyncScrollMap.ts | 92 +++++++++++++ packages/renderer/MdToHtml.ts | 2 + .../renderer/MdToHtml/rules/source_map.ts | 36 +++++ 12 files changed, 386 insertions(+), 108 deletions(-) create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.ts create mode 100644 packages/app-desktop/gui/utils/SyncScrollMap.ts create mode 100644 packages/renderer/MdToHtml/rules/source_map.ts diff --git a/.eslintignore b/.eslintignore index 4ad155549..ffa91ffa8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -396,6 +396,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js. packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js.map +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.d.ts +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js.map packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js.map @@ -678,6 +681,9 @@ packages/app-desktop/gui/style/StyledTextInput.js.map packages/app-desktop/gui/utils/NoteListUtils.d.ts packages/app-desktop/gui/utils/NoteListUtils.js packages/app-desktop/gui/utils/NoteListUtils.js.map +packages/app-desktop/gui/utils/SyncScrollMap.d.ts +packages/app-desktop/gui/utils/SyncScrollMap.js +packages/app-desktop/gui/utils/SyncScrollMap.js.map packages/app-desktop/gui/utils/convertToScreenCoordinates.d.ts packages/app-desktop/gui/utils/convertToScreenCoordinates.js packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map @@ -1848,6 +1854,9 @@ packages/renderer/MdToHtml/rules/mermaid.js.map packages/renderer/MdToHtml/rules/sanitize_html.d.ts packages/renderer/MdToHtml/rules/sanitize_html.js packages/renderer/MdToHtml/rules/sanitize_html.js.map +packages/renderer/MdToHtml/rules/source_map.d.ts +packages/renderer/MdToHtml/rules/source_map.js +packages/renderer/MdToHtml/rules/source_map.js.map packages/renderer/MdToHtml/setupLinkify.d.ts packages/renderer/MdToHtml/setupLinkify.js packages/renderer/MdToHtml/setupLinkify.js.map diff --git a/.gitignore b/.gitignore index 03cb51db2..13bd6c653 100644 --- a/.gitignore +++ b/.gitignore @@ -379,6 +379,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js. packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js.map +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.d.ts +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js.map packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js.map @@ -661,6 +664,9 @@ packages/app-desktop/gui/style/StyledTextInput.js.map packages/app-desktop/gui/utils/NoteListUtils.d.ts packages/app-desktop/gui/utils/NoteListUtils.js packages/app-desktop/gui/utils/NoteListUtils.js.map +packages/app-desktop/gui/utils/SyncScrollMap.d.ts +packages/app-desktop/gui/utils/SyncScrollMap.js +packages/app-desktop/gui/utils/SyncScrollMap.js.map packages/app-desktop/gui/utils/convertToScreenCoordinates.d.ts packages/app-desktop/gui/utils/convertToScreenCoordinates.js packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map @@ -1831,6 +1837,9 @@ packages/renderer/MdToHtml/rules/mermaid.js.map packages/renderer/MdToHtml/rules/sanitize_html.d.ts packages/renderer/MdToHtml/rules/sanitize_html.js packages/renderer/MdToHtml/rules/sanitize_html.js.map +packages/renderer/MdToHtml/rules/source_map.d.ts +packages/renderer/MdToHtml/rules/source_map.js +packages/renderer/MdToHtml/rules/source_map.js.map packages/renderer/MdToHtml/setupLinkify.d.ts packages/renderer/MdToHtml/setupLinkify.js packages/renderer/MdToHtml/setupLinkify.js.map diff --git a/packages/app-cli/tests/MdToHtml.ts b/packages/app-cli/tests/MdToHtml.ts index 3736e223f..408320108 100644 --- a/packages/app-cli/tests/MdToHtml.ts +++ b/packages/app-cli/tests/MdToHtml.ts @@ -234,4 +234,18 @@ describe('MdToHtml', function() { } })); + it('should return attributes of line numbers', (async () => { + const mdToHtml = newTestMdToHtml(); + + // Mapping information between source lines and html elements is + // annotated. + { + const input = '# Head\nFruits\n- Apple\n'; + const result = await mdToHtml.render(input, null, { bodyOnly: true, mapsToLine: true }); + expect(result.html.trim()).toBe('

Head

\n' + + '

Fruits

\n' + + '' + ); + } + })); }); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx index a8f0e2d71..830ad837d 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx @@ -6,7 +6,8 @@ import { EditorCommand, NoteBodyEditorProps } from '../../utils/types'; import { commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling'; import { ScrollOptions, ScrollOptionTypes } from '../../utils/types'; import { CommandValue } from '../../utils/types'; -import { useScrollHandler, usePrevious, cursorPositionToTextOffset } from './utils'; +import { usePrevious, cursorPositionToTextOffset } from './utils'; +import useScrollHandler, { translateScrollPercentToEditor, translateScrollPercentToViewer } from './utils/useScrollHandler'; import useElementSize from '@joplin/lib/hooks/useElementSize'; import Toolbar from './Toolbar'; import styles_ from './styles'; @@ -114,9 +115,10 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { if (!webviewRef.current) return; webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string); } else if (options.type === ScrollOptionTypes.Percent) { - const p = options.value as number; - setEditorPercentScroll(p); - setViewerPercentScroll(p); + const editorPercent = options.value as number; + setEditorPercentScroll(editorPercent); + const viewerPercent = translateScrollPercentToViewer(editorRef, webviewRef, editorPercent); + setViewerPercentScroll(viewerPercent); } else { throw new Error(`Unsupported scroll options: ${options.type}`); } @@ -579,7 +581,17 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { editorRef.current.updateBody(newBody); } } else if (msg === 'percentScroll') { - setEditorPercentScroll(arg0); + const viewerPercent = arg0; + const editorPercent = translateScrollPercentToEditor(editorRef, webviewRef, viewerPercent); + setEditorPercentScroll(editorPercent); + } else if (msg === 'syncViewerScrollWithEditor') { + const force = !!arg0; + webviewRef.current?.wrappedInstance?.refreshSyncScrollMap(force); + const editorPercent = Math.max(0, Math.min(1, editorRef.current?.getScrollPercent())); + if (!isNaN(editorPercent)) { + const viewerPercent = translateScrollPercentToViewer(editorRef, webviewRef, editorPercent); + setViewerPercentScroll(viewerPercent); + } } else { props.onMessage(event); } @@ -604,6 +616,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ resourceInfos: props.resourceInfos, contentMaxWidth: props.contentMaxWidth, + mapsToLine: true, })); if (cancelled) return; @@ -639,6 +652,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { // Since we can't do much about it we just print an error. if (webviewRef.current && webviewRef.current.wrappedInstance) { webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options); + webviewRef.current.wrappedInstance.refreshSyncScrollMap(true); } else { console.error('Trying to set HTML on an undefined webview ref'); } diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.ts index 8767410f5..8ac43a4fb 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.ts @@ -1,5 +1,4 @@ -import { useEffect, useCallback, useRef } from 'react'; -import shim from '@joplin/lib/shim'; +import { useEffect, useRef } from 'react'; export function cursorPositionToTextOffset(cursorPos: any, body: string) { if (!body) return 0; @@ -28,64 +27,3 @@ export function usePrevious(value: any): any { }); return ref.current; } - -export function useScrollHandler(editorRef: any, webviewRef: any, onScroll: Function) { - const ignoreNextEditorScrollEvent_ = useRef(false); - const scrollTimeoutId_ = useRef(null); - - const scheduleOnScroll = useCallback((event: any) => { - if (scrollTimeoutId_.current) { - shim.clearTimeout(scrollTimeoutId_.current); - scrollTimeoutId_.current = null; - } - - scrollTimeoutId_.current = shim.setTimeout(() => { - scrollTimeoutId_.current = null; - onScroll(event); - }, 10); - }, [onScroll]); - - const setEditorPercentScroll = useCallback((p: number) => { - ignoreNextEditorScrollEvent_.current = true; - - if (editorRef.current) { - editorRef.current.setScrollPercent(p); - - scheduleOnScroll({ percent: p }); - } - }, [scheduleOnScroll]); - - const setViewerPercentScroll = useCallback((p: number) => { - if (webviewRef.current) { - webviewRef.current.wrappedInstance.send('setPercentScroll', p); - scheduleOnScroll({ percent: p }); - } - }, [scheduleOnScroll]); - - const editor_scroll = useCallback(() => { - if (ignoreNextEditorScrollEvent_.current) { - ignoreNextEditorScrollEvent_.current = false; - return; - } - - if (editorRef.current) { - const percent = editorRef.current.getScrollPercent(); - if (!isNaN(percent)) { - // when switching to another note, the percent can sometimes be NaN - // this is coming from `gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.ts` - // when CodeMirror returns scroll info with heigth == clientHeigth - // https://github.com/laurent22/joplin/issues/4797 - setViewerPercentScroll(percent); - } - } - }, [setViewerPercentScroll]); - - const resetScroll = useCallback(() => { - if (editorRef.current) { - editorRef.current.setScrollPercent(0); - } - }, []); - - return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll }; -} - diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.ts new file mode 100644 index 000000000..228f57087 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.ts @@ -0,0 +1,125 @@ +import { useCallback, useRef } from 'react'; +import shim from '@joplin/lib/shim'; +import { SyncScrollMap } from '../../../../utils/SyncScrollMap'; + +export default function useScrollHandler(editorRef: any, webviewRef: any, onScroll: Function) { + const ignoreNextEditorScrollEvent_ = useRef(false); + const scrollTimeoutId_ = useRef(null); + + const scheduleOnScroll = useCallback((event: any) => { + if (scrollTimeoutId_.current) { + shim.clearTimeout(scrollTimeoutId_.current); + scrollTimeoutId_.current = null; + } + + scrollTimeoutId_.current = shim.setTimeout(() => { + scrollTimeoutId_.current = null; + onScroll(event); + }, 10); + }, [onScroll]); + + const setEditorPercentScroll = useCallback((p: number) => { + ignoreNextEditorScrollEvent_.current = true; + + if (editorRef.current) { + editorRef.current.setScrollPercent(p); + + scheduleOnScroll({ percent: p }); + } + }, [scheduleOnScroll]); + + const setViewerPercentScroll = useCallback((p: number) => { + if (webviewRef.current) { + webviewRef.current.wrappedInstance.send('setPercentScroll', p); + scheduleOnScroll({ percent: p }); + } + }, [scheduleOnScroll]); + + const editor_scroll = useCallback(() => { + if (ignoreNextEditorScrollEvent_.current) { + ignoreNextEditorScrollEvent_.current = false; + return; + } + + if (editorRef.current) { + const editorPercent = Math.max(0, Math.min(1, editorRef.current.getScrollPercent())); + if (!isNaN(editorPercent)) { + // when switching to another note, the percent can sometimes be NaN + // this is coming from `gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.ts` + // when CodeMirror returns scroll info with heigth == clientHeigth + // https://github.com/laurent22/joplin/issues/4797 + const viewerPercent = translateScrollPercentToViewer(editorRef, webviewRef, editorPercent); + setViewerPercentScroll(viewerPercent); + } + } + }, [setViewerPercentScroll]); + + const resetScroll = useCallback(() => { + if (editorRef.current) { + editorRef.current.setScrollPercent(0); + } + }, []); + + return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll }; +} + +const translateScrollPercent_ = (editorRef: any, webviewRef: any, percent: number, editorToViewer: boolean) => { + // If the input is out of (0,1) or not number, it is not translated. + if (!(0 < percent && percent < 1)) return percent; + const map: SyncScrollMap = webviewRef.current?.wrappedInstance.getSyncScrollMap(); + const cm = editorRef.current; + if (!map || map.line.length <= 2 || !cm) return percent; // No translation + const lineCount = cm.lineCount(); + if (map.line[map.line.length - 2] >= lineCount) { + // Discarded a obsolete map and use no translation. + webviewRef.current.wrappedInstance.refreshSyncScrollMap(false); + return percent; + } + const info = cm.getScrollInfo(); + const height = Math.max(1, info.height - info.clientHeight); + let values = map.percent, target = percent; + if (editorToViewer) { + const top = percent * height; + const line = cm.lineAtHeight(top, 'local'); + values = map.line; + target = line; + } + // Binary search (rightmost): finds where map[r-1][field] <= target < map[r][field] + let l = 1, r = values.length - 1; + while (l < r) { + const m = Math.floor(l + (r - l) / 2); + if (target < values[m]) r = m; else l = m + 1; + } + const lineU = map.line[r - 1]; + const lineL = Math.min(lineCount, map.line[r]); + const ePercentU = r == 1 ? 0 : Math.min(1, cm.heightAtLine(lineU, 'local') / height); + const ePercentL = Math.min(1, cm.heightAtLine(lineL, 'local') / height); + const vPercentU = map.percent[r - 1]; + const vPercentL = ePercentL == 1 ? 1 : map.percent[r]; + let result; + if (editorToViewer) { + const linInterp = (percent - ePercentU) / (ePercentL - ePercentU); + result = vPercentU + (vPercentL - vPercentU) * linInterp; + } else { + const linInterp = (percent - vPercentU) / (vPercentL - vPercentU); + result = ePercentU + (ePercentL - ePercentU) * linInterp; + } + return Math.max(0, Math.min(1, result)); +}; + +// translateScrollPercentToEditor() and translateScrollPercentToViewer() are +// the translation functions between Editor's scroll percent and Viewer's scroll +// percent. They are used for synchronous scrolling between Editor and Viewer. +// They use a SyncScrollMap provided by Viewer for its translation. +// To see the detail of synchronous scrolling, refer the following design document. +// https://github.com/laurent22/joplin/pull/5512#issuecomment-931277022 + +export const translateScrollPercentToEditor = (editorRef: any, webviewRef: any, viewerPercent: number) => { + const editorPercent = translateScrollPercent_(editorRef, webviewRef, viewerPercent, false); + return editorPercent; +}; + +export const translateScrollPercentToViewer = (editorRef: any, webviewRef: any, editorPercent: number) => { + const viewerPercent = translateScrollPercent_(editorRef, webviewRef, editorPercent, true); + return viewerPercent; +}; diff --git a/packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts b/packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts index 8fb24995d..8703442de 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts @@ -19,6 +19,7 @@ export interface MarkupToHtmlOptions { contentMaxWidth?: number; plugins?: Record; bodyOnly?: boolean; + mapsToLine?: boolean; } export default function useMarkupToHtml(deps: HookDependencies) { diff --git a/packages/app-desktop/gui/NoteTextViewer.tsx b/packages/app-desktop/gui/NoteTextViewer.tsx index d211fa724..9f1212e10 100644 --- a/packages/app-desktop/gui/NoteTextViewer.tsx +++ b/packages/app-desktop/gui/NoteTextViewer.tsx @@ -2,6 +2,7 @@ import PostMessageService, { MessageResponse, ResponderComponentType } from '@jo import * as React from 'react'; const { connect } = require('react-redux'); import { reg } from '@joplin/lib/registry'; +import { SyncScrollMap, SyncScrollMapper } from './utils/SyncScrollMap'; interface Props { onDomReady: Function; @@ -167,6 +168,17 @@ class NoteTextViewerComponent extends React.Component { } } + private syncScrollMapper_ = new SyncScrollMapper; + + refreshSyncScrollMap(forced: boolean) { + return this.syncScrollMapper_.refresh(forced); + } + + getSyncScrollMap(): SyncScrollMap { + const doc = this.webviewRef_.current?.contentWindow?.document; + return this.syncScrollMapper_.get(doc); + } + // ---------------------------------------------------------------- // Wrap WebView functions (END) // ---------------------------------------------------------------- diff --git a/packages/app-desktop/gui/note-viewer/index.html b/packages/app-desktop/gui/note-viewer/index.html index 14a237c42..427f24a91 100644 --- a/packages/app-desktop/gui/note-viewer/index.html +++ b/packages/app-desktop/gui/note-viewer/index.html @@ -102,14 +102,7 @@ // images are being displayed then restored while images are being reloaded, the new scrollTop might be changed // so that it is not greater than contentHeight. On the other hand, with percentScroll it is possible to restore // it at any time knowing that it's not going to be changed because the content height has changed. - // To restore percentScroll the "checkScrollIID" interval is used. It constantly resets the scroll position during - // one second after the content has been updated. - // - // ignoreNextScroll is used to differentiate between scroll event from the users and those that are the result - // of programmatically changing scrollTop. We only want to respond to events initiated by the user. - let percentScroll_ = 0; - let checkScrollIID_ = null; // This variable provides a way to skip scroll events for a certain duration. // In general, it should be set whenever the scroll value is set explicitely (programmatically) @@ -195,7 +188,66 @@ return true; } - let checkAllImageLoadedIID_ = null; + let alreadyAllImagesLoaded = false; + + // During a note is being rendered, its height is varying. To keep scroll + // consistency, observing the height of the content element and updating its + // scroll position is required. For the purpose, 'ResizeObserver' is used. + // ResizeObserver is standard and an element's counterpart to 'window.resize' + // event. It's overhead is cheaper than observation using an interval timer. + // + // To observe the scroll height of the content element, adding, removing and + // resizing of its children should be observed. So, the combination of + // ResizeObserver (used for resizing) and MutationObserver (used for ading + // and removing) is used. + // + // References: + // https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver + // https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver + // + // By using them, this observeRendering() function provides a efficient way + // to observe the changes of the scroll height of the content element + // using a callback approach. + function observeRendering(callback, compress = false) { + let previousHeight = 0; + const fn = (cause) => { + const height = contentElement.scrollHeight; + const heightChanged = height != previousHeight; + if (!compress || heightChanged) { + previousHeight = height; + callback(cause, height, heightChanged); + } + }; + // 'resized' means DOM Layout change or Window resize event + let resizeObserver = new ResizeObserver(() => fn('resized')); + // An HTML document to be rendered is added and removed as a child of + // the content element for each setHtml() invocation. + let mutationObserver = new MutationObserver(entries => { + const e = entries[0]; + e.removedNodes.forEach(n => n instanceof Element && resizeObserver.unobserve(n)); + e.addedNodes.forEach(n => n instanceof Element && resizeObserver.observe(n)); + if (e.removedNodes.length + e.addedNodes.length) fn('dom-changed'); + }); + mutationObserver.observe(contentElement, { childList: true }); + return { mutationObserver, resizeObserver }; + }; + + // A callback anonymous function invoked when the scroll height changes. + const onRendering = observeRendering((cause, height, heightChanged) => { + if (!alreadyAllImagesLoaded) { + const loaded = allImagesLoaded(); + if (loaded) { + alreadyAllImagesLoaded = true; + ipcProxySendToHost('syncViewerScrollWithEditor', true); + ipcProxySendToHost('noteRenderComplete'); + return; + } + } + if (heightChanged) { + // When the scroll height changes, sync is needed. + ipcProxySendToHost('syncViewerScrollWithEditor'); + } + }); ipc.focus = (event) => { const dummyID = 'joplin-content-focus-dummy'; @@ -217,23 +269,10 @@ contentElement.innerHTML = html; - let previousContentHeight = contentElement.scrollHeight; - let startTime = Date.now(); - restorePercentScroll(); + restorePercentScroll(); // First, a quick treatment is applied. + ipcProxySendToHost('syncViewerScrollWithEditor'); - if (!checkScrollIID_) { - checkScrollIID_ = setInterval(() => { - const h = contentElement.scrollHeight; - if (h !== previousContentHeight) { - previousContentHeight = h; - restorePercentScroll(); - } - if (Date.now() - startTime >= 1000) { - clearInterval(checkScrollIID_); - checkScrollIID_ = null; - } - }, 1); - } + alreadyAllImagesLoaded = false; addPluginAssets(event.options.pluginAssets); @@ -242,25 +281,10 @@ } document.dispatchEvent(new Event('joplin-noteDidUpdate')); - - if (checkAllImageLoadedIID_) clearInterval(checkAllImageLoadedIID_); - - checkAllImageLoadedIID_ = setInterval(() => { - if (!allImagesLoaded()) return; - - clearInterval(checkAllImageLoadedIID_); - ipcProxySendToHost('noteRenderComplete'); - }, 100); } ipc.setPercentScroll = (event) => { const percent = event.percent; - - if (checkScrollIID_) { - clearInterval(checkScrollIID_); - checkScrollIID_ = null; - } - lastScrollEventTime = Date.now(); setPercentScroll(percent); } @@ -365,7 +389,9 @@ function currentPercentScroll() { const m = maxScrollTop(); - return m ? contentElement.scrollTop / m : 0; + // As of 2021, if zoomFactor != 1, underlying Chrome returns scrollTop with + // some numerical error. It can be more than maxScrollTop(). + return m ? Math.min(1, contentElement.scrollTop / m) : 0; } contentElement.addEventListener('wheel', webviewLib.logEnabledEventHandler(e => { diff --git a/packages/app-desktop/gui/utils/SyncScrollMap.ts b/packages/app-desktop/gui/utils/SyncScrollMap.ts new file mode 100644 index 000000000..61d1d14d4 --- /dev/null +++ b/packages/app-desktop/gui/utils/SyncScrollMap.ts @@ -0,0 +1,92 @@ +import shim from '@joplin/lib/shim'; + +// SyncScrollMap is used for synchronous scrolling between Markdown Editor and Viewer. +// It has the mapping information between the line numbers of a Markdown text and +// the scroll positions (percents) of the elements in the HTML document transformed +// from the Markdown text. +// To see the detail of synchronous scrolling, refer the following design document. +// https://github.com/laurent22/joplin/pull/5512#issuecomment-931277022 + +export interface SyncScrollMap { + line: number[]; + percent: number[]; + viewHeight: number; +} + +// Map creation utility class +export class SyncScrollMapper { + private map_: SyncScrollMap = null; + private refreshTimeoutId_: any = null; + private refreshTime_ = 0; + + // Invalidates an outdated SyncScrollMap. + // For a performance reason, too frequent refresh requests are + // skippend and delayed. If forced is true, refreshing is immediately performed. + public refresh(forced: boolean) { + const elapsed = this.refreshTime_ ? Date.now() - this.refreshTime_ : 10 * 1000; + if (!forced && (elapsed < 200 || this.refreshTimeoutId_)) { + // to avoid too frequent recreations of a sync-scroll map. + if (this.refreshTimeoutId_) { + shim.clearTimeout(this.refreshTimeoutId_); + this.refreshTimeoutId_ = null; + } + this.refreshTimeoutId_ = shim.setTimeout(() => { + this.refreshTimeoutId_ = null; + this.map_ = null; + this.refreshTime_ = Date.now(); + }, 200); + } else { + this.map_ = null; + this.refreshTime_ = Date.now(); + } + } + + // Creates a new SyncScrollMap or reuses an existing one. + public get(doc: Document): SyncScrollMap { + // Returns a cached translation map between editor's scroll percenet + // and viewer's scroll percent. Both attributes (line and percent) of + // the returned map are sorted respectively. + // Since creating this map is costly for each scroll event, it is cached. + // When some update events which outdate it such as switching a note or + // editing a note, it has to be invalidated (using refresh()), + // and a new map will be created at a next scroll event. + if (!doc) return null; + const contentElement = doc.getElementById('joplin-container-content'); + if (!contentElement) return null; + const height = Math.max(1, contentElement.scrollHeight - contentElement.clientHeight); + if (this.map_) { + // check whether map_ is obsolete + if (this.map_.viewHeight === height) return this.map_; + this.map_ = null; + } + // Since getBoundingClientRect() returns a relative position, + // the offset of the origin is needed to get its aboslute position. + const offset = doc.getElementById('rendered-md').getBoundingClientRect().top; + if (!offset) return null; + // Mapping information between editor's lines and viewer's elements is + // embedded into elements by the renderer. + // See also renderer/MdToHtml/rules/source_map.ts. + const elems = doc.getElementsByClassName('maps-to-line'); + const map: SyncScrollMap = { line: [0], percent: [0], viewHeight: height }; + // Each map entry is total-ordered. + let last = 0; + for (let i = 0; i < elems.length; i++) { + const top = elems[i].getBoundingClientRect().top - offset; + const line = Number(elems[i].getAttribute('source-line')); + const percent = Math.max(0, Math.min(1, top / height)); + if (map.line[last] < line && map.percent[last] < percent) { + map.line.push(line); + map.percent.push(percent); + last += 1; + } + } + if (map.percent[last] < 1) { + map.line.push(1e10); + map.percent.push(1); + } else { + map.line[last] = 1e10; + } + this.map_ = map; + return map; + } +} diff --git a/packages/renderer/MdToHtml.ts b/packages/renderer/MdToHtml.ts index db86baf20..dc5db3fcc 100644 --- a/packages/renderer/MdToHtml.ts +++ b/packages/renderer/MdToHtml.ts @@ -25,6 +25,7 @@ export interface RenderOptions { pdfViewerEnabled?: boolean; codeHighlightCacheKey?: string; plainResourceRendering?: boolean; + mapsToLine?: boolean; } interface RendererRule { @@ -62,6 +63,7 @@ const rules: RendererRules = { code_inline: require('./MdToHtml/rules/code_inline').default, fountain: require('./MdToHtml/rules/fountain').default, mermaid: require('./MdToHtml/rules/mermaid').default, + source_map: require('./MdToHtml/rules/source_map').default, }; const hljs = require('highlight.js'); diff --git a/packages/renderer/MdToHtml/rules/source_map.ts b/packages/renderer/MdToHtml/rules/source_map.ts new file mode 100644 index 000000000..32c9e5f83 --- /dev/null +++ b/packages/renderer/MdToHtml/rules/source_map.ts @@ -0,0 +1,36 @@ +export default { + plugin: (markdownIt: any, params: any) => { + + if (!params.mapsToLine) return; + + const allowedLevels = { + paragraph_open: 0, + heading_open: 0, + // fence: 0, // fence uses custom rendering that doesn't propogate attr so it can't be used for now + blockquote_open: 0, + table_open: 0, + code_block: 0, + hr: 0, + html_block: 0, + list_item_open: 99, // this will stop matching if a list goes more than 99 indents deep + math_block: 0, + }; + + for (const [key, allowedLevel] of Object.entries(allowedLevels)) { + const precedentRule = markdownIt.renderer.rules[key]; + + markdownIt.renderer.rules[key] = (tokens: any[], idx: number, options: any, env: any, self: any) => { + if (!!tokens[idx].map && tokens[idx].level <= allowedLevel) { + const line = tokens[idx].map[0]; + tokens[idx].attrJoin('class', 'maps-to-line'); + tokens[idx].attrSet('source-line', `${line}`); + } + if (precedentRule) { + return precedentRule(tokens, idx, options, env, self); + } else { + return self.renderToken(tokens, idx, options); + } + }; + } + }, +};