From d81233261390975d6045c23f7c088dcdb8112a9f Mon Sep 17 00:00:00 2001 From: nyne Date: Wed, 26 Mar 2025 22:50:00 +0800 Subject: [PATCH] Add image copy functionality. Currently only supports Windows. Close #260 --- assets/translation.json | 6 ++-- lib/pages/reader/comic_image.dart | 17 +++++++++++ lib/pages/reader/gesture.dart | 16 +++++++++++ lib/pages/reader/images.dart | 48 +++++++++++++++++++++++++++++++ lib/pages/reader/reader.dart | 3 ++ lib/utils/clipboard_image.dart | 22 ++++++++++++++ windows/runner/flutter_window.cpp | 41 ++++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 lib/utils/clipboard_image.dart diff --git a/assets/translation.json b/assets/translation.json index 9a5ab79..e472a5b 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -376,7 +376,8 @@ "Show Page Number": "显示页码", "Jump to page": "跳转到页面", "Page": "页面", - "Jump": "跳转" + "Jump": "跳转", + "Copy Image": "复制图片" }, "zh_TW": { "Home": "首頁", @@ -755,6 +756,7 @@ "Show Page Number": "顯示頁碼", "Jump to page": "跳轉到頁面", "Page": "頁面", - "Jump": "跳轉" + "Jump": "跳轉", + "Copy Image": "複製圖片" } } diff --git a/lib/pages/reader/comic_image.dart b/lib/pages/reader/comic_image.dart index 7f6d5e3..ff8a2da 100644 --- a/lib/pages/reader/comic_image.dart +++ b/lib/pages/reader/comic_image.dart @@ -24,6 +24,8 @@ class ComicImage extends StatefulWidget { Map? headers, int? cacheWidth, int? cacheHeight, + this.onInit, + this.onDispose, }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image), assert(cacheWidth == null || cacheWidth > 0), assert(cacheHeight == null || cacheHeight > 0); @@ -60,6 +62,10 @@ class ComicImage extends StatefulWidget { final bool isAntiAlias; + final void Function(State state)? onInit; + + final void Function(State state)? onDispose; + static void clear() => _ComicImageState.clear(); @override @@ -87,6 +93,7 @@ class _ComicImageState extends State with WidgetsBindingObserver { super.initState(); WidgetsBinding.instance.addObserver(this); _scrollAwareContext = DisposableBuildContext>(this); + widget.onInit?.call(this); } @override @@ -97,6 +104,7 @@ class _ComicImageState extends State with WidgetsBindingObserver { _completerHandle?.dispose(); _scrollAwareContext.dispose(); _replaceImage(info: null); + widget.onDispose?.call(this); super.dispose(); } @@ -136,6 +144,15 @@ class _ComicImageState extends State with WidgetsBindingObserver { super.reassemble(); } + bool containsPoint(Offset point) { + if (!mounted) { + return false; + } + var renderBox = context.findRenderObject() as RenderBox; + var localPoint = renderBox.globalToLocal(point); + return renderBox.paintBounds.contains(localPoint); + } + void _updateInvertColors() { _invertColors = MediaQuery.maybeInvertColorsOf(context) ?? SemanticsBinding.instance.accessibilityFeatures.invertColors; diff --git a/lib/pages/reader/gesture.dart b/lib/pages/reader/gesture.dart index 0b3928c..04ef114 100644 --- a/lib/pages/reader/gesture.dart +++ b/lib/pages/reader/gesture.dart @@ -281,6 +281,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet context.pop(); }, ), + if (App.isDesktop && !reader.isLoading) + MenuEntry( + icon: Icons.copy, + text: "Copy Image".tl, + onClick: () => copyImage(location), + ), ], ); } @@ -303,6 +309,16 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet @override Object? get key => "reader_gesture"; + + void copyImage(Offset location) async { + var controller = reader._imageViewController; + var image = await controller!.getImageByOffset(location); + if (image != null) { + writeImageToClipboard(image); + } else { + context.showMessage(message: "No Image"); + } + } } class _DragListener { diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index cce4b09..3ea42b0 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -113,6 +113,8 @@ class _GalleryModeState extends State<_GalleryMode> int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil(); + var imageStates = >{}; + @override void initState() { reader = context.reader; @@ -234,6 +236,8 @@ class _GalleryModeState extends State<_GalleryMode> child: ComicImage( image: imageProvider, fit: BoxFit.contain, + onInit: (state) => imageStates.add(state), + onDispose: (state) => imageStates.remove(state), ), ); }).toList(); @@ -370,6 +374,28 @@ class _GalleryModeState extends State<_GalleryMode> bool handleOnTap(Offset location) { return false; } + + @override + Future getImageByOffset(Offset offset) async { + String? imageKey; + if (reader.imagesPerPage == 1) { + imageKey = reader.images![reader.page - 1]; + } else { + for (var imageState in imageStates) { + if ((imageState as _ComicImageState).containsPoint(offset)) { + imageKey = (imageState.widget.image as ReaderImageProvider).imageKey; + } + } + } + if (imageKey == null) return null; + if (imageKey.startsWith("file://")) { + return await File(imageKey.substring(7)).readAsBytes(); + } else { + return (await CacheManager().findCache( + "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! + .readAsBytes(); + } + } } const Set _kTouchLikeDeviceTypes = { @@ -414,6 +440,8 @@ class _ContinuousModeState extends State<_ContinuousMode> /// To handle the tap event, we need to know if the user was scrolling before the delay. bool delayedIsScrolling = false; + var imageStates = >{}; + void delayedSetIsScrolling(bool value) { Future.delayed( const Duration(milliseconds: 300), @@ -574,6 +602,8 @@ class _ContinuousModeState extends State<_ContinuousMode> width: width, height: height, fit: BoxFit.contain, + onInit: (state) => imageStates.add(state), + onDispose: (state) => imageStates.remove(state), ), ); }, @@ -857,6 +887,24 @@ class _ContinuousModeState extends State<_ContinuousMode> } return false; } + + @override + Future getImageByOffset(Offset offset) async { + String? imageKey; + for (var imageState in imageStates) { + if ((imageState as _ComicImageState).containsPoint(offset)) { + imageKey = (imageState.widget.image as ReaderImageProvider).imageKey; + } + } + if (imageKey == null) return null; + if (imageKey.startsWith("file://")) { + return await File(imageKey.substring(7)).readAsBytes(); + } else { + return (await CacheManager().findCache( + "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! + .readAsBytes(); + } + } } ImageProvider _createImageProviderFromKey( diff --git a/lib/pages/reader/reader.dart b/lib/pages/reader/reader.dart index cb75c04..5321f10 100644 --- a/lib/pages/reader/reader.dart +++ b/lib/pages/reader/reader.dart @@ -30,6 +30,7 @@ import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/pages/settings/settings_page.dart'; +import 'package:venera/utils/clipboard_image.dart'; import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/file_type.dart'; @@ -577,4 +578,6 @@ abstract interface class _ImageViewController { /// Returns true if the event is handled. bool handleOnTap(Offset location); + + Future getImageByOffset(Offset offset); } diff --git a/lib/utils/clipboard_image.dart b/lib/utils/clipboard_image.dart new file mode 100644 index 0000000..93d5eac --- /dev/null +++ b/lib/utils/clipboard_image.dart @@ -0,0 +1,22 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/services.dart'; + +Future writeImageToClipboard(Uint8List imageBytes) async { + const channel = MethodChannel("venera/clipboard"); + if (Platform.isWindows) { + var image = await instantiateImageCodec(imageBytes); + var frame = await image.getNextFrame(); + var data = await frame.image.toByteData(format: ImageByteFormat.rawRgba); + await channel.invokeMethod("writeImageToClipboard", { + "width": frame.image.width, + "height": frame.image.height, + "data": Uint8List.view(data!.buffer) + }); + image.dispose(); + } else { + // TODO: Implement for other platforms + throw UnsupportedError("Clipboard image is not supported on this platform"); + } +} diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index f22f2f4..f379ea8 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -102,6 +102,47 @@ bool FlutterWindow::OnCreate() { channel2.SetStreamHandler(std::move(eventHandler)); + const flutter::MethodChannel<> channel3( + flutter_controller_->engine()->messenger(), "venera/clipboard", + &flutter::StandardMethodCodec::GetInstance() + ); + channel3.SetMethodCallHandler( + [](const flutter::MethodCall<>& call,const std::unique_ptr>& result) { + if(call.method_name() == "writeImageToClipboard"){ + flutter::EncodableMap arguments = std::get(*call.arguments()); + std::vector data = std::get>(arguments["data"]); + std::int32_t width = std::get(arguments["width"]); + std::int32_t height = std::get(arguments["height"]); + + // convert rgba to bgra + for (int i = 0; i < data.size()/4; i++) { + uint8_t temp = data[i * 4]; + data[i * 4] = data[i * 4 + 2]; + data[i * 4 + 2] = temp; + } + + auto bitmap = CreateBitmap((int)width, (int)height, 1, 32, data.data()); + + if (!bitmap) { + result->Error("0", "Invalid Image Data"); + return; + } + + if (OpenClipboard(NULL)) + { + EmptyClipboard(); + SetClipboardData(CF_BITMAP, bitmap); + CloseClipboard(); + result->Success(); + } + else { + result->Error("Failed to open clipboard"); + } + + DeleteObject(bitmap); + } + }); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); flutter_controller_->engine()->SetNextFrameCallback([&]() {