Merge pull request #1199 from kiwix/1197-link-to-current-page

1197 link to current page
This commit is contained in:
Kelson 2025-05-11 09:16:14 +02:00 committed by GitHub
commit 2fe66010e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 347 additions and 241 deletions

View File

@ -18,7 +18,6 @@ import UserNotifications
import Combine
import Defaults
import CoreKiwix
import PassKit
#if os(macOS)
final class AppDelegate: NSObject, NSApplicationDelegate {
@ -37,6 +36,7 @@ struct Kiwix: App {
@State private var selectedAmount: SelectedAmount?
@StateObject var formReset = FormReset()
@FocusState private var isSearchFocused: Bool
@FocusedValue(\.browserURL) var browserURL
init() {
UNUserNotificationCenter.current().delegate = notificationCenterDelegate
@ -78,6 +78,16 @@ struct Kiwix: App {
SidebarNavigationCommands()
Divider()
}
CommandGroup(after: .pasteboard) {
Button(LocalString.library_zim_file_context_copy_url) {
if let browserURL {
CopyPasteMenu.copyToPasteBoard(url: browserURL)
}
}
.disabled(browserURL == nil)
.keyboardShortcut("c", modifiers: [.command, .shift])
}
CommandGroup(after: .textEditing) {
Button(LocalString.common_search) {
isSearchFocused = true
@ -169,231 +179,4 @@ struct Kiwix: App {
}
}
struct RootView: View {
@Environment(\.openWindow) var openWindow
@Environment(\.controlActiveState) var controlActiveState
@StateObject private var navigation = NavigationViewModel()
@State private var currentNavItem: MenuItem?
@StateObject private var windowTracker = WindowTracker()
@State private var paymentButtonLabel: PayWithApplePayButtonLabel?
var isSearchFocused: FocusState<Bool>.Binding
@StateObject private var selection = SelectedZimFileViewModel()
// Open file alerts
@State private var isOpenFileAlertPresented = false
@State private var openFileAlert: OpenFileAlert?
private let primaryItems: [MenuItem] = [.bookmarks]
private let libraryItems: [MenuItem] = [.opened, .categories, .downloads, .new]
private let openURL = NotificationCenter.default.publisher(for: .openURL)
private let appTerminates = NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
private let tabCloses = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)
private let goBackPublisher = NotificationCenter.default.publisher(for: .goBack)
private let goForwardPublisher = NotificationCenter.default.publisher(for: .goForward)
/// Close other tabs then the ones received
private let keepOnlyTabs = NotificationCenter.default.publisher(for: .keepOnlyTabs)
// in essence it's the "zim://" value
private static let zimURL: String = "\(KiwixURLSchemeHandler.ZIMScheme)://"
var body: some View {
NavigationSplitView {
List(selection: $currentNavItem) {
ForEach(
[MenuItem.tab(objectID: navigation.currentTabId)] + primaryItems,
id: \.self
) { menuItem in
Label(menuItem.name, systemImage: menuItem.icon)
}
if FeatureFlags.hasLibrary {
Section(LocalString.app_macos_navigation_button_library) {
ForEach(libraryItems, id: \.self) { menuItem in
Label(menuItem.name, systemImage: menuItem.icon)
}
}
}
}
.frame(minWidth: 160)
.safeAreaInset(edge: .bottom) {
if paymentButtonLabel != nil && Brand.hideDonation != true {
SupportKiwixButton {
openWindow(id: "donation")
}
}
}
} detail: {
switch navigation.currentItem {
case .loading:
LoadingDataView()
case .tab(let tabID):
BrowserTab(tabID: tabID)
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
case .bookmarks:
Bookmarks()
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
case .opened:
ZimFilesMultiOpened()
case .categories:
DetailSidePanel(content: { ZimFilesCategories(dismiss: nil) })
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
case .downloads:
DetailSidePanel(content: { ZimFilesDownloads(dismiss: nil) })
case .new:
DetailSidePanel(content: { ZimFilesNew(dismiss: nil) })
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
default:
EmptyView()
}
}
.frame(minWidth: 650, minHeight: 500)
.focusedSceneValue(\.navigationItem, $navigation.currentItem)
.modifier(AlertHandler())
.modifier(OpenFileHandler())
.modifier(SaveContentHandler())
.environmentObject(navigation)
.onChange(of: currentNavItem) { newValue in
navigation.currentItem = newValue?.navigationItem
}
.onChange(of: navigation.currentItem) { newValue in
guard let newValue else { return }
let navItem = MenuItem(from: newValue)
if currentNavItem != navItem {
currentNavItem = navItem
}
}
.onOpenURL { url in
/// if the app was just started via URL or file (wasn't open before)
/// we want to load the content in the first window
/// otherwise in a new tab (but within the currently active window)
let isAppStart = NSApplication.shared.windows.count == 1
if url.isFileURL {
// from opening an external file
let browser = BrowserViewModel.getCached(tabID: navigation.currentTabId)
if isAppStart {
browser.forceLoadingState()
}
Task { // open the ZIM file
if let metadata = await LibraryOperations.open(url: url),
let mainPageURL = await ZimFileService.shared.getMainPageURL(zimFileID: metadata.fileID) {
if isAppStart {
browser.load(url: mainPageURL)
} else {
browser.createNewWindow(with: mainPageURL)
}
} else {
await browser.clear()
isOpenFileAlertPresented = true
openFileAlert = .unableToOpen(filenames: [url.lastPathComponent])
}
}
} else if url.isZIMURL {
// from deeplinks
if isAppStart {
let browser = BrowserViewModel.getCached(tabID: navigation.currentTabId)
browser.load(url: url)
} else {
Task { @MainActor in
// we want to open the deeplink a new tab (in the currently active window)
// at this point though, the latest tab is active, that received the deeplink handling
// therefore we do need to wait 1 UI cycle, to finish the original
// deeplink handling (hence the Task { @MainActor solution)
// This way we can activate the newly opened tab with the deeplink content in it
let browser = BrowserViewModel.getCached(tabID: navigation.currentTabId)
browser.createNewWindow(with: url)
}
}
}
}
.alert(LocalString.file_import_alert_no_open_title,
isPresented: $isOpenFileAlertPresented, presenting: openFileAlert) { _ in
} message: { alert in
switch alert {
case .unableToOpen(let filenames):
let name = ListFormatter.localizedString(byJoining: filenames)
Text(LocalString.file_import_alert_no_open_message(withArgs: name))
}
}
.onReceive(openURL) { notification in
guard let url = notification.userInfo?["url"] as? URL else {
return
}
guard controlActiveState == .key else { return }
let tabID = navigation.currentTabId
currentNavItem = .tab(objectID: tabID)
BrowserViewModel.getCached(tabID: tabID).load(url: url)
}
.onReceive(tabCloses) { publisher in
// closing one window either by CMD+W || red(X) close button
guard windowTracker.current == publisher.object as? NSWindow else {
// when exiting full screen video, we get the same notification
// but that's not comming from our window
return
}
windowTracker.current = nil // remove the reference to this window, see guard above
guard !navigation.isTerminating else {
// tab closed by app termination
return
}
let tabID = navigation.currentTabId
let browser = BrowserViewModel.getCached(tabID: tabID)
// tab closed by user
browser.pauseVideoWhenNotInPIP()
navigation.deleteTab(tabID: tabID)
}
.onReceive(keepOnlyTabs) {notification in
guard let tabsToKeep = notification.userInfo?["tabIds"] as? Set<NSManagedObjectID> else {
return
}
navigation.keepOnlyTabsBy(tabIds: tabsToKeep)
}
.onReceive(appTerminates) { _ in
// CMD+Q -> Quit Kiwix, this also closes the last window
navigation.isTerminating = true
}.onReceive(goForwardPublisher) { _ in
guard case .tab(let tabID) = navigation.currentItem else {
return
}
BrowserViewModel.getCached(tabID: tabID).webView.goForward()
}.onReceive(goBackPublisher) { [weak navigation] _ in
guard case .tab(let tabID) = navigation?.currentItem else {
return
}
BrowserViewModel.getCached(tabID: tabID).webView.goBack()
}.task {
switch AppType.current {
case .kiwix:
await LibraryOperations.reopen()
currentNavItem = .tab(objectID: navigation.currentTabId)
LibraryOperations.scanDirectory(URL.documentDirectory)
LibraryOperations.applyFileBackupSetting()
DownloadService.shared.restartHeartbeatIfNeeded()
case let .custom(zimFileURL):
await LibraryOperations.open(url: zimFileURL)
ZimMigration.forCustomApps()
currentNavItem = .tab(objectID: navigation.currentTabId)
}
// MARK: - payment button init
if Brand.hideDonation == false {
paymentButtonLabel = await Payment.paymentButtonTypeAsync()
}
// MARK: - migrations
if !ProcessInfo.processInfo.arguments.contains("testing") {
_ = MigrationService().migrateAll()
}
}
// special hook to trigger the zim file search in the nav bar, when a web view is opened
// and the cmd+f is triggering the search in page
.onReceive(NotificationCenter.default.publisher(for: .zimSearch)) { _ in
isSearchFocused.wrappedValue = true
}
.withHostingWindow { [weak windowTracker] hostWindow in
windowTracker?.current = hostWindow
}
.handlesExternalEvents(
preferring: Set([Self.zimURL]),
allowing: Set([Self.zimURL, "file:///"])
)
}
}
#endif

247
App/RootView_macOS.swift Normal file
View File

@ -0,0 +1,247 @@
// This file is part of Kiwix for iOS & macOS.
//
// Kiwix is free software; you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// any later version.
//
// Kiwix is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
#if os(macOS)
import SwiftUI
import PassKit
struct RootView: View {
@Environment(\.openWindow) var openWindow
@Environment(\.controlActiveState) var controlActiveState
@StateObject private var navigation = NavigationViewModel()
@State private var currentNavItem: MenuItem?
@StateObject private var windowTracker = WindowTracker()
@State private var paymentButtonLabel: PayWithApplePayButtonLabel?
var isSearchFocused: FocusState<Bool>.Binding
@StateObject private var selection = SelectedZimFileViewModel()
// Open file alerts
@State private var isOpenFileAlertPresented = false
@State private var openFileAlert: OpenFileAlert?
private let primaryItems: [MenuItem] = [.bookmarks]
private let libraryItems: [MenuItem] = [.opened, .categories, .downloads, .new]
private let openURL = NotificationCenter.default.publisher(for: .openURL)
private let appTerminates = NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
private let tabCloses = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)
private let goBackPublisher = NotificationCenter.default.publisher(for: .goBack)
private let goForwardPublisher = NotificationCenter.default.publisher(for: .goForward)
/// Close other tabs then the ones received
private let keepOnlyTabs = NotificationCenter.default.publisher(for: .keepOnlyTabs)
// in essence it's the "zim://" value
private static let zimURL: String = "\(KiwixURLSchemeHandler.ZIMScheme)://"
var body: some View {
NavigationSplitView {
List(selection: $currentNavItem) {
ForEach(
[MenuItem.tab(objectID: navigation.currentTabId)] + primaryItems,
id: \.self
) { menuItem in
Label(menuItem.name, systemImage: menuItem.icon)
}
if FeatureFlags.hasLibrary {
Section(LocalString.app_macos_navigation_button_library) {
ForEach(libraryItems, id: \.self) { menuItem in
Label(menuItem.name, systemImage: menuItem.icon)
}
}
}
}
.frame(minWidth: 160)
.safeAreaInset(edge: .bottom) {
if paymentButtonLabel != nil && Brand.hideDonation != true {
SupportKiwixButton {
openWindow(id: "donation")
}
}
}
} detail: {
switch navigation.currentItem {
case .loading:
LoadingDataView()
case .tab(let tabID):
BrowserTab(tabID: tabID)
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
case .bookmarks:
Bookmarks()
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
case .opened:
ZimFilesMultiOpened()
case .categories:
DetailSidePanel(content: { ZimFilesCategories(dismiss: nil) })
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
case .downloads:
DetailSidePanel(content: { ZimFilesDownloads(dismiss: nil) })
case .new:
DetailSidePanel(content: { ZimFilesNew(dismiss: nil) })
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
default:
EmptyView()
}
}
.frame(minWidth: 650, minHeight: 500)
.focusedSceneValue(\.navigationItem, $navigation.currentItem)
.modifier(AlertHandler())
.modifier(OpenFileHandler())
.modifier(SaveContentHandler())
.environmentObject(navigation)
.onChange(of: currentNavItem) { newValue in
navigation.currentItem = newValue?.navigationItem
}
.onChange(of: navigation.currentItem) { newValue in
guard let newValue else { return }
let navItem = MenuItem(from: newValue)
if currentNavItem != navItem {
currentNavItem = navItem
}
}
.onOpenURL { url in
/// if the app was just started via URL or file (wasn't open before)
/// we want to load the content in the first window
/// otherwise in a new tab (but within the currently active window)
let isAppStart = NSApplication.shared.windows.count == 1
if url.isFileURL {
// from opening an external file
let browser = BrowserViewModel.getCached(tabID: navigation.currentTabId)
if isAppStart {
browser.forceLoadingState()
}
Task { // open the ZIM file
if let metadata = await LibraryOperations.open(url: url),
let mainPageURL = await ZimFileService.shared.getMainPageURL(zimFileID: metadata.fileID) {
if isAppStart {
browser.load(url: mainPageURL)
} else {
browser.createNewWindow(with: mainPageURL)
}
} else {
await browser.clear()
isOpenFileAlertPresented = true
openFileAlert = .unableToOpen(filenames: [url.lastPathComponent])
}
}
} else if url.isZIMURL {
// from deeplinks
if isAppStart {
let browser = BrowserViewModel.getCached(tabID: navigation.currentTabId)
browser.load(url: url)
} else {
Task { @MainActor in
// we want to open the deeplink a new tab (in the currently active window)
// at this point though, the latest tab is active, that received the deeplink handling
// therefore we do need to wait 1 UI cycle, to finish the original
// deeplink handling (hence the Task { @MainActor solution)
// This way we can activate the newly opened tab with the deeplink content in it
let browser = BrowserViewModel.getCached(tabID: navigation.currentTabId)
browser.createNewWindow(with: url)
}
}
}
}
.alert(LocalString.file_import_alert_no_open_title,
isPresented: $isOpenFileAlertPresented, presenting: openFileAlert) { _ in
} message: { alert in
switch alert {
case .unableToOpen(let filenames):
let name = ListFormatter.localizedString(byJoining: filenames)
Text(LocalString.file_import_alert_no_open_message(withArgs: name))
}
}
.onReceive(openURL) { notification in
guard let url = notification.userInfo?["url"] as? URL else {
return
}
guard controlActiveState == .key else { return }
let tabID = navigation.currentTabId
currentNavItem = .tab(objectID: tabID)
BrowserViewModel.getCached(tabID: tabID).load(url: url)
}
.onReceive(tabCloses) { publisher in
// closing one window either by CMD+W || red(X) close button
guard windowTracker.current == publisher.object as? NSWindow else {
// when exiting full screen video, we get the same notification
// but that's not comming from our window
return
}
windowTracker.current = nil // remove the reference to this window, see guard above
guard !navigation.isTerminating else {
// tab closed by app termination
return
}
let tabID = navigation.currentTabId
let browser = BrowserViewModel.getCached(tabID: tabID)
// tab closed by user
browser.pauseVideoWhenNotInPIP()
navigation.deleteTab(tabID: tabID)
}
.onReceive(keepOnlyTabs) {notification in
guard let tabsToKeep = notification.userInfo?["tabIds"] as? Set<NSManagedObjectID> else {
return
}
navigation.keepOnlyTabsBy(tabIds: tabsToKeep)
}
.onReceive(appTerminates) { _ in
// CMD+Q -> Quit Kiwix, this also closes the last window
navigation.isTerminating = true
}.onReceive(goForwardPublisher) { _ in
guard case .tab(let tabID) = navigation.currentItem else {
return
}
BrowserViewModel.getCached(tabID: tabID).webView.goForward()
}.onReceive(goBackPublisher) { [weak navigation] _ in
guard case .tab(let tabID) = navigation?.currentItem else {
return
}
BrowserViewModel.getCached(tabID: tabID).webView.goBack()
}.task {
switch AppType.current {
case .kiwix:
await LibraryOperations.reopen()
currentNavItem = .tab(objectID: navigation.currentTabId)
LibraryOperations.scanDirectory(URL.documentDirectory)
LibraryOperations.applyFileBackupSetting()
DownloadService.shared.restartHeartbeatIfNeeded()
case let .custom(zimFileURL):
await LibraryOperations.open(url: zimFileURL)
ZimMigration.forCustomApps()
currentNavItem = .tab(objectID: navigation.currentTabId)
}
// MARK: - payment button init
if Brand.hideDonation == false {
paymentButtonLabel = await Payment.paymentButtonTypeAsync()
}
// MARK: - migrations
if !ProcessInfo.processInfo.arguments.contains("testing") {
_ = MigrationService().migrateAll()
}
}
// special hook to trigger the zim file search in the nav bar, when a web view is opened
// and the cmd+f is triggering the search in page
.onReceive(NotificationCenter.default.publisher(for: .zimSearch)) { _ in
isSearchFocused.wrappedValue = true
}
.withHostingWindow { [weak windowTracker] hostWindow in
windowTracker?.current = hostWindow
}
.handlesExternalEvents(
preferring: Set([Self.zimURL]),
allowing: Set([Self.zimURL, "file:///"])
)
}
}
#endif

View File

@ -39,6 +39,7 @@
"common.button.no" = "no";
"common.button.print" = "Print";
"common.button.share" = "Share";
"common.button.share_as_pdf" = "Share as PDF";
"common.search" = "Search";
"common.tab.manager.title" = "Tabs Manager";

View File

@ -23,6 +23,7 @@
"common.button.no" = "It is a title in the summary table on macOS: we list the attributes of a ZIM file: does it contain pictures yes/no, does it contain Videos yes/no, Details? yes/no";
"common.button.print" = "Accessibility label for button to print the currently loaded article";
"common.button.share" = "Accessibility label for share button, to share the currently loaded article with an external app.";
"common.button.share_as_pdf" = "Accesibility label for share button. to share the curretly loadded article with an external application in a PDF file format.";
"common.search" = "The default placeholder text for searchbars, when the search input field is empty";
"common.tab.manager.title" = "Accessibility label for button tab bar button that opens an overlay menu.";
"common.tab.navigation.title" = "On iPad it is a title in the sidemenu grouping tabs related buttons";

View File

@ -77,12 +77,21 @@ struct BrowserTab: View {
}
#else
if !Brand.hideShareButton {
ExportButton(
relativeToView: browser.webView,
webViewURL: browser.webView.url,
pageDataWithExtension: { [weak browser] in await browser?.pageDataWithExtension() },
isButtonDisabled: browser.zimFileName.isEmpty
)
Menu {
ExportButton(
relativeToView: browser.webView,
webViewURL: browser.webView.url,
pageDataWithExtension: { [weak browser] in await browser?.pageDataWithExtension() },
isButtonDisabled: browser.zimFileName.isEmpty,
buttonLabel: LocalString.common_button_share_as_pdf
)
if let url = browser.webView.url {
CopyPasteMenu(url: url)
.keyboardShortcut("c", modifiers: [.command, .shift])
}
} label: {
Label(LocalString.common_button_share, systemImage: "square.and.arrow.up")
}.disabled(browser.webView.url == nil)
}
if !Brand.hidePrintButton {
PrintButton(browserURLName: { [weak browser] in
@ -112,6 +121,9 @@ struct BrowserTab: View {
}
.environmentObject(search)
.focusedSceneValue(\.isBrowserURLSet, browser.url != nil)
#if os(macOS)
.focusedSceneValue(\.browserURL, browser.url)
#endif
.focusedSceneValue(\.canGoBack, browser.canGoBack)
.focusedSceneValue(\.canGoForward, browser.canGoForward)
.modifier(ExternalLinkHandler(externalURL: $browser.externalURL))

View File

@ -18,18 +18,24 @@ import UniformTypeIdentifiers
struct CopyPasteMenu: View {
let downloadURL: URL
let url: URL
var body: some View {
Button {
#if os(macOS)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(downloadURL.absoluteString, forType: .string)
Self.copyToPasteBoard(url: url)
#elseif os(iOS)
UIPasteboard.general.setValue(downloadURL.absoluteString, forPasteboardType: UTType.url.identifier)
UIPasteboard.general.setValue(url.absoluteString, forPasteboardType: UTType.url.identifier)
#endif
} label: {
Label(LocalString.library_zim_file_context_copy_url, systemImage: "doc.on.doc")
}
}
#if os(macOS)
public static func copyToPasteBoard(url: URL) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url.absoluteString, forType: .string)
}
#endif
}

View File

@ -23,6 +23,8 @@ struct ExportButton: View {
let webViewURL: URL?
let pageDataWithExtension: () async -> (Data, String?)?
let isButtonDisabled: Bool
var buttonLabel: String = LocalString.common_button_share
/// - Returns: Returns the browser data, fileName and extension
private func dataNameAndExtension() async -> FileExportData? {
@ -48,12 +50,22 @@ struct ExportButton: View {
NotificationCenter.exportFileData(exportData)
#else
guard let url = await tempFileURL() else { return }
NSSharingServicePicker(items: [url]).show(relativeTo: .null, of: relativeToView, preferredEdge: .minY)
NSSharingServicePicker(items: [url]).show(
relativeTo: NSRect(
origin: .zero,
size: CGSize(
width: 640,
height: 54
)
),
of: relativeToView,
preferredEdge: .minY
)
#endif
}
} label: {
Label {
Text(LocalString.common_button_share)
Text(buttonLabel)
} icon: {
Image(systemName: "square.and.arrow.up")
}

View File

@ -25,6 +25,10 @@ struct IsBrowserURLSet: FocusedValueKey {
typealias Value = Bool
}
struct BrowserURL: FocusedValueKey {
typealias Value = URL
}
struct CanGoBackKey: FocusedValueKey {
typealias Value = Bool
}
@ -38,6 +42,12 @@ struct NavigationItemKey: FocusedValueKey {
}
extension FocusedValues {
#if os(macOS)
var browserURL: BrowserURL.Value? {
get { self[BrowserURL.self] }
set { self[BrowserURL.self] = newValue}
}
#endif
var isBrowserURLSet: IsBrowserURLSet.Value? {
get { self[IsBrowserURLSet.self] }
set { self[IsBrowserURLSet.self] = newValue }

View File

@ -23,7 +23,7 @@ struct ZimFileContextMenu: View {
Section { ArticleActions(zimFileID: zimFile.fileID) }
}
if let downloadURL = zimFile.downloadURL {
Section { CopyPasteMenu(downloadURL: downloadURL) }
Section { CopyPasteMenu(url: downloadURL) }
}
}
}

View File

@ -0,0 +1,34 @@
// This file is part of Kiwix for iOS & macOS.
//
// Kiwix is free software; you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// any later version.
//
// Kiwix is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
#if os(macOS)
import SwiftUI
struct CopyURLContext: ViewModifier {
@State var url: URL?
func body(content: Content) -> some View {
if let url {
content.contextMenu {
CopyPasteMenu(url: url)
}
} else {
content
}
}
}
#endif