diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index dba5dfc5..395becc0 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,12 @@ + + 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,6 +79,8 @@ PODS: - GTMSessionFetcher/Core (5.0.0) - image_picker_ios (0.0.1): - Flutter + - irondash_engine_context (0.0.1): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -87,20 +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`) + - 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: @@ -122,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: @@ -134,18 +145,23 @@ EXTERNAL SOURCES: :path: Flutter image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/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 @@ -162,11 +178,13 @@ SPEC CHECKSUMS: GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29 image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b + 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 20c23bad..eca53e6e 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,22 +5,30 @@ import FlutterMacOS import Foundation +import device_info_plus import file_selector_macos import firebase_app_check import firebase_auth import firebase_core +import irondash_engine_context +import mac_menu_bar 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")) + IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) + MacMenuBarPlugin.register(with: registry.registrar(forPlugin: "MacMenuBarPlugin")) 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 f5abe519..c59f086a 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,6 +79,10 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - GTMSessionFetcher/Core (5.0.0) + - irondash_engine_context (0.0.1): + - FlutterMacOS + - mac_menu_bar (0.0.1): + - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -86,18 +92,24 @@ 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`) + - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) + - mac_menu_bar (from `Flutter/ephemeral/.symlinks/plugins/mac_menu_bar/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: @@ -116,6 +128,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: @@ -126,17 +140,24 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos FlutterMacOS: :path: Flutter/ephemeral + irondash_engine_context: + :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos + mac_menu_bar: + :path: Flutter/ephemeral/.symlinks/plugins/mac_menu_bar/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 @@ -152,10 +173,13 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29 + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + mac_menu_bar: 9290444652836c996e840adeaac5bff963673507 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 new file mode 100644 index 00000000..8555e182 --- /dev/null +++ b/lib/src/helpers/paste_helper/paste_handler.dart @@ -0,0 +1,172 @@ +// 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:typed_data'; + +import 'package:cross_file/cross_file.dart'; +import 'package:flutter/widgets.dart' + show TextEditingController, debugPrint, debugPrintStack; +import 'package:mime/mime.dart'; +import 'package:super_clipboard/super_clipboard.dart'; + +import '../../providers/interface/attachments.dart'; + +/// Handles paste operations, 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 if no images are found +/// +/// On web, it delegates to [handlePasteWeb] for more comprehensive handling +/// of web-specific clipboard APIs. +/// +/// Parameters: +/// - [controller]: The text editing controller to insert text into +/// - [onAttachments]: Callback that receives a list of attachments when images are pasted. +/// If null, image pasting will be skipped even if images are available. +/// +/// Returns: +/// A [Future] that completes when the paste operation is finished +Future handlePaste({ + required TextEditingController controller, + required void Function(List attachments)? onAttachments, + required void Function({ + required TextEditingController controller, + required String text, + }) + insertText, +}) async { + try { + 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; + } + } + } + + 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; + } + } + } catch (e, s) { + debugPrint('Error pasting image: $e'); + debugPrintStack(stackTrace: s); + } +} + +/// 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; +} 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..ac7eac59 --- /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'; 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..d987f8d3 --- /dev/null +++ b/lib/src/helpers/paste_helper/paste_helper_stub.dart @@ -0,0 +1,35 @@ +// 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 {} + +/// 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 new file mode 100644 index 00000000..4253c116 --- /dev/null +++ b/lib/src/helpers/paste_helper/paste_helper_web.dart @@ -0,0 +1,212 @@ +// 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:cross_file/cross_file.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show TextEditingController; +import 'package:mime/mime.dart'; +import 'package:super_clipboard/super_clipboard.dart'; + +import '../../providers/interface/attachments.dart'; + +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, 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 +/// +/// 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 { + if (_isListenerRegistered) return; + + _isListenerRegistered = true; + + if (_events == null) 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); + } +} + +/// 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; +} + +/// 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_input/chat_input.dart b/lib/src/views/chat_input/chat_input.dart index 45a886d5..fbe84138 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..6838e835 100644 --- a/lib/src/views/chat_input/text_or_audio_input.dart +++ b/lib/src/views/chat_input/text_or_audio_input.dart @@ -1,8 +1,8 @@ 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'; import '../../utility.dart'; import '../chat_text_field.dart'; import 'editing_indicator.dart'; @@ -17,6 +17,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 +31,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 +47,7 @@ class TextOrAudioInput extends StatelessWidget { _focusNode = focusNode, _textController = textController, _onSubmitPrompt = onSubmitPrompt, + _onAttachments = onAttachments, _onRecordingStopped = onRecordingStopped, _onCancelEdit = onCancelEdit, _waveController = waveController, @@ -53,6 +56,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 +117,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..ce8aa88f 100644 --- a/lib/src/views/chat_text_field.dart +++ b/lib/src/views/chat_text_field.dart @@ -2,11 +2,29 @@ // 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:mac_menu_bar/mac_menu_bar.dart'; +import 'package:super_clipboard/super_clipboard.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'; import '../styles/toolkit_colors.dart'; import '../utility.dart'; @@ -16,7 +34,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 @@ -30,6 +48,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 +88,263 @@ class ChatTextField extends StatelessWidget { /// Called when the user submits editable content. final void Function(String text) onSubmitted; + /// 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 { + if (UniversalPlatform.isMacOS) { + MacMenuBar.onPaste(() async { + await _handlePaste(); + return true; + }); + + MacMenuBar.onCopy(_copyToClipboard); + MacMenuBar.onCut(_cutToClipboard); + MacMenuBar.onSelectAll(_selectAll); + } + return pst.handlePasteWeb( + controller: widget.controller, + onAttachments: widget.onAttachments, + insertText: _insertText, + ); + } + + Future _handlePaste() async { + return handlePaste( + controller: widget.controller, + onAttachments: widget.onAttachments, + insertText: _insertText, + ); + } + + /// Copies the currently selected text to the system clipboard. + /// + /// If there is a text selection, it will be copied to the clipboard. + /// If no text is selected, this method does nothing. + /// + /// Returns `true` if the operation was successful, `false` otherwise. + /// This method is typically called when the user triggers the copy command + /// from the mac menu bar. + Future _copyToClipboard() async { + final selection = TextSelection( + baseOffset: widget.controller.selection.start, + extentOffset: widget.controller.selection.end, + ); + + if (selection.isValid && !selection.isCollapsed) { + final selectedText = widget.controller.text.substring( + selection.start, + selection.end, + ); + final data = DataWriterItem(); + data.add(Formats.plainText(selectedText)); + await SystemClipboard.instance?.write([data]); + } + return true; + } + + /// Selects all text in the text field. + /// + /// This method updates the text selection to include all characters + /// in the text field's content. The selection will have its base at the + /// start of the text and its extent at the end. + /// + /// Returns `true` to indicate successful completion. + /// This method is typically called when the user triggers the select all command + /// (e.g., Cmd+A on macOS or Ctrl+A on other platforms). + Future _selectAll() async { + widget.controller.selection = TextSelection( + baseOffset: 0, + extentOffset: widget.controller.text.length, + ); + return true; + } + + /// Cuts the currently selected text to the system clipboard and removes it from the text field. + /// + /// If there is a text selection, it will be copied to the clipboard and then + /// removed from the text field. The cursor will be positioned at the start + /// of the original selection after the cut operation. + /// + /// Returns `true` if the operation was successful, `false` otherwise. + /// This method is typically called when the user triggers the cut command + /// (e.g., Cmd+X on macOS or Ctrl+X on other platforms). + Future _cutToClipboard() async { + final selection = widget.controller.selection; + if (selection.isValid && !selection.isCollapsed) { + final selectedText = widget.controller.text.substring( + selection.start, + selection.end, + ); + + final data = DataWriterItem(); + data.add(Formats.plainText(selectedText)); + await SystemClipboard.instance?.write([data]); + + final newValue = widget.controller.value; + final newText = newValue.text.replaceRange( + selection.start, + selection.end, + '', + ); + + widget.controller.value = newValue.copyWith( + text: newText, + selection: TextSelection.collapsed(offset: selection.start), + composing: TextRange.empty, + ); + } + return true; + } + + @override + void initState() { + registerListeners(); + super.initState(); + } + @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), + void dispose() { + pst.unregisterPasteListener(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.enter): + () => widget.onSubmitted(widget.controller.text), + if (UniversalPlatform.isMacOS) + const SingleActivator(LogicalKeyboardKey.keyV, meta: true): + _handlePaste, + if (UniversalPlatform.isWindows || UniversalPlatform.isLinux) + const SingleActivator(LogicalKeyboardKey.keyV, control: true): + _handlePaste, + }, + child: _buildAdaptiveTextField(context), + ); + } + + Widget _buildAdaptiveTextField(BuildContext context) { + return isCupertinoApp(context) + ? CupertinoTextField( + 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, 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(); + }, ), - 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, + ...filteredItems, + ]; + + return CupertinoAdaptiveTextSelectionToolbar.buttonItems( + anchors: editable.contextMenuAnchors, + buttonItems: customItems, + ); + }, + ) + : TextField( + 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: widget.hintText, + hintStyle: widget.hintStyle, + border: InputBorder.none, + contentPadding: widget.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 3ba0fbff..5f7c0191 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,7 +25,9 @@ dependencies: flutter_picture_taker: ^0.2.0 google_fonts: ^6.2.1 image_picker: ^1.1.2 + mac_menu_bar: ^0.0.1 mime: ^2.0.0 + super_clipboard: ^0.9.1 universal_platform: ^1.1.0 url_launcher: ^6.3.2 uuid: ^4.4.2