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..bac66446 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,22 +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 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")) + 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 f5abe519..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,6 +79,8 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - GTMSessionFetcher/Core (5.0.0) + - irondash_engine_context (0.0.1): + - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -86,18 +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`) + - 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: @@ -116,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: @@ -126,17 +137,22 @@ 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 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 +168,12 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29 + 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 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..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..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..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..0659bfb4 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 '../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 +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 @@ -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,173 @@ 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 - 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, + 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: widget.controller, + onAttachments: widget.onAttachments, + insertText: _insertText, + ); + } + + @override + void initState() { + registerListeners(); + super.initState(); + } + + @override + 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: + 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(); + }, + ), + ...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..acc7d66c 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 + super_clipboard: ^0.9.1 universal_platform: ^1.1.0 url_launcher: ^6.3.2 uuid: ^4.4.2