From f78cb360bbaf26806abef0ad7f45fc972c358eca Mon Sep 17 00:00:00 2001 From: msynk Date: Wed, 29 Jul 2026 14:39:36 +0330 Subject: [PATCH] apply BitFileUpload improvements #12778 --- .../Inputs/FileUpload/BitFileInfo.cs | 87 +- .../Inputs/FileUpload/BitFileUpload.razor | 69 +- .../Inputs/FileUpload/BitFileUpload.razor.cs | 1578 +++++++++-- .../Inputs/FileUpload/BitFileUpload.scss | 266 +- .../Inputs/FileUpload/BitFileUpload.ts | 431 ++- .../FileUpload/BitFileUploadClassStyles.cs | 109 + .../BitFileUploadJsRuntimeExtensions.cs | 40 +- .../Inputs/FileUpload/BitFileUploadStatus.cs | 2 +- .../FileUpload/_BitFileUploadItem.razor | 124 +- .../FileUpload/_BitFileUploadItem.razor.cs | 57 +- .../Inputs/FileUpload/BitFileUploadDemo.razor | 842 +++++- .../FileUpload/BitFileUploadDemo.razor.cs | 1387 ++++++++-- .../FileUpload/BitFileUploadDemo.razor.scss | 65 +- .../Inputs/FileUpload/BitFileUploadTests.cs | 2461 ++++++++++++++++- .../Utils/Theme/component-css-variables.md | 17 +- 15 files changed, 6962 insertions(+), 573 deletions(-) create mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs index 6222c96f43..d282265f94 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs @@ -29,6 +29,34 @@ public class BitFileInfo /// [JsonPropertyName("index")] public int Index { get; set; } + /// + /// The last modified time of the file reported by the browser, in milliseconds since the Unix epoch. + /// + [JsonPropertyName("lastModified")] public long LastModified { get; set; } + + /// + /// An object URL of the file content that can be used as the source of an img element to preview image files. + /// This is only populated for image files when the ShowPreview parameter of the BitFileUpload is enabled. + /// + [JsonPropertyName("previewUrl")] public string? PreviewUrl { get; set; } + + /// + /// The width of the image in pixels, only populated for decodable image files when the + /// ReadImageDimensions parameter of the BitFileUpload is enabled. It is null for anything else. + /// + [JsonPropertyName("width")] public int? Width { get; set; } + + /// + /// The height of the image in pixels, only populated for decodable image files when the + /// ReadImageDimensions parameter of the BitFileUpload is enabled. It is null for anything else. + /// + [JsonPropertyName("height")] public int? Height { get; set; } + + /// + /// The last modified time of the file reported by the browser, as a DateTimeOffset. + /// + [JsonIgnore] public DateTimeOffset LastModifiedDate => DateTimeOffset.FromUnixTimeMilliseconds(LastModified); + /// /// The size of the last uploaded chunk of the file. /// @@ -39,12 +67,57 @@ public class BitFileInfo /// public long TotalUploadedSize { get; internal set; } + /// + /// The observed speed of the upload of this file in bytes per second, measured over the request currently + /// in flight. It is null while the file is not uploading and until the first progress report arrives. + /// + [JsonIgnore] public double? UploadSpeed { get; internal set; } + + /// + /// The estimated time left before the upload of this file completes, derived from the + /// and the bytes still to be sent. It is null whenever the speed is unknown. + /// + [JsonIgnore] public TimeSpan? RemainingTime { get; internal set; } + + /// + /// Whether the file is waiting in the upload queue for a free slot of the ConcurrentUploads limit, + /// which is what tells a file that is about to start apart from one that was never asked to upload. + /// + [JsonIgnore] public bool IsQueued { get; internal set; } + + + // Whether a request of this file is on the wire right now. A second request for the same file would + // take over the connection of the first one and start it over from the beginning, so a repeated + // upload call has to find out that this file is already busy and leave it alone. + internal bool IsRequestInFlight { get; set; } + + // The moment the request currently in flight was sent and the byte count already uploaded back then, + // which is what the upload speed of that request is measured against. + internal DateTime? TransferStartTime { get; set; } + internal long TransferStartOffset { get; set; } - internal bool PauseUploadRequested { get; set; } - internal bool CancelUploadRequested { get; set; } + // The span of the in-flight upload request, remembered at send time since the dynamic chunk size + // can change before the response of that request arrives. + internal long PendingChunkSize { get; set; } + + // Whether this specific file is being removed from the server, driving its own spinner in the UI. + internal bool IsRemoving { get; set; } + + // The number of automatic retries already spent on the upload of this file, + // reset by a successful chunk and by a manual retry. + internal int AutoRetryAttempts { get; set; } + + // The URL the queued upload of this file was requested with, remembered so that a file waiting for a + // free slot still goes to the very endpoint the Upload call that queued it asked for. + internal string? QueuedUploadUrl { get; set; } + + // Tracks whether the file was rejected by a list level rule (a duplicate, MaxCount or MaxTotalSize), + // so it can be taken back once removals free up room or drop the original of a duplicate. + internal bool ListValidationFailed { get; set; } /// - /// The error message is issued during file validation before uploading the file or at the time of uploading. + /// The message attached to the current of the file: the reason it was rejected by + /// the validations before the upload, or the body of the server response of its upload or removal. /// [JsonIgnore] public string? Message { get; internal set; } @@ -57,5 +130,13 @@ public class BitFileInfo /// The HTTP header at upload file. /// [JsonIgnore] public Dictionary? HttpHeaders { get; set; } + + /// + /// Additional multipart form fields sent alongside the content of this specific file in its upload + /// requests, merged over the ones of the UploadRequestFormFields parameter of the BitFileUpload. + /// The natural place to fill it in is the OnUploading callback. + /// + [JsonIgnore] public Dictionary? FormFields { get; set; } + [JsonIgnore] internal DateTime? StartTimeUpload { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor index 87f431b30b..55a3a844d6 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor @@ -1,4 +1,4 @@ -@namespace Bit.BlazorUI +@namespace Bit.BlazorUI @inherits BitComponentBase
+ id="@_buttonId" + disabled="@(IsEnabled is false)" + aria-label="@AriaLabel" + aria-describedby="@(_HasDescription ? _descriptionId : null)" + style="@Styles?.Label" + class="bit-upl-lbl @Classes?.Label"> @Label } - + @if (DescriptionTemplate is not null) + { + @DescriptionTemplate + } + else + { + @Description + } +
+ } + + + aria-label="@AriaLabel" + aria-labelledby="@(AriaLabel is null && LabelTemplate is null && HideLabel is false && Label.HasValue() ? _buttonId : null)" + aria-describedby="@(_HasDescription ? _descriptionId : null)" + accept="@GetAcceptValue()" /> - @if (Files is not null) + @* a list whose every file has been removed renders nothing at all, so the list element itself goes + away with them rather than staying behind as an empty landmark in the accessibility tree. *@ + @if (HideFileView is false && Files.Any(f => f.Status != BitFileUploadStatus.Removed)) { -
- @for (var i = 0; i < Files.Count; i++) +
+ @* a removed file is not part of the list anymore, so it is left out of it here rather than + leaving an empty listitem behind for a custom template that renders nothing for it. *@ + @foreach (var file in Files.Where(f => f.Status != BitFileUploadStatus.Removed)) { - var index = i; - var file = Files[index]; - file.Index = index; - - if (HideFileView is false) + if (FileViewTemplate is not null) { - if (FileViewTemplate is not null) - { + @* the custom template is wrapped so that every child of the list keeps carrying a listitem role *@ +
@FileViewTemplate(file) - } - else - { - <_BitFileUploadItem FileUpload="this" Item="file" /> - } +
+ } + else + { + <_BitFileUploadItem @key="file.FileId" FileUpload="this" Item="file" /> } }
} -
\ No newline at end of file + +
@_announcement
+ diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs index a812d01898..dc3cc69b58 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs @@ -1,20 +1,39 @@ -using System.Text; +using System.Globalization; +using System.Text; using System.Text.Encodings.Web; namespace Bit.BlazorUI; /// -/// BitFileUpload component wraps the HTML file input element(s) and uploads them to a given URL. The files can be removed by specifying the URL they have been uploaded. +/// BitFileUpload wraps the HTML file input element(s) and uploads them to a given URL, with support for +/// drag-and-drop, clipboard paste, folder and camera capture selection, image previews, chunked and resumable +/// uploads, a concurrency limit, pause/cancel, automatic retries, validation, and server-side removal. /// public partial class BitFileUpload : BitComponentBase { private const int MIN_CHUNK_SIZE = 512 * 1024; // 512 kb private const int MAX_CHUNK_SIZE = 10 * 1024 * 1024; // 10 mb + // roughly three repaints a second, which is as often as a progress bar is worth redrawing and + // far less often than the browser reports the progress of the requests behind it. + private static readonly TimeSpan PROGRESS_RENDER_INTERVAL = TimeSpan.FromMilliseconds(300); + + private bool _allowDrop = true; + private DateTime _lastProgressRender = DateTime.MinValue; + private bool _allowPaste = true; + private bool _expandDirectories; + private string? _dragClass; + private string? _dragStyle; + private string? _announcement; + private bool _announcementMarker; + private int _removingCount; + private string _buttonId = default!; + private string _descriptionId = default!; private ElementReference _inputRef; private List _files = []; + private List _uploadQueue = []; private long _internalChunkSize = MIN_CHUNK_SIZE; private IJSObjectReference _dropZoneRef = default!; private DotNetObjectReference _dotnetObj = default!; @@ -28,32 +47,83 @@ public partial class BitFileUpload : BitComponentBase /// - /// The value of the accept attribute of the input element. + /// Accepted file types for the file browser using MIME types or file extensions (e.g., "image/*", ".pdf,.doc"). + /// Applied to the underlying HTML input element's accept attribute. + /// When not set, the accept attribute is generated from . /// [Parameter] public string? Accept { get; set; } /// - /// Filters files by extension. + /// Whether files can be selected by dragging them from the operating system and dropping them on the component. + /// The default value is true. + /// + [Parameter] public bool AllowDrop { get; set; } = true; + + /// + /// Whether a file that is already in the file list can be selected again. + /// When disabled, a newly selected file matching an existing one by name, size and last modified time + /// is rejected with the instead of being uploaded a second time, + /// becoming eligible again once the file it duplicates is removed. + /// The default value is true. + /// + [Parameter] public bool AllowDuplicates { get; set; } = true; + + /// + /// Allowed file types for validation purposes, accepting both file extensions (e.g., [".jpg", ".png", ".pdf"]) + /// and MIME types with an optional wildcard (e.g., ["image/*", "application/pdf"]). + /// The leading dot of an extension is optional and the matching is case-insensitive. + /// Use ["*"] to allow all file types. Files not matching any of these entries will not be uploaded. /// [Parameter] public IReadOnlyCollection AllowedExtensions { get; set; } = ["*"]; /// - /// Enables the append mode that appends any additional selected file(s) to the current file list. + /// Whether files can be selected by pasting them from the clipboard onto the component. + /// The paste is only captured while the focus is inside the component, so the browse button must be focused first. + /// The default value is true. + /// + [Parameter] public bool AllowPaste { get; set; } = true; + + /// + /// Custom provider of the text announced by the screen reader through the live region of the component + /// whenever the file list or an upload outcome changes. Receives the current file list and returns the text + /// to announce, or null to announce nothing. When not set, a built-in English announcement is used. + /// + [Parameter] public Func, string?>? AnnouncementProvider { get; set; } + + /// + /// Whether a new selection is added to the end of the current file list instead of replacing it, + /// which is what lets the user build a batch up over several rounds of browsing, dropping or pasting. + /// The files already in the list keep their upload state. /// [Parameter] public bool Append { get; set; } + /// + /// The number of times a failed upload of a file gets retried automatically before it is reported as failed. + /// In the chunked mode each retry resumes from the last successfully uploaded chunk. + /// Set to 0 (the default) to disable the automatic retries. + /// + [Parameter] public int AutoRetries { get; set; } + + /// + /// The delay before each automatic retry of a failed upload. + /// Set to null (the default) to retry immediately. + /// + [Parameter] public TimeSpan? AutoRetryDelay { get; set; } + /// /// Calculate the chunk size dynamically based on the user's Internet speed between 512 KB and 10 MB. /// [Parameter] public bool AutoChunkSize { get; set; } /// - /// Automatically resets the file-upload before starting to browse for files. + /// Whether the file list and the upload state are cleared right before the file dialog opens, so that + /// every browse starts from a clean slate - the list empties even if the dialog is then cancelled. /// [Parameter] public bool AutoReset { get; set; } /// - /// Automatically starts the upload file(s) process immediately after selecting the file(s). + /// Whether the selected files start uploading the moment they are selected, skipping the per-file + /// upload button entirely, for the cases where the selection itself expresses the intent to upload. /// [Parameter] public bool AutoUpload { get; set; } @@ -80,17 +150,81 @@ public partial class BitFileUpload : BitComponentBase [Parameter] public string? CancelIconName { get; set; } /// - /// Enables the chunked upload. + /// The tooltip of the cancel upload button, which is also used as the prefix of its accessible label + /// (e.g., "Cancel report.pdf"). Defaults to "Cancel". + /// + [Parameter] public string? CancelButtonTitle { get; set; } + + /// + /// The message shown for canceled file uploads. + /// + [Parameter] public string CanceledUploadMessage { get; set; } = "File upload canceled"; + + /// + /// The capture behavior of the file input on devices with a camera or microphone, + /// rendered as the capture attribute of the input element (e.g., "user" for the front camera, + /// "environment" for the rear camera). + /// + [Parameter] public string? Capture { get; set; } + + /// + /// Whether each file is sliced and sent as a series of sequential requests instead of one monolithic + /// one, which is what makes a paused or failed file resume from the last chunk that made it through + /// rather than starting over, so a dropped connection costs one chunk instead of the whole transfer. /// [Parameter] public bool ChunkedUpload { get; set; } /// - /// The size of each chunk of file upload in bytes. + /// The size in bytes of each chunk of a chunked upload. When not set - and whenever + /// is enabled, which takes the decision over - it starts at 512 KB. /// [Parameter] [CallOnSet(nameof(OnSetChunkSize))] public long? ChunkSize { get; set; } + /// + /// Custom CSS classes for different parts of the BitFileUpload. + /// + [Parameter] public BitFileUploadClassStyles? Classes { get; set; } + + /// + /// The general color of the file upload, applied to the browse button, the drag-and-drop indicator, + /// the progress bars and the hovered action buttons. + /// + [Parameter, ResetClassBuilder] + public BitColor? Color { get; set; } + + /// + /// The maximum number of files uploading at the same time, the remaining ones waiting in a queue + /// and starting as soon as a slot frees up. Set to 0 (the default) to start every file at once. + /// + [Parameter] public int ConcurrentUploads { get; set; } + + /// + /// A short hint rendered under the browse button and wired to it through aria-describedby, + /// which is the place to spell out the accepted file types and the size limits so that both sighted + /// and screen reader users learn the constraints before hitting them. + /// + [Parameter] public string? Description { get; set; } + + /// + /// Custom Razor template of the hint rendered under the browse button, taking precedence over . + /// + [Parameter] public RenderFragment? DescriptionTemplate { get; set; } + + /// + /// Whether to select folders (directories) instead of files, rendered as the webkitdirectory attribute. + /// All files inside the selected folder and its subfolders will be added to the file list. + /// It also makes a dropped folder expand into its contents instead of being ignored. + /// + [Parameter] public bool Directory { get; set; } + + /// + /// The message shown for the files rejected for being already in the file list + /// while is disabled. + /// + [Parameter] public string DuplicateErrorMessage { get; set; } = "The file is already selected"; + /// /// The message shown for failed file removes. /// @@ -102,82 +236,165 @@ public partial class BitFileUpload : BitComponentBase [Parameter] public string FailedUploadMessage { get; set; } = "File upload failed"; /// - /// The custom file view template. + /// Custom formatter of the file size shown under the name of each file item. + /// Receives the size of the file in bytes and returns the text to display, + /// which is the place to localize the units or to switch between the binary and the decimal bases. + /// When not set, a built-in humanizer is used. + /// + [Parameter] public Func? FileSizeFormatter { get; set; } + + /// + /// Custom validation function called for each newly selected file after the built-in validations pass. + /// Return an error message to reject the file so it will not be uploaded, or null to accept it. + /// + [Parameter] public Func? FileValidator { get; set; } + + /// + /// Custom Razor template rendering each item of the file list in place of the built-in one, receiving + /// the file as its context with its name, size, progress, speed and status all available. It is only + /// asked for the files that are actually in the list, so a removed file leaves no empty item behind. /// [Parameter] public RenderFragment? FileViewTemplate { get; set; } /// - /// Hides the file view section of the file upload. + /// Whether the built-in file list is left unrendered. The files are still selected, validated, + /// uploaded and reported through and the callbacks - they are simply not drawn, + /// which is what the surrounding page needs when it shows the attachments in a layout of its own. /// [Parameter] public bool HideFileView { get; set; } /// - /// The text of select file button. + /// Whether to hide the default browse button label from the UI. + /// + [Parameter] public bool HideLabel { get; set; } + + /// + /// The text of the browse button. Setting it to an empty string hides the button altogether. /// [Parameter] public string Label { get; set; } = "Browse"; /// - /// A custom razor template for select button. + /// Custom Razor template rendered in place of the browse button, which also replaces the built-in + /// dashed drop indicator living on that button - a custom label should bring its own drag feedback + /// through the Dragging entry of or . /// [Parameter] public RenderFragment? LabelTemplate { get; set; } /// - /// Specifies the maximum size (byte) of the file (0 for unlimited). + /// Maximum allowed number of files in the file list (0 for unlimited). + /// Files selected beyond this count are rejected at selection time and will not be uploaded. + /// Only files that pass the other validations consume a slot. + /// + [Parameter] public int MaxCount { get; set; } + + /// + /// Specifies the message shown for the files rejected due to exceeding the maximum number of files. + /// + [Parameter] public string MaxCountErrorMessage { get; set; } = "The maximum number of files is exceeded"; + + /// + /// The maximum allowed size in bytes of each file (0 for unlimited). A larger file is rejected at + /// selection time with the and will not be uploaded. /// [Parameter] public long MaxSize { get; set; } /// - /// Specifies the message for the failed uploading progress due to exceeding the maximum size. + /// The message shown for the files rejected for being larger than the . /// [Parameter] public string MaxSizeErrorMessage { get; set; } = "The file size is larger than the max size"; /// - /// Enables multi-file selection. + /// Maximum allowed total size in bytes of all the files of the file list (0 for unlimited). + /// Files pushing the accumulated size beyond this limit are rejected at selection time and will not be + /// uploaded, becoming eligible again once removals free up room. + /// Only files that pass the other validations consume the budget. + /// + [Parameter] public long MaxTotalSize { get; set; } + + /// + /// Specifies the message shown for the files rejected for making the total size of the file list + /// exceed the maximum total size. + /// + [Parameter] public string MaxTotalSizeErrorMessage { get; set; } = "The total size of the files is larger than the max total size"; + + /// + /// The minimum allowed size in bytes of each file (0 for no limit). A smaller file is rejected at + /// selection time with the and will not be uploaded. + /// + [Parameter] public long MinSize { get; set; } + + /// + /// The message shown for the files rejected for being smaller than the . + /// + [Parameter] public string MinSizeErrorMessage { get; set; } = "The file size is smaller than the min size"; + + /// + /// Whether several files can be handed over at once, both through the file dialog and through a + /// single drop or paste. Without it a multi-file drop or paste is trimmed down to its first file. /// [Parameter] public bool Multiple { get; set; } /// - /// Specifies the message for the failed uploading progress due to the allowed extensions. + /// The message shown for the files rejected for not matching any entry of . /// [Parameter] public string NotAllowedExtensionErrorMessage { get; set; } = "The file type is not allowed"; /// - /// Callback for when all files are uploaded. + /// Callback for when every file of a batch that actually started uploading has reached a terminal + /// state - completed, failed, canceled, removed or rejected by the validations. A selection that was + /// never asked to upload never settles, so it never reports itself as complete. /// [Parameter] public EventCallback OnAllUploadsComplete { get; set; } /// - /// Callback for when file or files status change. + /// Callback for when file or files status change. It is invoked with the whole file list right after + /// a selection, and with only the file that changed whenever a single status changes afterwards, so + /// the current state of the batch is better read back from than from the argument. /// [Parameter] public EventCallback OnChange { get; set; } /// - /// Callback for when the file upload is progressed. + /// Callback invoked right after whenever a selection carries at least one file + /// rejected by the validations, providing an array of only the rejected files along with their messages. + /// + [Parameter] public EventCallback OnInvalid { get; set; } + + /// + /// Callback for when the upload of a file makes progress, invoked on every progress report of the + /// browser with the file whose , + /// and have just moved. /// [Parameter] public EventCallback OnProgress { get; set; } /// - /// Callback for when a remove file is done. + /// Callback for when a file has been removed, whether it was dropped from the list on this side or + /// deleted from the server through the . /// [Parameter] public EventCallback OnRemoveComplete { get; set; } /// - /// Callback for when a remove file is failed. + /// Callback for when the removal of a file from the server failed, leaving the file in the list with + /// the rather than pretending it is gone. /// [Parameter] public EventCallback OnRemoveFailed { get; set; } /// - /// Callback for when a file upload is about to start. + /// Callback for when a file upload is about to start, invoked before the request that carries its + /// first byte and therefore once per run of the file rather than once per chunk. It is the place to + /// attach the and the + /// that belong to this one file, both of which are read again for every request it makes. /// [Parameter] public EventCallback OnUploading { get; set; } /// - /// Callback for when a file upload is done. + /// Callback for when a file has been uploaded successfully, with the body of the server response of + /// its last request on its . /// [Parameter] public EventCallback OnUploadComplete { get; set; } /// - /// Callback for when an upload file is failed. + /// Callback for when the upload of a file failed for good - after the automatic retries, if any, have + /// all been spent - with the body of the failed response on its . /// [Parameter] public EventCallback OnUploadFailed { get; set; } @@ -203,6 +420,27 @@ public partial class BitFileUpload : BitComponentBase /// [Parameter] public string? PauseIconName { get; set; } + /// + /// The tooltip of the pause upload button, which is also used as the prefix of its accessible label + /// (e.g., "Pause report.pdf"). Defaults to "Pause". + /// + [Parameter] public string? PauseButtonTitle { get; set; } + + /// + /// The message shown for the files waiting in the queue for a free slot of the + /// limit, which is what tells a file that is about to start apart from one that was never asked to upload. + /// + [Parameter] public string QueuedUploadMessage { get; set; } = "Waiting to upload"; + + /// + /// Whether to read the pixel dimensions of the selected image files, filling the + /// and of each of them before the + /// validations run, so that a can reject an image by its dimensions. + /// Reading them means decoding every image in the browser, which costs time and memory on a large + /// selection, so it is off by default. + /// + [Parameter] public bool ReadImageDimensions { get; set; } + /// /// Gets or sets the icon to use for the remove file button using custom CSS classes for external icon libraries. /// Takes precedence over when both are set. @@ -226,39 +464,111 @@ public partial class BitFileUpload : BitComponentBase [Parameter] public string? RemoveIconName { get; set; } /// - /// Custom http headers for remove request. + /// The tooltip of the remove file button, which is also used as the prefix of its accessible label + /// (e.g., "Remove report.pdf"). Defaults to "Remove". + /// + [Parameter] public string? RemoveButtonTitle { get; set; } + + /// + /// Custom HTTP headers attached to the remove request. /// [Parameter] public Dictionary? RemoveRequestHttpHeaders { get; set; } /// - /// The provider function to create the http headers for remove request. + /// The provider function creating the HTTP headers of the remove request, invoked right before the + /// request goes out and taking precedence over . /// [Parameter] public Func>>? RemoveRequestHttpHeadersProvider { get; set; } /// - /// Custom query strings for remove request. + /// The HTTP method of the remove request (e.g., "POST"). Defaults to "DELETE". + /// + [Parameter] public string? RemoveRequestHttpMethod { get; set; } + + /// + /// Custom query strings appended to the URL of the remove request. /// [Parameter] public Dictionary? RemoveRequestQueryStrings { get; set; } /// - /// The provider function to create the query strings for remove request. + /// The provider function creating the query strings of the remove request, invoked right before the + /// request goes out and taking precedence over . /// [Parameter] public Func>>? RemoveRequestQueryStringsProvider { get; set; } /// - /// URL of the server endpoint removing the files. + /// URL of the server endpoint removing the files. A file whose bytes already reached the server is + /// deleted from it through a request to this URL carrying its name as a query string and its id in + /// the BIT_FILE_ID header; a file that never uploaded is simply dropped from the list without one. /// [Parameter] public string? RemoveUrl { get; set; } /// - /// Show/Hide after upload remove button. + /// Gets or sets the icon to use for the retry button of a failed or canceled file using custom CSS classes + /// for external icon libraries. Takes precedence over when both are set. + /// Defaults to the built-in Refresh icon when neither is set. + /// + /// + /// Use this property to render a custom retry icon from external libraries like FontAwesome or Bootstrap Icons. + /// For built-in Fluent UI icons, use instead. + /// + [Parameter] public BitIconInfo? RetryIcon { get; set; } + + /// + /// Gets or sets the name of the icon to use for the retry button of a failed or canceled file + /// from the built-in Fluent UI icons. Defaults to Refresh when not set. + /// + /// + /// The icon name should be from the Fluent UI icon set (e.g., BitIconName.Refresh). + ///
+ /// For external icon libraries, use instead. + ///
+ [Parameter] public string? RetryIconName { get; set; } + + /// + /// The tooltip of the retry button of a failed or canceled file, which is also used as the prefix of its + /// accessible label (e.g., "Retry report.pdf"). Defaults to "Retry". + /// + [Parameter] public string? RetryButtonTitle { get; set; } + + /// + /// Decides whether a failed upload is worth retrying automatically, receiving the file and the HTTP status + /// code of the failed request (0 for a network error, a timeout or an aborted request) and returning true + /// to spend one of the attempts on it. + /// When not set, a built-in rule retries the failures a second attempt can plausibly survive - network + /// errors, timeouts, 408, 429 and the 5xx server errors - and gives up right away on the other 4xx, + /// which say that the request itself is the problem and would fail again just the same. + /// + [Parameter] public Func? ShouldAutoRetry { get; set; } + + /// + /// Whether a thumbnail of every selected image is shown at the head of its file item, produced + /// entirely in the browser from an object URL that is handed back as soon as the file is removed or + /// the component is reset. The same URL is on the of each file. + /// + [Parameter] public bool ShowPreview { get; set; } + + /// + /// Whether each settled file item offers a remove button, which drops a file that never uploaded from + /// the list and deletes an uploaded one from the server through the . /// [Parameter] public bool ShowRemoveButton { get; set; } + /// + /// The size of the file upload, applied to the browse button and the file list items. + /// + [Parameter, ResetClassBuilder] + public BitSize? Size { get; set; } + + /// + /// Custom CSS styles for different parts of the BitFileUpload. + /// + [Parameter] public BitFileUploadClassStyles? Styles { get; set; } + /// /// The message shown for successful file uploads. /// - [Parameter] public string SuccessfulUploadMessage { get; set; } = "File upload succeed"; + [Parameter] public string SuccessfulUploadMessage { get; set; } = "File upload succeeded"; /// /// Gets or sets the icon to use for the upload button using custom CSS classes for external icon libraries. @@ -283,35 +593,85 @@ public partial class BitFileUpload : BitComponentBase [Parameter] public string? UploadIconName { get; set; } /// - /// Custom http headers for upload request. + /// The tooltip of the upload button, which is also used as the prefix of its accessible label + /// (e.g., "Upload report.pdf"). Defaults to "Upload". + /// + [Parameter] public string? UploadButtonTitle { get; set; } + + /// + /// The name of the form field carrying the file content in the upload request. Defaults to "file". + /// + [Parameter] public string? UploadFormFieldName { get; set; } + + /// + /// Additional multipart form fields sent alongside the content of every file in its upload requests, + /// which is what carries the metadata a server needs next to the bytes - a target folder, an album id, + /// a caption - for the endpoints that read it from the form rather than from the query string. + /// The of a file is merged over these for that file. + /// + [Parameter] public Dictionary? UploadRequestFormFields { get; set; } + + /// + /// Custom HTTP headers attached to the upload requests, fixed at selection time. /// [Parameter] public Dictionary? UploadRequestHttpHeaders { get; set; } /// /// The provider function to create the http headers for upload request. + /// Unlike , it is invoked right before every single request - + /// each file and each chunk - which is what lets it hand over a freshly minted access token. /// [Parameter] public Func>>? UploadRequestHttpHeadersProvider { get; set; } /// - /// Custom query strings for upload request. + /// The HTTP method of the upload request (e.g., "PUT"). Defaults to "POST". + /// + [Parameter] public string? UploadRequestHttpMethod { get; set; } + + /// + /// Custom query strings appended to the URL of the upload requests, fixed at selection time. /// [Parameter] public Dictionary? UploadRequestQueryStrings { get; set; } /// /// The provider function to create the query strings for upload request. + /// Unlike , it is invoked right before every single request - + /// each file and each chunk - which is what lets it hand over a value that does not survive a batch. /// [Parameter] public Func>>? UploadRequestQueryStringsProvider { get; set; } /// - /// URL of the server endpoint receiving the files. + /// The timeout of the upload request for each file or chunk. When it elapses the upload of the file fails. + /// Set to null (the default) for no timeout. + /// + [Parameter] public TimeSpan? UploadTimeout { get; set; } + + /// + /// URL of the server endpoint receiving the files, fixed at selection time. Use + /// instead for an endpoint that has to be minted per request. /// [Parameter] public string? UploadUrl { get; set; } /// /// The provider function to create the URL of the server endpoint receiving the files. + /// Unlike , it is invoked right before every single request - each file and + /// each chunk - which is what lets it hand over a presigned URL that expires. /// [Parameter] public Func>? UploadUrlProvider { get; set; } + /// + /// The visual variant of the browse button, which decides how much of the it carries: + /// a full fill, only an outline, or neither. + /// + [Parameter, ResetClassBuilder] + public BitVariant? Variant { get; set; } + + /// + /// Whether the upload request is sent with credentials such as cookies and authorization headers + /// for cross-origin requests (the withCredentials flag of the underlying XMLHttpRequest). + /// + [Parameter] public bool WithCredentials { get; set; } + /// @@ -320,7 +680,10 @@ public partial class BitFileUpload : BitComponentBase public IReadOnlyList Files => _files; /// - /// The current status of the file uploader. + /// The status of the batch as a whole: while nothing has been + /// uploaded yet, from the moment an upload is started, and + /// once every file has reached a terminal state, whichever it is. + /// The outcome of each individual file is on its own . /// public BitFileUploadStatus UploadStatus { get; private set; } @@ -330,81 +693,181 @@ public partial class BitFileUpload : BitComponentBase public string? InputId { get; private set; } /// - /// Indicates that the file upload is in the middle of removing a file. + /// Indicates that the file upload is in the middle of removing at least one file. /// - public bool IsRemoving { get; private set; } + public bool IsRemoving => _removingCount > 0; /// - /// Starts uploading the file(s). + /// The total size in bytes of all the files of the batch, excluding the removed ones + /// and the ones rejected by the validations. /// - public async Task Upload(BitFileInfo? fileInfo = null, string? uploadUrl = null) + public long TotalSize => _files.Where(IsCountedInOverallProgress).Sum(f => f.Size); + + /// + /// The total uploaded size in bytes across all the files of the batch, excluding the removed ones + /// and the ones rejected by the validations. + /// + public long TotalUploadedSize => _files.Where(IsCountedInOverallProgress) + .Sum(f => Math.Min(f.Size, f.TotalUploadedSize + f.LastChunkUploadedSize)); + + /// + /// The overall upload progress of the batch as a percentage (0 to 100), + /// combining the progress of all the files weighted by their size. + /// + public int OverallUploadProgress { - if (_files.Any() is false) return; + get + { + var totalSize = TotalSize; + + return totalSize == 0 ? 0 : (int)(TotalUploadedSize * 100 / totalSize); + } + } - if (UploadStatus != BitFileUploadStatus.InProgress) + /// + /// The combined speed in bytes per second of every file of the batch that is uploading right now, + /// which is what the connection as a whole is carrying. It is null while nothing is on the wire. + /// + public double? TotalUploadSpeed + { + get { - UploadStatus = BitFileUploadStatus.InProgress; + var speeds = _files.Where(f => f.UploadSpeed is > 0).Sum(f => f.UploadSpeed!.Value); + + return speeds > 0 ? speeds : null; } + } - await UpdateStatus(BitFileUploadStatus.InProgress, fileInfo); + /// + /// The estimated time left before the whole batch is uploaded, derived from the + /// and the bytes of the batch that are still to be sent. + /// It is null whenever nothing is uploading and the speed is therefore unknown. + /// + public TimeSpan? OverallRemainingTime + { + get + { + if (TotalUploadSpeed is not { } speed) return null; - if (fileInfo is null) + // the progress reports count the multipart overhead too, so the bytes reported as sent can + // run slightly past the batch itself, which must not turn into a negative time left. + var remaining = Math.Max(0, TotalSize - TotalUploadedSize); + + return TimeSpan.FromSeconds(remaining / speed); + } + } + + /// + /// Starts uploading the file(s), resuming a paused or chunked file from the last chunk that made it + /// through and retrying a failed or canceled one with a fresh budget of automatic retries. A file whose + /// request is already on the wire is left running rather than being started over, and a file that has + /// completed, been removed or been rejected by the validations has nothing left to send. + /// + /// + /// null (default) => all files | else => specific file + /// + /// A custom URL to upload to, overriding the for this call. + public async Task Upload(BitFileInfo? fileInfo = null, string? uploadUrl = null) + { + if (_files.Any() is false) return; + + BitFileInfo[] targets = fileInfo is null ? [.. _files] : [fileInfo]; + + // an upload call with nothing left to upload must not disturb the state of the settled batch, + // and in particular must not report its completion once more. + if (targets.Any(HasPendingWork) is false) return; + + UploadStatus = BitFileUploadStatus.InProgress; + + foreach (var file in targets) { - foreach (var file in _files) + // a file already in flight is walking through its own sequence of chunks and keeps the slot + // it is holding, so it never goes back to the end of the queue between two chunks. + if (ConcurrentUploads > 0 && file.Status != BitFileUploadStatus.InProgress) { - await UploadOneFile(file, uploadUrl); + if (HasPendingWork(file) is false) continue; + + file.QueuedUploadUrl = uploadUrl; + + if (_uploadQueue.Contains(file) is false) + { + _uploadQueue.Add(file); + file.IsQueued = true; + } + + continue; } + + await UploadOneFile(file, uploadUrl); } - else - { - await UploadOneFile(fileInfo, uploadUrl); - } + + await PumpUploadQueue(); + + RequestRender(); } /// - /// Pauses the upload. + /// Pauses the upload of the files that are on their way: an in-progress file aborts its in-flight request + /// immediately and keeps the bytes that made it, and a file waiting in the queue of the + /// limit is taken out of that queue. Both can be resumed later through + /// , which picks a chunked file up from its last completed chunk. A file that was never + /// asked to upload, and one that has already settled, are left exactly as they are. /// /// /// null (default) => all files | else => specific file /// - public void PauseUpload(BitFileInfo? fileInfo = null) + public async Task PauseUpload(BitFileInfo? fileInfo = null) { if (_files.Any() is false) return; if (fileInfo is null) { - foreach (var file in _files) + foreach (var file in _files.ToArray()) { - file.PauseUploadRequested = true; + await PauseOneFile(file); } } else { - fileInfo.PauseUploadRequested = true; + await PauseOneFile(fileInfo); } + + // pausing a running file frees its slot up for the next file waiting in the queue. + await PumpUploadQueue(); + + RequestRender(); } /// - /// Cancels the upload. + /// Cancels the upload of every file that is still in play - running, waiting in the queue, paused or + /// merely selected - settling each of them as canceled right away rather than recording an intention + /// nobody can see, and aborting the in-flight request of a running one. A file that has already + /// settled is left alone, and a canceled file can be started again later through . /// /// /// null (default) => all files | else => specific file /// - public void CancelUpload(BitFileInfo? fileInfo = null) + public async Task CancelUpload(BitFileInfo? fileInfo = null) { if (_files.Any() is false) return; if (fileInfo is null) { - foreach (var file in _files) + foreach (var file in _files.ToArray()) { - file.CancelUploadRequested = true; + await CancelOneFile(file); } } else { - fileInfo.CancelUploadRequested = true; + await CancelOneFile(fileInfo); } + + // canceling a running file frees its slot up for the next file waiting in the queue, + // and canceling the last file still running settles the batch as a whole. + await SettleFile(); + + RequestRender(); } /// @@ -416,23 +879,44 @@ public void CancelUpload(BitFileInfo? fileInfo = null) public async Task RemoveFile(BitFileInfo? fileInfo = null) { if (_files.Any() is false) return; - if (IsRemoving) return; - IsRemoving = true; + // a removal already running for this very file must not be started a second time, but the removal + // of another file has no reason to be dropped just because one is already on its way. + if (fileInfo is null ? IsRemoving : fileInfo.IsRemoving) return; - if (fileInfo is null) + _removingCount++; + + try { - foreach (var file in _files) + if (fileInfo is null) { - await RemoveOneFile(file); + foreach (var file in _files.ToArray()) + { + await RemoveOneFile(file); + } + } + else + { + await RemoveOneFile(fileInfo); } } - else + finally { - await RemoveOneFile(fileInfo); + // a reset landing in the middle of a removal already dropped the counter to zero, + // and this removal must not push it below that into a state nothing recovers from. + _removingCount = Math.Max(0, _removingCount - 1); } - IsRemoving = false; + // the room the removed files gave back can take in the files a list level limit had rejected. + ApplyListValidations(); + + Announce(); + + // and it can also free a slot up for the next file waiting to be uploaded, or settle the + // batch as a whole when the file taken away was the last one still running. + await SettleFile(); + + RequestRender(); } /// @@ -455,8 +939,20 @@ public async Task Browse() /// public async Task Reset() { + if (IsDisposed) return; + _files.Clear(); + _uploadQueue.Clear(); + // the removals of the files that just went away have nobody left to report to, so the counter + // they were holding is dropped with them rather than leaving the component removing forever. + _removingCount = 0; + UploadStatus = BitFileUploadStatus.Pending; + await _js.BitFileUploadReset(UniqueId, _inputRef); + + Announce(); + + StateHasChanged(); } @@ -467,13 +963,26 @@ public async Task Reset() [JSInvokable("HandleChunkUploadProgress")] public async Task __HandleChunkUploadProgress(int index, long loaded) { - if (_files.Any() is false) return; + if (index < 0 || index >= _files.Count) return; var file = _files[index]; if (file.Status != BitFileUploadStatus.InProgress) return; file.LastChunkUploadedSize = loaded; + + UpdateTransferRate(file); + await UpdateStatus(BitFileUploadStatus.InProgress, file); + + // a browser reports the progress of a request many times a second, and every file being uploaded + // reports its own, so repainting the whole file list on each of them would spend more time + // rendering than uploading on a large batch. the settling of a chunk or of a file renders on its + // own anyway, so the only thing a skipped repaint costs is a progress bar a few frames behind. + var now = DateTime.UtcNow; + if (now - _lastProgressRender < PROGRESS_RENDER_INTERVAL) return; + + _lastProgressRender = now; + StateHasChanged(); } @@ -483,38 +992,68 @@ public async Task __HandleChunkUploadProgress(int index, long loaded) [JSInvokable("HandleChunkUpload")] public async Task __HandleChunkUpload(int fileIndex, int responseStatus, string responseText) { - if (_files.Any() is false || UploadStatus == BitFileUploadStatus.Paused) return; + if (fileIndex < 0 || fileIndex >= _files.Count) return; var file = _files[fileIndex]; + + // whatever this response says, the request it answers is over and the file is free again. + file.IsRequestInFlight = false; + if (file.Status != BitFileUploadStatus.InProgress) return; - file.TotalUploadedSize += ChunkedUpload ? _internalChunkSize : file.Size; file.LastChunkUploadedSize = 0; - UpdateChunkSize(fileIndex); - - if (file.TotalUploadedSize < file.Size) - { - await Upload(file); - } - else + if (responseStatus is >= 200 and <= 299) { - file.Message = responseText; - if (responseStatus is >= 200 and <= 299) + file.TotalUploadedSize += file.PendingChunkSize; + file.AutoRetryAttempts = 0; + + UpdateChunkSize(fileIndex); + + if (file.TotalUploadedSize < file.Size) { - await UpdateStatus(BitFileUploadStatus.Completed, file); + await Upload(file); } - else if ((responseStatus is 0 && (file.Status is BitFileUploadStatus.Paused or BitFileUploadStatus.Canceled)) is false) + else { - await UpdateStatus(BitFileUploadStatus.Failed, file); + file.Message = responseText; + await UpdateStatus(BitFileUploadStatus.Completed, file); + await SettleFile(); } - - var allFilesUploaded = _files.All(c => c.Status is BitFileUploadStatus.Completed or BitFileUploadStatus.Failed); - if (allFilesUploaded) + } + else + { + // a failed chunk fails the whole file right away instead of blindly moving on to the next chunk. + // its size is not counted as uploaded, so a retry - automatic or through the Upload method - + // resumes from the last successfully uploaded chunk. + if (AutoRetries > 0 && file.AutoRetryAttempts < AutoRetries && IsWorthRetrying(file, responseStatus)) { - UploadStatus = BitFileUploadStatus.Completed; - await OnAllUploadsComplete.InvokeAsync([.. _files]); + file.AutoRetryAttempts++; + + if (AutoRetryDelay is { } delay && delay > TimeSpan.Zero) + { + await Task.Delay(delay); + } + + if (IsDisposed) return; + + // the file may have been paused, canceled, removed or reset while the delay was pending, + // in which case that outcome stands instead of being overwritten by this stale failure. + if (file.Status != BitFileUploadStatus.InProgress || _files.Contains(file) is false) + { + await PumpUploadQueue(); + StateHasChanged(); + return; + } + + await Upload(file); + StateHasChanged(); + return; } + + file.Message = responseText; + await UpdateStatus(BitFileUploadStatus.Failed, file); + await SettleFile(); } StateHasChanged(); @@ -524,151 +1063,736 @@ public async Task __HandleChunkUpload(int fileIndex, int responseStatus, string protected override string RootElementClass => "bit-upl"; + protected override void RegisterCssClasses() + { + ClassBuilder.Register(() => Classes?.Root); + + ClassBuilder.Register(() => Variant switch + { + BitVariant.Fill => "bit-upl-fil", + BitVariant.Outline => "bit-upl-otl", + BitVariant.Text => "bit-upl-txt", + _ => "bit-upl-fil" + }); + + ClassBuilder.Register(() => Color switch + { + BitColor.Primary => "bit-upl-pri", + BitColor.Secondary => "bit-upl-sec", + BitColor.Tertiary => "bit-upl-ter", + BitColor.Info => "bit-upl-inf", + BitColor.Success => "bit-upl-suc", + BitColor.Warning => "bit-upl-wrn", + BitColor.SevereWarning => "bit-upl-swr", + BitColor.Error => "bit-upl-err", + BitColor.PrimaryBackground => "bit-upl-pbg", + BitColor.SecondaryBackground => "bit-upl-sbg", + BitColor.TertiaryBackground => "bit-upl-tbg", + BitColor.PrimaryForeground => "bit-upl-pfg", + BitColor.SecondaryForeground => "bit-upl-sfg", + BitColor.TertiaryForeground => "bit-upl-tfg", + BitColor.PrimaryBorder => "bit-upl-pbr", + BitColor.SecondaryBorder => "bit-upl-sbr", + BitColor.TertiaryBorder => "bit-upl-tbr", + _ => "bit-upl-pri" + }); + + ClassBuilder.Register(() => Size switch + { + BitSize.Small => "bit-upl-sm", + BitSize.Medium => "bit-upl-md", + BitSize.Large => "bit-upl-lg", + _ => "bit-upl-md" + }); + } + + protected override void RegisterCssStyles() + { + StyleBuilder.Register(() => Styles?.Root); + } + protected override Task OnInitializedAsync() { InputId = $"FileUpload-{UniqueId}-input"; + _buttonId = $"FileUpload-{UniqueId}-label"; + _descriptionId = $"FileUpload-{UniqueId}-description"; return base.OnInitializedAsync(); } + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + if (_dropZoneRef is null) return; + + await UpdateDropZone(); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender is false) return; _dotnetObj = DotNetObjectReference.Create(this); - _dropZoneRef = await _js.BitFileUploadSetupDragDrop(RootElement, _inputRef); - } + _allowDrop = AllowDrop; + _allowPaste = AllowPaste; + _expandDirectories = Directory; + _dragClass = GetDragClass(); + _dragStyle = Styles?.Dragging; + _dropZoneRef = await _js.BitFileUploadSetupDragDrop(RootElement, _inputRef, _dragClass, _dragStyle, + _allowDrop, _allowPaste, _expandDirectories); + if (IsDisposed) return; + if (_dropZoneRef is null) return; - internal bool IsFileTypeNotAllowed(BitFileInfo file) + // a parameter change that arrived while the setup was still awaiting found no drop zone to update yet, + // so the drop zone is synchronized once more against the parameters as they stand now. + await UpdateDropZone(); + } + + + + internal bool IsFileTypeNotAllowed(BitFileInfo file) { - if (Accept.HasNoValue()) return false; + if (AllowsAllFileTypes(AllowedExtensions)) return false; - var fileSections = file.Name.Split('.'); - var extension = $".{fileSections?.Last()}"; - return AllowedExtensions.Count > 0 && AllowedExtensions.All(ext => ext != "*") && AllowedExtensions.All(ext => ext != extension); + return IsFileTypeNotAllowed(file, GetNormalizedExtensions(AllowedExtensions).ToArray()); } - private async Task HandleOnChange() + internal string GetStatusMessage(BitFileInfo file) + { + return file.Status switch + { + BitFileUploadStatus.Completed => SuccessfulUploadMessage, + BitFileUploadStatus.Failed => FailedUploadMessage, + BitFileUploadStatus.Canceled => CanceledUploadMessage, + BitFileUploadStatus.RemoveFailed => FailedRemoveMessage, + BitFileUploadStatus.NotAllowed => file.Message ?? NotAllowedExtensionErrorMessage, + // a file waiting for a free slot of the concurrency limit says so, since it looks exactly + // like a file nobody asked to upload while it is in fact already on its way. + BitFileUploadStatus.Pending when file.IsQueued => QueuedUploadMessage, + _ => string.Empty, + }; + } + + + + // the public API methods can be called from outside a UI event - from a timer or a service callback - + // where nothing would repaint the component on their behalf, so they ask for the render themselves. + private void RequestRender() + { + if (IsDisposed) return; + + StateHasChanged(); + } + + private static bool IsCountedInOverallProgress(BitFileInfo file) + { + return file.Status is not BitFileUploadStatus.NotAllowed and not BitFileUploadStatus.Removed; + } + + private string GetDragClass() => $"bit-upl-drg {Classes?.Dragging}".Trim(); + + private async Task UpdateDropZone() + { + var dragClass = GetDragClass(); + var dragStyle = Styles?.Dragging; + + if (_allowDrop == AllowDrop && _allowPaste == AllowPaste && _expandDirectories == Directory && + _dragClass == dragClass && _dragStyle == dragStyle) return; + + _allowDrop = AllowDrop; + _allowPaste = AllowPaste; + _expandDirectories = Directory; + _dragClass = dragClass; + _dragStyle = dragStyle; + + try + { + await _dropZoneRef.InvokeVoidAsync("update", _allowDrop, _allowPaste, _expandDirectories, _dragClass, _dragStyle); + } + catch (JSDisconnectedException) { } // we can ignore this exception here + } + + private static bool AllowsAllFileTypes(IReadOnlyCollection? allowedExtensions) + { + return allowedExtensions is null + || allowedExtensions.Count == 0 + || allowedExtensions.Any(ext => ext?.Trim() is "*" or "*.*" or "*/*"); + } + + private static IEnumerable GetNormalizedExtensions(IReadOnlyCollection allowedExtensions) + { + // an entry is either a MIME type (it contains a slash) or a file extension whose leading dot is optional. + return allowedExtensions.Select(ext => ext?.Trim()) + .Where(ext => ext.HasValue()) + .Select(ext => ext!.Contains('/') || ext.StartsWith('.') ? ext : $".{ext}"); + } + + private static bool IsFileTypeNotAllowed(BitFileInfo file, string[] allowedTypes) + { + var extension = Path.GetExtension(file.Name); + + foreach (var entry in allowedTypes) + { + if (entry.Contains('/')) + { + if (file.ContentType.HasNoValue()) continue; + + if (entry.EndsWith("/*", StringComparison.Ordinal)) + { + // a wildcard MIME type like "image/*" matches every subtype of that group. + if (file.ContentType.StartsWith(entry[..^1], StringComparison.OrdinalIgnoreCase)) return false; + } + else if (entry.Equals(file.ContentType, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + continue; + } + + // files without an extension can never match an extension entry. + if (extension.HasNoValue()) continue; + + if (entry.Equals(extension, StringComparison.OrdinalIgnoreCase)) return false; + } + + return true; + } + + private string? GetAcceptValue() { - var uploadUrl = UploadUrl; - if (UploadUrlProvider is not null) + if (Accept.HasValue()) return Accept; + + if (AllowsAllFileTypes(AllowedExtensions)) return null; + + var accept = string.Join(",", GetNormalizedExtensions(AllowedExtensions)); + + return accept.HasValue() ? accept : null; + } + + private bool ValidateFile(BitFileInfo file) + { + if (MaxSize > 0 && file.Size > MaxSize) + { + file.Status = BitFileUploadStatus.NotAllowed; + file.Message = MaxSizeErrorMessage; + return false; + } + + if (MinSize > 0 && file.Size < MinSize) { - uploadUrl = await UploadUrlProvider.Invoke(); + file.Status = BitFileUploadStatus.NotAllowed; + file.Message = MinSizeErrorMessage; + return false; } - var qs = UploadRequestQueryStrings; - if (UploadRequestQueryStringsProvider is not null) + if (IsFileTypeNotAllowed(file)) { - qs = await UploadRequestQueryStringsProvider.Invoke(); + file.Status = BitFileUploadStatus.NotAllowed; + file.Message = NotAllowedExtensionErrorMessage; + return false; } - var url = qs is null ? uploadUrl : AddQueryString(uploadUrl, qs); + if (FileValidator is not null) + { + string? message; + + try + { + message = FileValidator(file); + } + catch (Exception ex) + { + // a throwing custom validator invalidates its own file instead of aborting the whole selection. + message = ex.Message; + } + + if (message.HasValue()) + { + file.Status = BitFileUploadStatus.NotAllowed; + file.Message = message; + return false; + } + } + + return true; + } + + private async Task HandleOnChange() + { + // only the static configuration is baked into the requests at selection time. the providers are + // the answer to a value that does not survive a batch - a token about to expire, a presigned URL - + // so they are invoked right before each request instead, and are left out here on purpose. + var url = UploadRequestQueryStrings is null ? UploadUrl : AddQueryString(UploadUrl, UploadRequestQueryStrings); if (Append is false) { _files.Clear(); + _uploadQueue.Clear(); + UploadStatus = BitFileUploadStatus.Pending; } if (IsDisposed) return; - var httpHeaders = UploadRequestHttpHeadersProvider is null ? UploadRequestHttpHeaders : (await UploadRequestHttpHeadersProvider.Invoke()); + var newFiles = await _js.BitFileUploadSetup(UniqueId, _dotnetObj, _inputRef, Append, url, UploadRequestHttpHeaders, + UploadRequestHttpMethod, WithCredentials, + (long)(UploadTimeout?.TotalMilliseconds ?? 0), UploadFormFieldName, + ShowPreview, ReadImageDimensions); + + if (IsDisposed) return; - _files.AddRange(await _js.BitFileUploadSetup(UniqueId, _dotnetObj, _inputRef, Append, url, httpHeaders)); + _files.AddRange(newFiles); + + for (var i = 0; i < _files.Count; i++) + { + _files[i].Index = i; + } if (_files.Any() is false) return; + UploadStatus = BitFileUploadStatus.Pending; + + // the built-in validations run right at selection time, so the user learns about a rejected file + // immediately instead of at the moment the upload gets attempted. + foreach (var file in newFiles) + { + ValidateFile(file); + } + + ApplyListValidations(); + + Announce(); + await OnChange.InvokeAsync([.. _files]); + if (OnInvalid.HasDelegate) + { + var invalidFiles = newFiles.Where(f => f.Status == BitFileUploadStatus.NotAllowed).ToArray(); + + if (invalidFiles.Length > 0) + { + await OnInvalid.InvokeAsync(invalidFiles); + } + } + if (AutoUpload) { await Upload(); } } - private async Task UploadOneFile(BitFileInfo fileInfo, string? uploadUrl = null) + // two selections of the same file are indistinguishable by their name, size and last modified time, + // which is as close to an identity as the browser exposes for a picked file. + private static string GetFileIdentity(BitFileInfo file) { - if (_files.Any() is false || fileInfo.Status == BitFileUploadStatus.NotAllowed) return; + return $"{file.Name}|{file.Size}|{file.LastModified}"; + } - var uploadedSize = fileInfo.TotalUploadedSize; - if (fileInfo.Size != 0 && uploadedSize >= fileInfo.Size) return; + // the rules that judge a file against the rest of the list - being a duplicate, the maximum count and the + // maximum total size - are re-evaluated from scratch every time the list changes, so that a file rejected + // by one of them can be taken back as soon as a removal frees up room or drops the original it duplicated. + private void ApplyListValidations() + { + foreach (var file in _files) + { + if (file.ListValidationFailed is false) continue; + + file.ListValidationFailed = false; + file.Status = BitFileUploadStatus.Pending; + file.Message = null; + } - if (MaxSize > 0 && fileInfo.Size > MaxSize) + if (AllowDuplicates is false) { - await UpdateStatus(BitFileUploadStatus.NotAllowed, fileInfo); - return; + var knownFiles = new HashSet(StringComparer.Ordinal); + + foreach (var file in _files) + { + if (file.Status is BitFileUploadStatus.Removed) continue; + + // every file registers its identity, even a rejected one, so that a re-selection of a file + // already in the list is caught no matter why that file was rejected. + if (knownFiles.Add(GetFileIdentity(file))) continue; + + // a file that already failed a validation of its own keeps that message, which would + // otherwise be lost as soon as the duplication is resolved, and one that already started + // uploading is committed and is not taken back by a later selection of the same file. + if (file.Status is not BitFileUploadStatus.Pending) continue; + + Reject(file, DuplicateErrorMessage); + } } - if (IsFileTypeNotAllowed(fileInfo)) + if (MaxCount <= 0 && MaxTotalSize <= 0) return; + + var count = 0; + var totalSize = 0L; + + foreach (var file in _files) { - await UpdateStatus(BitFileUploadStatus.NotAllowed, fileInfo); - return; + // only the files that made it through the other validations and are still around consume the + // budget, so a file rejected for its size or type never pushes a good file over a limit. + if (file.Status is BitFileUploadStatus.NotAllowed or BitFileUploadStatus.Removed) continue; + + // a file that already started or finished uploading is committed: it takes its share of the + // budget, but a limit that a later selection pushed over never takes it back. + if (file.Status is not BitFileUploadStatus.Pending) + { + count++; + totalSize += file.Size; + continue; + } + + if (MaxCount > 0 && count >= MaxCount) + { + Reject(file, MaxCountErrorMessage); + } + else if (MaxTotalSize > 0 && totalSize + file.Size > MaxTotalSize) + { + Reject(file, MaxTotalSizeErrorMessage); + } + else + { + count++; + totalSize += file.Size; + } } - if (fileInfo.PauseUploadRequested) + static void Reject(BitFileInfo file, string message) { - await PauseUploadOneFile(fileInfo.Index); - return; + file.ListValidationFailed = true; + file.Status = BitFileUploadStatus.NotAllowed; + file.Message = message; } + } - if (fileInfo.CancelUploadRequested) + // spending the retry budget on a failure that is going to come back identical helps nobody, so a + // failure is only retried when a second attempt could plausibly go differently. + private bool IsWorthRetrying(BitFileInfo file, int responseStatus) + { + if (ShouldAutoRetry is not null) { - await CancelUploadOneFile(fileInfo.Index); + try + { + return ShouldAutoRetry(file, responseStatus); + } + catch + { + // a throwing predicate settles the file rather than taking the whole upload down with it. + return false; + } + } + + // a status of 0 stands for a network error, a timeout or an aborted request, none of which say + // anything about the request being wrong; 408 and 429 explicitly ask for another attempt later, + // and a 5xx is the server having a bad moment rather than a verdict on what was sent. + return responseStatus is 0 or 408 or 429 or (>= 500 and <= 599); + } + + // whether the file still has bytes to send, which is what tells an upload call that is worth + // starting apart from one landing on an already settled batch. + private static bool HasPendingWork(BitFileInfo file) + { + return file.Status is not BitFileUploadStatus.Completed + and not BitFileUploadStatus.Removed + and not BitFileUploadStatus.NotAllowed + && (file.Size == 0 || file.TotalUploadedSize < file.Size); + } + + // starts as many queued files as the concurrency limit still has room for. it is called both when an + // upload is requested and whenever a file settles, which is what keeps the queue draining on its own. + private async Task PumpUploadQueue() + { + if (ConcurrentUploads <= 0) return; + + while (_uploadQueue.Count > 0) + { + if (_files.Count(f => f.Status is BitFileUploadStatus.InProgress) >= ConcurrentUploads) return; + + var next = _uploadQueue[0]; + _uploadQueue.RemoveAt(0); + next.IsQueued = false; + + // a file paused, canceled, removed or reset while it was waiting has nothing left to start. + if (HasPendingWork(next) is false) continue; + + await UploadOneFile(next, next.QueuedUploadUrl); + } + } + + private async Task UploadOneFile(BitFileInfo fileInfo, string? uploadUrl = null) + { + if (_files.Any() is false) return; + if (fileInfo.Status is BitFileUploadStatus.NotAllowed or BitFileUploadStatus.Removed) return; + + // a file whose request is already on the wire is busy: sending a second one would take the + // connection away from the first and start the file over from its very first byte, which is + // what a second press of an upload-everything button would otherwise do to every running file. + if (fileInfo.IsRequestInFlight) return; + + var uploadedSize = fileInfo.TotalUploadedSize; + if (fileInfo.Size != 0 && uploadedSize >= fileInfo.Size) return; + + if (ValidateFile(fileInfo) is false) + { + await UpdateStatus(BitFileUploadStatus.NotAllowed, fileInfo); return; } + if (fileInfo.Status is BitFileUploadStatus.Failed) + { + // a manual retry of a failed file starts with a fresh automatic retry budget. + fileInfo.AutoRetryAttempts = 0; + } + + await UpdateStatus(BitFileUploadStatus.InProgress, fileInfo); + long to; long from = 0; + + // a request that never finished left its partial byte count behind, which would otherwise be + // added on top of the bytes of the new request and show a progress running ahead of the truth. + fileInfo.LastChunkUploadedSize = 0; + if (ChunkedUpload) { from = fileInfo.TotalUploadedSize; - if (fileInfo.Size > _internalChunkSize) - { - to = from + _internalChunkSize; - } - else - { - to = fileInfo.Size; - } + to = Math.Min(fileInfo.Size, from + _internalChunkSize); fileInfo.StartTimeUpload = DateTime.UtcNow; - fileInfo.LastChunkUploadedSize = 0; } else { to = fileInfo.Size; } + // the span of the in-flight request is remembered as sent, since the chunk size can get + // adjusted dynamically before the response of this request arrives. + fileInfo.PendingChunkSize = to - from; + if (from == 0) { await OnUploading.InvokeAsync(fileInfo); } - await _js.BitFileUploadUpload(UniqueId, from, to, fileInfo.Index, uploadUrl, fileInfo.HttpHeaders); + // the providers get their say right before the request goes out, so a token they mint is as + // fresh as it can be. they are awaited, and the file may well have been paused, canceled, + // removed or reset in the meantime, in which case there is nothing left to send. + var requestUrl = await GetRequestUploadUrl(uploadUrl); + var requestHeaders = await GetUploadHeaders(fileInfo, from, to); + + if (IsDisposed) return; + if (fileInfo.Status is not BitFileUploadStatus.InProgress || _files.Contains(fileInfo) is false) return; + + // the speed of the request is measured from the moment it is handed over to the browser. + fileInfo.TransferStartTime = DateTime.UtcNow; + fileInfo.TransferStartOffset = fileInfo.TotalUploadedSize; + fileInfo.IsRequestInFlight = true; + + await _js.BitFileUploadUpload(UniqueId, from, to, fileInfo.Index, requestUrl, requestHeaders, + GetUploadFormFields(fileInfo)); + } + + // the URL of a single request: an explicit one passed to Upload wins, then the providers get to mint + // one for this very request, and otherwise the endpoint baked in at selection time keeps applying. + private async Task GetRequestUploadUrl(string? uploadUrl) + { + if (uploadUrl.HasValue()) return uploadUrl; + + if (UploadUrlProvider is null && UploadRequestQueryStringsProvider is null) return null; + + var url = UploadUrlProvider is null ? UploadUrl : await UploadUrlProvider.Invoke(); + + if (UploadRequestQueryStringsProvider is null) + { + // the static query strings are already part of the URL baked in at selection time, so they + // only have to be put back when a provider replaced that URL with one of its own. + return UploadRequestQueryStrings is null ? url : AddQueryString(url, UploadRequestQueryStrings); + } + + var merged = MergeEntries(UploadRequestQueryStrings, await UploadRequestQueryStringsProvider.Invoke()); + + return merged is null ? url : AddQueryString(url, merged); + } + + // the extra multipart fields of a request: the ones of the whole component, with the ones of this + // specific file - the OnUploading callback is where they are usually filled in - laid over them. + private Dictionary? GetUploadFormFields(BitFileInfo fileInfo) + { + return MergeEntries(UploadRequestFormFields, fileInfo.FormFields); + } + + // combines two optional sets of entries into a new one, the second one winning over the first, + // without ever handing back - let alone mutating - either of the dictionaries it was given. + private static Dictionary? MergeEntries(Dictionary? first, Dictionary? second) + { + if (first is null || first.Count == 0) return second; + if (second is null || second.Count == 0) return first; + + var merged = new Dictionary(first); + + foreach (var entry in second) + { + merged[entry.Key] = entry.Value; + } + + return merged; + } + + // the chunked mode leaves the server with the job of putting the pieces of a file back together, which + // it can only do reliably when it is told where each piece belongs. a blind append breaks as soon as two + // chunks overtake each other or a retry sends one of them twice, so every chunk carries its byte range - + // as the standard Content-Range header and as plain numbers for the servers that would rather not parse it. + private async Task?> GetUploadHeaders(BitFileInfo fileInfo, long from, long to) + { + // the headers of the provider are per request, so they are never handed to the setup call and + // are instead merged in here, under the ones of this specific file, right before each request. + // an entry sent twice would be concatenated into one comma separated value by the browser, + // which is exactly why the provider does not also go through the static setup headers. + var providedHeaders = UploadRequestHttpHeadersProvider is null + ? null + : await UploadRequestHttpHeadersProvider.Invoke(); + + var fileHeaders = MergeEntries(providedHeaders, fileInfo.HttpHeaders); + + if (ChunkedUpload is false) return fileHeaders; + + var headers = fileHeaders is null + ? [] + : new Dictionary(fileHeaders); + + // an empty file has no byte to describe a range over, so it only carries the size headers. + if (fileInfo.Size > 0) + { + headers["Content-Range"] = $"bytes {from}-{to - 1}/{fileInfo.Size}"; + } + + headers["BIT_CHUNK_FROM"] = from.ToString(CultureInfo.InvariantCulture); + headers["BIT_CHUNK_TO"] = to.ToString(CultureInfo.InvariantCulture); + headers["BIT_FILE_SIZE"] = fileInfo.Size.ToString(CultureInfo.InvariantCulture); + + return headers; + } + + private async Task PauseOneFile(BitFileInfo file) + { + // a file waiting in the queue is already on its way, and taking it out of the queue is what + // pausing means for it - there is no request of its own to abort yet. + if (file.IsQueued) + { + _uploadQueue.Remove(file); + file.IsQueued = false; + + await PauseUploadOneFile(file.Index); + return; + } + + // there is nothing to pause about a file that was never asked to upload, or one that has already + // settled: pausing them would only put them in a state their own buttons cannot get them out of. + if (file.Status is not BitFileUploadStatus.InProgress) return; + + // aborting right away instead of waiting for the next chunk boundary, so that pausing + // also works for non-chunked uploads whose only request is already in flight. + await PauseUploadOneFile(file.Index); + } + + private async Task CancelOneFile(BitFileInfo file) + { + // a file that has already settled has nothing left to cancel: taking a completed upload back would + // claim something about the server that canceling on this side cannot deliver, and calling off a + // file that already failed would only replace the reason it failed with a less useful one. + if (file.Status is BitFileUploadStatus.Completed + or BitFileUploadStatus.Failed + or BitFileUploadStatus.Canceled + or BitFileUploadStatus.Removed + or BitFileUploadStatus.NotAllowed) return; + + // an in-progress file aborts its in-flight request; a pending, queued, paused or failed file has + // no request to abort, but its cancellation must still land right away rather than sit as an + // intention nobody can see, waiting for an upload that may never be asked for. + _uploadQueue.Remove(file); + file.IsQueued = false; + + await CancelUploadOneFile(file.Index); } private async Task PauseUploadOneFile(int index) { - if (_files.Any() is false) return; + if (index < 0 || index >= _files.Count) return; - await _js.BitFileUploadPause(UniqueId, index); var file = _files[index]; + + // the status changes before the abort, so that the abort callback coming back from JavaScript + // finds the file already paused instead of mistaking the aborted request for a failed upload. await UpdateStatus(BitFileUploadStatus.Paused, file); - file.PauseUploadRequested = false; + file.IsRequestInFlight = false; + + await _js.BitFileUploadPause(UniqueId, index); + } + + private async Task CancelUploadOneFile(int index) + { + if (index < 0 || index >= _files.Count) return; + + var file = _files[index]; + + // the status changes before the abort, so that the abort callback coming back from JavaScript + // finds the file already canceled instead of mistaking the aborted request for a failed upload. + await UpdateStatus(BitFileUploadStatus.Canceled, file); + file.IsRequestInFlight = false; + + await _js.BitFileUploadPause(UniqueId, index); + } + + // the speed of an upload is what turns a progress bar into an answer to "how long is this going to + // take", so it is measured over the request currently in flight - the only window whose start is + // known exactly - and the bytes still to send are divided by it to get the time left. + private static void UpdateTransferRate(BitFileInfo file) + { + if (file.TransferStartTime is not { } startTime) return; + + var elapsed = (DateTime.UtcNow - startTime).TotalSeconds; + + // the very first progress reports arrive too close to the start for their ratio to mean + // anything, and a reading taken over no time at all would be an infinity. + if (elapsed < 0.2) return; + + var uploaded = file.TotalUploadedSize + file.LastChunkUploadedSize - file.TransferStartOffset; + + if (uploaded <= 0) return; + + var speed = uploaded / elapsed; + + file.UploadSpeed = speed; + + // the progress events count the multipart overhead too, so the bytes reported as sent can run + // slightly past the file itself, which must not turn into a negative time left. + var remaining = Math.Max(0, file.Size - (file.TotalUploadedSize + file.LastChunkUploadedSize)); + + file.RemainingTime = TimeSpan.FromSeconds(remaining / speed); } private void UpdateChunkSize(int fileIndex) { - if (_files.Any() is false || AutoChunkSize is false) return; + if (_files.Any() is false || AutoChunkSize is false || ChunkedUpload is false) return; + + var file = _files[fileIndex]; var dtNow = DateTime.UtcNow; - var duration = (dtNow - _files[fileIndex].StartTimeUpload.GetValueOrDefault(dtNow)).TotalMilliseconds; + var duration = (dtNow - file.StartTimeUpload.GetValueOrDefault(dtNow)).TotalMilliseconds; + if (duration <= 0) return; if (duration is >= 1000 and <= 1500) return; - _internalChunkSize = Convert.ToInt64(_internalChunkSize / (duration / 1000)); + // the new size is derived from the chunk that was actually measured rather than from the current + // setting, which another file uploading in parallel may well have moved since this one was sent. + if (file.PendingChunkSize <= 0) return; + + _internalChunkSize = Convert.ToInt64(file.PendingChunkSize / (duration / 1000)); if (_internalChunkSize > MAX_CHUNK_SIZE) { @@ -681,83 +1805,157 @@ private void UpdateChunkSize(int fileIndex) } } - private async Task UpdateStatus(BitFileUploadStatus uploadStatus, BitFileInfo? fileInfo = null) + private bool _HasDescription => DescriptionTemplate is not null || Description.HasValue(); + + private void Announce() { - if (_files.Any() is false) return; + var text = GetAnnouncementText(); - if (fileInfo is null) - { - UploadStatus = uploadStatus; + // screen readers skip a live region update that repeats the previous text verbatim, + // so an invisible zero width space is alternated to make every update unique. + _announcementMarker = !_announcementMarker; - var files = _files.Where(c => c.Status != BitFileUploadStatus.NotAllowed).ToArray(); - foreach (var file in files) - { - file.Status = uploadStatus; - } + _announcement = text.HasValue() && _announcementMarker ? text + '\u200B' : text; + } + + private string? GetAnnouncementText() + { + if (AnnouncementProvider is not null) return AnnouncementProvider(_files); - await OnChange.InvokeAsync(files); + var files = _files.Where(f => f.Status != BitFileUploadStatus.Removed).ToArray(); + + if (files.Length == 0) return "No file selected."; + + var completed = files.Count(f => f.Status is BitFileUploadStatus.Completed); + var failed = files.Count(f => f.Status is BitFileUploadStatus.Failed); + var notAllowed = files.Count(f => f.Status is BitFileUploadStatus.NotAllowed); + + return $"{files.Length} file{(files.Length == 1 ? string.Empty : "s")} selected." + + (completed > 0 ? $" {completed} uploaded." : string.Empty) + + (failed > 0 ? $" {failed} failed." : string.Empty) + + (notAllowed > 0 ? $" {notAllowed} not allowed." : string.Empty); + } + + private async Task UpdateStatus(BitFileUploadStatus uploadStatus, BitFileInfo fileInfo) + { + if (uploadStatus is not BitFileUploadStatus.InProgress) + { + // a file that is not on the wire has no speed, and a time left measured over a transfer + // that is over would keep counting down towards something that is never going to happen. + fileInfo.UploadSpeed = null; + fileInfo.RemainingTime = null; + fileInfo.TransferStartTime = null; } - else + + if (fileInfo.Status != uploadStatus) { - if (fileInfo.Status != uploadStatus) + fileInfo.Status = uploadStatus; + + // upload outcomes get announced to screen readers; the ever-changing progress does not, + // since announcing every tick would drown out everything else. + if (uploadStatus is not BitFileUploadStatus.InProgress and not BitFileUploadStatus.Pending) { - fileInfo.Status = uploadStatus; - await OnChange.InvokeAsync([fileInfo]); + Announce(); } - switch (uploadStatus) - { - case BitFileUploadStatus.InProgress: - await OnProgress.InvokeAsync(fileInfo); - break; + await OnChange.InvokeAsync([fileInfo]); + } - case BitFileUploadStatus.Completed: - await OnUploadComplete.InvokeAsync(fileInfo); - break; + switch (uploadStatus) + { + case BitFileUploadStatus.InProgress: + await OnProgress.InvokeAsync(fileInfo); + break; - case BitFileUploadStatus.Failed: - await OnUploadFailed.InvokeAsync(fileInfo); - break; + case BitFileUploadStatus.Completed: + await OnUploadComplete.InvokeAsync(fileInfo); + break; - case BitFileUploadStatus.Removed: - await OnRemoveComplete.InvokeAsync(fileInfo); - break; + case BitFileUploadStatus.Failed: + await OnUploadFailed.InvokeAsync(fileInfo); + break; - case BitFileUploadStatus.RemoveFailed: - await OnRemoveFailed.InvokeAsync(fileInfo); - break; - } + case BitFileUploadStatus.Removed: + await OnRemoveComplete.InvokeAsync(fileInfo); + break; + + case BitFileUploadStatus.RemoveFailed: + await OnRemoveFailed.InvokeAsync(fileInfo); + break; } } - private async Task CancelUploadOneFile(int index) + // a file reaching a terminal state hands its slot over to the next file waiting in the queue, + // and only once nothing is left running does the batch get reported as complete. + private async Task SettleFile() { - if (_files.Any() is false) return; + await PumpUploadQueue(); - await _js.BitFileUploadPause(UniqueId, index); - var file = _files[index]; - await UpdateStatus(BitFileUploadStatus.Canceled, file); - file.CancelUploadRequested = false; + await CheckAllUploadsComplete(); + } + + private async Task CheckAllUploadsComplete() + { + // a batch nobody ever asked to upload has not completed anything, however settled its files look: + // a selection of files rejected by the validations is not an upload that finished. + if (UploadStatus is not BitFileUploadStatus.InProgress) return; + + // paused files still count as in flight - the batch is only complete once every file has + // reached a terminal state (completed, failed, canceled, removed or rejected by validation). + if (_files.Any(f => f.Status is BitFileUploadStatus.Pending or BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused)) return; + + UploadStatus = BitFileUploadStatus.Completed; + await OnAllUploadsComplete.InvokeAsync([.. _files]); } private async Task RemoveOneFile(BitFileInfo fileInfo) { if (fileInfo.Status is BitFileUploadStatus.Removed) return; - if (fileInfo.TotalUploadedSize > 0) + _uploadQueue.Remove(fileInfo); + + // a file on its way out has no reason to keep sending itself, so its in-flight request is dropped + // before anything else - otherwise the bytes would keep flowing to an endpoint that is about to be + // told to delete them. + if (fileInfo.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused) { + await _js.BitFileUploadPause(UniqueId, fileInfo.Index); + } + + // a completed file counts as being on the server even when it carried no byte at all, + // which is exactly the case of an empty file that uploaded successfully. + var isOnServer = fileInfo.TotalUploadedSize > 0 || fileInfo.Status is BitFileUploadStatus.Completed; + + if (isOnServer && RemoveUrl.HasValue()) + { + fileInfo.IsRemoving = true; + StateHasChanged(); + await RemoveOneFileFromServer(fileInfo); + + fileInfo.IsRemoving = false; } else { await UpdateStatus(BitFileUploadStatus.Removed, fileInfo); } + + if (fileInfo.Status is not BitFileUploadStatus.Removed) return; + + // a removed file is never going to be sent again, so everything the browser was holding on to for + // it is handed back: the picked file itself, which would otherwise stay in memory for the whole + // life of the page, and the object URL of the thumbnail that is not rendered anymore. + fileInfo.PreviewUrl = null; + + try + { + await _js.BitFileUploadRelease(UniqueId, fileInfo.Index); + } + catch (JSDisconnectedException) { } // we can ignore this exception here } private async Task RemoveOneFileFromServer(BitFileInfo fileInfo) { - if (RemoveUrl.HasNoValue()) return; - try { var url = AddQueryString(RemoveUrl!, "fileName", fileInfo.Name); @@ -771,7 +1969,11 @@ private async Task RemoveOneFileFromServer(BitFileInfo fileInfo) url = AddQueryString(url, qs); } - using var request = new HttpRequestMessage(HttpMethod.Delete, url); + var method = RemoveRequestHttpMethod.HasValue() + ? new HttpMethod(RemoveRequestHttpMethod!) + : HttpMethod.Delete; + + using var request = new HttpRequestMessage(method, url); request.Headers.Add("BIT_FILE_ID", fileInfo.FileId); @@ -784,13 +1986,23 @@ private async Task RemoveOneFileFromServer(BitFileInfo fileInfo) request.Headers.Add(header.Key, header.Value); } - await _httpClient.SendAsync(request); + var response = await _httpClient.SendAsync(request); - await UpdateStatus(BitFileUploadStatus.Removed, fileInfo); + if (response.IsSuccessStatusCode) + { + await UpdateStatus(BitFileUploadStatus.Removed, fileInfo); + } + else + { + fileInfo.Message = $"{(int)response.StatusCode} {response.ReasonPhrase}"; + await UpdateStatus(BitFileUploadStatus.RemoveFailed, fileInfo); + } } catch (Exception ex) { - fileInfo.Message = ex.ToString(); + // only the message of the exception, since this text is rendered right in the file item and + // a full stack trace there says nothing to the user while telling a stranger far too much. + fileInfo.Message = ex.Message; await UpdateStatus(BitFileUploadStatus.RemoveFailed, fileInfo); } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss index d8f2f12f97..369f38aa4f 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss @@ -1,4 +1,4 @@ -@import "../../../Styles/functions.scss"; +@import "../../../Styles/functions.scss"; .bit-upl { display: flex; @@ -11,14 +11,15 @@ border-color: $clr-brd-dis; background-color: $clr-bg-dis; - @media (hover: hover) { - &:hover { - border-color: $clr-brd-dis; - } - } - - &:active { - background-color: $clr-bg-dis; + //the browse button carries the role color while enabled, but a disabled file upload reads as a + //neutral form field whatever its role and variant (see the disabled conventions in + //theme-variables.scss), so it drops the role colors for the generic disabled trio and stops + //answering the pointer. the border and the background are left to the variant blocks below, + //since which of the two is painted at all is exactly what the variants disagree on. + .bit-upl-lbl { + cursor: default; + color: $clr-fg-dis; + pointer-events: none; } } @@ -37,6 +38,8 @@ display: none; } +//everything the three variants share; the colors themselves live in the variant blocks right below, +//which is also why the border is declared without its color here. .bit-upl-lbl { outline: 0; display: flex; @@ -44,28 +47,115 @@ font-weight: 300; user-select: none; align-items: center; - font-size: spacing(2); justify-content: center; - min-height: spacing(4.5); border-radius: spacing(0.5); - padding: spacing(0.5) spacing(2); - color: $clr-fg-pri; - background-color: $clr-bg-pri; - border: $shp-border-width $shp-border-style $clr-brd-pri; + border-width: $shp-border-width; + border-style: $shp-border-style; + min-height: var(--bit-upl-lbl-height); + font-size: var(--bit-upl-lbl-fontsize); + padding: var(--bit-upl-lbl-padding); - @media (hover: hover) { - &:hover { - border-color: $clr-brd-pri-hover; + i { + margin-inline-end: spacing(0.625); + } +} + +//Fill - the default: a solid block of the role color labelled in the role's on-color. +.bit-upl-fil { + .bit-upl-lbl { + color: var(--bit-upl-clr-txt); + border-color: var(--bit-upl-clr); + background-color: var(--bit-upl-clr); + + @media (hover: hover) { + &:hover { + border-color: var(--bit-upl-clr-hover); + background-color: var(--bit-upl-clr-hover); + } + } + + &:active { + border-color: var(--bit-upl-clr-active); + background-color: var(--bit-upl-clr-active); } } - &:active { - background-color: $clr-bg-pri-active; + &.bit-dis .bit-upl-lbl { + border-color: $clr-brd-dis; + background-color: $clr-bg-dis; } +} - i { - padding-right: spacing(0.625); +//Outline: only the rule around the button carries the role color at rest, and it fills in like the Fill +//variant once a pointer lands on it. +.bit-upl-otl { + .bit-upl-lbl { + color: var(--bit-upl-clr); + border-color: var(--bit-upl-clr); + background-color: transparent; + + @media (hover: hover) { + &:hover { + color: var(--bit-upl-clr-txt); + background-color: var(--bit-upl-clr-hover); + } + } + + &:active { + color: var(--bit-upl-clr-txt); + background-color: var(--bit-upl-clr-active); + } + } + + &.bit-dis .bit-upl-lbl { + border-color: $clr-brd-dis; + background-color: transparent; + } +} + +//Text: neither rule nor fill at rest, for an uploader that should not outweigh the fields around it. +.bit-upl-txt { + .bit-upl-lbl { + color: var(--bit-upl-clr); + border-color: transparent; + background-color: transparent; + + @media (hover: hover) { + &:hover { + color: var(--bit-upl-clr-txt); + background-color: var(--bit-upl-clr-hover); + } + } + + &:active { + color: var(--bit-upl-clr-txt); + background-color: var(--bit-upl-clr-active); + } } + + &.bit-dis .bit-upl-lbl { + border-color: transparent; + background-color: transparent; + } +} + +//while a file is dragged over the component the browse button becomes the drop zone, and it does so the +//same way in every variant: the hover shade of the role fills it, and the rule turns into a dashed one +//in the on-color - the role color itself would vanish against that fill. the root class is repeated so +//that the drop state outranks the per-variant :hover rules above, which are otherwise as specific. +.bit-upl.bit-upl-drg { + .bit-upl-lbl { + border-style: dashed; + color: var(--bit-upl-clr-txt); + border-color: var(--bit-upl-clr-txt); + background-color: var(--bit-upl-clr-hover); + } +} + +.bit-upl-dsc { + color: $clr-fg-sec; + margin-top: spacing(0.5); + font-size: var(--bit-upl-fs-fontsize); } .bit-upl-fl { @@ -79,11 +169,11 @@ font-weight: 100; align-items: center; flex-flow: row nowrap; - font-size: spacing(1.75); margin-top: spacing(0.375); border-radius: spacing(0.625); justify-content: space-between; color: $clr-fg-pri; + font-size: var(--bit-upl-itm-fontsize); border: $shp-border-width $shp-border-style $clr-brd-pri; @media (hover: hover) { @@ -114,93 +204,134 @@ .bit-upl-psd { .bit-upl-us { - color: $clr-pri; + color: var(--bit-upl-clr); } } +.bit-upl-prv { + flex-shrink: 0; + object-fit: cover; + border-radius: spacing(0.5); + width: var(--bit-upl-prv-size); + height: var(--bit-upl-prv-size); + margin-inline-start: spacing(0.5); +} + .bit-upl-fic { + flex-grow: 1; height: 100%; display: flex; + overflow: hidden; cursor: default; box-sizing: border-box; align-items: flex-start; justify-content: center; flex-flow: column nowrap; - width: calc(100% - #{40px}); padding: spacing(1) spacing(2); } .bit-upl-fnc { width: 100%; display: flex; - - i { - padding-top: spacing(1); - } + overflow: hidden; + align-items: center; + flex-flow: row nowrap; } .bit-upl-fn { - height: 100%; + width: 100%; overflow: hidden; white-space: nowrap; - padding-top: spacing(1); text-overflow: ellipsis; - width: calc(100% - #{32px}); } .bit-upl-fsc { width: 100%; display: flex; + overflow: hidden; + align-items: center; + flex-flow: row nowrap; + margin-top: spacing(0.25); + font-size: var(--bit-upl-fs-fontsize); } .bit-upl-fs { - padding-top: spacing(1); - margin-right: spacing(1); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-inline-end: spacing(1); } .bit-upl-pct { - padding-top: spacing(1); + white-space: nowrap; } .bit-upl-pbc { width: 100%; overflow: hidden; height: spacing(0.25); - padding-top: spacing(1); - padding-bottom: spacing(1); - padding-left: spacing(0.25); + margin-top: spacing(1); + margin-bottom: spacing(0.5); + background-color: $clr-bg-sec; } -.bit-upl-pbr { +// named -pgb rather than -pbr so that it cannot collide with the generated PrimaryBorder role class. +.bit-upl-pgb { height: spacing(0.25); transition: width $mot-duration linear 0s; - background-color: $clr-pri; + background-color: var(--bit-upl-clr); } .bit-upl-usi { - height: 100%; + border: 0; + padding: 0; display: flex; + flex-shrink: 0; cursor: pointer; - width: spacing(4); + align-self: stretch; align-items: center; + color: $clr-fg-pri; justify-content: center; + background-color: transparent; + font-family: $tg-font-family; + width: var(--bit-upl-btn-size); @media (hover: hover) { &:hover { i { - color: $clr-pri; + color: var(--bit-upl-clr); } } } + + &:disabled { + cursor: default; + color: $clr-fg-dis; + } + + i { + font-size: var(--bit-upl-ico-fontsize); + } +} + +.bit-upl-lvr { + top: auto; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + clip-path: inset(50%); } .bit-upl-ldg { height: 100%; display: flex; - width: spacing(4); - margin-right: spacing(0.5); + flex-shrink: 0; align-items: center; justify-content: center; + width: var(--bit-upl-btn-size); } .bit-upl-spn { @@ -210,6 +341,49 @@ border-width: spacing(0.2125); border-style: $shp-border-style; border-color: $clr-brd-sec; - border-top-color: $clr-pri-dark; + border-top-color: var(--bit-upl-clr); animation: bit-upl-spinner-animation $mot-duration-spinner $mot-easing-spinner infinite; } + +// Role classes are generated from the shared $bit-color-roles map (see color-role-maps.scss). +@each $role, $tokens in $bit-color-roles { + .bit-upl-#{$role} { + --bit-upl-clr: #{role($tokens, main)}; + --bit-upl-clr-txt: #{role($tokens, on)}; + --bit-upl-clr-hover: #{role($tokens, hover)}; + --bit-upl-clr-active: #{role($tokens, active)}; + } +} + +.bit-upl-sm { + --bit-upl-lbl-height: #{spacing(3.5)}; + --bit-upl-lbl-fontsize: #{spacing(1.5)}; + --bit-upl-lbl-padding: #{spacing(0.25)} #{spacing(1.5)}; + --bit-upl-itm-fontsize: #{spacing(1.5)}; + --bit-upl-fs-fontsize: #{spacing(1.375)}; + --bit-upl-prv-size: #{spacing(4)}; + --bit-upl-btn-size: #{spacing(3.5)}; + --bit-upl-ico-fontsize: #{spacing(1.5)}; +} + +.bit-upl-md { + --bit-upl-lbl-height: #{spacing(4.5)}; + --bit-upl-lbl-fontsize: #{spacing(2)}; + --bit-upl-lbl-padding: #{spacing(0.5)} #{spacing(2)}; + --bit-upl-itm-fontsize: #{spacing(1.75)}; + --bit-upl-fs-fontsize: #{spacing(1.625)}; + --bit-upl-prv-size: #{spacing(5)}; + --bit-upl-btn-size: #{spacing(4)}; + --bit-upl-ico-fontsize: #{spacing(2)}; +} + +.bit-upl-lg { + --bit-upl-lbl-height: #{spacing(5.5)}; + --bit-upl-lbl-fontsize: #{spacing(2.25)}; + --bit-upl-lbl-padding: #{spacing(0.75)} #{spacing(2.5)}; + --bit-upl-itm-fontsize: #{spacing(2)}; + --bit-upl-fs-fontsize: #{spacing(1.75)}; + --bit-upl-prv-size: #{spacing(6.5)}; + --bit-upl-btn-size: #{spacing(5)}; + --bit-upl-ico-fontsize: #{spacing(2.5)}; +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts index f22ad996e8..30f0fb28ab 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts @@ -1,48 +1,79 @@ -namespace BitBlazorUI { +namespace BitBlazorUI { export class FileUpload { + private static readonly IMAGE_SIZE_CONCURRENCY = 8; + private static _fileUploaders: BitFileUploader[] = []; - public static setup( + public static async setup( id: string, dotnetReference: DotNetObject, inputElement: HTMLInputElement, append: boolean, uploadEndpointUrl: string | undefined, - headers: Record | undefined) { + headers: Record | undefined, + method: string | undefined, + withCredentials: boolean, + timeout: number, + fieldName: string | undefined, + showPreview: boolean, + readImageDimensions: boolean) { if (!append) { FileUpload.clear(id); } - const lastIndex = append ? FileUpload._fileUploaders.filter(u => u.id === id).length : 0; + const existingUploaders = append ? FileUpload._fileUploaders.filter(u => u.id === id) : []; + // a reduce instead of a spread into Math.max, since a selection can carry + // more items than the argument limit of a function call. + const lastIndex = existingUploaders.reduce((max, u) => u.index + 1 > max ? u.index + 1 : max, 0); const files = Array.from(inputElement.files!).map((file, index) => ({ name: file.name, size: file.size, type: file.type, + lastModified: file.lastModified, + previewUrl: (showPreview && file.type.startsWith('image/')) ? URL.createObjectURL(file) : null, fileId: Utils.uuidv4(), file: file, - index: (index + lastIndex) + index: (index + lastIndex), + width: null as number | null, + height: null as number | null })); files.forEach((f) => { const h = { ...(headers || {}), ...{ 'BIT_FILE_ID': f.fileId } }; - const uploader = new BitFileUploader(id, dotnetReference, f.file, uploadEndpointUrl, h, f.index); + const uploader = new BitFileUploader(id, dotnetReference, f.file, uploadEndpointUrl, h, f.index, + method || 'POST', withCredentials, timeout, fieldName || 'file', f.previewUrl); FileUpload._fileUploaders.push(uploader); }); + // the input has to be emptied before awaiting anything, otherwise selecting the same file + // right after would not raise a change event. inputElement.value = ''; - return files; + if (readImageDimensions) { + await FileUpload.readImageSizes(files.filter(f => f.type.startsWith('image/'))); + } + + // the File itself is only of use on this side, so it is left out of the interop payload. + return files.map(({ file, ...info }) => info); } - public static upload(id: string, from: number, to: number, index: number, uploadUrl: string, headers: Record = {}): void { + public static upload( + id: string, + from: number, + to: number, + index: number, + uploadUrl: string, + headers: Record = {}, + formFields: Record = {}): void { + const uploaders = FileUpload._fileUploaders.filter(u => u.id === id); if (index === -1) { - uploaders.forEach(u => u.upload(from, to, uploadUrl, headers)); + uploaders.forEach(u => u.upload(from, to, uploadUrl, headers, formFields)); } else { - const uploader = uploaders.filter(u => u.index === index)[0]; - uploader.upload(from, to, uploadUrl, headers); + const uploader = uploaders.find(u => u.index === index); + uploader?.upload(from, to, uploadUrl, headers, formFields); } } @@ -52,47 +83,207 @@ if (index === -1) { uploaders.forEach(u => u.pause()); } else { - const uploader = uploaders.filter(u => u.index === index)[0]; - uploader.pause(); + const uploader = uploaders.find(u => u.index === index); + uploader?.pause(); } } - public static setupDragDrop(dropZoneElement: HTMLElement, inputElement: HTMLInputElement) { + public static setupDragDrop( + dropZoneElement: HTMLElement, + inputElement: HTMLInputElement, + dragClass: string, + dragStyle: string | null, + allowDrop: boolean, + allowPaste: boolean, + expandDirectories: boolean) { + + let dragCounter = 0; + let originalStyle: string | null = null; + let dragClasses = dragClass.split(' ').filter(c => c.length > 0); + + function hasFiles(e: DragEvent) { + return !!e.dataTransfer && Array.prototype.includes.call(e.dataTransfer.types, 'Files'); + } + + function canAcceptDrop(e: DragEvent) { + return allowDrop && !inputElement.disabled && hasFiles(e); + } + + function applyDragStyling() { + dropZoneElement.classList.add(...dragClasses); + + if (!dragStyle) return; + originalStyle = dropZoneElement.getAttribute('style'); + dropZoneElement.setAttribute('style', [originalStyle, dragStyle].filter(s => s).join(';')); + } + + function clearDragStyling() { + dropZoneElement.classList.remove(...dragClasses); + + if (!dragStyle) return; + if (originalStyle) { + dropZoneElement.setAttribute('style', originalStyle); + } else { + dropZoneElement.removeAttribute('style'); + } + originalStyle = null; + } + + function addDragState() { + dragCounter++; + if (dragCounter > 1) return; + + applyDragStyling(); + } - function onDragHover(e: DragEvent) { + function removeDragState(force: boolean) { + if (dragCounter === 0) return; + + dragCounter = force ? 0 : dragCounter - 1; + if (dragCounter > 0) return; + + clearDragStyling(); + } + + function onDragEnter(e: DragEvent) { e.preventDefault(); + if (!canAcceptDrop(e)) return; + + addDragState(); } - function onDragLeave(e: DragEvent) { + function onDragOver(e: DragEvent) { + // the default must always be prevented, otherwise the browser navigates away + // to the dropped file and the app state gets lost. e.preventDefault(); + + if (!e.dataTransfer) return; + + // gives the OS the correct drag cursor (a copy badge or a no-drop sign). + e.dataTransfer.dropEffect = canAcceptDrop(e) ? 'copy' : 'none'; } - function onDrop(e: DragEvent) { + function onDragLeave(e: DragEvent) { e.preventDefault(); - inputElement.files = e.dataTransfer!.files; + if (!hasFiles(e)) return; + + removeDragState(false); + } + + // a drag that ends without a matching dragleave (cancelled with Escape, released outside the + // window, or dropped on something else) would otherwise leave the counter above zero and + // the drag classes and inline style stuck on the drop zone. + function onDragCancel() { + removeDragState(true); + } + + function setFiles(files: File[] | FileList) { + const list = Array.from(files as ArrayLike); + if (list.length === 0) return; + + // a directory input always hands over many files through the dialog, + // so a dropped folder must not be trimmed down to a single file either. + const acceptsMany = inputElement.multiple || inputElement.webkitdirectory; + + if (!acceptsMany && list.length > 1) { + // the file dialog can never hand over more than one file without the multiple attribute, + // so a multi-file drop or paste is trimmed down to its first file too. + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(list[0]); + inputElement.files = dataTransfer.files; + } else if (files instanceof FileList) { + inputElement.files = files; + } else { + const dataTransfer = new DataTransfer(); + list.forEach(f => dataTransfer.items.add(f)); + inputElement.files = dataTransfer.files; + } + const event = new Event('change', { bubbles: true }); inputElement.dispatchEvent(event); } + function onDrop(e: DragEvent) { + e.preventDefault(); + removeDragState(true); + + if (!allowDrop || inputElement.disabled || !e.dataTransfer) return; + + if (!expandDirectories) { + setFiles(e.dataTransfer.files); + return; + } + + // the entries of a DataTransfer are only readable synchronously inside the event handler, + // so they get collected first and walked afterwards. + const entries = FileUpload.readDroppedEntries(e.dataTransfer); + const fallback = Array.from(e.dataTransfer.files); + + FileUpload.collectEntries(entries) + .then(files => setFiles(files.length ? files : fallback)) + .catch(() => setFiles(fallback)); + } + function onPaste(e: ClipboardEvent) { - inputElement.files = e.clipboardData!.files; - const event = new Event('change', { bubbles: true }); - inputElement.dispatchEvent(event); + if (!allowPaste || inputElement.disabled) return; + if (!e.clipboardData || e.clipboardData.files.length === 0) return; + + setFiles(e.clipboardData.files); } - dropZoneElement.addEventListener("dragenter", onDragHover); - dropZoneElement.addEventListener("dragover", onDragHover); + dropZoneElement.addEventListener("dragenter", onDragEnter); + dropZoneElement.addEventListener("dragover", onDragOver); dropZoneElement.addEventListener("dragleave", onDragLeave); dropZoneElement.addEventListener("drop", onDrop); dropZoneElement.addEventListener('paste', onPaste); + dropZoneElement.addEventListener('dragend', onDragCancel); + // the window listener only cleans the state up, it never prevents the default, + // so a drop landing anywhere else on the page keeps behaving as it did. + window.addEventListener('dragend', onDragCancel); + window.addEventListener('drop', onDragCancel); return { + update: ( + newAllowDrop: boolean, + newAllowPaste: boolean, + newExpandDirectories: boolean, + newDragClass: string, + newDragStyle: string | null) => { + + allowDrop = newAllowDrop; + allowPaste = newAllowPaste; + expandDirectories = newExpandDirectories; + + if (newDragClass !== dragClass || newDragStyle !== dragStyle) { + // an ongoing drag is already showing the old class and style, which have to come off + // before they get replaced, otherwise nothing would ever take them off again. + const isDragging = dragCounter > 0; + if (isDragging) { + clearDragStyling(); + } + + dragClass = newDragClass; + dragClasses = dragClass.split(' ').filter(c => c.length > 0); + dragStyle = newDragStyle; + + if (isDragging) { + applyDragStyling(); + } + } + + if (!allowDrop) { + removeDragState(true); + } + }, dispose: () => { - dropZoneElement.removeEventListener('dragenter', onDragHover); - dropZoneElement.removeEventListener('dragover', onDragHover); + dropZoneElement.removeEventListener('dragenter', onDragEnter); + dropZoneElement.removeEventListener('dragover', onDragOver); dropZoneElement.removeEventListener('dragleave', onDragLeave); dropZoneElement.removeEventListener("drop", onDrop); dropZoneElement.removeEventListener('paste', onPaste); + dropZoneElement.removeEventListener('dragend', onDragCancel); + window.removeEventListener('dragend', onDragCancel); + window.removeEventListener('drop', onDragCancel); } } @@ -103,32 +294,167 @@ } public static clear(id: string) { + // an uploader dropped while its request is still in flight has to be fully detached first, + // otherwise the late response of the old request could get attributed to a newly selected + // file occupying the same index. + FileUpload._fileUploaders.filter(u => u.id === id).forEach(u => u.detach()); + FileUpload._fileUploaders = FileUpload._fileUploaders.filter(u => u.id !== id); } - public static reset(id: string, inputElement: HTMLInputElement,) { + // a file that was taken out of the list is never uploaded again, but its uploader stays in place + // so that the indexes of the files after it keep pointing at the right uploader. what it does give + // up is everything it was holding on to: the File itself, which the browser keeps in memory - and + // for a picked file, on disk - for as long as anything references it, and its preview object URL. + public static release(id: string, index: number) { + const uploader = FileUpload._fileUploaders.find(u => u.id === id && u.index === index); + + uploader?.detach(); + } + + public static reset(id: string, inputElement: HTMLInputElement) { FileUpload.clear(id); inputElement.value = ''; } + + private static async readImageSizes(images: { file: File, width: number | null, height: number | null }[]): Promise { + // a directory selection can carry thousands of images, and decoding them all at once would + // spike the memory and stall the tab, so a fixed window of workers walks the list instead. + let next = 0; + + const worker = async () => { + while (next < images.length) { + const image = images[next++]; + const size = await FileUpload.readImageSize(image.file); + image.width = size.width; + image.height = size.height; + } + }; + + await Promise.all(Array.from({ length: Math.min(FileUpload.IMAGE_SIZE_CONCURRENCY, images.length) }, worker)); + } + + private static async readImageSize(file: File): Promise<{ width: number | null, height: number | null }> { + // createImageBitmap decodes off the main thread and needs no DOM, so it is the fast path. + if (typeof createImageBitmap === 'function') { + try { + const bitmap = await createImageBitmap(file); + const size = { width: bitmap.width, height: bitmap.height }; + bitmap.close(); + return size; + } catch { /* falls back to the image element below (e.g. for SVG on some browsers) */ } + } + + return new Promise(resolve => { + const url = URL.createObjectURL(file); + const image = new Image(); + + const finish = (width: number | null, height: number | null) => { + URL.revokeObjectURL(url); + resolve({ width, height }); + }; + + image.onload = () => finish(image.naturalWidth, image.naturalHeight); + image.onerror = () => finish(null, null); + image.src = url; + }); + } + + private static readDroppedEntries(dataTransfer: DataTransfer): any[] { + const items = dataTransfer.items; + if (!items || items.length === 0) return []; + + const entries: any[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i] as any; + if (item.kind !== 'file') continue; + + entries.push(item.webkitGetAsEntry ? item.webkitGetAsEntry() : item.getAsFile()); + } + + return entries.filter(e => !!e); + } + + private static async collectEntries(entries: any[]): Promise { + const files: File[] = []; + + for (const entry of entries) { + await FileUpload.collectEntry(entry, files); + } + + return files; + } + + private static async collectEntry(entry: any, files: File[]): Promise { + if (entry instanceof File) { + files.push(entry); + return; + } + + if (entry.isFile) { + return new Promise(resolve => entry.file( + (file: File) => { files.push(file); resolve(); }, + () => resolve())); + } + + if (entry.isDirectory) { + const reader = entry.createReader(); + + // readEntries only returns a batch at a time, so it must be called until it comes back empty. + while (true) { + const batch: any[] = await new Promise(resolve => reader.readEntries( + (result: any[]) => resolve(result), + () => resolve([]))); + + if (batch.length === 0) break; + + for (const child of batch) { + await FileUpload.collectEntry(child, files); + } + } + } + } } class BitFileUploader { id: string; dotnetReference: DotNetObject; - file: File; + file: File | null; uploadUrl: string | undefined; headers: Record; index: number; + method: string; + withCredentials: boolean; + timeout: number; + fieldName: string; + previewUrl: string | null; private xhr: XMLHttpRequest = new XMLHttpRequest(); - constructor(id: string, dotnetReference: DotNetObject, file: File, uploadEndpointUrl: string | undefined, headers: Record, index: number) { + constructor( + id: string, + dotnetReference: DotNetObject, + file: File, + uploadEndpointUrl: string | undefined, + headers: Record, + index: number, + method: string, + withCredentials: boolean, + timeout: number, + fieldName: string, + previewUrl: string | null) { + + this.previewUrl = previewUrl; this.id = id; this.dotnetReference = dotnetReference; this.file = file; this.uploadUrl = uploadEndpointUrl; this.headers = headers; this.index = index; + this.method = method; + this.withCredentials = withCredentials; + this.timeout = timeout; + this.fieldName = fieldName; if (index < 0) return; @@ -146,19 +472,38 @@ }; } - upload(from: number, to: number, uploadUrl: string, headers: Record): void { + upload(from: number, to: number, uploadUrl: string, headers: Record, formFields: Record): void { const file = this.file; if (file === null) return; const data: FormData = new FormData(); const chunk = file.slice(from, to); - data.append('file', chunk, file.name); + data.append(this.fieldName, chunk, file.name); - var url = uploadUrl || this.uploadUrl; + // the extra fields go in after the content, so a field accidentally named like the file field + // cannot shadow the file itself for the servers that only read the first value of a name. + Object.keys(formFields).forEach(f => { + data.append(f, formFields[f]); + }); + + const url = uploadUrl || this.uploadUrl; + + if (!url) { + // silently swallowing the missing URL would leave the file stuck as in-progress forever, + // so it gets reported as a failed upload instead. + this.dotnetReference.invokeMethodAsync("HandleChunkUpload", this.index, 0, 'The upload URL is not provided.'); + return; + } + + this.xhr.open(this.method, url, true); - if (!url) return; + this.xhr.withCredentials = this.withCredentials; - this.xhr.open('POST', url, true); + if (this.timeout > 0) { + // a timed out request aborts with the readyState 4 and the status 0, + // which the .NET side then reports as a failed upload. + this.xhr.timeout = this.timeout; + } Object.keys(this.headers).forEach(h => { this.xhr.setRequestHeader(h, this.headers[h]); @@ -174,5 +519,23 @@ pause(): void { this.xhr.abort(); } + + detach(): void { + this.xhr.upload.onprogress = null; + this.xhr.onreadystatechange = null; + this.xhr.abort(); + this.revokePreview(); + // the File is what the browser is really holding on to, so it goes last and it goes for good. + this.file = null; + } + + // an object URL keeps the whole file alive in memory until it is revoked, so a preview that is + // not on screen anymore has to give it back rather than waiting for the tab to be closed. + revokePreview(): void { + if (!this.previewUrl) return; + + URL.revokeObjectURL(this.previewUrl); + this.previewUrl = null; + } } -} \ No newline at end of file +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs new file mode 100644 index 0000000000..75ea345941 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs @@ -0,0 +1,109 @@ +namespace Bit.BlazorUI; + +public class BitFileUploadClassStyles +{ + /// + /// Custom CSS classes/styles for the root element of the BitFileUpload. + /// + public string? Root { get; set; } + + /// + /// Custom CSS classes/styles for the root element while files are being dragged over the BitFileUpload. + /// + public string? Dragging { get; set; } + + /// + /// Custom CSS classes/styles for the browse button (label) of the BitFileUpload. + /// + public string? Label { get; set; } + + /// + /// Custom CSS classes/styles for the description (hint) of the BitFileUpload. + /// + public string? Description { get; set; } + + /// + /// Custom CSS classes/styles for the file list container of the BitFileUpload. + /// + public string? FileList { get; set; } + + /// + /// Custom CSS classes/styles for each file item of the BitFileUpload. + /// + public string? FileItem { get; set; } + + /// + /// Custom CSS classes/styles for the image preview thumbnail of each file item of the BitFileUpload. + /// + public string? Preview { get; set; } + + /// + /// Custom CSS classes/styles for the file name of each file item of the BitFileUpload. + /// + public string? FileName { get; set; } + + /// + /// Custom CSS classes/styles for the file size of each file item of the BitFileUpload. + /// + public string? FileSize { get; set; } + + /// + /// Custom CSS classes/styles for the upload percent indicator of each file item of the BitFileUpload. + /// + public string? Percentage { get; set; } + + /// + /// Custom CSS classes/styles for the progress bar container of each file item of the BitFileUpload. + /// + public string? ProgressBarContainer { get; set; } + + /// + /// Custom CSS classes/styles for the progress bar of each file item of the BitFileUpload. + /// + public string? ProgressBar { get; set; } + + /// + /// Custom CSS classes/styles for the status message of each file item of the BitFileUpload. + /// + public string? StatusMessage { get; set; } + + /// + /// Custom CSS classes/styles for the upload button of each file item of the BitFileUpload. + /// + public string? UploadButton { get; set; } + + /// + /// Custom CSS classes/styles for the upload button icon of each file item of the BitFileUpload. + /// + public string? UploadIcon { get; set; } + + /// + /// Custom CSS classes/styles for the pause button of each file item of the BitFileUpload. + /// + public string? PauseButton { get; set; } + + /// + /// Custom CSS classes/styles for the pause button icon of each file item of the BitFileUpload. + /// + public string? PauseIcon { get; set; } + + /// + /// Custom CSS classes/styles for the cancel button of each file item of the BitFileUpload. + /// + public string? CancelButton { get; set; } + + /// + /// Custom CSS classes/styles for the cancel button icon of each file item of the BitFileUpload. + /// + public string? CancelIcon { get; set; } + + /// + /// Custom CSS classes/styles for the remove button of each file item of the BitFileUpload. + /// + public string? RemoveButton { get; set; } + + /// + /// Custom CSS classes/styles for the remove button icon of each file item of the BitFileUpload. + /// + public string? RemoveIcon { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs index eb7de51c30..f1547e457e 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; namespace Bit.BlazorUI; @@ -11,9 +11,22 @@ internal static ValueTask BitFileUploadSetup(this IJSRuntime jsRu ElementReference element, bool append, string? uploadAddress, - Dictionary? uploadRequestHttpHeaders) + Dictionary? uploadRequestHttpHeaders, + string? method, + bool withCredentials, + long timeout, + string? fieldName, + bool showPreview, + bool readImageDimensions) { - return jsRuntime.Invoke("BitBlazorUI.FileUpload.setup", id, dotnetObjectReference, element, append, uploadAddress, uploadRequestHttpHeaders); + return jsRuntime.Invoke("BitBlazorUI.FileUpload.setup", + id, dotnetObjectReference, element, append, uploadAddress, uploadRequestHttpHeaders, + method, withCredentials, timeout, fieldName, showPreview, readImageDimensions); + } + + internal static ValueTask BitFileUploadRelease(this IJSRuntime jsRuntime, string id, int index) + { + return jsRuntime.InvokeVoid("BitBlazorUI.FileUpload.release", id, index); } internal static ValueTask BitFileUploadUpload(this IJSRuntime jsRuntime, @@ -22,8 +35,16 @@ internal static ValueTask BitFileUploadUpload(this IJSRuntime jsRuntime, long to, int index, string? uploadUrl, - Dictionary? httpHeaders) + Dictionary? httpHeaders, + Dictionary? formFields) { + // the optional arguments are only left out when there is nothing to send, so that the + // defaults of the JavaScript side keep applying instead of an explicit null overriding them. + if (formFields is not null) + { + return jsRuntime.InvokeVoid("BitBlazorUI.FileUpload.upload", id, from, to, index, uploadUrl, httpHeaders ?? [], formFields); + } + return (httpHeaders is null ? jsRuntime.InvokeVoid("BitBlazorUI.FileUpload.upload", id, from, to, index, uploadUrl) : jsRuntime.InvokeVoid("BitBlazorUI.FileUpload.upload", id, from, to, index, uploadUrl, httpHeaders)); } @@ -35,9 +56,16 @@ internal static ValueTask BitFileUploadPause(this IJSRuntime jsRuntime, string i internal static ValueTask BitFileUploadSetupDragDrop(this IJSRuntime jsRuntime, ElementReference dragDropZoneElement, - ElementReference inputFileElement) + ElementReference inputFileElement, + string dragClass, + string? dragStyle, + bool allowDrop, + bool allowPaste, + bool expandDirectories) { - return jsRuntime.Invoke("BitBlazorUI.FileUpload.setupDragDrop", dragDropZoneElement, inputFileElement); + return jsRuntime.Invoke("BitBlazorUI.FileUpload.setupDragDrop", + dragDropZoneElement, inputFileElement, dragClass, dragStyle, + allowDrop, allowPaste, expandDirectories); } internal static ValueTask BitFileUploadBrowse(this IJSRuntime jsRuntime, ElementReference inputFileElement) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadStatus.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadStatus.cs index 88f935f5aa..e50055652b 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadStatus.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadStatus.cs @@ -3,7 +3,7 @@ public enum BitFileUploadStatus { /// - /// File uploading progress is pended because the server cannot be contacted. + /// The file is selected and queued, and its uploading has not started yet. /// Pending, diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor index 19548c82e0..2e2f611ee4 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor @@ -6,72 +6,138 @@ @if (Item.Status != BitFileUploadStatus.Removed) { -
+
+ @if (FileUpload.ShowPreview && Item.PreviewUrl is not null) + { + @* draggable is off so that dragging a thumbnail does not start a drag over the drop zone itself. + the alt is empty on purpose: the thumbnail is decorative and its name is already + right next to it, so announcing it again would just repeat the same text. *@ + + }
-
@Item.Name
+
@Item.Name
- - @($"{GetFileUploadSize(Item)}/{FileSizeHumanizer.Humanize(Item.Size)}") - - - @fileUploadPercent% + + @($"{FormatSize(GetFileUploadSize(Item))}/{FormatSize(Item.Size)}") + @* a file that was never going to be sent has no progress to report, and a "0%" next to + the reason it was turned away only reads as something that failed halfway. *@ + @if (Item.Status != BitFileUploadStatus.NotAllowed) + { + + @fileUploadPercent% + + }
@if (Item.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused) { -
+
+ aria-valuenow="@fileUploadPercent" + aria-valuetext="@($"{fileUploadPercent}%")">
} else { -
@GetUploadMessage(Item)
+
+ @FileUpload.GetStatusMessage(Item) +
}
- @if (Item.Status is BitFileUploadStatus.Pending or BitFileUploadStatus.Paused) + @* a file already waiting in the concurrency queue is on its way, so it offers no button to start + it a second time - pressing it would do nothing but suggest that nothing is happening. *@ + @if (Item.IsQueued is false && + Item.Status is BitFileUploadStatus.Pending or BitFileUploadStatus.Paused or BitFileUploadStatus.Failed or BitFileUploadStatus.Canceled) { - var uploadIcon = BitIconInfo.From(FileUpload.UploadIcon, FileUpload.UploadIconName ?? "Play"); -
-