Browse Source

Use polyfill for open file and directory dialogs

pull/10343/head
Max Katz 4 years ago
parent
commit
49a6cbd5a6
  1. 2
      samples/ControlCatalog/Pages/DialogsPage.xaml.cs
  2. 5
      src/Browser/Avalonia.Browser/AvaloniaView.cs
  3. 10
      src/Browser/Avalonia.Browser/Interop/StorageHelper.cs
  4. 18
      src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs
  5. 2
      src/Browser/Avalonia.Browser/webapp/.eslintrc.json
  6. 2
      src/Browser/Avalonia.Browser/webapp/build.js
  7. 12
      src/Browser/Avalonia.Browser/webapp/modules/avalonia/caniuse.ts
  8. 3
      src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts
  9. 50
      src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts
  10. 25
      src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts
  11. 117
      src/Browser/Avalonia.Browser/webapp/package-lock.json
  12. 4
      src/Browser/Avalonia.Browser/webapp/package.json
  13. 6
      src/Browser/Avalonia.Browser/webapp/tsconfig.json

2
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@ -281,7 +281,7 @@ namespace ControlCatalog.Pages
{
ignoreTextChanged = true;
lastSelectedDirectory = folder;
currentFolderBox.Text = folder?.Path.LocalPath;
currentFolderBox.Text = folder?.Path is { IsAbsoluteUri: true } abs ? abs.LocalPath : folder?.Path?.ToString();
ignoreTextChanged = false;
}
async Task SetPickerResult(IReadOnlyCollection<IStorageItem>? items)

5
src/Browser/Avalonia.Browser/AvaloniaView.cs

@ -381,12 +381,10 @@ namespace Avalonia.Browser
{
if (_useGL && (_jsGlInfo == null))
{
Console.WriteLine("nothing to render");
return;
}
if (_canvasSize.Width <= 0 || _canvasSize.Height <= 0 || _dpi <= 0)
{
Console.WriteLine("nothing to render");
return;
}
@ -459,7 +457,6 @@ namespace Avalonia.Browser
void ITextInputMethodImpl.SetClient(ITextInputMethodClient? client)
{
Console.WriteLine("Set Client");
if (_client != null)
{
_client.SurroundingTextChanged -= SurroundingTextChanged;
@ -482,8 +479,6 @@ namespace Avalonia.Browser
var surroundingText = _client.SurroundingText;
InputHelper.SetSurroundingText(_inputElement, surroundingText.Text, surroundingText.AnchorOffset, surroundingText.CursorOffset);
Console.WriteLine("Shown, focused and surrounded.");
}
else
{

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

@ -5,14 +5,8 @@ namespace Avalonia.Browser.Interop;
internal static partial class StorageHelper
{
[JSImport("Caniuse.canShowOpenFilePicker", AvaloniaModule.MainModuleName)]
public static partial bool CanShowOpenFilePicker();
[JSImport("Caniuse.canShowSaveFilePicker", AvaloniaModule.MainModuleName)]
public static partial bool CanShowSaveFilePicker();
[JSImport("Caniuse.canShowDirectoryPicker", AvaloniaModule.MainModuleName)]
public static partial bool CanShowDirectoryPicker();
[JSImport("Caniuse.hasNativeFilePicker", AvaloniaModule.MainModuleName)]
public static partial bool HasNativeFilePicker();
[JSImport("StorageProvider.selectFolderDialog", AvaloniaModule.StorageModuleName)]
public static partial Task<JSObject?> SelectFolderDialog(JSObject? startIn);

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

@ -20,9 +20,9 @@ internal class BrowserStorageProvider : IStorageProvider
private readonly Lazy<Task> _lazyModule = new(() => AvaloniaModule.ImportStorage());
public bool CanOpen => StorageHelper.CanShowOpenFilePicker();
public bool CanSave => StorageHelper.CanShowSaveFilePicker();
public bool CanPickFolder => StorageHelper.CanShowDirectoryPicker();
public bool CanOpen => true;
public bool CanSave => StorageHelper.HasNativeFilePicker();
public bool CanPickFolder => true;
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
@ -186,10 +186,15 @@ internal abstract class JSStorageItem : IStorageBookmarkItem
dateModified: lastModified > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(lastModified.Value) : null);
}
public bool CanBookmark => true;
public bool CanBookmark => StorageHelper.HasNativeFilePicker();
public Task<string?> SaveBookmarkAsync()
{
if (!CanBookmark)
{
return Task.FromResult<string?>(null);
}
return StorageHelper.SaveBookmark(FileHandle);
}
@ -200,6 +205,11 @@ internal abstract class JSStorageItem : IStorageBookmarkItem
public Task ReleaseBookmarkAsync()
{
if (!CanBookmark)
{
return Task.CompletedTask;
}
return StorageHelper.DeleteBookmark(FileHandle);
}

2
src/Browser/Avalonia.Browser/webapp/.eslintrc.json

@ -43,5 +43,5 @@
}
]
},
"ignorePatterns": ["types/*"]
"ignorePatterns": ["types/*","node_modules/*"]
}

2
src/Browser/Avalonia.Browser/webapp/build.js

@ -7,7 +7,7 @@ require("esbuild").build({
bundle: true,
minify: true,
format: "esm",
target: "es2016",
target: "es2018",
platform: "browser",
sourcemap: "linked",
loader: { ".ts": "ts" }

12
src/Browser/Avalonia.Browser/webapp/modules/avalonia/caniuse.ts

@ -1,14 +1,6 @@
export class Caniuse {
public static canShowOpenFilePicker(): boolean {
return typeof globalThis.showOpenFilePicker !== "undefined";
}
public static canShowSaveFilePicker(): boolean {
return typeof globalThis.showSaveFilePicker !== "undefined";
}
public static canShowDirectoryPicker(): boolean {
return typeof globalThis.showDirectoryPicker !== "undefined";
public static hasNativeFilePicker(): boolean {
return "showSaveFilePicker" in globalThis;
}
public static isMobile(): boolean {

3
src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts

@ -1,3 +1,4 @@
import FileSystemWritableFileStream from "native-file-system-adapter/types/src/FileSystemWritableFileStream";
import { IMemoryView } from "../../types/dotnet";
export class StreamHelper {
@ -17,7 +18,7 @@ export class StreamHelper {
const array = new Uint8Array(span.byteLength);
span.copyTo(array);
const data: WriteParams = {
const data = {
type: "write",
data: array
};

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

@ -1,8 +1,10 @@
import { avaloniaDb, fileBookmarksStore } from "./indexedDb";
import { FileSystemFileHandle, FileSystemDirectoryHandle, FileSystemWritableFileStream } from "native-file-system-adapter";
import { Caniuse } from "../avalonia";
export class StorageItem {
constructor(
public handle?: FileSystemHandle,
public handle?: FileSystemFileHandle | FileSystemDirectoryHandle,
private readonly bookmarkId?: string,
public wellKnownType?: WellKnownDirectory
) {
@ -27,39 +29,44 @@ export class StorageItem {
}
public static async openRead(item: StorageItem): Promise<Blob> {
if (!(item.handle instanceof FileSystemFileHandle)) {
if (!item.handle || item.kind !== "file") {
throw new Error("StorageItem is not a file");
}
await item.verityPermissions("read");
const file = await item.handle.getFile();
const file = await (item.handle as FileSystemFileHandle).getFile();
return file;
}
public static async openWrite(item: StorageItem): Promise<FileSystemWritableFileStream> {
if (!(item.handle instanceof FileSystemFileHandle)) {
if (!item.handle || item.kind !== "file") {
throw new Error("StorageItem is not a file");
}
await item.verityPermissions("readwrite");
return await item.handle.createWritable({ keepExistingData: true });
return await (item.handle as FileSystemFileHandle).createWritable({ keepExistingData: true });
}
public static async getProperties(item: StorageItem): Promise<{ Size: number; LastModified: number; Type: string } | null> {
const file = item.handle instanceof FileSystemFileHandle &&
await item.handle.getFile();
if (!file) {
// 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();
if (!file) {
return null;
}
return {
Size: file.size,
LastModified: file.lastModified,
Type: file.type
};
} catch {
return null;
}
return {
Size: file.size,
LastModified: file.lastModified,
Type: file.type
};
}
public static async getItems(item: StorageItem): Promise<StorageItems> {
@ -74,11 +81,16 @@ export class StorageItem {
return new StorageItems(items);
}
private async verityPermissions(mode: FileSystemPermissionMode): Promise<void | never> {
private async verityPermissions(mode: "read" | "readwrite"): Promise<void | never> {
if (!this.handle) {
return;
}
// If we are using polyfill, let it decide permissions by itself, we can't request anything in this case.
if (!Caniuse.hasNativeFilePicker()) {
return;
}
if (await this.handle.queryPermission({ mode }) === "granted") {
return;
}
@ -93,7 +105,9 @@ export class StorageItem {
if (item.bookmarkId) {
return item.bookmarkId;
}
if (!item.handle) {
// Bookmarks are not supported with polyfill.
if (!item.handle || !Caniuse.hasNativeFilePicker()) {
return null;
}
@ -107,7 +121,7 @@ export class StorageItem {
}
public static async deleteBookmark(item: StorageItem): Promise<void> {
if (!item.bookmarkId) {
if (!item.bookmarkId || !Caniuse.hasNativeFilePicker()) {
return;
}

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

@ -1,14 +1,12 @@
import { avaloniaDb, fileBookmarksStore } from "./indexedDb";
import { StorageItem, StorageItems } from "./storageItem";
import { showOpenFilePicker, showDirectoryPicker, FileSystemFileHandle } from "native-file-system-adapter";
declare global {
type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos";
type StartInDirectory = WellKnownDirectory | FileSystemHandle;
interface OpenFilePickerOptions {
startIn?: StartInDirectory;
}
interface SaveFilePickerOptions {
startIn?: StartInDirectory;
interface FilePickerAcceptType {
description?: string | undefined;
accept: Record<string, string | string[]>;
}
}
@ -16,39 +14,40 @@ export class StorageProvider {
public static async selectFolderDialog(
startIn: StorageItem | null): Promise<StorageItem> {
// 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined.
const options: DirectoryPickerOptions = {
const options = {
startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined)
};
const handle = await window.showDirectoryPicker(options);
const handle = await showDirectoryPicker(options as any);
return new StorageItem(handle);
}
public static async openFileDialog(
startIn: StorageItem | null, multiple: boolean,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise<StorageItems> {
const options: OpenFilePickerOptions = {
const options = {
startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined),
multiple,
excludeAcceptAllOption,
types: (types ?? undefined)
};
const handles = await window.showOpenFilePicker(options);
return new StorageItems(handles.map((handle: FileSystemHandle) => new StorageItem(handle)));
const handles = await showOpenFilePicker(options);
return new StorageItems(handles.map((handle: FileSystemFileHandle) => new StorageItem(handle)));
}
public static async saveFileDialog(
startIn: StorageItem | null, suggestedName: string | null,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise<StorageItem> {
const options: SaveFilePickerOptions = {
const options = {
startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined),
suggestedName: (suggestedName ?? undefined),
excludeAcceptAllOption,
types: (types ?? undefined)
};
const handle = await window.showSaveFilePicker(options);
// Always prefer native save file picker, as polyfill solutions are not reliable.
const handle = await (globalThis as any).showSaveFilePicker(options);
return new StorageItem(handle);
}

117
src/Browser/Avalonia.Browser/webapp/package-lock.json

@ -5,9 +5,11 @@
"packages": {
"": {
"name": "avalonia.browser",
"dependencies": {
"native-file-system-adapter": "github:jimmywarting/native-file-system-adapter#d43ad841581c2cc3ce47bbd1e8f11950ebdff027"
},
"devDependencies": {
"@types/emscripten": "^1.39.6",
"@types/wicg-file-system-access": "^2020.9.5",
"@typescript-eslint/eslint-plugin": "^5.38.1",
"esbuild": "^0.15.7",
"eslint": "^8.24.0",
@ -170,12 +172,6 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
"node_modules/@types/wicg-file-system-access": {
"version": "2020.9.5",
"resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz",
"integrity": "sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.38.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.38.1.tgz",
@ -1573,6 +1569,29 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"optional": true,
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -2289,6 +2308,27 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
},
"node_modules/native-file-system-adapter": {
"version": "3.0.0",
"resolved": "git+ssh://git@github.com/jimmywarting/native-file-system-adapter.git#d43ad841581c2cc3ce47bbd1e8f11950ebdff027",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=14.8.0"
},
"optionalDependencies": {
"fetch-blob": "^3.2.0"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -2301,6 +2341,25 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"optional": true,
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@ -3196,6 +3255,15 @@
"spdx-expression-parse": "^3.0.0"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
"integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
"optional": true,
"engines": {
"node": ">= 8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -3366,12 +3434,6 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
"@types/wicg-file-system-access": {
"version": "2020.9.5",
"resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz",
"integrity": "sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==",
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "5.38.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.38.1.tgz",
@ -4275,6 +4337,16 @@
"reusify": "^1.0.4"
}
},
"fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"optional": true,
"requires": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
}
},
"file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -4796,6 +4868,13 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
},
"native-file-system-adapter": {
"version": "git+ssh://git@github.com/jimmywarting/native-file-system-adapter.git#d43ad841581c2cc3ce47bbd1e8f11950ebdff027",
"from": "native-file-system-adapter@github:jimmywarting/native-file-system-adapter#d43ad841581c2cc3ce47bbd1e8f11950ebdff027",
"requires": {
"fetch-blob": "^3.2.0"
}
},
"natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -4808,6 +4887,12 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
},
"node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"optional": true
},
"normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@ -5446,6 +5531,12 @@
"spdx-expression-parse": "^3.0.0"
}
},
"web-streams-polyfill": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
"integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
"optional": true
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

4
src/Browser/Avalonia.Browser/webapp/package.json

@ -8,7 +8,6 @@
},
"devDependencies": {
"@types/emscripten": "^1.39.6",
"@types/wicg-file-system-access": "^2020.9.5",
"@typescript-eslint/eslint-plugin": "^5.38.1",
"esbuild": "^0.15.7",
"eslint": "^8.24.0",
@ -18,5 +17,8 @@
"eslint-plugin-promise": "^6.0.1",
"npm-run-all": "^4.1.5",
"typescript": "^4.8.3"
},
"dependencies": {
"native-file-system-adapter": "github:jimmywarting/native-file-system-adapter#d43ad841581c2cc3ce47bbd1e8f11950ebdff027"
}
}

6
src/Browser/Avalonia.Browser/webapp/tsconfig.json

@ -1,14 +1,16 @@
{
"compilerOptions": {
"target": "es2016",
"target": "es2018",
"module": "es2020",
"strict": true,
"sourceMap": true,
"noEmitOnError": true,
"moduleResolution": "node",
"skipLibCheck": true,
"isolatedModules": true, // we need it for esbuild
"lib": [
"dom",
"es2016",
"es2018",
"esnext.asynciterable"
]
},

Loading…
Cancel
Save