diff --git a/Multiplatform/Shared/Article/ArticleModel.swift b/Multiplatform/Shared/Article/ArticleModel.swift index 8dc100836..f87c7897e 100644 --- a/Multiplatform/Shared/Article/ArticleModel.swift +++ b/Multiplatform/Shared/Article/ArticleModel.swift @@ -14,9 +14,7 @@ import Account import Articles protocol ArticleModelDelegate: class { - #if os(iOS) - var webViewProvider: WebViewProvider? { get } - #endif + var articleModelWebViewProvider: WebViewProvider? { get } func findPrevArticle(_: ArticleModel, article: Article) -> Article? func findNextArticle(_: ArticleModel, article: Article) -> Article? func selectArticle(_: ArticleModel, article: Article) @@ -26,11 +24,9 @@ class ArticleModel: ObservableObject { weak var delegate: ArticleModelDelegate? - #if os(iOS) var webViewProvider: WebViewProvider? { - return delegate?.webViewProvider + return delegate?.articleModelWebViewProvider } - #endif // MARK: API diff --git a/Multiplatform/Shared/Article/WebViewProvider.swift b/Multiplatform/Shared/Article/WebViewProvider.swift index ca30a4a57..3376cc623 100644 --- a/Multiplatform/Shared/Article/WebViewProvider.swift +++ b/Multiplatform/Shared/Article/WebViewProvider.swift @@ -18,8 +18,8 @@ class WebViewProvider: NSObject { private let operationQueue = MainThreadOperationQueue() private var queue = NSMutableArray() - init(sceneModel: SceneModel) { - articleIconSchemeHandler = ArticleIconSchemeHandler(sceneModel: sceneModel) + init(articleIconSchemeHandler: ArticleIconSchemeHandler) { + self.articleIconSchemeHandler = articleIconSchemeHandler super.init() replenishQueueIfNeeded() } diff --git a/Multiplatform/Shared/SceneModel.swift b/Multiplatform/Shared/SceneModel.swift index e93f7b7c8..0a5ac5bd6 100644 --- a/Multiplatform/Shared/SceneModel.swift +++ b/Multiplatform/Shared/SceneModel.swift @@ -19,7 +19,8 @@ final class SceneModel: ObservableObject { var articleModel: ArticleModel? private var refreshProgressModel: RefreshProgressModel? = nil - private var _webViewProvider: WebViewProvider? = nil + private var articleIconSchemeHandler: ArticleIconSchemeHandler? = nil + private var webViewProvider: WebViewProvider? = nil // MARK: API @@ -27,7 +28,8 @@ final class SceneModel: ObservableObject { self.refreshProgressModel = RefreshProgressModel() self.refreshProgressModel!.$state.assign(to: self.$refreshProgressState) - self._webViewProvider = WebViewProvider(sceneModel: self) + self.articleIconSchemeHandler = ArticleIconSchemeHandler(sceneModel: self) + self.webViewProvider = WebViewProvider(articleIconSchemeHandler: self.articleIconSchemeHandler!) } func articleFor(_ articleID: String) -> Article? { @@ -60,8 +62,8 @@ extension SceneModel: TimelineModelDelegate { extension SceneModel: ArticleModelDelegate { - var webViewProvider: WebViewProvider? { - return _webViewProvider + var articleModelWebViewProvider: WebViewProvider? { + return webViewProvider } func findPrevArticle(_: ArticleModel, article: Article) -> Article? { diff --git a/Multiplatform/macOS/Article/ArticleView.swift b/Multiplatform/macOS/Article/ArticleView.swift index dbf33d14c..7ee5e401b 100644 --- a/Multiplatform/macOS/Article/ArticleView.swift +++ b/Multiplatform/macOS/Article/ArticleView.swift @@ -1,21 +1,37 @@ // // ArticleView.swift -// NetNewsWire +// Multiplatform macOS // -// Created by Maurice Parker on 7/2/20. +// Created by Maurice Parker on 7/8/20. // Copyright © 2020 Ranchero Software. All rights reserved. // import SwiftUI import Articles -struct ArticleView: View { - +final class ArticleView: NSViewControllerRepresentable { + var sceneModel: SceneModel var articleModel: ArticleModel var article: Article - - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } + + init(sceneModel: SceneModel, articleModel: ArticleModel, article: Article) { + self.sceneModel = sceneModel + self.articleModel = articleModel + self.article = article + sceneModel.articleModel = articleModel + articleModel.delegate = sceneModel + } + + func makeNSViewController(context: Context) -> WebViewController { + let controller = WebViewController() + controller.articleModel = articleModel + controller.article = article + return controller + } + + func updateNSViewController(_ uiViewController: WebViewController, context: Context) { + + } + } diff --git a/Multiplatform/macOS/Article/WebViewController.swift b/Multiplatform/macOS/Article/WebViewController.swift new file mode 100644 index 000000000..920433386 --- /dev/null +++ b/Multiplatform/macOS/Article/WebViewController.swift @@ -0,0 +1,330 @@ +// +// WebViewController.swift +// Multiplatform macOS +// +// Created by Maurice Parker on 7/8/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import AppKit +import Articles + +import AppKit +import RSCore +import Articles + +extension Notification.Name { + static let appleColorPreferencesChangedNotification = Notification.Name("AppleColorPreferencesChangedNotification") + static let appleInterfaceThemeChangedNotification = Notification.Name("AppleInterfaceThemeChangedNotification") +} + +protocol WebViewControllerDelegate: class { + func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState) +} + +class WebViewController: NSViewController { + + private struct MessageName { + static let imageWasClicked = "imageWasClicked" + static let imageWasShown = "imageWasShown" + static let showFeedInspector = "showFeedInspector" + } + + private var webView: PreloadedWebView? + + private var articleExtractor: ArticleExtractor? = nil + var extractedArticle: ExtractedArticle? + var isShowingExtractedArticle = false + + var articleExtractorButtonState: ArticleExtractorButtonState = .off { + didSet { + delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState) + } + } + + var articleModel: ArticleModel? + weak var delegate: WebViewControllerDelegate? + + var article: Article? + + override func loadView() { + view = NSView() + } + + 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) + DistributedNotificationCenter.default().addObserver(self, selector: #selector(appleColorPreferencesChanged(_:)), name: .appleColorPreferencesChangedNotification, object: nil) + DistributedNotificationCenter.default().addObserver(self, selector: #selector(appleInterfaceThemeChanged(_:)), name: .appleInterfaceThemeChangedNotification, object: nil) + + loadWebView() + } + + // MARK: Notifications + + @objc func webFeedIconDidBecomeAvailable(_ note: Notification) { + reloadArticleImage() + } + + @objc func avatarDidBecomeAvailable(_ note: Notification) { + reloadArticleImage() + } + + @objc func faviconDidBecomeAvailable(_ note: Notification) { + reloadArticleImage() + } + + @objc func appleColorPreferencesChanged(_ note: Notification) { + loadWebView() + } + + @objc func appleInterfaceThemeChanged(_ note: Notification) { + loadWebView() + } + + // MARK: API + + func focus() { + webView?.becomeFirstResponder() + } + + func canScrollDown(_ completion: @escaping (Bool) -> Void) { + fetchScrollInfo { (scrollInfo) in + completion(scrollInfo?.canScrollDown ?? false) + } + } + + override func scrollPageDown(_ sender: Any?) { + webView?.scrollPageDown(sender) + } + + 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) + } + } + +} + +// 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: WKScriptMessageHandler + +extension WebViewController: WKScriptMessageHandler { + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + switch message.name { + case MessageName.imageWasShown: + return + case MessageName.imageWasClicked: + return + case MessageName.showFeedInspector: + return + default: + return + } + } + +} + +// MARK: Private + +private extension WebViewController { + + func loadWebView() { + if let webView = webView { + self.renderPage(webView) + return + } + + articleModel?.webViewProvider?.dequeueWebView() { webView in + + // Add the webview + webView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(webView) + 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) + ]) + + 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) { + 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 + ] + + let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions) + webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL) + + } + + func fetchScrollInfo(_ completion: @escaping (ScrollInfo?) -> Void) { + guard let webView = webView else { + completion(nil) + return + } + + let javascriptString = "var x = {contentHeight: document.body.scrollHeight, offsetY: window.pageYOffset}; x" + + webView.evaluateJavaScript(javascriptString) { (info, error) in + guard let info = info as? [String: Any] else { + completion(nil) + return + } + guard let contentHeight = info["contentHeight"] as? CGFloat, let offsetY = info["offsetY"] as? CGFloat else { + completion(nil) + return + } + + let scrollInfo = ScrollInfo(contentHeight: contentHeight, viewHeight: webView.frame.height, offsetY: offsetY) + completion(scrollInfo) + } + } + + 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 stopMediaPlayback(_ webView: WKWebView) { + webView.evaluateJavaScript("stopMediaPlayback();") + } + +} + +// MARK: - ScrollInfo + +private struct ScrollInfo { + + let contentHeight: CGFloat + let viewHeight: CGFloat + let offsetY: CGFloat + let canScrollDown: Bool + let canScrollUp: Bool + + init(contentHeight: CGFloat, viewHeight: CGFloat, offsetY: CGFloat) { + self.contentHeight = contentHeight + self.viewHeight = viewHeight + self.offsetY = offsetY + + self.canScrollDown = viewHeight + offsetY < contentHeight + self.canScrollUp = offsetY > 0.1 + } + +} diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 7a9f5f791..94e5be88a 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -312,7 +312,6 @@ 51A5769724AE617200078888 /* ArticleContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A5769524AE617200078888 /* ArticleContainerView.swift */; }; 51A576BB24AE621800078888 /* ArticleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A576BA24AE621800078888 /* ArticleModel.swift */; }; 51A576BC24AE621800078888 /* ArticleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A576BA24AE621800078888 /* ArticleModel.swift */; }; - 51A576BF24AE637400078888 /* ArticleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A576BD24AE637400078888 /* ArticleView.swift */; }; 51A66685238075AE00CB272D /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; 51A9A5E12380C4FE0033AADF /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C45255226507D200C03939 /* AppDefaults.swift */; }; 51A9A5E42380C8880033AADF /* ShareFolderPickerAccountCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */; }; @@ -332,6 +331,8 @@ 51B54A6724B549FE0014348B /* ArticleIconSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177471524B37D9700EB0F74 /* ArticleIconSchemeHandler.swift */; }; 51B54A6924B54A490014348B /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B54A6824B54A490014348B /* IconView.swift */; }; 51B54AB324B5AC830014348B /* ArticleExtractorButtonState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177472124B38CAE00EB0F74 /* ArticleExtractorButtonState.swift */; }; + 51B54AB624B5B33C0014348B /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B54AB524B5B33C0014348B /* WebViewController.swift */; }; + 51B54ABC24B5BEF20014348B /* ArticleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B54ABB24B5BEF20014348B /* ArticleView.swift */; }; 51B5C87723F22B8200032075 /* ExtensionContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87623F22B8200032075 /* ExtensionContainers.swift */; }; 51B5C87B23F2317700032075 /* ExtensionFeedAddRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */; }; 51B5C87D23F2346200032075 /* ExtensionContainersFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */; }; @@ -1969,7 +1970,6 @@ 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedbinAccountViewController.swift; sourceTree = ""; }; 51A5769524AE617200078888 /* ArticleContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleContainerView.swift; sourceTree = ""; }; 51A576BA24AE621800078888 /* ArticleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleModel.swift; sourceTree = ""; }; - 51A576BD24AE637400078888 /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = ""; }; 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWebFeedDefaultContainer.swift; sourceTree = ""; }; 51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerAccountCell.xib; sourceTree = ""; }; 51A9A5E52380C8B20033AADF /* ShareFolderPickerFolderCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerFolderCell.xib; sourceTree = ""; }; @@ -1978,6 +1978,8 @@ 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoppableGestureRecognizerDelegate.swift; sourceTree = ""; }; 51AB8AB223B7F4C6008F147D /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 51B54A6824B54A490014348B /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; + 51B54AB524B5B33C0014348B /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; + 51B54ABB24B5BEF20014348B /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = ""; }; 51B5C87623F22B8200032075 /* ExtensionContainers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionContainers.swift; sourceTree = ""; }; 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFeedAddRequest.swift; sourceTree = ""; }; 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionContainersFile.swift; sourceTree = ""; }; @@ -2730,8 +2732,9 @@ 5177470C24B2FF3B00EB0F74 /* Article */ = { isa = PBXGroup; children = ( - 51A576BD24AE637400078888 /* ArticleView.swift */, + 51B54AB524B5B33C0014348B /* WebViewController.swift */, 51B54A6824B54A490014348B /* IconView.swift */, + 51B54ABB24B5BEF20014348B /* ArticleView.swift */, ); path = Article; sourceTree = ""; @@ -5104,6 +5107,7 @@ 51E498FA24A808BA00B667CB /* SingleFaviconDownloader.swift in Sources */, 51E4993F24A8713B00B667CB /* ArticleStatusSyncTimer.swift in Sources */, 51E4993724A8680E00B667CB /* Reachability.swift in Sources */, + 51B54AB624B5B33C0014348B /* WebViewController.swift in Sources */, 51E4994B24A8734C00B667CB /* SendToMicroBlogCommand.swift in Sources */, 51E4996F24A8764C00B667CB /* ActivityType.swift in Sources */, 51E4994E24A8734C00B667CB /* SendToMarsEditCommand.swift in Sources */, @@ -5188,6 +5192,7 @@ 51919FA724AA64B000541E64 /* SidebarView.swift in Sources */, 51E498FD24A808BA00B667CB /* ColorHash.swift in Sources */, 51E4991824A8090A00B667CB /* CacheCleaner.swift in Sources */, + 51B54ABC24B5BEF20014348B /* ArticleView.swift in Sources */, 51E498CD24A8085D00B667CB /* SearchTimelineFeedDelegate.swift in Sources */, 51E4996124A875F400B667CB /* ArticleRenderer.swift in Sources */, FA80C13F24B072AB00974098 /* AddFolderModel.swift in Sources */, @@ -5202,7 +5207,6 @@ 51E4992D24A8676300B667CB /* FetchRequestOperation.swift in Sources */, 51E4992424A8098400B667CB /* SmartFeedPasteboardWriter.swift in Sources */, 51E4991424A808FF00B667CB /* ArticleStringFormatter.swift in Sources */, - 51A576BF24AE637400078888 /* ArticleView.swift in Sources */, FF64D0EA24AF53EE0084080A /* RefreshProgressView.swift in Sources */, 51B54A6624B549CB0014348B /* PreloadedWebView.swift in Sources */, 51E4991024A808DE00B667CB /* SmallIconProvider.swift in Sources */,