Browse Source

Support reading from and copying images to clipboard (#19930)

* support reading images from clipboard(win32/android)

* Add DataFormat.Image. Prioritize png format when retrieving image from clipboard

* add browser support

* addressed comments

* win32 - add support for CF_DIB and CF_BITMAP formats

* win32 - add support for copying bitmaps to clipboard as DIB

* browser - add support for copying bitmap to clipboard

* Implement bitmap clipboard for iOS

* rename DataFormat.Image to DataFormat.Bitmap

* Implement Bitmap clipboard on macOS

* Use MemoryStream for bitmap clipboard/dnd on macOS backend

* Add public.jpeg support on macOS backend (convert it to png while in objc)

* Support TIFF format on iOS backend, by converting it to UIImage first

* Add Bitmap DND sample

* add clipboard bitmap support on linux

* simply bitmap format search on win32 clipboard

* fix linux clipboard image

* Bump MACOSX_DEPLOYMENT_TARGET

* Fix IDL incompatibility

* address review

* more reviews

* Fix Android crash on copy when a bitmap is present in the clipboard

* Handle any error that might occur pasting from an external content provider

* Add data transfer extension methods for Bitmap

---------

Co-authored-by: Max Katz <maxkatz6@outlook.com>
Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/20078/head
Emmanuel Hansen 3 months ago
committed by GitHub
parent
commit
4be786a5f6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
  2. 77
      native/Avalonia.Native/src/OSX/clipboard.mm
  3. 6
      samples/ControlCatalog/Pages/ClipboardPage.xaml
  4. 31
      samples/ControlCatalog/Pages/ClipboardPage.xaml.cs
  5. 6
      samples/ControlCatalog/Pages/DragAndDropPage.xaml
  6. 26
      samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs
  7. 7
      src/Android/Avalonia.Android/Platform/AndroidDataFormatHelper.cs
  8. 84
      src/Android/Avalonia.Android/Platform/ClipDataItemToDataTransferItemWrapper.cs
  9. 14
      src/Android/Avalonia.Android/Platform/ClipDataToDataTransferWrapper.cs
  10. 6
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
  11. 16
      src/Avalonia.Base/Input/AsyncDataTransferExtensions.cs
  12. 14
      src/Avalonia.Base/Input/AsyncDataTransferItemExtensions.cs
  13. 7
      src/Avalonia.Base/Input/DataFormat.cs
  14. 16
      src/Avalonia.Base/Input/DataTransferExtensions.cs
  15. 11
      src/Avalonia.Base/Input/DataTransferItem.cs
  16. 16
      src/Avalonia.Base/Input/DataTransferItemExtensions.cs
  17. 28
      src/Avalonia.Base/Input/Platform/ClipboardExtensions.cs
  18. 5
      src/Avalonia.Native/ClipboardDataFormatHelper.cs
  19. 11
      src/Avalonia.Native/ClipboardDataTransferItem.cs
  20. 55
      src/Avalonia.Native/DataTransferItemToAvnClipboardDataItemWrapper.cs
  21. 2
      src/Avalonia.Native/avn.idl
  22. 33
      src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs
  23. 13
      src/Avalonia.X11/Clipboard/ClipboardDataReader.cs
  24. 35
      src/Avalonia.X11/Clipboard/X11Clipboard.cs
  25. 7
      src/Browser/Avalonia.Browser/BrowserClipboardDataTransferItem.cs
  26. 4
      src/Browser/Avalonia.Browser/BrowserDataFormatHelper.cs
  27. 26
      src/Browser/Avalonia.Browser/BrowserDataTransferHelper.cs
  28. 16
      src/Browser/Avalonia.Browser/ClipboardImpl.cs
  29. 39
      src/Windows/Avalonia.Win32/ClipboardFormatRegistry.cs
  30. 26
      src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs
  31. 234
      src/Windows/Avalonia.Win32/OleDataObjectHelper.cs
  32. 19
      src/Windows/Avalonia.Win32/OleDataObjectToDataTransferWrapper.cs
  33. 9
      src/iOS/Avalonia.iOS/Clipboard/ClipboardDataFormatHelper.cs
  34. 13
      src/iOS/Avalonia.iOS/Clipboard/ClipboardImpl.cs
  35. 45
      src/iOS/Avalonia.iOS/Clipboard/PasteboardItemToDataTransferItemWrapper.cs

2
native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj

@ -485,6 +485,7 @@
DYLIB_CURRENT_VERSION = 1;
EXECUTABLE_PREFIX = lib;
HEADER_SEARCH_PATHS = ../../inc;
MACOSX_DEPLOYMENT_TARGET = 10.13;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
@ -496,6 +497,7 @@
DYLIB_CURRENT_VERSION = 1;
EXECUTABLE_PREFIX = lib;
HEADER_SEARCH_PATHS = ../../inc;
MACOSX_DEPLOYMENT_TARGET = 10.13;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;

77
native/Avalonia.Native/src/OSX/clipboard.mm

@ -27,12 +27,11 @@ public:
if (changeCount != [_pasteboard changeCount])
return COR_E_OBJECTDISPOSED;
auto types = [_pasteboard types];
*ret = types == nil ? nullptr : CreateAvnStringArray(types);
*ret = ConvertPasteboardTypes([_pasteboard types]);
return S_OK;
}
virtual HRESULT GetItemCount(int64_t changeCount, int* ret) override
{
START_COM_ARP_CALL;
@ -57,13 +56,36 @@ public:
if (changeCount != [_pasteboard changeCount])
return COR_E_OBJECTDISPOSED;
auto item = [[_pasteboard pasteboardItems] objectAtIndex:index];
auto types = [item types];
*ret = types == nil ? nullptr : CreateAvnStringArray(types);
*ret = ConvertPasteboardTypes([item types]);
return S_OK;
}
static IAvnStringArray* ConvertPasteboardTypes(NSArray<NSPasteboardType> *types)
{
if (types != nil)
{
NSMutableArray<NSString *> *mutableTypes = [types mutableCopy];
// Add png if format list doesn't have PNG,
// but has any other image type that can be converter into PNG
if (![mutableTypes containsObject:NSPasteboardTypePNG])
{
if ([mutableTypes containsObject:NSPasteboardTypeTIFF]
|| [mutableTypes containsObject:@"public.jpeg"])
{
[mutableTypes addObject: NSPasteboardTypePNG];
}
}
return CreateAvnStringArray(mutableTypes);
}
return nil;
}
virtual HRESULT GetItemValueAsString(int index, int64_t changeCount, const char* format, IAvnString** ret) override
{
START_COM_ARP_CALL;
@ -91,8 +113,45 @@ public:
return COR_E_OBJECTDISPOSED;
auto item = [[_pasteboard pasteboardItems] objectAtIndex:index];
auto value = [item dataForType:[NSString stringWithUTF8String:format]];
auto formatStr = [NSString stringWithUTF8String:format];
auto value = [item dataForType: formatStr];
// If PNG wasn't found, try to convert TIFF or JPEG to PNG
if (value == nil && [formatStr isEqualToString: NSPasteboardTypePNG])
{
NSData *imageData = nil;
// Try TIFF first
imageData = [item dataForType:NSPasteboardTypeTIFF];
// If no TIFF, try JPEG
if (imageData == nil) {
imageData = [item dataForType:@"public.jpeg"];
}
if (imageData != nil)
{
auto image = [[NSImage alloc] initWithData:imageData];
NSBitmapImageRep *bitmapRep = nil;
for (NSImageRep *rep in image.representations) {
if ([rep isKindOfClass:[NSBitmapImageRep class]]) {
bitmapRep = (NSBitmapImageRep *)rep;
break;
}
}
if (!bitmapRep) {
[image lockFocus];
bitmapRep = [[NSBitmapImageRep alloc] initWithFocusedViewRect:NSMakeRect(0, 0, image.size.width, image.size.height)];
[image unlockFocus];
}
value = [bitmapRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
}
}
*ret = value == nil || [value length] == 0
? nullptr
: CreateByteArray((void*)[value bytes], (int)[value length]);

6
samples/ControlCatalog/Pages/ClipboardPage.xaml

@ -6,6 +6,8 @@
<Button Click="CopyText" Content="Copy text to clipboard" />
<Button Click="PasteText" Content="Paste text from clipboard" />
<Button Click="CopyImage" Content="Copy image to clipboard" />
<Button Click="PasteImage" Content="Paste image from clipboard" />
<Button Click="CopyFiles" Content="Copy files to clipboard" />
<Button Click="PasteFiles" Content="Paste files from clipboard" />
<Button Click="CopyBinaryData" Content="Copy bytes to clipboard" />
@ -20,5 +22,9 @@
MinHeight="100"
AcceptsReturn="True"
Watermark="Text to copy of file names per line" />
<Viewbox Width="420" Height="360">
<Image x:Name="ClipboardImage"
/>
</Viewbox>
</StackPanel>
</UserControl>

31
samples/ControlCatalog/Pages/ClipboardPage.xaml.cs

@ -8,6 +8,8 @@ using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
@ -24,11 +26,19 @@ namespace ControlCatalog.Pages
private readonly DispatcherTimer _clipboardLastDataObjectChecker;
private DataTransfer? _storedDataTransfer;
private bool _checkingClipboardDataTransfer;
private Bitmap _defaultImage;
public ClipboardPage()
{
InitializeComponent();
_clipboardLastDataObjectChecker =
new DispatcherTimer(TimeSpan.FromSeconds(0.5), default, CheckLastDataObject);
using var asset = AssetLoader.Open(new Uri("avares://ControlCatalog/Assets/image1.jpg"));
_defaultImage = new Bitmap(asset);
ClipboardImage.Source = _defaultImage;
}
private async void CopyText(object? sender, RoutedEventArgs args)
@ -37,6 +47,12 @@ namespace ControlCatalog.Pages
await clipboard.SetTextAsync(ClipboardContent.Text ?? string.Empty);
}
private async void CopyImage(object? sender, RoutedEventArgs args)
{
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
await clipboard.SetValueAsync(DataFormat.Bitmap, _defaultImage);
}
private async void PasteText(object? sender, RoutedEventArgs args)
{
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
@ -45,6 +61,20 @@ namespace ControlCatalog.Pages
}
}
private async void PasteImage(object? sender, RoutedEventArgs args)
{
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
{
using var data = await clipboard.TryGetDataAsync();
Bitmap? source = null;
if (data != null)
{
source = await data!.TryGetValueAsync(DataFormat.Bitmap);
}
ClipboardImage.Source = source;
}
}
private async void CopyFiles(object? sender, RoutedEventArgs args)
{
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
@ -154,7 +184,6 @@ namespace ControlCatalog.Pages
base.OnDetachedFromVisualTree(e);
}
private bool _checkingClipboardDataTransfer;
private async void CheckLastDataObject(object? sender, EventArgs e)
{
if (_checkingClipboardDataTransfer)

6
samples/ControlCatalog/Pages/DragAndDropPage.xaml

@ -25,6 +25,10 @@
Classes="draggable">
<TextBlock Name="DragStateFiles" TextWrapping="Wrap">Drag Me (files)</TextBlock>
</Border>
<Border Name="DragMeBitmap"
Classes="draggable">
<TextBlock Name="DragStateBitmap" TextWrapping="Wrap">Drag Me (bitmap)</TextBlock>
</Border>
<Border Name="DragMeCustom"
Classes="draggable">
<TextBlock Name="DragStateCustom" TextWrapping="Wrap">Drag Me (custom)</TextBlock>
@ -51,6 +55,6 @@
</StackPanel>
</WrapPanel>
<TextBlock x:Name="DropState" TextWrapping="Wrap" />
<ContentControl x:Name="DropState" TextBlock.TextWrapping="Wrap" />
</StackPanel>
</UserControl>

26
samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs

@ -4,6 +4,11 @@ using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
namespace ControlCatalog.Pages
@ -41,6 +46,11 @@ namespace ControlCatalog.Pages
}
},
DragDropEffects.Copy);
SetupDnd(
"Bitmap",
d => d.Add(DataTransferItem.Create(DataFormat.Bitmap, new Bitmap(AssetLoader.Open(new Uri("avares://ControlCatalog/Assets/image1.jpg"))))),
DragDropEffects.Copy);
}
private void SetupDnd(string suffix, Action<DataTransfer> factory, DragDropEffects effects) =>
@ -98,6 +108,7 @@ namespace ControlCatalog.Pages
// Only allow if the dragged data contains text or filenames.
if (!e.DataTransfer.Contains(DataFormat.Text)
&& !e.DataTransfer.Contains(DataFormat.File)
&& !e.DataTransfer.Contains(DataFormat.Bitmap)
&& !e.DataTransfer.Contains(_customFormat))
e.DragEffects = DragDropEffects.None;
}
@ -115,7 +126,7 @@ namespace ControlCatalog.Pages
if (e.DataTransfer.Contains(DataFormat.Text))
{
DropState.Text = e.DataTransfer.TryGetText();
DropState.Content = e.DataTransfer.TryGetText();
}
else if (e.DataTransfer.Contains(DataFormat.File))
{
@ -139,12 +150,19 @@ namespace ControlCatalog.Pages
contentStr += $"Folder {item.Name}: items {childrenCount}{Environment.NewLine}{Environment.NewLine}";
}
}
DropState.Text = contentStr;
DropState.Content = contentStr;
}
else if (e.DataTransfer.Contains(DataFormat.Bitmap))
{
var bitmap = e.DataTransfer.TryGetValue(DataFormat.Bitmap);
DropState.Content = new Image
{
Source = bitmap, Width = 400, Height = 300, Stretch = Stretch.Uniform
};
}
else if (e.DataTransfer.Contains(_customFormat))
{
DropState.Text = "Custom: " + e.DataTransfer.TryGetValue(_customFormat);
DropState.Content = "Custom: " + e.DataTransfer.TryGetValue(_customFormat);
}
}

7
src/Android/Avalonia.Android/Platform/AndroidDataFormatHelper.cs

@ -7,6 +7,7 @@ namespace Avalonia.Android.Platform;
internal static class AndroidDataFormatHelper
{
private const string AppPrefix = "application/avn-fmt.";
private const string MimeTypeImagePng = "image/png";
public static DataFormat MimeTypeToDataFormat(string mimeType)
{
@ -16,6 +17,9 @@ internal static class AndroidDataFormatHelper
if (mimeType == ClipDescription.MimetypeTextUrilist)
return DataFormat.File;
if (mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
return DataFormat.Bitmap;
if (mimeType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
return DataFormat.FromSystemName<string>(mimeType, AppPrefix);
@ -30,6 +34,9 @@ internal static class AndroidDataFormatHelper
if (DataFormat.File.Equals(format))
return ClipDescription.MimetypeTextUrilist;
if (DataFormat.Bitmap.Equals(format))
return MimeTypeImagePng;
return format.ToSystemName(AppPrefix);
}

84
src/Android/Avalonia.Android/Platform/ClipDataItemToDataTransferItemWrapper.cs

@ -1,9 +1,13 @@
using System;
using System.IO;
using Android.App;
using Android.Content;
using Avalonia.Android.Platform.Storage;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Logging;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
namespace Avalonia.Android.Platform;
@ -15,44 +19,90 @@ namespace Avalonia.Android.Platform;
internal sealed class ClipDataItemToDataTransferItemWrapper(ClipData.Item item, ClipDataToDataTransferWrapper owner)
: PlatformDataTransferItem
{
private readonly ClipData.Item _item = item;
private readonly ClipDataToDataTransferWrapper _owner = owner;
protected override DataFormat[] ProvideFormats()
=> _owner.Formats; // There's no "format per item", assume each item handle all formats
=> owner.Formats; // There's no "format per item", assume each item handle all formats
protected override object? TryGetRawCore(DataFormat format)
{
if (DataFormat.Text.Equals(format))
return _item.CoerceToText(_owner.Context);
return item.CoerceToText(owner.Context);
if (format is DataFormat<string>)
return TryGetString();
if (DataFormat.File.Equals(format))
{
return _item.Uri is { Scheme: "file" or "content" } fileUri && _owner.Context is Activity activity ?
AndroidStorageItem.CreateItem(activity, fileUri) :
null;
}
return TryGetStorageItem();
if (format is DataFormat<string>)
return TryGetAsString();
if (DataFormat.Bitmap.Equals(format))
return TryGetBitmap();
if (format is DataFormat<byte[]>)
return TryGetBytes();
return null;
}
private string? TryGetAsString()
private string? TryGetString()
{
if (_item.Text is { } text)
if (item.Text is { } text)
return text;
if (_item.HtmlText is { } htmlText)
if (item.HtmlText is { } htmlText)
return htmlText;
if (_item.Uri is { } uri)
if (item.Uri is { } uri)
return uri.ToString();
if (_item.Intent is { } intent)
if (item.Intent is { } intent)
return intent.ToUri(IntentUriType.Scheme);
return null;
}
private IStorageItem? TryGetStorageItem()
=> item.Uri is { Scheme: "file" or "content" } fileUri && owner.Context is Activity activity ?
AndroidStorageItem.CreateItem(activity, fileUri) :
null;
private object? TryGetBitmap()
{
try
{
if (TryGetStorageItem() is AndroidStorageFile storageFile)
{
using var stream = storageFile.OpenRead();
return new Bitmap(stream);
}
}
catch (Exception ex)
{
Logger.TryGet(LogEventLevel.Warning, LogArea.AndroidPlatform)
?.Log(this, "Could not get bitmap from clipboard: {Error}", ex.Message);
}
return null;
}
private object? TryGetBytes()
{
try
{
if (TryGetStorageItem() is AndroidStorageFile storageFile)
{
using var stream = storageFile.OpenRead();
using var mem = new MemoryStream();
stream.CopyTo(mem);
return mem.ToArray();
}
}
catch (Exception ex)
{
Logger.TryGet(LogEventLevel.Warning, LogArea.AndroidPlatform)
?.Log(this, "Could not get bytes from clipboard: {Error}", ex.Message);
}
return null;
}
}

14
src/Android/Avalonia.Android/Platform/ClipDataToDataTransferWrapper.cs

@ -1,4 +1,5 @@
using Android.Content;
using System.Linq;
using Android.Content;
using Avalonia.Input;
using Avalonia.Input.Platform;
@ -23,9 +24,20 @@ internal sealed class ClipDataToDataTransferWrapper(ClipData clipData, Context?
var formats = new DataFormat[count];
bool hasImage = false;
for (var i = 0; i < count; ++i)
{
formats[i] = AndroidDataFormatHelper.MimeTypeToDataFormat(clipDescription.GetMimeType(i)!);
if (!hasImage)
hasImage = formats[i].Identifier.StartsWith("image/", System.StringComparison.OrdinalIgnoreCase);
}
if (hasImage)
{
formats = [.. formats, DataFormat.Bitmap];
}
return formats;
}

6
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs

@ -389,8 +389,10 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
{
}
public Task<Stream> OpenReadAsync() => Task.FromResult(OpenContentStream(Activity, Uri, false)
?? throw new InvalidOperationException("Failed to open content stream"));
public Task<Stream> OpenReadAsync() => Task.FromResult(OpenRead());
public Stream OpenRead() => OpenContentStream(Activity, Uri, false)
?? throw new InvalidOperationException("Failed to open content stream");
public Task<Stream> OpenWriteAsync() => Task.FromResult(OpenContentStream(Activity, Uri, true)
?? throw new InvalidOperationException("Failed to open content stream"));

16
src/Avalonia.Base/Input/AsyncDataTransferExtensions.cs

@ -2,6 +2,7 @@
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Logging;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
namespace Avalonia.Input;
@ -113,7 +114,7 @@ public static class AsyncDataTransferExtensions
/// <summary>
/// Returns a text, if available, from a <see cref="IAsyncDataTransfer"/> instance.
/// </summary>
/// <param name="dataTransfer">The data transfer instance.</param>
/// <param name="dataTransfer">The <see cref="IAsyncDataTransfer"/> instance.</param>
/// <returns>A string, or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.Text"/>.
public static Task<string?> TryGetTextAsync(this IAsyncDataTransfer dataTransfer)
@ -122,7 +123,7 @@ public static class AsyncDataTransferExtensions
/// <summary>
/// Returns a file, if available, from a <see cref="IAsyncDataTransfer"/> instance.
/// </summary>
/// <param name="dataTransfer">The data transfer instance.</param>
/// <param name="dataTransfer">The <see cref="IAsyncDataTransfer"/> instance.</param>
/// <returns>An <see cref="IStorageItem"/> (file or folder), or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.File"/>.
public static Task<IStorageItem?> TryGetFileAsync(this IAsyncDataTransfer dataTransfer)
@ -131,9 +132,18 @@ public static class AsyncDataTransferExtensions
/// <summary>
/// Returns a list of files, if available, from a <see cref="IAsyncDataTransfer"/> instance.
/// </summary>
/// <param name="dataTransfer">The data transfer instance.</param>
/// <param name="dataTransfer">The <see cref="IAsyncDataTransfer"/> instance.</param>
/// <returns>An array of <see cref="IStorageItem"/> (files or folders), or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.File"/>.
public static Task<IStorageItem[]?> TryGetFilesAsync(this IAsyncDataTransfer dataTransfer)
=> dataTransfer.TryGetValuesAsync(DataFormat.File);
/// <summary>
/// Returns a bitmap, if available, from a <see cref="IAsyncDataTransfer"/> instance.
/// </summary>
/// <param name="dataTransfer">The <see cref="IAsyncDataTransfer"/> instance.</param>
/// <returns>A <see cref="Bitmap"/>, or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.Bitmap"/>.
public static Task<Bitmap?> TryGetBitmapAsync(this IAsyncDataTransfer dataTransfer)
=> dataTransfer.TryGetValueAsync(DataFormat.Bitmap);
}

14
src/Avalonia.Base/Input/AsyncDataTransferItemExtensions.cs

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
namespace Avalonia.Input;
@ -41,7 +42,7 @@ public static class AsyncDataTransferItemExtensions
/// <summary>
/// Returns a text, if available, from a <see cref="IAsyncDataTransferItem"/> instance.
/// </summary>
/// <param name="dataTransferItem">The data transfer instance.</param>
/// <param name="dataTransferItem">The <see cref="IAsyncDataTransferItem"/> instance.</param>
/// <returns>A string, or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.Text"/>.
public static Task<string?> TryGetTextAsync(this IAsyncDataTransferItem dataTransferItem)
@ -50,9 +51,18 @@ public static class AsyncDataTransferItemExtensions
/// <summary>
/// Returns a file, if available, from a <see cref="IAsyncDataTransferItem"/> instance.
/// </summary>
/// <param name="dataTransferItem">The data transfer instance.</param>
/// <param name="dataTransferItem">The <see cref="IAsyncDataTransferItem"/> instance.</param>
/// <returns>An <see cref="IStorageItem"/> (file or folder), or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.File"/>.
public static Task<IStorageItem?> TryGetFileAsync(this IAsyncDataTransferItem dataTransferItem)
=> dataTransferItem.TryGetValueAsync(DataFormat.File);
/// <summary>
/// Returns a bitmap, if available, from a <see cref="IAsyncDataTransferItem"/> instance.
/// </summary>
/// <param name="dataTransferItem">The <see cref="IAsyncDataTransferItem"/> instance.</param>
/// <returns>A <see cref="Bitmap"/>, or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.Bitmap"/>.
public static Task<Bitmap?> TryGetBitmapAsync(this IAsyncDataTransferItem dataTransferItem)
=> dataTransferItem.TryGetValueAsync(DataFormat.Bitmap);
}

7
src/Avalonia.Base/Input/DataFormat.cs

@ -1,4 +1,5 @@
using System;
using Avalonia.Media.Imaging;
using Avalonia.Metadata;
using Avalonia.Platform.Storage;
using Avalonia.Utilities;
@ -32,6 +33,12 @@ public abstract class DataFormat : IEquatable<DataFormat>
/// </summary>
public static DataFormat<string> Text { get; } = CreateUniversalFormat<string>("Text");
/// <summary>
/// Gets a data format representing a bitmap.
/// Its data type is <see cref="Media.Imaging.Bitmap"/>.
/// </summary>
public static DataFormat<Bitmap> Bitmap { get; } = CreateUniversalFormat<Bitmap>("Bitmap");
/// <summary>
/// Gets a data format representing a single file.
/// Its data type is <see cref="IStorageItem"/>.

16
src/Avalonia.Base/Input/DataTransferExtensions.cs

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using Avalonia.Input.Platform;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
namespace Avalonia.Input;
@ -104,7 +105,7 @@ public static class DataTransferExtensions
/// <summary>
/// Returns a text, if available, from a <see cref="IDataTransfer"/> instance.
/// </summary>
/// <param name="dataTransfer">The data transfer instance.</param>
/// <param name="dataTransfer">The <see cref="IDataTransfer"/> instance.</param>
/// <returns>A string, or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.Text"/>.
public static string? TryGetText(this IDataTransfer dataTransfer)
@ -113,7 +114,7 @@ public static class DataTransferExtensions
/// <summary>
/// Returns a file, if available, from a <see cref="IDataTransfer"/> instance.
/// </summary>
/// <param name="dataTransfer">The data transfer instance.</param>
/// <param name="dataTransfer">The <see cref="IDataTransfer"/> instance.</param>
/// <returns>An <see cref="IStorageItem"/> (file or folder), or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.File"/>.
public static IStorageItem? TryGetFile(this IDataTransfer dataTransfer)
@ -122,9 +123,18 @@ public static class DataTransferExtensions
/// <summary>
/// Returns a list of files, if available, from a <see cref="IDataTransfer"/> instance.
/// </summary>
/// <param name="dataTransfer">The data transfer instance.</param>
/// <param name="dataTransfer">The <see cref="IDataTransfer"/> instance.</param>
/// <returns>An array of <see cref="IStorageItem"/> (files or folders), or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.File"/>.
public static IStorageItem[]? TryGetFiles(this IDataTransfer dataTransfer)
=> dataTransfer.TryGetValues(DataFormat.File);
/// <summary>
/// Returns a bitmap, if available, from a <see cref="IDataTransfer"/> instance.
/// </summary>
/// <param name="dataTransfer">The <see cref="IDataTransfer"/> instance.</param>
/// <returns>A <see cref="Bitmap"/>, or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.Bitmap"/>.
public static Bitmap? TryGetBitmap(this IDataTransfer dataTransfer)
=> dataTransfer.TryGetValue(DataFormat.Bitmap);
}

11
src/Avalonia.Base/Input/DataTransferItem.cs

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Utilities;
@ -158,6 +159,16 @@ public sealed class DataTransferItem : IDataTransferItem, IAsyncDataTransferItem
public void SetFile(IStorageItem? value)
=> Set(DataFormat.File, value);
/// <summary>
/// Sets the value for the <see cref="DataFormat.Bitmap"/> format.
/// </summary>
/// <param name="value">
/// The value corresponding to the <see cref="DataFormat.Bitmap"/> format.
/// If null, the format won't be part of the <see cref="DataTransferItem"/>.
/// </param>
public void SetBitmap(Bitmap? value)
=> Set(DataFormat.Bitmap, value);
/// <summary>
/// Creates a new <see cref="DataTransferItem"/> for a single format with a given value.
/// </summary>

16
src/Avalonia.Base/Input/DataTransferItemExtensions.cs

@ -1,4 +1,5 @@
using Avalonia.Platform.Storage;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
namespace Avalonia.Input;
@ -40,7 +41,7 @@ public static class DataTransferItemExtensions
/// <summary>
/// Returns a text, if available, from a <see cref="IDataTransferItem"/> instance.
/// </summary>
/// <param name="dataTransferItem">The data transfer instance.</param>
/// <param name="dataTransferItem">The <see cref="IDataTransferItem"/> instance.</param>
/// <returns>A string, or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.Text"/>.
public static string? TryGetText(this IDataTransferItem dataTransferItem)
@ -49,9 +50,18 @@ public static class DataTransferItemExtensions
/// <summary>
/// Returns a file, if available, from a <see cref="IDataTransferItem"/> instance.
/// </summary>
/// <param name="dataTransferItem">The data transfer instance.</param>
/// <param name="dataTransferItem">The <see cref="IDataTransferItem"/> instance.</param>
/// <returns>An <see cref="IStorageItem"/> (file or folder), or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.File"/>.
public static IStorageItem? TryGetFile(this IDataTransferItem dataTransferItem)
=> dataTransferItem.TryGetValue(DataFormat.File);
/// <summary>
/// Returns a bitmap, if available, from a <see cref="IDataTransferItem"/> instance.
/// </summary>
/// <param name="dataTransferItem">The <see cref="IDataTransferItem"/> instance.</param>
/// <returns>A <see cref="Bitmap"/>, or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.Bitmap"/>.
public static Bitmap? TryGetBitmap(this IDataTransferItem dataTransferItem)
=> dataTransferItem.TryGetValue(DataFormat.Bitmap);
}

28
src/Avalonia.Base/Input/Platform/ClipboardExtensions.cs

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
namespace Avalonia.Input.Platform;
@ -133,12 +134,21 @@ public static class ClipboardExtensions
/// <summary>
/// Returns a list of files, if available, from the clipboard.
/// </summary>
/// <param name="clipboard">The data transfer instance.</param>
/// <param name="clipboard">The clipboard instance.</param>
/// <returns>An array of <see cref="IStorageItem"/> (files or folders), or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.File"/>.
public static Task<IStorageItem[]?> TryGetFilesAsync(this IClipboard clipboard)
=> clipboard.TryGetValuesAsync(DataFormat.File);
/// <summary>
/// Returns a bitmap, if available, from the clipboard.
/// </summary>
/// <param name="clipboard">The clipboard instance.</param>
/// <returns>A <see cref="Bitmap"/>, or null if the format isn't available.</returns>
/// <seealso cref="DataFormat.Bitmap"/>.
public static Task<Bitmap?> TryGetBitmapAsync(this IClipboard clipboard)
=> clipboard.TryGetValueAsync(DataFormat.Bitmap);
/// <summary>
/// Places a text on the clipboard.
/// </summary>
@ -186,4 +196,20 @@ public static class ClipboardExtensions
/// <seealso cref="DataFormat.File"/>
public static Task SetFilesAsync(this IClipboard clipboard, IEnumerable<IStorageItem>? files)
=> clipboard.SetValuesAsync(DataFormat.File, files);
/// <summary>
/// Places a bitmap on the clipboard.
/// </summary>
/// <param name="clipboard">The clipboard instance.</param>
/// <param name="bitmap">The bitmap to place on the clipboard.</param>
/// <remarks>
/// <para>By calling this method, the clipboard will get cleared of any possible previous data.</para>
/// <para>
/// If <paramref name="bitmap"/> is null, nothing will get placed on the clipboard and this method
/// will be equivalent to <see cref="IClipboard.ClearAsync"/>.
/// </para>
/// </remarks>
/// <seealso cref="DataFormat.Bitmap"/>
public static Task SetBitmapAsync(this IClipboard clipboard, Bitmap? bitmap)
=> clipboard.SetValueAsync(DataFormat.Bitmap, bitmap);
}

5
src/Avalonia.Native/ClipboardDataFormatHelper.cs

@ -11,6 +11,7 @@ internal static class ClipboardDataFormatHelper
// TODO hide native types behind IAvnClipboard abstraction, so managed side won't depend on macOS.
private const string NSPasteboardTypeString = "public.utf8-plain-text";
private const string NSPasteboardTypeFileUrl = "public.file-url";
private const string NSPasteboardTypePng = "public.png";
private const string AppPrefix = "net.avaloniaui.app.uti.";
public static DataFormat[] ToDataFormats(IAvnStringArray? nativeFormats, Func<string, bool> isTextFormat)
@ -38,6 +39,7 @@ internal static class ClipboardDataFormatHelper
{
NSPasteboardTypeString => DataFormat.Text,
NSPasteboardTypeFileUrl => DataFormat.File,
NSPasteboardTypePng => DataFormat.Bitmap,
_ when isTextFormat(nativeFormat) => DataFormat.FromSystemName<string>(nativeFormat, AppPrefix),
_ => DataFormat.FromSystemName<byte[]>(nativeFormat, AppPrefix)
};
@ -50,6 +52,9 @@ internal static class ClipboardDataFormatHelper
if (DataFormat.File.Equals(format))
return NSPasteboardTypeFileUrl;
if (DataFormat.Bitmap.Equals(format))
return NSPasteboardTypePng;
return format.ToSystemName(AppPrefix);
}
}

11
src/Avalonia.Native/ClipboardDataTransferItem.cs

@ -1,10 +1,12 @@
#nullable enable
using System;
using System.IO;
using System.Text;
using Avalonia.Controls.Platform;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
namespace Avalonia.Native;
@ -36,6 +38,9 @@ internal sealed class ClipboardDataTransferItem(ClipboardReadSession session, in
if (DataFormat.File.Equals(format))
return TryGetFile(nativeFormat);
if (DataFormat.Bitmap.Equals(format))
return TryGetBitmap(nativeFormat);
if (format is DataFormat<string>)
{
if (TryGetString(nativeFormat) is { } stringValue)
@ -57,6 +62,12 @@ internal sealed class ClipboardDataTransferItem(ClipboardReadSession session, in
return null;
}
private Bitmap? TryGetBitmap(string nativeFormat)
{
using var bytes = _session.GetItemValueAsBytes(_itemIndex, nativeFormat);
return bytes is null ? null : new Bitmap(new MemoryStream(bytes.Bytes, false));
}
private string? TryGetString(string nativeFormat)
{
using var text = _session.GetItemValueAsString(_itemIndex, nativeFormat);

55
src/Avalonia.Native/DataTransferItemToAvnClipboardDataItemWrapper.cs

@ -6,7 +6,7 @@ using System.Linq;
using Avalonia.Input;
using Avalonia.Logging;
using Avalonia.Native.Interop;
using Avalonia.Platform.Storage;
using Avalonia.Utilities;
namespace Avalonia.Native;
@ -33,6 +33,19 @@ internal sealed class DataTransferItemToAvnClipboardDataItemWrapper(IDataTransfe
if (DataFormat.File.Equals(dataFormat))
return _item.TryGetValue(DataFormat.File) is { } file ? new StringValue(file.Path.AbsoluteUri) : null;
if (DataFormat.Bitmap.Equals(dataFormat))
{
if (_item.TryGetValue(DataFormat.Bitmap) is { } bitmap)
{
var memoryStream = new MemoryStream();
bitmap.Save(memoryStream);
memoryStream.Seek(0, SeekOrigin.Begin);
return new StreamValue(memoryStream);
}
return null;
}
if (dataFormat is DataFormat<string> stringFormat)
return _item.TryGetValue(stringFormat) is { } stringValue ? new StringValue(stringValue) : null;
@ -70,7 +83,7 @@ internal sealed class DataTransferItemToAvnClipboardDataItemWrapper(IDataTransfe
IAvnString IAvnClipboardDataValue.AsString()
=> new AvnString(_value);
int IAvnClipboardDataValue.ByteLength
IntPtr IAvnClipboardDataValue.ByteLength
=> throw new InvalidOperationException();
unsafe void IAvnClipboardDataValue.CopyBytesTo(void* buffer)
@ -87,10 +100,44 @@ internal sealed class DataTransferItemToAvnClipboardDataItemWrapper(IDataTransfe
IAvnString IAvnClipboardDataValue.AsString()
=> throw new InvalidOperationException();
int IAvnClipboardDataValue.ByteLength
=> _value.Length;
IntPtr IAvnClipboardDataValue.ByteLength
=> new(_value.Length);
unsafe void IAvnClipboardDataValue.CopyBytesTo(void* buffer)
=> _value.Span.CopyTo(new Span<byte>(buffer, _value.Length));
}
private sealed class StreamValue(MemoryStream value) : NativeOwned, IAvnClipboardDataValue
{
private readonly MemoryStream _value = value;
private readonly byte[] _buffer = new byte[1024 * 1024];
int IAvnClipboardDataValue.IsString()
=> false.AsComBool();
IAvnString IAvnClipboardDataValue.AsString()
=> throw new InvalidOperationException();
IntPtr IAvnClipboardDataValue.ByteLength
#pragma warning disable CA2020 // overflow in unchecked context
=> (IntPtr)_value.Length;
#pragma warning restore CA2020
unsafe void IAvnClipboardDataValue.CopyBytesTo(void* output)
{
long totalCopied = 0;
while (true)
{
var read = _value.Read(_buffer, 0, _buffer.Length);
if (read == 0)
break;
var destinationSpan = new Span<byte>((byte*)output + totalCopied, read);
_buffer.AsSpan(0, read).CopyTo(destinationSpan);
totalCopied += read;
}
}
}
}

2
src/Avalonia.Native/avn.idl

@ -1007,7 +1007,7 @@ interface IAvnClipboardDataValue : IUnknown
{
bool IsString();
IAvnString* AsString();
int GetByteLength();
long GetByteLength();
void CopyBytesTo(void* buffer);
}

33
src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs

@ -1,5 +1,6 @@
using System;
using System.Text;
using Avalonia.Controls;
using Avalonia.Input;
namespace Avalonia.X11.Clipboard;
@ -8,6 +9,8 @@ internal static class ClipboardDataFormatHelper
{
private const string MimeTypeTextUriList = "text/uri-list";
private const string AppPrefix = "application/avn-fmt.";
public const string PngFormatMimeType = "image/png";
public const string JpegFormatMimeType = "image/jpeg";
public static DataFormat? ToDataFormat(IntPtr formatAtom, X11Atoms atoms)
{
@ -32,14 +35,13 @@ internal static class ClipboardDataFormatHelper
if (atoms.GetAtomName(formatAtom) is { } atomName)
{
return atomName == MimeTypeTextUriList ?
DataFormat.File :
DataFormat.FromSystemName<byte[]>(atomName, AppPrefix);
DataFormat.File : DataFormat.FromSystemName<byte[]>(atomName, AppPrefix);
}
return null;
}
public static IntPtr ToAtom(DataFormat format, IntPtr[] textFormatAtoms, X11Atoms atoms)
public static IntPtr ToAtom(DataFormat format, IntPtr[] textFormatAtoms, X11Atoms atoms, DataFormat[] dataFormats)
{
if (DataFormat.Text.Equals(format))
return GetPreferredStringFormatAtom(textFormatAtoms, atoms);
@ -47,6 +49,28 @@ internal static class ClipboardDataFormatHelper
if (DataFormat.File.Equals(format))
return atoms.GetAtom(MimeTypeTextUriList);
if (DataFormat.Bitmap.Equals(format))
{
DataFormat? pngFormat = null, jpegFormat = null;
foreach (var imageFormat in dataFormats)
{
if (imageFormat.Identifier is PngFormatMimeType)
pngFormat = imageFormat;
else if (imageFormat.Identifier is JpegFormatMimeType)
jpegFormat = imageFormat;
if (pngFormat != null && jpegFormat != null)
break;
}
var preferredFormat = pngFormat ?? jpegFormat ?? null;
if (preferredFormat != null)
return atoms.GetAtom(preferredFormat.ToSystemName(AppPrefix));
else
return IntPtr.Zero;
}
var systemName = format.ToSystemName(AppPrefix);
return atoms.GetAtom(systemName);
}
@ -59,6 +83,9 @@ internal static class ClipboardDataFormatHelper
if (DataFormat.File.Equals(format))
return [atoms.GetAtom(MimeTypeTextUriList)];
if (DataFormat.Bitmap.Equals(format))
return [atoms.GetAtom(PngFormatMimeType)];
var systemName = format.ToSystemName(AppPrefix);
return [atoms.GetAtom(systemName)];
}

13
src/Avalonia.X11/Clipboard/ClipboardDataReader.cs

@ -3,6 +3,7 @@ using System.IO;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Media.Imaging;
using static Avalonia.X11.XLib;
namespace Avalonia.X11.Clipboard;
@ -14,7 +15,8 @@ internal sealed class ClipboardDataReader(
X11Info x11,
AvaloniaX11Platform platform,
IntPtr[] textFormatAtoms,
IntPtr owner)
IntPtr owner,
DataFormat[] dataFormats)
: IDisposable
{
private readonly X11Info _x11 = x11;
@ -30,7 +32,7 @@ internal sealed class ClipboardDataReader(
if (!IsOwnerStillValid())
return null;
var formatAtom = ClipboardDataFormatHelper.ToAtom(format, _textFormatAtoms, _x11.Atoms);
var formatAtom = ClipboardDataFormatHelper.ToAtom(format, _textFormatAtoms, _x11.Atoms, dataFormats);
if (formatAtom == IntPtr.Zero)
return null;
@ -51,6 +53,13 @@ internal sealed class ClipboardDataReader(
null;
}
if(DataFormat.Bitmap.Equals(format))
{
using var data = result.AsStream();
return new Bitmap(data);
}
if (DataFormat.File.Equals(format))
{
// text/uri-list might not be supported

35
src/Avalonia.X11/Clipboard/X11Clipboard.cs

@ -95,7 +95,14 @@ namespace Avalonia.X11.Clipboard
}
else if (ClipboardDataFormatHelper.ToDataFormat(target, _x11.Atoms) is { } dataFormat)
{
if (_storedDataTransfer is null || !_storedDataTransfer.Contains(dataFormat))
if (_storedDataTransfer is null)
return IntPtr.Zero;
// Our default bitmap format is image/png
if (dataFormat.Identifier is "image/png" && _storedDataTransfer.Contains(DataFormat.Bitmap))
dataFormat = DataFormat.Bitmap;
if (!_storedDataTransfer.Contains(dataFormat))
return IntPtr.Zero;
if (TryGetDataAsBytes(_storedDataTransfer, dataFormat, target) is not { } bytes)
@ -145,6 +152,17 @@ namespace Avalonia.X11.Clipboard
encoding.GetBytes(text ?? string.Empty) :
null;
}
if (DataFormat.Bitmap.Equals(format))
{
if (dataTransfer.TryGetValueAsync(DataFormat.Bitmap).GetAwaiter().GetResult() is not { } bitmap)
return null;
using var stream = new MemoryStream();
bitmap.Save(stream);
return stream.ToArray();
}
if (DataFormat.File.Equals(format))
{
@ -286,7 +304,7 @@ namespace Avalonia.X11.Clipboard
return null;
// Get the items while we're in an async method. This does not get values, except for DataFormat.File.
var reader = new ClipboardDataReader(_x11, _platform, textFormatAtoms, owner);
var reader = new ClipboardDataReader(_x11, _platform, textFormatAtoms, owner, dataFormats);
var items = await CreateItemsAsync(reader, dataFormats);
return new ClipboardDataTransfer(reader, dataFormats, items);
}
@ -302,6 +320,8 @@ namespace Avalonia.X11.Clipboard
var formats = new List<DataFormat>(formatAtoms.Length);
List<IntPtr>? textFormatAtoms = null;
var hasImage = false;
foreach (var formatAtom in formatAtoms)
{
if (ClipboardDataFormatHelper.ToDataFormat(formatAtom, _x11.Atoms) is not { } format)
@ -317,9 +337,20 @@ namespace Avalonia.X11.Clipboard
textFormatAtoms.Add(formatAtom);
}
else
{
formats.Add(format);
if(!hasImage)
{
if (format.Identifier is ClipboardDataFormatHelper.JpegFormatMimeType or ClipboardDataFormatHelper.PngFormatMimeType)
hasImage = true;
}
}
}
if (hasImage)
formats.Add(DataFormat.Bitmap);
return (formats.ToArray(), textFormatAtoms?.ToArray() ?? []);
}

7
src/Browser/Avalonia.Browser/BrowserClipboardDataTransferItem.cs

@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
using Avalonia.Browser.Interop;
@ -22,8 +23,10 @@ internal sealed class BrowserClipboardDataTransferItem(JSObject readableDataItem
protected override async Task<object?> TryGetRawCoreAsync(DataFormat format)
{
var formatString = BrowserDataFormatHelper.ToBrowserFormat(format);
var value = await InputHelper.TryGetReadableDataItemValueAsync(_readableDataItem, formatString).ConfigureAwait(false);
string formatString = BrowserDataFormatHelper.ToBrowserFormat(format);
var value = await InputHelper.TryGetReadableDataItemValueAsync(_readableDataItem, formatString)
.ConfigureAwait(false);
return BrowserDataTransferHelper.TryGetValue(value, format);
}

4
src/Browser/Avalonia.Browser/BrowserDataFormatHelper.cs

@ -7,6 +7,7 @@ internal static class BrowserDataFormatHelper
{
private const string FormatTextPlain = "text/plain";
private const string FormatFiles = "Files";
private const string FormatImage = "image/png";
private const string AppPrefix = "application/avn-fmt.";
public static DataFormat ToDataFormat(string formatString)
@ -29,6 +30,9 @@ internal static class BrowserDataFormatHelper
if (DataFormat.File.Equals(format))
return FormatFiles;
if (DataFormat.Bitmap.Equals(format))
return FormatImage;
return format.ToSystemName(AppPrefix);
}
}

26
src/Browser/Avalonia.Browser/BrowserDataTransferHelper.cs

@ -1,8 +1,10 @@
using System.Runtime.InteropServices.JavaScript;
using System.IO;
using System.Runtime.InteropServices.JavaScript;
using System.Text;
using Avalonia.Browser.Interop;
using Avalonia.Browser.Storage;
using Avalonia.Input;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
namespace Avalonia.Browser;
@ -13,8 +15,17 @@ internal static class BrowserDataTransferHelper
{
var formatStrings = InputHelper.GetReadableDataItemFormats(readableDataItem);
var formats = new DataFormat[formatStrings.Length];
var hasSupportedImage = false;
for (var i = 0; i < formatStrings.Length; ++i)
formats[i] = BrowserDataFormatHelper.ToDataFormat(formatStrings[i]);
{
var formatString = formatStrings[i];
hasSupportedImage = formatString is "image/png";
formats[i] = BrowserDataFormatHelper.ToDataFormat(formatString);
}
if (hasSupportedImage)
formats = [..formats, DataFormat.Bitmap];
return formats;
}
@ -47,6 +58,17 @@ internal static class BrowserDataTransferHelper
};
}
if (DataFormat.Bitmap.Equals(format))
{
if (data is byte[] bytes)
{
using var stream = new MemoryStream(bytes);
return new Bitmap(stream);
}
return null;
}
if (format is DataFormat<byte[]>)
{
return data switch

16
src/Browser/Avalonia.Browser/ClipboardImpl.cs

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
using Avalonia.Input;
@ -51,6 +52,21 @@ internal sealed class ClipboardImpl : IClipboardImpl
continue;
}
if(DataFormat.Bitmap.Equals(format))
{
var bitmap = await dataTransferItem.TryGetValueAsync(DataFormat.Bitmap);
if (bitmap != null)
{
using var stream = new MemoryStream();
bitmap.Save(stream);
writeableItem ??= CreateWriteableClipboardItem(source);
AddBytesToWriteableClipboardItem(writeableItem, formatString, stream.ToArray());
}
continue;
}
if (format is DataFormat<string> stringFormat)
{
var stringValue = await dataTransferItem.TryGetValueAsync(stringFormat);

39
src/Windows/Avalonia.Win32/ClipboardFormatRegistry.cs

@ -1,8 +1,9 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Avalonia.Input;
using Avalonia.Win32.Interop;
using Avalonia.Utilities;
using Avalonia.Win32.Interop;
namespace Avalonia.Win32
{
@ -10,7 +11,10 @@ namespace Avalonia.Win32
{
private const int MaxFormatNameLength = 260;
private const string AppPrefix = "avn-app-fmt:";
public const string PngFormatMimeType = "image/png";
public const string JpegFormatMimeType = "image/jpeg";
public const string BitmapFormat = "CF_BITMAP";
public const string DibFormat = "CF_DIB";
private static readonly List<(DataFormat Format, ushort Id)> s_formats = [];
static ClipboardFormatRegistry()
@ -18,6 +22,7 @@ namespace Avalonia.Win32
AddDataFormat(DataFormat.Text, (ushort)UnmanagedMethods.ClipboardFormat.CF_UNICODETEXT);
AddDataFormat(DataFormat.Text, (ushort)UnmanagedMethods.ClipboardFormat.CF_TEXT);
AddDataFormat(DataFormat.File, (ushort)UnmanagedMethods.ClipboardFormat.CF_HDROP);
AddDataFormat(DataFormat.Bitmap, (ushort)UnmanagedMethods.ClipboardFormat.CF_DIB);
}
private static void AddDataFormat(DataFormat format, ushort id)
@ -28,6 +33,8 @@ namespace Avalonia.Win32
var buffer = StringBuilderCache.Acquire(MaxFormatNameLength);
if (UnmanagedMethods.GetClipboardFormatName(id, buffer, buffer.Capacity) > 0)
return StringBuilderCache.GetStringAndRelease(buffer);
if (Enum.IsDefined(typeof(UnmanagedMethods.ClipboardFormat), (int)id))
return Enum.GetName(typeof(UnmanagedMethods.ClipboardFormat), (int)id)!;
return $"Unknown_Format_{id}";
}
@ -52,6 +59,32 @@ namespace Avalonia.Win32
{
lock (s_formats)
{
if (DataFormat.Bitmap.Equals(format))
{
(DataFormat, ushort)? pngFormat = null;
(DataFormat, ushort)? jpgFormat = null;
(DataFormat, ushort)? dibFormat = null;
(DataFormat, ushort)? bitFormat = null;
foreach (var currentFormat in s_formats)
{
if (currentFormat.Id == (ushort)UnmanagedMethods.ClipboardFormat.CF_DIB)
dibFormat = currentFormat;
else if (currentFormat.Id == (ushort)UnmanagedMethods.ClipboardFormat.CF_BITMAP)
bitFormat = currentFormat;
else if (currentFormat.Format.Identifier == PngFormatMimeType)
pngFormat = currentFormat;
else if (currentFormat.Format.Identifier == JpegFormatMimeType)
jpgFormat = currentFormat;
}
var imageFormatId = dibFormat?.Item2 ?? bitFormat?.Item2 ?? pngFormat?.Item2 ?? jpgFormat?.Item2 ?? 0;
if(imageFormatId != 0)
{
return imageFormatId;
}
}
for (var i = 0; i < s_formats.Count; ++i)
{
if (s_formats[i].Format.Equals(format))

26
src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs

@ -6,9 +6,9 @@ using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using MicroCom.Runtime;
using Windows.Win32;
using Windows.Win32.Graphics.Gdi;
using MicroCom.Runtime;
// ReSharper disable InconsistentNaming
#pragma warning disable 169, 649
@ -1074,6 +1074,17 @@ namespace Avalonia.Win32.Interop
public byte rgbReserved;
}
public struct BITMAP
{
public int bmType;
public int bmWidth;
public int bmHeight;
public int bmWidthBytes;
public ushort bmPlanes;
public short bmBitsPixel;
public IntPtr bmBits;
}
[StructLayout(LayoutKind.Sequential)]
public struct BITMAPINFOHEADER
{
@ -1688,6 +1699,10 @@ namespace Avalonia.Win32.Interop
[DllImport("gdi32.dll")]
public static extern IntPtr CreateBitmap(int width, int height, int planes, int bitCount, IntPtr data);
[DllImport("gdi32.dll")]
public static extern int GetObject(IntPtr h, int c, IntPtr pv);
[DllImport("gdi32.dll")]
public static extern int GetBitmapDimensionEx(IntPtr hbit, ref SIZE lpsize);
[DllImport("gdi32.dll")]
public static extern int DeleteObject(IntPtr hObject);
[DllImport("gdi32.dll", SetLastError = true)]
public static extern IntPtr CreateCompatibleDC(IntPtr hdc);
@ -1703,10 +1718,15 @@ namespace Avalonia.Win32.Interop
[DllImport("gdi32.dll")]
public static extern int SetPixelFormat(IntPtr hdc, int iPixelFormat, ref PixelFormatDescriptor pfd);
[DllImport("gdi32.dll")]
public static extern int DescribePixelFormat(IntPtr hdc, int iPixelFormat, int bytes, ref PixelFormatDescriptor pfd);
[DllImport("gdi32.dll")]
public static extern int StretchDIBits(IntPtr hdc, int xDest, int yDest, int DestWidth, int DestHeight, int xSrc, int ySrc, int SrcWidth, int SrcHeight, IntPtr lpBits, [In] ref BITMAPINFO lpbmi, uint iUsage, uint rop);
[DllImport("gdi32.dll")]
public static extern int StretchBlt(IntPtr hdc, int xDest, int yDest, int DestWidth, int DestHeight, IntPtr hdcSrc, int xSrc, int ySrc, int SrcWidth, int SrcHeight, uint rop);
[DllImport("gdi32.dll")]
public static extern bool SwapBuffers(IntPtr hdc);
@ -2193,7 +2213,7 @@ namespace Avalonia.Win32.Interop
/// <summary>
/// A memory object containing a BITMAPINFO structure followed by the bitmap bits.
/// </summary>
CF_DIB = 3,
CF_DIB = 8,
/// <summary>
/// Unicode text format. Each line ends with a carriage return/linefeed (CR-LF) combination. A null character signals the end of the data.
/// </summary>

234
src/Windows/Avalonia.Win32/OleDataObjectHelper.cs

@ -1,16 +1,19 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using Avalonia.Input;
using Avalonia.Logging;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Avalonia.Utilities;
using Avalonia.Win32.Interop;
using static Avalonia.Win32.Interop.UnmanagedMethods;
using FORMATETC = Avalonia.Win32.Interop.UnmanagedMethods.FORMATETC;
using STGMEDIUM = Avalonia.Win32.Interop.UnmanagedMethods.STGMEDIUM;
using static Avalonia.Win32.Interop.UnmanagedMethods;
namespace Avalonia.Win32;
@ -19,6 +22,8 @@ namespace Avalonia.Win32;
/// </summary>
internal static class OleDataObjectHelper
{
private const int SRCCOPY = 0x00CC0020;
public static FORMATETC ToFormatEtc(this DataFormat format)
=> new()
{
@ -37,15 +42,36 @@ internal static class OleDataObjectHelper
return null;
var medium = new STGMEDIUM();
if (oleDataObject.GetData(&formatEtc, &medium) != (uint)HRESULT.S_OK)
return null;
var result = oleDataObject.GetData(&formatEtc, &medium);
if (result != (uint)HRESULT.S_OK)
{
if (result == DV_E_TYMED)
{
formatEtc.tymed = TYMED.TYMED_GDI;
if (oleDataObject.GetData(&formatEtc, &medium) != (uint)HRESULT.S_OK)
{
return null;
}
}
else
return null;
}
try
{
if (medium.tymed == TYMED.TYMED_HGLOBAL && medium.unionmember != IntPtr.Zero)
if (medium.unionmember != IntPtr.Zero)
{
var hGlobal = medium.unionmember;
return ReadDataFromHGlobal(format, hGlobal);
if (medium.tymed == TYMED.TYMED_HGLOBAL)
{
var hGlobal = medium.unionmember;
return ReadDataFromHGlobal(format, hGlobal, formatEtc);
}
else if (medium.tymed == TYMED.TYMED_GDI)
{
var bitmapHandle = medium.unionmember;
return ReadDataFromGdi(bitmapHandle);
}
}
}
finally
@ -56,7 +82,84 @@ internal static class OleDataObjectHelper
return null;
}
public static object? ReadDataFromHGlobal(DataFormat format, IntPtr hGlobal)
private static unsafe object? ReadDataFromGdi(nint bitmapHandle)
{
var bitmap = new BITMAP();
unsafe
{
var pBitmap = &bitmap;
if ((uint)GetObject(bitmapHandle, Marshal.SizeOf(bitmap), (IntPtr)pBitmap) == 0)
return null;
var bitmapInfoHeader = new BITMAPINFOHEADER()
{
biWidth = bitmap.bmWidth,
biHeight = bitmap.bmHeight,
biPlanes = bitmap.bmPlanes,
biBitCount = 32,
biCompression = 0,
biSizeImage = (uint)(bitmap.bmWidth * 4 * Math.Abs(bitmap.bmHeight))
};
bitmapInfoHeader.Init();
IntPtr destHdc = IntPtr.Zero, compatDc = IntPtr.Zero, section = IntPtr.Zero, sourceHdc = IntPtr.Zero, srcCompatHdc = IntPtr.Zero;
try
{
destHdc = GetDC(IntPtr.Zero);
if (destHdc == IntPtr.Zero)
return null;
compatDc = CreateCompatibleDC(destHdc);
if (compatDc == IntPtr.Zero)
return null;
section = CreateDIBSection(compatDc, ref bitmapInfoHeader, 0, out var lbBits, IntPtr.Zero, 0);
if (section == IntPtr.Zero)
return null;
SelectObject(compatDc, section);
sourceHdc = GetDC(IntPtr.Zero);
if (sourceHdc == IntPtr.Zero)
return null;
srcCompatHdc = CreateCompatibleDC(sourceHdc);
if (srcCompatHdc == IntPtr.Zero)
return null;
SelectObject(srcCompatHdc, bitmapHandle);
if (StretchBlt(compatDc, 0, bitmapInfoHeader.biHeight, bitmapInfoHeader.biWidth, -bitmapInfoHeader.biHeight, srcCompatHdc, 0, 0, bitmap.bmWidth, bitmap.bmHeight, SRCCOPY) != 0)
return new Bitmap(Platform.PixelFormats.Bgra8888,
Platform.AlphaFormat.Opaque,
lbBits,
new PixelSize(bitmapInfoHeader.biWidth, bitmapInfoHeader.biHeight),
new Vector(96, 96),
bitmapInfoHeader.biWidth * 4);
}
finally
{
if (sourceHdc != IntPtr.Zero)
ReleaseDC(IntPtr.Zero, sourceHdc);
if (srcCompatHdc != IntPtr.Zero)
ReleaseDC(IntPtr.Zero, srcCompatHdc);
if (compatDc != IntPtr.Zero)
ReleaseDC(IntPtr.Zero, compatDc);
if (destHdc != IntPtr.Zero)
ReleaseDC(IntPtr.Zero, destHdc);
if (section != IntPtr.Zero)
DeleteObject(section);
}
return null;
}
}
public unsafe static object? ReadDataFromHGlobal(DataFormat format, IntPtr hGlobal, FORMATETC formatEtc)
{
if (DataFormat.Text.Equals(format))
return ReadStringFromHGlobal(hGlobal);
@ -69,6 +172,85 @@ internal static class OleDataObjectHelper
.ToArray();
}
if (DataFormat.Bitmap.Equals(format))
{
if (formatEtc.cfFormat == (ushort)ClipboardFormat.CF_DIB)
{
var data = ReadBytesFromHGlobal(hGlobal);
fixed (byte* ptr = data)
{
var bitmapInfo = Marshal.PtrToStructure<BITMAPINFO>((IntPtr)ptr);
var bitmapInfoHeader = new BITMAPINFOHEADER()
{
biWidth = bitmapInfo.biWidth,
biHeight = bitmapInfo.biHeight,
biPlanes = bitmapInfo.biPlanes,
biBitCount = 32,
biCompression = 0,
biSizeImage = (uint)(bitmapInfo.biWidth * 4 * Math.Abs(bitmapInfo.biHeight))
};
bitmapInfoHeader.Init();
IntPtr hdc = IntPtr.Zero, compatDc = IntPtr.Zero, section = IntPtr.Zero;
try
{
hdc = GetDC(IntPtr.Zero);
if (hdc == IntPtr.Zero)
return null;
compatDc = CreateCompatibleDC(hdc);
if (compatDc == IntPtr.Zero)
return null;
section = CreateDIBSection(compatDc, ref bitmapInfoHeader, 0, out var lbBits, IntPtr.Zero, 0);
if (section == IntPtr.Zero)
return null;
SelectObject(compatDc, section);
if (StretchDIBits(compatDc,
0,
bitmapInfo.biHeight,
bitmapInfo.biWidth,
-bitmapInfo.biHeight,
0,
0,
bitmapInfoHeader.biWidth,
bitmapInfoHeader.biHeight,
(IntPtr)(ptr + bitmapInfo.biSize),
ref bitmapInfo,
0,
SRCCOPY
) != 0)
return new Bitmap(Platform.PixelFormats.Bgra8888,
Platform.AlphaFormat.Opaque,
lbBits,
new PixelSize(bitmapInfoHeader.biWidth, bitmapInfoHeader.biHeight),
new Vector(96, 96),
bitmapInfoHeader.biWidth * 4);
}
finally
{
if (section != IntPtr.Zero)
DeleteObject(section);
if (compatDc != IntPtr.Zero)
ReleaseDC(IntPtr.Zero, compatDc);
if (hdc != IntPtr.Zero)
ReleaseDC(IntPtr.Zero, hdc);
}
}
}
else
{
var data = ReadBytesFromHGlobal(hGlobal);
var stream = new MemoryStream(data);
return new Bitmap(stream);
}
}
if (format is DataFormat<string>)
return ReadStringFromHGlobal(hGlobal);
@ -126,7 +308,7 @@ internal static class OleDataObjectHelper
}
}
public static uint WriteDataToHGlobal(IDataTransfer dataTransfer, DataFormat format, ref IntPtr hGlobal)
public unsafe static uint WriteDataToHGlobal(IDataTransfer dataTransfer, DataFormat format, ref IntPtr hGlobal)
{
if (DataFormat.Text.Equals(format))
{
@ -145,6 +327,42 @@ internal static class OleDataObjectHelper
return WriteFileNamesToHGlobal(ref hGlobal, fileNames);
}
if (DataFormat.Bitmap.Equals(format))
{
var bitmap = dataTransfer.TryGetValue(DataFormat.Bitmap);
if (bitmap != null)
{
var pixelSize = bitmap.PixelSize;
var stride = ((bitmap.Format?.BitsPerPixel ?? 0) / 8) * pixelSize.Width;
var buffer = new byte[stride * pixelSize.Height];
fixed (byte* bytes = buffer)
{
bitmap.CopyPixels(new PixelRect(pixelSize), (IntPtr)bytes, buffer.Length, stride);
var infoHeader = new BITMAPINFOHEADER()
{
biSizeImage = (uint)buffer.Length,
biWidth = pixelSize.Width,
biHeight = -pixelSize.Height,
biBitCount = (ushort)(bitmap.Format?.BitsPerPixel ?? 0),
biPlanes = 1,
biCompression = (uint)BitmapCompressionMode.BI_RGB
};
infoHeader.Init();
var imageData = new byte[infoHeader.biSize + infoHeader.biSizeImage];
fixed (byte* image = imageData)
{
Marshal.StructureToPtr(infoHeader, (IntPtr)image, false);
new Span<byte>(bytes, buffer.Length).CopyTo(new Span<byte>((image + infoHeader.biSize), buffer.Length));
return WriteBytesToHGlobal(ref hGlobal, imageData);
}
}
}
}
if (format is DataFormat<string> stringFormat)
{
return dataTransfer.TryGetValue(stringFormat) is { } stringValue ?

19
src/Windows/Avalonia.Win32/OleDataObjectToDataTransferWrapper.cs

@ -32,6 +32,25 @@ internal sealed class OleDataObjectToDataTransferWrapper(Win32Com.IDataObject ol
while (Next(enumFormat) is { } format)
formats.Add(format);
bool hasSupportedFormat = false;
foreach (var format in formats)
{
if (format.Identifier is ClipboardFormatRegistry.DibFormat
or ClipboardFormatRegistry.BitmapFormat
or ClipboardFormatRegistry.PngFormatMimeType
or ClipboardFormatRegistry.JpegFormatMimeType)
{
hasSupportedFormat = true;
break;
}
}
if (hasSupportedFormat)
{
formats.Add(DataFormat.Bitmap);
}
return formats.ToArray();
static unsafe DataFormat? Next(IEnumFORMATETC enumFormat)

9
src/iOS/Avalonia.iOS/Clipboard/ClipboardDataFormatHelper.cs

@ -8,6 +8,10 @@ internal static class ClipboardDataFormatHelper
{
private const string UTTypeUTF8PlainText = "public.utf8-plain-text";
private const string UTTypeFileUrl = "public.file-url";
internal const string UTTypeImage = "public.image";
internal const string UTTypePng = "public.png";
internal const string UTTypeJpeg = "public.jpeg";
internal const string UTTypeTiff = "public.tiff";
private const string AppPrefix = "net.avaloniaui.app.uti.";
public static DataFormat ToDataFormat(string type)
@ -16,6 +20,7 @@ internal static class ClipboardDataFormatHelper
{
UTTypeUTF8PlainText => DataFormat.Text,
UTTypeFileUrl => DataFormat.File,
UTTypeImage or UTTypePng or UTTypeJpeg or UTTypeTiff => DataFormat.Bitmap,
_ when IsTextUti(type) => DataFormat.FromSystemName<string>(type, AppPrefix),
_ => DataFormat.FromSystemName<byte[]>(type, AppPrefix)
};
@ -29,6 +34,10 @@ internal static class ClipboardDataFormatHelper
if (DataFormat.File.Equals(format))
return UTTypeFileUrl;
if (DataFormat.Bitmap.Equals(format))
// Avalonia writes images as PNGs to the clipboard for iOS/macOS.
return UTTypePng;
return format.ToSystemName(AppPrefix);
}

13
src/iOS/Avalonia.iOS/Clipboard/ClipboardImpl.cs

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Input.Platform;
@ -90,6 +91,18 @@ internal sealed class ClipboardImpl(UIPasteboard pasteboard)
return file is null ? null : (NSString)file.Path.AbsoluteUri;
}
if (format.Equals(DataFormat.Bitmap))
{
var bitmap = await dataTransferItem.TryGetValueAsync(DataFormat.Bitmap);
if (bitmap is null)
return null;
using var memoryStream = new MemoryStream();
bitmap.Save(memoryStream, 100);
memoryStream.Seek(0, SeekOrigin.Begin);
using var data = NSData.FromStream(memoryStream)!;
return UIImage.LoadFromData(data);
}
if (format is DataFormat<string> stringFormat)
{
var stringValue = await dataTransferItem.TryGetValueAsync(stringFormat);

45
src/iOS/Avalonia.iOS/Clipboard/PasteboardItemToDataTransferItemWrapper.cs

@ -3,7 +3,9 @@ using System.Text;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.iOS.Storage;
using Avalonia.Media.Imaging;
using Foundation;
using UIKit;
namespace Avalonia.iOS.Clipboard;
@ -23,6 +25,45 @@ internal sealed class PasteboardItemToDataTransferItemWrapper(NSDictionary item)
protected override object? TryGetRawCore(DataFormat format)
{
// Handle images specially without ToSystemType, as we may have multiple UTI types for images.
if (DataFormat.Bitmap.Equals(format))
{
NSObject? imageValue;
if (_item.TryGetValue((NSString)ClipboardDataFormatHelper.UTTypePng, out imageValue)
|| _item.TryGetValue((NSString)ClipboardDataFormatHelper.UTTypeJpeg, out imageValue))
{
// keep imageValue as is, it can be either UIImage or NSData, in either case Bitmap can handle it directly.
}
else if (_item.TryGetValue((NSString)ClipboardDataFormatHelper.UTTypeImage, out imageValue)
|| _item.TryGetValue((NSString)ClipboardDataFormatHelper.UTTypeTiff, out imageValue))
{
// if it's NSData, we need to convert it to UIImage first, as TIFF is not directly supported by Bitmap.
if (imageValue is NSData imageData)
{
imageValue = UIImage.LoadFromData(imageData);
imageData.Dispose();
}
}
switch (imageValue)
{
case UIImage image:
{
using var pngData = image.AsPNG()!;
using var pngStream = pngData.AsStream();
return new Bitmap(pngStream);
}
case NSData data:
{
using var dataStream = data.AsStream();
return new Bitmap(dataStream);
}
default:
return null;
}
}
var type = ClipboardDataFormatHelper.ToSystemType(format);
if (!_item.TryGetValue((NSString)type, out var value))
return null;
@ -42,7 +83,7 @@ internal sealed class PasteboardItemToDataTransferItemWrapper(NSDictionary item)
return null;
}
private static unsafe string? TryConvertToString(NSObject value)
private static unsafe string? TryConvertToString(NSObject? value)
=> value switch
{
NSString str => str,
@ -50,7 +91,7 @@ internal sealed class PasteboardItemToDataTransferItemWrapper(NSDictionary item)
_ => null
};
private static byte[]? TryConvertToBytes(NSObject value)
private static byte[]? TryConvertToBytes(NSObject? value)
=> value switch
{
NSData data => data.ToArray(),

Loading…
Cancel
Save