Merge pull request #2063 from bdougsand/find-in-article-ios

Adds "Find in Article" activity to the share sheet
This commit is contained in:
Maurice Parker 2020-05-18 02:41:28 -05:00 committed by GitHub
commit b575d648dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 756 additions and 15 deletions

View File

@ -749,6 +749,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 */; };
@ -1812,6 +1814,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>"; };
@ -2314,6 +2318,8 @@
51AB8AB223B7F4C6008F147D /* WebViewController.swift */,
517630222336657E00E15FFF /* WebViewProvider.swift */,
51C9DE5723EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift */,
D3A398632465054F00F9A366 /* FindInArticleActivity.swift */,
D3555BF324664539005E48C3 /* ArticleSearchBar.swift */,
);
path = Article;
sourceTree = "<group>";
@ -4437,6 +4443,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 */,
@ -4446,6 +4453,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 */,

View File

@ -0,0 +1,176 @@
//
// 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 keyCommands: [UIKeyCommand]? {
return [UIKeyCommand(title: "Exit Find", action: #selector(donePressed(_:)), input: UIKeyCommand.inputEscape)]
}
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 {
searchField.becomeFirstResponder()
}
@discardableResult override func resignFirstResponder() -> Bool {
searchField.resignFirstResponder()
}
override var isFirstResponder: Bool {
searchField.isFirstResponder
}
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(_ _: Any? = nil) {
delegate?.doneWasPressed?(self)
}
}
extension ArticleSearchBar: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
delegate?.nextWasPressed?(self)
return false
}
}

View File

@ -26,6 +26,10 @@ class ArticleViewController: UIViewController {
@IBOutlet private weak var starBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var actionBarButtonItem: UIBarButtonItem!
@IBOutlet private var searchBar: ArticleSearchBar!
@IBOutlet private var searchBarBottomConstraint: NSLayoutConstraint!
private var defaultControls: [UIBarButtonItem]?
private var pageViewController: UIPageViewController!
private var currentWebViewController: WebViewController? {
@ -67,6 +71,10 @@ class ArticleViewController: UIViewController {
private let keyboardManager = KeyboardManager(type: .detail)
override var keyCommands: [UIKeyCommand]? {
if searchBar.isFirstResponder {
return nil
}
return keyboardManager.keyCommands
}
@ -127,6 +135,15 @@ class ArticleViewController: UIViewController {
if AppDefaults.articleFullscreenEnabled {
controller.hideBars()
}
// Search bar
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(keyboardWillChangeFrame(_:)), name: UIWindow.keyboardWillChangeFrameNotification, object: nil)
searchBar.delegate = self
view.bringSubviewToFront(searchBar)
updateUI()
}
@ -135,6 +152,12 @@ class ArticleViewController: UIViewController {
coordinator.isArticleViewControllerPending = false
}
override func viewWillDisappear(_ animated: Bool) {
if searchBar != nil && !searchBar.isHidden {
endFind()
}
}
override func viewSafeAreaInsetsDidChange() {
// This will animate if the show/hide bars animation is happening.
view.layoutIfNeeded()
@ -276,6 +299,80 @@ 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 {
@objc func beginFind(_ _: Any? = nil) {
searchBar.isHidden = false
navigationController?.setToolbarHidden(true, animated: true)
currentWebViewController?.additionalSafeAreaInsets.bottom = searchBar.frame.height
searchBar.becomeFirstResponder()
}
@objc func endFind(_ _: Any? = nil) {
searchBar.resignFirstResponder()
searchBar.isHidden = true
navigationController?.setToolbarHidden(false, animated: true)
currentWebViewController?.additionalSafeAreaInsets.bottom = 0
currentWebViewController?.endSearch()
}
@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 {

View File

@ -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)
}
}

View File

@ -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,66 @@ private extension WebViewController {
}
}
// MARK: Find in Article
private struct FindInArticleOptions: Codable {
var text: String
var caseSensitive = false
var regex = 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()")
}
}

View File

@ -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,18 @@
<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" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="h1Q-FS-jlg" customClass="ArticleSearchBar" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="777" width="414" height="36"/>
<color key="backgroundColor" name="barBackgroundColor"/>
</view>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="VUw-jc-0yf" firstAttribute="trailing" secondItem="h1Q-FS-jlg" secondAttribute="trailing" id="2Nt-fa-LhC"/>
<constraint firstItem="h1Q-FS-jlg" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="Vgz-hA-Zrp"/>
<constraint firstItem="VUw-jc-0yf" firstAttribute="bottom" secondItem="h1Q-FS-jlg" secondAttribute="bottom" id="XyH-A7-Trj"/>
</constraints>
<viewLayoutGuide key="safeArea" id="VUw-jc-0yf"/>
</view>
<toolbarItems>
@ -88,12 +99,14 @@
<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="searchBarBottomConstraint" destination="XyH-A7-Trj" id="5gH-az-8vg"/>
<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 +351,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 +417,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>

View File

@ -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
}

View File

@ -144,3 +144,338 @@ 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)',
pointerEvents: 'none'
});
return overlay;
}
function clearHighlightRects() {
let container = document.getElementById('nnw:highlightContainer')
if (container) container.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 && clearOldRects)
container.remove();
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=20, padBottom=60) {
const scrollToTop = top - pad;
let scrollBy = scrollToTop;
if (scrollToTop >= 0) {
const visible = window.visualViewport;
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
return;
scrollBy = Math.min(scrollToBottom, scrollBy);
}
scrollParent(node).scrollBy({ top: scrollBy });
}
function withEncodedArg(fn) {
return function(encodedData, ...rest) {
const data = encodedData && JSON.parse(atob(encodedData));
return fn(data, ...rest);
}
}
function escapeRegex(s) {
return s.replace(/[.?*+^$\\()[\]{}]/g, '\\$&');
}
class FindState {
constructor(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;
this.options = options;
}
get selected() {
return this.index > -1 ? this.results[this.index] : null;
}
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
let newFindState;
if (!options || !options.text) {
clearHighlightRects();
return
}
try {
newFindState = new FindState(options);
} catch (err) {
clearHighlightRects();
throw err;
}
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)));
});
selectNextResult = withEncodedArg(options => {
if (CurrentFindState)
CurrentFindState.selectNext();
});
selectPreviousResult = withEncodedArg(options => {
if (CurrentFindState)
CurrentFindState.selectPrevious();
});
function endFind() {
clearHighlightRects()
CurrentFindState = null;
}