diff --git a/Cartfile b/Cartfile index 91354f8c..f3bd16a3 100755 --- a/Cartfile +++ b/Cartfile @@ -1 +1,2 @@ github "readium/r2-shared-swift" == 1.4.3 +github "scinfu/SwiftSoup" == 2.3.1 diff --git a/Cartfile.resolved b/Cartfile.resolved index 8727a2fa..a2b9bf36 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1 +1,2 @@ github "readium/r2-shared-swift" "1.4.3" +github "scinfu/SwiftSoup" "2.3.1" diff --git a/r2-navigator-swift/EPUB/EPUBFixedSpreadView.swift b/r2-navigator-swift/EPUB/EPUBFixedSpreadView.swift index 8e191068..564d402c 100644 --- a/r2-navigator-swift/EPUB/EPUBFixedSpreadView.swift +++ b/r2-navigator-swift/EPUB/EPUBFixedSpreadView.swift @@ -81,10 +81,9 @@ final class EPUBFixedSpreadView: EPUBSpreadView { """) } - override func pointFromTap(_ data: [String : Any]) -> CGPoint? { - guard let x = data["screenX"] as? Int, let y = data["screenY"] as? Int else { - return nil - } + override func pointFromTap(_ data: TapData) -> CGPoint? { + let x = data.screenX + let y = data.screenY return CGPoint( x: CGFloat(x) * scrollView.zoomScale - scrollView.contentOffset.x + webView.frame.minX, diff --git a/r2-navigator-swift/EPUB/EPUBNavigatorViewController.swift b/r2-navigator-swift/EPUB/EPUBNavigatorViewController.swift index fe551d2e..316bcf25 100644 --- a/r2-navigator-swift/EPUB/EPUBNavigatorViewController.swift +++ b/r2-navigator-swift/EPUB/EPUBNavigatorViewController.swift @@ -13,6 +13,7 @@ import UIKit import R2Shared import WebKit import SafariServices +import SwiftSoup public protocol EPUBNavigatorDelegate: VisualNavigatorDelegate { @@ -366,10 +367,80 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { delegate?.navigator(self, presentExternalURL: url) } - func spreadView(_ spreadView: EPUBSpreadView, didTapOnInternalLink href: String) { + func spreadView(_ spreadView: EPUBSpreadView, didTapOnInternalLink href: String, tapData: TapData?) { + + // Check to see if this was a noteref link and give delegate the opportunity to display it. + if + let tapData = tapData, + let interactive = tapData.interactiveElement, + let (note, referrer) = getNoteData(anchor: interactive, href: href), + let delegate = self.delegate + { + if !delegate.navigator( + self, + shouldNavigateToNoteAt: Link(href: href, type: "text/html"), + content: note, + referrer: referrer + ) { + return + } + } + go(to: Link(href: href)) } + /// Checks if the internal link is a noteref, and retrieves both the referring text of the link and the body of the note. + /// + /// Uses the navigation href from didTapOnInternalLink because it is normalized to a path within the book, + /// whereas the anchor tag may have just a hash fragment like `#abc123` which is hard to work with. + /// We do at least validate to ensure that the two hrefs match. + /// + /// Uses `#id` when retrieving the body of the note, not `aside#id` because it may be a `
`. + /// See https://idpf.github.io/epub-vocabs/structure/#footnotes + /// and http://kb.daisy.org/publishing/docs/html/epub-type.html#ex + func getNoteData(anchor: String, href: String) -> (String, String)? { + do { + let doc = try parse(anchor) + guard let link = try doc.select("a[epub:type=noteref]").first() else { return nil } + + let anchorHref = try link.attr("href") + guard href.hasSuffix(anchorHref) else { return nil} + + let hashParts = href.split(separator: "#") + guard hashParts.count == 2 else { + log(.error, "Could not find hash in link \(href)") + return nil + } + let id = String(hashParts[1]) + var withoutFragment = String(hashParts[0]) + if withoutFragment.hasPrefix("/") { + withoutFragment = String(withoutFragment.dropFirst()) + } + + guard let base = publication.baseURL else { + log(.error, "Couldn't get publication base URL") + return nil + } + + let absolute = base.appendingPathComponent(withoutFragment) + + log(.debug, "Fetching note contents from \(absolute.absoluteString)") + let contents = try String(contentsOf: absolute) + let document = try parse(contents) + + guard let aside = try document.select("#\(id)").first() else { + log(.error, "Could not find the element '#\(id)' in document \(absolute)") + return nil + } + + return (try aside.html(), try link.html()) + + } catch { + log(.error, "Caught error while getting note content: \(error)") + return nil + } + } + func spreadViewPagesDidChange(_ spreadView: EPUBSpreadView) { if paginationView.currentView == spreadView { notifyCurrentLocation() diff --git a/r2-navigator-swift/EPUB/EPUBReflowableSpreadView.swift b/r2-navigator-swift/EPUB/EPUBReflowableSpreadView.swift index aab2ccc4..c146fb3c 100644 --- a/r2-navigator-swift/EPUB/EPUBReflowableSpreadView.swift +++ b/r2-navigator-swift/EPUB/EPUBReflowableSpreadView.swift @@ -108,10 +108,9 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { } } - override func pointFromTap(_ data: [String : Any]) -> CGPoint? { - guard let x = data["clientX"] as? Int, let y = data["clientY"] as? Int else { - return nil - } + override func pointFromTap(_ data: TapData) -> CGPoint? { + let x = data.clientX + let y = data.clientY var point = CGPoint(x: x, y: y) if isScrollEnabled { diff --git a/r2-navigator-swift/EPUB/EPUBSpreadView.swift b/r2-navigator-swift/EPUB/EPUBSpreadView.swift index 4cfbe7e4..403b7a1c 100644 --- a/r2-navigator-swift/EPUB/EPUBSpreadView.swift +++ b/r2-navigator-swift/EPUB/EPUBSpreadView.swift @@ -11,6 +11,7 @@ import WebKit import R2Shared +import SwiftSoup protocol EPUBSpreadViewDelegate: class { @@ -27,7 +28,7 @@ protocol EPUBSpreadViewDelegate: class { func spreadView(_ spreadView: EPUBSpreadView, didTapOnExternalURL url: URL) /// Called when the user tapped on an internal link. - func spreadView(_ spreadView: EPUBSpreadView, didTapOnInternalLink href: String) + func spreadView(_ spreadView: EPUBSpreadView, didTapOnInternalLink href: String, tapData: TapData?) /// Called when the pages visible in the spread changed. func spreadViewPagesDidChange(_ spreadView: EPUBSpreadView) @@ -50,6 +51,8 @@ class EPUBSpreadView: UIView, Loggable { let readingProgression: ReadingProgression let userSettings: UserSettings let editingActions: EditingActionsController + + private var lastTap: TapData? = nil /// If YES, the content will be faded in once loaded. let animatedLoad: Bool @@ -193,18 +196,26 @@ class EPUBSpreadView: UIView, Loggable { } /// Called from the JS code when a tap is detected. - private func didTap(_ body: Any) { - guard let body = body as? [String: Any], - let point = pointFromTap(body) else - { - return + /// If the JS indicates the tap is being handled within the webview, don't take action, + /// just save the tap data for use by webView(_ webView:decidePolicyFor:decisionHandler:) + private func didTap(_ data: Any) { + let tapData = TapData(data: data) + lastTap = tapData + + guard !tapData.defaultPrevented else { return } + if let interactive = tapData.interactiveElement { + let isNoteref = (try? parse(interactive).select("a[epub:type=noteref]").first()) == nil + if !isNoteref { + return + } } - + + guard let point = pointFromTap(tapData) else { return } delegate?.spreadView(self, didTapAt: point) } /// Converts the touch data returned by the JavaScript `tap` event into a point in the webview's coordinate space. - func pointFromTap(_ data: [String: Any]) -> CGPoint? { + func pointFromTap(_ data: TapData) -> CGPoint? { // To override in subclasses. return nil } @@ -423,7 +434,7 @@ extension EPUBSpreadView: WKNavigationDelegate { // Check if url is internal or external if let baseURL = publication.baseURL, url.host == baseURL.host { let href = url.absoluteString.replacingOccurrences(of: baseURL.absoluteString, with: "/") - delegate?.spreadView(self, didTapOnInternalLink: href) + delegate?.spreadView(self, didTapOnInternalLink: href, tapData: self.lastTap) } else { delegate?.spreadView(self, didTapOnExternalURL: url) } @@ -504,3 +515,28 @@ private extension EPUBSpreadView { } } + +/// Produced by gestures.js +struct TapData { + let defaultPrevented: Bool + let screenX: Int + let screenY: Int + let clientX: Int + let clientY: Int + let targetElement: String + let interactiveElement: String? + + init(dict: [String: Any]) { + self.defaultPrevented = dict["defaultPrevented"] as? Bool ?? false + self.screenX = dict["screenX"] as? Int ?? 0 + self.screenY = dict["screenY"] as? Int ?? 0 + self.clientX = dict["clientX"] as? Int ?? 0 + self.clientY = dict["clientY"] as? Int ?? 0 + self.targetElement = dict["targetElement"] as? String ?? "" + self.interactiveElement = dict["interactiveElement"] as? String + } + + init(data: Any) { + self.init(dict: data as? [String: Any] ?? [String: Any]()) + } +} diff --git a/r2-navigator-swift/EPUB/Resources/Scripts/gestures.js b/r2-navigator-swift/EPUB/Resources/Scripts/gestures.js index 3244b683..c72fc2e9 100644 --- a/r2-navigator-swift/EPUB/Resources/Scripts/gestures.js +++ b/r2-navigator-swift/EPUB/Resources/Scripts/gestures.js @@ -7,20 +7,23 @@ }); function onClick(event) { - if (event.defaultPrevented || isInteractiveElement(event.target)) { - return; - } if (!window.getSelection().isCollapsed) { // There's an on-going selection, the tap will dismiss it so we don't forward it. return; } + // Send the tap data over the JS bridge even if it's been handled + // within the webview, so that it can be preserved and used + // by the WKNavigationDelegate if needed. webkit.messageHandlers.tap.postMessage({ + "defaultPrevented": event.defaultPrevented, "screenX": event.screenX, "screenY": event.screenY, "clientX": event.clientX, "clientY": event.clientY, + "targetElement": event.target.outerHTML, + "interactiveElement": nearestInteractiveElement(event.target), }); // We don't want to disable the default WebView behavior as it breaks some features without bringing any value. @@ -29,7 +32,7 @@ } // See. https://github.com/JayPanoz/architecture/tree/touch-handling/misc/touch-handling - function isInteractiveElement(element) { + function nearestInteractiveElement(element) { var interactiveTags = [ 'a', 'audio', @@ -45,20 +48,20 @@ 'video', ] if (interactiveTags.indexOf(element.nodeName.toLowerCase()) != -1) { - return true; + return element.outerHTML; } // Checks whether the element is editable by the user. if (element.hasAttribute('contenteditable') && element.getAttribute('contenteditable').toLowerCase() != 'false') { - return true; + return element.outerHTML; } // Checks parents recursively because the touch might be for example on an inside a . if (element.parentElement) { - return isInteractiveElement(element.parentElement); + return nearestInteractiveElement(element.parentElement); } - return false; + return null; } })(); diff --git a/r2-navigator-swift/Navigator.swift b/r2-navigator-swift/Navigator.swift index cb4d6f11..d8620717 100644 --- a/r2-navigator-swift/Navigator.swift +++ b/r2-navigator-swift/Navigator.swift @@ -86,6 +86,13 @@ public protocol NavigatorDelegate: AnyObject { /// Called when the user tapped an external URL. The default implementation opens the URL with the default browser. func navigator(_ navigator: Navigator, presentExternalURL url: URL) + /// Called when the user taps on a link referring to a note. + /// + /// Return `true` to navigate to the note, or `false` if you intend to present the + /// note yourself, using its `content`. `link.type` contains information about the + /// format of `content` and `referrer`, such as `text/html`. + func navigator(_ navigator: Navigator, shouldNavigateToNoteAt link: Link, content: String, referrer: String?) -> Bool + } @@ -96,6 +103,10 @@ public extension NavigatorDelegate { UIApplication.shared.openURL(url) } } + + func navigator(_ navigator: Navigator, shouldNavigateToNoteAt link: Link, content: String, referrer: String?) -> Bool { + return true + } }