parent
73627a60ca
commit
737f4bfdf5
|
@ -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 = "<group>"; };
|
||||
C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleActivityItemSource.swift; sourceTree = "<group>"; };
|
||||
C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivityViewController-Extensions.swift"; sourceTree = "<group>"; };
|
||||
D3555BF324664539005E48C3 /* ArticleSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleSearchBar.swift; sourceTree = "<group>"; };
|
||||
D3A398632465054F00F9A366 /* FindInArticleActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindInArticleActivity.swift; sourceTree = "<group>"; };
|
||||
D519E74722EE553300923F27 /* NetNewsWire_safariextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target.xcconfig; sourceTree = "<group>"; };
|
||||
D553737C20186C1F006D8857 /* Article+Scriptability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Article+Scriptability.swift"; sourceTree = "<group>"; };
|
||||
D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScriptCommand+NetNewsWire.swift"; sourceTree = "<group>"; };
|
||||
|
@ -2286,6 +2290,8 @@
|
|||
51AB8AB223B7F4C6008F147D /* WebViewController.swift */,
|
||||
517630222336657E00E15FFF /* WebViewProvider.swift */,
|
||||
51C9DE5723EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift */,
|
||||
D3A398632465054F00F9A366 /* FindInArticleActivity.swift */,
|
||||
D3555BF324664539005E48C3 /* ArticleSearchBar.swift */,
|
||||
);
|
||||
path = Article;
|
||||
sourceTree = "<group>";
|
||||
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15705" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15706"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
|
@ -15,7 +15,21 @@
|
|||
<view key="view" contentMode="scaleToFill" id="svH-Pt-448">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="h1Q-FS-jlg" customClass="ArticleSearchBar" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="759" width="414" height="54"/>
|
||||
<color key="backgroundColor" name="barBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="54" id="FQw-KK-lT7"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="h1Q-FS-jlg" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="2DV-em-ArG"/>
|
||||
<constraint firstItem="VUw-jc-0yf" firstAttribute="trailing" secondItem="h1Q-FS-jlg" secondAttribute="trailing" id="5aF-IN-1ff"/>
|
||||
<constraint firstItem="VUw-jc-0yf" firstAttribute="bottom" secondItem="h1Q-FS-jlg" secondAttribute="bottom" id="Kmv-Hg-0wL"/>
|
||||
</constraints>
|
||||
<viewLayoutGuide key="safeArea" id="VUw-jc-0yf"/>
|
||||
</view>
|
||||
<toolbarItems>
|
||||
|
@ -88,12 +102,13 @@
|
|||
<outlet property="nextUnreadBarButtonItem" destination="2w5-e9-C2V" id="Ekf-My-AHN"/>
|
||||
<outlet property="prevArticleBarButtonItem" destination="v4j-fq-23N" id="Gny-Oh-cQa"/>
|
||||
<outlet property="readBarButtonItem" destination="hy0-LS-MzE" id="BzM-x9-tuj"/>
|
||||
<outlet property="searchBar" destination="h1Q-FS-jlg" id="IQA-Wt-BB8"/>
|
||||
<outlet property="starBarButtonItem" destination="wU4-eH-wC9" id="Z8Q-Lt-dKk"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="FJe-Yq-33r" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2320" y="-759"/>
|
||||
<point key="canvasLocation" x="2318.840579710145" y="-759.375"/>
|
||||
</scene>
|
||||
<!--Timeline-->
|
||||
<scene sceneID="fag-XH-avP">
|
||||
|
@ -338,7 +353,7 @@
|
|||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Article Title" textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="iFp-rn-HhQ">
|
||||
<rect key="frame" x="20" y="74.5" width="136" height="33.5"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle1"/>
|
||||
<nil key="textColor"/>
|
||||
<color key="textColor" name="iconDarkBackgroundColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0Hz-Dv-MhU">
|
||||
|
@ -404,20 +419,26 @@
|
|||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="chevron.down" catalog="system" width="64" height="36"/>
|
||||
<image name="chevron.down.circle" catalog="system" width="64" height="60"/>
|
||||
<image name="chevron.up" catalog="system" width="64" height="36"/>
|
||||
<image name="circle" catalog="system" width="64" height="60"/>
|
||||
<image name="gear" catalog="system" width="64" height="58"/>
|
||||
<image name="line.horizontal.3.decrease.circle" catalog="system" width="64" height="60"/>
|
||||
<image name="chevron.down" catalog="system" width="128" height="72"/>
|
||||
<image name="chevron.down.circle" catalog="system" width="128" height="121"/>
|
||||
<image name="chevron.up" catalog="system" width="128" height="72"/>
|
||||
<image name="circle" catalog="system" width="128" height="121"/>
|
||||
<image name="gear" catalog="system" width="128" height="119"/>
|
||||
<image name="line.horizontal.3.decrease.circle" catalog="system" width="128" height="121"/>
|
||||
<image name="markAllAsRead" width="17" height="26"/>
|
||||
<image name="multiply.circle.fill" catalog="system" width="64" height="60"/>
|
||||
<image name="square.and.arrow.up" catalog="system" width="56" height="64"/>
|
||||
<image name="square.and.arrow.up.fill" catalog="system" width="56" height="64"/>
|
||||
<image name="star" catalog="system" width="64" height="58"/>
|
||||
<image name="multiply.circle.fill" catalog="system" width="128" height="121"/>
|
||||
<image name="square.and.arrow.up" catalog="system" width="115" height="128"/>
|
||||
<image name="square.and.arrow.up.fill" catalog="system" width="115" height="128"/>
|
||||
<image name="star" catalog="system" width="128" height="116"/>
|
||||
<namedColor name="barBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</namedColor>
|
||||
<namedColor name="fullScreenBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</namedColor>
|
||||
<namedColor name="iconDarkBackgroundColor">
|
||||
<color red="0.2196078431372549" green="0.2196078431372549" blue="0.2196078431372549" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<namedColor name="primaryAccentColor">
|
||||
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue