mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
Add image copy functionality.
Currently only supports Windows. Close #260
This commit is contained in:
@@ -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": "複製圖片"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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(
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
|
22
lib/utils/clipboard_image.dart
Normal file
22
lib/utils/clipboard_image.dart
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
@@ -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([&]() {
|
||||||
|
Reference in New Issue
Block a user