joplin/packages/app-desktop/gui/note-viewer/index.html

691 lines
24 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

<!DOCTYPE html>
<html>
<head id="joplin-container-root-head">
<meta charset="UTF-8">
<style>
body {
overflow: hidden;
}
#joplin-container-content {
/* Needs this in case the content contains elements with absolute positioning */
/* Without this they would just stay at a fixed position when scrolling */
position: relative;
overflow-y: auto;
padding-left: 10px;
padding-right: 10px;
/* Note: the height is set via updateBodyHeight(). Setting it here to 100% */
/* won't work with some pages due to the position: relative */
}
#rendered-md {
/* This is used to enable the scroll-past end behaviour. The same height should */
/* be applied to the editor. */
padding-bottom: 400px;
}
.mark-selected {
background: #CF3F00;
color: white;
}
ul ul, ul ol, ol ul, ol ol {
margin-bottom: 0px;
}
</style>
</head>
<body id="joplin-container-body">
<div id="joplin-container-pluginAssetsContainer"></div>
<div id="joplin-container-markScriptContainer"></div>
<div id="joplin-container-content" ondragstart="return false;" ondrop="return false;"></div>
<script src="./lib.js"></script>
<script src="./scrollmap.js"></script>
<script>
// This is function used internally to send message from the webview to
// the host.
const ipcProxySendToHost = (methodName, arg) => {
window.postMessage({ target: 'main', name: methodName, args: [ arg ] }, '*');
}
const webviewApiPromises_ = {};
// This function is reserved for plugin, currently only to allow
// executing a command, but more features could be added to the object
// later on.
const webviewApi = {
postMessage: function(contentScriptId, message) {
const messageId = 'noteViewer_' + Date.now() + Math.random();
const promise = new Promise((resolve, reject) => {
webviewApiPromises_[messageId] = { resolve, reject };
});
ipcProxySendToHost('postMessageService.message', {
contentScriptId: contentScriptId,
viewId: '',
from: 'contentScript',
to: 'plugin',
id: messageId,
content: message,
});
return promise;
},
}
let pluginAssetsAdded_ = {};
try {
const contentElement = document.getElementById('joplin-container-content');
const ipc = {};
window.addEventListener('message', webviewLib.logEnabledEventHandler(event => {
// Here we only deal with messages that are sent from the main Electron process to the webview.
if (!event.data || event.data.target !== 'webview') return;
const callName = event.data.name;
const callData = event.data.data;
if (!ipc[callName]) {
console.warn('Missing IPC function:', event.data);
} else {
ipc[callName](callData);
}
}));
// Note: the scroll position source of truth is "percentScroll_". This is easier to manage than scrollTop because
// the scrollTop value depends on the images being loaded or not. For example, if the scrollTop is saved while
// 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.
let percentScroll_ = 0;
let ignoreNextScrollTime_ = Date.now();
let ignoreNextScrollEventCount_ = 0;
// ignoreNextScrollEvent() provides a way to skip scroll events for a certain duration.
// In general, it should be called whenever the scroll value is set explicitely (programmatically)
// so as to differentiate scroll events generated by the user (when scrolling the view) and those
// generated by the application.
function ignoreNextScrollEvent() {
const now = Date.now();
if (now >= ignoreNextScrollTime_) ignoreNextScrollEventCount_ = 0;
if (ignoreNextScrollEventCount_ < 10) { // for safety
ignoreNextScrollTime_ = now + 1000;
ignoreNextScrollEventCount_ += 1;
}
};
// Tests the next scroll event should be ignored and then decrements the count.
function isNextScrollEventIgnored() {
if (ignoreNextScrollEventCount_) {
if (Date.now() < ignoreNextScrollTime_) {
ignoreNextScrollEventCount_ -= 1;
return true;
}
ignoreNextScrollEventCount_ = 0;
}
return false;
}
function setPercentScroll(percent) {
// calculates viewer's GUI-dependent pixel-based raw percent
const viewerPercent = scrollmap.translateL2V(percent);
const newScrollTop = viewerPercent * maxScrollTop();
if (Math.floor(contentElement.scrollTop) !== Math.floor(newScrollTop)) {
ignoreNextScrollEvent();
percentScroll_ = percent;
contentElement.scrollTop = newScrollTop;
}
}
function restorePercentScroll() {
setPercentScroll(percentScroll_);
}
// Note that this function keeps track of what's been added so as not to add the same CSS files multiple times
// It also means that once an asset has been added it is never removed from the view, which in many case is
// desirable, but still something to keep in mind.
function addPluginAssets(assets) {
if (!assets) return;
const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer');
for (let i = 0; i < assets.length; i++) {
const asset = assets[i];
const assetId = asset.name ? asset.name : asset.path;
if (pluginAssetsAdded_[assetId]) continue;
pluginAssetsAdded_[assetId] = true;
if (asset.mime === 'application/javascript') {
const script = document.createElement('script');
script.src = asset.path;
pluginAssetsContainer.appendChild(script);
} else if (asset.mime === 'text/css') {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = asset.path;
pluginAssetsContainer.appendChild(link);
}
}
}
ipc.scrollToHash = (event) => {
let retry = 0;
const fn = () => {
if (window.scrollToHashTimeoutID_) {
clearInterval(window.scrollToHashTimeoutID_);
window.scrollToHashTimeoutID_ = null;
}
if (document.readyState === 'complete' ||
// If scrollmap is present, Element.scrollIntoView() is also
// available when document.readyState is interactive.
document.readyState === 'interactive' && scrollmap.isPresent()) {
const hash = event.hash.toLowerCase();
const e = document.getElementById(hash);
if (e) {
e.scrollIntoView();
// It causes a scroll event, whose listener sent a new scroll
// position to Editor.
} else {
console.warn('Cannot find hash', hash);
}
} else {
retry += 1;
if (retry <= 10) {
window.scrollToHashTimeoutID_ = setTimeout(fn, 100);
}
}
};
fn();
}
function isVisible() {
// See the logic of hiding viewer in CoderMirror.tsx
return window.innerWidth > 1;
}
// https://stackoverflow.com/a/1977898/561309
function isImageReady(img) {
if (!img.complete) return false;
if (!img.naturalWidth || !img.naturalHeight) return false;
return true;
}
function allImagesLoaded() {
if (!isVisible()) return true; // In the case, images would not be loaded.
for (const image of document.images) {
if (!isImageReady(image)) return false;
}
return true;
}
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 lastScrollHeight = 0;
let lastClientHeight = 0;
const fn = (cause) => {
const sh = contentElement.scrollHeight;
const ch = contentElement.clientHeight;
const heightChanged = (sh !== lastScrollHeight || ch !== lastClientHeight);
if (!compress || heightChanged) {
lastScrollHeight = sh;
lastClientHeight = ch;
callback(cause, sh, 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 };
};
// To suppress too frequent restoring of scroll positions and refreshing of the scroll map
let restoreAndRefreshTimeoutID_ = null;
let restoreAndRefreshTimeout_ = Date.now();
// If 'noteRenderComplete' message is ongoing, resizing should not trigger a 'percentScroll' messsage.
let noteRenderCompleteMessageIsOngoing_ = false;
// A callback anonymous function invoked when the scroll height changes.
const onRendering = observeRendering((cause, height, heightChanged) => {
if (!alreadyAllImagesLoaded && !scrollmap.isPresent()) {
const loaded = allImagesLoaded();
if (loaded) {
alreadyAllImagesLoaded = true;
scrollmap.refresh();
restorePercentScroll();
noteRenderCompleteMessageIsOngoing_ = true;
ipcProxySendToHost('noteRenderComplete');
return;
}
}
if (!heightChanged && cause !== 'dom-changed') return;
const restoreAndRefresh = () => {
scrollmap.refresh();
restorePercentScroll();
// To ensures Editor's scroll position is synced with Viewer's
if (!noteRenderCompleteMessageIsOngoing_) ipcProxySendToHost('percentScroll', percentScroll_);
};
const now = Date.now();
if (now < restoreAndRefreshTimeout_) {
if (restoreAndRefreshTimeoutID_) {
clearTimeout(restoreAndRefreshTimeoutID_);
restoreAndRefreshTimeoutID_ = null;
}
const msec = Math.min(1000, restoreAndRefreshTimeout_ - now);
restoreAndRefreshTimeoutID_ = setTimeout(restoreAndRefresh, msec);
} else {
restoreAndRefresh();
}
restoreAndRefreshTimeout_ = now + 200;
});
ipc.focus = (event) => {
const dummyID = 'joplin-content-focus-dummy';
if (! document.getElementById(dummyID)) {
const focusDummy = '<div style="width: 0; height: 0; overflow: hidden"><a id="' + dummyID + '" href="#">focus dummy</a></div>';
contentElement.insertAdjacentHTML("afterbegin", focusDummy);
}
const scrollTop = contentElement.scrollTop;
document.getElementById(dummyID).focus();
contentElement.scrollTop = scrollTop;
}
ipc.setHtml = (event) => {
const html = event.html;
markJsHackMarkerInserted_ = false;
updateBodyHeight();
alreadyAllImagesLoaded = false;
contentElement.innerHTML = html;
scrollmap.create(event.options.markupLineCount);
if (typeof event.options.percent !== 'number') {
restorePercentScroll(); // First, a quick treatment is applied.
} else {
setPercentScroll(event.options.percent);
}
addPluginAssets(event.options.pluginAssets);
if (event.options.downloadResources === 'manual') {
webviewLib.setupResourceManualDownload();
}
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
if (scrollmap.isPresent()) {
// Now, ready to receive scrollToHash/setPercentScroll from Editor.
noteRenderCompleteMessageIsOngoing_ = true;
ipcProxySendToHost('noteRenderComplete');
}
}
ipc.setPercentScroll = (event) => {
noteRenderCompleteMessageIsOngoing_ = false;
setPercentScroll(event.percent);
}
// HACK for Mark.js bug - https://github.com/julmot/mark.js/issues/127
let markJsHackMarkerInserted_ = false;
function addMarkJsSpaceHack(document) {
if (markJsHackMarkerInserted_) return;
const prepareElementsForMarkJs = (elements, type) => {
// const markJsHackMarker_ = '&#8203; &#8203;'
const markJsHackMarker_ = ' ';
for (let i = 0; i < elements.length; i++) {
if (!type) {
elements[i].insertAdjacentHTML('beforeend', markJsHackMarker_);
} else if (type === 'insertBefore') {
elements[i].insertAdjacentHTML('beforeBegin', markJsHackMarker_);
}
}
}
prepareElementsForMarkJs(contentElement.getElementsByTagName('p'));
prepareElementsForMarkJs(contentElement.getElementsByTagName('div'));
prepareElementsForMarkJs(contentElement.getElementsByTagName('br'), 'insertBefore');
markJsHackMarkerInserted_ = true;
}
let mark_ = null;
let markSelectedElement_ = null;
function setMarkers(keywords, options = null) {
if (!options) options = {};
// TODO: Add support for scriptType on mobile and CLI
if (!mark_) {
mark_ = new Mark(document.getElementById('joplin-container-content'), {
exclude: ['img'],
acrossElements: true,
});
}
addMarkJsSpaceHack(document);
mark_.unmark()
if (markSelectedElement_) markSelectedElement_.classList.remove('mark-selected');
let selectedElement = null;
let elementIndex = 0;
const markKeywordOptions = {};
if ('separateWordSearch' in options) markKeywordOptions.separateWordSearch = options.separateWordSearch;
for (let i = 0; i < keywords.length; i++) {
let keyword = keywords[i];
markJsUtils.markKeyword(mark_, keyword, {
pregQuote: pregQuote,
replaceRegexDiacritics: replaceRegexDiacritics,
}, markKeywordOptions);
}
}
let markLoader_ = { state: 'idle', whenDone: null };
ipc.setMarkers = (event) => {
const keywords = event.keywords;
const options = event.options;
if (!keywords.length && markLoader_.state === 'idle') return;
if (markLoader_.state === 'idle') {
markLoader_ = {
state: 'loading',
whenDone: {keywords:keywords, options:options},
};
const script = document.createElement('script');
script.onload = function() {
markLoader_.state = 'ready';
setMarkers(markLoader_.whenDone.keywords, markLoader_.whenDone.options);
};
script.src = '../../vendor/lib/mark.js/dist/mark.min.js';
document.getElementById('joplin-container-markScriptContainer').appendChild(script);
} else if (markLoader_.state === 'ready') {
setMarkers(keywords, options);
} else if (markLoader_.state === 'loading') {
markLoader_.whenDone = {keywords:keywords, options:options};
}
}
function maxScrollTop() {
return Math.max(0, contentElement.scrollHeight - contentElement.clientHeight);
}
function maxScrollLeft() {
return Math.max(0, contentElement.scrollWidth - contentElement.clientWidth);
}
// The body element needs to have a fixed height for the content to be scrollable
function updateBodyHeight() {
document.getElementById('joplin-container-body').style.height = window.innerHeight + 'px';
document.getElementById('joplin-container-content').style.height = window.innerHeight + 'px';
}
function getPercentFromViewer() {
const m = maxScrollTop();
// As of 2021, if zoomFactor != 1, underlying Chrome returns scrollTop with
// some numerical error. It can be more than maxScrollTop().
const viewerPecent = m ? Math.min(1, contentElement.scrollTop / m) : 0;
// calculates GUI-independent line-based logical percent
const percent = scrollmap.translateV2L(viewerPecent);
return percent;
}
// If zoom factor is not 1, Electron/Chromium calculates scrollTop incorrectly.
// This is automatically set.
let zoomFactorIsNotOne = false;
// When custom smooth scrolling is ongoing, remainedScrollDx/Dy keep the remaining
// amount of scrolling.
let remainedScrollDx = 0, remainedScrollDy = 0, remainedScrollTimerId = null;
function resetSmoothScroll() { remainedScrollDx = 0; remainedScrollDy = 0; }
// To avoid Electron/Chromium's scrolling bug when zoom fator is not 1,
// Custom scrolling is implemented. This is used only when zoom factor is not 1.
// If smoothly argument is true, smooth scrolling is performed.
// See https://github.com/laurent22/joplin/pull/5606#issuecomment-964293459
function customScroll(wheelEvent, smoothly) {
const linePixels = 100 / 3;
const pagePixelsX = Math.max(linePixels, contentElement.clientWidth);
const pagePixelsY = Math.max(linePixels, contentElement.clientHeight);
let pixelsPerUnitX = 1, pixelsPerUnitY = 1; // for WheelEvent.DOM_DELTA_PIXEL
if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_LINE) {
pixelsPerUnitX = pixelsPerUnitY = linePixels;
} else if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
pixelsPerUnitX = pagePixelsX;
pixelsPerUnitY = pagePixelsY;
}
if (!smoothly) {
if (wheelEvent.deltaX) {
const dx = wheelEvent.deltaX * pixelsPerUnitX;
contentElement.scrollLeft = Math.max(0, Math.min(maxScrollLeft(), contentElement.scrollLeft + dx));
}
if (wheelEvent.deltaY) {
const dy = wheelEvent.deltaY * pixelsPerUnitY;
contentElement.scrollTop = Math.max(0, Math.min(maxScrollTop(), contentElement.scrollTop + dy));
}
} else {
if (Math.sign(remainedScrollDx) !== Math.sign(wheelEvent.deltaX)) remainedScrollDx = 0;
if (Math.sign(remainedScrollDy) !== Math.sign(wheelEvent.deltaY)) remainedScrollDy = 0;
remainedScrollDx += wheelEvent.deltaX * pixelsPerUnitX;
remainedScrollDy += wheelEvent.deltaY * pixelsPerUnitY;
const maxDx = Math.max(8.5, Math.min(pagePixelsX, Math.abs(remainedScrollDx)) / 5);
const maxDy = Math.max(8.5, Math.min(pagePixelsY, Math.abs(remainedScrollDy)) / 5);
const f = () => {
if (remainedScrollTimerId) {
clearTimeout(remainedScrollTimerId);
remainedScrollTimerId = null;
}
if (remainedScrollDx) {
const dx = Math.max(-maxDx, Math.min(maxDx, remainedScrollDx));
remainedScrollDx -= dx;
contentElement.scrollLeft = Math.max(0, Math.min(maxScrollLeft(), contentElement.scrollLeft + dx));
}
if (remainedScrollDy) {
const dy = Math.max(-maxDy, Math.min(maxDy, remainedScrollDy));
remainedScrollDy -= dy;
contentElement.scrollTop = Math.max(0, Math.min(maxScrollTop(), contentElement.scrollTop + dy));
}
if (remainedScrollDx || remainedScrollDy) remainedScrollTimerId = setTimeout(f, 20);
};
f();
}
}
contentElement.addEventListener('wheel', webviewLib.logEnabledEventHandler(e => {
// When zoomFactor is not 1 (using an HD display is a typical case),
// DOM element's scrollTop is incorrectly calculated after wheel scroll events
// in the layer of Electron/Chromium, as of 2021-09-23.
// To avoid this problem, prevent the upstream from calculating scrollTop and
// calculate by yourself by accumulating wheel events.
// https://github.com/laurent22/joplin/pull/5496
// When the Electron/Chromium bug is fixed, remove this listener.
// If scrollTop ever has a fraction part, zoomFactor is not 1.
if (zoomFactorIsNotOne || !Number.isInteger(contentElement.scrollTop)) {
zoomFactorIsNotOne = true;
customScroll(e, true);
e.preventDefault();
}
}));
contentElement.addEventListener('scroll', webviewLib.logEnabledEventHandler(e => {
lastScrollTop_ = contentElement.scrollTop;
// If the last scroll event was done by the application, ignoreNextScrollEvent() is called and
// we can use that to skip the event handling. We skip it because in that case
// the scroll position has already been updated. Also we add a 200ms interval
// because otherwise it's most likely a glitch where we called ipc.setPercentScroll
// but the scroll event listener has not been called.
if (isNextScrollEventIgnored()) return;
percentScroll_ = getPercentFromViewer();
ipcProxySendToHost('percentScroll', percentScroll_);
}));
ipc['postMessageService.response'] = function(event) {
const promise = webviewApiPromises_[event.responseId];
if (!promise) {
console.warn('postMessageService.response: could not find callback for message', event);
return;
}
if (event.error) {
promise.reject(event.error);
} else {
promise.resolve(event.response);
}
}
window.addEventListener('hashchange', webviewLib.logEnabledEventHandler(e => {
if (!window.location.hash) return;
// The timeout is necessary to prevent a race condition and give time for the window to scroll
setTimeout(() => {
// Reset the window hash to allow clicking on the same anchor link more than once
window.location.hash = '';
}, 100);
}));
document.addEventListener('contextmenu', webviewLib.logEnabledEventHandler(event => {
// To handle right clicks on resource icons
let element = event.target;
// Mermaid svgs are wrapped inside a <pre> with class "mermaid"
let mermaidElement = element.closest(".mermaid")?.children[0];
if (mermaidElement) {
const svgString = new XMLSerializer().serializeToString(mermaidElement);
if (!!svgString) {
ipcProxySendToHost('contextMenu', {
type: 'image',
textToCopy: svgString,
mime: 'image/svg+xml',
filename: mermaidElement.id + '.svg',
});
}
return;
}
if (element && !element.getAttribute('data-resource-id')) element = element.parentElement;
if (element && element.getAttribute('data-resource-id')) {
ipcProxySendToHost('contextMenu', {
type: element.getAttribute('src') ? 'image' : 'resource',
resourceId: element.getAttribute('data-resource-id'),
});
} else {
const selectedText = window.getSelection().toString();
if (selectedText) {
const linkToCopy = event.target && event.target.getAttribute('href') ? event.target.getAttribute('href') : null;
ipcProxySendToHost('contextMenu', {
type: 'text',
textToCopy: selectedText,
linkToCopy: linkToCopy,
});
} else if (event.target.getAttribute('href')) {
ipcProxySendToHost('contextMenu', {
type: 'link',
textToCopy: event.target.getAttribute('href'),
});
}
}
}));
webviewLib.initialize({
postMessage: ipcProxySendToHost,
});
// Disable drag and drop otherwise it's possible to drop a URL
// on it and it will open in the view as a website.
document.addEventListener('drop', webviewLib.logEnabledEventHandler(e => {
e.preventDefault();
e.stopPropagation();
}));
document.addEventListener('dragover', webviewLib.logEnabledEventHandler(e => {
e.preventDefault();
e.stopPropagation();
}));
document.addEventListener('dragover', webviewLib.logEnabledEventHandler(e => {
e.preventDefault();
}));
let lastClientWidth_ = NaN, lastClientHeight_ = NaN, lastScrollTop_ = NaN;
window.addEventListener('resize', webviewLib.logEnabledEventHandler(() => {
updateBodyHeight();
// When zoomFactor is changed, resize event happens.
zoomFactorIsNotOne = false;
resetSmoothScroll();
// If this event resizes contentElement, ignore the scroll event caused by it.
const cw = contentElement.clientWidth;
const ch = contentElement.clientHeight;
const top = contentElement.scrollTop;
if (!(cw === lastClientWidth_ && ch === lastClientHeight_)) {
// Since scroll listeners are invoked before ResizeObserver and
// resize listeners are invoked before scroll listeners,
// this code should be here to ignore scroll events.
if (top !== lastScrollTop_) ignoreNextScrollEvent();
lastClientWidth_ = cw; lastClientHeight_ = ch; lastScrollTop_ = top;
}
}));
// Prevent middle-click as that would open the URL in an Electron window
// https://github.com/laurent22/joplin/issues/3287
window.addEventListener('auxclick', webviewLib.logEnabledEventHandler((event) => {
event.preventDefault();
}));
updateBodyHeight();
} catch (error) {
ipcProxySendToHost('error:' + JSON.stringify(webviewLib.cloneError(error)));
throw error;
}
</script>
</body>
</html>