Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Shared/Article Rendering/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions Shared/TextFragmentURL.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 43 additions & 0 deletions iOS/Article/PreloadedWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?

Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
}
20 changes: 20 additions & 0 deletions iOS/Article/WebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -895,3 +906,12 @@ extension WebViewController {
}

}

// MARK: PreloadedWebViewDelegate

extension WebViewController: PreloadedWebViewDelegate {

var articleURL: URL? {
return article?.preferredURL
}
}