Add image copy functionality.

Currently only supports Windows.
Close #260
This commit is contained in:
2025-03-26 22:50:00 +08:00
parent dee8d17b1e
commit d812332613
7 changed files with 151 additions and 2 deletions

View File

@@ -376,7 +376,8 @@
"Show Page Number": "显示页码", "Show Page Number": "显示页码",
"Jump to page": "跳转到页面", "Jump to page": "跳转到页面",
"Page": "页面", "Page": "页面",
"Jump": "跳转" "Jump": "跳转",
"Copy Image": "复制图片"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -755,6 +756,7 @@
"Show Page Number": "顯示頁碼", "Show Page Number": "顯示頁碼",
"Jump to page": "跳轉到頁面", "Jump to page": "跳轉到頁面",
"Page": "頁面", "Page": "頁面",
"Jump": "跳轉" "Jump": "跳轉",
"Copy Image": "複製圖片"
} }
} }

View File

@@ -24,6 +24,8 @@ class ComicImage extends StatefulWidget {
Map<String, String>? headers, Map<String, String>? headers,
int? cacheWidth, int? cacheWidth,
int? cacheHeight, int? cacheHeight,
this.onInit,
this.onDispose,
}) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image), }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0), assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0); assert(cacheHeight == null || cacheHeight > 0);
@@ -60,6 +62,10 @@ class ComicImage extends StatefulWidget {
final bool isAntiAlias; final bool isAntiAlias;
final void Function(State<ComicImage> state)? onInit;
final void Function(State<ComicImage> state)? onDispose;
static void clear() => _ComicImageState.clear(); static void clear() => _ComicImageState.clear();
@override @override
@@ -87,6 +93,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_scrollAwareContext = DisposableBuildContext<State<ComicImage>>(this); _scrollAwareContext = DisposableBuildContext<State<ComicImage>>(this);
widget.onInit?.call(this);
} }
@override @override
@@ -97,6 +104,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
_completerHandle?.dispose(); _completerHandle?.dispose();
_scrollAwareContext.dispose(); _scrollAwareContext.dispose();
_replaceImage(info: null); _replaceImage(info: null);
widget.onDispose?.call(this);
super.dispose(); super.dispose();
} }
@@ -136,6 +144,15 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.reassemble(); 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() { void _updateInvertColors() {
_invertColors = MediaQuery.maybeInvertColorsOf(context) ?? _invertColors = MediaQuery.maybeInvertColorsOf(context) ??
SemanticsBinding.instance.accessibilityFeatures.invertColors; SemanticsBinding.instance.accessibilityFeatures.invertColors;

View File

@@ -281,6 +281,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
context.pop(); 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 @override
Object? get key => "reader_gesture"; 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 { class _DragListener {

View File

@@ -113,6 +113,8 @@ class _GalleryModeState extends State<_GalleryMode>
int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil(); int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
var imageStates = <State<ComicImage>>{};
@override @override
void initState() { void initState() {
reader = context.reader; reader = context.reader;
@@ -234,6 +236,8 @@ class _GalleryModeState extends State<_GalleryMode>
child: ComicImage( child: ComicImage(
image: imageProvider, image: imageProvider,
fit: BoxFit.contain, fit: BoxFit.contain,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
), ),
); );
}).toList(); }).toList();
@@ -370,6 +374,28 @@ class _GalleryModeState extends State<_GalleryMode>
bool handleOnTap(Offset location) { bool handleOnTap(Offset location) {
return false; return false;
} }
@override
Future<Uint8List?> 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<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
@@ -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. /// To handle the tap event, we need to know if the user was scrolling before the delay.
bool delayedIsScrolling = false; bool delayedIsScrolling = false;
var imageStates = <State<ComicImage>>{};
void delayedSetIsScrolling(bool value) { void delayedSetIsScrolling(bool value) {
Future.delayed( Future.delayed(
const Duration(milliseconds: 300), const Duration(milliseconds: 300),
@@ -574,6 +602,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
width: width, width: width,
height: height, height: height,
fit: BoxFit.contain, fit: BoxFit.contain,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
), ),
); );
}, },
@@ -857,6 +887,24 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
return false; return false;
} }
@override
Future<Uint8List?> 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( ImageProvider _createImageProviderFromKey(

View File

@@ -30,6 +30,7 @@ import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/pages/settings/settings_page.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/data_sync.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/file_type.dart';
@@ -577,4 +578,6 @@ abstract interface class _ImageViewController {
/// Returns true if the event is handled. /// Returns true if the event is handled.
bool handleOnTap(Offset location); bool handleOnTap(Offset location);
Future<Uint8List?> getImageByOffset(Offset offset);
} }

View File

@@ -0,0 +1,22 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/services.dart';
Future<void> 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");
}
}

View File

@@ -102,6 +102,47 @@ bool FlutterWindow::OnCreate() {
channel2.SetStreamHandler(std::move(eventHandler)); 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<flutter::MethodResult<>>& result) {
if(call.method_name() == "writeImageToClipboard"){
flutter::EncodableMap arguments = std::get<flutter::EncodableMap>(*call.arguments());
std::vector<uint8_t> data = std::get<std::vector<uint8_t>>(arguments["data"]);
std::int32_t width = std::get<std::int32_t>(arguments["width"]);
std::int32_t height = std::get<std::int32_t>(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()); SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() { flutter_controller_->engine()->SetNextFrameCallback([&]() {