106 changes: 66 additions & 40 deletions packages/app-desktop/gui/note-viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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');
}
});
laurent22 marked this conversation as resolved.
Show resolved Hide resolved

ipc.setHtml = (event) => {
const html = event.html;
Expand All @@ -206,23 +258,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);

Expand All @@ -231,25 +270,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);
}
Expand Down Expand Up @@ -354,7 +378,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('scroll', webviewLib.logEnabledEventHandler(e => {
Expand Down
92 changes: 92 additions & 0 deletions packages/app-desktop/gui/utils/SyncScrollMap.ts
Original file line number Diff line number Diff line change
@@ -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 {
laurent22 marked this conversation as resolved.
Show resolved Hide resolved
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');
laurent22 marked this conversation as resolved.
Show resolved Hide resolved
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;
laurent22 marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}
2 changes: 2 additions & 0 deletions packages/renderer/MdToHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface RenderOptions {
pdfViewerEnabled?: boolean;
codeHighlightCacheKey?: string;
plainResourceRendering?: boolean;
mapsToLine?: boolean;
}

interface RendererRule {
Expand Down Expand Up @@ -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');
Expand Down
36 changes: 36 additions & 0 deletions packages/renderer/MdToHtml/rules/source_map.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
},
};