From 737f4bfdf564bdea7f52057908f1b7239ef5cfd1 Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Mon, 11 May 2020 16:08:01 -0400 Subject: [PATCH 1/9] Adds "Find in Article" activity to the share sheet addresses #1750 --- NetNewsWire.xcodeproj/project.pbxproj | 8 + iOS/Article/ArticleSearchBar.swift | 178 ++++++++++++++ iOS/Article/ArticleViewController.swift | 96 ++++++++ iOS/Article/FindInArticleActivity.swift | 40 ++++ iOS/Article/WebViewController.swift | 63 ++++- iOS/Base.lproj/Main.storyboard | 49 ++-- iOS/Resources/main_ios.js | 304 ++++++++++++++++++++++++ 7 files changed, 723 insertions(+), 15 deletions(-) create mode 100644 iOS/Article/ArticleSearchBar.swift create mode 100644 iOS/Article/FindInArticleActivity.swift diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 55d921e13..04e61abc7 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -740,6 +740,8 @@ BDCB516824282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; }; C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */; }; C5A6ED6D23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */; }; + D3555BF524664566005E48C3 /* ArticleSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3555BF324664539005E48C3 /* ArticleSearchBar.swift */; }; + D3A39865246505DF00F9A366 /* FindInArticleActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A398632465054F00F9A366 /* FindInArticleActivity.swift */; }; D553738B20186C20006D8857 /* Article+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553737C20186C1F006D8857 /* Article+Scriptability.swift */; }; D57BE6E0204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */; }; D5907D7F2004AC00005947E5 /* NSApplication+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5907D7E2004AC00005947E5 /* NSApplication+Scriptability.swift */; }; @@ -1796,6 +1798,8 @@ BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsNewsBlur.xib; sourceTree = ""; }; C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleActivityItemSource.swift; sourceTree = ""; }; C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivityViewController-Extensions.swift"; sourceTree = ""; }; + D3555BF324664539005E48C3 /* ArticleSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleSearchBar.swift; sourceTree = ""; }; + D3A398632465054F00F9A366 /* FindInArticleActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindInArticleActivity.swift; sourceTree = ""; }; D519E74722EE553300923F27 /* NetNewsWire_safariextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target.xcconfig; sourceTree = ""; }; D553737C20186C1F006D8857 /* Article+Scriptability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Article+Scriptability.swift"; sourceTree = ""; }; D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScriptCommand+NetNewsWire.swift"; sourceTree = ""; }; @@ -2286,6 +2290,8 @@ 51AB8AB223B7F4C6008F147D /* WebViewController.swift */, 517630222336657E00E15FFF /* WebViewProvider.swift */, 51C9DE5723EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift */, + D3A398632465054F00F9A366 /* FindInArticleActivity.swift */, + D3555BF324664539005E48C3 /* ArticleSearchBar.swift */, ); path = Article; sourceTree = ""; @@ -4398,6 +4404,7 @@ 514219372352510100E07E2C /* ImageScrollView.swift in Sources */, 516AE9B32371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift in Sources */, 51DC370B2405BC9A0095D371 /* PreloadedWebView.swift in Sources */, + D3555BF524664566005E48C3 /* ArticleSearchBar.swift in Sources */, B24E9ADE245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */, C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */, 51DC37092402F1470095D371 /* MasterFeedDataSourceOperation.swift in Sources */, @@ -4407,6 +4414,7 @@ 516AE9E02372269A007DEEAA /* IconImage.swift in Sources */, 519ED456244828C3007F8E94 /* AddExtensionPointViewController.swift in Sources */, 51C45268226508F600C03939 /* MasterFeedUnreadCountView.swift in Sources */, + D3A39865246505DF00F9A366 /* FindInArticleActivity.swift in Sources */, 5183CCD0226E1E880010922C /* NonIntrinsicLabel.swift in Sources */, 51C4529F22650A1900C03939 /* AuthorAvatarDownloader.swift in Sources */, 5108F6D22375EED2001ABC45 /* TimelineCustomizerViewController.swift in Sources */, diff --git a/iOS/Article/ArticleSearchBar.swift b/iOS/Article/ArticleSearchBar.swift new file mode 100644 index 000000000..5ab827b2b --- /dev/null +++ b/iOS/Article/ArticleSearchBar.swift @@ -0,0 +1,178 @@ +// +// ArticleSearchBar.swift +// NetNewsWire +// +// Created by Brian Sanders on 5/8/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit + +@objc protocol SearchBarDelegate: NSObjectProtocol { + @objc optional func nextWasPressed(_ searchBar: ArticleSearchBar) + @objc optional func previousWasPressed(_ searchBar: ArticleSearchBar) + @objc optional func doneWasPressed(_ searchBar: ArticleSearchBar) + @objc optional func searchBar(_ searchBar: ArticleSearchBar, textDidChange: String) +} + +@IBDesignable final class ArticleSearchBar: UIStackView { + var searchField: UISearchTextField! + var nextButton: UIButton! + var prevButton: UIButton! + var background: UIView! + + weak private var resultsLabel: UILabel! + + var resultsCount: UInt = 0 { + didSet { + updateUI() + } + } + var selectedResult: UInt = 1 { + didSet { + updateUI() + } + } + + weak var delegate: SearchBarDelegate? + + override var inputAccessoryView: UIView? { + get { + searchField.inputAccessoryView + } + + set { + searchField.inputAccessoryView = newValue + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + layer.backgroundColor = UIColor(named: "barBackgroundColor")?.cgColor ?? UIColor.white.cgColor + isOpaque = true + NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: searchField) + } + + private func updateUI() { + if resultsCount > 0 { + let format = NSLocalizedString("%d of %d", comment: "Results selection and count") + resultsLabel.text = String.localizedStringWithFormat(format, selectedResult, resultsCount) + } else { + resultsLabel.text = NSLocalizedString("No results", comment: "No results") + } + + nextButton.isEnabled = selectedResult < resultsCount + prevButton.isEnabled = resultsCount > 0 && selectedResult > 1 + } + + @discardableResult override func becomeFirstResponder() -> Bool { + super.becomeFirstResponder() + return searchField.becomeFirstResponder() + } + + @discardableResult override func resignFirstResponder() -> Bool { + super.resignFirstResponder() + return searchField.resignFirstResponder() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} + +private extension ArticleSearchBar { + func commonInit() { + isLayoutMarginsRelativeArrangement = true + alignment = .center + spacing = 8 + layoutMargins.left = 8 + layoutMargins.right = 8 + + background = UIView(frame: bounds) + background.backgroundColor = .systemGray5 + background.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(background) + + let doneButton = UIButton() + doneButton.setTitle(NSLocalizedString("Done", comment: "Done"), for: .normal) + doneButton.setTitleColor(UIColor.label, for: .normal) + doneButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14) + doneButton.isAccessibilityElement = true + doneButton.addTarget(self, action: #selector(donePressed), for: .touchUpInside) + doneButton.isEnabled = true + addArrangedSubview(doneButton) + + let resultsLabel = UILabel() + searchField = UISearchTextField() + searchField.autocapitalizationType = .none + searchField.autocorrectionType = .no + searchField.returnKeyType = .search + searchField.delegate = self + + resultsLabel.font = .systemFont(ofSize: UIFont.smallSystemFontSize) + resultsLabel.textColor = .secondaryLabel + resultsLabel.text = "" + resultsLabel.textAlignment = .right + resultsLabel.adjustsFontSizeToFitWidth = true + searchField.rightView = resultsLabel + searchField.rightViewMode = .always + + self.resultsLabel = resultsLabel + addArrangedSubview(searchField) + + prevButton = UIButton(type: .system) + prevButton.setImage(UIImage(systemName: "chevron.up"), for: .normal) + prevButton.accessibilityLabel = "Previous Result" + prevButton.isAccessibilityElement = true + prevButton.addTarget(self, action: #selector(previousPressed), for: .touchUpInside) + addArrangedSubview(prevButton) + + nextButton = UIButton(type: .system) + nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal) + nextButton.accessibilityLabel = "Next Result" + nextButton.isAccessibilityElement = true + nextButton.addTarget(self, action: #selector(nextPressed), for: .touchUpInside) + addArrangedSubview(nextButton) + } +} + +private extension ArticleSearchBar { + @objc func textDidChange(_ notification: Notification) { + delegate?.searchBar?(self, textDidChange: searchField.text ?? "") + + if searchField.text?.isEmpty ?? true { + searchField.rightViewMode = .never + } else { + searchField.rightViewMode = .always + } + } + + @objc func nextPressed() { + delegate?.nextWasPressed?(self) + } + + @objc func previousPressed() { + delegate?.previousWasPressed?(self) + } + + @objc func donePressed() { + delegate?.doneWasPressed?(self) + } +} + +extension ArticleSearchBar: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + delegate?.nextWasPressed?(self) + return false + } +} diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index a198a6c45..5a054c350 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -26,6 +26,9 @@ class ArticleViewController: UIViewController { @IBOutlet private weak var starBarButtonItem: UIBarButtonItem! @IBOutlet private weak var actionBarButtonItem: UIBarButtonItem! + @IBOutlet private var searchBar: ArticleSearchBar! + private var defaultControls: [UIBarButtonItem]? + private var pageViewController: UIPageViewController! private var currentWebViewController: WebViewController? { @@ -127,6 +130,18 @@ class ArticleViewController: UIViewController { if AppDefaults.articleFullscreenEnabled { controller.hideBars() } + + // Search bar + makeSearchBarConstraints() + NotificationCenter.default.addObserver(self, selector: #selector(beginFind(_:)), name: .FindInArticle, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(endFind(_:)), name: .EndFindInArticle, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIWindow.keyboardWillHideNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidChangeFrame(_:)), name: UIWindow.keyboardDidChangeFrameNotification, object: nil) + +// searchBar.translatesAutoresizingMaskIntoConstraints = false +// searchBar.delegate = self + view.bringSubviewToFront(searchBar) + updateUI() } @@ -135,6 +150,10 @@ class ArticleViewController: UIViewController { coordinator.isArticleViewControllerPending = false } + override func viewWillDisappear(_ animated: Bool) { + searchBar.inputAccessoryView = nil + } + override func viewSafeAreaInsetsDidChange() { // This will animate if the show/hide bars animation is happening. view.layoutIfNeeded() @@ -276,6 +295,83 @@ class ArticleViewController: UIViewController { } +// MARK: Find in Article +public extension Notification.Name { + static let FindInArticle = Notification.Name("FindInArticle") + static let EndFindInArticle = Notification.Name("EndFindInArticle") +} + +extension ArticleViewController: SearchBarDelegate { + + func searchBar(_ searchBar: ArticleSearchBar, textDidChange searchText: String) { + currentWebViewController?.searchText(searchText) { + found in + searchBar.resultsCount = found.count + + if let index = found.index { + searchBar.selectedResult = index + 1 + } + } + } + + func doneWasPressed(_ searchBar: ArticleSearchBar) { + NotificationCenter.default.post(name: .EndFindInArticle, object: nil) + } + + func nextWasPressed(_ searchBar: ArticleSearchBar) { + if searchBar.selectedResult < searchBar.resultsCount { + currentWebViewController?.selectNextSearchResult() + searchBar.selectedResult += 1 + } + } + + func previousWasPressed(_ searchBar: ArticleSearchBar) { + if searchBar.selectedResult > 1 { + currentWebViewController?.selectPreviousSearchResult() + searchBar.selectedResult -= 1 + } + } +} + +extension ArticleViewController { + + private func makeSearchBarConstraints() { + NSLayoutConstraint.activate([ + searchBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + searchBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + } + + @objc func beginFind(_ notification: Notification) { + searchBar.isHidden = false + navigationController?.setToolbarHidden(true, animated: true) + currentWebViewController?.additionalSafeAreaInsets.bottom = searchBar.frame.height + searchBar.delegate = self + searchBar.inputAccessoryView = searchBar + searchBar.becomeFirstResponder() + } + + @objc func endFind(_ notification: Notification) { + searchBar.resignFirstResponder() + searchBar.isHidden = true + navigationController?.setToolbarHidden(false, animated: true) + currentWebViewController?.additionalSafeAreaInsets.bottom = 0 + currentWebViewController?.endSearch() + } + + @objc func keyboardWillHide(_ _: Notification) { + view.addSubview(searchBar) + makeSearchBarConstraints() + } + + @objc func keyboardDidChangeFrame(_ notification: Notification) { + if let frame = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect { + currentWebViewController?.additionalSafeAreaInsets.bottom = frame.height + } + } +} + // MARK: WebViewControllerDelegate extension ArticleViewController: WebViewControllerDelegate { diff --git a/iOS/Article/FindInArticleActivity.swift b/iOS/Article/FindInArticleActivity.swift new file mode 100644 index 000000000..334a142fb --- /dev/null +++ b/iOS/Article/FindInArticleActivity.swift @@ -0,0 +1,40 @@ +// +// FindInArticleActivity.swift +// NetNewsWire-iOS +// +// Created by Brian Sanders on 5/7/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit + +class FindInArticleActivity: UIActivity { + override var activityTitle: String? { + NSLocalizedString("Find in Article", comment: "Find in Article") + } + + override var activityType: UIActivity.ActivityType? { + UIActivity.ActivityType(rawValue: "com.ranchero.NetNewsWire.find") + } + + override var activityImage: UIImage? { + UIImage(systemName: "magnifyingglass", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)) + } + + override class var activityCategory: UIActivity.Category { + .action + } + + override func canPerform(withActivityItems activityItems: [Any]) -> Bool { + true + } + + override func prepare(withActivityItems activityItems: [Any]) { + + } + + override func perform() { + NotificationCenter.default.post(Notification(name: .FindInArticle)) + activityDidFinish(true) + } +} diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index 2ad4b591d..addf819a2 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -223,7 +223,7 @@ class WebViewController: UIViewController { return } - let activityViewController = UIActivityViewController(url: url, title: article?.title, applicationActivities: [OpenInSafariActivity()]) + let activityViewController = UIActivityViewController(url: url, title: article?.title, applicationActivities: [FindInArticleActivity(), OpenInSafariActivity()]) activityViewController.popoverPresentationController?.barButtonItem = popOverBarButtonItem present(activityViewController, animated: true) } @@ -678,3 +678,64 @@ private extension WebViewController { } } + +// MARK: Find in Article + +private struct FindInArticleOptions: Codable { + var text: String + var caseSensitive = 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()") + } +} diff --git a/iOS/Base.lproj/Main.storyboard b/iOS/Base.lproj/Main.storyboard index ad9b7cb52..3a2feb18c 100644 --- a/iOS/Base.lproj/Main.storyboard +++ b/iOS/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -15,7 +15,21 @@ + + + + + + + + @@ -88,12 +102,13 @@ + - + @@ -338,7 +353,7 @@ - - - - - - + + + + + + - - - - + + + + + + + + + + diff --git a/iOS/Resources/main_ios.js b/iOS/Resources/main_ios.js index 4712f1c04..c3d441db6 100644 --- a/iOS/Resources/main_ios.js +++ b/iOS/Resources/main_ios.js @@ -144,3 +144,307 @@ 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)', + }); + + return overlay; +} + +function clearHighlightRects(container=document.getElementById('nnw:highlightContainer')) { + while (container.firstChild) container.firstChild.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) { + if (clearOldRects) + clearHighlightRects(container); + } else { + 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=0) { + const scrollToTop = top - pad; + + let scrollBy = scrollToTop; + + if (scrollToTop >= 0) { + const visible = window.visualViewport; + const scrollToBottom = top + height + pad - 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 findNext() { + const result = f.next(); + const bounds = textBounds(f.node, f.offset, f.offsetEnd); + highlightRects(bounds) +} + +function getFinderCount(finder) { + let count = 0; + while (finder.next()) + ++count; + finder.reset(); + return count; +} + +function withEncodedArg(fn) { + return function(encodedData, ...rest) { + const data = encodedData && JSON.parse(atob(encodedData)); + return fn(data, ...rest); + } +} + +class FindState { + constructor(options) { + const { text, caseSensitive } = options; + const finder = new Finder(new RegExp(text, caseSensitive ? 'g' : 'ig')); + this.results = Array.from(finder); + this.index = -1; + this.options = options; + } + + 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 + CurrentFindState = new FindState(options); + CurrentFindState.selectNext() || clearHighlightRects(); + 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; +} From 98e0434077f9a83f23e4242d51751d5e023dd47a Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Wed, 13 May 2020 05:29:09 -0400 Subject: [PATCH 2/9] Alters Find in Article to escape regex characters by default --- iOS/Article/WebViewController.swift | 1 + iOS/Resources/main_ios.js | 51 ++++++++++++++--------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index addf819a2..b8792b689 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -684,6 +684,7 @@ private extension WebViewController { private struct FindInArticleOptions: Codable { var text: String var caseSensitive = false + var regex = false } diff --git a/iOS/Resources/main_ios.js b/iOS/Resources/main_ios.js index c3d441db6..f1f057895 100644 --- a/iOS/Resources/main_ios.js +++ b/iOS/Resources/main_ios.js @@ -161,8 +161,9 @@ function makeHighlightRect({left, top, width, height}, offsetTop=0, offsetLeft=0 return overlay; } -function clearHighlightRects(container=document.getElementById('nnw:highlightContainer')) { - while (container.firstChild) container.firstChild.remove(); +function clearHighlightRects() { + let container = document.getElementById('nnw:highlightContainer') + if (container) container.remove(); } function highlightRects(rects, clearOldRects=true, makeHighlightRect=makeHighlightRect) { @@ -171,14 +172,12 @@ function highlightRects(rects, clearOldRects=true, makeHighlightRect=makeHighlig article.style.position = 'relative'; - if (container) { - if (clearOldRects) - clearHighlightRects(container); - } else { - container = document.createElement('div'); - container.id = 'nnw:highlightContainer'; - article.appendChild(container); - } + 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 => @@ -369,20 +368,6 @@ function scrollToRect({top, height}, node, pad=0) { scrollParent(node).scrollBy({ top: scrollBy }); } -function findNext() { - const result = f.next(); - const bounds = textBounds(f.node, f.offset, f.offsetEnd); - highlightRects(bounds) -} - -function getFinderCount(finder) { - let count = 0; - while (finder.next()) - ++count; - finder.reset(); - return count; -} - function withEncodedArg(fn) { return function(encodedData, ...rest) { const data = encodedData && JSON.parse(atob(encodedData)); @@ -390,9 +375,17 @@ function withEncodedArg(fn) { } } +function escapeRegex(s) { + return s.replace(/[.?*+^$\\()[\]{}]/g, '\\$&'); +} + class FindState { constructor(options) { - const { text, caseSensitive } = 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; @@ -429,7 +422,13 @@ 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 - CurrentFindState = new FindState(options); + + try { + CurrentFindState = new FindState(options); + } catch (err) { + clearHighlightRects(); + throw err; + } CurrentFindState.selectNext() || clearHighlightRects(); return btoa(JSON.stringify(CurrentFindState, (k, v) => (ExcludeKeys.has(k) ? undefined : v))); }); From edb7c50cb3fe106709f83a959c4755398233e4c8 Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Wed, 13 May 2020 06:04:34 -0400 Subject: [PATCH 3/9] Improves incremental search for "Find in Article" Article search now maintains its position in the document, if possible. --- iOS/Resources/main_ios.js | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/iOS/Resources/main_ios.js b/iOS/Resources/main_ios.js index f1f057895..961ae0844 100644 --- a/iOS/Resources/main_ios.js +++ b/iOS/Resources/main_ios.js @@ -392,6 +392,10 @@ class FindState { this.options = options; } + get selected() { + return this.index > -1 ? this.results[this.index] : null; + } + toJSON() { return { index: this.index > -1 ? this.index : null, @@ -423,13 +427,35 @@ 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; try { - CurrentFindState = new FindState(options); + newFindState = new FindState(options); } catch (err) { clearHighlightRects(); throw err; } - CurrentFindState.selectNext() || clearHighlightRects(); + + 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))); }); From 2631f4f3f002eba6d70d5ab1715de65c3568d1b0 Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Wed, 13 May 2020 06:10:57 -0400 Subject: [PATCH 4/9] Alters search highlights so they don't intercept pointer events --- iOS/Resources/main_ios.js | 1 + 1 file changed, 1 insertion(+) diff --git a/iOS/Resources/main_ios.js b/iOS/Resources/main_ios.js index 961ae0844..e19200d3d 100644 --- a/iOS/Resources/main_ios.js +++ b/iOS/Resources/main_ios.js @@ -156,6 +156,7 @@ function makeHighlightRect({left, top, width, height}, offsetTop=0, offsetLeft=0 width: `${Math.ceil(width)}px`, height: `${Math.ceil(height)}px`, backgroundColor: 'rgba(200, 220, 10, 0.4)', + pointerEvents: 'none' }); return overlay; From 96671df6674324d86ab8939748cd76e5dccbada1 Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Wed, 13 May 2020 06:13:31 -0400 Subject: [PATCH 5/9] Adds keyboard shortcut for "Find in Article" (iOS) --- iOS/Article/ArticleViewController.swift | 2 +- iOS/KeyboardManager.swift | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index 5a054c350..30d5ac813 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -343,7 +343,7 @@ extension ArticleViewController { ]) } - @objc func beginFind(_ notification: Notification) { + @objc func beginFind(_ _: Any? = nil) { searchBar.isHidden = false navigationController?.setToolbarHidden(true, animated: true) currentWebViewController?.additionalSafeAreaInsets.bottom = searchBar.frame.height diff --git a/iOS/KeyboardManager.swift b/iOS/KeyboardManager.swift index a13f44ceb..36c7a0544 100644 --- a/iOS/KeyboardManager.swift +++ b/iOS/KeyboardManager.swift @@ -185,6 +185,9 @@ private extension KeyboardManager { let toggleStarredTitle = NSLocalizedString("Toggle Starred Status", comment: "Toggle Starred Status") keys.append(KeyboardManager.createKeyCommand(title: toggleStarredTitle, action: "toggleStarred:", input: "l", modifiers: [.command, .shift])) + + let findInArticleTitle = NSLocalizedString("Find in Article", comment: "Find in Article") + keys.append(KeyboardManager.createKeyCommand(title: findInArticleTitle, action: "beginFind:", input: "f", modifiers: [.command])) return keys } From 46ce824b98504606b867a2da962dc1e64e8a48f7 Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Thu, 14 May 2020 07:20:40 -0400 Subject: [PATCH 6/9] Adds guard against empty search strings to Find in Article --- iOS/Article/WebViewController.swift | 1 + iOS/Resources/main_ios.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index b8792b689..5a6014ade 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -709,6 +709,7 @@ internal struct FindInArticleState: Codable { } extension WebViewController { + func searchText(_ searchText: String, completionHandler: @escaping (FindInArticleState) -> Void) { guard let json = try? JSONEncoder().encode(FindInArticleOptions(text: searchText)) else { return diff --git a/iOS/Resources/main_ios.js b/iOS/Resources/main_ios.js index e19200d3d..6f02ba28f 100644 --- a/iOS/Resources/main_ios.js +++ b/iOS/Resources/main_ios.js @@ -429,6 +429,11 @@ updateFind = withEncodedArg(options => { // 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) { From 62d04e88819c75f0e9a7b75b4f852f3cc38e94b0 Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Thu, 14 May 2020 07:22:25 -0400 Subject: [PATCH 7/9] Adds stored inputAccessoryView to ArticleSearchBar It should have been obvious that inputAccessoryView traverses the responder chain --- iOS/Article/ArticleSearchBar.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/iOS/Article/ArticleSearchBar.swift b/iOS/Article/ArticleSearchBar.swift index 5ab827b2b..9bbfac79f 100644 --- a/iOS/Article/ArticleSearchBar.swift +++ b/iOS/Article/ArticleSearchBar.swift @@ -36,14 +36,11 @@ import UIKit weak var delegate: SearchBarDelegate? + private var _inputAccessoryView: UIView? override var inputAccessoryView: UIView? { - get { - searchField.inputAccessoryView - } - - set { - searchField.inputAccessoryView = newValue - } + get { _inputAccessoryView } + set { _inputAccessoryView = newValue } + } } override init(frame: CGRect) { From b058f270648359873530a780945cf3b64187cd09 Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Fri, 15 May 2020 17:56:14 -0400 Subject: [PATCH 8/9] Fixes bugs in article search Placement of the article search bar is now always done with constraints. Previously, I'd used inputAccessoryView when the keyboard appeared. That approach, although ostensibly permitted, causes a hierarchy inconsistency error when the device orientation changes. --- iOS/Article/ArticleSearchBar.swift | 10 +++-- iOS/Article/ArticleViewController.swift | 53 +++++++++++++------------ iOS/Base.lproj/Main.storyboard | 14 +++---- iOS/Resources/main_ios.js | 6 +-- 4 files changed, 42 insertions(+), 41 deletions(-) diff --git a/iOS/Article/ArticleSearchBar.swift b/iOS/Article/ArticleSearchBar.swift index 9bbfac79f..3169b1e11 100644 --- a/iOS/Article/ArticleSearchBar.swift +++ b/iOS/Article/ArticleSearchBar.swift @@ -73,13 +73,15 @@ import UIKit } @discardableResult override func becomeFirstResponder() -> Bool { - super.becomeFirstResponder() - return searchField.becomeFirstResponder() + searchField.becomeFirstResponder() } @discardableResult override func resignFirstResponder() -> Bool { - super.resignFirstResponder() - return searchField.resignFirstResponder() + searchField.resignFirstResponder() + } + + override var isFirstResponder: Bool { + searchField.isFirstResponder } deinit { diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index 30d5ac813..8cceea97c 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -27,6 +27,7 @@ class ArticleViewController: UIViewController { @IBOutlet private weak var actionBarButtonItem: UIBarButtonItem! @IBOutlet private var searchBar: ArticleSearchBar! + @IBOutlet private var searchBarBottomConstraint: NSLayoutConstraint! private var defaultControls: [UIBarButtonItem]? private var pageViewController: UIPageViewController! @@ -70,6 +71,10 @@ class ArticleViewController: UIViewController { private let keyboardManager = KeyboardManager(type: .detail) override var keyCommands: [UIKeyCommand]? { + if searchBar.isFirstResponder { + return nil + } + return keyboardManager.keyCommands } @@ -132,14 +137,11 @@ class ArticleViewController: UIViewController { } // Search bar - makeSearchBarConstraints() + searchBar.translatesAutoresizingMaskIntoConstraints = false NotificationCenter.default.addObserver(self, selector: #selector(beginFind(_:)), name: .FindInArticle, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(endFind(_:)), name: .EndFindInArticle, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIWindow.keyboardWillHideNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidChangeFrame(_:)), name: UIWindow.keyboardDidChangeFrameNotification, object: nil) - -// searchBar.translatesAutoresizingMaskIntoConstraints = false -// searchBar.delegate = self + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIWindow.keyboardWillChangeFrameNotification, object: nil) + searchBar.delegate = self view.bringSubviewToFront(searchBar) updateUI() @@ -151,7 +153,9 @@ class ArticleViewController: UIViewController { } override func viewWillDisappear(_ animated: Bool) { - searchBar.inputAccessoryView = nil + if searchBar != nil && !searchBar.isHidden { + endFind() + } } override func viewSafeAreaInsetsDidChange() { @@ -335,24 +339,14 @@ extension ArticleViewController: SearchBarDelegate { extension ArticleViewController { - private func makeSearchBarConstraints() { - NSLayoutConstraint.activate([ - searchBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - searchBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - searchBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - ]) - } - @objc func beginFind(_ _: Any? = nil) { searchBar.isHidden = false navigationController?.setToolbarHidden(true, animated: true) currentWebViewController?.additionalSafeAreaInsets.bottom = searchBar.frame.height - searchBar.delegate = self - searchBar.inputAccessoryView = searchBar searchBar.becomeFirstResponder() } - @objc func endFind(_ notification: Notification) { + @objc func endFind(_ _: Any? = nil) { searchBar.resignFirstResponder() searchBar.isHidden = true navigationController?.setToolbarHidden(false, animated: true) @@ -360,18 +354,25 @@ extension ArticleViewController { currentWebViewController?.endSearch() } - @objc func keyboardWillHide(_ _: Notification) { - view.addSubview(searchBar) - makeSearchBarConstraints() - } - - @objc func keyboardDidChangeFrame(_ notification: Notification) { - if let frame = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect { - currentWebViewController?.additionalSafeAreaInsets.bottom = frame.height + @objc func keyboardWillChangeFrame(_ notification: Notification) { + if !searchBar.isHidden, + let duration = notification.userInfo?[UIWindow.keyboardAnimationDurationUserInfoKey] as? Double, + let curveRaw = notification.userInfo?[UIWindow.keyboardAnimationCurveUserInfoKey] as? UInt, + let frame = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect { + + let curve = UIView.AnimationOptions(rawValue: curveRaw) + let newHeight = view.safeAreaLayoutGuide.layoutFrame.maxY - frame.minY + currentWebViewController?.additionalSafeAreaInsets.bottom = newHeight + searchBar.frame.height + 10 + self.searchBarBottomConstraint.constant = newHeight + UIView.animate(withDuration: duration, delay: 0, options: curve, animations: { + self.view.layoutIfNeeded() + }) } } + } + // MARK: WebViewControllerDelegate extension ArticleViewController: WebViewControllerDelegate { diff --git a/iOS/Base.lproj/Main.storyboard b/iOS/Base.lproj/Main.storyboard index 3a2feb18c..dc69376e0 100644 --- a/iOS/Base.lproj/Main.storyboard +++ b/iOS/Base.lproj/Main.storyboard @@ -16,19 +16,16 @@ - - - - + + + @@ -103,6 +100,7 @@ + diff --git a/iOS/Resources/main_ios.js b/iOS/Resources/main_ios.js index 6f02ba28f..2de36d94b 100644 --- a/iOS/Resources/main_ios.js +++ b/iOS/Resources/main_ios.js @@ -349,15 +349,15 @@ function scrollParent(node) { elt = elt.parentElement; } } - -function scrollToRect({top, height}, node, pad=0) { + +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 + pad - visible.height; + 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 From d8dced634788ae59fef0dab8675b5c9acb530009 Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Fri, 15 May 2020 18:04:17 -0400 Subject: [PATCH 9/9] Adds a key command (ESC) to the article search bar --- iOS/Article/ArticleSearchBar.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/iOS/Article/ArticleSearchBar.swift b/iOS/Article/ArticleSearchBar.swift index 3169b1e11..c69cfdd4c 100644 --- a/iOS/Article/ArticleSearchBar.swift +++ b/iOS/Article/ArticleSearchBar.swift @@ -15,6 +15,7 @@ import UIKit @objc optional func searchBar(_ searchBar: ArticleSearchBar, textDidChange: String) } + @IBDesignable final class ArticleSearchBar: UIStackView { var searchField: UISearchTextField! var nextButton: UIButton! @@ -36,11 +37,8 @@ import UIKit weak var delegate: SearchBarDelegate? - private var _inputAccessoryView: UIView? - override var inputAccessoryView: UIView? { - get { _inputAccessoryView } - set { _inputAccessoryView = newValue } - } + override var keyCommands: [UIKeyCommand]? { + return [UIKeyCommand(title: "Exit Find", action: #selector(donePressed(_:)), input: UIKeyCommand.inputEscape)] } override init(frame: CGRect) { @@ -146,6 +144,7 @@ private extension ArticleSearchBar { } private extension ArticleSearchBar { + @objc func textDidChange(_ notification: Notification) { delegate?.searchBar?(self, textDidChange: searchField.text ?? "") @@ -164,7 +163,7 @@ private extension ArticleSearchBar { delegate?.previousWasPressed?(self) } - @objc func donePressed() { + @objc func donePressed(_ _: Any? = nil) { delegate?.doneWasPressed?(self) } }