Browse Source

Browser drag n drop target support

pull/10389/head
Max Katz 3 years ago
parent
commit
dcb73b9fef
  1. 55
      src/Browser/Avalonia.Browser/AvaloniaView.cs
  2. 91
      src/Browser/Avalonia.Browser/BrowserDataObject.cs
  3. 9
      src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs
  4. 2
      src/Browser/Avalonia.Browser/ClipboardImpl.cs
  5. 21
      src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs
  6. 22
      src/Browser/Avalonia.Browser/Interop/GeneralHelpers.cs
  7. 5
      src/Browser/Avalonia.Browser/Interop/InputHelper.cs
  8. 3
      src/Browser/Avalonia.Browser/Interop/StorageHelper.cs
  9. 16
      src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs
  10. 4
      src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts
  11. 19
      src/Browser/Avalonia.Browser/webapp/modules/avalonia/generalHelpers.ts
  12. 22
      src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts
  13. 42
      src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts
  14. 8
      src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts

55
src/Browser/Avalonia.Browser/AvaloniaView.cs

@ -106,6 +106,8 @@ namespace Avalonia.Browser
InputHelper.SubscribePointerEvents(_containerElement, OnPointerMove, OnPointerDown, OnPointerUp,
OnPointerCancel, OnWheel);
InputHelper.SubscribeDropEvents(_containerElement, OnDragEvent);
var skiaOptions = AvaloniaLocator.Current.GetService<SkiaOptions>();
_dpi = DomHelper.ObserveDpi(OnDpiChanged);
@ -293,6 +295,59 @@ namespace Avalonia.Browser
return modifiers;
}
public bool OnDragEvent(JSObject args)
{
var eventType = args?.GetPropertyAsString("type") switch
{
"dragenter" => RawDragEventType.DragEnter,
"dragover" => RawDragEventType.DragOver,
"dragleave" => RawDragEventType.DragLeave,
"drop" => RawDragEventType.Drop,
_ => (RawDragEventType)(int)-1
};
var dataObject = args?.GetPropertyAsJSObject("dataTransfer");
if (args is null || eventType < 0 || dataObject is null)
{
return false;
}
// If file is dropped, we need storage js to be referenced.
// TODO: restructure JS files, so it's not needed.
_ = AvaloniaModule.ImportStorage();
var position = new Point(args.GetPropertyAsDouble("offsetX"), args.GetPropertyAsDouble("offsetY"));
var modifiers = GetModifiers(args);
var effectAllowedStr = dataObject.GetPropertyAsString("effectAllowed") ?? "none";
var effectAllowed = DragDropEffects.None;
if (effectAllowedStr.Contains("copy", StringComparison.OrdinalIgnoreCase))
{
effectAllowed |= DragDropEffects.Copy;
}
if (effectAllowedStr.Contains("link", StringComparison.OrdinalIgnoreCase))
{
effectAllowed |= DragDropEffects.Link;
}
if (effectAllowedStr.Contains("move", StringComparison.OrdinalIgnoreCase))
{
effectAllowed |= DragDropEffects.Move;
}
if (effectAllowedStr.Equals("all", StringComparison.OrdinalIgnoreCase))
{
effectAllowed |= DragDropEffects.Move | DragDropEffects.Copy | DragDropEffects.Link;
}
if (effectAllowed == DragDropEffects.None)
{
return false;
}
var dropEffect = _topLevelImpl.RawDragEvent(eventType, position, modifiers, new BrowserDataObject(dataObject), effectAllowed);
dataObject.SetProperty("dropEffect", dropEffect.ToString().ToLowerInvariant());
return eventType is RawDragEventType.Drop or RawDragEventType.DragOver
&& dropEffect != DragDropEffects.None;
}
private bool OnKeyDown (string code, string key, int modifier)
{
var handled = _topLevelImpl.RawKeyboardEvent(RawKeyEventType.KeyDown, code, key, (RawInputModifiers)modifier);

91
src/Browser/Avalonia.Browser/BrowserDataObject.cs

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices.JavaScript;
using Avalonia.Browser.Interop;
using Avalonia.Browser.Storage;
using Avalonia.Input;
using Avalonia.Platform.Storage;
namespace Avalonia.Browser;
internal class BrowserDataObject : IDataObject
{
private readonly JSObject _dataObject;
public BrowserDataObject(JSObject dataObject)
{
_dataObject = dataObject;
}
public IEnumerable<string> GetDataFormats()
{
var types = new HashSet<string>(_dataObject.GetPropertyAsStringArray("types"));
var dataFormats = new HashSet<string>(types.Count);
foreach (var type in types)
{
if (type.StartsWith("text/", StringComparison.Ordinal))
{
dataFormats.Add(DataFormats.Text);
}
else if (type.Equals("Files", StringComparison.Ordinal))
{
dataFormats.Add(DataFormats.Files);
}
dataFormats.Add(type);
}
// If drag'n'drop an image from the another web page, if won't add "Files" to the supported types, but only a "text/uri-list".
// With "text/uri-list" browser can add actual file as well.
var filesCount = _dataObject.GetPropertyAsJSObject("files")?.GetPropertyAsInt32("count");
if (filesCount > 0)
{
dataFormats.Add(DataFormats.Files);
}
return dataFormats;
}
public bool Contains(string dataFormat)
{
return GetDataFormats().Contains(dataFormat);
}
public object? Get(string dataFormat)
{
if (dataFormat == DataFormats.Files)
{
var files = _dataObject.GetPropertyAsJSObject("files");
if (files is not null)
{
return StorageHelper.FilesToItemsArray(files)
.Select(reference => reference.GetPropertyAsString("kind") switch
{
"directory" => (IStorageItem)new JSStorageFolder(reference),
"file" => new JSStorageFile(reference),
_ => null
})
.Where(i => i is not null)
.ToArray()!;
}
return null;
}
if (dataFormat == DataFormats.Text)
{
if (_dataObject.CallMethodString("getData", "text/plain") is { Length :> 0 } textData)
{
return textData;
}
}
if (_dataObject.CallMethodString("getData", dataFormat) is { Length: > 0 } data)
{
return data;
}
return null;
}
}

9
src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs

@ -164,6 +164,15 @@ namespace Avalonia.Browser
return false;
}
public DragDropEffects RawDragEvent(RawDragEventType eventType, Point position, RawInputModifiers modifiers, BrowserDataObject dataObject, DragDropEffects dropEffect)
{
var device = AvaloniaLocator.Current.GetRequiredService<IDragDropDevice>();
var eventArgs = new RawDragEvent(device, eventType, _inputRoot!, position, dataObject, dropEffect, modifiers);
Console.WriteLine($"{eventArgs.Location} {eventArgs.Effects} {eventArgs.Type} {eventArgs.KeyModifiers}");
Input?.Invoke(eventArgs);
return eventArgs.Effects;
}
public void Dispose()
{

2
src/Browser/Avalonia.Browser/ClipboardImpl.cs

@ -24,6 +24,6 @@ namespace Avalonia.Browser
public Task<string[]> GetFormatsAsync() => Task.FromResult(Array.Empty<string>());
public Task<object?> GetDataAsync(string format) => Task.FromResult<object?>(new());
public Task<object?> GetDataAsync(string format) => Task.FromResult<object?>(null);
}
}

21
src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs

@ -1,24 +1,29 @@
using System.Runtime.InteropServices.JavaScript;
using System;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
namespace Avalonia.Browser.Interop;
internal static partial class AvaloniaModule
{
public const string MainModuleName = "avalonia";
public const string StorageModuleName = "storage";
public static Task ImportMain()
private static readonly Lazy<Task> s_importMain = new(() =>
{
var options = AvaloniaLocator.Current.GetService<BrowserPlatformOptions>() ?? new BrowserPlatformOptions();
return JSHost.ImportAsync(MainModuleName, options.FrameworkAssetPathResolver!("avalonia.js"));
}
});
public static Task ImportStorage()
private static readonly Lazy<Task> s_importStorage = new(() =>
{
var options = AvaloniaLocator.Current.GetService<BrowserPlatformOptions>() ?? new BrowserPlatformOptions();
return JSHost.ImportAsync(StorageModuleName, options.FrameworkAssetPathResolver!("storage.js"));
}
});
public const string MainModuleName = "avalonia";
public const string StorageModuleName = "storage";
public static Task ImportMain() => s_importMain.Value;
public static Task ImportStorage() => s_importStorage.Value;
[JSImport("Caniuse.isMobile", AvaloniaModule.MainModuleName)]
public static partial bool IsMobile();

22
src/Browser/Avalonia.Browser/Interop/GeneralHelpers.cs

@ -0,0 +1,22 @@
using System.Runtime.InteropServices.JavaScript;
namespace Avalonia.Browser.Interop;
internal static partial class GeneralHelpers
{
[JSImport("GeneralHelpers.itemsArrayAt", AvaloniaModule.MainModuleName)]
public static partial JSObject[] ItemsArrayAt(JSObject jsObject, string key);
public static JSObject[] GetPropertyAsJSObjectArray(this JSObject jsObject, string key) => ItemsArrayAt(jsObject, key);
[JSImport("GeneralHelpers.itemsArrayAt", AvaloniaModule.MainModuleName)]
public static partial string[] ItemsArrayAtAsStrings(JSObject jsObject, string key);
public static string[] GetPropertyAsStringArray(this JSObject jsObject, string key) => ItemsArrayAtAsStrings(jsObject, key);
[JSImport("GeneralHelpers.callMethod", AvaloniaModule.MainModuleName)]
public static partial string IntCallMethodString(JSObject jsObject, string name);
[JSImport("GeneralHelpers.callMethod", AvaloniaModule.MainModuleName)]
public static partial string IntCallMethodStringString(JSObject jsObject, string name, string arg1);
public static string CallMethodString(this JSObject jsObject, string name) => IntCallMethodString(jsObject, name);
public static string CallMethodString(this JSObject jsObject, string name, string arg1) => IntCallMethodStringString(jsObject, name, arg1);
}

5
src/Browser/Avalonia.Browser/Interop/InputHelper.cs

@ -43,13 +43,16 @@ internal static partial class InputHelper
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>]
Func<JSObject, bool> wheel);
[JSImport("InputHelper.subscribeInputEvents", AvaloniaModule.MainModuleName)]
public static partial void SubscribeInputEvents(
JSObject htmlElement,
[JSMarshalAs<JSType.Function<JSType.String, JSType.Boolean>>]
Func<string, bool> input);
[JSImport("InputHelper.subscribeDropEvents", AvaloniaModule.MainModuleName)]
public static partial void SubscribeDropEvents(JSObject containerElement,
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>] Func<JSObject, bool> dragEvent);
[JSImport("InputHelper.getCoalescedEvents", AvaloniaModule.MainModuleName)]
[return: JSMarshalAs<JSType.Array<JSType.Object>>]
public static partial JSObject[] GetCoalescedEvents(JSObject pointerEvent);

3
src/Browser/Avalonia.Browser/Interop/StorageHelper.cs

@ -46,6 +46,9 @@ internal static partial class StorageHelper
[JSImport("StorageItems.itemsArray", AvaloniaModule.StorageModuleName)]
public static partial JSObject[] ItemsArray(JSObject item);
[JSImport("StorageItems.filesToItemsArray", AvaloniaModule.StorageModuleName)]
public static partial JSObject[] FilesToItemsArray(JSObject item);
[JSImport("StorageProvider.createAcceptType", AvaloniaModule.StorageModuleName)]
public static partial JSObject CreateAcceptType(string description, string[] mimeTypes, string[]? extensions);

16
src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs

@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Avalonia.Browser.Interop;
using Avalonia.Platform.Storage;
@ -18,15 +16,13 @@ internal class BrowserStorageProvider : IStorageProvider
internal const string PickerCancelMessage = "The user aborted a request";
internal const string NoPermissionsMessage = "Permissions denied";
private readonly Lazy<Task> _lazyModule = new(() => AvaloniaModule.ImportStorage());
public bool CanOpen => true;
public bool CanSave => StorageHelper.HasNativeFilePicker();
public bool CanPickFolder => true;
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
await _lazyModule.Value;
await AvaloniaModule.ImportStorage();
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;
var (types, excludeAll) = ConvertFileTypes(options.FileTypeFilter);
@ -60,7 +56,7 @@ internal class BrowserStorageProvider : IStorageProvider
public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
await _lazyModule.Value;
await AvaloniaModule.ImportStorage();
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;
var (types, excludeAll) = ConvertFileTypes(options.FileTypeChoices);
@ -88,7 +84,7 @@ internal class BrowserStorageProvider : IStorageProvider
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
await _lazyModule.Value;
await AvaloniaModule.ImportStorage();
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;
try
@ -104,14 +100,14 @@ internal class BrowserStorageProvider : IStorageProvider
public async Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
{
await _lazyModule.Value;
await AvaloniaModule.ImportStorage();
var item = await StorageHelper.OpenBookmark(bookmark);
return item is not null ? new JSStorageFile(item) : null;
}
public async Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
{
await _lazyModule.Value;
await AvaloniaModule.ImportStorage();
var item = await StorageHelper.OpenBookmark(bookmark);
return item is not null ? new JSStorageFolder(item) : null;
}
@ -128,7 +124,7 @@ internal class BrowserStorageProvider : IStorageProvider
public async Task<IStorageFolder?> TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder)
{
await _lazyModule.Value;
await AvaloniaModule.ImportStorage();
var directory = StorageHelper.CreateWellKnownDirectory(wellKnownFolder switch
{
WellKnownFolder.Desktop => "desktop",

4
src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts

@ -5,6 +5,7 @@ import { Caniuse } from "./avalonia/caniuse";
import { StreamHelper } from "./avalonia/stream";
import { NativeControlHost } from "./avalonia/nativeControlHost";
import { NavigationHelper } from "./avalonia/navigationHelper";
import { GeneralHelpers } from "./avalonia/generalHelpers";
export {
Caniuse,
@ -15,5 +16,6 @@ export {
AvaloniaDOM,
StreamHelper,
NativeControlHost,
NavigationHelper
NavigationHelper,
GeneralHelpers
};

19
src/Browser/Avalonia.Browser/webapp/modules/avalonia/generalHelpers.ts

@ -0,0 +1,19 @@
export class GeneralHelpers {
public static itemsArrayAt(instance: any, key: string): any[] {
const items = instance[key];
if (!items) {
return [];
}
const retItems = [];
for (let i = 0; i < items.length; i++) {
retItems[i] = items[i];
}
return retItems;
}
public static callMethod(instance: any, name: string /*, args */): any {
const args = Array.prototype.slice.call(arguments, 2);
return instance[name].apply(instance, args);
}
}

22
src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts

@ -174,6 +174,28 @@ export class InputHelper {
};
}
public static subscribeDropEvents(
element: HTMLInputElement,
dragEvent: (args: any) => boolean
) {
const dragHandler = (args: Event) => {
if (dragEvent(args as any)) {
args.preventDefault();
}
};
element.addEventListener("dragover", dragHandler);
element.addEventListener("dragenter", dragHandler);
element.addEventListener("dragleave", dragHandler);
element.addEventListener("drop", dragHandler);
return () => {
element.removeEventListener("dragover", dragHandler);
element.removeEventListener("dragenter", dragHandler);
element.removeEventListener("dragleave", dragHandler);
element.removeEventListener("drop", dragHandler);
};
}
public static getCoalescedEvents(pointerEvent: PointerEvent): PointerEvent[] {
return pointerEvent.getCoalescedEvents();
}

42
src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts

@ -3,8 +3,9 @@ import { FileSystemFileHandle, FileSystemDirectoryHandle, FileSystemWritableFile
import { Caniuse } from "../avalonia";
export class StorageItem {
constructor(
private constructor(
public handle?: FileSystemFileHandle | FileSystemDirectoryHandle,
private readonly file?: File,
private readonly bookmarkId?: string,
public wellKnownType?: WellKnownDirectory
) {
@ -14,6 +15,9 @@ export class StorageItem {
if (this.handle) {
return this.handle.name;
}
if (this.file) {
return this.file.name;
}
return this.wellKnownType ?? "";
}
@ -21,14 +25,29 @@ export class StorageItem {
if (this.handle) {
return this.handle.kind;
}
if (this.file) {
return "file";
}
return "directory";
}
public static createFromHandle(handle: FileSystemFileHandle | FileSystemDirectoryHandle, bookmarkId?: string) {
return new StorageItem(handle, undefined, bookmarkId, undefined);
}
public static createFromFile(file: File) {
return new StorageItem(undefined, file, undefined, undefined);
}
public static createWellKnownDirectory(type: WellKnownDirectory) {
return new StorageItem(undefined, undefined, type);
return new StorageItem(undefined, undefined, undefined, type);
}
public static async openRead(item: StorageItem): Promise<Blob> {
if (item.file) {
return item.file;
}
if (!item.handle || item.kind !== "file") {
throw new Error("StorageItem is not a file");
}
@ -41,7 +60,7 @@ export class StorageItem {
public static async openWrite(item: StorageItem): Promise<FileSystemWritableFileStream> {
if (!item.handle || item.kind !== "file") {
throw new Error("StorageItem is not a file");
throw new Error("StorageItem is not a writeable file");
}
await item.verityPermissions("readwrite");
@ -52,8 +71,9 @@ export class StorageItem {
public static async getProperties(item: StorageItem): Promise<{ Size: number; LastModified: number; Type: string } | null> {
// getFile can fail with an exception depending if we use polyfill with a save file dialog or not.
try {
const file = item.handle instanceof FileSystemFileHandle &&
await item.handle.getFile();
const file = item.handle && "getFile" in item.handle
? await item.handle.getFile()
: item.file;
if (!file) {
return null;
@ -144,4 +164,16 @@ export class StorageItems {
public static itemsArray(instance: StorageItems): StorageItem[] {
return instance.items;
}
public static filesToItemsArray(files: File[]): StorageItem[] {
if (!files) {
return [];
}
const retItems = [];
for (let i = 0; i < files.length; i++) {
retItems[i] = StorageItem.createFromFile(files[i]);
}
return retItems;
}
}

8
src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts

@ -19,7 +19,7 @@ export class StorageProvider {
};
const handle = await showDirectoryPicker(options as any);
return new StorageItem(handle);
return StorageItem.createFromHandle(handle);
}
public static async openFileDialog(
@ -33,7 +33,7 @@ export class StorageProvider {
};
const handles = await showOpenFilePicker(options);
return new StorageItems(handles.map((handle: FileSystemFileHandle) => new StorageItem(handle)));
return new StorageItems(handles.map((handle: FileSystemFileHandle) => StorageItem.createFromHandle(handle)));
}
public static async saveFileDialog(
@ -48,14 +48,14 @@ export class StorageProvider {
// Always prefer native save file picker, as polyfill solutions are not reliable.
const handle = await (globalThis as any).showSaveFilePicker(options);
return new StorageItem(handle);
return StorageItem.createFromHandle(handle);
}
public static async openBookmark(key: string): Promise<StorageItem | null> {
const connection = await avaloniaDb.connect();
try {
const handle = await connection.get(fileBookmarksStore, key);
return handle && new StorageItem(handle, key);
return handle && StorageItem.createFromHandle(handle, key);
} finally {
connection.close();
}

Loading…
Cancel
Save