feat(clipboard): Add pasteboard support for cross-platform image paste

This commit is contained in:
cogwheel0
2025-12-08 12:47:12 +05:30
parent fbeaebe0e8
commit 145a42b504
16 changed files with 230 additions and 56 deletions

View File

@@ -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

View File

@@ -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',
);
}

View File

@@ -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();