From b6c813c68d52aebb6cc51c6b16ae93bffbfac342 Mon Sep 17 00:00:00 2001 From: Daniel Jalkut Date: Mon, 2 Feb 2026 18:04:05 -0500 Subject: [PATCH] Cherry-pick 9979758 to restore Find functionality in the displayed article content. Fixes #2046. --- Mac/Base.lproj/UnifiedWindow.storyboard | 11 ++++ .../Detail/DetailContainerView.swift | 60 ++++++++++++++++++- .../Detail/DetailViewController.swift | 39 ++++++++++++ Mac/MainWindow/Detail/DetailWebView.swift | 21 ++++++- .../Detail/DetailWebViewController.swift | 19 ++++++ Mac/MainWindow/MainWindowController.swift | 8 +++ 6 files changed, 155 insertions(+), 3 deletions(-) diff --git a/Mac/Base.lproj/UnifiedWindow.storyboard b/Mac/Base.lproj/UnifiedWindow.storyboard index 0e22b0c00..dc027cf07 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 f52a747b2..e376cfadb 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 1cc6ee4d1..9acc19846 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 97e5c5a5e..fc9654fb7 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 b002d48b6..095e87fdf 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 6b39d9c3b..453be6761 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 }