From 4b5885fab6f9662996fef949af75b2e8ffdd2764 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 23 Sep 2022 22:01:30 -0400 Subject: [PATCH 1/4] Add initial InputHelper --- src/Web/Avalonia.Web.Sample/index.html | 2 +- src/Web/Avalonia.Web/AvaloniaView.cs | 8 +- src/Web/Avalonia.Web/Interop/InputHelper.cs | 46 +++++++ src/Web/Avalonia.Web/Keycodes.cs | 129 ++++++++++++++++++ src/Web/Avalonia.Web/RazorViewTopLevelImpl.cs | 11 +- .../webapp/modules/avalonia/input.ts | 107 +++++++++++++++ .../webapp/modules/avalonia/runtime.ts | 4 +- 7 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 src/Web/Avalonia.Web/Interop/InputHelper.cs create mode 100644 src/Web/Avalonia.Web/Keycodes.cs create mode 100644 src/Web/Avalonia.Web/webapp/modules/avalonia/input.ts diff --git a/src/Web/Avalonia.Web.Sample/index.html b/src/Web/Avalonia.Web.Sample/index.html index 25b92798ad..518abd4a15 100644 --- a/src/Web/Avalonia.Web.Sample/index.html +++ b/src/Web/Avalonia.Web.Sample/index.html @@ -13,7 +13,7 @@ -
+
diff --git a/src/Web/Avalonia.Web/AvaloniaView.cs b/src/Web/Avalonia.Web/AvaloniaView.cs index f7af35d2b0..54652bc7e2 100644 --- a/src/Web/Avalonia.Web/AvaloniaView.cs +++ b/src/Web/Avalonia.Web/AvaloniaView.cs @@ -13,6 +13,8 @@ using Avalonia.Input.TextInput; using Avalonia.Platform.Storage; using Avalonia.Rendering.Composition; using Avalonia.Web.Blazor; +using Avalonia.Web.Interop; + using SkiaSharp; using static Avalonia.Web.AvaloniaRuntime; @@ -72,8 +74,10 @@ namespace Avalonia.Web _topLevel.Renderer.Start(); - - + InputHelper.SubscribeKeyboardEvents( + div, + (code, key, modifier) => _topLevelImpl.RawKeyboardEvent(Input.Raw.RawKeyEventType.KeyDown, code, key, (Input.RawInputModifiers)modifier), + (code, key, modifier) => _topLevelImpl.RawKeyboardEvent(Input.Raw.RawKeyEventType.KeyUp, code, key, (Input.RawInputModifiers)modifier)); //_interop = new SKHtmlCanvasInterop(_avaloniaModule, _htmlCanvas, OnRenderFrame); diff --git a/src/Web/Avalonia.Web/Interop/InputHelper.cs b/src/Web/Avalonia.Web/Interop/InputHelper.cs new file mode 100644 index 0000000000..6856a021f2 --- /dev/null +++ b/src/Web/Avalonia.Web/Interop/InputHelper.cs @@ -0,0 +1,46 @@ +using System; +using System.Runtime.InteropServices.JavaScript; + +namespace Avalonia.Web.Interop; + +internal static partial class InputHelper +{ + //[JSImport("InputHelper.subscribePointerEvents", "avalonia.ts")] + //public static partial void SubscribePointerEvents( + // JSObject htmlElement, + // [JSMarshalAs>] + // Func wheel); + + [JSImport("InputHelper.subscribeKeyboardEvents", "avalonia.ts")] + public static partial void SubscribeKeyboardEvents( + JSObject htmlElement, + [JSMarshalAs>] + Func keyDown, + [JSMarshalAs>] + Func keyUp); + + [JSImport("InputHelper.subscribeInputEvents", "avalonia.ts")] + public static partial void SubscribeInputEvents( + JSObject htmlElement, + [JSMarshalAs>] + Func input); + + + [JSImport("InputHelper.clearInput", "avalonia.ts")] + public static partial void ClearInputElement(JSObject htmlElement); + + [JSImport("InputHelper.isInputElement", "avalonia.ts")] + public static partial void IsInputElement(JSObject htmlElement); + + [JSImport("InputHelper.focusElement", "avalonia.ts")] + public static partial void FocusElement(JSObject htmlElement); + + [JSImport("InputHelper.setCursor", "avalonia.ts")] + public static partial void SetCursor(JSObject htmlElement, string kind); + + [JSImport("InputHelper.hide", "avalonia.ts")] + public static partial void HideElement(JSObject htmlElement); + + [JSImport("InputHelper.show", "avalonia.ts")] + public static partial void ShowElement(JSObject htmlElement); +} diff --git a/src/Web/Avalonia.Web/Keycodes.cs b/src/Web/Avalonia.Web/Keycodes.cs new file mode 100644 index 0000000000..d1185f6e45 --- /dev/null +++ b/src/Web/Avalonia.Web/Keycodes.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; + +using Avalonia.Input; + +namespace Avalonia.Web +{ + internal static class Keycodes + { + public static Dictionary KeyCodes = new() + { + { "Escape", Key.Escape }, + { "Digit1", Key.D1 }, + { "Digit2", Key.D2 }, + { "Digit3", Key.D3 }, + { "Digit4", Key.D4 }, + { "Digit5", Key.D5 }, + { "Digit6", Key.D6 }, + { "Digit7", Key.D7 }, + { "Digit8", Key.D8 }, + { "Digit9", Key.D9 }, + { "Digit0", Key.D0 }, + { "Minus", Key.OemMinus }, + //{ "Equal" , Key. }, + { "Backspace", Key.Back }, + { "Tab", Key.Tab }, + { "KeyQ", Key.Q }, + { "KeyW", Key.W }, + { "KeyE", Key.E }, + { "KeyR", Key.R }, + { "KeyT", Key.T }, + { "KeyY", Key.Y }, + { "KeyU", Key.U }, + { "KeyI", Key.I }, + { "KeyO", Key.O }, + { "KeyP", Key.P }, + { "BracketLeft", Key.OemOpenBrackets }, + { "BracketRight", Key.OemCloseBrackets }, + { "Enter", Key.Enter }, + { "ControlLeft", Key.LeftCtrl }, + { "KeyA", Key.A }, + { "KeyS", Key.S }, + { "KeyD", Key.D }, + { "KeyF", Key.F }, + { "KeyG", Key.G }, + { "KeyH", Key.H }, + { "KeyJ", Key.J }, + { "KeyK", Key.K }, + { "KeyL", Key.L }, + { "Semicolon", Key.OemSemicolon }, + { "Quote", Key.OemQuotes }, + //{ "Backquote" , Key. }, + { "ShiftLeft", Key.LeftShift }, + { "Backslash", Key.OemBackslash }, + { "KeyZ", Key.Z }, + { "KeyX", Key.X }, + { "KeyC", Key.C }, + { "KeyV", Key.V }, + { "KeyB", Key.B }, + { "KeyN", Key.N }, + { "KeyM", Key.M }, + { "Comma", Key.OemComma }, + { "Period", Key.OemPeriod }, + //{ "Slash" , Key. }, + { "ShiftRight", Key.RightShift }, + { "NumpadMultiply", Key.Multiply }, + { "AltLeft", Key.LeftAlt }, + { "Space", Key.Space }, + { "CapsLock", Key.CapsLock }, + { "F1", Key.F1 }, + { "F2", Key.F2 }, + { "F3", Key.F3 }, + { "F4", Key.F4 }, + { "F5", Key.F5 }, + { "F6", Key.F6 }, + { "F7", Key.F7 }, + { "F8", Key.F8 }, + { "F9", Key.F9 }, + { "F10", Key.F10 }, + { "NumLock", Key.NumLock }, + { "ScrollLock", Key.Scroll }, + { "Numpad7", Key.NumPad7 }, + { "Numpad8", Key.NumPad8 }, + { "Numpad9", Key.NumPad9 }, + { "NumpadSubtract", Key.Subtract }, + { "Numpad4", Key.NumPad4 }, + { "Numpad5", Key.NumPad5 }, + { "Numpad6", Key.NumPad6 }, + { "NumpadAdd", Key.Add }, + { "Numpad1", Key.NumPad1 }, + { "Numpad2", Key.NumPad2 }, + { "Numpad3", Key.NumPad3 }, + { "Numpad0", Key.NumPad0 }, + { "NumpadDecimal", Key.Decimal }, + { "Unidentified", Key.NoName }, + //{ "IntlBackslash" , Key.bac }, + { "F11", Key.F11 }, + { "F12", Key.F12 }, + //{ "IntlRo" , Key.Ro }, + //{ "Unidentified" , Key. }, + { "Convert", Key.ImeConvert }, + { "KanaMode", Key.KanaMode }, + { "NonConvert", Key.ImeNonConvert }, + //{ "Unidentified" , Key. }, + { "NumpadEnter", Key.Enter }, + { "ControlRight", Key.RightCtrl }, + { "NumpadDivide", Key.Divide }, + { "PrintScreen", Key.PrintScreen }, + { "AltRight", Key.RightAlt }, + //{ "Unidentified" , Key. }, + { "Home", Key.Home }, + { "ArrowUp", Key.Up }, + { "PageUp", Key.PageUp }, + { "ArrowLeft", Key.Left }, + { "ArrowRight", Key.Right }, + { "End", Key.End }, + { "ArrowDown", Key.Down }, + { "PageDown", Key.PageDown }, + { "Insert", Key.Insert }, + { "Delete", Key.Delete }, + //{ "Unidentified" , Key. }, + { "AudioVolumeMute", Key.VolumeMute }, + { "AudioVolumeDown", Key.VolumeDown }, + { "AudioVolumeUp", Key.VolumeUp }, + //{ "NumpadEqual" , Key. }, + { "Pause", Key.Pause }, + { "NumpadComma", Key.OemComma } + }; + } +} diff --git a/src/Web/Avalonia.Web/RazorViewTopLevelImpl.cs b/src/Web/Avalonia.Web/RazorViewTopLevelImpl.cs index 82f4064e1a..2bcc36e687 100644 --- a/src/Web/Avalonia.Web/RazorViewTopLevelImpl.cs +++ b/src/Web/Avalonia.Web/RazorViewTopLevelImpl.cs @@ -110,14 +110,15 @@ namespace Avalonia.Web.Blazor public bool RawKeyboardEvent(RawKeyEventType type, string code, string key, RawInputModifiers modifiers) { - /*if (Keycodes.KeyCodes.TryGetValue(code, out var avkey)) + Console.WriteLine($"{type} {code} {key} {modifiers}"); + if (Keycodes.KeyCodes.TryGetValue(code, out var avkey)) { if (_inputRoot is { }) { var args = new RawKeyEventArgs(KeyboardDevice, Timestamp, _inputRoot, type, avkey, modifiers); - + Input?.Invoke(args); - + return args.Handled; } } @@ -126,12 +127,12 @@ namespace Avalonia.Web.Blazor if (_inputRoot is { }) { var args = new RawKeyEventArgs(KeyboardDevice, Timestamp, _inputRoot, type, avkey, modifiers); - + Input?.Invoke(args); return args.Handled; } - }*/ + } return false; } diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia/input.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia/input.ts new file mode 100644 index 0000000000..a6734c8ce0 --- /dev/null +++ b/src/Web/Avalonia.Web/webapp/modules/avalonia/input.ts @@ -0,0 +1,107 @@ +enum RawInputModifiers +{ + None = 0, + Alt = 1, + Control = 2, + Shift = 4, + Meta = 8, + + LeftMouseButton = 16, + RightMouseButton = 32, + MiddleMouseButton = 64, + XButton1MouseButton = 128, + XButton2MouseButton = 256, + KeyboardMask = Alt | Control | Shift | Meta, + + PenInverted = 512, + PenEraser = 1024, + PenBarrelButton = 2048 +} + +export class InputHelper { + public static subscribeKeyboardEvents( + element: HTMLInputElement, + keyDownCallback: (code: string, key: string, modifiers: RawInputModifiers) => boolean, + keyUpCallback: (code: string, key: string, modifiers: RawInputModifiers) => boolean, + ) { + const keyDownHandler = (args: Event) => { + const keyArgs = args; + if (keyDownCallback(keyArgs.code, keyArgs.key, this.getModifiers(keyArgs))) { + args.preventDefault(); + } + }; + element.addEventListener("keydown", keyDownHandler); + + const keyUpHandler = (args: Event) => { + const keyArgs = args; + if (keyUpCallback(keyArgs.code, keyArgs.key, this.getModifiers(keyArgs))) { + args.preventDefault(); + } + }; + element.addEventListener("keyup", keyUpHandler); + + return () => { + element.removeEventListener("keydown", keyDownHandler); + element.removeEventListener("keyup", keyUpHandler); + }; + } + + public static subscribeInputEvents( + element: HTMLInputElement, + inputCallback: (value: string) => boolean + ) { + const inputHandler = (args: Event) => { + if (inputCallback((args as any).value)) { + args.preventDefault(); + } + }; + element.addEventListener("input", inputHandler); + + return () => { + element.removeEventListener("input", inputHandler); + }; + } + + public static clearInput(inputElement: HTMLInputElement) { + inputElement.value = ""; + } + + public static isInputElement(element: HTMLInputElement | HTMLElement): element is HTMLInputElement { + return (element as HTMLInputElement).setSelectionRange !== undefined; + } + + public static focusElement(inputElement: HTMLElement) { + inputElement.focus(); + + if (this.isInputElement(inputElement)) { + (inputElement as HTMLInputElement).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'; + } + + private static getModifiers(args: KeyboardEvent): RawInputModifiers { + var modifiers = RawInputModifiers.None; + + if (args.ctrlKey) + modifiers |= RawInputModifiers.Control; + if (args.altKey) + modifiers |= RawInputModifiers.Alt; + if (args.shiftKey) + modifiers |= RawInputModifiers.Shift; + if (args.metaKey) + modifiers |= RawInputModifiers.Meta; + + return modifiers; + } +} diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia/runtime.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia/runtime.ts index bb97d0c9b7..2f154aeb3f 100644 --- a/src/Web/Avalonia.Web/webapp/modules/avalonia/runtime.ts +++ b/src/Web/Avalonia.Web/webapp/modules/avalonia/runtime.ts @@ -1,6 +1,7 @@ import { RuntimeAPI } from "../../types/dotnet"; import { Canvas } from "./canvas"; +import { InputHelper } from "./input"; export class AvaloniaRuntime { constructor( @@ -8,7 +9,8 @@ export class AvaloniaRuntime { api: RuntimeAPI ) { api.setModuleImports("avalonia.ts", { - Canvas + Canvas, + InputHelper }); } } From 8402e6b5f2f22e2bd571b9d9fe782d1bb5cc1373 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 23 Sep 2022 22:01:52 -0400 Subject: [PATCH 2/4] Delete generated files --- src/Web/Avalonia.Web/wwwroot/avalonia.js | 124 ----------------------- src/Web/Avalonia.Web/wwwroot/storage.js | 10 -- 2 files changed, 134 deletions(-) delete mode 100644 src/Web/Avalonia.Web/wwwroot/avalonia.js delete mode 100644 src/Web/Avalonia.Web/wwwroot/storage.js diff --git a/src/Web/Avalonia.Web/wwwroot/avalonia.js b/src/Web/Avalonia.Web/wwwroot/avalonia.js deleted file mode 100644 index 8c2721c600..0000000000 --- a/src/Web/Avalonia.Web/wwwroot/avalonia.js +++ /dev/null @@ -1,124 +0,0 @@ -var __async = (__this, __arguments, generator) => { - return new Promise((resolve, reject) => { - var fulfilled = (value) => { - try { - step(generator.next(value)); - } catch (e) { - reject(e); - } - }; - var rejected = (value) => { - try { - step(generator.throw(value)); - } catch (e) { - reject(e); - } - }; - var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); - step((generator = generator.apply(__this, __arguments)).next()); - }); -}; - -// modules/avalonia/canvas.ts -var Canvas = class { - constructor(useGL, element) { - this.renderLoopEnabled = false; - this.renderLoopRequest = 0; - if (useGL) { - const ctx = Canvas.createWebGLContext(element); - if (!ctx) { - console.error(`Failed to create WebGL context: err ${ctx}`); - return; - } - var GL = globalThis.AvaloniaGL; - GL.makeContextCurrent(ctx); - var GLctx = GL.currentContext.GLctx; - 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 createCanvas(element) { - var canvas = document.createElement("canvas"); - element.appendChild(canvas); - return canvas; - } - static initGL(element, elementId) { - console.log("inside initGL"); - var view = Canvas.init(true, element, elementId); - if (!view || !view.glInfo) - return null; - return view.glInfo; - } - static init(useGL, element, elementId) { - var htmlCanvas = element; - if (!htmlCanvas) { - console.error(`No canvas element was provided.`); - return null; - } - if (!Canvas.elements) - Canvas.elements = /* @__PURE__ */ new Map(); - Canvas.elements.set(elementId, element); - const view = new Canvas(useGL, element); - htmlCanvas.SKHtmlCanvas = view; - return view; - } - static Foo(canvas) { - const ctx = canvas.getContext("2d"); - ctx.fillStyle = "#FF0000"; - ctx.fillRect(0, 0, 150, 75); - } - 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 - }; - var GL = globalThis.AvaloniaGL; - 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; - } -}; - -// modules/avalonia/runtime.ts -var AvaloniaRuntime = class { - constructor(dotnetAssembly, api) { - this.dotnetAssembly = dotnetAssembly; - api.setModuleImports("avalonia.ts", { - Canvas - }); - } -}; - -// modules/avalonia.ts -function createAvaloniaRuntime(api) { - return __async(this, null, function* () { - const dotnetAssembly = yield api.getAssemblyExports("Avalonia.Web.dll"); - return new AvaloniaRuntime(dotnetAssembly, api); - }); -} -export { - createAvaloniaRuntime -}; -//# sourceMappingURL=avalonia.js.map diff --git a/src/Web/Avalonia.Web/wwwroot/storage.js b/src/Web/Avalonia.Web/wwwroot/storage.js deleted file mode 100644 index a2042aaa11..0000000000 --- a/src/Web/Avalonia.Web/wwwroot/storage.js +++ /dev/null @@ -1,10 +0,0 @@ -// modules/storage.ts -var StorageProvider = class { - static isFileApiSupported() { - return globalThis.showOpenFilePicker !== void 0; - } -}; -export { - StorageProvider -}; -//# sourceMappingURL=storage.js.map From 03db5ac8986544c1b070fbbee3971baf52caa38c Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 23 Sep 2022 22:02:26 -0400 Subject: [PATCH 3/4] Ignore wwwroot of web project --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 84faae1806..61a3b53de1 100644 --- a/.gitignore +++ b/.gitignore @@ -215,3 +215,4 @@ src/Web/Avalonia.Web.Blazor/Interop/Typescript/*.js node_modules src/Web/Avalonia.Web.Blazor/webapp/package-lock.json src/Web/Avalonia.Web.Blazor/wwwroot +src/Web/Avalonia.Web/wwwroot From 722c05acc73e46604306c96c4efba5495b4c718d Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 23 Sep 2022 22:16:57 -0400 Subject: [PATCH 4/4] Add initial stream interop --- src/Web/Avalonia.Web/Interop/StreamHelper.cs | 55 +++++++ .../Storage/BlobReadableStream.cs | 100 +++++++++++++ .../Avalonia.Web/Storage/WriteableStream.cs | 136 ++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 src/Web/Avalonia.Web/Interop/StreamHelper.cs create mode 100644 src/Web/Avalonia.Web/Storage/BlobReadableStream.cs create mode 100644 src/Web/Avalonia.Web/Storage/WriteableStream.cs diff --git a/src/Web/Avalonia.Web/Interop/StreamHelper.cs b/src/Web/Avalonia.Web/Interop/StreamHelper.cs new file mode 100644 index 0000000000..c77d87fbe4 --- /dev/null +++ b/src/Web/Avalonia.Web/Interop/StreamHelper.cs @@ -0,0 +1,55 @@ +using System; +using System.Runtime.InteropServices.JavaScript; +using System.Threading.Tasks; + +namespace Avalonia.Web.Storage; + +/// +/// Set of FileSystemWritableFileStream and Blob methods. +/// +internal static partial class StreamHelper +{ + [JSImport("StreamHelper.seek", "avalonia.ts")] + public static partial void Seek(JSObject stream, [JSMarshalAs] long position); + + [JSImport("StreamHelper.truncate", "avalonia.ts")] + public static partial void Truncate(JSObject stream, [JSMarshalAs] long position); + + [JSImport("StreamHelper.write", "avalonia.ts")] + public static partial void Write(JSObject stream, [JSMarshalAs] Span data); + + [JSImport("StreamHelper.write", "avalonia.ts")] + public static partial Task WriteAsync(JSObject stream, [JSMarshalAs] ArraySegment data); + + [JSImport("StreamHelper.close", "avalonia.ts")] + public static partial void Close(JSObject stream); + + [JSImport("StreamHelper.close", "avalonia.ts")] + public static partial Task CloseAsync(JSObject stream); + + [JSImport("StreamHelper.size", "avalonia.ts")] + [return: JSMarshalAs] + public static partial long Size(JSObject stream); + + [JSImport("StreamHelper.byteLength", "avalonia.ts")] + [return: JSMarshalAs] + public static partial long ByteLength(JSObject stream); + + [JSImport("StreamHelper.sliceToArray", "avalonia.ts")] + [return: JSMarshalAs] + public static partial Span Slice(JSObject stream, [JSMarshalAs] long offset, int count); + + public static async Task> SliceAsync(JSObject stream, long offset, int count) + { + using var buffer = await SliceToBufferAsync(stream, offset, count); + return BufferToArray(buffer); + } + + [JSImport("StreamHelper.slice", "avalonia.ts")] + [return: JSMarshalAs>] + private static partial Task SliceToBufferAsync(JSObject stream, [JSMarshalAs] long offset, int count); + + [JSImport("StreamHelper.toArray", "avalonia.ts")] + [return: JSMarshalAs] + private static partial ArraySegment BufferToArray(JSObject stream); +} diff --git a/src/Web/Avalonia.Web/Storage/BlobReadableStream.cs b/src/Web/Avalonia.Web/Storage/BlobReadableStream.cs new file mode 100644 index 0000000000..6afc5e3054 --- /dev/null +++ b/src/Web/Avalonia.Web/Storage/BlobReadableStream.cs @@ -0,0 +1,100 @@ +using System; +using System.IO; +using System.Runtime.InteropServices.JavaScript; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia.Web.Storage; + +internal class BlobReadableStream : Stream +{ + private JSObject? _jSReference; + private long _position; + private readonly long _length; + + public BlobReadableStream(JSObject jsStreamReference) + { + _jSReference = jsStreamReference; + _position = 0; + _length = StreamHelper.ByteLength(JSReference); + } + + private JSObject JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(JSWriteableStream)); + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => _length; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() { } + + public override long Seek(long offset, SeekOrigin origin) + { + return _position = origin switch + { + SeekOrigin.Current => _position + offset, + SeekOrigin.End => _length + offset, + _ => offset + }; + } + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + => Read(buffer.AsSpan(offset, count)); + + public override int Read(Span buffer) + { + var numBytesToRead = (int)Math.Min(buffer.Length, Length - _position); + var bytesRead = StreamHelper.Slice(JSReference, _position, numBytesToRead); + if (bytesRead.Length != numBytesToRead) + { + throw new EndOfStreamException("Failed to read the requested number of bytes from the stream."); + } + + _position += bytesRead.Length; + bytesRead.CopyTo(buffer); + + return bytesRead.Length; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var numBytesToRead = (int)Math.Min(buffer.Length, Length - _position); + var bytesRead = await StreamHelper.SliceAsync(JSReference, _position, numBytesToRead); + if (bytesRead.Count != numBytesToRead) + { + throw new EndOfStreamException("Failed to read the requested number of bytes from the stream."); + } + + _position += bytesRead.Count; + bytesRead.AsMemory().CopyTo(buffer); + + return bytesRead.Count; + } + + protected override void Dispose(bool disposing) + { + if (_jSReference is { } jsReference) + { + _jSReference = null; + jsReference.Dispose(); + } + } +} diff --git a/src/Web/Avalonia.Web/Storage/WriteableStream.cs b/src/Web/Avalonia.Web/Storage/WriteableStream.cs new file mode 100644 index 0000000000..4dce5a4c6c --- /dev/null +++ b/src/Web/Avalonia.Web/Storage/WriteableStream.cs @@ -0,0 +1,136 @@ +using System; +using System.IO; +using System.Runtime.InteropServices.JavaScript; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia.Web.Storage; + +// Loose wrapper implementaion of a stream on top of FileAPI FileSystemWritableFileStream +internal sealed class JSWriteableStream : Stream +{ + private JSObject? _jSReference; + + // Unfortunatelly we can't read current length/position, so we need to keep it C#-side only. + private long _length, _position; + + internal JSWriteableStream(JSObject jSReference, long initialLength) + { + _jSReference = jSReference; + _length = initialLength; + } + + private JSObject JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(JSWriteableStream)); + + public override bool CanRead => false; + + public override bool CanSeek => true; + + public override bool CanWrite => true; + + public override long Length => _length; + + public override long Position + { + get => _position; + set => Seek(_position, SeekOrigin.Begin); + } + + public override void Flush() + { + // no-op + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + var position = origin switch + { + SeekOrigin.Current => _position + offset, + SeekOrigin.End => _length + offset, + _ => offset + }; + StreamHelper.Seek(JSReference, position); + return position; + } + + public override void SetLength(long value) + { + _length = value; + + // See https://docs.w3cub.com/dom/filesystemwritablefilestream/truncate + // If the offset is smaller than the size, it remains unchanged. If the offset is larger than size, the offset is set to that size + if (_position > _length) + { + _position = _length; + } + + StreamHelper.Truncate(JSReference, value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + StreamHelper.Write(JSReference, buffer.AsSpan(offset, count)); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (offset != 0 || count != buffer.Length) + { + // TODO, we need to pass prepared buffer to the JS + // Can't use ArrayPool as it can return bigger array than requested + // Can't use Span/Memory, as it's not supported by JS interop yet. + // Alternatively we can pass original buffer and offset+count, so it can be trimmed on the JS side (but is it more efficient tho?) + buffer = buffer.AsMemory(offset, count).ToArray(); + } + return WriteAsyncInternal(buffer, cancellationToken); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + return new ValueTask(WriteAsyncInternal(buffer.ToArray(), cancellationToken)); + } + + private Task WriteAsyncInternal(byte[] buffer, CancellationToken _) + { + _position += buffer.Length; + + return StreamHelper.WriteAsync(JSReference, buffer); + } + + protected override void Dispose(bool disposing) + { + if (_jSReference is { } jsReference) + { + _jSReference = null; + try + { + StreamHelper.Close(JSReference); + } + finally + { + jsReference.Dispose(); + } + } + } + + public override async ValueTask DisposeAsync() + { + if (_jSReference is { } jsReference) + { + _jSReference = null; + try + { + await StreamHelper.CloseAsync(JSReference); + } + finally + { + jsReference.Dispose(); + } + } + } +}