From 85b08667ae4054377647ef61b8c68e2dc08247f0 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Fri, 13 Mar 2026 03:44:47 +0100 Subject: [PATCH] long press copy link text --- .../contextmenu/extensions/hit_result.dart | 14 ++++- .../candidates/copy_link_text.dart | 54 +++++++++++++++++++ .../presentation/context_menu_dialog.dart | 3 ++ .../middleware/FlutterEventMiddleware.kt | 2 +- .../pigeons/Gecko.g.kt | 7 ++- .../lib/src/pigeons/gecko.g.dart | 5 ++ .../pigeons/gecko.dart | 3 +- 7 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link_text.dart diff --git a/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart b/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart index 06744e5b..8cf48b7b 100644 --- a/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart +++ b/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart @@ -36,6 +36,13 @@ extension HitResultJson on HitResult { } extension HitResultX on HitResult { + String? getLinkText() { + return switch (this) { + UnknownHitResult(linkText: final linkText) => linkText, + _ => null, + }; + } + Uri? tryGetLink() { const maxTitleLength = 2500; @@ -91,6 +98,8 @@ extension HitResultX on HitResult { bool get hasLink => tryGetLink() != null; + bool get hasLinkText => getLinkText()?.isNotEmpty == true; + bool isImage() { return (this is ImageHitResult || this is ImageSrcHitResult) && hasSrc; } @@ -131,7 +140,10 @@ extension HitResultX on HitResult { HitResult withCleanedLink(String cleanedUrl) { return switch (this) { - UnknownHitResult() => UnknownHitResult(src: cleanedUrl), + UnknownHitResult(:final linkText) => UnknownHitResult( + src: cleanedUrl, + linkText: linkText, + ), ImageSrcHitResult(:final src) => ImageSrcHitResult( src: src, uri: cleanedUrl, diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link_text.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link_text.dart new file mode 100644 index 00000000..5d7d4ecb --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link_text.dart @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; + +class CopyLinkText extends HookConsumerWidget { + final HitResult hitResult; + + const CopyLinkText({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isUri() && hitResult.hasLinkText; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.textShort), + title: const Text('Copy link text'), + onTap: () async { + await hitResult.getLinkText().mapNotNull((linkText) async { + await Clipboard.setData(ClipboardData(text: linkText)); + + if (context.mounted) { + context.pop(); + } + }); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart index 5d229604..7fdb8e78 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart @@ -28,6 +28,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/presentation/ca import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_image_location.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_link.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_link_text.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart'; @@ -112,6 +113,8 @@ class ContextMenuDialog extends HookConsumerWidget { if (showContainerUi && OpenInContainer.isSupported(effective)) OpenInContainer(hitResult: effective), if (CopyLink.isSupported(effective)) CopyLink(hitResult: effective), + if (CopyLinkText.isSupported(effective)) + CopyLinkText(hitResult: effective), if (SaveFile.isSupported(effective)) SaveFile(hitResult: effective), if (ShareLink.isSupported(effective)) ShareLink(hitResult: effective), if (ShareImage.isSupported(effective)) ShareImage(hitResult: effective), diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt index 2e07d774..da114f8c 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt @@ -104,7 +104,7 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd is HitResult.IMAGE -> ImageHitResult(result.src, result.title) is HitResult.IMAGE_SRC -> ImageSrcHitResult(result.src, result.uri) is HitResult.PHONE -> PhoneHitResult(result.src) - is HitResult.UNKNOWN -> UnknownHitResult(result.src) + is HitResult.UNKNOWN -> UnknownHitResult(result.src, result.linkText) is HitResult.VIDEO -> VideoHitResult(result.src, result.title) } ) { _ -> } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index 124caa25..2d416f0d 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -2468,18 +2468,21 @@ sealed class HitResult * Generated class from Pigeon that represents data sent in messages. */ data class UnknownHitResult ( - val src: String + val src: String, + val linkText: String? = null ) : HitResult() { companion object { fun fromList(pigeonVar_list: List): UnknownHitResult { val src = pigeonVar_list[0] as String - return UnknownHitResult(src) + val linkText = pigeonVar_list[1] as String? + return UnknownHitResult(src, linkText) } } fun toList(): List { return listOf( src, + linkText, ) } override fun equals(other: Any?): Boolean { diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 2c883e1a..3301d59c 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -3009,13 +3009,17 @@ sealed class HitResult { class UnknownHitResult extends HitResult { UnknownHitResult({ required this.src, + this.linkText, }); String src; + String? linkText; + List _toList() { return [ src, + linkText, ]; } @@ -3026,6 +3030,7 @@ class UnknownHitResult extends HitResult { result as List; return UnknownHitResult( src: result[0]! as String, + linkText: result[1] as String?, ); } diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index c81de867..f02843fb 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -968,8 +968,9 @@ sealed class HitResult {} /// Default type if we're unable to match the type to anything. It may or may not have a src. class UnknownHitResult extends HitResult { final String src; + final String? linkText; - UnknownHitResult(this.src); + UnknownHitResult(this.src, {this.linkText}); } /// If the HTML element was of type 'HTMLImageElement'.