2

I have a hosted Blazor WebAssembly application.

I need a strategy or a sample on how can I copy values from an excel spreadsheet and paste them into the application with a final goal to add them into my database through the existing API.

So the question here is this: what components should I paste the values into, and how should I handle the whole process:

excel > clipboard > Component > save in db

H H
  • 263,252
  • 30
  • 330
  • 514
S.Michaelides
  • 87
  • 1
  • 9
  • Is there a problem with Excel? – Solar Mike Jan 27 '21 at 09:15
  • No problem I am just asking for a strategy to follow in order to complete the task – S.Michaelides Jan 27 '21 at 09:37
  • 1
    You would need to use JsInterop to read "structure" data out of the clipboard. The API is documented [here](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/) and the read methods [here](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard). This interface offers an event "paste". A path could look like: Writing javascript as a bridge between the "paste" event and an C# EventHandler/Action. Call this bridge at the ```OnInitializedAsync()``` and register your handler. Inside the handler make sense of the data and display it. Call ```StateHasChanged``` to refresh the UI. – Just the benno Jan 27 '21 at 10:02
  • @Justthebenno Thank you I will try the approach you suggested. – S.Michaelides Jan 27 '21 at 10:06
  • No problem. It's a very brief description because I do not have much time today. Maybe, I find the time tomorrow to post a solution. However, if some parts are unclear, feel free to ask. – Just the benno Jan 27 '21 at 11:58

1 Answers1

11

It was actually more difficult than I initially thought. I've created a repo. The result is this.

copy data from excel to blazor

You can select any elements in Excel, copy them, focus the content of your Blazor page and paste it. As a simple view, it is displayed in a table.

Let's go through the solution.

Index.razor

@page "/"
<div class="form-group">
    <label for="parser">Parser type</label>
    <select class="form-control" id="parser" @bind="_parserType">
        <option value="text">Text</option>
        <option value="html">HTML</option>
    </select>
</div>

<PasteAwareComponent OnContentPasted="FillTable">
    @if (_excelContent.Any() == false)
    {
        <p>No Content</p>
    }
    else
    {
        <table class="table table-striped">
            @foreach (var row in _excelContent)
            {
                <tr>
                    @foreach (var cell in row)
                    {
                        <td>@cell</td>
                    }
                </tr>
            }
        </table>
    }
</PasteAwareComponent>

<button type="button" class="btn btn-primary" @onclick="@( () => _excelContent = new List<String[]>() )">Clear</button>

@code
{
    private IList<String[]> _excelContent = new List<String[]>();

    ...more content, explained later...
}

If you copy a selection from Excel into the clipboard, not a single text is copied, but multiple representations of the same content. In my experiment, it has been three different types.

not a single element but multiple representations are copied from excel

I've built two different parser: ExcelHtmlContentParser and ExcelTextContentParser. Regarding the many different possibilities of what a cell content in Excel can be, my implementation is merely completed and should be seen as an inspiration. To see both parsers in action, you can choose between them by changing the value in the select box.

The PasteAwareComponent handles the interaction with Javascript. You can place any content inside this component. If this component (or any child) has focus, the paste event will be handled correctly.

<span @ref="_reference">
    @ChildContent
</span>

@code {
    private ElementReference _reference;

    [Parameter]
    public RenderFragment ChildContent { get; set; }

    [Parameter]
    public EventCallback<IEnumerable<IDictionary<String, String>>> OnContentPasted { get; set; }

    [JSInvokable("Pasted")]
    public async void raisePasteEvent(IEnumerable<IDictionary<String, String>> items)
    {
        await OnContentPasted.InvokeAsync(items);
    }

}

The component handles the interoperation with javascript. As soon the paste events happen the EventCallback<IEnumerable<IDictionary<String, String>>> OnContentPasted is fired.

Potentially, there could be more than one element inside the clipboard. Hence, we need to handle a collection IEnumerable<>. As seen in the picture before, the same clipboard item can have multiple representations. Each representation has a mime-type like "text/plain" or "text/html" and the value. This is represented by the IDictionary<String, String> where the key is the mime-type, and the value is the content.

Before going into the details about the javascript interop, we go back to the Index component.

<PasteAwareComponent OnContentPasted="FillTable">
...
</PasteAwareComponent>

@code {
private async Task FillTable(IEnumerable<IDictionary<String, String>> content)
    {
        if (content == null || content.Count() != 1)
        {
            return;
        }

        var clipboardContent = content.ElementAt(0);
        IExcelContentParser parser = null;
        switch (_parserType)
        {
            case "text":
                parser = new ExcelTextContentParser();
                break;
            case "html":
                parser = new ExcelHtmlContentParser();
                break;
            default:
                break;
        }

        foreach (var item in clipboardContent)
        {
            if (parser.CanParse(item.Key) == false)
            {
                continue;
            }

            _excelContent = await parser.GetRows(item.Value);
        }
    }
}

The index component uses this event callback in the method FillTable. The method checks if there is one element in the clipboard. Based on the selection, the parser is chosen. Each representation is checked in the next step if the chosen parser can parse it, based on the provided mime-type. If the right parser is found, the parser does its magic, and the content of the field _excelContent is updated. Because it is an EventCallback StateHasChanged is called internally, and the view is updated.

The text parser In the text representation, Excel uses \r\n as the end of the row and a \t for each cell, even the empty ones. The parser logic is quite simple.

public class ExcelTextContentParser : IExcelContentParser
{
    public String ValidMimeType { get; } = "text/plain";

    public Task<IList<String[]>> GetRows(String input) =>
        Task.FromResult<IList<String[]>>(input.Split("\r\n", StringSplitOptions.RemoveEmptyEntries).Select(x =>
            x.Split("\t").Select(y => y ?? String.Empty).ToArray()
        ).ToList());
}

I haven't tested how this behavior changes if the content is more complex. I guess that the HTML representation is more stable. Hence, the second parser.

The HTML parser

The HTML representation is a table. With <tr> and <td>. I've used the library AngleSharp as HTML parser.

public class ExcelHtmlContentParser : IExcelContentParser
{
    public String ValidMimeType { get; } = "text/html";

    public async Task<IList<String[]>> GetRows(String input)
    {
        var context = BrowsingContext.New(Configuration.Default);
        var document = await context.OpenAsync(reg => reg.Content(input));

        var element = document.QuerySelector<IHtmlTableElement>("table");
        var result = element.Rows.Select(x => x.Cells.Select(y => y.TextContent).ToArray()).ToList();
        return result;
    }
}

We are loading the clipboard content as an HTML document, getting the table and iterating over all rows, and selected each column.

** The js interop ***

@inject IJSRuntime runtime
@implements IDisposable

<span @ref="_reference">
    @ChildContent
</span>

@code {

    private ElementReference _reference;

    private DotNetObjectReference<PasteAwareComponent> _objectReference;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        await base.OnAfterRenderAsync(firstRender);
        if (firstRender == true)
        {
            _objectReference = DotNetObjectReference.Create(this);
            await runtime.InvokeVoidAsync("BlazorClipboadInterop.ListeningForPasteEvents", new Object[] { _reference, _objectReference });
        }
    }

    public void Dispose()
    {
        GC.SuppressFinalize(this);
        if (_objectReference != null)
        {
            _objectReference.Dispose();
        }
    }
}

The PasteAwareComponent component overrides the OnAfterRenderAsync life cycle, to invoke a js interop method. It has to be the OnAfterRenderAsync because before, the HTML reference wouldn't exist, and we need the reference to add the paste event listener. When the paste event occurred the javascript has to call this object, so we need to create a DotNetObjectReference instance. We implemented the IDisposable interface and disposing the reference correctly to prevent memory leaks.

The last part is the javascript part itself. I've created a file called clipboard-interop.js and placed it inside the wwwroot/js folder.

var BlazorClipboadInterop = BlazorClipboadInterop || {};

BlazorClipboadInterop.ListeningForPasteEvents = function (element, dotNetObject) {
    element.addEventListener('paste', function (e) { BlazorClipboadInterop.pasteEvent(e, dotNetObject) });
};

We use the HTML reference to register an event listener for the 'paste' event. In the handling method, we create the object that is passed to the C# method.

BlazorClipboadInterop.pasteEvent =
    async function (e, dotNetObject) {

        var data = await navigator.clipboard.read();
        var items = []; //is passed to C#

        for (let i = 0; i < data.length; i++) {
            var item = {};
            items.push(item);
            for (let j = 0; j < data[i].types.length; j++) {

                const type = data[i].types[j];

                const blob = await data[i].getType(type);
                if (blob) {

                    if (type.startsWith("text") == true) {
                        const content = await blob.text();
                        item[type] = content;
                    }
                    else {
                        item[type] = await BlazorClipboadInterop.toBase64(blob);
                    }
                }
            }
        }

        dotNetObject.invokeMethodAsync('Pasted', items);
        e.preventDefault();
    }

When we are using js interop, we should use objects that are easy to serialize. In the case of a real blob, like an image, it would be based64-encoded string, otherwise just the content.

The solution used the navigator.clipboard capabilities. The user needs to allow it. Hence we see the dialog.

request to access the clipboard

Just the benno
  • 2,306
  • 8
  • 12
  • Thank you so much for taking the time to post a solution for this much appreciate it. – S.Michaelides Feb 01 '21 at 10:29
  • 1
    It was my pleasure—an exciting task. I've learned a lot. If I found the required time, I want to create a nuget package based on this work. Thanks to you. :) – Just the benno Feb 01 '21 at 16:05
  • Hello friend, one more question if you have any idea that may help: https://stackoverflow.com/questions/66063159/random-blazor-failed-to-find-a-valid-digest-in-the-integrity-attribute-for-re (Sorry for commenting here i had no other way to contact you :) ) – S.Michaelides Feb 06 '21 at 06:15
  • Hi @S.Michaelides. I've seen your issue before, but currently, I have no idea where to start - yet. I want to try somethings but haven't had the time today. I'll keep in touch. :) – Just the benno Feb 06 '21 at 11:56