From 71912e7a943b155b15d76ab2d316b40a979179bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Meyer?= Date: Sun, 1 Feb 2026 14:58:59 +0100 Subject: [PATCH] Add Copy Link with Highlight feature for iOS Adds support for text fragment URLs that highlight selected text when shared. When a user selects text and shares the article, the link includes a text fragment directive (#:~:text=...) that browsers will highlight on load. Co-Authored-By: Claude Opus 4.5 --- Shared/Article Rendering/main.js | 6 ++++ Shared/TextFragmentURL.swift | 53 +++++++++++++++++++++++++++++ iOS/Article/PreloadedWebView.swift | 43 +++++++++++++++++++++++ iOS/Article/WebViewController.swift | 20 +++++++++++ 4 files changed, 122 insertions(+) create mode 100644 Shared/TextFragmentURL.swift diff --git a/Shared/Article Rendering/main.js b/Shared/Article Rendering/main.js index 7894adeb42..492ac931ad 100644 --- a/Shared/Article Rendering/main.js +++ b/Shared/Article Rendering/main.js @@ -136,6 +136,12 @@ function error() { document.body.innerHTML = "error"; } +// Returns the currently selected text in the WebView, or empty string if no selection +function getSelectedText() { + const selection = window.getSelection(); + return selection ? selection.toString().trim() : ""; +} + // Takes into account absoluting of URLs. function isLocalFootnote(target) { return target.hash.startsWith("#fn") && target.href.indexOf(document.baseURI) === 0; diff --git a/Shared/TextFragmentURL.swift b/Shared/TextFragmentURL.swift new file mode 100644 index 0000000000..05f5d2c740 --- /dev/null +++ b/Shared/TextFragmentURL.swift @@ -0,0 +1,53 @@ +// +// TextFragmentURL.swift +// NetNewsWire +// +// Created by Jérôme Meyer on 1/24/26. +// Copyright © 2026 Ranchero Software. All rights reserved. +// + +import Foundation + +/// Utility for creating text fragment URLs. +/// Text fragments allow linking directly to a highlighted portion of a web page. +/// Format: https://example.com/article#:~:text=selected%20text +enum TextFragmentURL { + + /// Creates a text fragment URL from a base URL and selected text. + /// Returns nil if the URL cannot be created. + static func url(from baseURL: URL, selectedText: String) -> URL? { + guard !selectedText.isEmpty else { + return nil + } + + // Remove any existing fragment from the URL + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + components?.fragment = nil + + guard var urlString = components?.string else { + return nil + } + + // Encode the text for use in a text fragment + guard let encodedText = encodeTextFragment(selectedText) else { + return nil + } + + // Append the text fragment directive + urlString += "#:~:text=\(encodedText)" + + return URL(string: urlString) + } + + private static func encodeTextFragment(_ text: String) -> String? { + // Text fragments require encoding per the spec + // Special characters that need encoding: & - , = + // Plus standard percent encoding for non-ASCII and reserved characters + + var allowed = CharacterSet.urlQueryAllowed + // Remove characters that have special meaning in text fragments + allowed.remove(charactersIn: "&-,=") + + return text.addingPercentEncoding(withAllowedCharacters: allowed) + } +} diff --git a/iOS/Article/PreloadedWebView.swift b/iOS/Article/PreloadedWebView.swift index dc8f2492b3..bf21e28889 100644 --- a/iOS/Article/PreloadedWebView.swift +++ b/iOS/Article/PreloadedWebView.swift @@ -7,10 +7,18 @@ // import Foundation +import UIKit import WebKit +@MainActor protocol PreloadedWebViewDelegate: AnyObject { + var articleURL: URL? { get } + func getSelectedText(completion: @escaping (String?) -> Void) +} + final class PreloadedWebView: WKWebView { + weak var editMenuDelegate: PreloadedWebViewDelegate? + private var isReady: Bool = false private var readyCompletion: (() -> Void)? @@ -48,6 +56,24 @@ final class PreloadedWebView: WKWebView { reload() } } + + override func buildMenu(with builder: any UIMenuBuilder) { + super.buildMenu(with: builder) + + guard builder.system == .context else { + return + } + + let copyLinkAction = UIAction( + title: NSLocalizedString("Copy Link with Highlight", comment: "Copy Link with Highlight"), + image: UIImage(systemName: "link") + ) { [weak self] _ in + self?.copyLinkWithHighlight() + } + + let menu = UIMenu(title: "", options: .displayInline, children: [copyLinkAction]) + builder.insertSibling(menu, afterMenu: .lookup) + } } // MARK: WKScriptMessageHandler @@ -72,4 +98,21 @@ private extension PreloadedWebView { navigationDelegate = nil completion() } + + func copyLinkWithHighlight() { + guard let delegate = editMenuDelegate, let baseURL = delegate.articleURL else { + return + } + + delegate.getSelectedText { selectedText in + let urlToCopy: URL + if let selectedText, !selectedText.isEmpty, + let textFragmentURL = TextFragmentURL.url(from: baseURL, selectedText: selectedText) { + urlToCopy = textFragmentURL + } else { + urlToCopy = baseURL + } + UIPasteboard.general.url = urlToCopy + } + } } diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index cbca90d03c..b033c6d97f 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -282,6 +282,16 @@ final class WebViewController: UIViewController { present(activityViewController, animated: true) } + func getSelectedText(completion: @escaping (String?) -> Void) { + webView?.evaluateJavaScript("getSelectedText()") { result, error in + guard error == nil, let text = result as? String, !text.isEmpty else { + completion(nil) + return + } + completion(text) + } + } + func openInAppBrowser() { guard let url = article?.preferredURL else { return } if AppDefaults.shared.useSystemBrowser { @@ -558,6 +568,7 @@ private extension WebViewController { webView.navigationDelegate = self webView.uiDelegate = self webView.scrollView.delegate = self + webView.editMenuDelegate = self self.configureContextMenuInteraction() // Remove possible existing message handlers @@ -895,3 +906,12 @@ extension WebViewController { } } + +// MARK: PreloadedWebViewDelegate + +extension WebViewController: PreloadedWebViewDelegate { + + var articleURL: URL? { + return article?.preferredURL + } +}