InputFile Component (#24640)

This commit is contained in:
Mackinnon Buck 2020-08-20 17:52:41 -07:00 committed by GitHub
parent a700662dec
commit 0b1042c54e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1104 additions and 51 deletions

View File

@ -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<IBrowserFile> ToImageFileAsync(string format, int maxWidth, int maxHeight)
=> Owner.ConvertToImageFileAsync(this, format, maxWidth, maxHeight);
}
}

View File

@ -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<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> ReadAsync(new Memory<byte>(buffer, offset, count), cancellationToken).AsTask();
public override async ValueTask<int> ReadAsync(Memory<byte> 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<int> CopyFileDataIntoBuffer(long sourceOffset, Memory<byte> destination, CancellationToken cancellationToken);
}
}

View File

@ -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
{
/// <summary>
/// Represents the data of a file selected from an <see cref="InputFile"/> component.
/// </summary>
public interface IBrowserFile
{
/// <summary>
/// Gets the name of the file.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the last modified date.
/// </summary>
DateTime LastModified { get; }
/// <summary>
/// Gets the size of the file in bytes.
/// </summary>
long Size { get; }
/// <summary>
/// Gets the MIME type of the file.
/// </summary>
string Type { get; }
/// <summary>
/// Opens the stream for reading the uploaded file.
/// </summary>
/// <param name="cancellationToken">A cancellation token to signal the cancellation of streaming file data.</param>
Stream OpenReadStream(CancellationToken cancellationToken = default);
/// <summary>
/// Converts the current image file to a new one of the specified file type and maximum file dimensions.
/// </summary>
/// <remarks>
/// The image will be scaled to fit the specified dimensions while preserving the original aspect ratio.
/// </remarks>
/// <param name="format">The new image format.</param>
/// <param name="maxWith">The maximum image width.</param>
/// <param name="maxHeight">The maximum image height</param>
/// <returns>A <see cref="Task"/> representing the completion of the operation.</returns>
Task<IBrowserFile> ToImageFileAsync(string format, int maxWith, int maxHeight);
}
}

View File

@ -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);
}
}

View File

@ -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
{
/// <summary>
/// A component that wraps the HTML file input element and exposes a <see cref="Stream"/> for each file's contents.
/// </summary>
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<RemoteBrowserFileStreamOptions> Options { get; set; } = default!;
/// <summary>
/// Gets or sets the event callback that will be invoked when the collection of selected files changes.
/// </summary>
[Parameter]
public EventCallback<InputFileChangeEventArgs> OnChange { get; set; }
/// <summary>
/// Gets or sets a collection of additional attributes that will be applied to the input element.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>? 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<IBrowserFile> ConvertToImageFileAsync(BrowserFile file, string format, int maxWidth, int maxHeight)
{
var imageFile = await JSRuntime.InvokeAsync<BrowserFile>(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();
}
}
}

View File

@ -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
{
/// <summary>
/// Supplies information about an <see cref="InputFile.OnChange"/> event being raised.
/// </summary>
public class InputFileChangeEventArgs : EventArgs
{
/// <summary>
/// The updated file entries list.
/// </summary>
public IReadOnlyList<IBrowserFile> Files { get; }
/// <summary>
/// Constructs a new <see cref="InputFileChangeEventArgs"/> instance.
/// </summary>
/// <param name="files">The updated file entries list.</param>
public InputFileChangeEventArgs(IReadOnlyList<IBrowserFile> files)
{
Files = files;
}
}
}

View File

@ -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";
}
}

View File

@ -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();
}
}
}

View File

@ -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;
}
}

View File

@ -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<byte[]>(
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<int> CopyFileDataIntoBuffer(long sourceOffset, Memory<byte> 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);
}
}
}

View File

@ -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
{
/// <summary>
/// Repesents configurable options for <see cref="RemoteBrowserFileStream"/>.
/// </summary>
[UnsupportedOSPlatform("browser")]
public class RemoteBrowserFileStreamOptions
{
/// <summary>
/// Gets or sets the maximum segment size for file data sent over a SignalR circuit.
/// The default value is 20K.
/// <para>
/// This only has an effect when using Blazor Server.
/// </para>
/// </summary>
public int SegmentSize { get; set; } = 20 * 1024; // SignalR limit is 32K.
/// <summary>
/// Gets or sets the maximum internal buffer size for unread data sent over a SignalR circuit.
/// <para>
/// This only has an effect when using Blazor Server.
/// </para>
/// </summary>
public int MaxBufferSize { get; set; } = 1024 * 1024;
/// <summary>
/// Gets or sets the time limit for fetching a segment of file data.
/// <para>
/// This only has an effect when using Blazor Server.
/// </para>
/// </summary>
public TimeSpan SegmentFetchTimeout { get; set; } = TimeSpan.FromSeconds(3);
}
}

View File

@ -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<int> CopyFileDataIntoBuffer(long sourceOffset, Memory<byte> 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<byte> 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<byte>(readRequest.Destination));
}
return _jsUnmarshalledRuntime.InvokeUnmarshalled<ReadRequest, int>(InputFileInterop.ReadFileDataSharedMemory, readRequest);
}
}
}

View File

@ -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<ArrayBuffer>.
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,
};
})();

View File

@ -11,7 +11,7 @@ namespace Microsoft.JSInterop.WebAssembly
/// Provides methods for invoking JavaScript functions for applications running
/// on the Mono WebAssembly runtime.
/// </summary>
public abstract class WebAssemblyJSRuntime : JSInProcessRuntime
public abstract class WebAssemblyJSRuntime : JSInProcessRuntime, IJSUnmarshalledRuntime
{
/// <inheritdoc />
protected override string InvokeJS(string identifier, string argsJson)
@ -42,52 +42,20 @@ namespace Microsoft.JSInterop.WebAssembly
BeginInvokeJS(0, "DotNet.jsCallDispatcher.endInvokeDotNetFromJS", args);
}
/// <summary>
/// Invokes the JavaScript function registered with the specified identifier.
/// </summary>
/// <typeparam name="TResult">The .NET type corresponding to the function's return value type.</typeparam>
/// <param name="identifier">The identifier used when registering the target function.</param>
/// <returns>The result of the function invocation.</returns>
public TResult InvokeUnmarshalled<TResult>(string identifier)
=> InvokeUnmarshalled<object, object, object, TResult>(identifier, null, null, null);
/// <inheritdoc />
TResult IJSUnmarshalledRuntime.InvokeUnmarshalled<TResult>(string identifier)
=> ((IJSUnmarshalledRuntime)this).InvokeUnmarshalled<object, object, object, TResult>(identifier, null, null, null);
/// <summary>
/// Invokes the JavaScript function registered with the specified identifier.
/// </summary>
/// <typeparam name="T0">The type of the first argument.</typeparam>
/// <typeparam name="TResult">The .NET type corresponding to the function's return value type.</typeparam>
/// <param name="identifier">The identifier used when registering the target function.</param>
/// <param name="arg0">The first argument.</param>
/// <returns>The result of the function invocation.</returns>
public TResult InvokeUnmarshalled<T0, TResult>(string identifier, T0 arg0)
=> InvokeUnmarshalled<T0, object, object, TResult>(identifier, arg0, null, null);
/// <inheritdoc />
TResult IJSUnmarshalledRuntime.InvokeUnmarshalled<T0, TResult>(string identifier, T0 arg0)
=> ((IJSUnmarshalledRuntime)this).InvokeUnmarshalled<T0, object, object, TResult>(identifier, arg0, null, null);
/// <summary>
/// Invokes the JavaScript function registered with the specified identifier.
/// </summary>
/// <typeparam name="T0">The type of the first argument.</typeparam>
/// <typeparam name="T1">The type of the second argument.</typeparam>
/// <typeparam name="TResult">The .NET type corresponding to the function's return value type.</typeparam>
/// <param name="identifier">The identifier used when registering the target function.</param>
/// <param name="arg0">The first argument.</param>
/// <param name="arg1">The second argument.</param>
/// <returns>The result of the function invocation.</returns>
public TResult InvokeUnmarshalled<T0, T1, TResult>(string identifier, T0 arg0, T1 arg1)
=> InvokeUnmarshalled<T0, T1, object, TResult>(identifier, arg0, arg1, null);
/// <inheritdoc />
TResult IJSUnmarshalledRuntime.InvokeUnmarshalled<T0, T1, TResult>(string identifier, T0 arg0, T1 arg1)
=> ((IJSUnmarshalledRuntime)this).InvokeUnmarshalled<T0, T1, object, TResult>(identifier, arg0, arg1, null);
/// <summary>
/// Invokes the JavaScript function registered with the specified identifier.
/// </summary>
/// <typeparam name="T0">The type of the first argument.</typeparam>
/// <typeparam name="T1">The type of the second argument.</typeparam>
/// <typeparam name="T2">The type of the third argument.</typeparam>
/// <typeparam name="TResult">The .NET type corresponding to the function's return value type.</typeparam>
/// <param name="identifier">The identifier used when registering the target function.</param>
/// <param name="arg0">The first argument.</param>
/// <param name="arg1">The second argument.</param>
/// <param name="arg2">The third argument.</param>
/// <returns>The result of the function invocation.</returns>
public TResult InvokeUnmarshalled<T0, T1, T2, TResult>(string identifier, T0 arg0, T1 arg1, T2 arg2)
/// <inheritdoc />
TResult IJSUnmarshalledRuntime.InvokeUnmarshalled<T0, T1, T2, TResult>(string identifier, T0 arg0, T1 arg1, T2 arg2)
{
var result = InternalCalls.InvokeJSUnmarshalled<T0, T1, T2, TResult>(out var exception, identifier, arg0, arg1, arg2);
return exception != null

View File

@ -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
/// <inheritdoc />
protected override Task UpdateDisplayAsync(in RenderBatch batch)
{
DefaultWebAssemblyJSRuntime.Instance.InvokeUnmarshalled<int, RenderBatch, object>(
((IJSUnmarshalledRuntime)DefaultWebAssemblyJSRuntime.Instance).InvokeUnmarshalled<int, RenderBatch, object>(
"Blazor._internal.renderBatch",
_webAssemblyRendererId,
batch);

View File

@ -80,7 +80,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Services
var newAssembliesToLoad = assembliesToLoad.Where(assembly => !_loadedAssemblyCache.Contains(assembly));
var loadedAssemblies = new List<Assembly>();
var count = (int)await ((WebAssemblyJSRuntime)_jsRuntime).InvokeUnmarshalled<string[], object, object, Task<object>>(
var count = (int)await ((IJSUnmarshalledRuntime)_jsRuntime).InvokeUnmarshalled<string[], object, object, Task<object>>(
GetDynamicAssemblies,
newAssembliesToLoad.ToArray(),
null,
@ -91,7 +91,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Services
return loadedAssemblies;
}
var assemblies = ((WebAssemblyJSRuntime)_jsRuntime).InvokeUnmarshalled<object, object, object, object[]>(
var assemblies = ((IJSUnmarshalledRuntime)_jsRuntime).InvokeUnmarshalled<object, object, object, object[]>(
ReadDynamicAssemblies,
null,
null,

View File

@ -99,7 +99,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Services
_jsRuntime.InvokeVoid("console.error", formattedMessage);
break;
case LogLevel.Critical:
_jsRuntime.InvokeUnmarshalled<string, object>("Blazor._internal.dotNetCriticalError", formattedMessage);
((IJSUnmarshalledRuntime)_jsRuntime).InvokeUnmarshalled<string, object>("Blazor._internal.dotNetCriticalError", formattedMessage);
break;
default: // LogLevel.None or invalid enum values
Console.WriteLine(formattedMessage);

View File

@ -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
{
/// <summary>
/// This class exists to enable unit testing for code that needs to call
/// <see cref="WebAssemblyJSRuntime.InvokeUnmarshalled{T0, T1, T2, TResult}(string, T0, T1, T2)"/>.
/// <see cref="IJSUnmarshalledRuntime.InvokeUnmarshalled{T0, T1, T2, TResult}(string, T0, T1, T2)"/>.
///
/// 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<T0, T1, T2, TResult>(string identifier, T0 arg0, T1 arg1, T2 arg2)
=> DefaultWebAssemblyJSRuntime.Instance.InvokeUnmarshalled<T0, T1, T2, TResult>(identifier, arg0, arg1, arg2);
=> ((IJSUnmarshalledRuntime)DefaultWebAssemblyJSRuntime.Instance).InvokeUnmarshalled<T0, T1, T2, TResult>(identifier, arg0, arg1, arg2);
}
}

View File

@ -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<ToggleExecutionModeServerFixture<Program>>, IDisposable
{
private string _tempDirectory;
public InputFileTest(
BrowserFixture browserFixture,
ToggleExecutionModeServerFixture<Program> 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<InputFileComponent>();
}
[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<string>($@"
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));
}
}
}

View File

@ -46,6 +46,7 @@
<option value="BasicTestApp.HttpClientTest.CookieCounterComponent">HttpClient cookies</option>
<option value="BasicTestApp.HttpClientTest.HttpRequestsComponent">HttpClient tester</option>
<option value="BasicTestApp.InputEventComponent">Input events</option>
<option value="BasicTestApp.InputFileComponent">Input file</option>
<option value="BasicTestApp.InteropComponent">Interop component</option>
<option value="BasicTestApp.InteropOnInitializationComponent">Interop on initialization</option>
<option value="BasicTestApp.JsonSerializationCases">JSON serialization</option>

View File

@ -0,0 +1,80 @@
@using System.IO;
@using Microsoft.AspNetCore.Components.Web.Extensions
<h1>File preview</h1>
<InputFile OnChange="LoadFiles" id="input-file" multiple /><br />
@if (isLoading)
{
<p>Loading...</p><br />
}
@foreach (var (file, content) in loadedFiles)
{
<p id="file-@(file.Name)">
<strong>File name:</strong> @(file.Name)<br />
<strong>File size (bytes):</strong> <span id="file-size">@(file.Size)</span><br />
<strong>File content:</strong> <span id="file-content">@content</span><br />
</p>
}
<h1>Image upload</h1>
<InputFile OnChange="LoadImage" id="input-image" /><br />
@if (imageDataUri != null)
{
<p>
Uploaded image:<br />
<img id="image-uploaded" src="@imageDataUri" />
</p>
}
<p>
Source image:<br />
<img id="image-source" src="images/blazor_logo_1000x.png" />
</p>
@code {
Dictionary<IBrowserFile, string> loadedFiles = new Dictionary<IBrowserFile, string>();
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();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -45,6 +45,8 @@
<script src="_content/Microsoft.AspNetCore.Components.Web.Extensions/headManager.js"></script>
<script src="_content/Microsoft.AspNetCore.Components.Web.Extensions/inputFile.js"></script>
<!-- Used by ExternalContentPackage -->
<script src="_content/TestContentPackage/prompt.js"></script>
</body>

View File

@ -41,6 +41,8 @@
<script src="_content/Microsoft.AspNetCore.Components.Web.Extensions/headManager.js"></script>
<script src="_content/Microsoft.AspNetCore.Components.Web.Extensions/inputFile.js"></script>
<!-- Used by ExternalContentPackage -->
<script src="_content/TestContentPackage/prompt.js"></script>
<script>

View File

@ -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.
namespace Microsoft.JSInterop
{
/// <summary>
/// Represents an instance of a JavaScript runtime to which calls may be dispatched without JSON marshalling.
/// Not all JavaScript runtimes support this capability. Currently it is only supported on WebAssembly and for
/// security reasons, will never be supported for .NET code that runs on the server.
/// This is an advanced mechanism that should only be used in performance-critical scenarios.
/// </summary>
public interface IJSUnmarshalledRuntime
{
/// <summary>
/// Invokes the JavaScript function registered with the specified identifier.
/// </summary>
/// <typeparam name="TResult">The .NET type corresponding to the function's return value type.</typeparam>
/// <param name="identifier">The identifier used when registering the target function.</param>
/// <returns>The result of the function invocation.</returns>
TResult InvokeUnmarshalled<TResult>(string identifier);
/// <summary>
/// Invokes the JavaScript function registered with the specified identifier.
/// </summary>
/// <typeparam name="T0">The type of the first argument.</typeparam>
/// <typeparam name="TResult">The .NET type corresponding to the function's return value type.</typeparam>
/// <param name="identifier">The identifier used when registering the target function.</param>
/// <param name="arg0">The first argument.</param>
/// <returns>The result of the function invocation.</returns>
TResult InvokeUnmarshalled<T0, TResult>(string identifier, T0 arg0);
/// <summary>
/// Invokes the JavaScript function registered with the specified identifier.
/// </summary>
/// <typeparam name="T0">The type of the first argument.</typeparam>
/// <typeparam name="T1">The type of the second argument.</typeparam>
/// <typeparam name="TResult">The .NET type corresponding to the function's return value type.</typeparam>
/// <param name="identifier">The identifier used when registering the target function.</param>
/// <param name="arg0">The first argument.</param>
/// <param name="arg1">The second argument.</param>
/// <returns>The result of the function invocation.</returns>
TResult InvokeUnmarshalled<T0, T1, TResult>(string identifier, T0 arg0, T1 arg1);
/// <summary>
/// Invokes the JavaScript function registered with the specified identifier.
/// </summary>
/// <typeparam name="T0">The type of the first argument.</typeparam>
/// <typeparam name="T1">The type of the second argument.</typeparam>
/// <typeparam name="T2">The type of the third argument.</typeparam>
/// <typeparam name="TResult">The .NET type corresponding to the function's return value type.</typeparam>
/// <param name="identifier">The identifier used when registering the target function.</param>
/// <param name="arg0">The first argument.</param>
/// <param name="arg1">The second argument.</param>
/// <param name="arg2">The third argument.</param>
/// <returns>The result of the function invocation.</returns>
TResult InvokeUnmarshalled<T0, T1, T2, TResult>(string identifier, T0 arg0, T1 arg1, T2 arg2);
}
}