diff --git a/Mac/Base.lproj/UnifiedWindow.storyboard b/Mac/Base.lproj/UnifiedWindow.storyboard
index 0e22b0c00d..dc027cf073 100644
--- a/Mac/Base.lproj/UnifiedWindow.storyboard
+++ b/Mac/Base.lproj/UnifiedWindow.storyboard
@@ -364,14 +364,25 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Mac/MainWindow/Detail/DetailContainerView.swift b/Mac/MainWindow/Detail/DetailContainerView.swift
index f52a747b21..e376cfadbc 100644
--- a/Mac/MainWindow/Detail/DetailContainerView.swift
+++ b/Mac/MainWindow/Detail/DetailContainerView.swift
@@ -8,7 +8,7 @@
import AppKit
-final class DetailContainerView: NSView {
+final class DetailContainerView: NSView, @MainActor NSTextFinderBarContainer {
@IBOutlet var detailStatusBarView: DetailStatusBarView!
@@ -29,7 +29,12 @@ final class DetailContainerView: NSView {
if let contentView = contentView {
contentView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentView, positioned: .below, relativeTo: detailStatusBarView)
- let constraints = constraintsToMakeSubViewFullSize(contentView)
+
+ // Constrain the content view to fill the available space on all sides except the top, which we'll constrain to the find bar
+ var constraints = constraintsToMakeSubViewFullSize(contentView).filter { $0.firstAttribute != .top }
+
+ constraints.append(findBarContainerView.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor))
+ constraints.append(findBarContainerView.bottomAnchor.constraint(equalTo: contentView.topAnchor))
NSLayoutConstraint.activate(constraints)
contentViewConstraints = constraints
}
@@ -41,4 +46,55 @@ final class DetailContainerView: NSView {
let r = dirtyRect.intersection(bounds)
r.fill()
}
+
+ // MARK: NSTextFinderBarContainer
+
+ @IBOutlet var findBarContainerView: NSView!
+ @IBOutlet var findBarHeightConstraint: NSLayoutConstraint!
+
+
+ public var findBarView: NSView? = nil {
+ didSet {
+ oldValue?.removeFromSuperview()
+ }
+ }
+
+ public var isFindBarVisible = false {
+ didSet {
+ // It seems AppKit assumes the findBarView will be removed from its superview when it's
+ // not being shown, so we have to fulfill that expectation in addition to hiding the stack view
+ // container we embed it in.
+ if
+ self.isFindBarVisible,
+ let view = findBarView
+ {
+ view.layoutSubtreeIfNeeded()
+ view.frame.origin = NSZeroPoint
+ view.frame.size.width = self.findBarContainerView.bounds.width
+ findBarContainerView.frame = view.bounds
+ findBarHeightConstraint.constant = view.frame.size.height + 1.0
+ findBarContainerView.addSubview(view)
+ }
+ else {
+ if let view = findBarView {
+ view.removeFromSuperview()
+ findBarHeightConstraint.constant = 0
+ }
+ }
+
+ // Notify the web view so it can update its obscuredContentInsets
+ if let webView = contentView as? DetailWebView {
+ webView.isFindBarVisible = isFindBarVisible
+ }
+ }
+ }
+
+ func findBarViewDidChangeHeight() {
+ if let height = findBarView?.frame.size.height {
+ findBarHeightConstraint.constant = height + 1.0
+ findBarContainerView.layoutSubtreeIfNeeded()
+ findBarView?.setFrameOrigin(NSPoint.zero)
+ }
+ }
+
}
diff --git a/Mac/MainWindow/Detail/DetailViewController.swift b/Mac/MainWindow/Detail/DetailViewController.swift
index 1cc6ee4d1f..9acc198460 100644
--- a/Mac/MainWindow/Detail/DetailViewController.swift
+++ b/Mac/MainWindow/Detail/DetailViewController.swift
@@ -40,6 +40,7 @@ final class DetailViewController: NSViewController, WKUIDelegate {
}
statusBarView.mouseoverLink = nil
containerView.contentView = webview
+ resetTextFinder()
}
}
@@ -81,6 +82,8 @@ final class DetailViewController: NSViewController, WKUIDelegate {
case .search:
detailStateForSearch = state
}
+
+ resetTextFinder()
}
func showDetail(for mode: TimelineSourceMode) {
@@ -115,6 +118,42 @@ final class DetailViewController: NSViewController, WKUIDelegate {
}
window.makeFirstResponderUnlessDescendantIsFirstResponder(currentWebViewController.webView)
}
+
+ // MARK: State Restoration
+
+ func saveState(to state: inout [AnyHashable : Any]) {
+ currentWebViewController.saveState(to: &state)
+ }
+
+ // MARK: Find in Article
+
+ private var didLoadTextFinder = false
+ lazy private var textFinder: NSTextFinder = {
+ let finder = NSTextFinder()
+ finder.isIncrementalSearchingEnabled = true
+ finder.incrementalSearchingShouldDimContentView = false
+ finder.client = self.currentWebViewController.webView
+ finder.findBarContainer = self.containerView
+ didLoadTextFinder = true
+ return finder
+ }()
+
+ private func resetTextFinder() {
+ if didLoadTextFinder {
+ self.textFinder.performAction(.hideFindInterface)
+ self.textFinder.client = currentWebViewController.webView
+ }
+ }
+
+ @IBAction func performFindPanelAction(_ sender: Any?) {
+ if let menuItem = sender as? NSMenuItem, let findAction = NSTextFinder.Action(rawValue: menuItem.tag) {
+ self.textFinder.performAction(findAction)
+ }
+ }
+
+ var canFindInCurrentArticle: Bool {
+ currentWebViewController.canFindInArticle
+ }
}
// MARK: - DetailWebViewControllerDelegate
diff --git a/Mac/MainWindow/Detail/DetailWebView.swift b/Mac/MainWindow/Detail/DetailWebView.swift
index 97e5c5a5e2..fc9654fb7b 100644
--- a/Mac/MainWindow/Detail/DetailWebView.swift
+++ b/Mac/MainWindow/Detail/DetailWebView.swift
@@ -14,6 +14,16 @@ final class DetailWebView: WKWebView {
weak var keyboardDelegate: KeyboardDelegate?
private var isObservingResizeNotifications = false
+ /// When the find bar is visible, the web view is pushed below it and no longer
+ /// extends under the toolbar, so we should not set obscuredContentInsets.
+ var isFindBarVisible = false {
+ didSet {
+ if isFindBarVisible != oldValue {
+ updateObscuredContentInsets()
+ }
+ }
+ }
+
private static let estimatedToolbarHeight: CGFloat = 52 // Height of macOS 26.2 icon-only toolbar
private var toolbarHeight: CGFloat {
guard let window,
@@ -99,6 +109,12 @@ final class DetailWebView: WKWebView {
super.viewDidEndLiveResize()
evaluateJavaScript("document.body.style.overflow = 'visible';", completionHandler: nil)
}
+
+ // MARK: NSTextFinderClient
+
+ // Returning false here prevents the "Replace" checkbox from appearing in the find bar
+ override var isEditable: Bool { return false }
+
}
// MARK: - Private
@@ -132,7 +148,10 @@ private extension DetailWebView {
}
func updateObscuredContentInsets() {
- let updatedObscuredContentInsets = NSEdgeInsets(top: toolbarHeight, left: 0, bottom: 0, right: 0)
+ // When the find bar is visible, the web view is constrained below it and no longer
+ // extends under the toolbar, so we don't need to account for toolbar obscuring.
+ let topInset = isFindBarVisible ? 0 : toolbarHeight
+ let updatedObscuredContentInsets = NSEdgeInsets(top: topInset, left: 0, bottom: 0, right: 0)
if obscuredContentInsets != updatedObscuredContentInsets {
obscuredContentInsets = updatedObscuredContentInsets
}
diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift
index b002d48b6b..095e87fdf6 100644
--- a/Mac/MainWindow/Detail/DetailWebViewController.swift
+++ b/Mac/MainWindow/Detail/DetailWebViewController.swift
@@ -171,6 +171,25 @@ final class DetailWebViewController: NSViewController {
override func scrollPageUp(_ sender: Any?) {
webView.scrollPageUp(sender)
}
+
+ // MARK: State Restoration
+
+ func saveState(to state: inout [AnyHashable : Any]) {
+ state[UserInfoKey.isShowingExtractedArticle] = isShowingExtractedArticle
+ state[UserInfoKey.articleWindowScrollY] = windowScrollY
+ }
+
+ // MARK: Find in Article
+
+ var canFindInArticle: Bool {
+ switch state {
+ case .article(_, _), .extracted(_, _, _):
+ return true
+ default:
+ return false
+ }
+ }
+
}
// MARK: - WKScriptMessageHandler
diff --git a/Mac/MainWindow/MainWindowController.swift b/Mac/MainWindow/MainWindowController.swift
index 6b39d9c3be..453be6761a 100644
--- a/Mac/MainWindow/MainWindowController.swift
+++ b/Mac/MainWindow/MainWindowController.swift
@@ -280,6 +280,10 @@ final class MainWindowController: NSWindowController, NSUserInterfaceValidations
return validateToggleReadArticles(item)
}
+ if item.action == #selector(performFindPanelAction(_:)) {
+ return self.detailViewController?.canFindInCurrentArticle ?? false
+ }
+
return true
}
@@ -550,6 +554,10 @@ final class MainWindowController: NSWindowController, NSUserInterfaceValidations
timelineContainerViewController?.toggleReadFilter()
}
+ @IBAction func performFindPanelAction(_ sender: Any?) {
+ self.detailViewController?.performFindPanelAction(sender)
+ }
+
@objc func selectArticleTheme(_ menuItem: NSMenuItem) {
ArticleThemesManager.shared.currentThemeName = menuItem.title
}