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:
FoxyzandClaude Opus 5.5 committed 2026-10-09 06:31:15 +03:00
commit dc16bba054
46 files changed
+4738

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
export 'src/drop_target.dart';
export 'src/drop_item.dart';
export 'src/channel.dart';
+44
View File
@@ -0,0 +1,44 @@
import 'dart:async';
import 'dart:typed_data';
import 'src/pasteboard_platform_web.dart'
if (dart.library.io) 'src/pasteboard_platform_io.dart';
class Pasteboard {
/// Returns the image data of the pasteboard.
///
/// available on iOS, Desktop, Web, Android
static Future<Uint8List?> get image => pasteboard.image;
/// only available on Windows and the web.
/// Get "HTML format" from system pasteboard.
/// HTML format: https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767917(v=vs.85)
static Future<String?> get html => pasteboard.html;
/// available on iOS, Android, Web
///
/// set image data to system pasteboard.
static Future<void> writeImage(Uint8List? image) =>
pasteboard.writeImage(image);
/// available on Desktop, Android.
///
/// Android: paths are content uris, must read by content resolver.
///
/// Get files from system pasteboard.
static Future<List<String>> files() => pasteboard.files();
/// Only available on desktop platforms.
///
/// Set files to system pasteboard.
static Future<bool> writeFiles(List<String> files) =>
pasteboard.writeFiles(files);
/// Available on all platforms.
/// Get text from system pasteboard.
static Future<String?> get text => pasteboard.text;
/// Available on all platforms.
/// Set text to system pasteboard.
static void writeText(String value) => pasteboard.writeText(value);
}
+190
View File
@@ -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);
}
}
+104
View File
@@ -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();
}
+220
View File
@@ -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;
}
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+85
View File
@@ -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,
);
}
}
}