diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index f5006eeb7..6c7005aa9 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -56,6 +56,8 @@ 513C5D0D232574DA003D4054 /* RSTree.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 84C37F9520DD8CFE00CA8CF5 /* RSTree.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 513C5D0E232574E4003D4054 /* SyncDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51554C01228B6EB50055115A /* SyncDatabase.framework */; }; 513C5D0F232574E4003D4054 /* SyncDatabase.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 51554C01228B6EB50055115A /* SyncDatabase.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5142192A23522B5500E07E2C /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5142192923522B5500E07E2C /* ImageViewController.swift */; }; + 514219372352510100E07E2C /* ImageScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514219362352510100E07E2C /* ImageScrollView.swift */; }; 5144EA2F2279FAB600D19003 /* AccountsDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA2E2279FAB600D19003 /* AccountsDetailViewController.swift */; }; 5144EA362279FC3D00D19003 /* AccountsAddLocal.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */; }; 5144EA382279FC6200D19003 /* AccountsAddLocalWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA372279FC6200D19003 /* AccountsAddLocalWindowController.swift */; }; @@ -750,6 +752,8 @@ 513C5CE8232571C2003D4054 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 513C5CEB232571C2003D4054 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 513C5CED232571C2003D4054 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 5142192923522B5500E07E2C /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; + 514219362352510100E07E2C /* ImageScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageScrollView.swift; sourceTree = ""; }; 5144EA2E2279FAB600D19003 /* AccountsDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsDetailViewController.swift; sourceTree = ""; }; 5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountsAddLocal.xib; sourceTree = ""; }; 5144EA372279FC6200D19003 /* AccountsAddLocalWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsAddLocalWindowController.swift; sourceTree = ""; }; @@ -1359,6 +1363,8 @@ 51C4527E2265092C00C03939 /* ArticleViewController.swift */, 517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */, 51102164233A7D6C0007A5F7 /* ArticleExtractorButton.swift */, + 5142192923522B5500E07E2C /* ImageViewController.swift */, + 514219362352510100E07E2C /* ImageScrollView.swift */, ); path = Article; sourceTree = ""; @@ -2842,6 +2848,7 @@ 51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */, 5183CCE5226F4DFA0010922C /* RefreshInterval.swift in Sources */, 51C4529D22650A1000C03939 /* FaviconURLFinder.swift in Sources */, + 5142192A23522B5500E07E2C /* ImageViewController.swift in Sources */, 51C45258226508CF00C03939 /* AppAssets.swift in Sources */, 51FA73A82332BE880090D516 /* ExtractedArticle.swift in Sources */, 51C4527C2265091600C03939 /* MasterTimelineDefaultCellLayout.swift in Sources */, @@ -2874,6 +2881,7 @@ 5183CCE9226F68D90010922C /* AccountRefreshTimer.swift in Sources */, 51C452882265093600C03939 /* AddFeedViewController.swift in Sources */, 51934CCE2310792F006127BE /* ActivityManager.swift in Sources */, + 514219372352510100E07E2C /* ImageScrollView.swift in Sources */, DF999FF722B5AEFA0064B687 /* SafariView.swift in Sources */, 51C4529B22650A1000C03939 /* FaviconDownloader.swift in Sources */, 84DEE56622C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */, diff --git a/Shared/Article Rendering/main.js b/Shared/Article Rendering/main.js index f2fd4d24e..98147e77a 100644 --- a/Shared/Article Rendering/main.js +++ b/Shared/Article Rendering/main.js @@ -14,6 +14,18 @@ function linkHover() { }); } +// Used to pop a resizable image view +function imageWasClicked(img) { + window.webkit.messageHandlers.imageWasClicked.postMessage(img.src); +} + +// Add the click listeners for images +function imageClicks() { + document.querySelectorAll("img").forEach(element => { + element.addEventListener("click", function() { imageWasClicked(this) }); + }); +} + // Here we are making iframes responsive. Particularly useful for inline Youtube videos. function wrapFrames() { document.querySelectorAll("iframe").forEach(element => { @@ -52,5 +64,6 @@ function render(data) { wrapFrames() stripStyles() linkHover() + imageClicks() inlineVideos() } diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index 3d7ae5b8f..2454a1f64 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -21,6 +21,10 @@ enum ArticleViewState: Equatable { } class ArticleViewController: UIViewController { + + private struct MessageName { + static let imageWasClicked = "imageWasClicked" + } @IBOutlet private weak var nextUnreadBarButtonItem: UIBarButtonItem! @IBOutlet private weak var prevArticleBarButtonItem: UIBarButtonItem! @@ -102,7 +106,10 @@ class ArticleViewController: UIViewController { self.webViewContainer.addChildAndPin(webView) webView.navigationDelegate = self webView.uiDelegate = self - + + webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked) + webView.configuration.userContentController.add(self, name: MessageName.imageWasClicked) + // Even though page.html should be loaded into this webview, we have to do it again // to work around this bug: http://www.openradar.me/22855188 webView.loadHTMLString(ArticleRenderer.page.html, baseURL: ArticleRenderer.page.baseURL) @@ -337,6 +344,20 @@ extension ArticleViewController: WKUIDelegate { } } +// MARK: WKScriptMessageHandler + +extension ArticleViewController: WKScriptMessageHandler { + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == MessageName.imageWasClicked, let link = message.body as? String, let url = URL(string: link) { + let imageVC = UIStoryboard.main.instantiateController(ofType: ImageViewController.self) + imageVC.url = url + imageVC.modalPresentationStyle = .fullScreen + present(imageVC, animated: true) + } + } +} + // MARK: Private private extension ArticleViewController { diff --git a/iOS/Article/ImageScrollView.swift b/iOS/Article/ImageScrollView.swift new file mode 100644 index 000000000..df44c8e68 --- /dev/null +++ b/iOS/Article/ImageScrollView.swift @@ -0,0 +1,372 @@ +// +// ImageScrollView.swift +// Beauty +// +// Created by Nguyen Cong Huy on 1/19/16. +// Copyright © 2016 Nguyen Cong Huy. All rights reserved. +// + +import UIKit + +@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate { + func imageScrollViewDidChangeOrientation(imageScrollView: ImageScrollView) + 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 + + 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() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func initialize() { + showsVerticalScrollIndicator = false + showsHorizontalScrollIndicator = false + bouncesZoom = true + decelerationRate = UIScrollView.DecelerationRate.fast + delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(ImageScrollView.changeOrientationNotification), name: UIDevice.orientationDidChangeNotification, object: nil) + } + + @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) + } + + 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) + } + } + + // MARK: - Actions + + @objc func changeOrientationNotification() { + // A weird bug that frames are not update right after orientation changed. Need delay a little bit with async. + DispatchQueue.main.async { + self.configureImageForSize(self.imageSize) + self.imageScrollViewDelegate?.imageScrollViewDidChangeOrientation(imageScrollView: self) + } + } +} + +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) { + 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) + } + +} diff --git a/iOS/Article/ImageViewController.swift b/iOS/Article/ImageViewController.swift new file mode 100644 index 000000000..14ac99fab --- /dev/null +++ b/iOS/Article/ImageViewController.swift @@ -0,0 +1,69 @@ +// +// ImageViewController.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 10/12/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import UIKit + +class ImageViewController: UIViewController { + + @IBOutlet weak var activityIndicatorView: UIActivityIndicatorView! + @IBOutlet weak var imageScrollView: ImageScrollView! + + private var dataTask: URLSessionDataTask? = nil + var url: URL! + + override func viewDidLoad() { + super.viewDidLoad() + + activityIndicatorView.isHidden = false + activityIndicatorView.startAnimating() + + imageScrollView.setup() + imageScrollView.imageScrollViewDelegate = self + imageScrollView.imageContentMode = .aspectFit + imageScrollView.initialOffset = .center + + dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + guard let self = self else { return } + if let data = data, let image = UIImage(data: data) { + + DispatchQueue.main.async { + self.activityIndicatorView.isHidden = true + self.activityIndicatorView.stopAnimating() + self.imageScrollView.display(image: image) + } + + } + + } + + dataTask!.resume() + } + + @IBAction func done(_ sender: Any) { + dismiss(animated: true) + } + +} + +// MARK: ImageScrollViewDelegate + +extension ImageViewController: ImageScrollViewDelegate { + + func imageScrollViewDidChangeOrientation(imageScrollView: ImageScrollView) { + } + + func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView) { + dismiss(animated: true) + } + + func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView) { + dismiss(animated: true) + } + + +} diff --git a/iOS/Base.lproj/Main.storyboard b/iOS/Base.lproj/Main.storyboard index 5aa9eebcd..5ffa845a0 100644 --- a/iOS/Base.lproj/Main.storyboard +++ b/iOS/Base.lproj/Main.storyboard @@ -1,8 +1,9 @@ - + - + + @@ -226,6 +227,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -233,8 +289,12 @@ + + + +