diff --git a/src/Components/Web.Extensions/src/InputFile/BrowserFile.cs b/src/Components/Web.Extensions/src/InputFile/BrowserFile.cs new file mode 100644 index 0000000000..ede5b7dc75 --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/BrowserFile.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + internal class BrowserFile : IBrowserFile + { + internal InputFile Owner { get; set; } = default!; + + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public DateTime LastModified { get; set; } + + public long Size { get; set; } + + public string Type { get; set; } = string.Empty; + + public string? RelativePath { get; set; } + + public Stream OpenReadStream(CancellationToken cancellationToken = default) + => Owner.OpenReadStream(this, cancellationToken); + + public Task ToImageFileAsync(string format, int maxWidth, int maxHeight) + => Owner.ConvertToImageFileAsync(this, format, maxWidth, maxHeight); + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/BrowserFileStream.cs b/src/Components/Web.Extensions/src/InputFile/BrowserFileStream.cs new file mode 100644 index 0000000000..539e0c1d34 --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/BrowserFileStream.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + internal abstract class BrowserFileStream : Stream + { + private long _position; + + protected BrowserFile File { get; } + + protected BrowserFileStream(BrowserFile file) + { + File = file; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => File.Size; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() + => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException("Synchronous reads are not supported."); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => ReadAsync(new Memory(buffer, offset, count), cancellationToken).AsTask(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + int maxBytesToRead = (int)(Length - Position); + + if (maxBytesToRead > buffer.Length) + { + maxBytesToRead = buffer.Length; + } + + if (maxBytesToRead <= 0) + { + return 0; + } + + var bytesRead = await CopyFileDataIntoBuffer(_position, buffer.Slice(0, maxBytesToRead), cancellationToken); + + _position += bytesRead; + + return bytesRead; + } + + protected abstract ValueTask CopyFileDataIntoBuffer(long sourceOffset, Memory destination, CancellationToken cancellationToken); + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/IBrowserFile.cs b/src/Components/Web.Extensions/src/InputFile/IBrowserFile.cs new file mode 100644 index 0000000000..17d673e642 --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/IBrowserFile.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + /// + /// Represents the data of a file selected from an component. + /// + public interface IBrowserFile + { + /// + /// Gets the name of the file. + /// + string Name { get; } + + /// + /// Gets the last modified date. + /// + DateTime LastModified { get; } + + /// + /// Gets the size of the file in bytes. + /// + long Size { get; } + + /// + /// Gets the MIME type of the file. + /// + string Type { get; } + + /// + /// Opens the stream for reading the uploaded file. + /// + /// A cancellation token to signal the cancellation of streaming file data. + Stream OpenReadStream(CancellationToken cancellationToken = default); + + /// + /// Converts the current image file to a new one of the specified file type and maximum file dimensions. + /// + /// + /// The image will be scaled to fit the specified dimensions while preserving the original aspect ratio. + /// + /// The new image format. + /// The maximum image width. + /// The maximum image height + /// A representing the completion of the operation. + Task ToImageFileAsync(string format, int maxWith, int maxHeight); + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/IInputFileJsCallbacks.cs b/src/Components/Web.Extensions/src/InputFile/IInputFileJsCallbacks.cs new file mode 100644 index 0000000000..853a44a0c2 --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/IInputFileJsCallbacks.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + internal interface IInputFileJsCallbacks + { + Task NotifyChange(BrowserFile[] files); + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/InputFile.cs b/src/Components/Web.Extensions/src/InputFile/InputFile.cs new file mode 100644 index 0000000000..1f73eb2d6a --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/InputFile.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + /// + /// A component that wraps the HTML file input element and exposes a for each file's contents. + /// + public class InputFile : ComponentBase, IInputFileJsCallbacks, IDisposable + { + private ElementReference _inputFileElement; + + private IJSUnmarshalledRuntime? _jsUnmarshalledRuntime; + + private InputFileJsCallbacksRelay? _jsCallbacksRelay; + + [Inject] + private IJSRuntime JSRuntime { get; set; } = default!; + + [Inject] + private IOptions Options { get; set; } = default!; + + /// + /// Gets or sets the event callback that will be invoked when the collection of selected files changes. + /// + [Parameter] + public EventCallback OnChange { get; set; } + + /// + /// Gets or sets a collection of additional attributes that will be applied to the input element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary? AdditionalAttributes { get; set; } + + protected override void OnInitialized() + { + _jsUnmarshalledRuntime = JSRuntime as IJSUnmarshalledRuntime; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _jsCallbacksRelay = new InputFileJsCallbacksRelay(this); + await JSRuntime.InvokeVoidAsync(InputFileInterop.Init, _jsCallbacksRelay.DotNetReference, _inputFileElement); + } + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "input"); + builder.AddMultipleAttributes(1, AdditionalAttributes); + builder.AddAttribute(2, "type", "file"); + builder.AddElementReferenceCapture(3, elementReference => _inputFileElement = elementReference); + builder.CloseElement(); + } + + internal Stream OpenReadStream(BrowserFile file, CancellationToken cancellationToken) + => _jsUnmarshalledRuntime != null ? + (Stream)new SharedBrowserFileStream(JSRuntime, _jsUnmarshalledRuntime, _inputFileElement, file) : + new RemoteBrowserFileStream(JSRuntime, _inputFileElement, file, Options.Value, cancellationToken); + + internal async Task ConvertToImageFileAsync(BrowserFile file, string format, int maxWidth, int maxHeight) + { + var imageFile = await JSRuntime.InvokeAsync(InputFileInterop.ToImageFile, _inputFileElement, file.Id, format, maxWidth, maxHeight); + + imageFile.Owner = this; + + return imageFile; + } + + Task IInputFileJsCallbacks.NotifyChange(BrowserFile[] files) + { + foreach (var file in files) + { + file.Owner = this; + } + + return OnChange.InvokeAsync(new InputFileChangeEventArgs(files)); + } + + void IDisposable.Dispose() + { + _jsCallbacksRelay?.Dispose(); + } + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/InputFileChangeEventArgs.cs b/src/Components/Web.Extensions/src/InputFile/InputFileChangeEventArgs.cs new file mode 100644 index 0000000000..ae1dba5533 --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/InputFileChangeEventArgs.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + /// + /// Supplies information about an event being raised. + /// + public class InputFileChangeEventArgs : EventArgs + { + /// + /// The updated file entries list. + /// + public IReadOnlyList Files { get; } + + /// + /// Constructs a new instance. + /// + /// The updated file entries list. + public InputFileChangeEventArgs(IReadOnlyList files) + { + Files = files; + } + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/InputFileInterop.cs b/src/Components/Web.Extensions/src/InputFile/InputFileInterop.cs new file mode 100644 index 0000000000..15fba058d4 --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/InputFileInterop.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + internal static class InputFileInterop + { + private const string JsFunctionsPrefix = "_blazorInputFile."; + + public const string Init = JsFunctionsPrefix + "init"; + + public const string EnsureArrayBufferReadyForSharedMemoryInterop = JsFunctionsPrefix + "ensureArrayBufferReadyForSharedMemoryInterop"; + + public const string ReadFileData = JsFunctionsPrefix + "readFileData"; + + public const string ReadFileDataSharedMemory = JsFunctionsPrefix + "readFileDataSharedMemory"; + + public const string ToImageFile = JsFunctionsPrefix + "toImageFile"; + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/InputFileJsCallbacksRelay.cs b/src/Components/Web.Extensions/src/InputFile/InputFileJsCallbacksRelay.cs new file mode 100644 index 0000000000..c2d4a0aadd --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/InputFileJsCallbacksRelay.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + internal class InputFileJsCallbacksRelay : IDisposable + { + private readonly IInputFileJsCallbacks _callbacks; + + public IDisposable DotNetReference { get; } + + public InputFileJsCallbacksRelay(IInputFileJsCallbacks callbacks) + { + _callbacks = callbacks; + + DotNetReference = DotNetObjectReference.Create(this); + } + + [JSInvokable] + public Task NotifyChange(BrowserFile[] files) + => _callbacks.NotifyChange(files); + + public void Dispose() + { + DotNetReference.Dispose(); + } + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/ReadRequest.cs b/src/Components/Web.Extensions/src/InputFile/ReadRequest.cs new file mode 100644 index 0000000000..206667eb96 --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/ReadRequest.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + [StructLayout(LayoutKind.Explicit)] + internal struct ReadRequest + { + [FieldOffset(0)] + public string InputFileElementReferenceId; + + [FieldOffset(4)] + public int FileId; + + [FieldOffset(8)] + public long SourceOffset; + + [FieldOffset(16)] + public byte[] Destination; + + [FieldOffset(20)] + public int DestinationOffset; + + [FieldOffset(24)] + public int MaxBytes; + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/RemoteBrowserFileStream.cs b/src/Components/Web.Extensions/src/InputFile/RemoteBrowserFileStream.cs new file mode 100644 index 0000000000..cb22b6ac8c --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/RemoteBrowserFileStream.cs @@ -0,0 +1,145 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + internal class RemoteBrowserFileStream : BrowserFileStream + { + private readonly IJSRuntime _jsRuntime; + private readonly ElementReference _inputFileElement; + private readonly int _maxSegmentSize; + private readonly PipeReader _pipeReader; + private readonly CancellationTokenSource _fillBufferCts; + private readonly TimeSpan _segmentFetchTimeout; + + private bool _isReadingCompleted; + private bool _isDisposed; + + public RemoteBrowserFileStream( + IJSRuntime jsRuntime, + ElementReference inputFileElement, + BrowserFile file, + RemoteBrowserFileStreamOptions options, + CancellationToken cancellationToken) + : base(file) + { + _jsRuntime = jsRuntime; + _inputFileElement = inputFileElement; + _maxSegmentSize = options.SegmentSize; + _segmentFetchTimeout = options.SegmentFetchTimeout; + + var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: options.MaxBufferSize, resumeWriterThreshold: options.MaxBufferSize)); + _pipeReader = pipe.Reader; + _fillBufferCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + _ = FillBuffer(pipe.Writer, _fillBufferCts.Token); + } + + private async Task FillBuffer(PipeWriter writer, CancellationToken cancellationToken) + { + long offset = 0; + + while (offset < File.Size) + { + var pipeBuffer = writer.GetMemory(_maxSegmentSize); + var segmentSize = (int)Math.Min(_maxSegmentSize, File.Size - offset); + + try + { + using var readSegmentCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + readSegmentCts.CancelAfter(_segmentFetchTimeout); + + var bytes = await _jsRuntime.InvokeAsync( + InputFileInterop.ReadFileData, + readSegmentCts.Token, + _inputFileElement, + File.Id, + offset, + segmentSize); + + if (bytes.Length != segmentSize) + { + throw new InvalidOperationException( + $"A segment with size {bytes.Length} bytes was received, but {segmentSize} bytes were expected."); + } + + bytes.CopyTo(pipeBuffer); + writer.Advance(segmentSize); + offset += segmentSize; + + var result = await writer.FlushAsync(cancellationToken); + + if (result.IsCompleted) + { + break; + } + } + catch (Exception e) + { + await writer.CompleteAsync(e); + return; + } + } + + await writer.CompleteAsync(); + } + + protected override async ValueTask CopyFileDataIntoBuffer(long sourceOffset, Memory destination, CancellationToken cancellationToken) + { + if (_isReadingCompleted) + { + return 0; + } + + int totalBytesCopied = 0; + + while (destination.Length > 0) + { + var result = await _pipeReader.ReadAsync(cancellationToken); + var bytesToCopy = (int)Math.Min(result.Buffer.Length, destination.Length); + + if (bytesToCopy == 0) + { + if (result.IsCompleted) + { + _isReadingCompleted = true; + await _pipeReader.CompleteAsync(); + } + + break; + } + + var slice = result.Buffer.Slice(0, bytesToCopy); + slice.CopyTo(destination.Span); + + _pipeReader.AdvanceTo(slice.End); + + totalBytesCopied += bytesToCopy; + destination = destination.Slice(bytesToCopy); + } + + return totalBytesCopied; + } + + protected override void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + _fillBufferCts.Cancel(); + + _isDisposed = true; + + base.Dispose(disposing); + } + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/RemoteBrowserFileStreamOptions.cs b/src/Components/Web.Extensions/src/InputFile/RemoteBrowserFileStreamOptions.cs new file mode 100644 index 0000000000..b594f29d04 --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/RemoteBrowserFileStreamOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.Versioning; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + /// + /// Repesents configurable options for . + /// + [UnsupportedOSPlatform("browser")] + public class RemoteBrowserFileStreamOptions + { + /// + /// Gets or sets the maximum segment size for file data sent over a SignalR circuit. + /// The default value is 20K. + /// + /// This only has an effect when using Blazor Server. + /// + /// + public int SegmentSize { get; set; } = 20 * 1024; // SignalR limit is 32K. + + /// + /// Gets or sets the maximum internal buffer size for unread data sent over a SignalR circuit. + /// + /// This only has an effect when using Blazor Server. + /// + /// + public int MaxBufferSize { get; set; } = 1024 * 1024; + + /// + /// Gets or sets the time limit for fetching a segment of file data. + /// + /// This only has an effect when using Blazor Server. + /// + /// + public TimeSpan SegmentFetchTimeout { get; set; } = TimeSpan.FromSeconds(3); + } +} diff --git a/src/Components/Web.Extensions/src/InputFile/SharedBrowserFileStream.cs b/src/Components/Web.Extensions/src/InputFile/SharedBrowserFileStream.cs new file mode 100644 index 0000000000..bf7e50984c --- /dev/null +++ b/src/Components/Web.Extensions/src/InputFile/SharedBrowserFileStream.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components.Web.Extensions +{ + internal class SharedBrowserFileStream : BrowserFileStream + { + private readonly IJSRuntime _jsRuntime; + + private readonly IJSUnmarshalledRuntime _jsUnmarshalledRuntime; + + private readonly ElementReference _inputFileElement; + + public SharedBrowserFileStream(IJSRuntime jsRuntime, IJSUnmarshalledRuntime jsUnmarshalledRuntime, ElementReference inputFileElement, BrowserFile file) + : base(file) + { + _jsRuntime = jsRuntime; + _jsUnmarshalledRuntime = jsUnmarshalledRuntime; + _inputFileElement = inputFileElement; + } + + protected override async ValueTask CopyFileDataIntoBuffer(long sourceOffset, Memory destination, CancellationToken cancellationToken) + { + await _jsRuntime.InvokeVoidAsync(InputFileInterop.EnsureArrayBufferReadyForSharedMemoryInterop, cancellationToken, _inputFileElement, File.Id); + + var readRequest = new ReadRequest + { + InputFileElementReferenceId = _inputFileElement.Id, + FileId = File.Id, + SourceOffset = sourceOffset + }; + + if (MemoryMarshal.TryGetArray(destination, out ArraySegment destinationArraySegment)) + { + readRequest.Destination = destinationArraySegment.Array!; + readRequest.DestinationOffset = destinationArraySegment.Offset; + readRequest.MaxBytes = destinationArraySegment.Count; + } + else + { + // Worst case, we need to copy to a temporary array. + readRequest.Destination = new byte[destination.Length]; + readRequest.DestinationOffset = 0; + readRequest.MaxBytes = destination.Length; + + destination.CopyTo(new Memory(readRequest.Destination)); + } + + return _jsUnmarshalledRuntime.InvokeUnmarshalled(InputFileInterop.ReadFileDataSharedMemory, readRequest); + } + } +} diff --git a/src/Components/Web.Extensions/src/wwwroot/inputFile.js b/src/Components/Web.Extensions/src/wwwroot/inputFile.js new file mode 100644 index 0000000000..1fcec6929f --- /dev/null +++ b/src/Components/Web.Extensions/src/wwwroot/inputFile.js @@ -0,0 +1,142 @@ +(function () { + + // Exported functions + + function init(callbackWrapper, elem) { + elem._blazorInputFileNextFileId = 0; + + elem.addEventListener('click', function () { + // Permits replacing an existing file with a new one of the same file name. + elem.value = ''; + }); + + elem.addEventListener('change', function () { + // Reduce to purely serializable data, plus an index by ID. + elem._blazorFilesById = {}; + + const fileList = Array.prototype.map.call(elem.files, function (file) { + const result = { + id: ++elem._blazorInputFileNextFileId, + lastModified: new Date(file.lastModified).toISOString(), + name: file.name, + size: file.size, + type: file.type, + }; + + elem._blazorFilesById[result.id] = result; + + // Attach the blob data itself as a non-enumerable property so it doesn't appear in the JSON. + Object.defineProperty(result, 'blob', { value: file }); + + return result; + }); + + callbackWrapper.invokeMethodAsync('NotifyChange', fileList); + }); + } + + function toImageFile(elem, fileId, format, maxWidth, maxHeight) { + var originalFile = getFileById(elem, fileId); + + return new Promise(function (resolve) { + var originalFileImage = new Image(); + originalFileImage.onload = function () { resolve(originalFileImage); }; + originalFileImage.src = URL.createObjectURL(originalFile.blob); + }).then(function (loadedImage) { + return new Promise(function (resolve) { + var desiredWidthRatio = Math.min(1, maxWidth / loadedImage.width); + var desiredHeightRatio = Math.min(1, maxHeight / loadedImage.height); + var chosenSizeRatio = Math.min(desiredWidthRatio, desiredHeightRatio); + + var canvas = document.createElement('canvas'); + canvas.width = Math.round(loadedImage.width * chosenSizeRatio); + canvas.height = Math.round(loadedImage.height * chosenSizeRatio); + canvas.getContext('2d').drawImage(loadedImage, 0, 0, canvas.width, canvas.height); + canvas.toBlob(resolve, format); + }); + }).then(function (resizedImageBlob) { + var result = { + id: ++elem._blazorInputFileNextFileId, + lastModified: originalFile.lastModified, + name: originalFile.name, // Note: we're not changing the file extension. + size: resizedImageBlob.size, + type: format, + relativePath: originalFile.relativePath + }; + + elem._blazorFilesById[result.id] = result; + + // Attach the blob data itself as a non-enumerable property so it doesn't appear in the JSON. + Object.defineProperty(result, 'blob', { value: resizedImageBlob }); + + return result; + }); + } + + function ensureArrayBufferReadyForSharedMemoryInterop(elem, fileId) { + return getArrayBufferFromFileAsync(elem, fileId).then(function (arrayBuffer) { + getFileById(elem, fileId).arrayBuffer = arrayBuffer; + }); + } + + function readFileData(elem, fileId, startOffset, count) { + return getArrayBufferFromFileAsync(elem, fileId).then(function (arrayBuffer) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer, startOffset, count))); + }); + } + + function readFileDataSharedMemory(readRequest) { + const inputFileElementReferenceId = Blazor.platform.readStringField(readRequest, 0); + const inputFileElement = document.querySelector(`[_bl_${inputFileElementReferenceId}]`); + const fileId = Blazor.platform.readInt32Field(readRequest, 4); + const sourceOffset = Blazor.platform.readUint64Field(readRequest, 8); + const destination = Blazor.platform.readInt32Field(readRequest, 16); + const destinationOffset = Blazor.platform.readInt32Field(readRequest, 20); + const maxBytes = Blazor.platform.readInt32Field(readRequest, 24); + + const sourceArrayBuffer = getFileById(inputFileElement, fileId).arrayBuffer; + const bytesToRead = Math.min(maxBytes, sourceArrayBuffer.byteLength - sourceOffset); + const sourceUint8Array = new Uint8Array(sourceArrayBuffer, sourceOffset, bytesToRead); + + const destinationUint8Array = Blazor.platform.toUint8Array(destination); + destinationUint8Array.set(sourceUint8Array, destinationOffset); + + return bytesToRead; + } + + // Local helpers + + function getFileById(elem, fileId) { + const file = elem._blazorFilesById[fileId]; + + if (!file) { + throw new Error(`There is no file with ID ${fileId}. The file list may have changed.`); + } + + return file; + } + + function getArrayBufferFromFileAsync(elem, fileId) { + const file = getFileById(elem, fileId); + + // On the first read, convert the FileReader into a Promise. + if (!file.readPromise) { + file.readPromise = new Promise(function (resolve, reject) { + const reader = new FileReader(); + reader.onload = function () { resolve(reader.result); }; + reader.onerror = function (err) { reject(err); }; + reader.readAsArrayBuffer(file.blob); + }); + } + + return file.readPromise; + } + + window._blazorInputFile = { + init, + toImageFile, + ensureArrayBufferReadyForSharedMemoryInterop, + readFileData, + readFileDataSharedMemory, + }; +})(); diff --git a/src/Components/WebAssembly/JSInterop/src/WebAssemblyJSRuntime.cs b/src/Components/WebAssembly/JSInterop/src/WebAssemblyJSRuntime.cs index 77cd0bac8d..54bd6eb8f8 100644 --- a/src/Components/WebAssembly/JSInterop/src/WebAssemblyJSRuntime.cs +++ b/src/Components/WebAssembly/JSInterop/src/WebAssemblyJSRuntime.cs @@ -11,7 +11,7 @@ namespace Microsoft.JSInterop.WebAssembly /// Provides methods for invoking JavaScript functions for applications running /// on the Mono WebAssembly runtime. /// - public abstract class WebAssemblyJSRuntime : JSInProcessRuntime + public abstract class WebAssemblyJSRuntime : JSInProcessRuntime, IJSUnmarshalledRuntime { /// protected override string InvokeJS(string identifier, string argsJson) @@ -42,52 +42,20 @@ namespace Microsoft.JSInterop.WebAssembly BeginInvokeJS(0, "DotNet.jsCallDispatcher.endInvokeDotNetFromJS", args); } - /// - /// Invokes the JavaScript function registered with the specified identifier. - /// - /// The .NET type corresponding to the function's return value type. - /// The identifier used when registering the target function. - /// The result of the function invocation. - public TResult InvokeUnmarshalled(string identifier) - => InvokeUnmarshalled(identifier, null, null, null); + /// + TResult IJSUnmarshalledRuntime.InvokeUnmarshalled(string identifier) + => ((IJSUnmarshalledRuntime)this).InvokeUnmarshalled(identifier, null, null, null); - /// - /// Invokes the JavaScript function registered with the specified identifier. - /// - /// The type of the first argument. - /// The .NET type corresponding to the function's return value type. - /// The identifier used when registering the target function. - /// The first argument. - /// The result of the function invocation. - public TResult InvokeUnmarshalled(string identifier, T0 arg0) - => InvokeUnmarshalled(identifier, arg0, null, null); + /// + TResult IJSUnmarshalledRuntime.InvokeUnmarshalled(string identifier, T0 arg0) + => ((IJSUnmarshalledRuntime)this).InvokeUnmarshalled(identifier, arg0, null, null); - /// - /// Invokes the JavaScript function registered with the specified identifier. - /// - /// The type of the first argument. - /// The type of the second argument. - /// The .NET type corresponding to the function's return value type. - /// The identifier used when registering the target function. - /// The first argument. - /// The second argument. - /// The result of the function invocation. - public TResult InvokeUnmarshalled(string identifier, T0 arg0, T1 arg1) - => InvokeUnmarshalled(identifier, arg0, arg1, null); + /// + TResult IJSUnmarshalledRuntime.InvokeUnmarshalled(string identifier, T0 arg0, T1 arg1) + => ((IJSUnmarshalledRuntime)this).InvokeUnmarshalled(identifier, arg0, arg1, null); - /// - /// Invokes the JavaScript function registered with the specified identifier. - /// - /// The type of the first argument. - /// The type of the second argument. - /// The type of the third argument. - /// The .NET type corresponding to the function's return value type. - /// The identifier used when registering the target function. - /// The first argument. - /// The second argument. - /// The third argument. - /// The result of the function invocation. - public TResult InvokeUnmarshalled(string identifier, T0 arg0, T1 arg1, T2 arg2) + /// + TResult IJSUnmarshalledRuntime.InvokeUnmarshalled(string identifier, T0 arg0, T1 arg1, T2 arg2) { var result = InternalCalls.InvokeJSUnmarshalled(out var exception, identifier, arg0, arg1, arg2); return exception != null diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs index cfef7ed0d7..1988647457 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.WebAssembly.Services; using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; using Microsoft.JSInterop.WebAssembly; namespace Microsoft.AspNetCore.Components.WebAssembly.Rendering @@ -95,7 +96,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Rendering /// protected override Task UpdateDisplayAsync(in RenderBatch batch) { - DefaultWebAssemblyJSRuntime.Instance.InvokeUnmarshalled( + ((IJSUnmarshalledRuntime)DefaultWebAssemblyJSRuntime.Instance).InvokeUnmarshalled( "Blazor._internal.renderBatch", _webAssemblyRendererId, batch); diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/LazyAssemblyLoader.cs b/src/Components/WebAssembly/WebAssembly/src/Services/LazyAssemblyLoader.cs index 709c0f576c..9fc3cde388 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/LazyAssemblyLoader.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/LazyAssemblyLoader.cs @@ -80,7 +80,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Services var newAssembliesToLoad = assembliesToLoad.Where(assembly => !_loadedAssemblyCache.Contains(assembly)); var loadedAssemblies = new List(); - var count = (int)await ((WebAssemblyJSRuntime)_jsRuntime).InvokeUnmarshalled>( + var count = (int)await ((IJSUnmarshalledRuntime)_jsRuntime).InvokeUnmarshalled>( GetDynamicAssemblies, newAssembliesToLoad.ToArray(), null, @@ -91,7 +91,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Services return loadedAssemblies; } - var assemblies = ((WebAssemblyJSRuntime)_jsRuntime).InvokeUnmarshalled( + var assemblies = ((IJSUnmarshalledRuntime)_jsRuntime).InvokeUnmarshalled( ReadDynamicAssemblies, null, null, diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs index d8fb668579..398453fa99 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs @@ -99,7 +99,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Services _jsRuntime.InvokeVoid("console.error", formattedMessage); break; case LogLevel.Critical: - _jsRuntime.InvokeUnmarshalled("Blazor._internal.dotNetCriticalError", formattedMessage); + ((IJSUnmarshalledRuntime)_jsRuntime).InvokeUnmarshalled("Blazor._internal.dotNetCriticalError", formattedMessage); break; default: // LogLevel.None or invalid enum values Console.WriteLine(formattedMessage); diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyJSRuntimeInvoker.cs b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyJSRuntimeInvoker.cs index 4592c2ce9c..b2960de3ca 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyJSRuntimeInvoker.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyJSRuntimeInvoker.cs @@ -1,13 +1,13 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.JSInterop.WebAssembly; +using Microsoft.JSInterop; namespace Microsoft.AspNetCore.Components.WebAssembly.Services { /// /// This class exists to enable unit testing for code that needs to call - /// . + /// . /// /// We should only use this in non-perf-critical code paths (for example, during hosting startup, /// where we only call this a fixed number of times, and not during rendering where it might be @@ -22,6 +22,6 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Services public static WebAssemblyJSRuntimeInvoker Instance = new WebAssemblyJSRuntimeInvoker(); public virtual TResult InvokeUnmarshalled(string identifier, T0 arg0, T1 arg1, T2 arg2) - => DefaultWebAssemblyJSRuntime.Instance.InvokeUnmarshalled(identifier, arg0, arg1, arg2); + => ((IJSUnmarshalledRuntime)DefaultWebAssemblyJSRuntime.Instance).InvokeUnmarshalled(identifier, arg0, arg1, arg2); } } diff --git a/src/Components/test/E2ETest/Tests/InputFileTest.cs b/src/Components/test/E2ETest/Tests/InputFileTest.cs new file mode 100644 index 0000000000..d96d4358dc --- /dev/null +++ b/src/Components/test/E2ETest/Tests/InputFileTest.cs @@ -0,0 +1,175 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Text; +using BasicTestApp; +using Microsoft.AspNetCore.Components.E2ETest; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.Extensions; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETests.Tests +{ + public class InputFileTest : ServerTestBase>, IDisposable + { + private string _tempDirectory; + + public InputFileTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDirectory); + + Navigate(ServerPathBase, noReload: _serverFixture.ExecutionMode == ExecutionMode.Client); + Browser.MountTestComponent(); + } + + [Fact] + public void CanUploadSingleSmallFile() + { + // Create a temporary text file + var file = TempFile.Create(_tempDirectory, "txt", "This file was uploaded to the browser and read from .NET."); + + // Upload the file + var inputFile = Browser.FindElement(By.Id("input-file")); + inputFile.SendKeys(file.Path); + + var fileContainer = Browser.FindElement(By.Id($"file-{file.Name}")); + var fileSizeElement = fileContainer.FindElement(By.Id("file-size")); + var fileContentElement = fileContainer.FindElement(By.Id("file-content")); + + // Validate that the file was uploaded correctly + Browser.Equal(file.Contents.Length.ToString(), () => fileSizeElement.Text); + Browser.Equal(file.Text, () => fileContentElement.Text); + } + + [Fact] + public void CanUploadSingleLargeFile() + { + // Create a large text file + var fileContentSizeInBytes = 1024 * 1024; + var contentBuilder = new StringBuilder(); + + for (int i = 0; i < fileContentSizeInBytes; i++) + { + contentBuilder.Append((i % 10).ToString()); + } + + var file = TempFile.Create(_tempDirectory, "txt", contentBuilder.ToString()); + + // Upload the file + var inputFile = Browser.FindElement(By.Id("input-file")); + inputFile.SendKeys(file.Path); + + var fileContainer = Browser.FindElement(By.Id($"file-{file.Name}")); + var fileSizeElement = fileContainer.FindElement(By.Id("file-size")); + var fileContentElement = fileContainer.FindElement(By.Id("file-content")); + + // Validate that the file was uploaded correctly + Browser.Equal(file.Contents.Length.ToString(), () => fileSizeElement.Text); + Browser.Equal(file.Text, () => fileContentElement.Text); + } + + [Fact] + public void CanUploadMultipleFiles() + { + // Create multiple small text files + var files = Enumerable.Range(1, 3) + .Select(i => TempFile.Create(_tempDirectory, "txt", $"Contents of file {i}.")) + .ToList(); + + // Upload each file + var inputFile = Browser.FindElement(By.Id("input-file")); + inputFile.SendKeys(string.Join("\n", files.Select(f => f.Path))); + + // VAlidate that each file was uploaded correctly + Assert.All(files, file => + { + var fileContainer = Browser.FindElement(By.Id($"file-{file.Name}")); + var fileSizeElement = fileContainer.FindElement(By.Id("file-size")); + var fileContentElement = fileContainer.FindElement(By.Id("file-content")); + + Browser.Equal(file.Contents.Length.ToString(), () => fileSizeElement.Text); + Browser.Equal(file.Text, () => fileContentElement.Text); + }); + } + + [Fact] + public void CanUploadAndConvertImageFile() + { + var sourceImageId = "image-source"; + + // Get the source image base64 + var base64 = Browser.ExecuteJavaScript($@" + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + const image = document.getElementById('{sourceImageId}'); + + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + context.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight); + + return canvas.toDataURL().split(',').pop();"); + + // Save the image file locally + var file = TempFile.Create(_tempDirectory, "png", Convert.FromBase64String(base64)); + + // Re-upload the image file (it will be converted to a JPEG and scaled to fix 640x480) + var inputFile = Browser.FindElement(By.Id("input-image")); + inputFile.SendKeys(file.Path); + + // Validate that the image was converted without error and is the correct size + var uploadedImage = Browser.FindElement(By.Id("image-uploaded")); + + Browser.Equal(480, () => uploadedImage.Size.Width); + Browser.Equal(480, () => uploadedImage.Size.Height); + } + + public void Dispose() + { + Directory.Delete(_tempDirectory, recursive: true); + } + + private struct TempFile + { + public string Name { get; } + public string Path { get; } + public byte[] Contents { get; } + + public string Text => Encoding.ASCII.GetString(Contents); + + private TempFile(string tempDirectory, string extension, byte[] contents) + { + Name = $"{Guid.NewGuid():N}.{extension}"; + Path = $"{tempDirectory}\\{Name}"; + Contents = contents; + } + + public static TempFile Create(string tempDirectory, string extension, byte[] contents) + { + var file = new TempFile(tempDirectory, extension, contents); + + File.WriteAllBytes(file.Path, contents); + + return file; + } + + public static TempFile Create(string tempDirectory, string extension, string text) + => Create(tempDirectory, extension, Encoding.ASCII.GetBytes(text)); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index 6747601918..db232b3aa4 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -46,6 +46,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/InputFileComponent.razor b/src/Components/test/testassets/BasicTestApp/InputFileComponent.razor new file mode 100644 index 0000000000..e9f7dbd301 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/InputFileComponent.razor @@ -0,0 +1,80 @@ +@using System.IO; +@using Microsoft.AspNetCore.Components.Web.Extensions + +

File preview

+ +
+ +@if (isLoading) +{ +

Loading...


+} + +@foreach (var (file, content) in loadedFiles) +{ +

+ File name: @(file.Name)
+ File size (bytes): @(file.Size)
+ File content: @content
+

+} + +

Image upload

+ +
+ +@if (imageDataUri != null) +{ +

+ Uploaded image:
+ +

+} + +

+ Source image:
+ +

+ +@code { + Dictionary loadedFiles = new Dictionary(); + + bool isLoading; + + string imageDataUri; + + async Task LoadFiles(InputFileChangeEventArgs e) + { + isLoading = true; + loadedFiles.Clear(); + + foreach (var file in e.Files) + { + StateHasChanged(); + + using var reader = new StreamReader(file.OpenReadStream()); + + loadedFiles.Add(file, await reader.ReadToEndAsync()); + } + + isLoading = false; + } + + async Task LoadImage(InputFileChangeEventArgs e) + { + var file = e.Files.SingleOrDefault(); + + if (file != null) + { + var format = "image/jpeg"; + var imageFile = await file.ToImageFileAsync(format, 640, 480); + + using var fileStream = imageFile.OpenReadStream(); + using var memoryStream = new MemoryStream(); + await fileStream.CopyToAsync(memoryStream); + + imageDataUri = $"data:{format};base64,{Convert.ToBase64String(memoryStream.ToArray())}"; + StateHasChanged(); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/images/blazor_logo_1000x.png b/src/Components/test/testassets/BasicTestApp/wwwroot/images/blazor_logo_1000x.png new file mode 100644 index 0000000000..fb308a8e28 Binary files /dev/null and b/src/Components/test/testassets/BasicTestApp/wwwroot/images/blazor_logo_1000x.png differ diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html index 20c78f34f2..f387c50b57 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html @@ -45,6 +45,8 @@ + + diff --git a/src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml b/src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml index 54a00f8697..ebd2beeaf9 100644 --- a/src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml +++ b/src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml @@ -41,6 +41,8 @@ + +