mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-21 09:38:01 +02:00
806 lines
28 KiB
HTML
806 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head id="joplin-container-root-head">
|
|
<meta charset="UTF-8">
|
|
<title>Note viewer</title>
|
|
|
|
<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.parent.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 explicitly (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();
|
|
|
|
// Even if the scroll position hasn't changed (percent is the same),
|
|
// we still ignore the next scroll event, so that it doesn't create
|
|
// undesired side effects.
|
|
// https://github.com/laurent22/joplin/issues/7617
|
|
ignoreNextScrollEvent();
|
|
|
|
if (Math.floor(contentElement.scrollTop) !== Math.floor(newScrollTop)) {
|
|
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.
|
|
function addPluginAssets(assets) {
|
|
if (!assets) return;
|
|
|
|
const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer');
|
|
|
|
const processedAssetIds = [];
|
|
|
|
for (let i = 0; i < assets.length; i++) {
|
|
const asset = assets[i];
|
|
|
|
// # and ? can be used in valid paths and shouldn't be treated as the start of a query or fragment
|
|
const encodedPath = asset.path
|
|
.replaceAll('#','%23')
|
|
.replaceAll('?','%3F')
|
|
|
|
const assetId = asset.name ? asset.name : encodedPath;
|
|
|
|
processedAssetIds.push(assetId);
|
|
|
|
if (pluginAssetsAdded_[assetId]) continue;
|
|
|
|
let element = null;
|
|
|
|
// Needed on Windows:
|
|
// C:/Path/Here
|
|
// is interpreted as a file path, even without a starting file://.
|
|
let src = encodedPath;
|
|
if (src.match(/^[/]/) || src.match(/^[^:/\\]+[:][\\/]/)) {
|
|
src = `joplin-content://note-viewer/${src}`;
|
|
}
|
|
|
|
if (asset.mime === 'application/javascript') {
|
|
element = document.createElement('script');
|
|
element.src = src;
|
|
pluginAssetsContainer.appendChild(element);
|
|
} else if (asset.mime === 'text/css') {
|
|
element = document.createElement('link');
|
|
element.rel = 'stylesheet';
|
|
element.href = src;
|
|
pluginAssetsContainer.appendChild(element);
|
|
}
|
|
|
|
pluginAssetsAdded_[assetId] = {
|
|
element,
|
|
}
|
|
}
|
|
|
|
// Once we have added the relevant assets, we also remove those that
|
|
// are no longer needed. It's necessary in particular for the CSS
|
|
// generated by noteStyle - if we don't remove it, we might end up
|
|
// with two or more stylesheet and that will create conflicts.
|
|
//
|
|
// It was happening for example when automatically switching from
|
|
// light to dark theme, and then back to light theme - in that case
|
|
// the viewer would remain dark because it would use the dark
|
|
// stylesheet that would still be in the DOM.
|
|
for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
|
|
if (!processedAssetIds.includes(assetId)) {
|
|
try {
|
|
if (asset?.element) asset.element.remove();
|
|
} catch (error) {
|
|
// We don't throw an exception but we log it since
|
|
// it shouldn't happen
|
|
console.warn('Tried to remove asset ' + assetId + ' but got an error:', error);
|
|
console.warn('Assets are:', pluginAssetsAdded_);
|
|
}
|
|
pluginAssetsAdded_[assetId] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
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="#">Note viewer top</a></div>';
|
|
contentElement.insertAdjacentHTML("afterbegin", focusDummy);
|
|
}
|
|
const scrollTop = contentElement.scrollTop;
|
|
document.getElementById(dummyID).focus();
|
|
contentElement.scrollTop = scrollTop;
|
|
}
|
|
|
|
const rewriteFileUrls = (accessKey) => {
|
|
if (!accessKey) return;
|
|
|
|
// To allow accessing local files from the viewer's non-file URL, file:// URLs are re-written
|
|
// to joplin-content:// URLs:
|
|
const mediaElements = document.querySelectorAll('video[src], audio[src], source[src], img[src]');
|
|
for (const element of mediaElements) {
|
|
if (element.src?.startsWith('file:')) {
|
|
const newUrl = element.src.replace(/^file:\/\//, 'joplin-content://file-media/');
|
|
element.src = `${newUrl}?access-key=${accessKey}`;
|
|
}
|
|
}
|
|
};
|
|
|
|
ipc.setHtml = (event) => {
|
|
const html = event.html;
|
|
|
|
markJsHackMarkerInserted_ = false;
|
|
|
|
updateBodyHeight();
|
|
|
|
alreadyAllImagesLoaded = false;
|
|
|
|
contentElement.innerHTML = html;
|
|
|
|
if (html.includes('file://')) {
|
|
rewriteFileUrls(event.options.mediaAccessKey);
|
|
}
|
|
|
|
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_ = '​ ​'
|
|
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;
|
|
|
|
try {
|
|
for (const keyword of keywords) {
|
|
markJsUtils.markKeyword(mark_, keyword, {
|
|
pregQuote: pregQuote,
|
|
replaceRegexDiacritics: replaceRegexDiacritics,
|
|
}, markKeywordOptions);
|
|
}
|
|
} catch (error) {
|
|
if (error.name !== 'SyntaxError') {
|
|
throw error;
|
|
}
|
|
// An error of 'Regular expression too large' might occour in the markJs library
|
|
// when the input is really big, this catch is here to avoid the application crashing
|
|
// https://github.com/laurent22/joplin/issues/7634
|
|
console.error('Error while trying to highlight words from search: ', error);
|
|
}
|
|
}
|
|
|
|
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.
|
|
//
|
|
// 2024-02-01: The bug seems to be fixed, remove the above when we're not in
|
|
// feature-freeze.
|
|
|
|
// If scrollTop ever has a fraction part, zoomFactor is not 1.
|
|
if (zoomFactorIsNotOne || !Number.isInteger(contentElement.scrollTop)) {
|
|
zoomFactorIsNotOne = true;
|
|
|
|
// The custom scroll logic breaks horizontal scroll in child DOM nodes
|
|
// (e.g. scrollable code blocks). Disable it:
|
|
if (e.deltaY !== 0) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
ipc.textSelected = function(event) {
|
|
ipcProxySendToHost('contextMenu', {
|
|
type: 'text',
|
|
textToCopy: event.text,
|
|
});
|
|
}
|
|
|
|
ipc.openPdfViewer = function(event) {
|
|
ipcProxySendToHost('openPdfViewer', { resourceId: event.resourceId, mime: 'application/pdf', pageNo: event.pageNo || 1 });
|
|
}
|
|
|
|
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();
|
|
}));
|
|
|
|
document.addEventListener('click', webviewLib.logEnabledEventHandler(e => {
|
|
// Links should all have custom click handlers. Allowing Electron to load custom links
|
|
// can cause security issues, particularly if these links have the same domain as the
|
|
// top-level page.
|
|
if (e.target.hasAttribute('href')) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
document.querySelectorAll('.media-pdf').forEach(element => {
|
|
if(!!element.contentWindow){
|
|
element.contentWindow.postMessage({
|
|
type: 'blur'
|
|
}, '*');
|
|
}
|
|
}
|
|
);
|
|
}));
|
|
|
|
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>
|