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);
+ }
+ };
+ }
+ },
+};