Add basic support for Article view on iOS
This commit is contained in:
parent
52f1b9d036
commit
2e94ae9e8e
@ -110,6 +110,18 @@ struct AppAssets {
|
|||||||
return Image(systemName: "info.circle")
|
return Image(systemName: "info.circle")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
static var iconBackgroundColor: NSColor = {
|
||||||
|
return NSColor(named: "IconBackgroundColor")!
|
||||||
|
}()
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static var iconBackgroundColor: UIColor = {
|
||||||
|
return UIColor(named: "IconBackgroundColor")!
|
||||||
|
}()
|
||||||
|
#endif
|
||||||
|
|
||||||
static var nextArticleImage: Image = {
|
static var nextArticleImage: Image = {
|
||||||
return Image(systemName: "chevron.down")
|
return Image(systemName: "chevron.down")
|
||||||
}()
|
}()
|
||||||
|
@ -193,6 +193,8 @@ final class AppDefaults: ObservableObject {
|
|||||||
// MARK: Articles
|
// MARK: Articles
|
||||||
@AppStorage(wrappedValue: false, Key.articleFullscreenAvailable, store: store) var articleFullscreenAvailable: Bool
|
@AppStorage(wrappedValue: false, Key.articleFullscreenAvailable, store: store) var articleFullscreenAvailable: Bool
|
||||||
|
|
||||||
|
@AppStorage(wrappedValue: false, Key.articleFullscreenEnabled, store: store) var articleFullscreenEnabled: Bool
|
||||||
|
|
||||||
// MARK: Refresh
|
// MARK: Refresh
|
||||||
var lastRefresh: Date? {
|
var lastRefresh: Date? {
|
||||||
set {
|
set {
|
||||||
|
@ -16,13 +16,8 @@ struct ArticleContainerView: View {
|
|||||||
var article: Article
|
var article: Article
|
||||||
|
|
||||||
@ViewBuilder var body: some View {
|
@ViewBuilder var body: some View {
|
||||||
ArticleView()
|
ArticleView(sceneModel: sceneModel, articleModel: articleModel, article: article)
|
||||||
.modifier(ArticleToolbarModifier())
|
.modifier(ArticleToolbarModifier())
|
||||||
.environmentObject(articleModel)
|
|
||||||
.onAppear {
|
|
||||||
sceneModel.articleModel = articleModel
|
|
||||||
articleModel.delegate = sceneModel
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -14,12 +14,37 @@ import Account
|
|||||||
import Articles
|
import Articles
|
||||||
|
|
||||||
protocol ArticleModelDelegate: class {
|
protocol ArticleModelDelegate: class {
|
||||||
func timelineRequestedWebFeedSelection(_: TimelineModel, webFeed: WebFeed)
|
#if os(iOS)
|
||||||
|
var webViewProvider: WebViewProvider? { get }
|
||||||
|
#endif
|
||||||
|
func findPrevArticle(_: ArticleModel, article: Article) -> Article?
|
||||||
|
func findNextArticle(_: ArticleModel, article: Article) -> Article?
|
||||||
|
func selectArticle(_: ArticleModel, article: Article)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ArticleModel: ObservableObject {
|
class ArticleModel: ObservableObject {
|
||||||
|
|
||||||
weak var delegate: ArticleModelDelegate?
|
weak var delegate: ArticleModelDelegate?
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
var webViewProvider: WebViewProvider? {
|
||||||
|
return delegate?.webViewProvider
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: API
|
||||||
|
|
||||||
|
func findPrevArticle(_ article: Article) -> Article? {
|
||||||
|
return delegate?.findPrevArticle(self, article: article)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findNextArticle(_ article: Article) -> Article? {
|
||||||
|
return delegate?.findNextArticle(self, article: article)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectArticle(_ article: Article) {
|
||||||
|
delegate?.selectArticle(self, article: article)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
11
Multiplatform/Shared/Article/blank.html
Normal file
11
Multiplatform/Shared/Article/blank.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
481
Multiplatform/Shared/Article/main_multiplatform.js
Normal file
481
Multiplatform/Shared/Article/main_multiplatform.js
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
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;
|
||||||
|
}
|
22
Multiplatform/Shared/Article/page.html
Normal file
22
Multiplatform/Shared/Article/page.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>[[title]]</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
[[style]]
|
||||||
|
</style>
|
||||||
|
<script src="main.js"></script>
|
||||||
|
<script src="main_multiplatform.js"></script>
|
||||||
|
<script src="newsfoot.js" async="async"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
document.addEventListener("DOMContentLoaded", function(event) {
|
||||||
|
window.scrollTo(0, [[windowScrollY]]);
|
||||||
|
processPage();
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<base href="[[baseURL]]">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
[[body]]
|
||||||
|
</body>
|
||||||
|
</html>
|
66
Multiplatform/Shared/Article/styleSheet.css
Normal file
66
Multiplatform/Shared/Article/styleSheet.css
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
body {
|
||||||
|
margin-top: 3px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
|
||||||
|
word-break: break-word;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
font: -apple-system-body;
|
||||||
|
font-size: [[font-size]]px;
|
||||||
|
--primary-accent-color: #086AEE;
|
||||||
|
--secondary-accent-color: #086AEE;
|
||||||
|
--block-quote-border-color: rgba(8, 106, 238, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--primary-accent-color: #2D80F1;
|
||||||
|
--secondary-accent-color: #5E9EF4;
|
||||||
|
--block-quote-border-color: rgba(94, 158, 244, 0.75);
|
||||||
|
--header-table-border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body a, body a:link, body a:visited {
|
||||||
|
color: var(--secondary-accent-color);
|
||||||
|
}
|
||||||
|
body .header {
|
||||||
|
font: -apple-system-body;
|
||||||
|
font-size: [[font-size]]px;
|
||||||
|
}
|
||||||
|
body .header a:link, body .header a:visited {
|
||||||
|
color: var(--primary-accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
border: 1px solid var(--secondary-accent-color);
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nnw-overflow table {
|
||||||
|
border: 1px solid var(--secondary-accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activityIndicatorWrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activityIndicator {
|
||||||
|
z-index: 1;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.929",
|
||||||
|
"green" : "0.922",
|
||||||
|
"red" : "0.922"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.220",
|
||||||
|
"green" : "0.220",
|
||||||
|
"red" : "0.220"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Account
|
import Account
|
||||||
|
import Articles
|
||||||
|
|
||||||
final class SceneModel: ObservableObject {
|
final class SceneModel: ObservableObject {
|
||||||
|
|
||||||
@ -18,11 +19,25 @@ final class SceneModel: ObservableObject {
|
|||||||
var articleModel: ArticleModel?
|
var articleModel: ArticleModel?
|
||||||
|
|
||||||
private var refreshProgressModel: RefreshProgressModel? = nil
|
private var refreshProgressModel: RefreshProgressModel? = nil
|
||||||
|
#if os(iOS)
|
||||||
|
private var _webViewProvider: WebViewProvider? = nil
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: API
|
||||||
|
|
||||||
func startup() {
|
func startup() {
|
||||||
self.refreshProgressModel = RefreshProgressModel()
|
self.refreshProgressModel = RefreshProgressModel()
|
||||||
self.refreshProgressModel!.$state.assign(to: self.$refreshProgressState)
|
self.refreshProgressModel!.$state.assign(to: self.$refreshProgressState)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
self._webViewProvider = WebViewProvider(sceneModel: self)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func articleFor(_ articleID: String) -> Article? {
|
||||||
|
return timelineModel?.articleFor(articleID)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: SidebarModelDelegate
|
// MARK: SidebarModelDelegate
|
||||||
@ -49,5 +64,28 @@ extension SceneModel: TimelineModelDelegate {
|
|||||||
|
|
||||||
extension SceneModel: ArticleModelDelegate {
|
extension SceneModel: ArticleModelDelegate {
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
var webViewProvider: WebViewProvider? {
|
||||||
|
return _webViewProvider
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func findPrevArticle(_: ArticleModel, article: Article) -> Article? {
|
||||||
|
return timelineModel?.findPrevArticle(article)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findNextArticle(_: ArticleModel, article: Article) -> Article? {
|
||||||
|
return timelineModel?.findNextArticle(article)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectArticle(_: ArticleModel, article: Article) {
|
||||||
|
timelineModel?.selectArticle(article)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private extension SceneModel {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,6 @@
|
|||||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TimelineToolbarModifier: ViewModifier {
|
struct TimelineToolbarModifier: ViewModifier {
|
||||||
|
@ -18,6 +18,7 @@ struct TimelineContainerView: View {
|
|||||||
@ViewBuilder var body: some View {
|
@ViewBuilder var body: some View {
|
||||||
if let feed = feed {
|
if let feed = feed {
|
||||||
TimelineView()
|
TimelineView()
|
||||||
|
.modifier(TimelineTitleModifier(title: feed.nameForDisplay))
|
||||||
.modifier(TimelineToolbarModifier())
|
.modifier(TimelineToolbarModifier())
|
||||||
.environmentObject(timelineModel)
|
.environmentObject(timelineModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
@ -27,7 +27,20 @@ class TimelineModel: ObservableObject {
|
|||||||
private var exceptionArticleFetcher: ArticleFetcher?
|
private var exceptionArticleFetcher: ArticleFetcher?
|
||||||
private var isReadFiltered = false
|
private var isReadFiltered = false
|
||||||
|
|
||||||
private var articles = [Article]()
|
private var articles = [Article]() {
|
||||||
|
didSet {
|
||||||
|
articleDictionaryNeedsUpdate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var articleDictionaryNeedsUpdate = true
|
||||||
|
private var _idToArticleDictionary = [String: Article]()
|
||||||
|
private var idToAticleDictionary: [String: Article] {
|
||||||
|
if articleDictionaryNeedsUpdate {
|
||||||
|
rebuildArticleDictionaries()
|
||||||
|
}
|
||||||
|
return _idToArticleDictionary
|
||||||
|
}
|
||||||
|
|
||||||
private var sortDirection = AppDefaults.shared.timelineSortDirection {
|
private var sortDirection = AppDefaults.shared.timelineSortDirection {
|
||||||
didSet {
|
didSet {
|
||||||
@ -63,6 +76,28 @@ class TimelineModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func articleFor(_ articleID: String) -> Article? {
|
||||||
|
return idToAticleDictionary[articleID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPrevArticle(_ article: Article) -> Article? {
|
||||||
|
guard let index = articles.firstIndex(of: article), index > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return articles[index - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func findNextArticle(_ article: Article) -> Article? {
|
||||||
|
guard let index = articles.firstIndex(of: article), index + 1 != articles.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return articles[index + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectArticle(_ article: Article) {
|
||||||
|
// TODO: Implement me!
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Private
|
// MARK: Private
|
||||||
@ -82,6 +117,15 @@ private extension TimelineModel {
|
|||||||
// restoreSelection(savedSelection)
|
// restoreSelection(savedSelection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rebuildArticleDictionaries() {
|
||||||
|
var idDictionary = [String: Article]()
|
||||||
|
articles.forEach { article in
|
||||||
|
idDictionary[article.articleID] = article
|
||||||
|
}
|
||||||
|
_idToArticleDictionary = idDictionary
|
||||||
|
articleDictionaryNeedsUpdate = false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Article Fetching
|
// MARK: Article Fetching
|
||||||
|
|
||||||
func fetchAndReplaceArticlesAsync() {
|
func fetchAndReplaceArticlesAsync() {
|
||||||
|
23
Multiplatform/Shared/Timeline/TimelineTitleModifier.swift
Normal file
23
Multiplatform/Shared/Timeline/TimelineTitleModifier.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// TimelineTitleModifier.swift
|
||||||
|
// NetNewsWire
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimelineTitleModifier: ViewModifier {
|
||||||
|
|
||||||
|
var title: String
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
#if os(macOS)
|
||||||
|
return content
|
||||||
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
return content.navigationBarTitle(Text(verbatim: title), displayMode: .inline)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
16
Multiplatform/iOS/Article/ArticleExtractorButtonState.swift
Normal file
16
Multiplatform/iOS/Article/ArticleExtractorButtonState.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// ArticleExtractorButtonState.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ArticleExtractorButtonState {
|
||||||
|
case error
|
||||||
|
case animated
|
||||||
|
case on
|
||||||
|
case off
|
||||||
|
}
|
60
Multiplatform/iOS/Article/ArticleIconSchemeHandler.swift
Normal file
60
Multiplatform/iOS/Article/ArticleIconSchemeHandler.swift
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
//
|
||||||
|
// ArticleIconSchemeHandler.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebKit
|
||||||
|
import Articles
|
||||||
|
|
||||||
|
class ArticleIconSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||||
|
|
||||||
|
weak var sceneModel: SceneModel?
|
||||||
|
|
||||||
|
init(sceneModel: SceneModel) {
|
||||||
|
self.sceneModel = sceneModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||||
|
|
||||||
|
guard let url = urlSchemeTask.request.url, let sceneModel = sceneModel else {
|
||||||
|
urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let articleID = components.path
|
||||||
|
guard let iconImage = sceneModel.articleFor(articleID)?.iconImage() else {
|
||||||
|
urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let iconView = IconView(frame: CGRect(x: 0, y: 0, width: 48, height: 48))
|
||||||
|
iconView.iconImage = iconImage
|
||||||
|
let renderedImage = iconView.asImage()
|
||||||
|
|
||||||
|
guard let data = renderedImage.dataRepresentation() else {
|
||||||
|
urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let headerFields = ["Cache-Control": "no-cache"]
|
||||||
|
if let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headerFields) {
|
||||||
|
urlSchemeTask.didReceive(response)
|
||||||
|
urlSchemeTask.didReceive(data)
|
||||||
|
urlSchemeTask.didFinish()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||||
|
urlSchemeTask.didFailWithError(URLError(.unknown))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -7,15 +7,43 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Articles
|
||||||
|
|
||||||
struct ArticleView: View {
|
final class ArticleView: UIViewControllerRepresentable {
|
||||||
var body: some View {
|
|
||||||
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
var sceneModel: SceneModel
|
||||||
}
|
var articleModel: ArticleModel
|
||||||
|
var article: Article
|
||||||
|
|
||||||
|
init(sceneModel: SceneModel, articleModel: ArticleModel, article: Article) {
|
||||||
|
self.sceneModel = sceneModel
|
||||||
|
self.articleModel = articleModel
|
||||||
|
self.article = article
|
||||||
|
sceneModel.articleModel = articleModel
|
||||||
|
articleModel.delegate = sceneModel
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ArticleView_Previews: PreviewProvider {
|
func makeUIViewController(context: Context) -> ArticleViewController {
|
||||||
static var previews: some View {
|
let controller = ArticleViewController()
|
||||||
ArticleView()
|
controller.articleModel = articleModel
|
||||||
|
controller.article = article
|
||||||
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: ArticleViewController, context: Context) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//struct ArticleView: View {
|
||||||
|
//
|
||||||
|
// var sceneModel: SceneModel
|
||||||
|
// var articleModel: ArticleModel
|
||||||
|
// var article: Article
|
||||||
|
//
|
||||||
|
// var body: some View {
|
||||||
|
// ArticleViewControllerAdapter(sceneModel: sceneModel, articleModel: articleModel, article: article)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//}
|
||||||
|
172
Multiplatform/iOS/Article/ArticleViewController.swift
Normal file
172
Multiplatform/iOS/Article/ArticleViewController.swift
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
//
|
||||||
|
// ArticleViewController.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
import Account
|
||||||
|
import Articles
|
||||||
|
import SafariServices
|
||||||
|
|
||||||
|
class ArticleViewController: UIViewController {
|
||||||
|
|
||||||
|
weak var articleModel: ArticleModel?
|
||||||
|
|
||||||
|
private var pageViewController: UIPageViewController!
|
||||||
|
|
||||||
|
private var currentWebViewController: WebViewController? {
|
||||||
|
return pageViewController?.viewControllers?.first as? WebViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
var article: Article? {
|
||||||
|
didSet {
|
||||||
|
if let controller = currentWebViewController, controller.article != article {
|
||||||
|
controller.setArticle(article)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// You have to set the view controller to clear out the UIPageViewController child controller cache.
|
||||||
|
// You also have to do it in an async call or you will get a strange assertion error.
|
||||||
|
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:])
|
||||||
|
pageViewController.delegate = self
|
||||||
|
pageViewController.dataSource = self
|
||||||
|
|
||||||
|
pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(pageViewController.view)
|
||||||
|
addChild(pageViewController!)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
view.leadingAnchor.constraint(equalTo: pageViewController.view.leadingAnchor),
|
||||||
|
view.trailingAnchor.constraint(equalTo: pageViewController.view.trailingAnchor),
|
||||||
|
view.topAnchor.constraint(equalTo: pageViewController.view.topAnchor),
|
||||||
|
view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
let controller = createWebViewController(article, updateView: true)
|
||||||
|
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: API
|
||||||
|
|
||||||
|
func focus() {
|
||||||
|
currentWebViewController?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func canScrollDown() -> Bool {
|
||||||
|
return currentWebViewController?.canScrollDown() ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollPageDown() {
|
||||||
|
currentWebViewController?.scrollPageDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopArticleExtractorIfProcessing() {
|
||||||
|
currentWebViewController?.stopArticleExtractorIfProcessing()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: WebViewControllerDelegate
|
||||||
|
|
||||||
|
extension ArticleViewController: WebViewControllerDelegate {
|
||||||
|
|
||||||
|
func webViewController(_ webViewController: WebViewController, articleExtractorButtonStateDidUpdate buttonState: ArticleExtractorButtonState) {
|
||||||
|
if webViewController === currentWebViewController {
|
||||||
|
// articleExtractorButton.buttonState = buttonState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UIPageViewControllerDataSource
|
||||||
|
|
||||||
|
extension ArticleViewController: UIPageViewControllerDataSource {
|
||||||
|
|
||||||
|
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||||
|
guard let webViewController = viewController as? WebViewController,
|
||||||
|
let currentArticle = webViewController.article,
|
||||||
|
let article = articleModel?.findPrevArticle(currentArticle) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return createWebViewController(article)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
||||||
|
guard let webViewController = viewController as? WebViewController,
|
||||||
|
let currentArticle = webViewController.article,
|
||||||
|
let article = articleModel?.findNextArticle(currentArticle) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return createWebViewController(article)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UIPageViewControllerDelegate
|
||||||
|
|
||||||
|
extension ArticleViewController: UIPageViewControllerDelegate {
|
||||||
|
|
||||||
|
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||||
|
guard finished, completed else { return }
|
||||||
|
guard let article = currentWebViewController?.article else { return }
|
||||||
|
|
||||||
|
articleModel?.selectArticle(article)
|
||||||
|
// articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off
|
||||||
|
|
||||||
|
previousViewControllers.compactMap({ $0 as? WebViewController }).forEach({ $0.stopWebViewActivity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UIGestureRecognizerDelegate
|
||||||
|
|
||||||
|
extension ArticleViewController: UIGestureRecognizerDelegate {
|
||||||
|
|
||||||
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
let point = gestureRecognizer.location(in: nil)
|
||||||
|
if point.x > 40 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private extension ArticleViewController {
|
||||||
|
|
||||||
|
func createWebViewController(_ article: Article?, updateView: Bool = true) -> WebViewController {
|
||||||
|
let controller = WebViewController()
|
||||||
|
controller.articleModel = articleModel
|
||||||
|
controller.delegate = self
|
||||||
|
controller.setArticle(article, updateView: updateView)
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetWebViewController() {
|
||||||
|
articleModel?.webViewProvider?.flushQueue()
|
||||||
|
articleModel?.webViewProvider?.replenishQueueIfNeeded()
|
||||||
|
if let controller = currentWebViewController {
|
||||||
|
controller.fullReload()
|
||||||
|
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
124
Multiplatform/iOS/Article/IconView.swift
Normal file
124
Multiplatform/iOS/Article/IconView.swift
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
//
|
||||||
|
// IconView.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class IconView: UIView {
|
||||||
|
|
||||||
|
var iconImage: IconImage? = nil {
|
||||||
|
didSet {
|
||||||
|
if iconImage !== oldValue {
|
||||||
|
imageView.image = iconImage?.image
|
||||||
|
|
||||||
|
if self.traitCollection.userInterfaceStyle == .dark {
|
||||||
|
if self.iconImage?.isDark ?? false {
|
||||||
|
self.isDisconcernable = false
|
||||||
|
self.setNeedsLayout()
|
||||||
|
} else {
|
||||||
|
self.isDisconcernable = true
|
||||||
|
self.setNeedsLayout()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if self.iconImage?.isBright ?? false {
|
||||||
|
self.isDisconcernable = false
|
||||||
|
self.setNeedsLayout()
|
||||||
|
} else {
|
||||||
|
self.isDisconcernable = true
|
||||||
|
self.setNeedsLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.setNeedsLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isDisconcernable = true
|
||||||
|
|
||||||
|
private let imageView: UIImageView = {
|
||||||
|
let imageView = UIImageView(image: AppAssets.faviconTemplateImage)
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
imageView.layer.cornerRadius = 4.0
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var isVerticalBackgroundExposed: Bool {
|
||||||
|
return imageView.frame.size.height < bounds.size.height
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isSymbolImage: Bool {
|
||||||
|
return imageView.image?.isSymbolImage ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
commonInit()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
commonInit()
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init() {
|
||||||
|
self.init(frame: .zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didMoveToSuperview() {
|
||||||
|
setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
imageView.setFrameIfNotEqual(rectForImageView())
|
||||||
|
if (iconImage != nil && isVerticalBackgroundExposed && !isSymbolImage) || !isDisconcernable {
|
||||||
|
backgroundColor = AppAssets.iconBackgroundColor
|
||||||
|
} else {
|
||||||
|
backgroundColor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension IconView {
|
||||||
|
|
||||||
|
func commonInit() {
|
||||||
|
layer.cornerRadius = 4
|
||||||
|
clipsToBounds = true
|
||||||
|
addSubview(imageView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rectForImageView() -> CGRect {
|
||||||
|
guard let image = iconImage?.image else {
|
||||||
|
return CGRect.zero
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageSize = image.size
|
||||||
|
let viewSize = bounds.size
|
||||||
|
if imageSize.height == imageSize.width {
|
||||||
|
if imageSize.height >= viewSize.height * 0.75 {
|
||||||
|
// Close enough to viewSize to scale up the image.
|
||||||
|
return CGRect(x: 0.0, y: 0.0, width: viewSize.width, height: viewSize.height)
|
||||||
|
}
|
||||||
|
let offset = floor((viewSize.height - imageSize.height) / 2.0)
|
||||||
|
return CGRect(x: offset, y: offset, width: imageSize.width, height: imageSize.height)
|
||||||
|
}
|
||||||
|
else if imageSize.height > imageSize.width {
|
||||||
|
let factor = viewSize.height / imageSize.height
|
||||||
|
let width = imageSize.width * factor
|
||||||
|
let originX = floor((viewSize.width - width) / 2.0)
|
||||||
|
return CGRect(x: originX, y: 0.0, width: width, height: viewSize.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wider than tall: imageSize.width > imageSize.height
|
||||||
|
let factor = viewSize.width / imageSize.width
|
||||||
|
let height = imageSize.height * factor
|
||||||
|
let originY = floor((viewSize.height - height) / 2.0)
|
||||||
|
return CGRect(x: 0.0, y: originY, width: viewSize.width, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
361
Multiplatform/iOS/Article/ImageScrollView.swift
Normal file
361
Multiplatform/iOS/Article/ImageScrollView.swift
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
//
|
||||||
|
// ImageScrollView.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate {
|
||||||
|
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView)
|
||||||
|
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
open class ImageScrollView: UIScrollView {
|
||||||
|
|
||||||
|
@objc public enum ScaleMode: Int {
|
||||||
|
case aspectFill
|
||||||
|
case aspectFit
|
||||||
|
case widthFill
|
||||||
|
case heightFill
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public enum Offset: Int {
|
||||||
|
case begining
|
||||||
|
case center
|
||||||
|
}
|
||||||
|
|
||||||
|
static let kZoomInFactorFromMinWhenDoubleTap: CGFloat = 2
|
||||||
|
|
||||||
|
@objc open var imageContentMode: ScaleMode = .widthFill
|
||||||
|
@objc open var initialOffset: Offset = .begining
|
||||||
|
|
||||||
|
@objc public private(set) var zoomView: UIImageView? = nil
|
||||||
|
|
||||||
|
@objc open weak var imageScrollViewDelegate: ImageScrollViewDelegate?
|
||||||
|
|
||||||
|
var imageSize: CGSize = CGSize.zero
|
||||||
|
private var pointToCenterAfterResize: CGPoint = CGPoint.zero
|
||||||
|
private var scaleToRestoreAfterResize: CGFloat = 1.0
|
||||||
|
var maxScaleFromMinScale: CGFloat = 3.0
|
||||||
|
|
||||||
|
var zoomedFrame: CGRect {
|
||||||
|
return zoomView?.frame ?? CGRect.zero
|
||||||
|
}
|
||||||
|
|
||||||
|
override open var frame: CGRect {
|
||||||
|
willSet {
|
||||||
|
if frame.equalTo(newValue) == false && newValue.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
|
||||||
|
prepareToResize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
didSet {
|
||||||
|
if frame.equalTo(oldValue) == false && frame.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
|
||||||
|
recoverFromResizing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override public init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
required public init?(coder aDecoder: NSCoder) {
|
||||||
|
super.init(coder: aDecoder)
|
||||||
|
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialize() {
|
||||||
|
showsVerticalScrollIndicator = false
|
||||||
|
showsHorizontalScrollIndicator = false
|
||||||
|
bouncesZoom = true
|
||||||
|
decelerationRate = UIScrollView.DecelerationRate.fast
|
||||||
|
delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public func adjustFrameToCenter() {
|
||||||
|
|
||||||
|
guard let unwrappedZoomView = zoomView else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameToCenter = unwrappedZoomView.frame
|
||||||
|
|
||||||
|
// center horizontally
|
||||||
|
if frameToCenter.size.width < bounds.width {
|
||||||
|
frameToCenter.origin.x = (bounds.width - frameToCenter.size.width) / 2
|
||||||
|
} else {
|
||||||
|
frameToCenter.origin.x = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// center vertically
|
||||||
|
if frameToCenter.size.height < bounds.height {
|
||||||
|
frameToCenter.origin.y = (bounds.height - frameToCenter.size.height) / 2
|
||||||
|
} else {
|
||||||
|
frameToCenter.origin.y = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrappedZoomView.frame = frameToCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareToResize() {
|
||||||
|
let boundsCenter = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||||
|
pointToCenterAfterResize = convert(boundsCenter, to: zoomView)
|
||||||
|
|
||||||
|
scaleToRestoreAfterResize = zoomScale
|
||||||
|
|
||||||
|
// If we're at the minimum zoom scale, preserve that by returning 0, which will be converted to the minimum
|
||||||
|
// allowable scale when the scale is restored.
|
||||||
|
if scaleToRestoreAfterResize <= minimumZoomScale + CGFloat(Float.ulpOfOne) {
|
||||||
|
scaleToRestoreAfterResize = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recoverFromResizing() {
|
||||||
|
setMaxMinZoomScalesForCurrentBounds()
|
||||||
|
|
||||||
|
// restore zoom scale, first making sure it is within the allowable range.
|
||||||
|
let maxZoomScale = max(minimumZoomScale, scaleToRestoreAfterResize)
|
||||||
|
zoomScale = min(maximumZoomScale, maxZoomScale)
|
||||||
|
|
||||||
|
// restore center point, first making sure it is within the allowable range.
|
||||||
|
|
||||||
|
// convert our desired center point back to our own coordinate space
|
||||||
|
let boundsCenter = convert(pointToCenterAfterResize, to: zoomView)
|
||||||
|
|
||||||
|
// calculate the content offset that would yield that center point
|
||||||
|
var offset = CGPoint(x: boundsCenter.x - bounds.size.width/2.0, y: boundsCenter.y - bounds.size.height/2.0)
|
||||||
|
|
||||||
|
// restore offset, adjusted to be within the allowable range
|
||||||
|
let maxOffset = maximumContentOffset()
|
||||||
|
let minOffset = minimumContentOffset()
|
||||||
|
|
||||||
|
var realMaxOffset = min(maxOffset.x, offset.x)
|
||||||
|
offset.x = max(minOffset.x, realMaxOffset)
|
||||||
|
|
||||||
|
realMaxOffset = min(maxOffset.y, offset.y)
|
||||||
|
offset.y = max(minOffset.y, realMaxOffset)
|
||||||
|
|
||||||
|
contentOffset = offset
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maximumContentOffset() -> CGPoint {
|
||||||
|
return CGPoint(x: contentSize.width - bounds.width,y:contentSize.height - bounds.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func minimumContentOffset() -> CGPoint {
|
||||||
|
return CGPoint.zero
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Set up
|
||||||
|
|
||||||
|
open func setup() {
|
||||||
|
var topSupperView = superview
|
||||||
|
|
||||||
|
while topSupperView?.superview != nil {
|
||||||
|
topSupperView = topSupperView?.superview
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure views have already layout with precise frame
|
||||||
|
topSupperView?.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display image
|
||||||
|
|
||||||
|
@objc open func display(image: UIImage) {
|
||||||
|
|
||||||
|
if let zoomView = zoomView {
|
||||||
|
zoomView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomView = UIImageView(image: image)
|
||||||
|
zoomView!.isUserInteractionEnabled = true
|
||||||
|
addSubview(zoomView!)
|
||||||
|
|
||||||
|
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(doubleTapGestureRecognizer(_:)))
|
||||||
|
tapGesture.numberOfTapsRequired = 2
|
||||||
|
zoomView!.addGestureRecognizer(tapGesture)
|
||||||
|
|
||||||
|
let downSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeUpGestureRecognizer(_:)))
|
||||||
|
downSwipeGesture.direction = .down
|
||||||
|
zoomView!.addGestureRecognizer(downSwipeGesture)
|
||||||
|
|
||||||
|
let upSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeDownGestureRecognizer(_:)))
|
||||||
|
upSwipeGesture.direction = .up
|
||||||
|
zoomView!.addGestureRecognizer(upSwipeGesture)
|
||||||
|
|
||||||
|
configureImageForSize(image.size)
|
||||||
|
adjustFrameToCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureImageForSize(_ size: CGSize) {
|
||||||
|
imageSize = size
|
||||||
|
contentSize = imageSize
|
||||||
|
setMaxMinZoomScalesForCurrentBounds()
|
||||||
|
zoomScale = minimumZoomScale
|
||||||
|
|
||||||
|
switch initialOffset {
|
||||||
|
case .begining:
|
||||||
|
contentOffset = CGPoint.zero
|
||||||
|
case .center:
|
||||||
|
let xOffset = contentSize.width < bounds.width ? 0 : (contentSize.width - bounds.width)/2
|
||||||
|
let yOffset = contentSize.height < bounds.height ? 0 : (contentSize.height - bounds.height)/2
|
||||||
|
|
||||||
|
switch imageContentMode {
|
||||||
|
case .aspectFit:
|
||||||
|
contentOffset = CGPoint.zero
|
||||||
|
case .aspectFill:
|
||||||
|
contentOffset = CGPoint(x: xOffset, y: yOffset)
|
||||||
|
case .heightFill:
|
||||||
|
contentOffset = CGPoint(x: xOffset, y: 0)
|
||||||
|
case .widthFill:
|
||||||
|
contentOffset = CGPoint(x: 0, y: yOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setMaxMinZoomScalesForCurrentBounds() {
|
||||||
|
// calculate min/max zoomscale
|
||||||
|
let xScale = bounds.width / imageSize.width // the scale needed to perfectly fit the image width-wise
|
||||||
|
let yScale = bounds.height / imageSize.height // the scale needed to perfectly fit the image height-wise
|
||||||
|
|
||||||
|
var minScale: CGFloat = 1
|
||||||
|
|
||||||
|
switch imageContentMode {
|
||||||
|
case .aspectFill:
|
||||||
|
minScale = max(xScale, yScale)
|
||||||
|
case .aspectFit:
|
||||||
|
minScale = min(xScale, yScale)
|
||||||
|
case .widthFill:
|
||||||
|
minScale = xScale
|
||||||
|
case .heightFill:
|
||||||
|
minScale = yScale
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let maxScale = maxScaleFromMinScale*minScale
|
||||||
|
|
||||||
|
// don't let minScale exceed maxScale. (If the image is smaller than the screen, we don't want to force it to be zoomed.)
|
||||||
|
if minScale > maxScale {
|
||||||
|
minScale = maxScale
|
||||||
|
}
|
||||||
|
|
||||||
|
maximumZoomScale = maxScale
|
||||||
|
minimumZoomScale = minScale // * 0.999 // the multiply factor to prevent user cannot scroll page while they use this control in UIPageViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gesture
|
||||||
|
|
||||||
|
@objc func doubleTapGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||||
|
// zoom out if it bigger than middle scale point. Else, zoom in
|
||||||
|
if zoomScale >= maximumZoomScale / 2.0 {
|
||||||
|
setZoomScale(minimumZoomScale, animated: true)
|
||||||
|
} else {
|
||||||
|
let center = gestureRecognizer.location(in: gestureRecognizer.view)
|
||||||
|
let zoomRect = zoomRectForScale(ImageScrollView.kZoomInFactorFromMinWhenDoubleTap * minimumZoomScale, center: center)
|
||||||
|
zoom(to: zoomRect, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func swipeUpGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||||
|
if gestureRecognizer.state == .ended {
|
||||||
|
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeUp(imageScrollView: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func swipeDownGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||||
|
if gestureRecognizer.state == .ended {
|
||||||
|
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeDown(imageScrollView: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect {
|
||||||
|
var zoomRect = CGRect.zero
|
||||||
|
|
||||||
|
// the zoom rect is in the content view's coordinates.
|
||||||
|
// at a zoom scale of 1.0, it would be the size of the imageScrollView's bounds.
|
||||||
|
// as the zoom scale decreases, so more content is visible, the size of the rect grows.
|
||||||
|
zoomRect.size.height = frame.size.height / scale
|
||||||
|
zoomRect.size.width = frame.size.width / scale
|
||||||
|
|
||||||
|
// choose an origin so as to get the right center.
|
||||||
|
zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0)
|
||||||
|
zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0)
|
||||||
|
|
||||||
|
return zoomRect
|
||||||
|
}
|
||||||
|
|
||||||
|
open func refresh() {
|
||||||
|
if let image = zoomView?.image {
|
||||||
|
display(image: image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open func resize() {
|
||||||
|
self.configureImageForSize(self.imageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ImageScrollView: UIScrollViewDelegate {
|
||||||
|
|
||||||
|
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
imageScrollViewDelegate?.scrollViewDidScroll?(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||||
|
imageScrollViewDelegate?.scrollViewWillBeginDragging?(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
||||||
|
imageScrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||||
|
imageScrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
imageScrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
imageScrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||||
|
imageScrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
|
||||||
|
imageScrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||||
|
imageScrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 11.0, *)
|
||||||
|
public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
|
||||||
|
imageScrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||||
|
return zoomView
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||||
|
adjustFrameToCenter()
|
||||||
|
imageScrollViewDelegate?.scrollViewDidZoom?(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
110
Multiplatform/iOS/Article/ImageTransition.swift
Normal file
110
Multiplatform/iOS/Article/ImageTransition.swift
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
//
|
||||||
|
// ImageTransition.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
|
|
||||||
|
private weak var webViewController: WebViewController?
|
||||||
|
private let duration = 0.4
|
||||||
|
var presenting = true
|
||||||
|
var originFrame: CGRect!
|
||||||
|
var maskFrame: CGRect!
|
||||||
|
var originImage: UIImage!
|
||||||
|
|
||||||
|
init(controller: WebViewController) {
|
||||||
|
self.webViewController = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
if presenting {
|
||||||
|
animateTransitionPresenting(using: transitionContext)
|
||||||
|
} else {
|
||||||
|
animateTransitionReturning(using: transitionContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func animateTransitionPresenting(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
|
||||||
|
let imageView = UIImageView(image: originImage)
|
||||||
|
imageView.frame = originFrame
|
||||||
|
|
||||||
|
let fromView = transitionContext.view(forKey: .from)!
|
||||||
|
fromView.removeFromSuperview()
|
||||||
|
|
||||||
|
transitionContext.containerView.backgroundColor = .systemBackground
|
||||||
|
transitionContext.containerView.addSubview(imageView)
|
||||||
|
|
||||||
|
webViewController?.hideClickedImage()
|
||||||
|
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: duration,
|
||||||
|
delay:0.0,
|
||||||
|
usingSpringWithDamping: 0.8,
|
||||||
|
initialSpringVelocity: 0.2,
|
||||||
|
animations: {
|
||||||
|
let imageController = transitionContext.viewController(forKey: .to) as! ImageViewController
|
||||||
|
imageView.frame = imageController.zoomedFrame
|
||||||
|
}, completion: { _ in
|
||||||
|
imageView.removeFromSuperview()
|
||||||
|
let toView = transitionContext.view(forKey: .to)!
|
||||||
|
transitionContext.containerView.addSubview(toView)
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func animateTransitionReturning(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
let imageController = transitionContext.viewController(forKey: .from) as! ImageViewController
|
||||||
|
let imageView = UIImageView(image: originImage)
|
||||||
|
imageView.frame = imageController.zoomedFrame
|
||||||
|
|
||||||
|
let fromView = transitionContext.view(forKey: .from)!
|
||||||
|
let windowFrame = fromView.window!.frame
|
||||||
|
fromView.removeFromSuperview()
|
||||||
|
|
||||||
|
let toView = transitionContext.view(forKey: .to)!
|
||||||
|
transitionContext.containerView.addSubview(toView)
|
||||||
|
|
||||||
|
let maskingView = UIView()
|
||||||
|
|
||||||
|
let fullMaskFrame = CGRect(x: windowFrame.minX, y: maskFrame.minY, width: windowFrame.width, height: maskFrame.height)
|
||||||
|
let path = UIBezierPath(rect: fullMaskFrame)
|
||||||
|
let maskLayer = CAShapeLayer()
|
||||||
|
maskLayer.path = path.cgPath
|
||||||
|
maskingView.layer.mask = maskLayer
|
||||||
|
|
||||||
|
maskingView.addSubview(imageView)
|
||||||
|
transitionContext.containerView.addSubview(maskingView)
|
||||||
|
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: duration,
|
||||||
|
delay:0.0,
|
||||||
|
usingSpringWithDamping: 0.8,
|
||||||
|
initialSpringVelocity: 0.2,
|
||||||
|
animations: {
|
||||||
|
imageView.frame = self.originFrame
|
||||||
|
}, completion: { _ in
|
||||||
|
if let controller = self.webViewController {
|
||||||
|
controller.showClickedImage() {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
imageView.removeFromSuperview()
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imageView.removeFromSuperview()
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
92
Multiplatform/iOS/Article/ImageViewController.swift
Normal file
92
Multiplatform/iOS/Article/ImageViewController.swift
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
//
|
||||||
|
// ImageViewController.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ImageViewController: UIViewController {
|
||||||
|
|
||||||
|
@IBOutlet weak var closeButton: UIButton!
|
||||||
|
@IBOutlet weak var shareButton: UIButton!
|
||||||
|
@IBOutlet weak var imageScrollView: ImageScrollView!
|
||||||
|
@IBOutlet weak var titleLabel: UILabel!
|
||||||
|
@IBOutlet weak var titleBackground: UIVisualEffectView!
|
||||||
|
@IBOutlet weak var titleLeading: NSLayoutConstraint!
|
||||||
|
@IBOutlet weak var titleTrailing: NSLayoutConstraint!
|
||||||
|
|
||||||
|
var image: UIImage!
|
||||||
|
var imageTitle: String?
|
||||||
|
var zoomedFrame: CGRect {
|
||||||
|
return imageScrollView.zoomedFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
closeButton.imageView?.contentMode = .scaleAspectFit
|
||||||
|
closeButton.accessibilityLabel = NSLocalizedString("Close", comment: "Close")
|
||||||
|
shareButton.accessibilityLabel = NSLocalizedString("Share", comment: "Share")
|
||||||
|
|
||||||
|
imageScrollView.setup()
|
||||||
|
imageScrollView.imageScrollViewDelegate = self
|
||||||
|
imageScrollView.imageContentMode = .aspectFit
|
||||||
|
imageScrollView.initialOffset = .center
|
||||||
|
imageScrollView.display(image: image)
|
||||||
|
|
||||||
|
titleLabel.text = imageTitle ?? ""
|
||||||
|
layoutTitleLabel()
|
||||||
|
|
||||||
|
guard imageTitle != "" else {
|
||||||
|
titleBackground.removeFromSuperview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
titleBackground.layer.cornerRadius = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
super.viewWillTransition(to: size, with: coordinator)
|
||||||
|
coordinator.animate(alongsideTransition: { [weak self] context in
|
||||||
|
self?.imageScrollView.resize()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBAction func share(_ sender: Any) {
|
||||||
|
guard let image = image else { return }
|
||||||
|
let activityViewController = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||||
|
activityViewController.popoverPresentationController?.sourceView = shareButton
|
||||||
|
activityViewController.popoverPresentationController?.sourceRect = shareButton.bounds
|
||||||
|
present(activityViewController, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBAction func done(_ sender: Any) {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutTitleLabel(){
|
||||||
|
let width = view.frame.width
|
||||||
|
let multiplier = UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(0.1) : CGFloat(0.04)
|
||||||
|
titleLeading.constant += width * multiplier
|
||||||
|
titleTrailing.constant -= width * multiplier
|
||||||
|
titleLabel.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: ImageScrollViewDelegate
|
||||||
|
|
||||||
|
extension ImageViewController: ImageScrollViewDelegate {
|
||||||
|
|
||||||
|
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView) {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView) {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
75
Multiplatform/iOS/Article/PreloadedWebView.swift
Normal file
75
Multiplatform/iOS/Article/PreloadedWebView.swift
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
//
|
||||||
|
// PreloadedWebView.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
class PreloadedWebView: WKWebView {
|
||||||
|
|
||||||
|
private var isReady: Bool = false
|
||||||
|
private var readyCompletion: ((PreloadedWebView) -> Void)?
|
||||||
|
|
||||||
|
init(articleIconSchemeHandler: ArticleIconSchemeHandler) {
|
||||||
|
let preferences = WKPreferences()
|
||||||
|
preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||||
|
preferences.javaScriptEnabled = true
|
||||||
|
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
configuration.preferences = preferences
|
||||||
|
configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
|
||||||
|
configuration.allowsInlineMediaPlayback = true
|
||||||
|
configuration.mediaTypesRequiringUserActionForPlayback = .audio
|
||||||
|
configuration.setURLSchemeHandler(articleIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme)
|
||||||
|
|
||||||
|
super.init(frame: .zero, configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func preload() {
|
||||||
|
navigationDelegate = self
|
||||||
|
loadFileURL(ArticleRenderer.blank.url, allowingReadAccessTo: ArticleRenderer.blank.baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ready(completion: @escaping (PreloadedWebView) -> Void) {
|
||||||
|
if isReady {
|
||||||
|
completeRequest(completion: completion)
|
||||||
|
} else {
|
||||||
|
readyCompletion = completion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: WKScriptMessageHandler
|
||||||
|
|
||||||
|
extension PreloadedWebView: WKNavigationDelegate {
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
isReady = true
|
||||||
|
if let completion = readyCompletion {
|
||||||
|
completeRequest(completion: completion)
|
||||||
|
readyCompletion = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private extension PreloadedWebView {
|
||||||
|
|
||||||
|
func completeRequest(completion: @escaping (PreloadedWebView) -> Void) {
|
||||||
|
isReady = false
|
||||||
|
navigationDelegate = nil
|
||||||
|
completion(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
772
Multiplatform/iOS/Article/WebViewController.swift
Normal file
772
Multiplatform/iOS/Article/WebViewController.swift
Normal file
@ -0,0 +1,772 @@
|
|||||||
|
//
|
||||||
|
// WebViewController.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
import RSCore
|
||||||
|
import Account
|
||||||
|
import Articles
|
||||||
|
import SafariServices
|
||||||
|
import MessageUI
|
||||||
|
|
||||||
|
protocol WebViewControllerDelegate: class {
|
||||||
|
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebViewController: UIViewController {
|
||||||
|
|
||||||
|
private struct MessageName {
|
||||||
|
static let imageWasClicked = "imageWasClicked"
|
||||||
|
static let imageWasShown = "imageWasShown"
|
||||||
|
static let showFeedInspector = "showFeedInspector"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var topShowBarsView: UIView!
|
||||||
|
private var bottomShowBarsView: UIView!
|
||||||
|
private var topShowBarsViewConstraint: NSLayoutConstraint!
|
||||||
|
private var bottomShowBarsViewConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
|
private var webView: PreloadedWebView? {
|
||||||
|
guard view.subviews.count > 0 else { return nil }
|
||||||
|
return view.subviews[0] as? PreloadedWebView
|
||||||
|
}
|
||||||
|
|
||||||
|
// private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self)
|
||||||
|
private var isFullScreenAvailable: Bool {
|
||||||
|
return AppDefaults.shared.articleFullscreenAvailable && traitCollection.userInterfaceIdiom == .phone // && coordinator.isRootSplitCollapsed
|
||||||
|
}
|
||||||
|
private lazy var transition = ImageTransition(controller: self)
|
||||||
|
private var clickedImageCompletion: (() -> Void)?
|
||||||
|
|
||||||
|
private var articleExtractor: ArticleExtractor? = nil
|
||||||
|
var extractedArticle: ExtractedArticle? {
|
||||||
|
didSet {
|
||||||
|
windowScrollY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var isShowingExtractedArticle = false
|
||||||
|
|
||||||
|
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
|
||||||
|
didSet {
|
||||||
|
delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var articleModel: ArticleModel?
|
||||||
|
weak var delegate: WebViewControllerDelegate?
|
||||||
|
|
||||||
|
private(set) var article: Article?
|
||||||
|
|
||||||
|
let scrollPositionQueue = CoalescingQueue(name: "Article Scroll Position", interval: 0.3, maxInterval: 0.3)
|
||||||
|
var windowScrollY = 0
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
|
||||||
|
|
||||||
|
// Configure the tap zones
|
||||||
|
// configureTopShowBarsView()
|
||||||
|
// configureBottomShowBarsView()
|
||||||
|
|
||||||
|
loadWebView()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Notifications
|
||||||
|
|
||||||
|
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
|
||||||
|
reloadArticleImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func avatarDidBecomeAvailable(_ note: Notification) {
|
||||||
|
reloadArticleImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func faviconDidBecomeAvailable(_ note: Notification) {
|
||||||
|
reloadArticleImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Actions
|
||||||
|
|
||||||
|
// @objc func showBars(_ sender: Any) {
|
||||||
|
// showBars()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// MARK: API
|
||||||
|
|
||||||
|
func setArticle(_ article: Article?, updateView: Bool = true) {
|
||||||
|
stopArticleExtractor()
|
||||||
|
|
||||||
|
if article != self.article {
|
||||||
|
self.article = article
|
||||||
|
if updateView {
|
||||||
|
if article?.webFeed?.isArticleExtractorAlwaysOn ?? false {
|
||||||
|
startArticleExtractor()
|
||||||
|
}
|
||||||
|
windowScrollY = 0
|
||||||
|
loadWebView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func focus() {
|
||||||
|
webView?.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
func canScrollDown() -> Bool {
|
||||||
|
guard let webView = webView else { return false }
|
||||||
|
return webView.scrollView.contentOffset.y < finalScrollPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollPageDown() {
|
||||||
|
guard let webView = webView else { return }
|
||||||
|
|
||||||
|
let overlap = 2 * UIFont.systemFont(ofSize: UIFont.systemFontSize).lineHeight * UIScreen.main.scale
|
||||||
|
let scrollToY: CGFloat = {
|
||||||
|
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height - overlap
|
||||||
|
let final = finalScrollPosition()
|
||||||
|
return fullScroll < final ? fullScroll : final
|
||||||
|
}()
|
||||||
|
|
||||||
|
let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView)
|
||||||
|
let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY)
|
||||||
|
webView.scrollView.setContentOffset(scrollToPoint, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideClickedImage() {
|
||||||
|
webView?.evaluateJavaScript("hideClickedImage();")
|
||||||
|
}
|
||||||
|
|
||||||
|
func showClickedImage(completion: @escaping () -> Void) {
|
||||||
|
clickedImageCompletion = completion
|
||||||
|
webView?.evaluateJavaScript("showClickedImage();")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullReload() {
|
||||||
|
loadWebView(replaceExistingWebView: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// func showBars() {
|
||||||
|
// AppDefaults.shared.articleFullscreenEnabled = false
|
||||||
|
// coordinator.showStatusBar()
|
||||||
|
// topShowBarsViewConstraint?.constant = 0
|
||||||
|
// bottomShowBarsViewConstraint?.constant = 0
|
||||||
|
// navigationController?.setNavigationBarHidden(false, animated: true)
|
||||||
|
// navigationController?.setToolbarHidden(false, animated: true)
|
||||||
|
// configureContextMenuInteraction()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func hideBars() {
|
||||||
|
// if isFullScreenAvailable {
|
||||||
|
// AppDefaults.shared.articleFullscreenEnabled = true
|
||||||
|
// coordinator.hideStatusBar()
|
||||||
|
// topShowBarsViewConstraint?.constant = -44.0
|
||||||
|
// bottomShowBarsViewConstraint?.constant = 44.0
|
||||||
|
// navigationController?.setNavigationBarHidden(true, animated: true)
|
||||||
|
// navigationController?.setToolbarHidden(true, animated: true)
|
||||||
|
// configureContextMenuInteraction()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
func toggleArticleExtractor() {
|
||||||
|
|
||||||
|
guard let article = article else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard articleExtractor?.state != .processing else {
|
||||||
|
stopArticleExtractor()
|
||||||
|
loadWebView()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isShowingExtractedArticle else {
|
||||||
|
isShowingExtractedArticle = false
|
||||||
|
loadWebView()
|
||||||
|
articleExtractorButtonState = .off
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let articleExtractor = articleExtractor {
|
||||||
|
if article.preferredLink == articleExtractor.articleLink {
|
||||||
|
isShowingExtractedArticle = true
|
||||||
|
loadWebView()
|
||||||
|
articleExtractorButtonState = .on
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startArticleExtractor()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopArticleExtractorIfProcessing() {
|
||||||
|
if articleExtractor?.state == .processing {
|
||||||
|
stopArticleExtractor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopWebViewActivity() {
|
||||||
|
if let webView = webView {
|
||||||
|
stopMediaPlayback(webView)
|
||||||
|
cancelImageLoad(webView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: ArticleExtractorDelegate
|
||||||
|
|
||||||
|
extension WebViewController: ArticleExtractorDelegate {
|
||||||
|
|
||||||
|
func articleExtractionDidFail(with: Error) {
|
||||||
|
stopArticleExtractor()
|
||||||
|
articleExtractorButtonState = .error
|
||||||
|
loadWebView()
|
||||||
|
}
|
||||||
|
|
||||||
|
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
|
||||||
|
if articleExtractor?.state != .cancelled {
|
||||||
|
self.extractedArticle = extractedArticle
|
||||||
|
isShowingExtractedArticle = true
|
||||||
|
loadWebView()
|
||||||
|
articleExtractorButtonState = .on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UIContextMenuInteractionDelegate
|
||||||
|
|
||||||
|
//extension WebViewController: UIContextMenuInteractionDelegate {
|
||||||
|
// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
//
|
||||||
|
// return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in
|
||||||
|
// guard let self = self else { return nil }
|
||||||
|
// var actions = [UIAction]()
|
||||||
|
//
|
||||||
|
// if let action = self.prevArticleAction() {
|
||||||
|
// actions.append(action)
|
||||||
|
// }
|
||||||
|
// if let action = self.nextArticleAction() {
|
||||||
|
// actions.append(action)
|
||||||
|
// }
|
||||||
|
// if let action = self.toggleReadAction() {
|
||||||
|
// actions.append(action)
|
||||||
|
// }
|
||||||
|
// actions.append(self.toggleStarredAction())
|
||||||
|
// if let action = self.nextUnreadArticleAction() {
|
||||||
|
// actions.append(action)
|
||||||
|
// }
|
||||||
|
// actions.append(self.toggleArticleExtractorAction())
|
||||||
|
// actions.append(self.shareAction())
|
||||||
|
//
|
||||||
|
// return UIMenu(title: "", children: actions)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
|
// coordinator.showBrowserForCurrentArticle()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//}
|
||||||
|
|
||||||
|
// MARK: WKNavigationDelegate
|
||||||
|
|
||||||
|
extension WebViewController: WKNavigationDelegate {
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
for (index, view) in view.subviews.enumerated() {
|
||||||
|
if index != 0, let oldWebView = view as? PreloadedWebView {
|
||||||
|
oldWebView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
|
||||||
|
if navigationAction.navigationType == .linkActivated {
|
||||||
|
guard let url = navigationAction.request.url else {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
if components?.scheme == "http" || components?.scheme == "https" {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
|
||||||
|
// If the resource cannot be opened with an installed app, present the web view.
|
||||||
|
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
guard didOpen == false else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let vc = SFSafariViewController(url: url)
|
||||||
|
self.present(vc, animated: true)
|
||||||
|
}
|
||||||
|
} else if components?.scheme == "mailto" {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
|
||||||
|
guard let emailAddress = url.emailAddress else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if MFMailComposeViewController.canSendMail() {
|
||||||
|
let mailComposeViewController = MFMailComposeViewController()
|
||||||
|
mailComposeViewController.setToRecipients([emailAddress])
|
||||||
|
mailComposeViewController.mailComposeDelegate = self
|
||||||
|
self.present(mailComposeViewController, animated: true, completion: {})
|
||||||
|
} else {
|
||||||
|
let alert = UIAlertController(title: NSLocalizedString("Error", comment: "Error"), message: NSLocalizedString("This device cannot send emails.", comment: "This device cannot send emails."), preferredStyle: .alert)
|
||||||
|
alert.addAction(.init(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .cancel, handler: nil))
|
||||||
|
self.present(alert, animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
} else if components?.scheme == "tel" {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
|
||||||
|
if UIApplication.shared.canOpenURL(url) {
|
||||||
|
UIApplication.shared.open(url, options: [.universalLinksOnly : false], completionHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
|
fullReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: WKUIDelegate
|
||||||
|
|
||||||
|
extension WebViewController: WKUIDelegate {
|
||||||
|
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
|
// We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the
|
||||||
|
// link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get
|
||||||
|
// the link from the elementInfo above and transition to SFSafariViewController instead of launching
|
||||||
|
// Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_(ツ)_/¯
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: WKScriptMessageHandler
|
||||||
|
|
||||||
|
extension WebViewController: WKScriptMessageHandler {
|
||||||
|
|
||||||
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
switch message.name {
|
||||||
|
case MessageName.imageWasShown:
|
||||||
|
clickedImageCompletion?()
|
||||||
|
case MessageName.imageWasClicked:
|
||||||
|
imageWasClicked(body: message.body as? String)
|
||||||
|
case MessageName.showFeedInspector:
|
||||||
|
if let webFeed = article?.webFeed {
|
||||||
|
// coordinator.showFeedInspector(for: webFeed)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UIViewControllerTransitioningDelegate
|
||||||
|
|
||||||
|
extension WebViewController: UIViewControllerTransitioningDelegate {
|
||||||
|
|
||||||
|
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
transition.presenting = true
|
||||||
|
return transition
|
||||||
|
}
|
||||||
|
|
||||||
|
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
transition.presenting = false
|
||||||
|
return transition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK:
|
||||||
|
|
||||||
|
extension WebViewController: UIScrollViewDelegate {
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
scrollPositionQueue.add(self, #selector(scrollPositionDidChange))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func scrollPositionDidChange() {
|
||||||
|
webView?.evaluateJavaScript("window.scrollY") { (scrollY, error) in
|
||||||
|
guard error == nil else { return }
|
||||||
|
let javascriptScrollY = scrollY as? Int ?? 0
|
||||||
|
// I don't know why this value gets returned sometimes, but it is in error
|
||||||
|
guard javascriptScrollY != 33554432 else { return }
|
||||||
|
self.windowScrollY = javascriptScrollY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: MFMailComposeViewControllerDelegate
|
||||||
|
extension WebViewController: MFMailComposeViewControllerDelegate {
|
||||||
|
|
||||||
|
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
|
||||||
|
self.dismiss(animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: JSON
|
||||||
|
|
||||||
|
private struct ImageClickMessage: Codable {
|
||||||
|
let x: Float
|
||||||
|
let y: Float
|
||||||
|
let width: Float
|
||||||
|
let height: Float
|
||||||
|
let imageTitle: String?
|
||||||
|
let imageURL: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private extension WebViewController {
|
||||||
|
|
||||||
|
func loadWebView(replaceExistingWebView: Bool = false) {
|
||||||
|
guard isViewLoaded else { return }
|
||||||
|
|
||||||
|
if !replaceExistingWebView, let webView = webView {
|
||||||
|
self.renderPage(webView)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
articleModel?.webViewProvider?.dequeueWebView() { webView in
|
||||||
|
|
||||||
|
// Add the webview
|
||||||
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.view.insertSubview(webView, at: 0)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
|
||||||
|
self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
|
||||||
|
self.view.topAnchor.constraint(equalTo: webView.topAnchor),
|
||||||
|
self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
// UISplitViewController reports the wrong size to WKWebView which can cause horizontal
|
||||||
|
// rubberbanding on the iPad. This interferes with our UIPageViewController preventing
|
||||||
|
// us from easily swiping between WKWebViews. This hack fixes that.
|
||||||
|
webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0)
|
||||||
|
|
||||||
|
webView.scrollView.setZoomScale(1.0, animated: false)
|
||||||
|
|
||||||
|
self.view.setNeedsLayout()
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
// Configure the webview
|
||||||
|
webView.navigationDelegate = self
|
||||||
|
webView.uiDelegate = self
|
||||||
|
webView.scrollView.delegate = self
|
||||||
|
// self.configureContextMenuInteraction()
|
||||||
|
|
||||||
|
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
|
||||||
|
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
|
||||||
|
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector)
|
||||||
|
|
||||||
|
self.renderPage(webView)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderPage(_ webView: PreloadedWebView?) {
|
||||||
|
guard let webView = webView else { return }
|
||||||
|
|
||||||
|
let style = ArticleStylesManager.shared.currentStyle
|
||||||
|
let rendering: ArticleRenderer.Rendering
|
||||||
|
|
||||||
|
if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
|
||||||
|
rendering = ArticleRenderer.loadingHTML(style: style)
|
||||||
|
} else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = article {
|
||||||
|
rendering = ArticleRenderer.articleHTML(article: article, style: style)
|
||||||
|
} else if let article = article, let extractedArticle = extractedArticle {
|
||||||
|
if isShowingExtractedArticle {
|
||||||
|
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style)
|
||||||
|
} else {
|
||||||
|
rendering = ArticleRenderer.articleHTML(article: article, style: style)
|
||||||
|
}
|
||||||
|
} else if let article = article {
|
||||||
|
rendering = ArticleRenderer.articleHTML(article: article, style: style)
|
||||||
|
} else {
|
||||||
|
rendering = ArticleRenderer.noSelectionHTML(style: style)
|
||||||
|
}
|
||||||
|
|
||||||
|
let substitutions = [
|
||||||
|
"title": rendering.title,
|
||||||
|
"baseURL": rendering.baseURL,
|
||||||
|
"style": rendering.style,
|
||||||
|
"body": rendering.html,
|
||||||
|
"windowScrollY": String(windowScrollY)
|
||||||
|
]
|
||||||
|
|
||||||
|
let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
|
||||||
|
webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalScrollPosition() -> CGFloat {
|
||||||
|
guard let webView = webView else { return 0 }
|
||||||
|
return webView.scrollView.contentSize.height - webView.scrollView.bounds.height + webView.scrollView.safeAreaInsets.bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
func startArticleExtractor() {
|
||||||
|
if let link = article?.preferredLink, let extractor = ArticleExtractor(link) {
|
||||||
|
extractor.delegate = self
|
||||||
|
extractor.process()
|
||||||
|
articleExtractor = extractor
|
||||||
|
articleExtractorButtonState = .animated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopArticleExtractor() {
|
||||||
|
articleExtractor?.cancel()
|
||||||
|
articleExtractor = nil
|
||||||
|
isShowingExtractedArticle = false
|
||||||
|
articleExtractorButtonState = .off
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadArticleImage() {
|
||||||
|
guard let article = article else { return }
|
||||||
|
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = ArticleRenderer.imageIconScheme
|
||||||
|
components.path = article.articleID
|
||||||
|
|
||||||
|
if let imageSrc = components.string {
|
||||||
|
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageWasClicked(body: String?) {
|
||||||
|
guard let webView = webView,
|
||||||
|
let body = body,
|
||||||
|
let data = body.data(using: .utf8),
|
||||||
|
let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data),
|
||||||
|
let range = clickMessage.imageURL.range(of: ";base64,")
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound))
|
||||||
|
if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) {
|
||||||
|
|
||||||
|
let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top
|
||||||
|
let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height))
|
||||||
|
transition.originFrame = webView.convert(rect, to: nil)
|
||||||
|
|
||||||
|
if navigationController?.navigationBar.isHidden ?? false {
|
||||||
|
transition.maskFrame = webView.convert(webView.frame, to: nil)
|
||||||
|
} else {
|
||||||
|
transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
transition.originImage = image
|
||||||
|
|
||||||
|
// coordinator.showFullScreenImage(image: image, imageTitle: clickMessage.imageTitle, transitioningDelegate: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopMediaPlayback(_ webView: WKWebView) {
|
||||||
|
webView.evaluateJavaScript("stopMediaPlayback();")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelImageLoad(_ webView: WKWebView) {
|
||||||
|
webView.evaluateJavaScript("cancelImageLoad();")
|
||||||
|
}
|
||||||
|
|
||||||
|
// func configureTopShowBarsView() {
|
||||||
|
// topShowBarsView = UIView()
|
||||||
|
// topShowBarsView.backgroundColor = .clear
|
||||||
|
// topShowBarsView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
// view.addSubview(topShowBarsView)
|
||||||
|
//
|
||||||
|
// if AppDefaults.shared.articleFullscreenEnabled {
|
||||||
|
// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: -44.0)
|
||||||
|
// } else {
|
||||||
|
// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: 0.0)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// NSLayoutConstraint.activate([
|
||||||
|
// topShowBarsViewConstraint,
|
||||||
|
// view.leadingAnchor.constraint(equalTo: topShowBarsView.leadingAnchor),
|
||||||
|
// view.trailingAnchor.constraint(equalTo: topShowBarsView.trailingAnchor),
|
||||||
|
// topShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
|
||||||
|
// ])
|
||||||
|
// topShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func configureBottomShowBarsView() {
|
||||||
|
// bottomShowBarsView = UIView()
|
||||||
|
// topShowBarsView.backgroundColor = .clear
|
||||||
|
// bottomShowBarsView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
// view.addSubview(bottomShowBarsView)
|
||||||
|
// if AppDefaults.shared.articleFullscreenEnabled {
|
||||||
|
// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 44.0)
|
||||||
|
// } else {
|
||||||
|
// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 0.0)
|
||||||
|
// }
|
||||||
|
// NSLayoutConstraint.activate([
|
||||||
|
// bottomShowBarsViewConstraint,
|
||||||
|
// view.leadingAnchor.constraint(equalTo: bottomShowBarsView.leadingAnchor),
|
||||||
|
// view.trailingAnchor.constraint(equalTo: bottomShowBarsView.trailingAnchor),
|
||||||
|
// bottomShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
|
||||||
|
// ])
|
||||||
|
// bottomShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func configureContextMenuInteraction() {
|
||||||
|
// if isFullScreenAvailable {
|
||||||
|
// if navigationController?.isNavigationBarHidden ?? false {
|
||||||
|
// webView?.addInteraction(contextMenuInteraction)
|
||||||
|
// } else {
|
||||||
|
// webView?.removeInteraction(contextMenuInteraction)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func contextMenuPreviewProvider() -> UIViewController {
|
||||||
|
// let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self)
|
||||||
|
// previewProvider.article = article
|
||||||
|
// return previewProvider
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func prevArticleAction() -> UIAction? {
|
||||||
|
// guard coordinator.isPrevArticleAvailable else { return nil }
|
||||||
|
// let title = NSLocalizedString("Previous Article", comment: "Previous Article")
|
||||||
|
// return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in
|
||||||
|
// self?.coordinator.selectPrevArticle()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func nextArticleAction() -> UIAction? {
|
||||||
|
// guard coordinator.isNextArticleAvailable else { return nil }
|
||||||
|
// let title = NSLocalizedString("Next Article", comment: "Next Article")
|
||||||
|
// return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in
|
||||||
|
// self?.coordinator.selectNextArticle()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func toggleReadAction() -> UIAction? {
|
||||||
|
// guard let article = article, !article.status.read || article.isAvailableToMarkUnread else { return nil }
|
||||||
|
//
|
||||||
|
// let title = article.status.read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read")
|
||||||
|
// let readImage = article.status.read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage
|
||||||
|
// return UIAction(title: title, image: readImage) { [weak self] action in
|
||||||
|
// self?.coordinator.toggleReadForCurrentArticle()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func toggleStarredAction() -> UIAction {
|
||||||
|
// let starred = article?.status.starred ?? false
|
||||||
|
// let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred")
|
||||||
|
// let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage
|
||||||
|
// return UIAction(title: title, image: starredImage) { [weak self] action in
|
||||||
|
// self?.coordinator.toggleStarredForCurrentArticle()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func nextUnreadArticleAction() -> UIAction? {
|
||||||
|
// guard coordinator.isAnyUnreadAvailable else { return nil }
|
||||||
|
// let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article")
|
||||||
|
// return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in
|
||||||
|
// self?.coordinator.selectNextUnread()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func toggleArticleExtractorAction() -> UIAction {
|
||||||
|
// let extracted = articleExtractorButtonState == .on
|
||||||
|
// let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View")
|
||||||
|
// let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF
|
||||||
|
// return UIAction(title: title, image: extractorImage) { [weak self] action in
|
||||||
|
// self?.toggleArticleExtractor()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func shareAction() -> UIAction {
|
||||||
|
// let title = NSLocalizedString("Share", comment: "Share")
|
||||||
|
// return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in
|
||||||
|
// self?.showActivityDialog()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Find in Article
|
||||||
|
|
||||||
|
private struct FindInArticleOptions: Codable {
|
||||||
|
var text: String
|
||||||
|
var caseSensitive = false
|
||||||
|
var regex = false
|
||||||
|
}
|
||||||
|
|
||||||
|
internal struct FindInArticleState: Codable {
|
||||||
|
struct WebViewClientRect: Codable {
|
||||||
|
let x: Double
|
||||||
|
let y: Double
|
||||||
|
let width: Double
|
||||||
|
let height: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FindInArticleResult: Codable {
|
||||||
|
let rects: [WebViewClientRect]
|
||||||
|
let bounds: WebViewClientRect
|
||||||
|
let index: UInt
|
||||||
|
let matchGroups: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
let index: UInt?
|
||||||
|
let results: [FindInArticleResult]
|
||||||
|
let count: UInt
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WebViewController {
|
||||||
|
|
||||||
|
func searchText(_ searchText: String, completionHandler: @escaping (FindInArticleState) -> Void) {
|
||||||
|
guard let json = try? JSONEncoder().encode(FindInArticleOptions(text: searchText)) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let encoded = json.base64EncodedString()
|
||||||
|
|
||||||
|
webView?.evaluateJavaScript("updateFind(\"\(encoded)\")") {
|
||||||
|
(result, error) in
|
||||||
|
guard error == nil,
|
||||||
|
let b64 = result as? String,
|
||||||
|
let rawData = Data(base64Encoded: b64),
|
||||||
|
let findState = try? JSONDecoder().decode(FindInArticleState.self, from: rawData) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completionHandler(findState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func endSearch() {
|
||||||
|
webView?.evaluateJavaScript("endFind()")
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectNextSearchResult() {
|
||||||
|
webView?.evaluateJavaScript("selectNextResult()")
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPreviousSearchResult() {
|
||||||
|
webView?.evaluateJavaScript("selectPreviousResult()")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
133
Multiplatform/iOS/Article/WebViewProvider.swift
Normal file
133
Multiplatform/iOS/Article/WebViewProvider.swift
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// WebViewProvider.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import RSCore
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// WKWebView has an awful behavior of a flash to white on first load when in dark mode.
|
||||||
|
/// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle.
|
||||||
|
class WebViewProvider: NSObject {
|
||||||
|
|
||||||
|
private let articleIconSchemeHandler: ArticleIconSchemeHandler
|
||||||
|
private let operationQueue = MainThreadOperationQueue()
|
||||||
|
private var queue = NSMutableArray()
|
||||||
|
|
||||||
|
init(sceneModel: SceneModel) {
|
||||||
|
articleIconSchemeHandler = ArticleIconSchemeHandler(sceneModel: sceneModel)
|
||||||
|
super.init()
|
||||||
|
replenishQueueIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func flushQueue() {
|
||||||
|
operationQueue.add(WebViewProviderFlushQueueOperation(queue: queue))
|
||||||
|
}
|
||||||
|
|
||||||
|
func replenishQueueIfNeeded() {
|
||||||
|
operationQueue.add(WebViewProviderReplenishQueueOperation(queue: queue, articleIconSchemeHandler: articleIconSchemeHandler))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dequeueWebView(completion: @escaping (PreloadedWebView) -> ()) {
|
||||||
|
operationQueue.add(WebViewProviderDequeueOperation(queue: queue, articleIconSchemeHandler: articleIconSchemeHandler, completion: completion))
|
||||||
|
operationQueue.add(WebViewProviderReplenishQueueOperation(queue: queue, articleIconSchemeHandler: articleIconSchemeHandler))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebViewProviderFlushQueueOperation: MainThreadOperation {
|
||||||
|
|
||||||
|
// MainThreadOperation
|
||||||
|
public var isCanceled = false
|
||||||
|
public var id: Int?
|
||||||
|
public weak var operationDelegate: MainThreadOperationDelegate?
|
||||||
|
public var name: String? = "WebViewProviderFlushQueueOperation"
|
||||||
|
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||||
|
|
||||||
|
private var queue: NSMutableArray
|
||||||
|
|
||||||
|
init(queue: NSMutableArray) {
|
||||||
|
self.queue = queue
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() {
|
||||||
|
queue.removeAllObjects()
|
||||||
|
self.operationDelegate?.operationDidComplete(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebViewProviderReplenishQueueOperation: MainThreadOperation {
|
||||||
|
|
||||||
|
// MainThreadOperation
|
||||||
|
public var isCanceled = false
|
||||||
|
public var id: Int?
|
||||||
|
public weak var operationDelegate: MainThreadOperationDelegate?
|
||||||
|
public var name: String? = "WebViewProviderReplenishQueueOperation"
|
||||||
|
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||||
|
|
||||||
|
private let minimumQueueDepth = 3
|
||||||
|
|
||||||
|
private var queue: NSMutableArray
|
||||||
|
private var articleIconSchemeHandler: ArticleIconSchemeHandler
|
||||||
|
|
||||||
|
init(queue: NSMutableArray, articleIconSchemeHandler: ArticleIconSchemeHandler) {
|
||||||
|
self.queue = queue
|
||||||
|
self.articleIconSchemeHandler = articleIconSchemeHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() {
|
||||||
|
while queue.count < minimumQueueDepth {
|
||||||
|
let webView = PreloadedWebView(articleIconSchemeHandler: articleIconSchemeHandler)
|
||||||
|
queue.insert(webView, at: 0)
|
||||||
|
webView.preload()
|
||||||
|
}
|
||||||
|
self.operationDelegate?.operationDidComplete(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebViewProviderDequeueOperation: MainThreadOperation {
|
||||||
|
|
||||||
|
// MainThreadOperation
|
||||||
|
public var isCanceled = false
|
||||||
|
public var id: Int?
|
||||||
|
public weak var operationDelegate: MainThreadOperationDelegate?
|
||||||
|
public var name: String? = "WebViewProviderFlushQueueOperation"
|
||||||
|
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||||
|
|
||||||
|
private var queue: NSMutableArray
|
||||||
|
private var articleIconSchemeHandler: ArticleIconSchemeHandler
|
||||||
|
private var completion: (PreloadedWebView) -> ()
|
||||||
|
|
||||||
|
init(queue: NSMutableArray, articleIconSchemeHandler: ArticleIconSchemeHandler, completion: @escaping (PreloadedWebView) -> ()) {
|
||||||
|
self.queue = queue
|
||||||
|
self.articleIconSchemeHandler = articleIconSchemeHandler
|
||||||
|
self.completion = completion
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() {
|
||||||
|
if let webView = queue.lastObject as? PreloadedWebView {
|
||||||
|
queue.removeLastObject()
|
||||||
|
webView.ready { preloadedWebView in
|
||||||
|
self.completion(preloadedWebView)
|
||||||
|
self.operationDelegate?.operationDidComplete(self)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assertionFailure("Creating PreloadedWebView in \(#function); queue has run dry.")
|
||||||
|
|
||||||
|
let webView = PreloadedWebView(articleIconSchemeHandler: articleIconSchemeHandler)
|
||||||
|
webView.preload()
|
||||||
|
webView.ready { preloadedWebView in
|
||||||
|
self.completion(preloadedWebView)
|
||||||
|
self.operationDelegate?.operationDidComplete(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
25
Multiplatform/iOS/Article/WrapperScriptMessageHandler.swift
Normal file
25
Multiplatform/iOS/Article/WrapperScriptMessageHandler.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// WrapperScriptMessageHandler.swift
|
||||||
|
// Multiplatform iOS
|
||||||
|
//
|
||||||
|
// Created by Maurice Parker on 7/6/20.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
class WrapperScriptMessageHandler: NSObject, WKScriptMessageHandler {
|
||||||
|
|
||||||
|
// We need to wrap a message handler to prevent a circlular reference
|
||||||
|
private weak var handler: WKScriptMessageHandler?
|
||||||
|
|
||||||
|
init(_ handler: WKScriptMessageHandler) {
|
||||||
|
self.handler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
handler?.userContentController(userContentController, didReceive: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -7,15 +7,15 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Articles
|
||||||
|
|
||||||
struct ArticleView: View {
|
struct ArticleView: View {
|
||||||
|
|
||||||
|
var sceneModel: SceneModel
|
||||||
|
var articleModel: ArticleModel
|
||||||
|
var article: Article
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ArticleView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
ArticleView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -219,6 +219,16 @@
|
|||||||
5177470924B2F87600EB0F74 /* SidebarListStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470824B2F87600EB0F74 /* SidebarListStyleModifier.swift */; };
|
5177470924B2F87600EB0F74 /* SidebarListStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470824B2F87600EB0F74 /* SidebarListStyleModifier.swift */; };
|
||||||
5177470A24B2F87600EB0F74 /* SidebarListStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470824B2F87600EB0F74 /* SidebarListStyleModifier.swift */; };
|
5177470A24B2F87600EB0F74 /* SidebarListStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470824B2F87600EB0F74 /* SidebarListStyleModifier.swift */; };
|
||||||
5177470E24B2FF6F00EB0F74 /* ArticleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470D24B2FF6F00EB0F74 /* ArticleView.swift */; };
|
5177470E24B2FF6F00EB0F74 /* ArticleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470D24B2FF6F00EB0F74 /* ArticleView.swift */; };
|
||||||
|
5177471024B3029400EB0F74 /* ArticleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177470F24B3029400EB0F74 /* ArticleViewController.swift */; };
|
||||||
|
5177471224B37C5400EB0F74 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471124B37C5400EB0F74 /* WebViewController.swift */; };
|
||||||
|
5177471424B37D4000EB0F74 /* PreloadedWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471324B37D4000EB0F74 /* PreloadedWebView.swift */; };
|
||||||
|
5177471624B37D9700EB0F74 /* ArticleIconSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471524B37D9700EB0F74 /* ArticleIconSchemeHandler.swift */; };
|
||||||
|
5177471824B3812200EB0F74 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471724B3812200EB0F74 /* IconView.swift */; };
|
||||||
|
5177471A24B3863000EB0F74 /* WebViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471924B3863000EB0F74 /* WebViewProvider.swift */; };
|
||||||
|
5177471C24B387AC00EB0F74 /* ImageScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471B24B387AC00EB0F74 /* ImageScrollView.swift */; };
|
||||||
|
5177471E24B387E100EB0F74 /* ImageTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471D24B387E100EB0F74 /* ImageTransition.swift */; };
|
||||||
|
5177472024B3882600EB0F74 /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471F24B3882600EB0F74 /* ImageViewController.swift */; };
|
||||||
|
5177472224B38CAE00EB0F74 /* ArticleExtractorButtonState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177472124B38CAE00EB0F74 /* ArticleExtractorButtonState.swift */; };
|
||||||
5177475C24B39AD500EB0F74 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 5177475824B39AD400EB0F74 /* Credits.rtf */; };
|
5177475C24B39AD500EB0F74 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 5177475824B39AD400EB0F74 /* Credits.rtf */; };
|
||||||
5177475D24B39AD500EB0F74 /* Dedication.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 5177475924B39AD400EB0F74 /* Dedication.rtf */; };
|
5177475D24B39AD500EB0F74 /* Dedication.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 5177475924B39AD400EB0F74 /* Dedication.rtf */; };
|
||||||
5177475E24B39AD500EB0F74 /* Thanks.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 5177475A24B39AD500EB0F74 /* Thanks.rtf */; };
|
5177475E24B39AD500EB0F74 /* Thanks.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 5177475A24B39AD500EB0F74 /* Thanks.rtf */; };
|
||||||
@ -233,6 +243,17 @@
|
|||||||
517A757A24451C0700B553B9 /* OAuthSwift.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 517A755524451BD500B553B9 /* OAuthSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
517A757A24451C0700B553B9 /* OAuthSwift.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 517A755524451BD500B553B9 /* OAuthSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||||
517A757B24451C1500B553B9 /* OAuthSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 517A755324451BD500B553B9 /* OAuthSwift.framework */; };
|
517A757B24451C1500B553B9 /* OAuthSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 517A755324451BD500B553B9 /* OAuthSwift.framework */; };
|
||||||
517A757C24451C1500B553B9 /* OAuthSwift.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 517A755324451BD500B553B9 /* OAuthSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
517A757C24451C1500B553B9 /* OAuthSwift.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 517A755324451BD500B553B9 /* OAuthSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||||
|
517B2EBC24B3E62A001AC46C /* WrapperScriptMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517B2EBB24B3E62A001AC46C /* WrapperScriptMessageHandler.swift */; };
|
||||||
|
517B2EE224B3E8FE001AC46C /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EDE24B3E8FE001AC46C /* page.html */; };
|
||||||
|
517B2EE324B3E8FE001AC46C /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EDE24B3E8FE001AC46C /* page.html */; };
|
||||||
|
517B2EE424B3E8FE001AC46C /* blank.html in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EDF24B3E8FE001AC46C /* blank.html */; };
|
||||||
|
517B2EE524B3E8FE001AC46C /* blank.html in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EDF24B3E8FE001AC46C /* blank.html */; };
|
||||||
|
517B2EE624B3E8FE001AC46C /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EE024B3E8FE001AC46C /* styleSheet.css */; };
|
||||||
|
517B2EE724B3E8FE001AC46C /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EE024B3E8FE001AC46C /* styleSheet.css */; };
|
||||||
|
517B2EE824B3E8FE001AC46C /* main_multiplatform.js in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EE124B3E8FE001AC46C /* main_multiplatform.js */; };
|
||||||
|
517B2EE924B3E8FE001AC46C /* main_multiplatform.js in Resources */ = {isa = PBXBuildFile; fileRef = 517B2EE124B3E8FE001AC46C /* main_multiplatform.js */; };
|
||||||
|
517B2EEB24B40E09001AC46C /* TimelineTitleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517B2EEA24B40E09001AC46C /* TimelineTitleModifier.swift */; };
|
||||||
|
517B2EEC24B40E09001AC46C /* TimelineTitleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517B2EEA24B40E09001AC46C /* TimelineTitleModifier.swift */; };
|
||||||
5181C5AD24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5181C5AC24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift */; };
|
5181C5AD24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5181C5AC24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift */; };
|
||||||
5181C5AE24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5181C5AC24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift */; };
|
5181C5AE24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5181C5AC24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift */; };
|
||||||
5181C66224B0C326002E0F70 /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5181C66124B0C326002E0F70 /* SettingsModel.swift */; };
|
5181C66224B0C326002E0F70 /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5181C66124B0C326002E0F70 /* SettingsModel.swift */; };
|
||||||
@ -1872,6 +1893,16 @@
|
|||||||
5177470524B2910300EB0F74 /* ArticleToolbarModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleToolbarModifier.swift; sourceTree = "<group>"; };
|
5177470524B2910300EB0F74 /* ArticleToolbarModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleToolbarModifier.swift; sourceTree = "<group>"; };
|
||||||
5177470824B2F87600EB0F74 /* SidebarListStyleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListStyleModifier.swift; sourceTree = "<group>"; };
|
5177470824B2F87600EB0F74 /* SidebarListStyleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListStyleModifier.swift; sourceTree = "<group>"; };
|
||||||
5177470D24B2FF6F00EB0F74 /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = "<group>"; };
|
5177470D24B2FF6F00EB0F74 /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = "<group>"; };
|
||||||
|
5177470F24B3029400EB0F74 /* ArticleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleViewController.swift; sourceTree = "<group>"; };
|
||||||
|
5177471124B37C5400EB0F74 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
|
||||||
|
5177471324B37D4000EB0F74 /* PreloadedWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreloadedWebView.swift; sourceTree = "<group>"; };
|
||||||
|
5177471524B37D9700EB0F74 /* ArticleIconSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleIconSchemeHandler.swift; sourceTree = "<group>"; };
|
||||||
|
5177471724B3812200EB0F74 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = "<group>"; };
|
||||||
|
5177471924B3863000EB0F74 /* WebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewProvider.swift; sourceTree = "<group>"; };
|
||||||
|
5177471B24B387AC00EB0F74 /* ImageScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScrollView.swift; sourceTree = "<group>"; };
|
||||||
|
5177471D24B387E100EB0F74 /* ImageTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTransition.swift; sourceTree = "<group>"; };
|
||||||
|
5177471F24B3882600EB0F74 /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = "<group>"; };
|
||||||
|
5177472124B38CAE00EB0F74 /* ArticleExtractorButtonState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleExtractorButtonState.swift; sourceTree = "<group>"; };
|
||||||
5177475824B39AD400EB0F74 /* Credits.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = "<group>"; };
|
5177475824B39AD400EB0F74 /* Credits.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = "<group>"; };
|
||||||
5177475924B39AD400EB0F74 /* Dedication.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Dedication.rtf; sourceTree = "<group>"; };
|
5177475924B39AD400EB0F74 /* Dedication.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Dedication.rtf; sourceTree = "<group>"; };
|
||||||
5177475A24B39AD500EB0F74 /* Thanks.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Thanks.rtf; sourceTree = "<group>"; };
|
5177475A24B39AD500EB0F74 /* Thanks.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Thanks.rtf; sourceTree = "<group>"; };
|
||||||
@ -1881,6 +1912,12 @@
|
|||||||
5177476624B3BE3400EB0F74 /* SettingsAboutModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAboutModel.swift; sourceTree = "<group>"; };
|
5177476624B3BE3400EB0F74 /* SettingsAboutModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAboutModel.swift; sourceTree = "<group>"; };
|
||||||
517A745A2443665000B553B9 /* UIPageViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIPageViewController-Extensions.swift"; sourceTree = "<group>"; };
|
517A745A2443665000B553B9 /* UIPageViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIPageViewController-Extensions.swift"; sourceTree = "<group>"; };
|
||||||
517A754424451BD500B553B9 /* OAuthSwift.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = OAuthSwift.xcodeproj; path = submodules/OAuthSwift/OAuthSwift.xcodeproj; sourceTree = "<group>"; };
|
517A754424451BD500B553B9 /* OAuthSwift.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = OAuthSwift.xcodeproj; path = submodules/OAuthSwift/OAuthSwift.xcodeproj; sourceTree = "<group>"; };
|
||||||
|
517B2EBB24B3E62A001AC46C /* WrapperScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrapperScriptMessageHandler.swift; sourceTree = "<group>"; };
|
||||||
|
517B2EDE24B3E8FE001AC46C /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = "<group>"; };
|
||||||
|
517B2EDF24B3E8FE001AC46C /* blank.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = blank.html; sourceTree = "<group>"; };
|
||||||
|
517B2EE024B3E8FE001AC46C /* styleSheet.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = styleSheet.css; sourceTree = "<group>"; };
|
||||||
|
517B2EE124B3E8FE001AC46C /* main_multiplatform.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = main_multiplatform.js; sourceTree = "<group>"; };
|
||||||
|
517B2EEA24B40E09001AC46C /* TimelineTitleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTitleModifier.swift; sourceTree = "<group>"; };
|
||||||
5181C5AC24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredColorSchemeModifier.swift; sourceTree = "<group>"; };
|
5181C5AC24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredColorSchemeModifier.swift; sourceTree = "<group>"; };
|
||||||
5181C66124B0C326002E0F70 /* SettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModel.swift; sourceTree = "<group>"; };
|
5181C66124B0C326002E0F70 /* SettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModel.swift; sourceTree = "<group>"; };
|
||||||
5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicLabel.swift; sourceTree = "<group>"; };
|
5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicLabel.swift; sourceTree = "<group>"; };
|
||||||
@ -2424,9 +2461,7 @@
|
|||||||
172199EB24AB228E00A31D04 /* Settings */ = {
|
172199EB24AB228E00A31D04 /* Settings */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
65CBAD5924AE03C20006DD91 /* ColorPaletteContainerView.swift */,
|
|
||||||
65C2E40024B05D8A000AFDF6 /* FeedsSettingsModel.swift */,
|
65C2E40024B05D8A000AFDF6 /* FeedsSettingsModel.swift */,
|
||||||
17B223DB24AC24D2001E4592 /* TimelineLayoutView.swift */,
|
|
||||||
65CBAD5924AE03C20006DD91 /* ColorPaletteContainerView.swift */,
|
65CBAD5924AE03C20006DD91 /* ColorPaletteContainerView.swift */,
|
||||||
5181C66124B0C326002E0F70 /* SettingsModel.swift */,
|
5181C66124B0C326002E0F70 /* SettingsModel.swift */,
|
||||||
172199C824AB228900A31D04 /* SettingsView.swift */,
|
172199C824AB228900A31D04 /* SettingsView.swift */,
|
||||||
@ -2663,7 +2698,18 @@
|
|||||||
5177470B24B2FF2C00EB0F74 /* Article */ = {
|
5177470B24B2FF2C00EB0F74 /* Article */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
5177472124B38CAE00EB0F74 /* ArticleExtractorButtonState.swift */,
|
||||||
|
5177471524B37D9700EB0F74 /* ArticleIconSchemeHandler.swift */,
|
||||||
5177470D24B2FF6F00EB0F74 /* ArticleView.swift */,
|
5177470D24B2FF6F00EB0F74 /* ArticleView.swift */,
|
||||||
|
5177470F24B3029400EB0F74 /* ArticleViewController.swift */,
|
||||||
|
5177471724B3812200EB0F74 /* IconView.swift */,
|
||||||
|
5177471B24B387AC00EB0F74 /* ImageScrollView.swift */,
|
||||||
|
5177471D24B387E100EB0F74 /* ImageTransition.swift */,
|
||||||
|
5177471F24B3882600EB0F74 /* ImageViewController.swift */,
|
||||||
|
5177471324B37D4000EB0F74 /* PreloadedWebView.swift */,
|
||||||
|
5177471124B37C5400EB0F74 /* WebViewController.swift */,
|
||||||
|
5177471924B3863000EB0F74 /* WebViewProvider.swift */,
|
||||||
|
517B2EBB24B3E62A001AC46C /* WrapperScriptMessageHandler.swift */,
|
||||||
);
|
);
|
||||||
path = Article;
|
path = Article;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -2762,6 +2808,7 @@
|
|||||||
514E6C0124AD29A300AC6F6E /* TimelineItemStatusView.swift */,
|
514E6C0124AD29A300AC6F6E /* TimelineItemStatusView.swift */,
|
||||||
514E6BD924ACEA0400AC6F6E /* TimelineItemView.swift */,
|
514E6BD924ACEA0400AC6F6E /* TimelineItemView.swift */,
|
||||||
51919FF024AB864A00541E64 /* TimelineModel.swift */,
|
51919FF024AB864A00541E64 /* TimelineModel.swift */,
|
||||||
|
517B2EEA24B40E09001AC46C /* TimelineTitleModifier.swift */,
|
||||||
51919FF624AB8B7700541E64 /* TimelineView.swift */,
|
51919FF624AB8B7700541E64 /* TimelineView.swift */,
|
||||||
);
|
);
|
||||||
path = Timeline;
|
path = Timeline;
|
||||||
@ -2779,6 +2826,10 @@
|
|||||||
51A576B924AE617B00078888 /* Article */ = {
|
51A576B924AE617B00078888 /* Article */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
517B2EE024B3E8FE001AC46C /* styleSheet.css */,
|
||||||
|
517B2EDF24B3E8FE001AC46C /* blank.html */,
|
||||||
|
517B2EDE24B3E8FE001AC46C /* page.html */,
|
||||||
|
517B2EE124B3E8FE001AC46C /* main_multiplatform.js */,
|
||||||
51A5769524AE617200078888 /* ArticleContainerView.swift */,
|
51A5769524AE617200078888 /* ArticleContainerView.swift */,
|
||||||
51A576BA24AE621800078888 /* ArticleModel.swift */,
|
51A576BA24AE621800078888 /* ArticleModel.swift */,
|
||||||
5177470524B2910300EB0F74 /* ArticleToolbarModifier.swift */,
|
5177470524B2910300EB0F74 /* ArticleToolbarModifier.swift */,
|
||||||
@ -4534,14 +4585,18 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
5177475E24B39AD500EB0F74 /* Thanks.rtf in Resources */,
|
5177475E24B39AD500EB0F74 /* Thanks.rtf in Resources */,
|
||||||
|
517B2EE224B3E8FE001AC46C /* page.html in Resources */,
|
||||||
51E4995F24A875F300B667CB /* shared.css in Resources */,
|
51E4995F24A875F300B667CB /* shared.css in Resources */,
|
||||||
|
517B2EE824B3E8FE001AC46C /* main_multiplatform.js in Resources */,
|
||||||
51E4997324A8784300B667CB /* DefaultFeeds.opml in Resources */,
|
51E4997324A8784300B667CB /* DefaultFeeds.opml in Resources */,
|
||||||
51C0516224A77DF800194D5E /* Assets.xcassets in Resources */,
|
51C0516224A77DF800194D5E /* Assets.xcassets in Resources */,
|
||||||
5177475F24B39AD500EB0F74 /* About.rtf in Resources */,
|
5177475F24B39AD500EB0F74 /* About.rtf in Resources */,
|
||||||
51E4996024A875F300B667CB /* template.html in Resources */,
|
51E4996024A875F300B667CB /* template.html in Resources */,
|
||||||
5177475D24B39AD500EB0F74 /* Dedication.rtf in Resources */,
|
5177475D24B39AD500EB0F74 /* Dedication.rtf in Resources */,
|
||||||
51E4995E24A875F300B667CB /* newsfoot.js in Resources */,
|
51E4995E24A875F300B667CB /* newsfoot.js in Resources */,
|
||||||
|
517B2EE424B3E8FE001AC46C /* blank.html in Resources */,
|
||||||
5177475C24B39AD500EB0F74 /* Credits.rtf in Resources */,
|
5177475C24B39AD500EB0F74 /* Credits.rtf in Resources */,
|
||||||
|
517B2EE624B3E8FE001AC46C /* styleSheet.css in Resources */,
|
||||||
51E4995D24A875F300B667CB /* main.js in Resources */,
|
51E4995D24A875F300B667CB /* main.js in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@ -4550,12 +4605,16 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
517B2EE324B3E8FE001AC46C /* page.html in Resources */,
|
||||||
51E4996424A875F400B667CB /* shared.css in Resources */,
|
51E4996424A875F400B667CB /* shared.css in Resources */,
|
||||||
51E4997524A8784400B667CB /* DefaultFeeds.opml in Resources */,
|
51E4997524A8784400B667CB /* DefaultFeeds.opml in Resources */,
|
||||||
51C0516324A77DF800194D5E /* Assets.xcassets in Resources */,
|
51C0516324A77DF800194D5E /* Assets.xcassets in Resources */,
|
||||||
51E4996524A875F400B667CB /* template.html in Resources */,
|
51E4996524A875F400B667CB /* template.html in Resources */,
|
||||||
|
517B2EE924B3E8FE001AC46C /* main_multiplatform.js in Resources */,
|
||||||
|
517B2EE524B3E8FE001AC46C /* blank.html in Resources */,
|
||||||
51E4996324A875F400B667CB /* newsfoot.js in Resources */,
|
51E4996324A875F400B667CB /* newsfoot.js in Resources */,
|
||||||
51E4996224A875F400B667CB /* main.js in Resources */,
|
51E4996224A875F400B667CB /* main.js in Resources */,
|
||||||
|
517B2EE724B3E8FE001AC46C /* styleSheet.css in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -4880,6 +4939,7 @@
|
|||||||
51E4995924A873F900B667CB /* ErrorHandler.swift in Sources */,
|
51E4995924A873F900B667CB /* ErrorHandler.swift in Sources */,
|
||||||
51392D1B24AC19A000BE0D35 /* SidebarExpandedContainers.swift in Sources */,
|
51392D1B24AC19A000BE0D35 /* SidebarExpandedContainers.swift in Sources */,
|
||||||
51E4992F24A8676400B667CB /* ArticleArray.swift in Sources */,
|
51E4992F24A8676400B667CB /* ArticleArray.swift in Sources */,
|
||||||
|
5177471C24B387AC00EB0F74 /* ImageScrollView.swift in Sources */,
|
||||||
51E498F824A8085D00B667CB /* UnreadFeed.swift in Sources */,
|
51E498F824A8085D00B667CB /* UnreadFeed.swift in Sources */,
|
||||||
FF64D0E724AF53EE0084080A /* RefreshProgressModel.swift in Sources */,
|
FF64D0E724AF53EE0084080A /* RefreshProgressModel.swift in Sources */,
|
||||||
51E4996A24A8762D00B667CB /* ExtractedArticle.swift in Sources */,
|
51E4996A24A8762D00B667CB /* ExtractedArticle.swift in Sources */,
|
||||||
@ -4893,11 +4953,13 @@
|
|||||||
5177470624B2910300EB0F74 /* ArticleToolbarModifier.swift in Sources */,
|
5177470624B2910300EB0F74 /* ArticleToolbarModifier.swift in Sources */,
|
||||||
51919FAF24AA8EFA00541E64 /* SidebarItemView.swift in Sources */,
|
51919FAF24AA8EFA00541E64 /* SidebarItemView.swift in Sources */,
|
||||||
514E6BDA24ACEA0400AC6F6E /* TimelineItemView.swift in Sources */,
|
514E6BDA24ACEA0400AC6F6E /* TimelineItemView.swift in Sources */,
|
||||||
|
5177471624B37D9700EB0F74 /* ArticleIconSchemeHandler.swift in Sources */,
|
||||||
FA80C11724B0728000974098 /* AddFolderView.swift in Sources */,
|
FA80C11724B0728000974098 /* AddFolderView.swift in Sources */,
|
||||||
51E4990D24A808C500B667CB /* RSHTMLMetadata+Extension.swift in Sources */,
|
51E4990D24A808C500B667CB /* RSHTMLMetadata+Extension.swift in Sources */,
|
||||||
51919FF424AB869C00541E64 /* TimelineItem.swift in Sources */,
|
51919FF424AB869C00541E64 /* TimelineItem.swift in Sources */,
|
||||||
514E6C0224AD29A300AC6F6E /* TimelineItemStatusView.swift in Sources */,
|
514E6C0224AD29A300AC6F6E /* TimelineItemStatusView.swift in Sources */,
|
||||||
51E49A0024A91FC100B667CB /* SidebarContainerView.swift in Sources */,
|
51E49A0024A91FC100B667CB /* SidebarContainerView.swift in Sources */,
|
||||||
|
5177471824B3812200EB0F74 /* IconView.swift in Sources */,
|
||||||
51E4995C24A875F300B667CB /* ArticleRenderer.swift in Sources */,
|
51E4995C24A875F300B667CB /* ArticleRenderer.swift in Sources */,
|
||||||
51E4992324A8095700B667CB /* URL-Extensions.swift in Sources */,
|
51E4992324A8095700B667CB /* URL-Extensions.swift in Sources */,
|
||||||
51E4993624A867E800B667CB /* UserInfoKey.swift in Sources */,
|
51E4993624A867E800B667CB /* UserInfoKey.swift in Sources */,
|
||||||
@ -4920,6 +4982,7 @@
|
|||||||
51E4991B24A8091000B667CB /* IconImage.swift in Sources */,
|
51E4991B24A8091000B667CB /* IconImage.swift in Sources */,
|
||||||
51E4995424A8734D00B667CB /* ExtensionPointIdentifer.swift in Sources */,
|
51E4995424A8734D00B667CB /* ExtensionPointIdentifer.swift in Sources */,
|
||||||
51E4996924A8760C00B667CB /* ArticleStylesManager.swift in Sources */,
|
51E4996924A8760C00B667CB /* ArticleStylesManager.swift in Sources */,
|
||||||
|
5177471E24B387E100EB0F74 /* ImageTransition.swift in Sources */,
|
||||||
51E498F324A8085D00B667CB /* PseudoFeed.swift in Sources */,
|
51E498F324A8085D00B667CB /* PseudoFeed.swift in Sources */,
|
||||||
51A5769624AE617200078888 /* ArticleContainerView.swift in Sources */,
|
51A5769624AE617200078888 /* ArticleContainerView.swift in Sources */,
|
||||||
5181C5AD24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift in Sources */,
|
5181C5AD24AF89B1002E0F70 /* PreferredColorSchemeModifier.swift in Sources */,
|
||||||
@ -4929,8 +4992,10 @@
|
|||||||
51E4991D24A8092100B667CB /* NSAttributedString+NetNewsWire.swift in Sources */,
|
51E4991D24A8092100B667CB /* NSAttributedString+NetNewsWire.swift in Sources */,
|
||||||
FF64D0E924AF53EE0084080A /* RefreshProgressView.swift in Sources */,
|
FF64D0E924AF53EE0084080A /* RefreshProgressView.swift in Sources */,
|
||||||
51E499FD24A9137600B667CB /* SidebarModel.swift in Sources */,
|
51E499FD24A9137600B667CB /* SidebarModel.swift in Sources */,
|
||||||
|
517B2EEB24B40E09001AC46C /* TimelineTitleModifier.swift in Sources */,
|
||||||
5181C66224B0C326002E0F70 /* SettingsModel.swift in Sources */,
|
5181C66224B0C326002E0F70 /* SettingsModel.swift in Sources */,
|
||||||
51E4995324A8734D00B667CB /* RedditFeedProvider-Extensions.swift in Sources */,
|
51E4995324A8734D00B667CB /* RedditFeedProvider-Extensions.swift in Sources */,
|
||||||
|
5177471024B3029400EB0F74 /* ArticleViewController.swift in Sources */,
|
||||||
172199C924AB228900A31D04 /* SettingsView.swift in Sources */,
|
172199C924AB228900A31D04 /* SettingsView.swift in Sources */,
|
||||||
17D232A824AFF10A0005F075 /* AddWebFeedModel.swift in Sources */,
|
17D232A824AFF10A0005F075 /* AddWebFeedModel.swift in Sources */,
|
||||||
51E4994224A8713C00B667CB /* ArticleStatusSyncTimer.swift in Sources */,
|
51E4994224A8713C00B667CB /* ArticleStatusSyncTimer.swift in Sources */,
|
||||||
@ -4939,6 +5004,7 @@
|
|||||||
51919FB624AABCA100541E64 /* IconImageView.swift in Sources */,
|
51919FB624AABCA100541E64 /* IconImageView.swift in Sources */,
|
||||||
51919FA624AA64B000541E64 /* SidebarView.swift in Sources */,
|
51919FA624AA64B000541E64 /* SidebarView.swift in Sources */,
|
||||||
51E4997024A8764C00B667CB /* ActivityManager.swift in Sources */,
|
51E4997024A8764C00B667CB /* ActivityManager.swift in Sources */,
|
||||||
|
5177471224B37C5400EB0F74 /* WebViewController.swift in Sources */,
|
||||||
51E4990F24A808CC00B667CB /* HTMLMetadataDownloader.swift in Sources */,
|
51E4990F24A808CC00B667CB /* HTMLMetadataDownloader.swift in Sources */,
|
||||||
5177476524B3BDAE00EB0F74 /* AttributedStringView.swift in Sources */,
|
5177476524B3BDAE00EB0F74 /* AttributedStringView.swift in Sources */,
|
||||||
51E4993124A8676400B667CB /* FetchRequestOperation.swift in Sources */,
|
51E4993124A8676400B667CB /* FetchRequestOperation.swift in Sources */,
|
||||||
@ -4952,7 +5018,10 @@
|
|||||||
51E4991E24A8094300B667CB /* RSImage-AppIcons.swift in Sources */,
|
51E4991E24A8094300B667CB /* RSImage-AppIcons.swift in Sources */,
|
||||||
51E499D824A912C200B667CB /* SceneModel.swift in Sources */,
|
51E499D824A912C200B667CB /* SceneModel.swift in Sources */,
|
||||||
5177470E24B2FF6F00EB0F74 /* ArticleView.swift in Sources */,
|
5177470E24B2FF6F00EB0F74 /* ArticleView.swift in Sources */,
|
||||||
|
5177471424B37D4000EB0F74 /* PreloadedWebView.swift in Sources */,
|
||||||
|
517B2EBC24B3E62A001AC46C /* WrapperScriptMessageHandler.swift in Sources */,
|
||||||
51919FB324AAB97900541E64 /* FeedIconImageLoader.swift in Sources */,
|
51919FB324AAB97900541E64 /* FeedIconImageLoader.swift in Sources */,
|
||||||
|
5177472024B3882600EB0F74 /* ImageViewController.swift in Sources */,
|
||||||
51919FB324AAB97900541E64 /* FeedIconImageLoader.swift in Sources */,
|
51919FB324AAB97900541E64 /* FeedIconImageLoader.swift in Sources */,
|
||||||
51E4991324A808FB00B667CB /* AddWebFeedDefaultContainer.swift in Sources */,
|
51E4991324A808FB00B667CB /* AddWebFeedDefaultContainer.swift in Sources */,
|
||||||
51E4993C24A8709900B667CB /* AppDelegate.swift in Sources */,
|
51E4993C24A8709900B667CB /* AppDelegate.swift in Sources */,
|
||||||
@ -4961,6 +5030,8 @@
|
|||||||
17930ED424AF10EE00A9BA52 /* AddWebFeedView.swift in Sources */,
|
17930ED424AF10EE00A9BA52 /* AddWebFeedView.swift in Sources */,
|
||||||
51E4995124A8734D00B667CB /* ExtensionPointManager.swift in Sources */,
|
51E4995124A8734D00B667CB /* ExtensionPointManager.swift in Sources */,
|
||||||
51E4990C24A808C500B667CB /* AuthorAvatarDownloader.swift in Sources */,
|
51E4990C24A808C500B667CB /* AuthorAvatarDownloader.swift in Sources */,
|
||||||
|
5177472224B38CAE00EB0F74 /* ArticleExtractorButtonState.swift in Sources */,
|
||||||
|
5177471A24B3863000EB0F74 /* WebViewProvider.swift in Sources */,
|
||||||
51E4992124A8095000B667CB /* RSImage-Extensions.swift in Sources */,
|
51E4992124A8095000B667CB /* RSImage-Extensions.swift in Sources */,
|
||||||
51E4990324A808BB00B667CB /* FaviconDownloader.swift in Sources */,
|
51E4990324A808BB00B667CB /* FaviconDownloader.swift in Sources */,
|
||||||
172199ED24AB2E0100A31D04 /* SafariView.swift in Sources */,
|
172199ED24AB2E0100A31D04 /* SafariView.swift in Sources */,
|
||||||
@ -5030,6 +5101,7 @@
|
|||||||
51E4995B24A875D500B667CB /* ArticlePasteboardWriter.swift in Sources */,
|
51E4995B24A875D500B667CB /* ArticlePasteboardWriter.swift in Sources */,
|
||||||
51E4993424A867E700B667CB /* UserInfoKey.swift in Sources */,
|
51E4993424A867E700B667CB /* UserInfoKey.swift in Sources */,
|
||||||
1776E88F24AC5F8A00E78166 /* AppDefaults.swift in Sources */,
|
1776E88F24AC5F8A00E78166 /* AppDefaults.swift in Sources */,
|
||||||
|
517B2EEC24B40E09001AC46C /* TimelineTitleModifier.swift in Sources */,
|
||||||
1729529724AA1CD000D65E66 /* MacPreferencesView.swift in Sources */,
|
1729529724AA1CD000D65E66 /* MacPreferencesView.swift in Sources */,
|
||||||
51E4994C24A8734C00B667CB /* RedditFeedProvider-Extensions.swift in Sources */,
|
51E4994C24A8734C00B667CB /* RedditFeedProvider-Extensions.swift in Sources */,
|
||||||
1729529324AA1CAA00D65E66 /* AccountsPreferencesView.swift in Sources */,
|
1729529324AA1CAA00D65E66 /* AccountsPreferencesView.swift in Sources */,
|
||||||
|
@ -12,7 +12,7 @@ import WebKit
|
|||||||
|
|
||||||
/// WKWebView has an awful behavior of a flash to white on first load when in dark mode.
|
/// WKWebView has an awful behavior of a flash to white on first load when in dark mode.
|
||||||
/// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle.
|
/// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle.
|
||||||
class WebViewProvider: NSObject {
|
class : NSObject {
|
||||||
|
|
||||||
private let articleIconSchemeHandler: ArticleIconSchemeHandler
|
private let articleIconSchemeHandler: ArticleIconSchemeHandler
|
||||||
private let operationQueue = MainThreadOperationQueue()
|
private let operationQueue = MainThreadOperationQueue()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user