fox_utils 0.1.7: clipboard, drag & drop, Android file saving
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dc16bba054
46 files changed
+4738
No files matched your search
Vendored
+190
@@ -0,0 +1,190 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fox_utils/vendor/src/drop_item.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'events.dart';
|
||||
import 'web_drop_item.dart';
|
||||
|
||||
typedef RawDropListener = void Function(DropEvent);
|
||||
|
||||
class DesktopDrop {
|
||||
static const MethodChannel _channel = MethodChannel('desktop_drop');
|
||||
|
||||
DesktopDrop._();
|
||||
|
||||
static final instance = DesktopDrop._();
|
||||
|
||||
final _listeners = <RawDropListener>{};
|
||||
|
||||
var _inited = false;
|
||||
|
||||
Offset? _offset;
|
||||
|
||||
void init() {
|
||||
if (_inited) {
|
||||
return;
|
||||
}
|
||||
_inited = true;
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
return await _handleMethodChannel(call);
|
||||
} catch (e, s) {
|
||||
debugPrint('_handleMethodChannel: $e $s');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// macOS: Attempt to start security-scoped access for a bookmarked URL.
|
||||
///
|
||||
/// Pass the [DropItem.extraAppleBookmark] bytes from a dropped file that
|
||||
/// originated outside the app container. Returns `true` if access began.
|
||||
///
|
||||
/// If [bookmark] is empty, this function returns `false` and does not
|
||||
/// invoke the platform call. Promise files written under your container do
|
||||
/// not require security-scoped access.
|
||||
Future<bool> startAccessingSecurityScopedResource(
|
||||
{required Uint8List bookmark}) async {
|
||||
if (bookmark.isEmpty) return false;
|
||||
Map<String, dynamic> resultMap = {};
|
||||
resultMap["apple-bookmark"] = bookmark;
|
||||
final bool? result = await _channel.invokeMethod(
|
||||
"startAccessingSecurityScopedResource", resultMap);
|
||||
if (result == null) return false;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// macOS: Stop security-scoped access previously started.
|
||||
///
|
||||
/// If [bookmark] is empty, this function returns `true` and does not
|
||||
/// invoke the platform call, acting as a no-op.
|
||||
Future<bool> stopAccessingSecurityScopedResource(
|
||||
{required Uint8List bookmark}) async {
|
||||
if (bookmark.isEmpty) return true;
|
||||
Map<String, dynamic> resultMap = {};
|
||||
resultMap["apple-bookmark"] = bookmark;
|
||||
final bool result = await _channel.invokeMethod(
|
||||
"stopAccessingSecurityScopedResource", resultMap);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _handleMethodChannel(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case "entered":
|
||||
final position = (call.arguments as List).cast<double>();
|
||||
_offset = Offset(position[0], position[1]);
|
||||
_notifyEvent(DropEnterEvent(location: _offset!));
|
||||
break;
|
||||
case "updated":
|
||||
if (_offset == null && UniversalPlatform.isLinux) {
|
||||
final position = (call.arguments as List).cast<double>();
|
||||
_offset = Offset(position[0], position[1]);
|
||||
_notifyEvent(DropEnterEvent(location: _offset!));
|
||||
return;
|
||||
}
|
||||
final position = (call.arguments as List).cast<double>();
|
||||
_offset = Offset(position[0], position[1]);
|
||||
_notifyEvent(DropUpdateEvent(location: _offset!));
|
||||
break;
|
||||
case "exited":
|
||||
_notifyEvent(DropExitEvent(location: _offset ?? Offset.zero));
|
||||
_offset = null;
|
||||
break;
|
||||
case "performOperation":
|
||||
final paths = (call.arguments as List).cast<String>();
|
||||
_notifyEvent(
|
||||
DropDoneEvent(
|
||||
location: _offset ?? Offset.zero,
|
||||
files: paths.map((e) => DropItemFile(e)).toList(),
|
||||
),
|
||||
);
|
||||
_offset = null;
|
||||
break;
|
||||
case "performOperation_macos":
|
||||
final items = (call.arguments as List).cast<Map>();
|
||||
_notifyEvent(
|
||||
DropDoneEvent(
|
||||
location: _offset ?? Offset.zero,
|
||||
files: items.map((raw) {
|
||||
final path = raw["path"] as String;
|
||||
final bookmark = raw["apple-bookmark"] as Uint8List?;
|
||||
final isDir = (raw["isDirectory"] as bool?) ?? false;
|
||||
final fromPromise = (raw["fromPromise"] as bool?) ?? false;
|
||||
if (isDir) {
|
||||
return DropItemDirectory(
|
||||
path,
|
||||
const [],
|
||||
extraAppleBookmark: bookmark,
|
||||
fromPromise: fromPromise,
|
||||
);
|
||||
}
|
||||
return DropItemFile(
|
||||
path,
|
||||
extraAppleBookmark: bookmark,
|
||||
fromPromise: fromPromise,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
_offset = null;
|
||||
break;
|
||||
|
||||
case "performOperation_linux":
|
||||
// gtk notify 'exit' before 'performOperation'.
|
||||
final text = (call.arguments as List<dynamic>)[0] as String;
|
||||
final offset = ((call.arguments as List<dynamic>)[1] as List<dynamic>)
|
||||
.cast<double>();
|
||||
final paths = const LineSplitter().convert(text).map((e) {
|
||||
try {
|
||||
final uri = Uri.tryParse(e);
|
||||
if (uri == null) {
|
||||
return '';
|
||||
}
|
||||
if (uri.scheme == 'file') {
|
||||
return uri.toFilePath();
|
||||
}
|
||||
return e;
|
||||
} catch (error, stacktrace) {
|
||||
debugPrint('failed to parse linux path: $error $stacktrace');
|
||||
}
|
||||
return '';
|
||||
}).where((e) => e.isNotEmpty);
|
||||
_notifyEvent(DropDoneEvent(
|
||||
location: Offset(offset[0], offset[1]),
|
||||
files: paths.map((e) => DropItemFile(e)).toList(),
|
||||
));
|
||||
break;
|
||||
case "performOperation_web":
|
||||
final results = (call.arguments as List)
|
||||
.cast<Map>()
|
||||
.map((e) => WebDropItem.fromJson(e.cast<String, dynamic>()))
|
||||
.map((e) => e.toDropItem())
|
||||
.toList();
|
||||
_notifyEvent(
|
||||
DropDoneEvent(location: _offset ?? Offset.zero, files: results),
|
||||
);
|
||||
_offset = null;
|
||||
break;
|
||||
default:
|
||||
throw UnimplementedError('${call.method} not implement.');
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyEvent(DropEvent event) {
|
||||
for (final listener in _listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
void addRawDropEventListener(RawDropListener listener) {
|
||||
assert(!_listeners.contains(listener));
|
||||
_listeners.add(listener);
|
||||
}
|
||||
|
||||
void removeRawDropEventListener(RawDropListener listener) {
|
||||
assert(_listeners.contains(listener));
|
||||
_listeners.remove(listener);
|
||||
}
|
||||
}
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
|
||||
/// A dropped item.
|
||||
///
|
||||
/// On desktop, this is usually a filesystem path (file or directory).
|
||||
///
|
||||
/// macOS specifics:
|
||||
/// - If the drag source provided a real file URL (e.g. Finder/JetBrains),
|
||||
/// [extraAppleBookmark] will typically be non-null and allow security-scoped
|
||||
/// access when running sandboxed.
|
||||
/// - If the drag source used a file promise (e.g. VS Code/Electron), the
|
||||
/// system delivers bytes into a per-drop temporary folder inside your app's
|
||||
/// container. In that case [fromPromise] is true and [extraAppleBookmark]
|
||||
/// is usually null/empty. There is no original source path in this flow.
|
||||
abstract class DropItem extends XFile {
|
||||
/// Security-scoped bookmark bytes for the dropped item (macOS only).
|
||||
///
|
||||
/// Use with [DesktopDrop.startAccessingSecurityScopedResource] to gain
|
||||
/// temporary access to files outside your sandbox. When empty or null,
|
||||
/// you typically don't need to call start/stop (e.g. promise files in
|
||||
/// your app's container).
|
||||
Uint8List? extraAppleBookmark;
|
||||
|
||||
/// True when this item was delivered via a macOS file promise and was
|
||||
/// written into your app's temporary Drops directory.
|
||||
///
|
||||
/// In this case, the original source path is not available by design.
|
||||
final bool fromPromise;
|
||||
DropItem(
|
||||
super.path, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.bytes,
|
||||
super.lastModified,
|
||||
this.extraAppleBookmark,
|
||||
this.fromPromise = false,
|
||||
});
|
||||
|
||||
DropItem.fromData(
|
||||
super.bytes, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.lastModified,
|
||||
super.path,
|
||||
this.extraAppleBookmark,
|
||||
this.fromPromise = false,
|
||||
}) : super.fromData();
|
||||
}
|
||||
|
||||
class DropItemFile extends DropItem {
|
||||
DropItemFile(
|
||||
super.path, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.bytes,
|
||||
super.lastModified,
|
||||
super.extraAppleBookmark,
|
||||
super.fromPromise,
|
||||
});
|
||||
|
||||
DropItemFile.fromData(
|
||||
super.bytes, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.lastModified,
|
||||
super.path,
|
||||
super.fromPromise,
|
||||
}) : super.fromData();
|
||||
}
|
||||
|
||||
/// A dropped directory.
|
||||
class DropItemDirectory extends DropItem {
|
||||
final List<DropItem> children;
|
||||
|
||||
DropItemDirectory(
|
||||
super.path,
|
||||
this.children, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.bytes,
|
||||
super.lastModified,
|
||||
super.extraAppleBookmark,
|
||||
super.fromPromise,
|
||||
});
|
||||
|
||||
DropItemDirectory.fromData(
|
||||
super.bytes,
|
||||
this.children, {
|
||||
super.mimeType,
|
||||
super.name,
|
||||
super.length,
|
||||
super.lastModified,
|
||||
super.path,
|
||||
super.extraAppleBookmark,
|
||||
super.fromPromise,
|
||||
}) : super.fromData();
|
||||
}
|
||||
Vendored
+220
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'channel.dart';
|
||||
import 'drop_item.dart';
|
||||
import 'events.dart';
|
||||
|
||||
@immutable
|
||||
class DropDoneDetails {
|
||||
const DropDoneDetails({
|
||||
required this.files,
|
||||
required this.localPosition,
|
||||
required this.globalPosition,
|
||||
});
|
||||
|
||||
final List<DropItem> files;
|
||||
final Offset localPosition;
|
||||
final Offset globalPosition;
|
||||
}
|
||||
|
||||
class DropEventDetails {
|
||||
DropEventDetails({
|
||||
required this.localPosition,
|
||||
required this.globalPosition,
|
||||
});
|
||||
|
||||
final Offset localPosition;
|
||||
|
||||
final Offset globalPosition;
|
||||
}
|
||||
|
||||
typedef OnDragDoneCallback = void Function(DropDoneDetails details);
|
||||
|
||||
typedef OnDragCallback<Detail> = void Function(Detail details);
|
||||
|
||||
/// A widget that accepts draggable files.
|
||||
class DropTarget extends StatefulWidget {
|
||||
const DropTarget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onDragEntered,
|
||||
this.onDragExited,
|
||||
this.onDragDone,
|
||||
this.onDragUpdated,
|
||||
this.enable = true,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Callback when drag entered target area.
|
||||
final OnDragCallback<DropEventDetails>? onDragEntered;
|
||||
|
||||
/// Callback when drag exited target area.
|
||||
final OnDragCallback<DropEventDetails>? onDragExited;
|
||||
|
||||
/// Callback when drag hover on target area.
|
||||
final OnDragCallback<DropEventDetails>? onDragUpdated;
|
||||
|
||||
/// Callback when drag dropped on target area.
|
||||
final OnDragDoneCallback? onDragDone;
|
||||
|
||||
/// Whether to enable drop target.
|
||||
///
|
||||
/// ATTENTION: You should disable drop target when you push a new page/widget in
|
||||
/// front of this drop target, since the drop target will still receive drag events
|
||||
/// even it is invisible.
|
||||
/// https://github.com/MixinNetwork/flutter-plugins/issues/2
|
||||
final bool enable;
|
||||
|
||||
@override
|
||||
State<DropTarget> createState() => _DropTargetState();
|
||||
}
|
||||
|
||||
enum _DragTargetStatus {
|
||||
enter,
|
||||
update,
|
||||
idle,
|
||||
}
|
||||
|
||||
class _DropTargetState extends State<DropTarget> {
|
||||
_DragTargetStatus _status = _DragTargetStatus.idle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
DesktopDrop.instance.init();
|
||||
if (widget.enable) {
|
||||
DesktopDrop.instance.addRawDropEventListener(_onDropEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DropTarget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.enable && !oldWidget.enable) {
|
||||
DesktopDrop.instance.addRawDropEventListener(_onDropEvent);
|
||||
} else if (!widget.enable && oldWidget.enable) {
|
||||
DesktopDrop.instance.removeRawDropEventListener(_onDropEvent);
|
||||
if (_status != _DragTargetStatus.idle) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
localLocation: Offset.zero,
|
||||
globalLocation: Offset.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onDropEvent(DropEvent event) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) {
|
||||
return;
|
||||
}
|
||||
final globalPosition = _scaleHoverPoint(context, event.location);
|
||||
final position = renderBox.globalToLocal(globalPosition);
|
||||
bool inBounds = renderBox.paintBounds.contains(position);
|
||||
if (event is DropEnterEvent) {
|
||||
if (!inBounds) {
|
||||
assert(_status == _DragTargetStatus.idle);
|
||||
} else {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.enter,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
}
|
||||
} else if (event is DropUpdateEvent) {
|
||||
if (_status == _DragTargetStatus.idle && inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.enter,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
} else if ((_status == _DragTargetStatus.enter ||
|
||||
_status == _DragTargetStatus.update) &&
|
||||
inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.update,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
debugRequiredStatus: false,
|
||||
);
|
||||
} else if (_status != _DragTargetStatus.idle && !inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
}
|
||||
} else if (event is DropExitEvent && _status != _DragTargetStatus.idle) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
} else if (event is DropDoneEvent &&
|
||||
(_status != _DragTargetStatus.idle || UniversalPlatform.isLinux) &&
|
||||
inBounds) {
|
||||
_updateStatus(
|
||||
_DragTargetStatus.idle,
|
||||
debugRequiredStatus: false,
|
||||
globalLocation: globalPosition,
|
||||
localLocation: position,
|
||||
);
|
||||
widget.onDragDone?.call(DropDoneDetails(
|
||||
files: event.files,
|
||||
localPosition: position,
|
||||
globalPosition: globalPosition,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStatus(
|
||||
_DragTargetStatus status, {
|
||||
bool debugRequiredStatus = true,
|
||||
required Offset localLocation,
|
||||
required Offset globalLocation,
|
||||
}) {
|
||||
assert(!debugRequiredStatus || _status != status);
|
||||
_status = status;
|
||||
final details = DropEventDetails(
|
||||
localPosition: localLocation,
|
||||
globalPosition: globalLocation,
|
||||
);
|
||||
switch (_status) {
|
||||
case _DragTargetStatus.enter:
|
||||
widget.onDragEntered?.call(details);
|
||||
break;
|
||||
case _DragTargetStatus.update:
|
||||
widget.onDragUpdated?.call(details);
|
||||
break;
|
||||
case _DragTargetStatus.idle:
|
||||
widget.onDragExited?.call(details);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.enable) {
|
||||
DesktopDrop.instance.removeRawDropEventListener(_onDropEvent);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
Offset _scaleHoverPoint(BuildContext context, Offset point) {
|
||||
if (UniversalPlatform.isWindows || UniversalPlatform.isAndroid) {
|
||||
return point.scale(
|
||||
1 / MediaQuery.of(context).devicePixelRatio,
|
||||
1 / MediaQuery.of(context).devicePixelRatio,
|
||||
);
|
||||
}
|
||||
return point;
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
import 'package:fox_utils/vendor/src/drop_item.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
abstract class DropEvent {
|
||||
Offset location;
|
||||
|
||||
DropEvent(this.location);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType($location)';
|
||||
}
|
||||
}
|
||||
|
||||
class DropEnterEvent extends DropEvent {
|
||||
DropEnterEvent({required Offset location}) : super(location);
|
||||
}
|
||||
|
||||
class DropExitEvent extends DropEvent {
|
||||
DropExitEvent({required Offset location}) : super(location);
|
||||
}
|
||||
|
||||
class DropUpdateEvent extends DropEvent {
|
||||
DropUpdateEvent({required Offset location}) : super(location);
|
||||
}
|
||||
|
||||
class DropDoneEvent extends DropEvent {
|
||||
final List<DropItem> files;
|
||||
|
||||
DropDoneEvent({
|
||||
required Offset location,
|
||||
required this.files,
|
||||
}) : super(location);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType($location, $files)';
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
abstract class PasteboardPlatform {
|
||||
Future<Uint8List?> get image;
|
||||
|
||||
Future<String?> get html;
|
||||
|
||||
Future<void> writeImage(Uint8List? image);
|
||||
|
||||
Future<List<String>> files();
|
||||
|
||||
Future<bool> writeFiles(List<String> files);
|
||||
|
||||
Future<String?> get text;
|
||||
|
||||
void writeText(String value);
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import 'dart:io';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'pasteboard_platform.dart';
|
||||
|
||||
const PasteboardPlatform pasteboard = PasteboardPlatformIO();
|
||||
|
||||
class PasteboardPlatformIO implements PasteboardPlatform {
|
||||
const PasteboardPlatformIO();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel('pasteboard');
|
||||
|
||||
@override
|
||||
Future<List<String>> files() async {
|
||||
final files = await _channel.invokeMethod<List>('files');
|
||||
return files?.cast<String>() ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> get html async {
|
||||
if (Platform.isWindows || Platform.isAndroid) {
|
||||
return await _channel.invokeMethod<Object>('html') as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> get image async {
|
||||
final image = await _channel.invokeMethod<Object>('image');
|
||||
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
if (Platform.isMacOS ||
|
||||
Platform.isLinux ||
|
||||
Platform.isIOS ||
|
||||
Platform.isAndroid) {
|
||||
return image as Uint8List;
|
||||
} else if (Platform.isWindows) {
|
||||
final file = File(image as String);
|
||||
final bytes = await file.readAsBytes();
|
||||
await file.delete();
|
||||
return bytes;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> writeFiles(List<String> files) async {
|
||||
try {
|
||||
await _channel.invokeMethod<Object>('writeFiles', files);
|
||||
return true;
|
||||
} catch (error, stacktrace) {
|
||||
debugPrint('$error\n$stacktrace');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeImage(Uint8List? image) async {
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) {
|
||||
await _channel.invokeMethod<void>('writeImage', image);
|
||||
} else if (Platform.isWindows) {
|
||||
final file = await File(_getTempFileName()).create();
|
||||
file.writeAsBytesSync(image);
|
||||
await _channel
|
||||
.invokeMethod<Object>('writeImage', {'fileName': file.path});
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> get text async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return data?.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void writeText(String value) {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
}
|
||||
}
|
||||
|
||||
String _getTempFileName() {
|
||||
final dir = Directory.systemTemp;
|
||||
String tempFileName;
|
||||
|
||||
const uuid = Uuid();
|
||||
|
||||
while (true) {
|
||||
tempFileName = p.join(dir.path, uuid.v1().toString());
|
||||
if (!File(tempFileName).existsSync()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tempFileName;
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:web/web.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'pasteboard_platform.dart';
|
||||
|
||||
Future<String?> _readBlobAsText(Blob blob) async {
|
||||
final FileReader reader = FileReader();
|
||||
final future =
|
||||
reader.onLoadEnd.first.then((ProgressEvent event) => reader.result);
|
||||
|
||||
reader.readAsText(blob);
|
||||
|
||||
final res = await future;
|
||||
return res.toString();
|
||||
}
|
||||
|
||||
Clipboard get _clipboard => window.navigator.clipboard;
|
||||
|
||||
extension on Blob {
|
||||
Future<Uint8List?> _readAsUint8List() async {
|
||||
final buffer = await arrayBuffer().toDart;
|
||||
return buffer.toDart.asUint8List();
|
||||
}
|
||||
}
|
||||
|
||||
const PasteboardPlatform pasteboard = PasteboardPlatformWeb();
|
||||
|
||||
class PasteboardPlatformWeb implements PasteboardPlatform {
|
||||
const PasteboardPlatformWeb();
|
||||
|
||||
@override
|
||||
Future<List<String>> files() async => const [];
|
||||
|
||||
@override
|
||||
Future<String?> get html async {
|
||||
try {
|
||||
final clipboardItems = await _clipboard.read().toDart;
|
||||
for (var clipboardItem in clipboardItems.toDart) {
|
||||
if (clipboardItem.types.toDart.contains('text/html'.toJS)) {
|
||||
final blob = await clipboardItem.getType('text/html').toDart;
|
||||
return _readBlobAsText(blob);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> get image async {
|
||||
try {
|
||||
final clipboardItems = await _clipboard.read().toDart;
|
||||
for (var item in clipboardItems.toDart) {
|
||||
for (var type in item.types.toDart) {
|
||||
if (type.toDart.startsWith('image/')) {
|
||||
final blob = await item.getType(type.toDart).toDart;
|
||||
return blob._readAsUint8List();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error, stacktrace) {
|
||||
debugPrint('get image failed: $error $stacktrace');
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> writeFiles(List<String> files) async => false;
|
||||
|
||||
@override
|
||||
Future<void> writeImage(Uint8List? image) async {
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final blob = Blob([image.toJS].toJS, BlobPropertyBag(type: 'image/png'));
|
||||
window.navigator.clipboard.write([
|
||||
ClipboardItem({'image/png': blob}.jsify()! as JSObject)
|
||||
].toJS);
|
||||
} catch (e) {
|
||||
debugPrint('Error writing image to clipboard: $e');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> get text async {
|
||||
final data = await _clipboard.readText().toDart;
|
||||
return data.toDart;
|
||||
}
|
||||
|
||||
@override
|
||||
void writeText(String value) {
|
||||
final fakeElement = _createCopyFakeElement(value)..select();
|
||||
document.body?.append(fakeElement);
|
||||
_select(fakeElement);
|
||||
_copyCommand();
|
||||
fakeElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
void _select(HTMLTextAreaElement element) {
|
||||
final isReadOnly = element.hasAttribute('readonly');
|
||||
|
||||
if (!isReadOnly) {
|
||||
element.setAttribute('readonly', '');
|
||||
}
|
||||
|
||||
element
|
||||
..select()
|
||||
..setSelectionRange(0, element.value.length);
|
||||
|
||||
if (!isReadOnly) {
|
||||
element.removeAttribute('readonly');
|
||||
}
|
||||
}
|
||||
|
||||
/// https://github.com/zenorocha/clipboard.js/blob/master/src/common/create-fake-element.js
|
||||
HTMLTextAreaElement _createCopyFakeElement(String value) {
|
||||
final isRtl = document.documentElement?.getAttribute('dir') == 'rtl';
|
||||
final fakeElement = HTMLTextAreaElement()
|
||||
// Prevent zooming on iOS
|
||||
..style.fontSize = '12pt'
|
||||
// Reset box model
|
||||
..style.border = '0'
|
||||
..style.padding = '0'
|
||||
// Move element out of screen horizontally
|
||||
..style.position = 'absolute'
|
||||
..style.setProperty(isRtl ? 'right' : 'left', '-9999px');
|
||||
|
||||
// Move element to the same position vertically
|
||||
final yPosition = document.documentElement?.scrollTop.toInt() ?? 0;
|
||||
fakeElement
|
||||
..style.top = '${yPosition}px'
|
||||
..setAttribute('readonly', '')
|
||||
..value = value;
|
||||
return fakeElement;
|
||||
}
|
||||
|
||||
bool _copyCommand() {
|
||||
try {
|
||||
return document.execCommand('copy');
|
||||
} catch (error, stack) {
|
||||
window.alert('$error, $stack');
|
||||
// ignore
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Vendored
+85
@@ -0,0 +1,85 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fox_utils/vendor/src/drop_item.dart';
|
||||
|
||||
class WebDropItem {
|
||||
WebDropItem({
|
||||
required this.uri,
|
||||
required this.children,
|
||||
this.data,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.size,
|
||||
required this.relativePath,
|
||||
required this.lastModified,
|
||||
});
|
||||
|
||||
final String uri;
|
||||
final List<WebDropItem> children;
|
||||
final Uint8List? data;
|
||||
final String name;
|
||||
final String type;
|
||||
final int size;
|
||||
final String? relativePath;
|
||||
final DateTime lastModified;
|
||||
|
||||
factory WebDropItem.fromJson(Map<String, dynamic> json) => WebDropItem(
|
||||
uri: json['uri'],
|
||||
children: json['children'] == null
|
||||
? []
|
||||
: (json['children'] as List)
|
||||
.cast<Map>()
|
||||
.map((e) => WebDropItem.fromJson(e.cast<String, dynamic>()))
|
||||
.toList(),
|
||||
data: json['data'] != null ? Uint8List.fromList(json['data']) : null,
|
||||
name: json['name'],
|
||||
type: json['type'],
|
||||
size: json['size'],
|
||||
relativePath: json['relativePath'],
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(json['lastModified']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'uri': uri,
|
||||
'children': children.map((e) => e.toJson()).toList(),
|
||||
'data': data?.toList(),
|
||||
'name': name,
|
||||
'type': type,
|
||||
'size': size,
|
||||
'relativePath': relativePath,
|
||||
'lastModified': lastModified.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
DropItem toDropItem() {
|
||||
if (type == 'directory') {
|
||||
return DropItemDirectory(
|
||||
uri,
|
||||
children.map((e) => e.toDropItem()).toList(),
|
||||
name: name,
|
||||
mimeType: type,
|
||||
length: size,
|
||||
lastModified: lastModified,
|
||||
bytes: data,
|
||||
);
|
||||
} else {
|
||||
if (data != null) {
|
||||
return DropItemFile.fromData(
|
||||
data!,
|
||||
name: name,
|
||||
mimeType: type,
|
||||
length: size,
|
||||
lastModified: lastModified,
|
||||
path: uri,
|
||||
);
|
||||
}
|
||||
return DropItemFile(
|
||||
uri,
|
||||
name: name,
|
||||
mimeType: type,
|
||||
length: size,
|
||||
lastModified: lastModified,
|
||||
bytes: data,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user