From 737f4bfdf564bdea7f52057908f1b7239ef5cfd1 Mon Sep 17 00:00:00 2001 From: Brian Sanders Date: Mon, 11 May 2020 16:08:01 -0400 Subject: [PATCH] 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; +}