From 098f57ecad75c664382fb4b5aca3b8e4245ad333 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 19 Aug 2022 16:01:25 +0200 Subject: [PATCH 01/24] Detect method BuildAvaloniaApp in base class. --- .../Remote/RemoteDesignerEntryPoint.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs b/src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs index be2405efde..ca2c2c7fb2 100644 --- a/src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs +++ b/src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs @@ -168,8 +168,12 @@ namespace Avalonia.DesignerSupport.Remote var entryPoint = asm.EntryPoint; if (entryPoint == null) throw Die($"Assembly {args.AppPath} doesn't have an entry point"); - var builderMethod = entryPoint.DeclaringType.GetMethod(BuilderMethodName, - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, null, Array.Empty(), null); + var builderMethod = entryPoint.DeclaringType.GetMethod( + BuilderMethodName, + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy, + null, + Array.Empty(), + null); if (builderMethod == null) throw Die($"{entryPoint.DeclaringType.FullName} doesn't have a method named {BuilderMethodName}"); Design.IsDesignMode = true; From 6b70e56baa763bc4c41e15f4f990fdb663d8f231 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 15 Jul 2022 23:10:10 -0400 Subject: [PATCH 02/24] Use npm and typescript without msbuild tasks --- .gitignore | 2 + .../Avalonia.Web.Blazor.csproj | 33 +- .../Interop/Typescript/DpiWatcher.ts | 41 --- .../Interop/Typescript/InputHelper.ts | 23 -- .../Interop/Typescript/SKHtmlCanvas.ts | 261 -------------- .../Interop/Typescript/SizeWatcher.ts | 68 ---- .../Typescript/types/dotnet/extras.d.ts | 7 - .../Typescript/types/emscripten/index.d.ts | 326 ------------------ src/Web/Avalonia.Web.Blazor/tsconfig.json | 14 - .../webapp/modules/Common/Common.ts | 1 + .../webapp/modules/Common/DpiWatcher.ts | 40 +++ .../webapp/modules/Common/IndexedDbWrapper.ts | 79 +++++ .../webapp/modules/Common/InputHelper.ts | 22 ++ .../modules/Common}/NativeControlHost.ts | 35 +- .../webapp/modules/Common/SKHtmlCanvas.ts | 255 ++++++++++++++ .../webapp/modules/Common/SizeWatcher.ts | 67 ++++ .../modules/Storage}/StorageProvider.ts | 164 ++------- .../Avalonia.Web.Blazor/webapp/package.json | 14 + .../Avalonia.Web.Blazor/webapp/tsconfig.json | 18 + .../types/dotnet/index.d.ts | 0 .../webapp/webpack.config.js | 0 21 files changed, 566 insertions(+), 904 deletions(-) delete mode 100644 src/Web/Avalonia.Web.Blazor/Interop/Typescript/DpiWatcher.ts delete mode 100644 src/Web/Avalonia.Web.Blazor/Interop/Typescript/InputHelper.ts delete mode 100644 src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts delete mode 100644 src/Web/Avalonia.Web.Blazor/Interop/Typescript/SizeWatcher.ts delete mode 100644 src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/extras.d.ts delete mode 100644 src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/emscripten/index.d.ts delete mode 100644 src/Web/Avalonia.Web.Blazor/tsconfig.json create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/modules/Common/Common.ts create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/modules/Common/DpiWatcher.ts create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/modules/Common/IndexedDbWrapper.ts create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/modules/Common/InputHelper.ts rename src/Web/Avalonia.Web.Blazor/{Interop/Typescript => webapp/modules/Common}/NativeControlHost.ts (57%) create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SKHtmlCanvas.ts create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SizeWatcher.ts rename src/Web/Avalonia.Web.Blazor/{Interop/Typescript => webapp/modules/Storage}/StorageProvider.ts (52%) create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/package.json create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/tsconfig.json rename src/Web/Avalonia.Web.Blazor/{Interop/Typescript => webapp}/types/dotnet/index.d.ts (100%) create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/webpack.config.js diff --git a/.gitignore b/.gitignore index 44fe5e4ba4..0f0e153e9c 100644 --- a/.gitignore +++ b/.gitignore @@ -212,3 +212,5 @@ coc-settings.json *.map src/Web/Avalonia.Web.Blazor/wwwroot/*.js src/Web/Avalonia.Web.Blazor/Interop/Typescript/*.js +node_modules +src/Web/Avalonia.Web.Blazor/webapp/package-lock.json diff --git a/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj b/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj index 2d598687a9..3fcfaa1c9c 100644 --- a/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj +++ b/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj @@ -8,31 +8,16 @@ false true - + - - wwwroot - true - true - - - - false - true - - - true - false - - - + true @@ -47,10 +32,24 @@ + + + + + + + + + + + + + + diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/DpiWatcher.ts b/src/Web/Avalonia.Web.Blazor/Interop/Typescript/DpiWatcher.ts deleted file mode 100644 index 72baf14d8f..0000000000 --- a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/DpiWatcher.ts +++ /dev/null @@ -1,41 +0,0 @@ - -export class DpiWatcher { - static lastDpi: number; - static timerId: number; - static callback: DotNet.DotNetObjectReference; - - public static getDpi() { - return window.devicePixelRatio; - } - - public static start(callback: DotNet.DotNetObjectReference): number { - //console.info(`Starting DPI watcher with callback ${callback._id}...`); - - DpiWatcher.lastDpi = window.devicePixelRatio; - DpiWatcher.timerId = window.setInterval(DpiWatcher.update, 1000); - DpiWatcher.callback = callback; - - return DpiWatcher.lastDpi; - } - - public static stop() { - //console.info(`Stopping DPI watcher with callback ${DpiWatcher.callback._id}...`); - - window.clearInterval(DpiWatcher.timerId); - - DpiWatcher.callback = undefined; - } - - static update() { - if (!DpiWatcher.callback) - return; - - const currentDpi = window.devicePixelRatio; - const lastDpi = DpiWatcher.lastDpi; - DpiWatcher.lastDpi = currentDpi; - - if (Math.abs(lastDpi - currentDpi) > 0.001) { - DpiWatcher.callback.invokeMethod('Invoke', lastDpi, currentDpi); - } - } -} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/InputHelper.ts b/src/Web/Avalonia.Web.Blazor/Interop/Typescript/InputHelper.ts deleted file mode 100644 index a3bf9de31d..0000000000 --- a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/InputHelper.ts +++ /dev/null @@ -1,23 +0,0 @@ - -export class InputHelper { - public static clear (inputElement: HTMLInputElement){ - inputElement.value = ""; - } - - public static focus (inputElement: HTMLInputElement){ - inputElement.focus(); - inputElement.setSelectionRange(0, 0); - } - - public static setCursor (inputElement: HTMLInputElement, kind: string) { - inputElement.style.cursor = kind; - } - - public static hide (inputElement: HTMLInputElement){ - inputElement.style.display = 'none'; - } - - public static show (inputElement: HTMLInputElement){ - inputElement.style.display = 'block'; - } -} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts b/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts deleted file mode 100644 index 04d57a7756..0000000000 --- a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts +++ /dev/null @@ -1,261 +0,0 @@ -// aliases for emscripten -declare let GL: any; -declare let GLctx: WebGLRenderingContext; -declare let Module: EmscriptenModule; - -// container for gl info -type SKGLViewInfo = { - context: WebGLRenderingContext | WebGL2RenderingContext | undefined; - fboId: number; - stencil: number; - sample: number; - depth: number; -} - -// alias for a potential skia html canvas -type SKHtmlCanvasElement = { - SKHtmlCanvas: SKHtmlCanvas -} & HTMLCanvasElement - -export class SKHtmlCanvas { - static elements: Map; - - htmlCanvas: HTMLCanvasElement; - glInfo: SKGLViewInfo; - renderFrameCallback: DotNet.DotNetObjectReference; - renderLoopEnabled: boolean = false; - renderLoopRequest: number = 0; - newWidth: number; - newHeight: number; - - public static initGL(element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObjectReference): SKGLViewInfo { - var view = SKHtmlCanvas.init(true, element, elementId, callback); - if (!view || !view.glInfo) - return null; - - return view.glInfo; - } - - public static initRaster(element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObjectReference): boolean { - var view = SKHtmlCanvas.init(false, element, elementId, callback); - if (!view) - return false; - - return true; - } - - static init(useGL: boolean, element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObjectReference): SKHtmlCanvas { - var htmlCanvas = element as SKHtmlCanvasElement; - if (!htmlCanvas) { - console.error(`No canvas element was provided.`); - return null; - } - - if (!SKHtmlCanvas.elements) - SKHtmlCanvas.elements = new Map(); - SKHtmlCanvas.elements[elementId] = element; - - const view = new SKHtmlCanvas(useGL, element, callback); - - htmlCanvas.SKHtmlCanvas = view; - - return view; - } - - public static deinit(elementId: string) { - if (!elementId) - return; - - const element = SKHtmlCanvas.elements[elementId]; - SKHtmlCanvas.elements.delete(elementId); - - const htmlCanvas = element as SKHtmlCanvasElement; - if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) - return; - - htmlCanvas.SKHtmlCanvas.deinit(); - htmlCanvas.SKHtmlCanvas = undefined; - } - - public static requestAnimationFrame(element: HTMLCanvasElement, renderLoop?: boolean) { - const htmlCanvas = element as SKHtmlCanvasElement; - if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) - return; - - htmlCanvas.SKHtmlCanvas.requestAnimationFrame(renderLoop); - } - - public static setCanvasSize(element: HTMLCanvasElement, width: number, height: number) - { - const htmlCanvas = element as SKHtmlCanvasElement; - if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) - return; - - htmlCanvas.SKHtmlCanvas.setCanvasSize(width, height); - } - - public static setEnableRenderLoop(element: HTMLCanvasElement, enable: boolean) { - const htmlCanvas = element as SKHtmlCanvasElement; - if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) - return; - - htmlCanvas.SKHtmlCanvas.setEnableRenderLoop(enable); - } - - public static putImageData(element: HTMLCanvasElement, pData: number, width: number, height: number) { - const htmlCanvas = element as SKHtmlCanvasElement; - if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) - return; - - htmlCanvas.SKHtmlCanvas.putImageData(pData, width, height); - } - - public constructor(useGL: boolean, element: HTMLCanvasElement, callback: DotNet.DotNetObjectReference) { - this.htmlCanvas = element; - this.renderFrameCallback = callback; - - if (useGL) { - const ctx = SKHtmlCanvas.createWebGLContext(this.htmlCanvas); - if (!ctx) { - console.error(`Failed to create WebGL context: err ${ctx}`); - return null; - } - - // make current - GL.makeContextCurrent(ctx); - - // read values - const fbo = GLctx.getParameter(GLctx.FRAMEBUFFER_BINDING); - this.glInfo = { - context: ctx, - fboId: fbo ? fbo.id : 0, - stencil: GLctx.getParameter(GLctx.STENCIL_BITS), - sample: 0, // TODO: GLctx.getParameter(GLctx.SAMPLES) - depth: GLctx.getParameter(GLctx.DEPTH_BITS), - }; - } - } - - public deinit() { - this.setEnableRenderLoop(false); - } - - public setCanvasSize(width: number, height: number) - { - this.newWidth = width; - this.newHeight = height; - - if(this.htmlCanvas.width != this.newWidth) - { - this.htmlCanvas.width = this.newWidth; - } - - if(this.htmlCanvas.height != this.newHeight) - { - this.htmlCanvas.height = this.newHeight; - } - - if (this.glInfo) { - // make current - GL.makeContextCurrent(this.glInfo.context); - } - } - - public requestAnimationFrame(renderLoop?: boolean) { - // optionally update the render loop - if (renderLoop !== undefined && this.renderLoopEnabled !== renderLoop) - this.setEnableRenderLoop(renderLoop); - - // skip because we have a render loop - if (this.renderLoopRequest !== 0) - return; - - // add the draw to the next frame - this.renderLoopRequest = window.requestAnimationFrame(() => { - if (this.glInfo) { - // make current - GL.makeContextCurrent(this.glInfo.context); - } - - if(this.htmlCanvas.width != this.newWidth) - { - this.htmlCanvas.width = this.newWidth; - } - - if(this.htmlCanvas.height != this.newHeight) - { - this.htmlCanvas.height = this.newHeight; - } - - this.renderFrameCallback.invokeMethod('Invoke'); - this.renderLoopRequest = 0; - - // we may want to draw the next frame - if (this.renderLoopEnabled) - this.requestAnimationFrame(); - }); - } - - public setEnableRenderLoop(enable: boolean) { - this.renderLoopEnabled = enable; - - // either start the new frame or cancel the existing one - if (enable) { - //console.info(`Enabling render loop with callback ${this.renderFrameCallback._id}...`); - this.requestAnimationFrame(); - } else if (this.renderLoopRequest !== 0) { - window.cancelAnimationFrame(this.renderLoopRequest); - this.renderLoopRequest = 0; - } - } - - public putImageData(pData: number, width: number, height: number): boolean { - if (this.glInfo || !pData || width <= 0 || width <= 0) - return false; - - var ctx = this.htmlCanvas.getContext('2d'); - if (!ctx) { - console.error(`Failed to obtain 2D canvas context.`); - return false; - } - - // make sure the canvas is scaled correctly for the drawing - this.htmlCanvas.width = width; - this.htmlCanvas.height = height; - - // set the canvas to be the bytes - var buffer = new Uint8ClampedArray(Module.HEAPU8.buffer, pData, width * height * 4); - var imageData = new ImageData(buffer, width, height); - ctx.putImageData(imageData, 0, 0); - - return true; - } - - static createWebGLContext(htmlCanvas: HTMLCanvasElement): WebGLRenderingContext | WebGL2RenderingContext { - const contextAttributes = { - alpha: 1, - depth: 1, - stencil: 8, - antialias: 0, - premultipliedAlpha: 1, - preserveDrawingBuffer: 0, - preferLowPowerToHighPerformance: 0, - failIfMajorPerformanceCaveat: 0, - majorVersion: 2, - minorVersion: 0, - enableExtensionsByDefault: 1, - explicitSwapControl: 0, - renderViaOffscreenBackBuffer: 1, - }; - - let ctx: WebGLRenderingContext = GL.createContext(htmlCanvas, contextAttributes); - if (!ctx && contextAttributes.majorVersion > 1) { - console.warn('Falling back to WebGL 1.0'); - contextAttributes.majorVersion = 1; - contextAttributes.minorVersion = 0; - ctx = GL.createContext(htmlCanvas, contextAttributes); - } - - return ctx; - } -} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SizeWatcher.ts b/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SizeWatcher.ts deleted file mode 100644 index 88b94f3a80..0000000000 --- a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SizeWatcher.ts +++ /dev/null @@ -1,68 +0,0 @@ - -type SizeWatcherElement = { - SizeWatcher: SizeWatcherInstance; -} & HTMLElement - -type SizeWatcherInstance = { - callback: DotNet.DotNetObjectReference; -} - -export class SizeWatcher { - static observer: ResizeObserver; - static elements: Map; - - public static observe(element: HTMLElement, elementId: string, callback: DotNet.DotNetObjectReference) { - if (!element || !callback) - return; - - //console.info(`Adding size watcher observation with callback ${callback._id}...`); - - SizeWatcher.init(); - - const watcherElement = element as SizeWatcherElement; - watcherElement.SizeWatcher = { - callback: callback - }; - - SizeWatcher.elements[elementId] = element; - SizeWatcher.observer.observe(element); - - SizeWatcher.invoke(element); - } - - public static unobserve(elementId: string) { - if (!elementId || !SizeWatcher.observer) - return; - - //console.info('Removing size watcher observation...'); - - const element = SizeWatcher.elements[elementId]; - - SizeWatcher.elements.delete(elementId); - SizeWatcher.observer.unobserve(element); - } - - static init() { - if (SizeWatcher.observer) - return; - - //console.info('Starting size watcher...'); - - SizeWatcher.elements = new Map(); - SizeWatcher.observer = new ResizeObserver((entries) => { - for (let entry of entries) { - SizeWatcher.invoke(entry.target); - } - }); - } - - static invoke(element: Element) { - const watcherElement = element as SizeWatcherElement; - const instance = watcherElement.SizeWatcher; - - if (!instance || !instance.callback) - return; - - return instance.callback.invokeMethod('Invoke', element.clientWidth, element.clientHeight); - } -} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/extras.d.ts b/src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/extras.d.ts deleted file mode 100644 index 4a3d71e034..0000000000 --- a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/extras.d.ts +++ /dev/null @@ -1,7 +0,0 @@ - -declare namespace DotNet { - interface DotNetObjectReference extends DotNet.DotNetObject { - _id: number; - dispose(); - } -} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/emscripten/index.d.ts b/src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/emscripten/index.d.ts deleted file mode 100644 index e3829d4db1..0000000000 --- a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/emscripten/index.d.ts +++ /dev/null @@ -1,326 +0,0 @@ -// Type definitions for Emscripten 1.39.16 -// Project: https://emscripten.org -// Definitions by: Kensuke Matsuzaki -// Periklis Tsirakidis -// Bumsik Kim -// Louis DeScioli -// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped -// TypeScript Version: 2.2 - -/** Other WebAssembly declarations, for compatibility with older versions of Typescript */ -declare namespace WebAssembly { - interface Module {} -} - -declare namespace Emscripten { - interface FileSystemType {} - type EnvironmentType = 'WEB' | 'NODE' | 'SHELL' | 'WORKER'; - - type JSType = 'number' | 'string' | 'array' | 'boolean'; - type TypeCompatibleWithC = number | string | any[] | boolean; - - type CIntType = 'i8' | 'i16' | 'i32' | 'i64'; - type CFloatType = 'float' | 'double'; - type CPointerType = 'i8*' | 'i16*' | 'i32*' | 'i64*' | 'float*' | 'double*' | '*'; - type CType = CIntType | CFloatType | CPointerType; - - type WebAssemblyImports = Array<{ - name: string; - kind: string; - }>; - - type WebAssemblyExports = Array<{ - module: string; - name: string; - kind: string; - }>; - - interface CCallOpts { - async?: boolean | undefined; - } -} - -interface EmscriptenModule { - print(str: string): void; - printErr(str: string): void; - arguments: string[]; - environment: Emscripten.EnvironmentType; - preInit: Array<{ (): void }>; - preRun: Array<{ (): void }>; - postRun: Array<{ (): void }>; - onAbort: { (what: any): void }; - onRuntimeInitialized: { (): void }; - preinitializedWebGLContext: WebGLRenderingContext; - noInitialRun: boolean; - noExitRuntime: boolean; - logReadFiles: boolean; - filePackagePrefixURL: string; - wasmBinary: ArrayBuffer; - - destroy(object: object): void; - getPreloadedPackage(remotePackageName: string, remotePackageSize: number): ArrayBuffer; - instantiateWasm( - imports: Emscripten.WebAssemblyImports, - successCallback: (module: WebAssembly.Module) => void, - ): Emscripten.WebAssemblyExports; - locateFile(url: string, scriptDirectory: string): string; - onCustomMessage(event: MessageEvent): void; - - // USE_TYPED_ARRAYS == 1 - HEAP: Int32Array; - IHEAP: Int32Array; - FHEAP: Float64Array; - - // USE_TYPED_ARRAYS == 2 - HEAP8: Int8Array; - HEAP16: Int16Array; - HEAP32: Int32Array; - HEAPU8: Uint8Array; - HEAPU16: Uint16Array; - HEAPU32: Uint32Array; - HEAPF32: Float32Array; - HEAPF64: Float64Array; - - TOTAL_STACK: number; - TOTAL_MEMORY: number; - FAST_MEMORY: number; - - addOnPreRun(cb: () => any): void; - addOnInit(cb: () => any): void; - addOnPreMain(cb: () => any): void; - addOnExit(cb: () => any): void; - addOnPostRun(cb: () => any): void; - - preloadedImages: any; - preloadedAudios: any; - - _malloc(size: number): number; - _free(ptr: number): void; -} - -/** - * A factory function is generated when setting the `MODULARIZE` build option - * to `1` in your Emscripten build. It return a Promise that resolves to an - * initialized, ready-to-call `EmscriptenModule` instance. - * - * By default, the factory function will be named `Module`. It's recommended to - * use the `EXPORT_ES6` option, in which the factory function will be the - * default export. If used without `EXPORT_ES6`, the factory function will be a - * global variable. You can rename the variable using the `EXPORT_NAME` build - * option. It's left to you to declare any global variables as needed in your - * application's types. - * @param moduleOverrides Default properties for the initialized module. - */ -type EmscriptenModuleFactory = ( - moduleOverrides?: Partial, -) => Promise; - -declare namespace FS { - interface Lookup { - path: string; - node: FSNode; - } - - interface FSStream {} - interface FSNode {} - interface ErrnoError {} - - let ignorePermissions: boolean; - let trackingDelegate: any; - let tracking: any; - let genericErrors: any; - - // - // paths - // - function lookupPath(path: string, opts: any): Lookup; - function getPath(node: FSNode): string; - - // - // nodes - // - function isFile(mode: number): boolean; - function isDir(mode: number): boolean; - function isLink(mode: number): boolean; - function isChrdev(mode: number): boolean; - function isBlkdev(mode: number): boolean; - function isFIFO(mode: number): boolean; - function isSocket(mode: number): boolean; - - // - // devices - // - function major(dev: number): number; - function minor(dev: number): number; - function makedev(ma: number, mi: number): number; - function registerDevice(dev: number, ops: any): void; - - // - // core - // - function syncfs(populate: boolean, callback: (e: any) => any): void; - function syncfs(callback: (e: any) => any, populate?: boolean): void; - function mount(type: Emscripten.FileSystemType, opts: any, mountpoint: string): any; - function unmount(mountpoint: string): void; - - function mkdir(path: string, mode?: number): any; - function mkdev(path: string, mode?: number, dev?: number): any; - function symlink(oldpath: string, newpath: string): any; - function rename(old_path: string, new_path: string): void; - function rmdir(path: string): void; - function readdir(path: string): any; - function unlink(path: string): void; - function readlink(path: string): string; - function stat(path: string, dontFollow?: boolean): any; - function lstat(path: string): any; - function chmod(path: string, mode: number, dontFollow?: boolean): void; - function lchmod(path: string, mode: number): void; - function fchmod(fd: number, mode: number): void; - function chown(path: string, uid: number, gid: number, dontFollow?: boolean): void; - function lchown(path: string, uid: number, gid: number): void; - function fchown(fd: number, uid: number, gid: number): void; - function truncate(path: string, len: number): void; - function ftruncate(fd: number, len: number): void; - function utime(path: string, atime: number, mtime: number): void; - function open(path: string, flags: string, mode?: number, fd_start?: number, fd_end?: number): FSStream; - function close(stream: FSStream): void; - function llseek(stream: FSStream, offset: number, whence: number): any; - function read(stream: FSStream, buffer: ArrayBufferView, offset: number, length: number, position?: number): number; - function write( - stream: FSStream, - buffer: ArrayBufferView, - offset: number, - length: number, - position?: number, - canOwn?: boolean, - ): number; - function allocate(stream: FSStream, offset: number, length: number): void; - function mmap( - stream: FSStream, - buffer: ArrayBufferView, - offset: number, - length: number, - position: number, - prot: number, - flags: number, - ): any; - function ioctl(stream: FSStream, cmd: any, arg: any): any; - function readFile(path: string, opts: { encoding: 'binary'; flags?: string | undefined }): Uint8Array; - function readFile(path: string, opts: { encoding: 'utf8'; flags?: string | undefined }): string; - function readFile(path: string, opts?: { flags?: string | undefined }): Uint8Array; - function writeFile(path: string, data: string | ArrayBufferView, opts?: { flags?: string | undefined }): void; - - // - // module-level FS code - // - function cwd(): string; - function chdir(path: string): void; - function init( - input: null | (() => number | null), - output: null | ((c: number) => any), - error: null | ((c: number) => any), - ): void; - - function createLazyFile( - parent: string | FSNode, - name: string, - url: string, - canRead: boolean, - canWrite: boolean, - ): FSNode; - function createPreloadedFile( - parent: string | FSNode, - name: string, - url: string, - canRead: boolean, - canWrite: boolean, - onload?: () => void, - onerror?: () => void, - dontCreateFile?: boolean, - canOwn?: boolean, - ): void; - function createDataFile( - parent: string | FSNode, - name: string, - data: ArrayBufferView, - canRead: boolean, - canWrite: boolean, - canOwn: boolean, - ): FSNode; -} - -declare var MEMFS: Emscripten.FileSystemType; -declare var NODEFS: Emscripten.FileSystemType; -declare var IDBFS: Emscripten.FileSystemType; - -// Below runtime function/variable declarations are exportable by -// -s EXTRA_EXPORTED_RUNTIME_METHODS. You can extend or merge -// EmscriptenModule interface to add runtime functions. -// -// For example, by using -s "EXTRA_EXPORTED_RUNTIME_METHODS=['ccall']" -// You can access ccall() via Module["ccall"]. In this case, you should -// extend EmscriptenModule to pass the compiler check like the following: -// -// interface YourOwnEmscriptenModule extends EmscriptenModule { -// ccall: typeof ccall; -// } -// -// See: https://emscripten.org/docs/getting_started/FAQ.html#why-do-i-get-typeerror-module-something-is-not-a-function - -declare function ccall( - ident: string, - returnType: Emscripten.JSType | null, - argTypes: Emscripten.JSType[], - args: Emscripten.TypeCompatibleWithC[], - opts?: Emscripten.CCallOpts, -): any; -declare function cwrap( - ident: string, - returnType: Emscripten.JSType | null, - argTypes: Emscripten.JSType[], - opts?: Emscripten.CCallOpts, -): (...args: any[]) => any; - -declare function setValue(ptr: number, value: any, type: Emscripten.CType, noSafe?: boolean): void; -declare function getValue(ptr: number, type: Emscripten.CType, noSafe?: boolean): number; - -declare function allocate( - slab: number[] | ArrayBufferView | number, - types: Emscripten.CType | Emscripten.CType[], - allocator: number, - ptr?: number, -): number; - -declare function stackAlloc(size: number): number; -declare function stackSave(): number; -declare function stackRestore(ptr: number): void; - -declare function UTF8ToString(ptr: number, maxBytesToRead?: number): string; -declare function stringToUTF8(str: string, outPtr: number, maxBytesToRead?: number): void; -declare function lengthBytesUTF8(str: string): number; -declare function allocateUTF8(str: string): number; -declare function allocateUTF8OnStack(str: string): number; -declare function UTF16ToString(ptr: number): string; -declare function stringToUTF16(str: string, outPtr: number, maxBytesToRead?: number): void; -declare function lengthBytesUTF16(str: string): number; -declare function UTF32ToString(ptr: number): string; -declare function stringToUTF32(str: string, outPtr: number, maxBytesToRead?: number): void; -declare function lengthBytesUTF32(str: string): number; - -declare function intArrayFromString(stringy: string, dontAddNull?: boolean, length?: number): number[]; -declare function intArrayToString(array: number[]): string; -declare function writeStringToMemory(str: string, buffer: number, dontAddNull: boolean): void; -declare function writeArrayToMemory(array: number[], buffer: number): void; -declare function writeAsciiToMemory(str: string, buffer: number, dontAddNull: boolean): void; - -declare function addRunDependency(id: any): void; -declare function removeRunDependency(id: any): void; - -declare function addFunction(func: (...args: any[]) => any, signature?: string): number; -declare function removeFunction(funcPtr: number): void; - -declare var ALLOC_NORMAL: number; -declare var ALLOC_STACK: number; -declare var ALLOC_STATIC: number; -declare var ALLOC_DYNAMIC: number; -declare var ALLOC_NONE: number; diff --git a/src/Web/Avalonia.Web.Blazor/tsconfig.json b/src/Web/Avalonia.Web.Blazor/tsconfig.json deleted file mode 100644 index 71c462cd60..0000000000 --- a/src/Web/Avalonia.Web.Blazor/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "noImplicitAny": false, - "noEmitOnError": true, - "removeComments": false, - "sourceMap": true, - "target": "ES2020", - "module": "ES2020", - "outDir": "wwwroot" - }, - "exclude": [ - "node_modules" - ] -} diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/Common.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/Common.ts new file mode 100644 index 0000000000..5f282702bb --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/Common.ts @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/DpiWatcher.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/DpiWatcher.ts new file mode 100644 index 0000000000..06235782f8 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/DpiWatcher.ts @@ -0,0 +1,40 @@ +export class DpiWatcher { + static lastDpi: number; + static timerId: number; + static callback?: DotNet.DotNetObject; + + public static getDpi() { + return window.devicePixelRatio; + } + + public static start(callback: DotNet.DotNetObject): number { + //console.info(`Starting DPI watcher with callback ${callback._id}...`); + + DpiWatcher.lastDpi = window.devicePixelRatio; + DpiWatcher.timerId = window.setInterval(DpiWatcher.update, 1000); + DpiWatcher.callback = callback; + + return DpiWatcher.lastDpi; + } + + public static stop() { + //console.info(`Stopping DPI watcher with callback ${DpiWatcher.callback._id}...`); + + window.clearInterval(DpiWatcher.timerId); + + DpiWatcher.callback = undefined; + } + + static update() { + if (!DpiWatcher.callback) + return; + + const currentDpi = window.devicePixelRatio; + const lastDpi = DpiWatcher.lastDpi; + DpiWatcher.lastDpi = currentDpi; + + if (Math.abs(lastDpi - currentDpi) > 0.001) { + DpiWatcher.callback.invokeMethod('Invoke', lastDpi, currentDpi); + } + } +} diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/IndexedDbWrapper.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/IndexedDbWrapper.ts new file mode 100644 index 0000000000..2eaa8de2fe --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/IndexedDbWrapper.ts @@ -0,0 +1,79 @@ +class InnerDbConnection { + constructor(private database: IDBDatabase) { } + + private openStore(store: string, mode: IDBTransactionMode): IDBObjectStore { + const tx = this.database.transaction(store, mode); + return tx.objectStore(store); + } + + public put(store: string, obj: any, key?: IDBValidKey): Promise { + const os = this.openStore(store, "readwrite"); + + return new Promise((resolve, reject) => { + const response = os.put(obj, key); + response.onsuccess = () => { + resolve(response.result); + }; + response.onerror = () => { + reject(response.error); + }; + }); + } + + public get(store: string, key: IDBValidKey): any { + const os = this.openStore(store, "readonly"); + + return new Promise((resolve, reject) => { + const response = os.get(key); + response.onsuccess = () => { + resolve(response.result); + }; + response.onerror = () => { + reject(response.error); + }; + }); + } + + public delete(store: string, key: IDBValidKey): Promise { + const os = this.openStore(store, "readwrite"); + + return new Promise((resolve, reject) => { + const response = os.delete(key); + response.onsuccess = () => { + resolve(); + }; + response.onerror = () => { + reject(response.error); + }; + }); + } + + public close() { + this.database.close(); + } +} + +export class IndexedDbWrapper { + constructor(private databaseName: string, private objectStores: [string]) { + } + + public connect(): Promise { + const conn = window.indexedDB.open(this.databaseName, 1); + + conn.onupgradeneeded = event => { + const db = (>event.target).result; + this.objectStores.forEach(store => { + db.createObjectStore(store); + }); + }; + + return new Promise((resolve, reject) => { + conn.onsuccess = event => { + resolve(new InnerDbConnection((>event.target).result)); + }; + conn.onerror = event => { + reject((>event.target).error); + }; + }); + } +} diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/InputHelper.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/InputHelper.ts new file mode 100644 index 0000000000..2cce411376 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/InputHelper.ts @@ -0,0 +1,22 @@ +export class InputHelper { + public static clear(inputElement: HTMLInputElement) { + inputElement.value = ""; + } + + public static focus(inputElement: HTMLInputElement) { + inputElement.focus(); + inputElement.setSelectionRange(0, 0); + } + + public static setCursor(inputElement: HTMLInputElement, kind: string) { + inputElement.style.cursor = kind; + } + + public static hide(inputElement: HTMLInputElement) { + inputElement.style.display = 'none'; + } + + public static show(inputElement: HTMLInputElement) { + inputElement.style.display = 'block'; + } +} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/NativeControlHost.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/NativeControlHost.ts similarity index 57% rename from src/Web/Avalonia.Web.Blazor/Interop/Typescript/NativeControlHost.ts rename to src/Web/Avalonia.Web.Blazor/webapp/modules/Common/NativeControlHost.ts index baa9191845..9e5c3843c8 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/NativeControlHost.ts +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/NativeControlHost.ts @@ -14,10 +14,9 @@ } } -class NativeControlHostTopLevelAttachment -{ - _child: HTMLElement; - _host: HTMLElement; +class NativeControlHostTopLevelAttachment { + _child?: HTMLElement; + _host?: HTMLElement; InitializeWithChildHandle(child: HTMLElement) { this._child = child; @@ -25,32 +24,38 @@ class NativeControlHostTopLevelAttachment } AttachTo(host: HTMLElement): void { - if (this._host) { + if (this._host && this._child) { this._host.removeChild(this._child); } this._host = host; - if (this._host) { + if (this._host && this._child) { this._host.appendChild(this._child); } } ShowInBounds(x: number, y: number, width: number, height: number): void { - this._child.style.top = y + "px"; - this._child.style.left = x + "px"; - this._child.style.width = width + "px"; - this._child.style.height = height + "px"; - this._child.style.display = "block"; + if (this._child) { + this._child.style.top = y + "px"; + this._child.style.left = x + "px"; + this._child.style.width = width + "px"; + this._child.style.height = height + "px"; + this._child.style.display = "block"; + } } HideWithSize(width: number, height: number): void { - this._child.style.width = width + "px"; - this._child.style.height = height + "px"; - this._child.style.display = "none"; + if (this._child) { + this._child.style.width = width + "px"; + this._child.style.height = height + "px"; + this._child.style.display = "none"; + } } ReleaseChild(): void { - this._child = null; + if (this._child) { + this._child = undefined; + } } } diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SKHtmlCanvas.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SKHtmlCanvas.ts new file mode 100644 index 0000000000..e934f74807 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SKHtmlCanvas.ts @@ -0,0 +1,255 @@ +// aliases for emscripten +declare let GL: any; +declare let GLctx: WebGLRenderingContext; +declare let Module: EmscriptenModule; + +// container for gl info +type SKGLViewInfo = { + context: WebGLRenderingContext | WebGL2RenderingContext | undefined; + fboId: number; + stencil: number; + sample: number; + depth: number; +} + +// alias for a potential skia html canvas +type SKHtmlCanvasElement = { + SKHtmlCanvas: SKHtmlCanvas | undefined +} & HTMLCanvasElement + +export class SKHtmlCanvas { + static elements: Map; + + htmlCanvas: HTMLCanvasElement; + glInfo?: SKGLViewInfo; + renderFrameCallback: DotNet.DotNetObject; + renderLoopEnabled: boolean = false; + renderLoopRequest: number = 0; + newWidth?: number; + newHeight?: number; + + public static initGL(element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObject): SKGLViewInfo | null { + var view = SKHtmlCanvas.init(true, element, elementId, callback); + if (!view || !view.glInfo) + return null; + + return view.glInfo; + } + + public static initRaster(element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObject): boolean { + var view = SKHtmlCanvas.init(false, element, elementId, callback); + if (!view) + return false; + + return true; + } + + static init(useGL: boolean, element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObject): SKHtmlCanvas | null { + var htmlCanvas = element as SKHtmlCanvasElement; + if (!htmlCanvas) { + console.error(`No canvas element was provided.`); + return null; + } + + if (!SKHtmlCanvas.elements) + SKHtmlCanvas.elements = new Map(); + SKHtmlCanvas.elements.set(elementId, element); + + const view = new SKHtmlCanvas(useGL, element, callback); + + htmlCanvas.SKHtmlCanvas = view; + + return view; + } + + public static deinit(elementId: string) { + if (!elementId) + return; + + const element = SKHtmlCanvas.elements.get(elementId); + SKHtmlCanvas.elements.delete(elementId); + + const htmlCanvas = element as SKHtmlCanvasElement; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + + htmlCanvas.SKHtmlCanvas.deinit(); + htmlCanvas.SKHtmlCanvas = undefined; + } + + public static requestAnimationFrame(element: HTMLCanvasElement, renderLoop?: boolean) { + const htmlCanvas = element as SKHtmlCanvasElement; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + + htmlCanvas.SKHtmlCanvas.requestAnimationFrame(renderLoop); + } + + public static setCanvasSize(element: HTMLCanvasElement, width: number, height: number) { + const htmlCanvas = element as SKHtmlCanvasElement; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + + htmlCanvas.SKHtmlCanvas.setCanvasSize(width, height); + } + + public static setEnableRenderLoop(element: HTMLCanvasElement, enable: boolean) { + const htmlCanvas = element as SKHtmlCanvasElement; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + + htmlCanvas.SKHtmlCanvas.setEnableRenderLoop(enable); + } + + public static putImageData(element: HTMLCanvasElement, pData: number, width: number, height: number) { + const htmlCanvas = element as SKHtmlCanvasElement; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + + htmlCanvas.SKHtmlCanvas.putImageData(pData, width, height); + } + + public constructor(useGL: boolean, element: HTMLCanvasElement, callback: DotNet.DotNetObject) { + this.htmlCanvas = element; + this.renderFrameCallback = callback; + + if (useGL) { + const ctx = SKHtmlCanvas.createWebGLContext(this.htmlCanvas); + if (!ctx) { + console.error(`Failed to create WebGL context: err ${ctx}`); + return; + } + + // make current + GL.makeContextCurrent(ctx); + + // read values + const fbo = GLctx.getParameter(GLctx.FRAMEBUFFER_BINDING); + this.glInfo = { + context: ctx, + fboId: fbo ? fbo.id : 0, + stencil: GLctx.getParameter(GLctx.STENCIL_BITS), + sample: 0, // TODO: GLctx.getParameter(GLctx.SAMPLES) + depth: GLctx.getParameter(GLctx.DEPTH_BITS), + }; + } + } + + public deinit() { + this.setEnableRenderLoop(false); + } + + public setCanvasSize(width: number, height: number) { + this.newWidth = width; + this.newHeight = height; + + if (this.htmlCanvas.width != this.newWidth) { + this.htmlCanvas.width = this.newWidth; + } + + if (this.htmlCanvas.height != this.newHeight) { + this.htmlCanvas.height = this.newHeight; + } + + if (this.glInfo) { + // make current + GL.makeContextCurrent(this.glInfo.context); + } + } + + public requestAnimationFrame(renderLoop?: boolean) { + // optionally update the render loop + if (renderLoop !== undefined && this.renderLoopEnabled !== renderLoop) + this.setEnableRenderLoop(renderLoop); + + // skip because we have a render loop + if (this.renderLoopRequest !== 0) + return; + + // add the draw to the next frame + this.renderLoopRequest = window.requestAnimationFrame(() => { + if (this.glInfo) { + // make current + GL.makeContextCurrent(this.glInfo.context); + } + + if (this.htmlCanvas.width != this.newWidth) { + this.htmlCanvas.width = this.newWidth || 0; + } + + if (this.htmlCanvas.height != this.newHeight) { + this.htmlCanvas.height = this.newHeight || 0; + } + + this.renderFrameCallback.invokeMethod('Invoke'); + this.renderLoopRequest = 0; + + // we may want to draw the next frame + if (this.renderLoopEnabled) + this.requestAnimationFrame(); + }); + } + + public setEnableRenderLoop(enable: boolean) { + this.renderLoopEnabled = enable; + + // either start the new frame or cancel the existing one + if (enable) { + //console.info(`Enabling render loop with callback ${this.renderFrameCallback._id}...`); + this.requestAnimationFrame(); + } else if (this.renderLoopRequest !== 0) { + window.cancelAnimationFrame(this.renderLoopRequest); + this.renderLoopRequest = 0; + } + } + + public putImageData(pData: number, width: number, height: number): boolean { + if (this.glInfo || !pData || width <= 0 || width <= 0) + return false; + + var ctx = this.htmlCanvas.getContext('2d'); + if (!ctx) { + console.error(`Failed to obtain 2D canvas context.`); + return false; + } + + // make sure the canvas is scaled correctly for the drawing + this.htmlCanvas.width = width; + this.htmlCanvas.height = height; + + // set the canvas to be the bytes + var buffer = new Uint8ClampedArray(Module.HEAPU8.buffer, pData, width * height * 4); + var imageData = new ImageData(buffer, width, height); + ctx.putImageData(imageData, 0, 0); + + return true; + } + + static createWebGLContext(htmlCanvas: HTMLCanvasElement): WebGLRenderingContext | WebGL2RenderingContext { + const contextAttributes = { + alpha: 1, + depth: 1, + stencil: 8, + antialias: 0, + premultipliedAlpha: 1, + preserveDrawingBuffer: 0, + preferLowPowerToHighPerformance: 0, + failIfMajorPerformanceCaveat: 0, + majorVersion: 2, + minorVersion: 0, + enableExtensionsByDefault: 1, + explicitSwapControl: 0, + renderViaOffscreenBackBuffer: 1, + }; + + let ctx: WebGLRenderingContext = GL.createContext(htmlCanvas, contextAttributes); + if (!ctx && contextAttributes.majorVersion > 1) { + console.warn('Falling back to WebGL 1.0'); + contextAttributes.majorVersion = 1; + contextAttributes.minorVersion = 0; + ctx = GL.createContext(htmlCanvas, contextAttributes); + } + + return ctx; + } +} diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SizeWatcher.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SizeWatcher.ts new file mode 100644 index 0000000000..715b252988 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SizeWatcher.ts @@ -0,0 +1,67 @@ +type SizeWatcherElement = { + SizeWatcher: SizeWatcherInstance; +} & HTMLElement + +type SizeWatcherInstance = { + callback: DotNet.DotNetObject; +} + +export class SizeWatcher { + static observer: ResizeObserver; + static elements: Map; + + public static observe(element: HTMLElement, elementId: string, callback: DotNet.DotNetObject) { + if (!element || !callback) + return; + + //console.info(`Adding size watcher observation with callback ${callback._id}...`); + + SizeWatcher.init(); + + const watcherElement = element as SizeWatcherElement; + watcherElement.SizeWatcher = { + callback: callback + }; + + SizeWatcher.elements.set(elementId, element); + SizeWatcher.observer.observe(element); + + SizeWatcher.invoke(element); + } + + public static unobserve(elementId: string) { + if (!elementId || !SizeWatcher.observer) + return; + + //console.info('Removing size watcher observation...'); + + const element = SizeWatcher.elements.get(elementId)!; + + SizeWatcher.elements.delete(elementId); + SizeWatcher.observer.unobserve(element); + } + + static init() { + if (SizeWatcher.observer) + return; + + //console.info('Starting size watcher...'); + + SizeWatcher.elements = new Map(); + SizeWatcher.observer = new ResizeObserver((entries) => { + for (let entry of entries) { + SizeWatcher.invoke(entry.target); + } + }); + } + + static invoke(element: Element) { + const watcherElement = element as SizeWatcherElement; + const instance = watcherElement.SizeWatcher; + + if (!instance || !instance.callback) + return; + + return instance.callback.invokeMethod('Invoke', element.clientWidth, element.clientHeight); + } +} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/StorageProvider.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Storage/StorageProvider.ts similarity index 52% rename from src/Web/Avalonia.Web.Blazor/Interop/Typescript/StorageProvider.ts rename to src/Web/Avalonia.Web.Blazor/webapp/modules/Storage/StorageProvider.ts index 1ee055eff9..65b8fa908e 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/StorageProvider.ts +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Storage/StorageProvider.ts @@ -1,132 +1,14 @@ -// As we don't have proper package managing for Avalonia.Web project, declare types manually -declare global { - interface FileSystemWritableFileStream { - write(position: number, data: BufferSource | Blob | string): Promise; - truncate(size: number): Promise; - close(): Promise; - } - type PermissionsMode = "read" | "readwrite"; - interface FileSystemFileHandle { - name: string, - getFile(): Promise; - createWritable(options?: { keepExistingData?: boolean }): Promise; - - queryPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">; - requestPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">; +import { IndexedDbWrapper } from "./IndexedDbWrapper"; - entries(): AsyncIterableIterator<[string, FileSystemFileHandle]>; - } - type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos"; - type StartInDirectory = WellKnownDirectory | FileSystemFileHandle; - interface FilePickerAcceptType { - description: string, - // mime -> ext[] array - accept: { [mime: string]: string | string[] } - } - interface FilePickerOptions { - types?: FilePickerAcceptType[], - excludeAcceptAllOption: boolean, - id?: string, +declare global { + type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos"; + type StartInDirectory = WellKnownDirectory | FileSystemFileHandle; + interface OpenFilePickerOptions { startIn?: StartInDirectory } - interface OpenFilePickerOptions extends FilePickerOptions { - multiple: boolean - } - interface SaveFilePickerOptions extends FilePickerOptions { - suggestedName?: string - } - interface DirectoryPickerOptions { - id?: string, + interface SaveFilePickerOptions { startIn?: StartInDirectory } - - interface Window { - showOpenFilePicker: (options: OpenFilePickerOptions) => Promise; - showSaveFilePicker: (options: SaveFilePickerOptions) => Promise; - showDirectoryPicker: (options: DirectoryPickerOptions) => Promise; - } -} - -// TODO move to another file and use import -class IndexedDbWrapper { - constructor(private databaseName: string, private objectStores: [ string ]) { - - } - - public connect(): Promise { - const conn = window.indexedDB.open(this.databaseName, 1); - - conn.onupgradeneeded = event => { - const db = (>event.target).result; - this.objectStores.forEach(store => { - db.createObjectStore(store); - }); - } - - return new Promise((resolve, reject) => { - conn.onsuccess = event => { - resolve(new InnerDbConnection((>event.target).result)); - } - conn.onerror = event => { - reject((>event.target).error); - } - }); - } -} - -class InnerDbConnection { - constructor(private database: IDBDatabase) { } - - private openStore(store: string, mode: IDBTransactionMode): IDBObjectStore { - const tx = this.database.transaction(store, mode); - return tx.objectStore(store); - } - - public put(store: string, obj: any, key?: IDBValidKey): Promise { - const os = this.openStore(store, "readwrite"); - - return new Promise((resolve, reject) => { - const response = os.put(obj, key); - response.onsuccess = () => { - resolve(response.result); - }; - response.onerror = () => { - reject(response.error); - }; - }); - } - - public get(store: string, key: IDBValidKey): any { - const os = this.openStore(store, "readonly"); - - return new Promise((resolve, reject) => { - const response = os.get(key); - response.onsuccess = () => { - resolve(response.result); - }; - response.onerror = () => { - reject(response.error); - }; - }); - } - - public delete(store: string, key: IDBValidKey): Promise { - const os = this.openStore(store, "readwrite"); - - return new Promise((resolve, reject) => { - const response = os.delete(key); - response.onsuccess = () => { - resolve(); - }; - response.onerror = () => { - reject(response.error); - }; - }); - } - - public close() { - this.database.close(); - } } const fileBookmarksStore: string = "fileBookmarks"; @@ -135,7 +17,7 @@ const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [ ]) class StorageItem { - constructor(public handle: FileSystemFileHandle, private bookmarkId?: string) { } + constructor(private handle: FileSystemHandle, private bookmarkId?: string) { } public getName(): string { return this.handle.name @@ -146,21 +28,35 @@ class StorageItem { } public async openRead(): Promise { + if (!(this.handle instanceof FileSystemFileHandle)) { + throw new Error("StorageItem is not a file"); + } + await this.verityPermissions('read'); - return await this.handle.getFile(); + const file = await this.handle.getFile(); + return file; } public async openWrite(): Promise { + if (!(this.handle instanceof FileSystemFileHandle)) { + throw new Error("StorageItem is not a file"); + } + await this.verityPermissions('readwrite'); return await this.handle.createWritable({ keepExistingData: true }); } - public async getProperties(): Promise<{ Size: number, LastModified: number, Type: string }> { - const file = this.handle.getFile && await this.handle.getFile(); - - return file && { + public async getProperties(): Promise<{ Size: number, LastModified: number, Type: string } | null> { + const file = this.handle instanceof FileSystemFileHandle + && await this.handle.getFile(); + + if (!file) { + return null; + } + + return { Size: file.size, LastModified: file.lastModified, Type: file.type @@ -168,6 +64,10 @@ class StorageItem { } public async getItems(): Promise { + if (this.handle.kind !== "directory"){ + return new StorageItems([]); + } + const items: StorageItem[] = []; for await (const [key, value] of this.handle.entries()) { items.push(new StorageItem(value)); @@ -175,7 +75,7 @@ class StorageItem { return new StorageItems(items); } - private async verityPermissions(mode: PermissionsMode): Promise { + private async verityPermissions(mode: FileSystemPermissionMode): Promise { if (await this.handle.queryPermission({ mode }) === 'granted') { return; } @@ -200,7 +100,7 @@ class StorageItem { connection.close(); } } - + public async deleteBookmark(): Promise { if (!this.bookmarkId) { return; diff --git a/src/Web/Avalonia.Web.Blazor/webapp/package.json b/src/Web/Avalonia.Web.Blazor/webapp/package.json new file mode 100644 index 0000000000..130e3032b4 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/package.json @@ -0,0 +1,14 @@ +{ + "name": "avalonia.web", + "scripts": { + "build": "tsc --build", + "clean": "tsc --build --clean" + }, + "devDependencies": { + "@types/emscripten": "^1.39.6", + "@types/wicg-file-system-access": "^2020.9.5", + "typescript": "^4.7.4", + "webpack": "^5.73.0", + "webpack-cli": "^4.10.0" + } +} diff --git a/src/Web/Avalonia.Web.Blazor/webapp/tsconfig.json b/src/Web/Avalonia.Web.Blazor/webapp/tsconfig.json new file mode 100644 index 0000000000..9a18b259c0 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES6", + "strict": true, + "sourceMap": true, + "outDir": "../wwwroot", + "removeComments": true, + "noEmitOnError": true, + "lib": [ + "dom", + "ES6", + "esnext.asynciterable" + ] + }, + "exclude": [ + "node_modules" + ] +} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/index.d.ts b/src/Web/Avalonia.Web.Blazor/webapp/types/dotnet/index.d.ts similarity index 100% rename from src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/index.d.ts rename to src/Web/Avalonia.Web.Blazor/webapp/types/dotnet/index.d.ts diff --git a/src/Web/Avalonia.Web.Blazor/webapp/webpack.config.js b/src/Web/Avalonia.Web.Blazor/webapp/webpack.config.js new file mode 100644 index 0000000000..e69de29bb2 From c8a2e65120130669043d9251889bad2a7deb5b93 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 24 Aug 2022 14:55:01 +0200 Subject: [PATCH 03/24] Use webpack modules --- .../Avalonia.Web.Blazor/AvaloniaView.razor.cs | 15 +- .../Interop/AvaloniaModule.cs | 18 ++ .../Interop/DpiWatcherInterop.cs | 41 ++-- .../Interop/InputHelperInterop.cs | 31 +-- .../Interop/JSModuleInterop.cs | 12 +- .../Interop/NativeControlHostImpl.cs | 32 ++- .../Interop/SKHtmlCanvasInterop.cs | 45 ++-- .../Interop/SizeWatcherInterop.cs | 41 ++-- .../Interop/Storage/StorageProviderInterop.cs | 2 +- .../webapp/modules/Avalonia/Avalonia.ts | 5 + .../{Common => Avalonia}/DpiWatcher.ts | 0 .../{Common => Avalonia}/InputHelper.ts | 0 .../{Common => Avalonia}/NativeControlHost.ts | 0 .../{Common => Avalonia}/SKHtmlCanvas.ts | 0 .../{Common => Avalonia}/SizeWatcher.ts | 0 .../webapp/modules/Common/Common.ts | 1 - .../{Common => Storage}/IndexedDbWrapper.ts | 0 .../webapp/modules/Storage/StorageProvider.ts | 12 +- .../Avalonia.Web.Blazor/webapp/package.json | 6 +- .../webapp/webpack.config.js | 40 ++++ .../wwwroot/Avalonia/Avalonia.js | 6 + .../wwwroot/Avalonia/DpiWatcher.js | 26 +++ .../wwwroot/Avalonia/InputHelper.js | 19 ++ .../wwwroot/Avalonia/NativeControlHost.js | 48 +++++ .../wwwroot/Avalonia/SKHtmlCanvas.js | 172 +++++++++++++++ .../wwwroot/Avalonia/SizeWatcher.js | 39 ++++ .../wwwroot/Storage/IndexedDbWrapper.js | 72 +++++++ .../wwwroot/Storage/StorageProvider.js | 199 ++++++++++++++++++ 28 files changed, 739 insertions(+), 143 deletions(-) create mode 100644 src/Web/Avalonia.Web.Blazor/Interop/AvaloniaModule.cs create mode 100644 src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/Avalonia.ts rename src/Web/Avalonia.Web.Blazor/webapp/modules/{Common => Avalonia}/DpiWatcher.ts (100%) rename src/Web/Avalonia.Web.Blazor/webapp/modules/{Common => Avalonia}/InputHelper.ts (100%) rename src/Web/Avalonia.Web.Blazor/webapp/modules/{Common => Avalonia}/NativeControlHost.ts (100%) rename src/Web/Avalonia.Web.Blazor/webapp/modules/{Common => Avalonia}/SKHtmlCanvas.ts (100%) rename src/Web/Avalonia.Web.Blazor/webapp/modules/{Common => Avalonia}/SizeWatcher.ts (100%) delete mode 100644 src/Web/Avalonia.Web.Blazor/webapp/modules/Common/Common.ts rename src/Web/Avalonia.Web.Blazor/webapp/modules/{Common => Storage}/IndexedDbWrapper.ts (100%) create mode 100644 src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/Avalonia.js create mode 100644 src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/DpiWatcher.js create mode 100644 src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/InputHelper.js create mode 100644 src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/NativeControlHost.js create mode 100644 src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/SKHtmlCanvas.js create mode 100644 src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/SizeWatcher.js create mode 100644 src/Web/Avalonia.Web.Blazor/wwwroot/Storage/IndexedDbWrapper.js create mode 100644 src/Web/Avalonia.Web.Blazor/wwwroot/Storage/StorageProvider.js diff --git a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs index 07db6013c7..d4081ca043 100644 --- a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs +++ b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs @@ -28,6 +28,7 @@ namespace Avalonia.Web.Blazor private SizeWatcherInterop? _sizeWatcher = null; private DpiWatcherInterop? _dpiWatcher = null; private SKHtmlCanvasInterop.GLInfo? _jsGlInfo = null; + private AvaloniaModule? _avaloniaModule = null; private InputHelperInterop? _inputHelper = null; private InputHelperInterop? _canvasHelper = null; private NativeControlHostInterop? _nativeControlHost = null; @@ -241,8 +242,10 @@ namespace Avalonia.Web.Blazor { AvaloniaLocator.CurrentMutable.Bind().ToConstant((IJSInProcessRuntime)Js); - _inputHelper = await InputHelperInterop.ImportAsync(Js, _inputElement); - _canvasHelper = await InputHelperInterop.ImportAsync(Js, _htmlCanvas); + _avaloniaModule = await AvaloniaModule.ImportAsync(Js); + + _inputHelper = new InputHelperInterop(_avaloniaModule, _inputElement); + _canvasHelper = new InputHelperInterop(_avaloniaModule, _htmlCanvas); _inputHelper.Hide(); _canvasHelper.SetCursor("default"); @@ -252,11 +255,11 @@ namespace Avalonia.Web.Blazor _canvasHelper.SetCursor(x); //windows }; - _nativeControlHost = await NativeControlHostInterop.ImportAsync(Js, _nativeControlsContainer); + _nativeControlHost = new NativeControlHostInterop(_avaloniaModule, _nativeControlsContainer); _storageProvider = await StorageProviderInterop.ImportAsync(Js); Console.WriteLine("starting html canvas setup"); - _interop = await SKHtmlCanvasInterop.ImportAsync(Js, _htmlCanvas, OnRenderFrame); + _interop = new SKHtmlCanvasInterop(_avaloniaModule, _htmlCanvas, OnRenderFrame); Console.WriteLine("Interop created"); @@ -306,8 +309,8 @@ namespace Avalonia.Web.Blazor { _interop.RequestAnimationFrame(true); - _sizeWatcher = await SizeWatcherInterop.ImportAsync(Js, _htmlCanvas, OnSizeChanged); - _dpiWatcher = await DpiWatcherInterop.ImportAsync(Js, OnDpiChanged); + _sizeWatcher = new SizeWatcherInterop(_avaloniaModule, _htmlCanvas, OnSizeChanged); + _dpiWatcher = new DpiWatcherInterop(_avaloniaModule, OnDpiChanged); _topLevel.Prepare(); diff --git a/src/Web/Avalonia.Web.Blazor/Interop/AvaloniaModule.cs b/src/Web/Avalonia.Web.Blazor/Interop/AvaloniaModule.cs new file mode 100644 index 0000000000..7aac879fa6 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/Interop/AvaloniaModule.cs @@ -0,0 +1,18 @@ +using Microsoft.JSInterop; + +namespace Avalonia.Web.Blazor.Interop +{ + internal class AvaloniaModule : JSModuleInterop + { + private AvaloniaModule(IJSRuntime js) : base(js, "./_content/Avalonia.Web.Blazor/avalonia.js") + { + } + + public static async Task ImportAsync(IJSRuntime js) + { + var interop = new AvaloniaModule(js); + await interop.ImportAsync(); + return interop; + } + } +} diff --git a/src/Web/Avalonia.Web.Blazor/Interop/DpiWatcherInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/DpiWatcherInterop.cs index 29a2686b3f..2c39cb8d00 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/DpiWatcherInterop.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/DpiWatcherInterop.cs @@ -1,43 +1,29 @@ -using System; -using System.Threading.Tasks; using Microsoft.JSInterop; namespace Avalonia.Web.Blazor.Interop { - internal class DpiWatcherInterop : JSModuleInterop + internal class DpiWatcherInterop : IDisposable { - private const string JsFilename = "./_content/Avalonia.Web.Blazor/DpiWatcher.js"; private const string StartSymbol = "DpiWatcher.start"; private const string StopSymbol = "DpiWatcher.stop"; private const string GetDpiSymbol = "DpiWatcher.getDpi"; - private static DpiWatcherInterop? instance; - private event Action? callbacksEvent; - private readonly FloatFloatActionHelper callbackHelper; + private readonly FloatFloatActionHelper _callbackHelper; + private readonly AvaloniaModule _module; private DotNetObjectReference? callbackReference; - public static async Task ImportAsync(IJSRuntime js, Action? callback = null) + public DpiWatcherInterop(AvaloniaModule module, Action? callback = null) { - var interop = Get(js); - await interop.ImportAsync(); - if (callback != null) - interop.Subscribe(callback); - return interop; - } + _module = module; + _callbackHelper = new FloatFloatActionHelper((o, n) => callbacksEvent?.Invoke(n)); - public static DpiWatcherInterop Get(IJSRuntime js) => - instance ??= new DpiWatcherInterop(js); - - private DpiWatcherInterop(IJSRuntime js) - : base(js, JsFilename) - { - callbackHelper = new FloatFloatActionHelper((o, n) => callbacksEvent?.Invoke(n)); + if (callback != null) + Subscribe(callback); } - protected override void OnDisposingModule() => - Stop(); + public void Dispose() => Stop(); public void Subscribe(Action callback) { @@ -65,9 +51,9 @@ namespace Avalonia.Web.Blazor.Interop if (callbackReference != null) return GetDpi(); - callbackReference = DotNetObjectReference.Create(callbackHelper); + callbackReference = DotNetObjectReference.Create(_callbackHelper); - return Invoke(StartSymbol, callbackReference); + return _module.Invoke(StartSymbol, callbackReference); } private void Stop() @@ -75,13 +61,12 @@ namespace Avalonia.Web.Blazor.Interop if (callbackReference == null) return; - Invoke(StopSymbol); + _module.Invoke(StopSymbol); callbackReference?.Dispose(); callbackReference = null; } - public double GetDpi() => - Invoke(GetDpiSymbol); + public double GetDpi() => _module.Invoke(GetDpiSymbol); } } diff --git a/src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs index 6d71e0c48f..294e71eb1f 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs @@ -1,41 +1,32 @@ using Microsoft.AspNetCore.Components; -using Microsoft.JSInterop; -using SkiaSharp; namespace Avalonia.Web.Blazor.Interop { - internal class InputHelperInterop : JSModuleInterop + internal class InputHelperInterop { - private const string JsFilename = "./_content/Avalonia.Web.Blazor/InputHelper.js"; private const string ClearSymbol = "InputHelper.clear"; private const string FocusSymbol = "InputHelper.focus"; private const string SetCursorSymbol = "InputHelper.setCursor"; private const string HideSymbol = "InputHelper.hide"; private const string ShowSymbol = "InputHelper.show"; - private readonly ElementReference inputElement; + private readonly AvaloniaModule _module; + private readonly ElementReference _inputElement; - public static async Task ImportAsync(IJSRuntime js, ElementReference element) + public InputHelperInterop(AvaloniaModule module, ElementReference element) { - var interop = new InputHelperInterop(js, element); - await interop.ImportAsync(); - return interop; + _module = module; + _inputElement = element; } - public InputHelperInterop(IJSRuntime js, ElementReference element) - : base(js, JsFilename) - { - inputElement = element; - } - - public void Clear() => Invoke(ClearSymbol, inputElement); + public void Clear() => _module.Invoke(ClearSymbol, _inputElement); - public void Focus() => Invoke(FocusSymbol, inputElement); + public void Focus() => _module.Invoke(FocusSymbol, _inputElement); - public void SetCursor(string kind) => Invoke(SetCursorSymbol, inputElement, kind); + public void SetCursor(string kind) => _module.Invoke(SetCursorSymbol, _inputElement, kind); - public void Hide() => Invoke(HideSymbol, inputElement); + public void Hide() => _module.Invoke(HideSymbol, _inputElement); - public void Show() => Invoke(ShowSymbol, inputElement); + public void Show() => _module.Invoke(ShowSymbol, _inputElement); } } diff --git a/src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs index 589b6b56bb..dca1b53650 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs @@ -1,6 +1,4 @@ -using System; -using System.Threading.Tasks; -using Microsoft.JSInterop; +using Microsoft.JSInterop; namespace Avalonia.Web.Blazor.Interop { @@ -31,16 +29,16 @@ namespace Avalonia.Web.Blazor.Interop protected IJSUnmarshalledObjectReference Module => module ?? throw new InvalidOperationException("Make sure to run ImportAsync() first."); - protected void Invoke(string identifier, params object?[]? args) => + internal void Invoke(string identifier, params object?[]? args) => Module.InvokeVoid(identifier, args); - protected TValue Invoke(string identifier, params object?[]? args) => + internal TValue Invoke(string identifier, params object?[]? args) => Module.Invoke(identifier, args); - protected ValueTask InvokeAsync(string identifier, params object?[]? args) => + internal ValueTask InvokeAsync(string identifier, params object?[]? args) => Module.InvokeVoidAsync(identifier, args); - protected ValueTask InvokeAsync(string identifier, params object?[]? args) => + internal ValueTask InvokeAsync(string identifier, params object?[]? args) => Module.InvokeAsync(identifier, args); protected virtual void OnDisposingModule() { } diff --git a/src/Web/Avalonia.Web.Blazor/Interop/NativeControlHostImpl.cs b/src/Web/Avalonia.Web.Blazor/Interop/NativeControlHostImpl.cs index 48362b03c4..b824fcae46 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/NativeControlHostImpl.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/NativeControlHostImpl.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using Avalonia.Controls.Platform; using Avalonia.Platform; @@ -10,31 +9,24 @@ using Microsoft.JSInterop; namespace Avalonia.Web.Blazor.Interop { - internal class NativeControlHostInterop : JSModuleInterop, INativeControlHostImpl + internal class NativeControlHostInterop : INativeControlHostImpl { - private const string JsFilename = "./_content/Avalonia.Web.Blazor/NativeControlHost.js"; private const string CreateDefaultChildSymbol = "NativeControlHost.CreateDefaultChild"; private const string CreateAttachmentSymbol = "NativeControlHost.CreateAttachment"; private const string GetReferenceSymbol = "NativeControlHost.GetReference"; - private readonly ElementReference hostElement; + private readonly AvaloniaModule _module; + private readonly ElementReference _hostElement; - public static async Task ImportAsync(IJSRuntime js, ElementReference element) + public NativeControlHostInterop(AvaloniaModule module, ElementReference element) { - var interop = new NativeControlHostInterop(js, element); - await interop.ImportAsync(); - return interop; - } - - public NativeControlHostInterop(IJSRuntime js, ElementReference element) - : base(js, JsFilename) - { - hostElement = element; + _module = module; + _hostElement = element; } public INativeControlHostDestroyableControlHandle CreateDefaultChild(IPlatformHandle parent) { - var element = Invoke(CreateDefaultChildSymbol); + var element = _module.Invoke(CreateDefaultChildSymbol); return new JSObjectControlHandle(element); } @@ -43,9 +35,9 @@ namespace Avalonia.Web.Blazor.Interop Attachment? a = null; try { - using var hostElementJsReference = Invoke(GetReferenceSymbol, hostElement); + using var hostElementJsReference = _module.Invoke(GetReferenceSymbol, _hostElement); var child = create(new JSObjectControlHandle(hostElementJsReference)); - var attachmenetReference = Invoke(CreateAttachmentSymbol); + var attachmenetReference = _module.Invoke(CreateAttachmentSymbol); // It has to be assigned to the variable before property setter is called so we dispose it on exception #pragma warning disable IDE0017 // Simplify object initialization a = new Attachment(attachmenetReference, child); @@ -62,7 +54,7 @@ namespace Avalonia.Web.Blazor.Interop public INativeControlHostControlTopLevelAttachment CreateNewAttachment(IPlatformHandle handle) { - var attachmenetReference = Invoke(CreateAttachmentSymbol); + var attachmenetReference = _module.Invoke(CreateAttachmentSymbol); var a = new Attachment(attachmenetReference, handle); a.AttachedTo = this; return a; @@ -111,7 +103,7 @@ namespace Avalonia.Web.Blazor.Interop } else { - _native.InvokeVoid(AttachToSymbol, host.hostElement); + _native.InvokeVoid(AttachToSymbol, host._hostElement); } _attachedTo = host; } diff --git a/src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs index 9cbbf24086..cf9350fb62 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs @@ -4,7 +4,7 @@ using SkiaSharp; namespace Avalonia.Web.Blazor.Interop { - internal class SKHtmlCanvasInterop : JSModuleInterop + internal class SKHtmlCanvasInterop : IDisposable { private const string JsFilename = "./_content/Avalonia.Web.Blazor/SKHtmlCanvas.js"; private const string InitGLSymbol = "SKHtmlCanvas.initGL"; @@ -14,39 +14,32 @@ namespace Avalonia.Web.Blazor.Interop private const string SetCanvasSizeSymbol = "SKHtmlCanvas.setCanvasSize"; private const string PutImageDataSymbol = "SKHtmlCanvas.putImageData"; - private readonly ElementReference htmlCanvas; - private readonly string htmlElementId; - private readonly ActionHelper callbackHelper; + private readonly AvaloniaModule _module; + private readonly ElementReference _htmlCanvas; + private readonly string _htmlElementId; + private readonly ActionHelper _callbackHelper; private DotNetObjectReference? callbackReference; - public static async Task ImportAsync(IJSRuntime js, ElementReference element, Action callback) + public SKHtmlCanvasInterop(AvaloniaModule module, ElementReference element, Action renderFrameCallback) { - var interop = new SKHtmlCanvasInterop(js, element, callback); - await interop.ImportAsync(); - return interop; - } - - public SKHtmlCanvasInterop(IJSRuntime js, ElementReference element, Action renderFrameCallback) - : base(js, JsFilename) - { - htmlCanvas = element; - htmlElementId = element.Id; + _module = module; + _htmlCanvas = element; + _htmlElementId = element.Id; - callbackHelper = new ActionHelper(renderFrameCallback); + _callbackHelper = new ActionHelper(renderFrameCallback); } - protected override void OnDisposingModule() => - Deinit(); + public void Dispose() => Deinit(); public GLInfo InitGL() { if (callbackReference != null) throw new InvalidOperationException("Unable to initialize the same canvas more than once."); - callbackReference = DotNetObjectReference.Create(callbackHelper); + callbackReference = DotNetObjectReference.Create(_callbackHelper); - return Invoke(InitGLSymbol, htmlCanvas, htmlElementId, callbackReference); + return _module.Invoke(InitGLSymbol, _htmlCanvas, _htmlElementId, callbackReference); } public bool InitRaster() @@ -54,9 +47,9 @@ namespace Avalonia.Web.Blazor.Interop if (callbackReference != null) throw new InvalidOperationException("Unable to initialize the same canvas more than once."); - callbackReference = DotNetObjectReference.Create(callbackHelper); + callbackReference = DotNetObjectReference.Create(_callbackHelper); - return Invoke(InitRasterSymbol, htmlCanvas, htmlElementId, callbackReference); + return _module.Invoke(InitRasterSymbol, _htmlCanvas, _htmlElementId, callbackReference); } public void Deinit() @@ -64,19 +57,19 @@ namespace Avalonia.Web.Blazor.Interop if (callbackReference == null) return; - Invoke(DeinitSymbol, htmlElementId); + _module.Invoke(DeinitSymbol, _htmlElementId); callbackReference?.Dispose(); } public void RequestAnimationFrame(bool enableRenderLoop) => - Invoke(RequestAnimationFrameSymbol, htmlCanvas, enableRenderLoop); + _module.Invoke(RequestAnimationFrameSymbol, _htmlCanvas, enableRenderLoop); public void SetCanvasSize(int rawWidth, int rawHeight) => - Invoke(SetCanvasSizeSymbol, htmlCanvas, rawWidth, rawHeight); + _module.Invoke(SetCanvasSizeSymbol, _htmlCanvas, rawWidth, rawHeight); public void PutImageData(IntPtr intPtr, SKSizeI rawSize) => - Invoke(PutImageDataSymbol, htmlCanvas, intPtr.ToInt64(), rawSize.Width, rawSize.Height); + _module.Invoke(PutImageDataSymbol, _htmlCanvas, intPtr.ToInt64(), rawSize.Width, rawSize.Height); public record GLInfo(int ContextId, uint FboId, int Stencils, int Samples, int Depth); } diff --git a/src/Web/Avalonia.Web.Blazor/Interop/SizeWatcherInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/SizeWatcherInterop.cs index 8904137b8b..10198751a9 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/SizeWatcherInterop.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/SizeWatcherInterop.cs @@ -1,50 +1,39 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using SkiaSharp; namespace Avalonia.Web.Blazor.Interop { - internal class SizeWatcherInterop : JSModuleInterop + internal class SizeWatcherInterop : IDisposable { - private const string JsFilename = "./_content/Avalonia.Web.Blazor/SizeWatcher.js"; private const string ObserveSymbol = "SizeWatcher.observe"; private const string UnobserveSymbol = "SizeWatcher.unobserve"; - private readonly ElementReference htmlElement; - private readonly string htmlElementId; - private readonly FloatFloatActionHelper callbackHelper; + private readonly AvaloniaModule _module; + private readonly ElementReference _htmlElement; + private readonly string _htmlElementId; + private readonly FloatFloatActionHelper _callbackHelper; private DotNetObjectReference? callbackReference; - public static async Task ImportAsync(IJSRuntime js, ElementReference element, Action callback) + public SizeWatcherInterop(AvaloniaModule module, ElementReference element, Action callback) { - var interop = new SizeWatcherInterop(js, element, callback); - await interop.ImportAsync(); - interop.Start(); - return interop; + _module = module; + _htmlElement = element; + _htmlElementId = element.Id; + _callbackHelper = new FloatFloatActionHelper((x, y) => callback(new SKSize(x, y))); } - public SizeWatcherInterop(IJSRuntime js, ElementReference element, Action callback) - : base(js, JsFilename) - { - htmlElement = element; - htmlElementId = element.Id; - callbackHelper = new FloatFloatActionHelper((x, y) => callback(new SKSize(x, y))); - } - - protected override void OnDisposingModule() => - Stop(); + public void Dispose() => Stop(); public void Start() { if (callbackReference != null) return; - callbackReference = DotNetObjectReference.Create(callbackHelper); + callbackReference = DotNetObjectReference.Create(_callbackHelper); - Invoke(ObserveSymbol, htmlElement, htmlElementId, callbackReference); + _module.Invoke(ObserveSymbol, _htmlElement, _htmlElementId, callbackReference); } public void Stop() @@ -52,7 +41,7 @@ namespace Avalonia.Web.Blazor.Interop if (callbackReference == null) return; - Invoke(UnobserveSymbol, htmlElementId); + _module.Invoke(UnobserveSymbol, _htmlElementId); callbackReference?.Dispose(); callbackReference = null; diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs index 2bc46e97b5..f6428c8ef1 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs @@ -12,7 +12,7 @@ namespace Avalonia.Web.Blazor.Interop.Storage internal class StorageProviderInterop : JSModuleInterop, IStorageProvider { - private const string JsFilename = "./_content/Avalonia.Web.Blazor/StorageProvider.js"; + private const string JsFilename = "./_content/Avalonia.Web.Blazor/avaloniaStorage.js"; private const string PickerCancelMessage = "The user aborted a request"; public static async Task ImportAsync(IJSRuntime js) diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/Avalonia.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/Avalonia.ts new file mode 100644 index 0000000000..1ce2e850d9 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/Avalonia.ts @@ -0,0 +1,5 @@ +export { DpiWatcher } from "./DpiWatcher" +export { InputHelper } from "./InputHelper" +export { NativeControlHost } from "./NativeControlHost" +export { SizeWatcher } from "./SizeWatcher" +export { SKHtmlCanvas } from "./SKHtmlCanvas" diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/DpiWatcher.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/DpiWatcher.ts similarity index 100% rename from src/Web/Avalonia.Web.Blazor/webapp/modules/Common/DpiWatcher.ts rename to src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/DpiWatcher.ts diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/InputHelper.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/InputHelper.ts similarity index 100% rename from src/Web/Avalonia.Web.Blazor/webapp/modules/Common/InputHelper.ts rename to src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/InputHelper.ts diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/NativeControlHost.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/NativeControlHost.ts similarity index 100% rename from src/Web/Avalonia.Web.Blazor/webapp/modules/Common/NativeControlHost.ts rename to src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/NativeControlHost.ts diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SKHtmlCanvas.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/SKHtmlCanvas.ts similarity index 100% rename from src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SKHtmlCanvas.ts rename to src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/SKHtmlCanvas.ts diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SizeWatcher.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/SizeWatcher.ts similarity index 100% rename from src/Web/Avalonia.Web.Blazor/webapp/modules/Common/SizeWatcher.ts rename to src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/SizeWatcher.ts diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/Common.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/Common.ts deleted file mode 100644 index 5f282702bb..0000000000 --- a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/Common.ts +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Common/IndexedDbWrapper.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Storage/IndexedDbWrapper.ts similarity index 100% rename from src/Web/Avalonia.Web.Blazor/webapp/modules/Common/IndexedDbWrapper.ts rename to src/Web/Avalonia.Web.Blazor/webapp/modules/Storage/IndexedDbWrapper.ts diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Storage/StorageProvider.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Storage/StorageProvider.ts index 65b8fa908e..896e174e43 100644 --- a/src/Web/Avalonia.Web.Blazor/webapp/modules/Storage/StorageProvider.ts +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Storage/StorageProvider.ts @@ -2,7 +2,7 @@ declare global { type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos"; - type StartInDirectory = WellKnownDirectory | FileSystemFileHandle; + type StartInDirectory = WellKnownDirectory | FileSystemHandle; interface OpenFilePickerOptions { startIn?: StartInDirectory } @@ -14,10 +14,10 @@ declare global { const fileBookmarksStore: string = "fileBookmarks"; const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [ fileBookmarksStore -]) +]); class StorageItem { - constructor(private handle: FileSystemHandle, private bookmarkId?: string) { } + constructor(public handle: FileSystemHandle, private bookmarkId?: string) { } public getName(): string { return this.handle.name @@ -69,7 +69,7 @@ class StorageItem { } const items: StorageItem[] = []; - for await (const [key, value] of this.handle.entries()) { + for await (const [key, value] of (this.handle as any).entries()) { items.push(new StorageItem(value)); } return new StorageItems(items); @@ -90,7 +90,7 @@ class StorageItem { if (this.bookmarkId) { return this.bookmarkId; } - + const connection = await avaloniaDb.connect(); try { const key = await connection.put(fileBookmarksStore, this.handle, this.generateBookmarkId()); @@ -172,7 +172,7 @@ export class StorageProvider { }; const handles = await window.showOpenFilePicker(options); - return new StorageItems(handles.map(handle => new StorageItem(handle))); + return new StorageItems(handles.map((handle: FileSystemHandle) => new StorageItem(handle))); } public static async saveFileDialog( diff --git a/src/Web/Avalonia.Web.Blazor/webapp/package.json b/src/Web/Avalonia.Web.Blazor/webapp/package.json index 130e3032b4..c312118a72 100644 --- a/src/Web/Avalonia.Web.Blazor/webapp/package.json +++ b/src/Web/Avalonia.Web.Blazor/webapp/package.json @@ -1,12 +1,14 @@ { "name": "avalonia.web", "scripts": { - "build": "tsc --build", - "clean": "tsc --build --clean" + "dist": "cross-env NODE_ENV=production webpack", + "build": "cross-env NODE_ENV=development webpack" }, "devDependencies": { "@types/emscripten": "^1.39.6", "@types/wicg-file-system-access": "^2020.9.5", + "cross-env": "^7.0.3", + "ts-loader": "^9.3.1", "typescript": "^4.7.4", "webpack": "^5.73.0", "webpack-cli": "^4.10.0" diff --git a/src/Web/Avalonia.Web.Blazor/webapp/webpack.config.js b/src/Web/Avalonia.Web.Blazor/webapp/webpack.config.js index e69de29bb2..52d507a2b2 100644 --- a/src/Web/Avalonia.Web.Blazor/webapp/webpack.config.js +++ b/src/Web/Avalonia.Web.Blazor/webapp/webpack.config.js @@ -0,0 +1,40 @@ +const path = require('path'); +const prod = process.env.NODE_ENV == 'production'; + +module.exports = { + mode: prod ? "production" : "development", + devtool: 'source-map', + target: ["web", "es2020"], + entry: { + avalonia: './modules/Avalonia/Avalonia.ts', + avaloniaStorage: { + import: './modules/Storage/StorageProvider.ts', + dependOn: 'avalonia', + } + }, + output: { + filename: '[name].js', + path: path.resolve(__dirname, '../wwwroot'), + library: { + type: 'module', + }, + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + ], + }, + resolve: { + extensions: ['.ts', '.js'], + }, + optimization: { + minimize: false + }, + experiments: { + outputModule: true, + } +}; diff --git a/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/Avalonia.js b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/Avalonia.js new file mode 100644 index 0000000000..849ea089d5 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/Avalonia.js @@ -0,0 +1,6 @@ +export { DpiWatcher } from "./DpiWatcher"; +export { InputHelper } from "./InputHelper"; +export { NativeControlHost } from "./NativeControlHost"; +export { SizeWatcher } from "./SizeWatcher"; +export { SKHtmlCanvas } from "./SKHtmlCanvas"; +//# sourceMappingURL=Avalonia.js.map \ No newline at end of file diff --git a/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/DpiWatcher.js b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/DpiWatcher.js new file mode 100644 index 0000000000..17b6999108 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/DpiWatcher.js @@ -0,0 +1,26 @@ +export class DpiWatcher { + static getDpi() { + return window.devicePixelRatio; + } + static start(callback) { + DpiWatcher.lastDpi = window.devicePixelRatio; + DpiWatcher.timerId = window.setInterval(DpiWatcher.update, 1000); + DpiWatcher.callback = callback; + return DpiWatcher.lastDpi; + } + static stop() { + window.clearInterval(DpiWatcher.timerId); + DpiWatcher.callback = undefined; + } + static update() { + if (!DpiWatcher.callback) + return; + const currentDpi = window.devicePixelRatio; + const lastDpi = DpiWatcher.lastDpi; + DpiWatcher.lastDpi = currentDpi; + if (Math.abs(lastDpi - currentDpi) > 0.001) { + DpiWatcher.callback.invokeMethod('Invoke', lastDpi, currentDpi); + } + } +} +//# sourceMappingURL=DpiWatcher.js.map \ No newline at end of file diff --git a/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/InputHelper.js b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/InputHelper.js new file mode 100644 index 0000000000..97a4500a00 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/InputHelper.js @@ -0,0 +1,19 @@ +export class InputHelper { + static clear(inputElement) { + inputElement.value = ""; + } + static focus(inputElement) { + inputElement.focus(); + inputElement.setSelectionRange(0, 0); + } + static setCursor(inputElement, kind) { + inputElement.style.cursor = kind; + } + static hide(inputElement) { + inputElement.style.display = 'none'; + } + static show(inputElement) { + inputElement.style.display = 'block'; + } +} +//# sourceMappingURL=InputHelper.js.map \ No newline at end of file diff --git a/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/NativeControlHost.js b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/NativeControlHost.js new file mode 100644 index 0000000000..05f804ed8c --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/NativeControlHost.js @@ -0,0 +1,48 @@ +export class NativeControlHost { + static CreateDefaultChild(parent) { + return document.createElement("div"); + } + static GetReference(element) { + return element; + } + static CreateAttachment() { + return new NativeControlHostTopLevelAttachment(); + } +} +class NativeControlHostTopLevelAttachment { + InitializeWithChildHandle(child) { + this._child = child; + this._child.style.position = "absolute"; + } + AttachTo(host) { + if (this._host && this._child) { + this._host.removeChild(this._child); + } + this._host = host; + if (this._host && this._child) { + this._host.appendChild(this._child); + } + } + ShowInBounds(x, y, width, height) { + if (this._child) { + this._child.style.top = y + "px"; + this._child.style.left = x + "px"; + this._child.style.width = width + "px"; + this._child.style.height = height + "px"; + this._child.style.display = "block"; + } + } + HideWithSize(width, height) { + if (this._child) { + this._child.style.width = width + "px"; + this._child.style.height = height + "px"; + this._child.style.display = "none"; + } + } + ReleaseChild() { + if (this._child) { + this._child = undefined; + } + } +} +//# sourceMappingURL=NativeControlHost.js.map \ No newline at end of file diff --git a/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/SKHtmlCanvas.js b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/SKHtmlCanvas.js new file mode 100644 index 0000000000..5900026acd --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/SKHtmlCanvas.js @@ -0,0 +1,172 @@ +export class SKHtmlCanvas { + constructor(useGL, element, callback) { + this.renderLoopEnabled = false; + this.renderLoopRequest = 0; + this.htmlCanvas = element; + this.renderFrameCallback = callback; + if (useGL) { + const ctx = SKHtmlCanvas.createWebGLContext(this.htmlCanvas); + if (!ctx) { + console.error(`Failed to create WebGL context: err ${ctx}`); + return; + } + GL.makeContextCurrent(ctx); + const fbo = GLctx.getParameter(GLctx.FRAMEBUFFER_BINDING); + this.glInfo = { + context: ctx, + fboId: fbo ? fbo.id : 0, + stencil: GLctx.getParameter(GLctx.STENCIL_BITS), + sample: 0, + depth: GLctx.getParameter(GLctx.DEPTH_BITS), + }; + } + } + static initGL(element, elementId, callback) { + var view = SKHtmlCanvas.init(true, element, elementId, callback); + if (!view || !view.glInfo) + return null; + return view.glInfo; + } + static initRaster(element, elementId, callback) { + var view = SKHtmlCanvas.init(false, element, elementId, callback); + if (!view) + return false; + return true; + } + static init(useGL, element, elementId, callback) { + var htmlCanvas = element; + if (!htmlCanvas) { + console.error(`No canvas element was provided.`); + return null; + } + if (!SKHtmlCanvas.elements) + SKHtmlCanvas.elements = new Map(); + SKHtmlCanvas.elements.set(elementId, element); + const view = new SKHtmlCanvas(useGL, element, callback); + htmlCanvas.SKHtmlCanvas = view; + return view; + } + static deinit(elementId) { + if (!elementId) + return; + const element = SKHtmlCanvas.elements.get(elementId); + SKHtmlCanvas.elements.delete(elementId); + const htmlCanvas = element; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + htmlCanvas.SKHtmlCanvas.deinit(); + htmlCanvas.SKHtmlCanvas = undefined; + } + static requestAnimationFrame(element, renderLoop) { + const htmlCanvas = element; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + htmlCanvas.SKHtmlCanvas.requestAnimationFrame(renderLoop); + } + static setCanvasSize(element, width, height) { + const htmlCanvas = element; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + htmlCanvas.SKHtmlCanvas.setCanvasSize(width, height); + } + static setEnableRenderLoop(element, enable) { + const htmlCanvas = element; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + htmlCanvas.SKHtmlCanvas.setEnableRenderLoop(enable); + } + static putImageData(element, pData, width, height) { + const htmlCanvas = element; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + htmlCanvas.SKHtmlCanvas.putImageData(pData, width, height); + } + deinit() { + this.setEnableRenderLoop(false); + } + setCanvasSize(width, height) { + this.newWidth = width; + this.newHeight = height; + if (this.htmlCanvas.width != this.newWidth) { + this.htmlCanvas.width = this.newWidth; + } + if (this.htmlCanvas.height != this.newHeight) { + this.htmlCanvas.height = this.newHeight; + } + if (this.glInfo) { + GL.makeContextCurrent(this.glInfo.context); + } + } + requestAnimationFrame(renderLoop) { + if (renderLoop !== undefined && this.renderLoopEnabled !== renderLoop) + this.setEnableRenderLoop(renderLoop); + if (this.renderLoopRequest !== 0) + return; + this.renderLoopRequest = window.requestAnimationFrame(() => { + if (this.glInfo) { + GL.makeContextCurrent(this.glInfo.context); + } + if (this.htmlCanvas.width != this.newWidth) { + this.htmlCanvas.width = this.newWidth || 0; + } + if (this.htmlCanvas.height != this.newHeight) { + this.htmlCanvas.height = this.newHeight || 0; + } + this.renderFrameCallback.invokeMethod('Invoke'); + this.renderLoopRequest = 0; + if (this.renderLoopEnabled) + this.requestAnimationFrame(); + }); + } + setEnableRenderLoop(enable) { + this.renderLoopEnabled = enable; + if (enable) { + this.requestAnimationFrame(); + } + else if (this.renderLoopRequest !== 0) { + window.cancelAnimationFrame(this.renderLoopRequest); + this.renderLoopRequest = 0; + } + } + putImageData(pData, width, height) { + if (this.glInfo || !pData || width <= 0 || width <= 0) + return false; + var ctx = this.htmlCanvas.getContext('2d'); + if (!ctx) { + console.error(`Failed to obtain 2D canvas context.`); + return false; + } + this.htmlCanvas.width = width; + this.htmlCanvas.height = height; + var buffer = new Uint8ClampedArray(Module.HEAPU8.buffer, pData, width * height * 4); + var imageData = new ImageData(buffer, width, height); + ctx.putImageData(imageData, 0, 0); + return true; + } + static createWebGLContext(htmlCanvas) { + const contextAttributes = { + alpha: 1, + depth: 1, + stencil: 8, + antialias: 0, + premultipliedAlpha: 1, + preserveDrawingBuffer: 0, + preferLowPowerToHighPerformance: 0, + failIfMajorPerformanceCaveat: 0, + majorVersion: 2, + minorVersion: 0, + enableExtensionsByDefault: 1, + explicitSwapControl: 0, + renderViaOffscreenBackBuffer: 1, + }; + let ctx = GL.createContext(htmlCanvas, contextAttributes); + if (!ctx && contextAttributes.majorVersion > 1) { + console.warn('Falling back to WebGL 1.0'); + contextAttributes.majorVersion = 1; + contextAttributes.minorVersion = 0; + ctx = GL.createContext(htmlCanvas, contextAttributes); + } + return ctx; + } +} +//# sourceMappingURL=SKHtmlCanvas.js.map \ No newline at end of file diff --git a/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/SizeWatcher.js b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/SizeWatcher.js new file mode 100644 index 0000000000..1c6ce26155 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/wwwroot/Avalonia/SizeWatcher.js @@ -0,0 +1,39 @@ +export class SizeWatcher { + static observe(element, elementId, callback) { + if (!element || !callback) + return; + SizeWatcher.init(); + const watcherElement = element; + watcherElement.SizeWatcher = { + callback: callback + }; + SizeWatcher.elements.set(elementId, element); + SizeWatcher.observer.observe(element); + SizeWatcher.invoke(element); + } + static unobserve(elementId) { + if (!elementId || !SizeWatcher.observer) + return; + const element = SizeWatcher.elements.get(elementId); + SizeWatcher.elements.delete(elementId); + SizeWatcher.observer.unobserve(element); + } + static init() { + if (SizeWatcher.observer) + return; + SizeWatcher.elements = new Map(); + SizeWatcher.observer = new ResizeObserver((entries) => { + for (let entry of entries) { + SizeWatcher.invoke(entry.target); + } + }); + } + static invoke(element) { + const watcherElement = element; + const instance = watcherElement.SizeWatcher; + if (!instance || !instance.callback) + return; + return instance.callback.invokeMethod('Invoke', element.clientWidth, element.clientHeight); + } +} +//# sourceMappingURL=SizeWatcher.js.map \ No newline at end of file diff --git a/src/Web/Avalonia.Web.Blazor/wwwroot/Storage/IndexedDbWrapper.js b/src/Web/Avalonia.Web.Blazor/wwwroot/Storage/IndexedDbWrapper.js new file mode 100644 index 0000000000..bf9cab01bc --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/wwwroot/Storage/IndexedDbWrapper.js @@ -0,0 +1,72 @@ +class InnerDbConnection { + constructor(database) { + this.database = database; + } + openStore(store, mode) { + const tx = this.database.transaction(store, mode); + return tx.objectStore(store); + } + put(store, obj, key) { + const os = this.openStore(store, "readwrite"); + return new Promise((resolve, reject) => { + const response = os.put(obj, key); + response.onsuccess = () => { + resolve(response.result); + }; + response.onerror = () => { + reject(response.error); + }; + }); + } + get(store, key) { + const os = this.openStore(store, "readonly"); + return new Promise((resolve, reject) => { + const response = os.get(key); + response.onsuccess = () => { + resolve(response.result); + }; + response.onerror = () => { + reject(response.error); + }; + }); + } + delete(store, key) { + const os = this.openStore(store, "readwrite"); + return new Promise((resolve, reject) => { + const response = os.delete(key); + response.onsuccess = () => { + resolve(); + }; + response.onerror = () => { + reject(response.error); + }; + }); + } + close() { + this.database.close(); + } +} +export class IndexedDbWrapper { + constructor(databaseName, objectStores) { + this.databaseName = databaseName; + this.objectStores = objectStores; + } + connect() { + const conn = window.indexedDB.open(this.databaseName, 1); + conn.onupgradeneeded = event => { + const db = event.target.result; + this.objectStores.forEach(store => { + db.createObjectStore(store); + }); + }; + return new Promise((resolve, reject) => { + conn.onsuccess = event => { + resolve(new InnerDbConnection(event.target.result)); + }; + conn.onerror = event => { + reject(event.target.error); + }; + }); + } +} +//# sourceMappingURL=IndexedDbWrapper.js.map \ No newline at end of file diff --git a/src/Web/Avalonia.Web.Blazor/wwwroot/Storage/StorageProvider.js b/src/Web/Avalonia.Web.Blazor/wwwroot/Storage/StorageProvider.js new file mode 100644 index 0000000000..042a83d177 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/wwwroot/Storage/StorageProvider.js @@ -0,0 +1,199 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __asyncValues = (this && this.__asyncValues) || function (o) { + if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); + var m = o[Symbol.asyncIterator], i; + return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i); + function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; } + function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); } +}; +import { IndexedDbWrapper } from "./IndexedDbWrapper"; +const fileBookmarksStore = "fileBookmarks"; +const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [ + fileBookmarksStore +]); +class StorageItem { + constructor(handle, bookmarkId) { + this.handle = handle; + this.bookmarkId = bookmarkId; + } + getName() { + return this.handle.name; + } + getKind() { + return this.handle.kind; + } + openRead() { + return __awaiter(this, void 0, void 0, function* () { + if (!(this.handle instanceof FileSystemFileHandle)) { + throw new Error("StorageItem is not a file"); + } + yield this.verityPermissions('read'); + const file = yield this.handle.getFile(); + return file; + }); + } + openWrite() { + return __awaiter(this, void 0, void 0, function* () { + if (!(this.handle instanceof FileSystemFileHandle)) { + throw new Error("StorageItem is not a file"); + } + yield this.verityPermissions('readwrite'); + return yield this.handle.createWritable({ keepExistingData: true }); + }); + } + getProperties() { + return __awaiter(this, void 0, void 0, function* () { + const file = this.handle instanceof FileSystemFileHandle + && (yield this.handle.getFile()); + if (!file) { + return null; + } + return { + Size: file.size, + LastModified: file.lastModified, + Type: file.type + }; + }); + } + getItems() { + var e_1, _a; + return __awaiter(this, void 0, void 0, function* () { + if (this.handle.kind !== "directory") { + return new StorageItems([]); + } + const items = []; + try { + for (var _b = __asyncValues(this.handle.entries()), _c; _c = yield _b.next(), !_c.done;) { + const [key, value] = _c.value; + items.push(new StorageItem(value)); + } + } + catch (e_1_1) { e_1 = { error: e_1_1 }; } + finally { + try { + if (_c && !_c.done && (_a = _b.return)) yield _a.call(_b); + } + finally { if (e_1) throw e_1.error; } + } + return new StorageItems(items); + }); + } + verityPermissions(mode) { + return __awaiter(this, void 0, void 0, function* () { + if ((yield this.handle.queryPermission({ mode })) === 'granted') { + return; + } + if ((yield this.handle.requestPermission({ mode })) === "denied") { + throw new Error("Read permissions denied"); + } + }); + } + saveBookmark() { + return __awaiter(this, void 0, void 0, function* () { + if (this.bookmarkId) { + return this.bookmarkId; + } + const connection = yield avaloniaDb.connect(); + try { + const key = yield connection.put(fileBookmarksStore, this.handle, this.generateBookmarkId()); + return key; + } + finally { + connection.close(); + } + }); + } + deleteBookmark() { + return __awaiter(this, void 0, void 0, function* () { + if (!this.bookmarkId) { + return; + } + const connection = yield avaloniaDb.connect(); + try { + const key = yield connection.delete(fileBookmarksStore, this.bookmarkId); + } + finally { + connection.close(); + } + }); + } + generateBookmarkId() { + return Date.now().toString(36) + Math.random().toString(36).substring(2); + } +} +class StorageItems { + constructor(items) { + this.items = items; + } + count() { + return this.items.length; + } + at(index) { + return this.items[index]; + } +} +export class StorageProvider { + static canOpen() { + return typeof window.showOpenFilePicker !== 'undefined'; + } + static canSave() { + return typeof window.showSaveFilePicker !== 'undefined'; + } + static canPickFolder() { + return typeof window.showDirectoryPicker !== 'undefined'; + } + static selectFolderDialog(startIn) { + return __awaiter(this, void 0, void 0, function* () { + const options = { + startIn: ((startIn === null || startIn === void 0 ? void 0 : startIn.handle) || undefined) + }; + const handle = yield window.showDirectoryPicker(options); + return new StorageItem(handle); + }); + } + static openFileDialog(startIn, multiple, types, excludeAcceptAllOption) { + return __awaiter(this, void 0, void 0, function* () { + const options = { + startIn: ((startIn === null || startIn === void 0 ? void 0 : startIn.handle) || undefined), + multiple, + excludeAcceptAllOption, + types: (types || undefined) + }; + const handles = yield window.showOpenFilePicker(options); + return new StorageItems(handles.map((handle) => new StorageItem(handle))); + }); + } + static saveFileDialog(startIn, suggestedName, types, excludeAcceptAllOption) { + return __awaiter(this, void 0, void 0, function* () { + const options = { + startIn: ((startIn === null || startIn === void 0 ? void 0 : startIn.handle) || undefined), + suggestedName: (suggestedName || undefined), + excludeAcceptAllOption, + types: (types || undefined) + }; + const handle = yield window.showSaveFilePicker(options); + return new StorageItem(handle); + }); + } + static openBookmark(key) { + return __awaiter(this, void 0, void 0, function* () { + const connection = yield avaloniaDb.connect(); + try { + const handle = yield connection.get(fileBookmarksStore, key); + return handle && new StorageItem(handle, key); + } + finally { + connection.close(); + } + }); + } +} +//# sourceMappingURL=StorageProvider.js.map \ No newline at end of file From 53aa470898113e0a1372545eacd2f495d8dbd950 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 26 Aug 2022 14:16:59 +0100 Subject: [PATCH 04/24] start sizewatcher. --- src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs index d4081ca043..09128d714d 100644 --- a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs +++ b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs @@ -312,6 +312,7 @@ namespace Avalonia.Web.Blazor _sizeWatcher = new SizeWatcherInterop(_avaloniaModule, _htmlCanvas, OnSizeChanged); _dpiWatcher = new DpiWatcherInterop(_avaloniaModule, OnDpiChanged); + _sizeWatcher.Start(); _topLevel.Prepare(); _topLevel.Renderer.Start(); From 0b01a51c9e83a779ea273277d9efc8a90e444fea Mon Sep 17 00:00:00 2001 From: hacklex Date: Sun, 28 Aug 2022 18:41:21 +0300 Subject: [PATCH 05/24] TextConverter property for finer control over NumericUpDown text display (#8839) * Added TextConverter property to NumericUpDown * Added a hex NumericUpDown sample to ControlCatalog * Requested changes to conversion calls * Fixed NumericUpDown::OnTextConverterChanged logic --- .../ControlCatalog/Converter/HexConverter.cs | 34 +++++++++++ .../Pages/NumericUpDownPage.xaml | 12 ++++ .../NumericUpDown/NumericUpDown.cs | 61 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 samples/ControlCatalog/Converter/HexConverter.cs diff --git a/samples/ControlCatalog/Converter/HexConverter.cs b/samples/ControlCatalog/Converter/HexConverter.cs new file mode 100644 index 0000000000..83a52212f6 --- /dev/null +++ b/samples/ControlCatalog/Converter/HexConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; + +namespace ControlCatalog.Converter; + +public class HexConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var str = value?.ToString(); + if (str == null) + return AvaloniaProperty.UnsetValue; + if (int.TryParse(str, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int x)) + return (decimal)x; + return AvaloniaProperty.UnsetValue; + + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + try + { + if (value is decimal d) + return ((int)d).ToString("X8"); + return AvaloniaProperty.UnsetValue; + } + catch + { + return AvaloniaProperty.UnsetValue; + } + } +} diff --git a/samples/ControlCatalog/Pages/NumericUpDownPage.xaml b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml index 1f4d1e6018..045ba4a059 100644 --- a/samples/ControlCatalog/Pages/NumericUpDownPage.xaml +++ b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml @@ -1,6 +1,7 @@  @@ -97,6 +98,17 @@ + + + + + + + + + + diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs index dc2b2cd7cc..190dab67aa 100644 --- a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs +++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs @@ -5,6 +5,7 @@ using System.Linq; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; using Avalonia.Data; +using Avalonia.Data.Converters; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; @@ -96,6 +97,13 @@ namespace Avalonia.Controls AvaloniaProperty.RegisterDirect(nameof(Text), o => o.Text, (o, v) => o.Text = v, defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true); + /// + /// Defines the property. + /// + public static readonly DirectProperty TextConverterProperty = + AvaloniaProperty.RegisterDirect(nameof(TextConverter), + updown => updown.TextConverter, (o, v) => o.TextConverter = v, null, BindingMode.OneWay, false); + /// /// Defines the property. /// @@ -125,6 +133,7 @@ namespace Avalonia.Controls private decimal? _value; private string? _text; + private IValueConverter? _textConverter; private bool _internalValueSet; private bool _clipValueToMinMax; private bool _isSyncingTextAndValueProperties; @@ -235,6 +244,8 @@ namespace Avalonia.Controls /// /// Gets or sets the parsing style (AllowLeadingWhite, Float, AllowHexSpecifier, ...). By default, Any. + /// Note that Hex style does not work with decimal. + /// For hexadecimal display, use . /// public NumberStyles ParsingNumberStyle { @@ -251,6 +262,17 @@ namespace Avalonia.Controls set { SetAndRaise(TextProperty, ref _text, value); } } + /// + /// Gets or sets the custom bidirectional Text-Value converter. + /// Non-null converter overrides , providing finer control over + /// string representation of the underlying value. + /// + public IValueConverter? TextConverter + { + get { return _textConverter; } + set { SetAndRaise(TextConverterProperty, ref _textConverter, value); } + } + /// /// Gets or sets the value. /// @@ -319,6 +341,7 @@ namespace Avalonia.Controls MaximumProperty.Changed.Subscribe(OnMaximumChanged); MinimumProperty.Changed.Subscribe(OnMinimumChanged); TextProperty.Changed.Subscribe(OnTextChanged); + TextConverterProperty.Changed.Subscribe(OnTextConverterChanged); ValueProperty.Changed.Subscribe(OnValueChanged); } @@ -485,6 +508,19 @@ namespace Avalonia.Controls SyncTextAndValueProperties(true, Text); } } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnTextConverterChanged(IValueConverter? oldValue, IValueConverter? newValue) + { + if (IsInitialized) + { + SyncTextAndValueProperties(false, null); + } + } /// /// Called when the property value changed. @@ -612,6 +648,10 @@ namespace Avalonia.Controls /// private string? ConvertValueToText() { + if (TextConverter != null) + { + return TextConverter.ConvertBack(Value, typeof(string), null, CultureInfo.CurrentCulture)?.ToString(); + } //Manage FormatString of type "{}{0:N2} °" (in xaml) or "{0:N2} °" in code-behind. if (FormatString.Contains("{0")) { @@ -788,6 +828,21 @@ namespace Avalonia.Controls } } + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnTextConverterChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (IValueConverter?)e.OldValue; + var newValue = (IValueConverter?)e.NewValue; + upDown.OnTextConverterChanged(oldValue, newValue); + } + } + + /// /// Called when the property value changed. /// @@ -1012,6 +1067,12 @@ namespace Avalonia.Controls return null; } + if (TextConverter != null) + { + var valueFromText = TextConverter.Convert(text, typeof(decimal?), null, CultureInfo.CurrentCulture); + return (decimal?)valueFromText; + } + if (IsPercent(FormatString)) { result = ParsePercent(text, NumberFormat); From 4207a5f93b386b152f83b82bdef96d97dcad97af Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 29 Aug 2022 14:16:11 +0300 Subject: [PATCH 06/24] Filter-out child elements during hit-testing too --- .../Composition/CompositingRenderer.cs | 11 +++++++- .../Composition/CompositionDrawListVisual.cs | 4 +-- .../Composition/CompositionTarget.cs | 10 ++++--- .../Rendering/Composition/Visual.cs | 2 +- .../Rendering/CompositorHitTestingTests.cs | 27 +++++++++++++++++++ 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs index 9aa3c25425..187d0b644c 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs @@ -84,7 +84,16 @@ public class CompositingRenderer : IRendererWithCompositor /// public IEnumerable HitTest(Point p, IVisual root, Func? filter) { - var res = CompositionTarget.TryHitTest(p, filter); + Func? f = null; + if (filter != null) + f = v => + { + if (v is CompositionDrawListVisual dlv) + return filter(dlv.Visual); + return true; + }; + + var res = CompositionTarget.TryHitTest(p, f); if(res == null) yield break; foreach(var v in res) diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs b/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs index 77b392eee5..b019d1792b 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs @@ -54,13 +54,11 @@ internal class CompositionDrawListVisual : CompositionContainerVisual Visual = visual; } - internal override bool HitTest(Point pt, Func? filter) + internal override bool HitTest(Point pt) { var custom = Visual as ICustomHitTest; if (DrawList == null && custom == null) return false; - if (filter != null && !filter(Visual)) - return false; if (custom != null) { // Simulate the old behavior diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs index 4e53e163ec..eb499604e0 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs @@ -31,7 +31,7 @@ namespace Avalonia.Rendering.Composition /// /// /// - public PooledList? TryHitTest(Point point, Func? filter) + public PooledList? TryHitTest(Point point, Func? filter) { Server.Readback.NextRead(); if (Root == null) @@ -88,10 +88,14 @@ namespace Avalonia.Rendering.Composition } void HitTestCore(CompositionVisual visual, Point globalPoint, PooledList result, - Func? filter) + Func? filter) { if (visual.Visible == false) return; + + if (filter != null && !filter(visual)) + return; + if (!TryTransformTo(visual, globalPoint, out var point)) return; @@ -111,7 +115,7 @@ namespace Avalonia.Rendering.Composition } // Hit-test the current node - if (visual.HitTest(point, filter)) + if (visual.HitTest(point)) result.Add(visual); } diff --git a/src/Avalonia.Base/Rendering/Composition/Visual.cs b/src/Avalonia.Base/Rendering/Composition/Visual.cs index 7356b7b9e8..6d6818256a 100644 --- a/src/Avalonia.Base/Rendering/Composition/Visual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Visual.cs @@ -53,6 +53,6 @@ namespace Avalonia.Rendering.Composition internal object? Tag { get; set; } - internal virtual bool HitTest(Point point, Func? filter) => true; + internal virtual bool HitTest(Point point) => true; } } diff --git a/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs index 02012bf62b..d83bb12aea 100644 --- a/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs +++ b/tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs @@ -432,6 +432,33 @@ public class CompositorHitTestingTests : CompositorTestsBase s.AssertHitTest(new Point(5, 10), null, targetRectangle); } } + + + [Fact] + public void HitTest_Filter_Should_Filter_Out_Children() + { + using (var s = new CompositorServices(new Size(200, 200))) + { + Border child, parent; + s.TopLevel.Content = parent = new Border + { + Width = 100, + Height = 100, + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Child = child = new Border + { + Background = Brushes.Red, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + } + }; + + s.AssertHitTest(new Point(100, 100), null, child, parent); + s.AssertHitTest(new Point(100, 100), v => v != parent); + } + } private IDisposable TestApplication() { From ec164ea0c3aa1cfeef16bc9d9f920a40709c8efa Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 29 Aug 2022 14:48:11 +0300 Subject: [PATCH 07/24] Throw on attempt to invalidate a visual during a render pass --- .../Composition/CompositingRenderer.cs | 22 ++++++++++++- src/Avalonia.Base/Rendering/DirtyVisuals.cs | 33 ++++--------------- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs index 9aa3c25425..9742a6b3ba 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs @@ -29,6 +29,7 @@ public class CompositingRenderer : IRendererWithCompositor private bool _queuedUpdate; private Action _update; private Action _invalidateScene; + private bool _updating; internal CompositionTarget CompositionTarget; @@ -77,6 +78,8 @@ public class CompositingRenderer : IRendererWithCompositor /// public void AddDirty(IVisual visual) { + if (_updating) + throw new InvalidOperationException("Visual was invalidated during the render pass"); _dirty.Add((Visual)visual); QueueUpdate(); } @@ -107,6 +110,8 @@ public class CompositingRenderer : IRendererWithCompositor /// public void RecalculateChildren(IVisual visual) { + if (_updating) + throw new InvalidOperationException("Visual was invalidated during the render pass"); _recalculateChildren.Add((Visual)visual); QueueUpdate(); } @@ -191,7 +196,7 @@ public class CompositingRenderer : IRendererWithCompositor private void InvalidateScene() => SceneInvalidated?.Invoke(this, new SceneInvalidatedEventArgs(_root, new Rect(_root.ClientSize))); - private void Update() + private void UpdateCore() { _queuedUpdate = false; foreach (var visual in _dirty) @@ -240,6 +245,21 @@ public class CompositingRenderer : IRendererWithCompositor CompositionTarget.Scaling = _root.RenderScaling; Compositor.InvokeOnNextCommit(_invalidateScene); } + + private void Update() + { + if(_updating) + return; + _updating = true; + try + { + UpdateCore(); + } + finally + { + _updating = false; + } + } public void Resized(Size size) { diff --git a/src/Avalonia.Base/Rendering/DirtyVisuals.cs b/src/Avalonia.Base/Rendering/DirtyVisuals.cs index 00bc236b9c..999b12e810 100644 --- a/src/Avalonia.Base/Rendering/DirtyVisuals.cs +++ b/src/Avalonia.Base/Rendering/DirtyVisuals.cs @@ -17,8 +17,7 @@ namespace Avalonia.Rendering { private SortedDictionary> _inner = new SortedDictionary>(); private Dictionary _index = new Dictionary(); - private List _deferredChanges = new List(); - private int _deferring; + private int _enumerating; /// /// Gets the number of dirty visuals. @@ -31,10 +30,9 @@ namespace Avalonia.Rendering /// The dirty visual. public void Add(IVisual visual) { - if (_deferring > 0) + if (_enumerating > 0) { - _deferredChanges.Add(visual); - return; + throw new InvalidOperationException("Visual was invalidated during a render pass"); } var distance = visual.CalculateDistanceFromAncestor(visual.VisualRoot); @@ -65,7 +63,7 @@ namespace Avalonia.Rendering /// public void Clear() { - if (_deferring > 0) + if (_enumerating > 0) { throw new InvalidOperationException("Cannot clear while enumerating"); } @@ -80,7 +78,7 @@ namespace Avalonia.Rendering /// A collection of visuals. public IEnumerator GetEnumerator() { - BeginDefer(); + _enumerating++; try { foreach (var i in _inner) @@ -93,27 +91,10 @@ namespace Avalonia.Rendering } finally { - EndDefer(); + _enumerating--; } } - - private void BeginDefer() - { - ++_deferring; - } - - private void EndDefer() - { - if (--_deferring > 0) return; - - foreach (var visual in _deferredChanges) - { - Add(visual); - } - - _deferredChanges.Clear(); - } - + /// /// Gets the dirty visuals, in ascending order of distance to their root. /// From 854b11e38c2efa8ff19bf0d3636f17c125af9fcb Mon Sep 17 00:00:00 2001 From: Lobster Uberlord Date: Mon, 29 Aug 2022 19:25:18 +0700 Subject: [PATCH 08/24] Disable auto-capitalization of input element text in AvaloniaView On Android Chrome the auto-capitalization forces the keyboard to switch to uppercase for each and every character, this made typing in rather difficult. --- src/Web/Avalonia.Web.Blazor/AvaloniaView.razor | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor index b501db16d3..4802191077 100644 --- a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor +++ b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor @@ -15,7 +15,8 @@ + oncut="return false;" + autocapitalize="none"/>