From 6cb298ffa52cc2db726f0481e6bed569d1ae82f4 Mon Sep 17 00:00:00 2001 From: dt_emmy Date: Tue, 2 Dec 2025 16:50:12 +0100 Subject: [PATCH 1/3] feat: implemented copying and pasting of image on desktop and mobile --- example/ios/Podfile.lock | 6 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + example/macos/Podfile.lock | 6 + lib/src/views/chat_input/chat_input.dart | 1 + .../views/chat_input/text_or_audio_input.dart | 6 + lib/src/views/chat_text_field.dart | 217 ++++++++++++++---- pubspec.yaml | 1 + 7 files changed, 192 insertions(+), 47 deletions(-) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 818227d5..5faacbe5 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -77,6 +77,8 @@ PODS: - GTMSessionFetcher/Core (5.0.0) - image_picker_ios (0.0.1): - Flutter + - pasteboard (0.0.1): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -98,6 +100,7 @@ DEPENDENCIES: - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - Flutter (from `Flutter`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - pasteboard (from `.symlinks/plugins/pasteboard/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - record_ios (from `.symlinks/plugins/record_ios/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -134,6 +137,8 @@ EXTERNAL SOURCES: :path: Flutter image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + pasteboard: + :path: ".symlinks/plugins/pasteboard/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" record_ios: @@ -162,6 +167,7 @@ SPEC CHECKSUMS: GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29 image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b + pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 20c23bad..18d14e30 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import file_selector_macos import firebase_app_check import firebase_auth import firebase_core +import pasteboard import path_provider_foundation import record_macos import shared_preferences_foundation @@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index f5abe519..3239665c 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -77,6 +77,8 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - GTMSessionFetcher/Core (5.0.0) + - pasteboard (0.0.1): + - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -95,6 +97,7 @@ DEPENDENCIES: - firebase_auth (from `Flutter/ephemeral/.symlinks/plugins/firebase_auth/macos`) - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -126,6 +129,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos FlutterMacOS: :path: Flutter/ephemeral + pasteboard: + :path: Flutter/ephemeral/.symlinks/plugins/pasteboard/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin record_macos: @@ -152,6 +157,7 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29 + pasteboard: 9b69dba6fedbb04866be632205d532fe2f6b1d99 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 record_macos: 4440ca269ad3b870ebb1965297a365d558f0c520 diff --git a/lib/src/views/chat_input/chat_input.dart b/lib/src/views/chat_input/chat_input.dart index 45a886d5..e5498183 100644 --- a/lib/src/views/chat_input/chat_input.dart +++ b/lib/src/views/chat_input/chat_input.dart @@ -196,6 +196,7 @@ class _ChatInputState extends State { cancelButtonStyle: _chatStyle!.cancelButtonStyle!, voiceNoteRecorderStyle: _chatStyle!.voiceNoteRecorderStyle!, + onAttachments: onAttachments, ), ), Padding( diff --git a/lib/src/views/chat_input/text_or_audio_input.dart b/lib/src/views/chat_input/text_or_audio_input.dart index 05732397..3c0a8a70 100644 --- a/lib/src/views/chat_input/text_or_audio_input.dart +++ b/lib/src/views/chat_input/text_or_audio_input.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; import 'package:waveform_recorder/waveform_recorder.dart'; import '../../styles/styles.dart'; @@ -17,6 +18,7 @@ class TextOrAudioInput extends StatelessWidget { /// The [TextOrAudioInput] widget requires several parameters: /// - [inputStyle]: Defines the styling for the input field. /// - [waveController]: Controls the waveform recorder. + /// - [onAttachments]: Callback for when attachments are pasted into the text field. /// - [onCancelEdit]: Callback for when editing is canceled. /// - [onRecordingStopped]: Callback for when audio recording is stopped. /// - [onSubmitPrompt]: Callback for when the text input is submitted. @@ -30,6 +32,7 @@ class TextOrAudioInput extends StatelessWidget { super.key, required ChatInputStyle inputStyle, required WaveformRecorderController waveController, + required void Function(List attachments)? onAttachments, required void Function()? onCancelEdit, required void Function() onRecordingStopped, required void Function() onSubmitPrompt, @@ -45,6 +48,7 @@ class TextOrAudioInput extends StatelessWidget { _focusNode = focusNode, _textController = textController, _onSubmitPrompt = onSubmitPrompt, + _onAttachments = onAttachments, _onRecordingStopped = onRecordingStopped, _onCancelEdit = onCancelEdit, _waveController = waveController, @@ -53,6 +57,7 @@ class TextOrAudioInput extends StatelessWidget { final ChatInputStyle _inputStyle; final WaveformRecorderController _waveController; + final void Function(List attachments)? _onAttachments; final void Function()? _onCancelEdit; final void Function() _onRecordingStopped; final void Function() _onSubmitPrompt; @@ -113,6 +118,7 @@ class TextOrAudioInput extends StatelessWidget { horizontal: 12, vertical: 8, ), + onAttachments: _onAttachments, ), ), ), diff --git a/lib/src/views/chat_text_field.dart b/lib/src/views/chat_text_field.dart index 551bc01e..38520c62 100644 --- a/lib/src/views/chat_text_field.dart +++ b/lib/src/views/chat_text_field.dart @@ -2,11 +2,27 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/cupertino.dart' show CupertinoTextField; +import 'package:flutter/cupertino.dart' + show + CupertinoTextField, + CupertinoAdaptiveTextSelectionToolbar, + CupertinoLocalizations; import 'package:flutter/material.dart' - show InputBorder, InputDecoration, TextField, TextInputAction; + show + InputBorder, + InputDecoration, + TextField, + TextInputAction, + ContextMenuController, + AdaptiveTextSelectionToolbar, + MaterialLocalizations; + import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:pasteboard/pasteboard.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../providers/interface/attachments.dart'; import '../styles/toolkit_colors.dart'; import '../utility.dart'; @@ -30,6 +46,7 @@ class ChatTextField extends StatelessWidget { required this.controller, required this.focusNode, required this.onSubmitted, + this.onAttachments, required this.hintText, required this.hintStyle, required this.hintPadding, @@ -69,48 +86,154 @@ class ChatTextField extends StatelessWidget { /// Called when the user submits editable content. final void Function(String text) onSubmitted; - @override - Widget build(BuildContext context) => CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.enter): - () => onSubmitted(controller.text), - }, - child: - isCupertinoApp(context) - ? CupertinoTextField( - minLines: minLines, - maxLines: maxLines, - controller: controller, - autofocus: autofocus, - focusNode: focusNode, - onSubmitted: onSubmitted, - style: style, - placeholder: hintText, - placeholderStyle: hintStyle, - padding: hintPadding ?? EdgeInsets.zero, - decoration: BoxDecoration( - border: Border.all(width: 0, color: ToolkitColors.transparent), - ), - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - ) - : TextField( - minLines: minLines, - maxLines: maxLines, - controller: controller, - autofocus: autofocus, - focusNode: focusNode, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - onSubmitted: onSubmitted, - style: style, - decoration: InputDecoration( - border: InputBorder.none, - hintText: hintText, - hintStyle: hintStyle, - contentPadding: hintPadding, - isDense: false, - ), - ), - ); -} + /// Called when attachments are pasted into the text field. + final void Function(List attachments)? onAttachments; + + Future _handlePaste() async { + try { + final image = await Pasteboard.image; + if (image != null && onAttachments != null) { + final attachment = ImageFileAttachment( + name: 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.png', + mimeType: 'image/png', + bytes: image.asUnmodifiableView(), + ); + onAttachments!([attachment]); + return; + } + } catch (e, s) { + debugPrint('Error pasting image: $e'); + debugPrintStack(stackTrace: s); + } + + final text = await Pasteboard.text; + if (text != null && text.isNotEmpty) _insertText(text); + } + + void _insertText(String text) { + final cursorPosition = controller.selection.base.offset; + if (cursorPosition == -1) { + controller.text = text; + } else { + final newText = controller.text.replaceRange( + controller.selection.start, + controller.selection.end, + text, + ); + controller.value = controller.value.copyWith( + text: newText, + selection: TextSelection.collapsed( + offset: controller.selection.start + text.length, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.enter): + () => onSubmitted(controller.text), + if (UniversalPlatform.isMacOS || + UniversalPlatform.isLinux || + UniversalPlatform.isWeb || + UniversalPlatform.isWindows) + const SingleActivator(LogicalKeyboardKey.keyV, meta: true): + _handlePaste, + if (UniversalPlatform.isWindows || + UniversalPlatform.isLinux || + UniversalPlatform.isWeb) + const SingleActivator(LogicalKeyboardKey.keyV, control: true): + _handlePaste, + }, + child: + isCupertinoApp(context) + ? CupertinoTextField( + minLines: minLines, + maxLines: maxLines, + autofocus: autofocus, + style: style, + textInputAction: textInputAction, + controller: controller, + focusNode: focusNode, + onSubmitted: onSubmitted, + placeholder: hintText, + placeholderStyle: hintStyle, + padding: hintPadding ?? EdgeInsets.zero, + decoration: BoxDecoration( + border: Border.all( + width: 0, + color: ToolkitColors.transparent, + ), + ), + keyboardType: TextInputType.multiline, + contextMenuBuilder: (context, editable) { + final l10n = CupertinoLocalizations.of(context); + final defaultItems = editable.contextMenuButtonItems; + + final filteredItems = defaultItems.where((item) { + return item.type.name != 'paste'; + }); + + final customItems = [ + ContextMenuButtonItem( + label: l10n.pasteButtonLabel, + onPressed: () async { + ContextMenuController.removeAny(); + await _handlePaste(); + }, + ), + ...filteredItems, + ]; + + return CupertinoAdaptiveTextSelectionToolbar.buttonItems( + anchors: editable.contextMenuAnchors, + buttonItems: customItems, + ); + }, + ) + : TextField( + minLines: minLines, + maxLines: maxLines, + autofocus: autofocus, + style: style, + textInputAction: textInputAction, + controller: controller, + focusNode: focusNode, + onSubmitted: onSubmitted, + decoration: InputDecoration( + hintText: hintText, + hintStyle: hintStyle, + border: InputBorder.none, + contentPadding: hintPadding, + isDense: false, + ), + keyboardType: TextInputType.multiline, + contextMenuBuilder: (context, editable) { + final defaultItems = editable.contextMenuButtonItems; + + final filteredItems = defaultItems.where((item) { + return item.type.name != 'paste'; + }); + + final customItems = [ + ContextMenuButtonItem( + label: + MaterialLocalizations.of(context).pasteButtonLabel, + onPressed: () async { + ContextMenuController.removeAny(); + await _handlePaste(); + }, + ), + ...filteredItems, + ]; + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editable.contextMenuAnchors, + buttonItems: customItems, + ); + }, + ), + ); + } + } diff --git a/pubspec.yaml b/pubspec.yaml index 3ba0fbff..5fd8694f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: google_fonts: ^6.2.1 image_picker: ^1.1.2 mime: ^2.0.0 + pasteboard: ^0.4.0 universal_platform: ^1.1.0 url_launcher: ^6.3.2 uuid: ^4.4.2 From 64d31012567ceb5126aafb339dcc8c5b58d83586 Mon Sep 17 00:00:00 2001 From: dt_emmy Date: Fri, 5 Dec 2025 20:37:37 +0100 Subject: [PATCH 2/3] feat: pasting images on web and desktop --- .../android/app/src/main/AndroidManifest.xml | 9 + .../helpers/paste_helper/paste_handler.dart | 148 ++++++++++ .../helpers/paste_helper/paste_helper.dart | 2 + .../paste_helper/paste_helper_stub.dart | 25 ++ .../paste_helper/paste_helper_web.dart | 140 ++++++++++ lib/src/views/chat_text_field.dart | 252 ++++++++---------- pubspec.yaml | 1 + 7 files changed, 433 insertions(+), 144 deletions(-) create mode 100644 lib/src/helpers/paste_helper/paste_handler.dart create mode 100644 lib/src/helpers/paste_helper/paste_helper.dart create mode 100644 lib/src/helpers/paste_helper/paste_helper_stub.dart create mode 100644 lib/src/helpers/paste_helper/paste_helper_web.dart diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index dba5dfc5..19463a40 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,15 @@ + + + handlePaste({ + required TextEditingController controller, + required void Function(List attachments)? onAttachments, +}) async { + try { + if (kIsWeb) { + await pst.handlePasteWeb( + controller: controller, + onAttachments: onAttachments, + insertText: _insertText, + ); + return; + } + final files = await Pasteboard.files(); + if (files.isNotEmpty && onAttachments != null) { + for (final file in files) { + final looksLikeImage = _looksLikeImagePath(file); + if (looksLikeImage["isFile"] == true) { + final bytes = await XFile(file).readAsBytes(); + final attachment = ImageFileAttachment( + name: 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(looksLikeImage["mimeType"], bytes)}', + mimeType: looksLikeImage["mimeType"], + bytes: bytes, + ); + onAttachments([attachment]); + } + return; + } + } + + + final image = await Pasteboard.image; + if (image != null && onAttachments != null) { + final mimeType = lookupMimeType('', headerBytes: image) ?? 'image/png'; + final attachment = ImageFileAttachment( + name: 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}', + mimeType: mimeType, + bytes: image, + ); + onAttachments([attachment]); + return; + } + } catch (e, s) { + debugPrint('Error pasting image: $e'); + debugPrintStack(stackTrace: s); + } + + final text = await Pasteboard.text; + if (text != null && text.isNotEmpty) { + _insertText(controller: controller, text: text); + } +} + +/// Inserts text at the current cursor position in the text controller. +/// +/// If there's a text selection, it will be replaced by the new text. +/// If there's no selection, the text will be inserted at the cursor position. +/// +/// Parameters: +/// - [controller]: The text editing controller to insert text into +/// - [text]: The text to insert +void _insertText({ + required TextEditingController controller, + required String text, +}) { + final cursorPosition = controller.selection.base.offset; + if (cursorPosition == -1) { + controller.text = text; + } else { + final newText = controller.text.replaceRange( + controller.selection.start, + controller.selection.end, + text, + ); + controller.value = controller.value.copyWith( + text: newText, + selection: TextSelection.collapsed( + offset: controller.selection.start + text.length, + ), + ); + } +} + +/// Determines the appropriate file extension for a given MIME type. +/// +/// Parameters: +/// - [mimeType]: The MIME type to get the extension for (e.g., 'image/png') +/// +/// Returns: +/// A string representing the file extension (without the dot), defaults to 'bin' if unknown +String _getExtensionFromMime(String mimeType, [List? bytes]) { + String detectedMimeType = mimeType; + if (bytes != null && (mimeType.isEmpty || mimeType == 'application/octet-stream')) { + detectedMimeType = lookupMimeType('', headerBytes: bytes) ?? mimeType; + } + final extension = extensionFromMime(detectedMimeType); + if (extension == null || extension.isEmpty) { + return detectedMimeType.startsWith('image/') ? 'png' : 'bin'; + } + return extension.startsWith('.') ? extension.substring(1) : extension; +} + +/// Checks if the given text appears to be an image file path or URL. +/// +/// Parameters: +/// - [text]: The text to check +/// +/// Returns: +/// true if the text looks like an image path/URL, false otherwise +Map _looksLikeImagePath(String text) { + final lower = text.toLowerCase(); + final uri = Uri.tryParse(lower); + if (uri != null && uri.path.isNotEmpty) { + final mimeType = lookupMimeType(uri.path) ?? ""; + return mimeType.startsWith('image/') ? {"isFile": mimeType.startsWith('image/'), "mimeType": mimeType} : {"isFile": false, "mimeType": ""}; + } + return {"isFile": false, "mimeType": ""}; +} \ No newline at end of file diff --git a/lib/src/helpers/paste_helper/paste_helper.dart b/lib/src/helpers/paste_helper/paste_helper.dart new file mode 100644 index 00000000..4ceee934 --- /dev/null +++ b/lib/src/helpers/paste_helper/paste_helper.dart @@ -0,0 +1,2 @@ +export 'paste_helper_stub.dart' + if (dart.library.js_interop) 'paste_helper_web.dart'; \ No newline at end of file diff --git a/lib/src/helpers/paste_helper/paste_helper_stub.dart b/lib/src/helpers/paste_helper/paste_helper_stub.dart new file mode 100644 index 00000000..52a70461 --- /dev/null +++ b/lib/src/helpers/paste_helper/paste_helper_stub.dart @@ -0,0 +1,25 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart' show TextEditingController; + +import '../../providers/interface/attachments.dart'; + +/// A no-op implementation of the web paste handler for non-web platforms. +/// +/// This function is provided for API compatibility with web platforms but does nothing +/// when called on non-web platforms. On web, this would handle paste events. +/// +/// Parameters: +/// - [controller]: The text editing controller (unused in stub) +/// - [onAttachments]: Callback for handling attachments (unused in stub) +/// - [insertText]: Function to handle text insertion (unused in stub) +/// +/// Returns: +/// A [Future] that completes immediately with no effect +Future handlePasteWeb({ + required TextEditingController controller, + required void Function(List attachments)? onAttachments, + required void Function({required TextEditingController controller, required String text}) insertText, +}) async { } diff --git a/lib/src/helpers/paste_helper/paste_helper_web.dart b/lib/src/helpers/paste_helper/paste_helper_web.dart new file mode 100644 index 00000000..a195ef8b --- /dev/null +++ b/lib/src/helpers/paste_helper/paste_helper_web.dart @@ -0,0 +1,140 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:js_interop'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show TextEditingController; +import 'package:mime/mime.dart'; +import 'package:web/web.dart' as web; + +import '../../providers/interface/attachments.dart'; + +/// Handles paste events in a web environment, supporting both text and image pasting. +/// +/// This function processes the clipboard contents and either: +/// - Extracts and handles image data if images are present in the clipboard +/// - Inserts plain text into the provided text controller +/// +/// Parameters: +/// - [controller]: The text editing controller to insert text into +/// - [onAttachments]: Callback that receives a list of attachments when images are pasted +/// - [insertText]: Function to handle text insertion, allowing for custom text processing +/// +/// Returns: +/// A [Future] that completes when the paste operation is finished +Future handlePasteWeb({ + required TextEditingController controller, + required void Function(List attachments)? onAttachments, + required void Function({ + required TextEditingController controller, + required String text, + }) insertText, +}) async { + try { + final clipboard = web.window.navigator.clipboard; + final jsItems = await clipboard.read().toDart; + + final items = jsItems.toDart; + + for (final item in items) { + final types = item.types.toDart; + for (final type in types) { + final typeString = type.toDart; + + if (typeString.startsWith("image/")) { + final blob = await item.getType(typeString).toDart; + + if (blob.isUndefinedOrNull) continue; + + final bytes = await _blobToBytes(blob); + if (bytes == null || bytes.isEmpty) continue; + + final extension = _getExtensionFromMime(typeString); + + final attachment = ImageFileAttachment( + name: + 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.$extension', + mimeType: typeString, + bytes: bytes, + ); + + onAttachments?.call([attachment]); + return; + } + } + + for (final type in types) { + final typeString = type.toDart; + + if (typeString.startsWith("text/") && + typeString != "text/plain") { + final blob = await item.getType(typeString).toDart; + if (blob.isUndefinedOrNull) continue; + + final text = await blob.text().toDart; + final textString = text.toDart; + + if (textString.isNotEmpty) { + insertText(controller: controller, text: textString); + return; + } + } + } + + if (types.contains('text/plain'.toJS)) { + final blob = await item.getType("text/plain").toDart; + if (blob.isUndefinedOrNull) continue; + + final text = await blob.text().toDart; + final textString = text.toDart; + + if (textString.isNotEmpty) { + insertText(controller: controller, text: textString); + return; + } + } + } + } catch (e, s) { + debugPrint('Error in handlePasteWeb: $e'); + debugPrintStack(stackTrace: s); + } +} + + +/// Converts a web Blob object to a Uint8List. +/// +/// Parameters: +/// - [blob]: The web Blob to convert +/// +/// Returns: +/// A [Future] that completes with the binary data as [Uint8List], or null if conversion fails +Future _blobToBytes(web.Blob blob) async { + try { + final arrayBuffer = await blob.arrayBuffer().toDart; + return Uint8List.view(arrayBuffer.toDart); + } catch (e) { + debugPrint('Error converting blob to bytes: $e'); + return null; + } +} + +/// Determines the appropriate file extension for a given MIME type. +/// +/// Parameters: +/// - [mimeType]: The MIME type to get the extension for (e.g., 'image/png') +/// - [bytes]: Optional header bytes to detect the MIME type if the provided type is generic. +/// +/// Returns: +/// A string representing the file extension (without the dot), defaults to 'bin' if unknown +String _getExtensionFromMime(String mimeType, [List? bytes]) { + String detectedMimeType = mimeType; + if (bytes != null && (mimeType.isEmpty || mimeType == 'application/octet-stream')) { + detectedMimeType = lookupMimeType('', headerBytes: bytes) ?? mimeType; + } + final extension = extensionFromMime(detectedMimeType); + if (extension == null || extension.isEmpty) { + return detectedMimeType.startsWith('image/') ? 'png' : 'bin'; + } + return extension.startsWith('.') ? extension.substring(1) : extension; +} diff --git a/lib/src/views/chat_text_field.dart b/lib/src/views/chat_text_field.dart index 38520c62..a4d12f41 100644 --- a/lib/src/views/chat_text_field.dart +++ b/lib/src/views/chat_text_field.dart @@ -19,9 +19,8 @@ import 'package:flutter/material.dart' import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:pasteboard/pasteboard.dart'; +import '../helpers/paste_helper/paste_handler.dart'; import 'package:universal_platform/universal_platform.dart'; - import '../providers/interface/attachments.dart'; import '../styles/toolkit_colors.dart'; @@ -90,150 +89,115 @@ class ChatTextField extends StatelessWidget { final void Function(List attachments)? onAttachments; Future _handlePaste() async { - try { - final image = await Pasteboard.image; - if (image != null && onAttachments != null) { - final attachment = ImageFileAttachment( - name: 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.png', - mimeType: 'image/png', - bytes: image.asUnmodifiableView(), - ); - onAttachments!([attachment]); - return; - } - } catch (e, s) { - debugPrint('Error pasting image: $e'); - debugPrintStack(stackTrace: s); - } - - final text = await Pasteboard.text; - if (text != null && text.isNotEmpty) _insertText(text); - } - - void _insertText(String text) { - final cursorPosition = controller.selection.base.offset; - if (cursorPosition == -1) { - controller.text = text; - } else { - final newText = controller.text.replaceRange( - controller.selection.start, - controller.selection.end, - text, - ); - controller.value = controller.value.copyWith( - text: newText, - selection: TextSelection.collapsed( - offset: controller.selection.start + text.length, - ), - ); - } + return handlePaste(controller: controller, onAttachments: onAttachments); } - @override - Widget build(BuildContext context) { - return CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.enter): - () => onSubmitted(controller.text), - if (UniversalPlatform.isMacOS || - UniversalPlatform.isLinux || - UniversalPlatform.isWeb || - UniversalPlatform.isWindows) - const SingleActivator(LogicalKeyboardKey.keyV, meta: true): - _handlePaste, - if (UniversalPlatform.isWindows || - UniversalPlatform.isLinux || - UniversalPlatform.isWeb) - const SingleActivator(LogicalKeyboardKey.keyV, control: true): - _handlePaste, - }, - child: - isCupertinoApp(context) - ? CupertinoTextField( - minLines: minLines, - maxLines: maxLines, - autofocus: autofocus, - style: style, - textInputAction: textInputAction, - controller: controller, - focusNode: focusNode, - onSubmitted: onSubmitted, - placeholder: hintText, - placeholderStyle: hintStyle, - padding: hintPadding ?? EdgeInsets.zero, - decoration: BoxDecoration( - border: Border.all( - width: 0, - color: ToolkitColors.transparent, - ), - ), - keyboardType: TextInputType.multiline, - contextMenuBuilder: (context, editable) { - final l10n = CupertinoLocalizations.of(context); - final defaultItems = editable.contextMenuButtonItems; - - final filteredItems = defaultItems.where((item) { - return item.type.name != 'paste'; - }); - - final customItems = [ - ContextMenuButtonItem( - label: l10n.pasteButtonLabel, - onPressed: () async { - ContextMenuController.removeAny(); - await _handlePaste(); - }, - ), - ...filteredItems, - ]; - - return CupertinoAdaptiveTextSelectionToolbar.buttonItems( - anchors: editable.contextMenuAnchors, - buttonItems: customItems, - ); - }, - ) - : TextField( - minLines: minLines, - maxLines: maxLines, - autofocus: autofocus, - style: style, - textInputAction: textInputAction, - controller: controller, - focusNode: focusNode, - onSubmitted: onSubmitted, - decoration: InputDecoration( - hintText: hintText, - hintStyle: hintStyle, - border: InputBorder.none, - contentPadding: hintPadding, - isDense: false, + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.enter): + () => onSubmitted(controller.text), + if (UniversalPlatform.isMacOS || + UniversalPlatform.isLinux || + UniversalPlatform.isWeb || + UniversalPlatform.isWindows) + const SingleActivator(LogicalKeyboardKey.keyV, meta: true): + _handlePaste, + if (UniversalPlatform.isWindows || + UniversalPlatform.isLinux || + UniversalPlatform.isWeb) + const SingleActivator(LogicalKeyboardKey.keyV, control: true): + _handlePaste, + }, + child: + isCupertinoApp(context) + ? CupertinoTextField( + minLines: minLines, + maxLines: maxLines, + autofocus: autofocus, + style: style, + textInputAction: textInputAction, + controller: controller, + focusNode: focusNode, + onSubmitted: onSubmitted, + placeholder: hintText, + placeholderStyle: hintStyle, + padding: hintPadding ?? EdgeInsets.zero, + decoration: BoxDecoration( + border: Border.all( + width: 0, + color: ToolkitColors.transparent, ), - keyboardType: TextInputType.multiline, - contextMenuBuilder: (context, editable) { - final defaultItems = editable.contextMenuButtonItems; - - final filteredItems = defaultItems.where((item) { - return item.type.name != 'paste'; - }); - - final customItems = [ - ContextMenuButtonItem( - label: - MaterialLocalizations.of(context).pasteButtonLabel, - onPressed: () async { - ContextMenuController.removeAny(); - await _handlePaste(); - }, - ), - ...filteredItems, - ]; - return AdaptiveTextSelectionToolbar.buttonItems( - anchors: editable.contextMenuAnchors, - buttonItems: customItems, - ); - }, ), - ); - } + keyboardType: TextInputType.multiline, + contextMenuBuilder: (context, editable) { + final l10n = CupertinoLocalizations.of(context); + final defaultItems = editable.contextMenuButtonItems; + + final filteredItems = defaultItems.where((item) { + return item.type.name != 'paste'; + }); + + final customItems = [ + ContextMenuButtonItem( + label: l10n.pasteButtonLabel, + onPressed: () async { + ContextMenuController.removeAny(); + await _handlePaste(); + }, + ), + ...filteredItems, + ]; + + return CupertinoAdaptiveTextSelectionToolbar.buttonItems( + anchors: editable.contextMenuAnchors, + buttonItems: customItems, + ); + }, + ) + : TextField( + minLines: minLines, + maxLines: maxLines, + autofocus: autofocus, + style: style, + textInputAction: textInputAction, + controller: controller, + focusNode: focusNode, + onSubmitted: onSubmitted, + decoration: InputDecoration( + hintText: hintText, + hintStyle: hintStyle, + border: InputBorder.none, + contentPadding: hintPadding, + isDense: false, + ), + keyboardType: TextInputType.multiline, + contextMenuBuilder: (context, editable) { + final defaultItems = editable.contextMenuButtonItems; + + final filteredItems = defaultItems.where((item) { + return item.type.name != 'paste'; + }); + + final customItems = [ + ContextMenuButtonItem( + label: MaterialLocalizations.of(context).pasteButtonLabel, + onPressed: () async { + ContextMenuController.removeAny(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _handlePaste(); + }); + }, + ), + ...filteredItems, + ]; + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editable.contextMenuAnchors, + buttonItems: customItems, + ); + }, + ), + ); } +} diff --git a/pubspec.yaml b/pubspec.yaml index 5fd8694f..92b152d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: url_launcher: ^6.3.2 uuid: ^4.4.2 waveform_recorder: ^1.3.0 + web: ^1.1.1 dev_dependencies: flutter_lints: ^6.0.0 From 14f8bbc55ec25ba0979a99aab49b2dfd4c93bb42 Mon Sep 17 00:00:00 2001 From: dt_emmy Date: Wed, 10 Dec 2025 21:10:07 +0100 Subject: [PATCH 3/3] feat: full implementation of paste features across platforms --- .../android/app/src/main/AndroidManifest.xml | 11 +- example/devtools_options.yaml | 3 + example/ios/Podfile.lock | 22 +- .../Flutter/GeneratedPluginRegistrant.swift | 8 +- example/macos/Podfile.lock | 22 +- .../helpers/paste_helper/paste_handler.dart | 200 +++++++------- .../paste_helper/paste_helper_stub.dart | 14 +- .../paste_helper/paste_helper_web.dart | 244 ++++++++++++------ lib/src/views/chat_text_field.dart | 119 ++++++--- pubspec.yaml | 3 +- 10 files changed, 417 insertions(+), 229 deletions(-) create mode 100644 example/devtools_options.yaml diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 19463a40..395becc0 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -26,13 +26,10 @@ - + android:name="com.superlist.super_native_extensions.DataProvider" + android:authorities="com.example.flutter_ai_toolkit_example.SuperClipboardDataProvider" + android:exported="true" + android:grantUriPermissions="true" > diff --git a/example/devtools_options.yaml b/example/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/example/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 5faacbe5..99c72b2a 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -5,6 +5,8 @@ PODS: - PromisesObjC (~> 2.4) - camera_avfoundation (0.0.1): - Flutter + - device_info_plus (0.0.1): + - Flutter - file_selector_ios (0.0.1): - Flutter - Firebase/Auth (12.4.0): @@ -77,7 +79,7 @@ PODS: - GTMSessionFetcher/Core (5.0.0) - image_picker_ios (0.0.1): - Flutter - - pasteboard (0.0.1): + - irondash_engine_context (0.0.1): - Flutter - path_provider_foundation (0.0.1): - Flutter @@ -89,21 +91,25 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - super_native_extensions (0.0.1): + - Flutter - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - firebase_app_check (from `.symlinks/plugins/firebase_app_check/ios`) - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - Flutter (from `Flutter`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - - pasteboard (from `.symlinks/plugins/pasteboard/ios`) + - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - record_ios (from `.symlinks/plugins/record_ios/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: @@ -125,6 +131,8 @@ SPEC REPOS: EXTERNAL SOURCES: camera_avfoundation: :path: ".symlinks/plugins/camera_avfoundation/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" file_selector_ios: :path: ".symlinks/plugins/file_selector_ios/ios" firebase_app_check: @@ -137,20 +145,23 @@ EXTERNAL SOURCES: :path: Flutter image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" - pasteboard: - :path: ".symlinks/plugins/pasteboard/ios" + irondash_engine_context: + :path: ".symlinks/plugins/irondash_engine_context/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" record_ios: :path: ".symlinks/plugins/record_ios/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + super_native_extensions: + :path: ".symlinks/plugins/super_native_extensions/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f camera_avfoundation: 281867ff09f1da66f031a184ecfbc6f2e625c9f5 + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 file_selector_ios: 80c12e90ad3f2045ed6819d03742f1a4c5ec3f93 Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e firebase_app_check: 53a9efd793edad49230d8d49b19cb8d47b8450ed @@ -167,12 +178,13 @@ SPEC CHECKSUMS: GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29 image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b - pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 + irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba record_ios: 840d21cce013c5a3b2168b74a54ebdb4136359e2 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7 url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa PODFILE CHECKSUM: 7773a3d1e948b3cef227c6713241e4fcfe42cda9 diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 18d14e30..bac66446 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,24 +5,28 @@ import FlutterMacOS import Foundation +import device_info_plus import file_selector_macos import firebase_app_check import firebase_auth import firebase_core -import pasteboard +import irondash_engine_context import path_provider_foundation import record_macos import shared_preferences_foundation +import super_native_extensions import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) - PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) + IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 3239665c..b3e95270 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -3,6 +3,8 @@ PODS: - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) - PromisesObjC (~> 2.4) + - device_info_plus (0.0.1): + - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS - Firebase/AppCheck (12.4.0): @@ -77,7 +79,7 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - GTMSessionFetcher/Core (5.0.0) - - pasteboard (0.0.1): + - irondash_engine_context (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter @@ -88,19 +90,23 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - super_native_extensions (0.0.1): + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS DEPENDENCIES: + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - firebase_app_check (from `Flutter/ephemeral/.symlinks/plugins/firebase_app_check/macos`) - firebase_auth (from `Flutter/ephemeral/.symlinks/plugins/firebase_auth/macos`) - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`) + - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) SPEC REPOS: @@ -119,6 +125,8 @@ SPEC REPOS: - PromisesObjC EXTERNAL SOURCES: + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos firebase_app_check: @@ -129,19 +137,22 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos FlutterMacOS: :path: Flutter/ephemeral - pasteboard: - :path: Flutter/ephemeral/.symlinks/plugins/pasteboard/macos + irondash_engine_context: + :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin record_macos: :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + super_native_extensions: + :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f + device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150 Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e firebase_app_check: 87116ccdfe0f153231af37b0431e96b0d5a76b9c @@ -157,11 +168,12 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29 - pasteboard: 9b69dba6fedbb04866be632205d532fe2f6b1d99 + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 record_macos: 4440ca269ad3b870ebb1965297a365d558f0c520 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce PODFILE CHECKSUM: abc7d4662afc18f3dac224359a4bbdfd943487c9 diff --git a/lib/src/helpers/paste_helper/paste_handler.dart b/lib/src/helpers/paste_helper/paste_handler.dart index 75aff6f7..8555e182 100644 --- a/lib/src/helpers/paste_helper/paste_handler.dart +++ b/lib/src/helpers/paste_helper/paste_handler.dart @@ -2,14 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:typed_data'; + import 'package:cross_file/cross_file.dart'; import 'package:flutter/widgets.dart' - show TextEditingController, TextSelection, debugPrint, debugPrintStack; -import 'package:flutter/foundation.dart' show kIsWeb; + show TextEditingController, debugPrint, debugPrintStack; import 'package:mime/mime.dart'; -import 'package:pasteboard/pasteboard.dart'; +import 'package:super_clipboard/super_clipboard.dart'; -import 'paste_helper.dart' as pst; import '../../providers/interface/attachments.dart'; /// Handles paste operations, supporting both text and image pasting. @@ -31,84 +31,124 @@ import '../../providers/interface/attachments.dart'; Future handlePaste({ required TextEditingController controller, required void Function(List attachments)? onAttachments, + required void Function({ + required TextEditingController controller, + required String text, + }) + insertText, }) async { try { - if (kIsWeb) { - await pst.handlePasteWeb( - controller: controller, - onAttachments: onAttachments, - insertText: _insertText, - ); - return; - } - final files = await Pasteboard.files(); - if (files.isNotEmpty && onAttachments != null) { - for (final file in files) { - final looksLikeImage = _looksLikeImagePath(file); - if (looksLikeImage["isFile"] == true) { - final bytes = await XFile(file).readAsBytes(); - final attachment = ImageFileAttachment( - name: 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(looksLikeImage["mimeType"], bytes)}', - mimeType: looksLikeImage["mimeType"], - bytes: bytes, - ); - onAttachments([attachment]); + final clipboard = SystemClipboard.instance; + if (clipboard == null) return; + final reader = await clipboard.read(); + + if (onAttachments != null) { + final imageFormats = [ + Formats.png, + Formats.jpeg, + Formats.bmp, + Formats.gif, + Formats.tiff, + Formats.webp, + ]; + + final fileFormats = [ + Formats.pdf, + Formats.doc, + Formats.docx, + Formats.xls, + Formats.xlsx, + Formats.ppt, + Formats.pptx, + Formats.epub, + ]; + + if (reader.canProvide(Formats.fileUri)) { + await reader.readValue(Formats.fileUri).then((val) async { + if (val != null) { + if (val.isScheme('file')) { + final path = val.toFilePath(); + final file = XFile(path); + final attachment = await FileAttachment.fromFile(file); + onAttachments([attachment]); + } + } + }); + return; + } + + for (final format in fileFormats) { + if (reader.canProvide(format)) { + reader.getFile(format, (file) async { + final stream = file.getStream(); + + await stream.toList().then((chunks) { + final attachmentBytes = Uint8List.fromList( + chunks.expand((e) => e).toList(), + ); + final mimeType = + lookupMimeType('', headerBytes: attachmentBytes) ?? + 'application/octet-stream'; + final attachment = FileAttachment.fileOrImage( + name: + 'pasted_file_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}', + mimeType: mimeType, + bytes: attachmentBytes, + ); + onAttachments([attachment]); + return; + }); + }); + return; + } + } + + for (final format in imageFormats) { + if (reader.canProvide(format)) { + reader.getFile(format, (file) async { + final stream = file.getStream(); + + await stream.toList().then((chunks) { + final attachmentBytes = Uint8List.fromList( + chunks.expand((e) => e).toList(), + ); + final mimeType = + lookupMimeType('', headerBytes: attachmentBytes) ?? + 'image/png'; + final attachment = ImageFileAttachment( + name: + 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}', + mimeType: mimeType, + bytes: attachmentBytes, + ); + onAttachments([attachment]); + return; + }); + }); + return; } - return; } } + if (reader.canProvide(Formats.plainText)) { + final text = await reader.readValue(Formats.plainText); + if (text != null && text.isNotEmpty) { + insertText(controller: controller, text: text); + return; + } + } - final image = await Pasteboard.image; - if (image != null && onAttachments != null) { - final mimeType = lookupMimeType('', headerBytes: image) ?? 'image/png'; - final attachment = ImageFileAttachment( - name: 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}', - mimeType: mimeType, - bytes: image, - ); - onAttachments([attachment]); - return; + if (reader.canProvide(Formats.htmlText)) { + final html = await reader.readValue(Formats.htmlText); + if (html != null && html.isNotEmpty) { + insertText(controller: controller, text: html); + return; + } } } catch (e, s) { debugPrint('Error pasting image: $e'); debugPrintStack(stackTrace: s); } - - final text = await Pasteboard.text; - if (text != null && text.isNotEmpty) { - _insertText(controller: controller, text: text); - } -} - -/// Inserts text at the current cursor position in the text controller. -/// -/// If there's a text selection, it will be replaced by the new text. -/// If there's no selection, the text will be inserted at the cursor position. -/// -/// Parameters: -/// - [controller]: The text editing controller to insert text into -/// - [text]: The text to insert -void _insertText({ - required TextEditingController controller, - required String text, -}) { - final cursorPosition = controller.selection.base.offset; - if (cursorPosition == -1) { - controller.text = text; - } else { - final newText = controller.text.replaceRange( - controller.selection.start, - controller.selection.end, - text, - ); - controller.value = controller.value.copyWith( - text: newText, - selection: TextSelection.collapsed( - offset: controller.selection.start + text.length, - ), - ); - } } /// Determines the appropriate file extension for a given MIME type. @@ -120,7 +160,8 @@ void _insertText({ /// A string representing the file extension (without the dot), defaults to 'bin' if unknown String _getExtensionFromMime(String mimeType, [List? bytes]) { String detectedMimeType = mimeType; - if (bytes != null && (mimeType.isEmpty || mimeType == 'application/octet-stream')) { + if (bytes != null && + (mimeType.isEmpty || mimeType == 'application/octet-stream')) { detectedMimeType = lookupMimeType('', headerBytes: bytes) ?? mimeType; } final extension = extensionFromMime(detectedMimeType); @@ -129,20 +170,3 @@ String _getExtensionFromMime(String mimeType, [List? bytes]) { } return extension.startsWith('.') ? extension.substring(1) : extension; } - -/// Checks if the given text appears to be an image file path or URL. -/// -/// Parameters: -/// - [text]: The text to check -/// -/// Returns: -/// true if the text looks like an image path/URL, false otherwise -Map _looksLikeImagePath(String text) { - final lower = text.toLowerCase(); - final uri = Uri.tryParse(lower); - if (uri != null && uri.path.isNotEmpty) { - final mimeType = lookupMimeType(uri.path) ?? ""; - return mimeType.startsWith('image/') ? {"isFile": mimeType.startsWith('image/'), "mimeType": mimeType} : {"isFile": false, "mimeType": ""}; - } - return {"isFile": false, "mimeType": ""}; -} \ No newline at end of file diff --git a/lib/src/helpers/paste_helper/paste_helper_stub.dart b/lib/src/helpers/paste_helper/paste_helper_stub.dart index 52a70461..d987f8d3 100644 --- a/lib/src/helpers/paste_helper/paste_helper_stub.dart +++ b/lib/src/helpers/paste_helper/paste_helper_stub.dart @@ -21,5 +21,15 @@ import '../../providers/interface/attachments.dart'; Future handlePasteWeb({ required TextEditingController controller, required void Function(List attachments)? onAttachments, - required void Function({required TextEditingController controller, required String text}) insertText, -}) async { } + required void Function({ + required TextEditingController controller, + required String text, + }) + insertText, +}) async {} + +/// A no-op implementation of unregistering the web listener for non-web platforms. +/// +/// This function is provided for API compatibility with web platforms but does nothing +/// when called on non-web platforms. On web, this unregister the paste event listener. +void unregisterPasteListener() {} diff --git a/lib/src/helpers/paste_helper/paste_helper_web.dart b/lib/src/helpers/paste_helper/paste_helper_web.dart index a195ef8b..4253c116 100644 --- a/lib/src/helpers/paste_helper/paste_helper_web.dart +++ b/lib/src/helpers/paste_helper/paste_helper_web.dart @@ -2,17 +2,20 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:js_interop'; +import 'package:cross_file/cross_file.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart' show TextEditingController; import 'package:mime/mime.dart'; -import 'package:web/web.dart' as web; +import 'package:super_clipboard/super_clipboard.dart'; import '../../providers/interface/attachments.dart'; -/// Handles paste events in a web environment, supporting both text and image pasting. +bool _isListenerRegistered = false; +final _events = ClipboardEvents.instance; + +/// Handles paste events in a web environment, supporting both text, file, and image pasting. /// -/// This function processes the clipboard contents and either: +/// This function processes the clipboard contents, registers a paste event listener and either: /// - Extracts and handles image data if images are present in the clipboard /// - Inserts plain text into the provided text controller /// @@ -27,98 +30,33 @@ Future handlePasteWeb({ required TextEditingController controller, required void Function(List attachments)? onAttachments, required void Function({ - required TextEditingController controller, - required String text, - }) insertText, + required TextEditingController controller, + required String text, + }) + insertText, }) async { try { - final clipboard = web.window.navigator.clipboard; - final jsItems = await clipboard.read().toDart; - - final items = jsItems.toDart; - - for (final item in items) { - final types = item.types.toDart; - for (final type in types) { - final typeString = type.toDart; - - if (typeString.startsWith("image/")) { - final blob = await item.getType(typeString).toDart; - - if (blob.isUndefinedOrNull) continue; - - final bytes = await _blobToBytes(blob); - if (bytes == null || bytes.isEmpty) continue; - - final extension = _getExtensionFromMime(typeString); - - final attachment = ImageFileAttachment( - name: - 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.$extension', - mimeType: typeString, - bytes: bytes, - ); - - onAttachments?.call([attachment]); - return; - } - } - - for (final type in types) { - final typeString = type.toDart; - - if (typeString.startsWith("text/") && - typeString != "text/plain") { - final blob = await item.getType(typeString).toDart; - if (blob.isUndefinedOrNull) continue; - - final text = await blob.text().toDart; - final textString = text.toDart; - - if (textString.isNotEmpty) { - insertText(controller: controller, text: textString); - return; - } - } - } + if (_isListenerRegistered) return; - if (types.contains('text/plain'.toJS)) { - final blob = await item.getType("text/plain").toDart; - if (blob.isUndefinedOrNull) continue; + _isListenerRegistered = true; - final text = await blob.text().toDart; - final textString = text.toDart; + if (_events == null) return; - if (textString.isNotEmpty) { - insertText(controller: controller, text: textString); - return; - } - } - } + _events!.registerPasteEventListener((event) async { + final reader = await event.getClipboardReader(); + await _pasteOperation( + controller: controller, + onAttachments: onAttachments, + insertText: insertText, + reader: reader, + ); + }); } catch (e, s) { debugPrint('Error in handlePasteWeb: $e'); debugPrintStack(stackTrace: s); } } - -/// Converts a web Blob object to a Uint8List. -/// -/// Parameters: -/// - [blob]: The web Blob to convert -/// -/// Returns: -/// A [Future] that completes with the binary data as [Uint8List], or null if conversion fails -Future _blobToBytes(web.Blob blob) async { - try { - final arrayBuffer = await blob.arrayBuffer().toDart; - return Uint8List.view(arrayBuffer.toDart); - } catch (e) { - debugPrint('Error converting blob to bytes: $e'); - return null; - } -} - /// Determines the appropriate file extension for a given MIME type. /// /// Parameters: @@ -129,7 +67,8 @@ Future _blobToBytes(web.Blob blob) async { /// A string representing the file extension (without the dot), defaults to 'bin' if unknown String _getExtensionFromMime(String mimeType, [List? bytes]) { String detectedMimeType = mimeType; - if (bytes != null && (mimeType.isEmpty || mimeType == 'application/octet-stream')) { + if (bytes != null && + (mimeType.isEmpty || mimeType == 'application/octet-stream')) { detectedMimeType = lookupMimeType('', headerBytes: bytes) ?? mimeType; } final extension = extensionFromMime(detectedMimeType); @@ -138,3 +77,136 @@ String _getExtensionFromMime(String mimeType, [List? bytes]) { } return extension.startsWith('.') ? extension.substring(1) : extension; } + +/// Internal function to handle the actual clipboard reading and data processing. +/// +/// It checks for various data formats (files, images, plain text, HTML) in a specific order +/// and executes the appropriate action (calling [onAttachments] or [insertText]). +/// +/// Parameters: +/// - [controller]: The text editing controller. +/// - [onAttachments]: Callback to handle file/image attachments. +/// - [insertText]: Function to handle text insertion. +/// - [reader]: The [ClipboardReader] containing the clipboard data. +Future _pasteOperation({ + required TextEditingController controller, + required void Function(List attachments)? onAttachments, + required void Function({ + required TextEditingController controller, + required String text, + }) + insertText, + required ClipboardReader reader, +}) async { + if (onAttachments != null) { + final imageFormats = [ + Formats.png, + Formats.jpeg, + Formats.bmp, + Formats.gif, + Formats.tiff, + Formats.webp, + ]; + + final fileFormats = [ + Formats.pdf, + Formats.doc, + Formats.docx, + Formats.xls, + Formats.xlsx, + Formats.ppt, + Formats.pptx, + Formats.epub, + ]; + + for (final format in fileFormats) { + if (reader.canProvide(format)) { + reader.getFile(format, (file) async { + final stream = file.getStream(); + + await stream.toList().then((chunks) { + final attachmentBytes = Uint8List.fromList( + chunks.expand((e) => e).toList(), + ); + final mimeType = + lookupMimeType('', headerBytes: attachmentBytes) ?? + 'application/octet-stream'; + final attachment = FileAttachment.fileOrImage( + name: + 'pasted_file_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}', + mimeType: mimeType, + bytes: attachmentBytes, + ); + onAttachments([attachment]); + return; + }); + }); + return; + } + } + + if (reader.canProvide(Formats.fileUri)) { + await reader.readValue(Formats.fileUri).then((val) async { + if (val != null) { + if (val.isScheme('file')) { + final path = val.toFilePath(); + final file = XFile(path); + final attachment = await FileAttachment.fromFile(file); + onAttachments([attachment]); + } + } + }); + return; + } + + for (final format in imageFormats) { + if (reader.canProvide(format)) { + reader.getFile(format, (file) async { + final stream = file.getStream(); + await stream.toList().then((chunks) { + final attachmentBytes = Uint8List.fromList( + chunks.expand((e) => e).toList(), + ); + final mimeType = + lookupMimeType('', headerBytes: attachmentBytes) ?? 'image/png'; + final attachment = ImageFileAttachment( + name: + 'pasted_image_${DateTime.now().millisecondsSinceEpoch}.${_getExtensionFromMime(mimeType)}', + mimeType: mimeType, + bytes: attachmentBytes, + ); + onAttachments([attachment]); + return; + }); + }); + return; + } + } + + if (reader.canProvide(Formats.plainText)) { + final text = await reader.readValue(Formats.plainText); + if (text != null && text.isNotEmpty) { + insertText(controller: controller, text: text); + return; + } + } + + if (reader.canProvide(Formats.htmlText)) { + final html = await reader.readValue(Formats.htmlText); + if (html != null && html.isNotEmpty) { + insertText(controller: controller, text: html); + return; + } + } + } +} + +/// Unregisters the paste event listener established in [handlePasteWeb]. +/// +/// This is necessary to stop processing paste events when they are no longer needed +/// (e.g., when a widget is disposed). +void unregisterPasteListener() { + if (_events != null) { + _events!.unregisterPasteEventListener; + } +} diff --git a/lib/src/views/chat_text_field.dart b/lib/src/views/chat_text_field.dart index a4d12f41..0659bfb4 100644 --- a/lib/src/views/chat_text_field.dart +++ b/lib/src/views/chat_text_field.dart @@ -20,6 +20,7 @@ import 'package:flutter/material.dart' import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import '../helpers/paste_helper/paste_handler.dart'; +import '../helpers/paste_helper/paste_helper.dart' as pst; import 'package:universal_platform/universal_platform.dart'; import '../providers/interface/attachments.dart'; @@ -31,7 +32,7 @@ import '../utility.dart'; /// This widget will render either a [CupertinoTextField] or a [TextField] /// depending on whether the app is using Cupertino or Material design. @immutable -class ChatTextField extends StatelessWidget { +class ChatTextField extends StatefulWidget { /// Creates an adaptive text field. /// /// Many of the parameters are required to ensure consistent behavior @@ -88,8 +89,67 @@ class ChatTextField extends StatelessWidget { /// Called when attachments are pasted into the text field. final void Function(List attachments)? onAttachments; + @override + State createState() => _ChatTextFieldState(); +} + +class _ChatTextFieldState extends State { + /// Inserts text at the current cursor position in the text controller. + /// + /// If there's a text selection, it will be replaced by the new text. + /// If there's no selection, the text will be inserted at the cursor position. + /// + /// Parameters: + /// - [controller]: The text editing controller to insert text into + /// - [text]: The text to insert + void _insertText({ + required TextEditingController controller, + required String text, + }) { + final cursorPosition = controller.selection.base.offset; + if (cursorPosition == -1) { + controller.text = text; + } else { + final newText = controller.text.replaceRange( + controller.selection.start, + controller.selection.end, + text, + ); + controller.value = controller.value.copyWith( + text: newText, + selection: TextSelection.collapsed( + offset: controller.selection.start + text.length, + ), + ); + } + } + + Future registerListeners() async { + return pst.handlePasteWeb( + controller: widget.controller, + onAttachments: widget.onAttachments, + insertText: _insertText, + ); + } + Future _handlePaste() async { - return handlePaste(controller: controller, onAttachments: onAttachments); + return handlePaste( + controller: widget.controller, + onAttachments: widget.onAttachments, + insertText: _insertText, + ); + } + + @override + void initState() { + registerListeners(); + super.initState(); + } + + @override + void dispose() { + pst.unregisterPasteListener(); + super.dispose(); } @override @@ -97,33 +157,28 @@ class ChatTextField extends StatelessWidget { return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.enter): - () => onSubmitted(controller.text), - if (UniversalPlatform.isMacOS || - UniversalPlatform.isLinux || - UniversalPlatform.isWeb || - UniversalPlatform.isWindows) + () => widget.onSubmitted(widget.controller.text), + if (UniversalPlatform.isMacOS) const SingleActivator(LogicalKeyboardKey.keyV, meta: true): _handlePaste, - if (UniversalPlatform.isWindows || - UniversalPlatform.isLinux || - UniversalPlatform.isWeb) + if (UniversalPlatform.isWindows || UniversalPlatform.isLinux) const SingleActivator(LogicalKeyboardKey.keyV, control: true): _handlePaste, }, child: isCupertinoApp(context) ? CupertinoTextField( - minLines: minLines, - maxLines: maxLines, - autofocus: autofocus, - style: style, - textInputAction: textInputAction, - controller: controller, - focusNode: focusNode, - onSubmitted: onSubmitted, - placeholder: hintText, - placeholderStyle: hintStyle, - padding: hintPadding ?? EdgeInsets.zero, + minLines: widget.minLines, + maxLines: widget.maxLines, + autofocus: widget.autofocus, + style: widget.style, + textInputAction: widget.textInputAction, + controller: widget.controller, + focusNode: widget.focusNode, + onSubmitted: widget.onSubmitted, + placeholder: widget.hintText, + placeholderStyle: widget.hintStyle, + padding: widget.hintPadding ?? EdgeInsets.zero, decoration: BoxDecoration( border: Border.all( width: 0, @@ -157,19 +212,19 @@ class ChatTextField extends StatelessWidget { }, ) : TextField( - minLines: minLines, - maxLines: maxLines, - autofocus: autofocus, - style: style, - textInputAction: textInputAction, - controller: controller, - focusNode: focusNode, - onSubmitted: onSubmitted, + minLines: widget.minLines, + maxLines: widget.maxLines, + autofocus: widget.autofocus, + style: widget.style, + textInputAction: widget.textInputAction, + controller: widget.controller, + focusNode: widget.focusNode, + onSubmitted: widget.onSubmitted, decoration: InputDecoration( - hintText: hintText, - hintStyle: hintStyle, + hintText: widget.hintText, + hintStyle: widget.hintStyle, border: InputBorder.none, - contentPadding: hintPadding, + contentPadding: widget.hintPadding, isDense: false, ), keyboardType: TextInputType.multiline, diff --git a/pubspec.yaml b/pubspec.yaml index 92b152d9..acc7d66c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,12 +26,11 @@ dependencies: google_fonts: ^6.2.1 image_picker: ^1.1.2 mime: ^2.0.0 - pasteboard: ^0.4.0 + super_clipboard: ^0.9.1 universal_platform: ^1.1.0 url_launcher: ^6.3.2 uuid: ^4.4.2 waveform_recorder: ^1.3.0 - web: ^1.1.1 dev_dependencies: flutter_lints: ^6.0.0