var activeImageViewer = null; class ImageViewer { constructor(img) { this.img = img; this.loadingInterval = null; this.activityIndicator = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjAiIHdpZHRoPSI2NHB4IiBoZWlnaHQ9IjY0cHgiIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB4bWw6c3BhY2U9InByZXNlcnZlIj48Zz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiMwMDAwMDAiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiNjY2NjY2MiIHRyYW5zZm9ybT0icm90YXRlKDMwIDY0IDY0KSIvPjxwYXRoIGQ9Ik01OS42IDBoOHY0MGgtOFYweiIgZmlsbD0iI2NjY2NjYyIgdHJhbnNmb3JtPSJyb3RhdGUoNjAgNjQgNjQpIi8+PHBhdGggZD0iTTU5LjYgMGg4djQwaC04VjB6IiBmaWxsPSIjY2NjY2NjIiB0cmFuc2Zvcm09InJvdGF0ZSg5MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiNjY2NjY2MiIHRyYW5zZm9ybT0icm90YXRlKDEyMCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiNiMmIyYjIiIHRyYW5zZm9ybT0icm90YXRlKDE1MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM5OTk5OTkiIHRyYW5zZm9ybT0icm90YXRlKDE4MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM3ZjdmN2YiIHRyYW5zZm9ybT0icm90YXRlKDIxMCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM2NjY2NjYiIHRyYW5zZm9ybT0icm90YXRlKDI0MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM0YzRjNGMiIHRyYW5zZm9ybT0icm90YXRlKDI3MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiMzMzMzMzMiIHRyYW5zZm9ybT0icm90YXRlKDMwMCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiMxOTE5MTkiIHRyYW5zZm9ybT0icm90YXRlKDMzMCA2NCA2NCkiLz48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIHR5cGU9InJvdGF0ZSIgdmFsdWVzPSIwIDY0IDY0OzMwIDY0IDY0OzYwIDY0IDY0OzkwIDY0IDY0OzEyMCA2NCA2NDsxNTAgNjQgNjQ7MTgwIDY0IDY0OzIxMCA2NCA2NDsyNDAgNjQgNjQ7MjcwIDY0IDY0OzMwMCA2NCA2NDszMzAgNjQgNjQiIGNhbGNNb2RlPSJkaXNjcmV0ZSIgZHVyPSIxMDgwbXMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIj48L2FuaW1hdGVUcmFuc2Zvcm0+PC9nPjwvc3ZnPg=="; } isLoaded() { return this.img.classList.contains("nnwLoaded"); } clicked() { this.showLoadingIndicator(); if (this.isLoaded()) { this.showViewer(); } else { var callback = () => { if (this.isLoaded()) { clearInterval(this.loadingInterval); this.showViewer(); } } this.loadingInterval = setInterval(callback, 100); } } cancel() { clearInterval(this.loadingInterval); this.hideLoadingIndicator(); } showViewer() { this.hideLoadingIndicator(); var canvas = document.createElement("canvas"); var pixelRatio = window.devicePixelRatio; do { canvas.width = this.img.naturalWidth * pixelRatio; canvas.height = this.img.naturalHeight * pixelRatio; pixelRatio--; } while (pixelRatio > 0 && canvas.width * canvas.height > 16777216) canvas.getContext("2d").drawImage(this.img, 0, 0, canvas.width, canvas.height); const rect = this.img.getBoundingClientRect(); const message = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, imageTitle: this.img.title, imageURL: canvas.toDataURL(), }; var jsonMessage = JSON.stringify(message); window.webkit.messageHandlers.imageWasClicked.postMessage(jsonMessage); } hideImage() { this.img.style.opacity = 0; } showImage() { this.img.style.opacity = 1 } showLoadingIndicator() { var wrapper = document.createElement("div"); wrapper.classList.add("activityIndicatorWrap"); this.img.parentNode.insertBefore(wrapper, this.img); wrapper.appendChild(this.img); var activityIndicatorImg = document.createElement("img"); activityIndicatorImg.classList.add("activityIndicator"); activityIndicatorImg.style.opacity = 0; activityIndicatorImg.src = this.activityIndicator; wrapper.appendChild(activityIndicatorImg); activityIndicatorImg.style.opacity = 1; } hideLoadingIndicator() { var wrapper = this.img.parentNode; if (wrapper.classList.contains("activityIndicatorWrap")) { var wrapperParent = wrapper.parentNode; wrapperParent.insertBefore(this.img, wrapper); wrapperParent.removeChild(wrapper); } } static init() { cancelImageLoad(); // keep track of when an image has finished downloading for ImageViewer document.querySelectorAll("img").forEach(element => { element.onload = function() { this.classList.add("nnwLoaded"); } }); // Add the click listener for images window.onclick = function(event) { if (event.target.matches("img") && !event.target.classList.contains("nnw-nozoom")) { if (activeImageViewer && activeImageViewer.img === event.target) { cancelImageLoad(); } else { cancelImageLoad(); activeImageViewer = new ImageViewer(event.target); activeImageViewer.clicked(); } } } } } function cancelImageLoad() { if (activeImageViewer) { activeImageViewer.cancel(); activeImageViewer = null; } } function hideClickedImage() { if (activeImageViewer) { activeImageViewer.hideImage(); } } // Used to animate the transition from a fullscreen image function showClickedImage() { if (activeImageViewer) { activeImageViewer.showImage(); } window.webkit.messageHandlers.imageWasShown.postMessage(""); } function showFeedInspectorSetup() { document.getElementById("nnwImageIcon").onclick = function(event) { window.webkit.messageHandlers.showFeedInspector.postMessage(""); } } function postRenderProcessing() { ImageViewer.init(); showFeedInspectorSetup(); } function makeHighlightRect({left, top, width, height}, offsetTop=0, offsetLeft=0) { const overlay = document.createElement('a'); Object.assign(overlay.style, { position: 'absolute', left: `${Math.floor(left + offsetLeft)}px`, top: `${Math.floor(top + offsetTop)}px`, width: `${Math.ceil(width)}px`, height: `${Math.ceil(height)}px`, backgroundColor: 'rgba(200, 220, 10, 0.4)', pointerEvents: 'none' }); return overlay; } function clearHighlightRects() { let container = document.getElementById('nnw:highlightContainer') if (container) container.remove(); } function highlightRects(rects, clearOldRects=true, makeHighlightRect=makeHighlightRect) { const article = document.querySelector('article'); let container = document.getElementById('nnw:highlightContainer'); article.style.position = 'relative'; if (container && clearOldRects) container.remove(); container = document.createElement('div'); container.id = 'nnw:highlightContainer'; article.appendChild(container); const {top, left} = article.getBoundingClientRect(); return Array.from(rects, rect => container.appendChild(makeHighlightRect(rect, -top, -left)) ); } FinderResult = class { constructor(result) { Object.assign(this, result); } range() { const range = document.createRange(); range.setStart(this.node, this.offset); range.setEnd(this.node, this.offsetEnd); return range; } bounds() { return this.range().getBoundingClientRect(); } rects() { return this.range().getClientRects(); } highlight({clearOldRects=true, fn=makeHighlightRect} = {}) { highlightRects(this.rects(), clearOldRects, fn); } scrollTo() { scrollToRect(this.bounds(), this.node); } toJSON() { return { rects: Array.from(this.rects()), bounds: this.bounds(), index: this.index, matchGroups: this.match }; } toJSONString() { return JSON.stringify(this.toJSON()); } } Finder = class { constructor(pattern, options) { if (!pattern.global) { pattern = new RegExp(pattern, 'g'); } this.pattern = pattern; this.lastResult = null; this._nodeMatches = []; this.options = { rootSelector: '.articleBody', startNode: null, startOffset: null, } this.resultIndex = -1 Object.assign(this.options, options); this.walker = document.createTreeWalker(this.root, NodeFilter.SHOW_TEXT); } get root() { return document.querySelector(this.options.rootSelector) } get count() { const node = this.walker.currentNode; const index = this.resultIndex; this.reset(); let result, count = 0; while ((result = this.next())) ++count; this.resultIndex = index; this.walker.currentNode = node; return count; } reset() { this.walker.currentNode = this.options.startNode || this.root; this.resultIndex = -1; } [Symbol.iterator]() { return this; } next({wrap = false} = {}) { const { startNode } = this.options; const { pattern, walker } = this; let { node, matchIndex = -1 } = this.lastResult || { node: startNode }; while (true) { if (!node) node = walker.nextNode(); if (!node) { if (!wrap || this.resultIndex < 0) break; this.reset(); continue; } let nextIndex = matchIndex + 1; let matches = this._nodeMatches; if (!matches.length) { matches = Array.from(node.textContent.matchAll(pattern)); nextIndex = 0; } if (matches[nextIndex]) { this._nodeMatches = matches; const m = matches[nextIndex]; this.lastResult = new FinderResult({ node, offset: m.index, offsetEnd: m.index + m[0].length, text: m[0], match: m, matchIndex: nextIndex, index: ++this.resultIndex, }); return { value: this.lastResult, done: false }; } this._nodeMatches = []; node = null; } return { value: undefined, done: true }; } /// TODO Call when the search text changes retry() { if (this.lastResult) { this.lastResult.offsetEnd = this.lastResult.offset; } } toJSON() { const results = Array.from(this); } } function scrollParent(node) { let elt = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; while (elt) { if (elt.scrollHeight > elt.clientHeight) return elt; elt = elt.parentElement; } } function scrollToRect({top, height}, node, pad=20, padBottom=60) { const scrollToTop = top - pad; let scrollBy = scrollToTop; if (scrollToTop >= 0) { const visible = window.visualViewport; const scrollToBottom = top + height + padBottom - visible.height; // The top of the rect is already in the viewport if (scrollToBottom <= 0 || scrollToTop === 0) // Don't need to scroll up--or can't return; scrollBy = Math.min(scrollToBottom, scrollBy); } scrollParent(node).scrollBy({ top: scrollBy }); } function withEncodedArg(fn) { return function(encodedData, ...rest) { const data = encodedData && JSON.parse(atob(encodedData)); return fn(data, ...rest); } } function escapeRegex(s) { return s.replace(/[.?*+^$\\()[\]{}]/g, '\\$&'); } class FindState { constructor(options) { let { text, caseSensitive, regex } = options; if (!regex) text = escapeRegex(text); const finder = new Finder(new RegExp(text, caseSensitive ? 'g' : 'ig')); this.results = Array.from(finder); this.index = -1; this.options = options; } get selected() { return this.index > -1 ? this.results[this.index] : null; } toJSON() { return { index: this.index > -1 ? this.index : null, results: this.results, count: this.results.length }; } selectNext(step=1) { const index = this.index + step; const result = this.results[index]; if (result) { this.index = index; result.highlight(); result.scrollTo(); } return result; } selectPrevious() { return this.selectNext(-1); } } CurrentFindState = null; const ExcludeKeys = new Set(['top', 'right', 'bottom', 'left']); updateFind = withEncodedArg(options => { // TODO Start at the current result position // TODO Introduce slight delay, cap the number of results, and report results asynchronously let newFindState; if (!options || !options.text) { clearHighlightRects(); return } try { newFindState = new FindState(options); } catch (err) { clearHighlightRects(); throw err; } if (newFindState.results.length) { let selected = CurrentFindState && CurrentFindState.selected; let selectIndex = 0; if (selected) { let {node: currentNode, offset: currentOffset} = selected; selectIndex = newFindState.results.findIndex(r => { if (r.node === currentNode) { return r.offset >= currentOffset; } let relation = currentNode.compareDocumentPosition(r.node); return Boolean(relation & Node.DOCUMENT_POSITION_FOLLOWING); }); } newFindState.selectNext(selectIndex+1); } else { clearHighlightRects(); } CurrentFindState = newFindState; return btoa(JSON.stringify(CurrentFindState, (k, v) => (ExcludeKeys.has(k) ? undefined : v))); }); selectNextResult = withEncodedArg(options => { if (CurrentFindState) CurrentFindState.selectNext(); }); selectPreviousResult = withEncodedArg(options => { if (CurrentFindState) CurrentFindState.selectPrevious(); }); function endFind() { clearHighlightRects() CurrentFindState = null; }