Merge pull request #250 from cogwheel0/clipboard-cross-platform-paste
feat(clipboard): Add pasteboard support for cross-platform image paste
This commit is contained in:
@@ -65,6 +65,8 @@ PODS:
|
||||
- onnxruntime-c (= 1.22.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- pasteboard (0.0.1):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -120,6 +122,7 @@ DEPENDENCIES:
|
||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- quick_actions_ios (from `.symlinks/plugins/quick_actions_ios/ios`)
|
||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||
@@ -171,6 +174,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
pasteboard:
|
||||
:path: ".symlinks/plugins/pasteboard/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
quick_actions_ios:
|
||||
@@ -218,6 +223,7 @@ SPEC CHECKSUMS:
|
||||
onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a
|
||||
onnxruntime-objc: 83d28b87525bd971259a66e153ea32b5d023de19
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
quick_actions_ios: 500fcc11711d9f646739093395c4ae8eec25f779
|
||||
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'file_attachment_service.dart';
|
||||
@@ -9,6 +10,11 @@ import 'file_attachment_service.dart';
|
||||
///
|
||||
/// This service converts pasted image data into [LocalAttachment] objects
|
||||
/// that integrate with the existing file attachment flow.
|
||||
///
|
||||
/// Uses the pasteboard package to read images from the system clipboard,
|
||||
/// which works across iOS, Android, and desktop platforms. On iOS 16+,
|
||||
/// this properly handles privacy restrictions that prevent standard paste
|
||||
/// operations from accessing image content.
|
||||
class ClipboardAttachmentService {
|
||||
/// Supported MIME types for image paste operations.
|
||||
static const Set<String> supportedImageMimeTypes = {
|
||||
@@ -20,6 +26,95 @@ class ClipboardAttachmentService {
|
||||
'image/bmp',
|
||||
};
|
||||
|
||||
/// Reads an image from the system clipboard.
|
||||
///
|
||||
/// Returns the image data as bytes if an image is present, null otherwise.
|
||||
/// This uses the pasteboard package which properly interfaces with iOS's
|
||||
/// UIPasteboard and works on other platforms as well.
|
||||
Future<Uint8List?> getClipboardImage() async {
|
||||
try {
|
||||
final imageBytes = await Pasteboard.image;
|
||||
return imageBytes;
|
||||
} catch (e) {
|
||||
debugPrint('ClipboardAttachmentService: Failed to read clipboard: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets files from the clipboard (supported on desktop platforms).
|
||||
///
|
||||
/// Returns a list of file paths if files are present, empty list otherwise.
|
||||
Future<List<String>> getClipboardFiles() async {
|
||||
try {
|
||||
final files = await Pasteboard.files();
|
||||
return files;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'ClipboardAttachmentService: Failed to read clipboard files: $e',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the clipboard currently contains image data.
|
||||
///
|
||||
/// Note: This reads the full image data from the clipboard because the
|
||||
/// pasteboard package doesn't provide a lightweight check method. On iOS,
|
||||
/// this also triggers the paste confirmation dialog.
|
||||
Future<bool> hasClipboardImage() async {
|
||||
try {
|
||||
// The pasteboard package doesn't have a dedicated hasImage method,
|
||||
// so we need to attempt to read the image. On iOS this is required
|
||||
// for the user to see the paste confirmation.
|
||||
final imageBytes = await Pasteboard.image;
|
||||
return imageBytes != null && imageBytes.isNotEmpty;
|
||||
} catch (e) {
|
||||
debugPrint('ClipboardAttachmentService: Failed to check clipboard: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a [LocalAttachment] from the current clipboard image.
|
||||
///
|
||||
/// Works on iOS, Android, and desktop platforms via the pasteboard package.
|
||||
/// Returns null if no image is in the clipboard or if the operation fails.
|
||||
Future<LocalAttachment?> createAttachmentFromClipboard() async {
|
||||
final imageData = await getClipboardImage();
|
||||
if (imageData == null || imageData.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pasteboard returns PNG data by default
|
||||
return createAttachmentFromImageData(
|
||||
imageData: imageData,
|
||||
mimeType: 'image/png',
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates [LocalAttachment]s from clipboard files.
|
||||
///
|
||||
/// Useful on desktop platforms where files can be copied directly.
|
||||
Future<List<LocalAttachment>> createAttachmentsFromClipboardFiles() async {
|
||||
final filePaths = await getClipboardFiles();
|
||||
final attachments = <LocalAttachment>[];
|
||||
|
||||
for (final filePath in filePaths) {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
final fileName = path.basename(file.path);
|
||||
attachments.add(LocalAttachment(file: file, displayName: fileName));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'ClipboardAttachmentService: Failed to process file $filePath: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/// Creates a [LocalAttachment] from pasted image data.
|
||||
///
|
||||
/// The image data is saved to a temporary file with an appropriate extension
|
||||
|
||||
@@ -76,10 +76,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
bool validateFileCount(int currentCount, int newCount, int maxCount) {
|
||||
return (currentCount + newCount) <= maxCount;
|
||||
}
|
||||
|
||||
bool validateFileSize(int fileSize, int maxSizeMB) {
|
||||
return fileSize <= (maxSizeMB * 1024 * 1024);
|
||||
}
|
||||
@@ -479,13 +475,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
final attachments = await fileService.pickFiles();
|
||||
if (attachments.isEmpty) return;
|
||||
|
||||
// Validate file count
|
||||
final currentFiles = ref.read(attachedFilesProvider);
|
||||
if (!validateFileCount(currentFiles.length, attachments.length, 10)) {
|
||||
if (!mounted) return;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file sizes
|
||||
for (final attachment in attachments) {
|
||||
final fileSize = await attachment.file.length();
|
||||
@@ -570,13 +559,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file count (default 10 files limit like OpenWebUI)
|
||||
final currentFiles = ref.read(attachedFilesProvider);
|
||||
if (!validateFileCount(currentFiles.length, 1, 10)) {
|
||||
if (!mounted) return;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add image to the attachment list
|
||||
ref.read(attachedFilesProvider.notifier).addFiles([attachment]);
|
||||
DebugLogger.log('Image added to attachment list', scope: 'chat/page');
|
||||
@@ -613,15 +595,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
scope: 'chat/page',
|
||||
);
|
||||
|
||||
// Validate file count (default 10 files limit like OpenWebUI)
|
||||
final currentFiles = ref.read(attachedFilesProvider);
|
||||
if (!validateFileCount(currentFiles.length, attachments.length, 10)) {
|
||||
if (!mounted) return;
|
||||
return;
|
||||
}
|
||||
// Add attachments to the list
|
||||
ref.read(attachedFilesProvider.notifier).addFiles(attachments);
|
||||
|
||||
// Validate file sizes and process each attachment
|
||||
final validAttachments = <LocalAttachment>[];
|
||||
// Enqueue uploads via task queue for unified retry/progress
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
for (final attachment in attachments) {
|
||||
try {
|
||||
final fileSize = await attachment.file.length();
|
||||
@@ -629,35 +607,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
'Pasted file: ${attachment.displayName}, size: $fileSize bytes',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
|
||||
// Validate file size (default 20MB limit like OpenWebUI)
|
||||
if (!validateFileSize(fileSize, 20)) {
|
||||
DebugLogger.log(
|
||||
'Skipping oversized pasted file: ${attachment.displayName}',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
validAttachments.add(attachment);
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Error processing pasted attachment: $e',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (validAttachments.isEmpty) return;
|
||||
|
||||
// Add attachments to the list
|
||||
ref.read(attachedFilesProvider.notifier).addFiles(validAttachments);
|
||||
|
||||
// Enqueue uploads via task queue for unified retry/progress
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
for (final attachment in validAttachments) {
|
||||
try {
|
||||
final fileSize = await attachment.file.length();
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
@@ -672,7 +621,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
}
|
||||
|
||||
DebugLogger.log(
|
||||
'Added ${validAttachments.length} pasted attachment(s)',
|
||||
'Added ${attachments.length} pasted attachment(s)',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
import 'dart:math' as math;
|
||||
import '../providers/chat_providers.dart';
|
||||
@@ -285,6 +286,90 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles pasting images/files from clipboard with pre-loaded image data.
|
||||
///
|
||||
/// This avoids a second clipboard read by using data already fetched when
|
||||
/// building the context menu.
|
||||
Future<void> _handleClipboardPasteWithData(Uint8List imageData) async {
|
||||
if (!widget.enabled) return;
|
||||
|
||||
final onPasted = widget.onPastedAttachments;
|
||||
if (onPasted == null) return;
|
||||
|
||||
PlatformUtils.lightHaptic();
|
||||
|
||||
final attachment = await _clipboardService.createAttachmentFromImageData(
|
||||
imageData: imageData,
|
||||
mimeType: 'image/png',
|
||||
);
|
||||
if (attachment != null) {
|
||||
await onPasted([attachment]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a custom context menu with standard options plus "Paste Image".
|
||||
///
|
||||
/// The standard paste only works for text. This adds a "Paste Image"
|
||||
/// option that uses the pasteboard package to read images from clipboard
|
||||
/// on both iOS and Android. The option only appears when there's actually
|
||||
/// an image in the clipboard.
|
||||
Widget _buildContextMenu(
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) {
|
||||
final List<ContextMenuButtonItem> buttonItems = List.from(
|
||||
editableTextState.contextMenuButtonItems,
|
||||
);
|
||||
|
||||
// Only add "Paste Image" if we have a callback for pasted attachments
|
||||
if (widget.onPastedAttachments == null) {
|
||||
return AdaptiveTextSelectionToolbar.buttonItems(
|
||||
anchors: editableTextState.contextMenuAnchors,
|
||||
buttonItems: buttonItems,
|
||||
);
|
||||
}
|
||||
|
||||
// Check clipboard for images - the data is captured in the closure to
|
||||
// avoid double-read and stale cache issues
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: _clipboardService.getClipboardImage(),
|
||||
builder: (context, snapshot) {
|
||||
final imageData = snapshot.data;
|
||||
final hasImage = imageData != null && imageData.isNotEmpty;
|
||||
|
||||
if (hasImage) {
|
||||
// Find the index of the standard Paste button to insert after it
|
||||
final pasteIndex = buttonItems.indexWhere(
|
||||
(item) => item.type == ContextMenuButtonType.paste,
|
||||
);
|
||||
|
||||
// Capture imageData in closure to avoid re-reading clipboard
|
||||
final pasteImageItem = ContextMenuButtonItem(
|
||||
label: AppLocalizations.of(context)?.pasteImage ?? 'Paste Image',
|
||||
onPressed: () {
|
||||
// Close the context menu first
|
||||
ContextMenuController.removeAny();
|
||||
// Use the captured imageData directly
|
||||
_handleClipboardPasteWithData(imageData);
|
||||
},
|
||||
);
|
||||
|
||||
// Insert after Paste if found, otherwise add at the end
|
||||
if (pasteIndex >= 0) {
|
||||
buttonItems.insert(pasteIndex + 1, pasteImageItem);
|
||||
} else {
|
||||
buttonItems.add(pasteImageItem);
|
||||
}
|
||||
}
|
||||
|
||||
return AdaptiveTextSelectionToolbar.buttonItems(
|
||||
anchors: editableTextState.contextMenuAnchors,
|
||||
buttonItems: buttonItems,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _insertNewline() {
|
||||
final text = _controller.text;
|
||||
TextSelection sel = _controller.selection;
|
||||
@@ -1545,6 +1630,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
.toList(),
|
||||
onContentInserted: _handleContentInserted,
|
||||
),
|
||||
// Custom context menu with "Paste Image" option for iOS
|
||||
contextMenuBuilder: (context, editableTextState) {
|
||||
return _buildContextMenu(context, editableTextState);
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
if (sendOnEnter) {
|
||||
_sendMessage();
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"file": "Datei",
|
||||
"photo": "Foto",
|
||||
"camera": "Kamera",
|
||||
"pasteFromClipboard": "Einfügen",
|
||||
"pasteImage": "Bild einfügen",
|
||||
"apiUnavailable": "API-Dienst nicht verfügbar",
|
||||
"unableToLoadImage": "Bild kann nicht geladen werden",
|
||||
"notAnImageFile": "Keine Bilddatei: {fileName}",
|
||||
|
||||
@@ -606,6 +606,14 @@
|
||||
"@camera": {
|
||||
"description": "Camera source label."
|
||||
},
|
||||
"pasteFromClipboard": "Paste",
|
||||
"@pasteFromClipboard": {
|
||||
"description": "Action label to paste images or files from the clipboard."
|
||||
},
|
||||
"pasteImage": "Paste Image",
|
||||
"@pasteImage": {
|
||||
"description": "Context menu action to paste an image from the clipboard."
|
||||
},
|
||||
"apiUnavailable": "API service not available",
|
||||
"@apiUnavailable": {
|
||||
"description": "Shown when backend API service is unavailable."
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"file": "Archivo",
|
||||
"photo": "Foto",
|
||||
"camera": "Cámara",
|
||||
"pasteFromClipboard": "Pegar",
|
||||
"pasteImage": "Pegar imagen",
|
||||
"apiUnavailable": "Servicio de API no disponible",
|
||||
"unableToLoadImage": "No se puede cargar la imagen",
|
||||
"notAnImageFile": "No es un archivo de imagen: {fileName}",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"file": "Fichier",
|
||||
"photo": "Photo",
|
||||
"camera": "Appareil photo",
|
||||
"pasteFromClipboard": "Coller",
|
||||
"pasteImage": "Coller l'image",
|
||||
"apiUnavailable": "Service API indisponible",
|
||||
"unableToLoadImage": "Impossible de charger l'image",
|
||||
"notAnImageFile": "Ce n'est pas un fichier image : {fileName}",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"file": "File",
|
||||
"photo": "Foto",
|
||||
"camera": "Fotocamera",
|
||||
"pasteFromClipboard": "Incolla",
|
||||
"pasteImage": "Incolla immagine",
|
||||
"apiUnavailable": "Servizio API non disponibile",
|
||||
"unableToLoadImage": "Impossibile caricare l'immagine",
|
||||
"notAnImageFile": "Non è un file immagine: {fileName}",
|
||||
|
||||
@@ -192,6 +192,8 @@
|
||||
"chooseDifferentFile": "다른 파일 선택",
|
||||
"photo": "사진",
|
||||
"camera": "카메라",
|
||||
"pasteFromClipboard": "붙여넣기",
|
||||
"pasteImage": "이미지 붙여넣기",
|
||||
"apiUnavailable": "API 서비스를 사용할 수 없습니다",
|
||||
"unableToLoadImage": "이미지를 불러올 수 없습니다",
|
||||
"notAnImageFile": "이미지 파일이 아닙니다: {fileName}",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"file": "Bestand",
|
||||
"photo": "Foto",
|
||||
"camera": "Camera",
|
||||
"pasteFromClipboard": "Plakken",
|
||||
"pasteImage": "Afbeelding plakken",
|
||||
"apiUnavailable": "API-service niet beschikbaar",
|
||||
"unableToLoadImage": "Kan afbeelding niet laden",
|
||||
"notAnImageFile": "Geen afbeeldingsbestand: {fileName}",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"file": "Файл",
|
||||
"photo": "Фото",
|
||||
"camera": "Камера",
|
||||
"pasteFromClipboard": "Вставить",
|
||||
"pasteImage": "Вставить изображение",
|
||||
"apiUnavailable": "Служба API недоступна",
|
||||
"unableToLoadImage": "Не удалось загрузить изображение",
|
||||
"notAnImageFile": "Не является файлом изображения: {fileName}",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"file": "文件",
|
||||
"photo": "照片",
|
||||
"camera": "相机",
|
||||
"pasteFromClipboard": "粘贴",
|
||||
"pasteImage": "粘贴图片",
|
||||
"apiUnavailable": "API 服务不可用",
|
||||
"unableToLoadImage": "无法加载图像",
|
||||
"notAnImageFile": "不是图像文件:{fileName}",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"file": "文件",
|
||||
"photo": "照片",
|
||||
"camera": "相機",
|
||||
"pasteFromClipboard": "貼上",
|
||||
"pasteImage": "貼上圖片",
|
||||
"apiUnavailable": "API 服務不可用",
|
||||
"unableToLoadImage": "無法加載圖像",
|
||||
"notAnImageFile": "不是圖像文件:{fileName}",
|
||||
|
||||
@@ -1061,6 +1061,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
pasteboard:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pasteboard
|
||||
sha256: "9ff73ada33f79a59ff91f6c01881fd4ed0a0031cfc4ae2d86c0384471525fca1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -79,6 +79,7 @@ dependencies:
|
||||
html_unescape: ^2.0.0
|
||||
home_widget: ^0.8.1
|
||||
flutter_highlight: ^0.7.0
|
||||
pasteboard: ^0.4.0
|
||||
|
||||
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user