Skip to content

Commit b429266

Browse files
jpgmeyerclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 6665d01 commit b429266

5 files changed

Lines changed: 145 additions & 3 deletions

File tree

Shared/Article Rendering/main.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ function error() {
136136
document.body.innerHTML = "error";
137137
}
138138

139+
// Returns the currently selected text in the WebView, or empty string if no selection
140+
function getSelectedText() {
141+
const selection = window.getSelection();
142+
return selection ? selection.toString().trim() : "";
143+
}
144+
139145
// Takes into account absoluting of URLs.
140146
function isLocalFootnote(target) {
141147
return target.hash.startsWith("#fn") && target.href.indexOf(document.baseURI) === 0;

Shared/TextFragmentURL.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// TextFragmentURL.swift
3+
// NetNewsWire
4+
//
5+
// Created by Jérôme Meyer on 1/24/26.
6+
// Copyright © 2026 Ranchero Software. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
/// Utility for creating text fragment URLs.
12+
/// Text fragments allow linking directly to a highlighted portion of a web page.
13+
/// Format: https://example.com/article#:~:text=selected%20text
14+
enum TextFragmentURL {
15+
16+
/// Creates a text fragment URL from a base URL and selected text.
17+
/// Returns nil if the URL cannot be created.
18+
static func url(from baseURL: URL, selectedText: String) -> URL? {
19+
guard !selectedText.isEmpty else {
20+
return nil
21+
}
22+
23+
// Remove any existing fragment from the URL
24+
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
25+
components?.fragment = nil
26+
27+
guard var urlString = components?.string else {
28+
return nil
29+
}
30+
31+
// Encode the text for use in a text fragment
32+
guard let encodedText = encodeTextFragment(selectedText) else {
33+
return nil
34+
}
35+
36+
// Append the text fragment directive
37+
urlString += "#:~:text=\(encodedText)"
38+
39+
return URL(string: urlString)
40+
}
41+
42+
private static func encodeTextFragment(_ text: String) -> String? {
43+
// Text fragments require encoding per the spec
44+
// Special characters that need encoding: & - , =
45+
// Plus standard percent encoding for non-ASCII and reserved characters
46+
47+
var allowed = CharacterSet.urlQueryAllowed
48+
// Remove characters that have special meaning in text fragments
49+
allowed.remove(charactersIn: "&-,=")
50+
51+
return text.addingPercentEncoding(withAllowedCharacters: allowed)
52+
}
53+
}

iOS/Article/PreloadedWebView.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,18 @@
77
//
88

99
import Foundation
10+
import UIKit
1011
import WebKit
1112

13+
@MainActor protocol PreloadedWebViewDelegate: AnyObject {
14+
var articleURL: URL? { get }
15+
func getSelectedText(completion: @escaping (String?) -> Void)
16+
}
17+
1218
final class PreloadedWebView: WKWebView {
1319

20+
weak var editMenuDelegate: PreloadedWebViewDelegate?
21+
1422
private var isReady: Bool = false
1523
private var readyCompletion: (() -> Void)?
1624

@@ -48,6 +56,24 @@ final class PreloadedWebView: WKWebView {
4856
reload()
4957
}
5058
}
59+
60+
override func buildMenu(with builder: any UIMenuBuilder) {
61+
super.buildMenu(with: builder)
62+
63+
guard builder.system == .context else {
64+
return
65+
}
66+
67+
let copyLinkAction = UIAction(
68+
title: NSLocalizedString("Copy Link with Highlight", comment: "Copy Link with Highlight"),
69+
image: UIImage(systemName: "link")
70+
) { [weak self] _ in
71+
self?.copyLinkWithHighlight()
72+
}
73+
74+
let menu = UIMenu(title: "", options: .displayInline, children: [copyLinkAction])
75+
builder.insertSibling(menu, afterMenu: .lookup)
76+
}
5177
}
5278

5379
// MARK: WKScriptMessageHandler
@@ -72,4 +98,21 @@ private extension PreloadedWebView {
7298
navigationDelegate = nil
7399
completion()
74100
}
101+
102+
func copyLinkWithHighlight() {
103+
guard let delegate = editMenuDelegate, let baseURL = delegate.articleURL else {
104+
return
105+
}
106+
107+
delegate.getSelectedText { selectedText in
108+
let urlToCopy: URL
109+
if let selectedText, !selectedText.isEmpty,
110+
let textFragmentURL = TextFragmentURL.url(from: baseURL, selectedText: selectedText) {
111+
urlToCopy = textFragmentURL
112+
} else {
113+
urlToCopy = baseURL
114+
}
115+
UIPasteboard.general.url = urlToCopy
116+
}
117+
}
75118
}

iOS/Article/WebViewController.swift

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -277,9 +277,23 @@ final class WebViewController: UIViewController {
277277

278278
func showActivityDialog(popOverBarButtonItem: UIBarButtonItem? = nil) {
279279
guard let url = article?.preferredURL else { return }
280-
let activityViewController = UIActivityViewController(url: url, title: article?.title, applicationActivities: [FindInArticleActivity(), OpenInBrowserActivity()])
281-
activityViewController.popoverPresentationController?.barButtonItem = popOverBarButtonItem
282-
present(activityViewController, animated: true)
280+
281+
getSelectedText { [weak self] selectedText in
282+
guard let self else { return }
283+
let activityViewController = UIActivityViewController(url: url, title: self.article?.title, selectedText: selectedText, applicationActivities: [FindInArticleActivity(), OpenInBrowserActivity()])
284+
activityViewController.popoverPresentationController?.barButtonItem = popOverBarButtonItem
285+
self.present(activityViewController, animated: true)
286+
}
287+
}
288+
289+
func getSelectedText(completion: @escaping (String?) -> Void) {
290+
webView?.evaluateJavaScript("getSelectedText()") { result, error in
291+
guard error == nil, let text = result as? String, !text.isEmpty else {
292+
completion(nil)
293+
return
294+
}
295+
completion(text)
296+
}
283297
}
284298

285299
func openInAppBrowser() {
@@ -558,6 +572,7 @@ private extension WebViewController {
558572
webView.navigationDelegate = self
559573
webView.uiDelegate = self
560574
webView.scrollView.delegate = self
575+
webView.editMenuDelegate = self
561576
self.configureContextMenuInteraction()
562577

563578
// Remove possible existing message handlers
@@ -895,3 +910,12 @@ extension WebViewController {
895910
}
896911

897912
}
913+
914+
// MARK: PreloadedWebViewDelegate
915+
916+
extension WebViewController: PreloadedWebViewDelegate {
917+
918+
var articleURL: URL? {
919+
return article?.preferredURL
920+
}
921+
}

iOS/UIKit Extensions/UIActivityViewController+Extras.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ public extension UIActivityViewController {
1818

1919
self.init(activityItems: [titleSource, itemSource], applicationActivities: applicationActivities)
2020
}
21+
22+
convenience init(url: URL, title: String?, selectedText: String?, applicationActivities: [UIActivity]?) {
23+
// Use text fragment URL when text is selected
24+
let shareURL: URL
25+
if let selectedText, !selectedText.isEmpty,
26+
let textFragmentURL = TextFragmentURL.url(from: url, selectedText: selectedText) {
27+
shareURL = textFragmentURL
28+
} else {
29+
shareURL = url
30+
}
31+
32+
let itemSource = ArticleActivityItemSource(url: shareURL, subject: title)
33+
let titleSource = TitleActivityItemSource(title: title)
34+
35+
self.init(activityItems: [titleSource, itemSource], applicationActivities: applicationActivities)
36+
}
2137
}
2238

2339
#endif

0 commit comments

Comments
 (0)