NetNewsWire/iOS/Resources/main_ios.js
Brian Sanders b058f27064 Fixes bugs in article search
Placement of the article search bar is now always done with constraints.
Previously, I'd used inputAccessoryView when the keyboard appeared. That
approach, although ostensibly permitted, causes a hierarchy inconsistency error
when the device orientation changes.
2020-05-15 21:41:14 -04:00

482 lines
12 KiB
JavaScript

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;
}