I'm looking for a way to create a link that will create a screenshot of a Razor component and download it as a JPG, PNG or PDF through a Blazor Server application. Ideally, it will only contain the Razor component and all child components, but no parents, and the image will have the precise appearance of the current state displayed on the browser.
The only similar thing is capturing HTML canvases, but since I'm so new to Blazor, I'm not exactly sure how to apply that, and was wondering if there's a way of achieving something similar via C#. Any suggestions would be appreciated. Thanks :)
I don't think it will be easy to achieve that with C# alone, since the best approach to the problem is to convert the HTML data to canvas and export it as an image which is a hard process, but there are libraries available for that, in JS.
How to do it:
I'm gonna use this library since it seems to be the simplest: html2canvas
Here is the link to it's JS file: html2canvas.js
download it and place it in your wwwroot folder, then, include that in the _host.cshtml by adding this at the end of the body tag:
<script src="html2canvas.js"></script>
then add a JS function to call from C# so we can have the even handler written in C# by adding this after the previous code in _host.cshtml:
<script>
window.takeScreenshot = async function(id) {
var img = "";
await html2canvas(document.querySelector("#" + id)).then(canvas => img = canvas.toDataURL("image/png"));
var d = document.createElement("a");
d.href = img;
d.download = "image.png";
d.click();
return img;
}
</script>
This will automatically take a screenshot from the element and download it, also return its URL. Note that the component must be inside a div tag with an id, otherwise, you can't select it alone, example good child in parent:
Parent.razor
<div id="child"></div>
<Child />
</div>
To use this function, use the JsInterop class. Simply, inject (basically include) it in your razor component where you need this functionality by adding this at the top of the file:
#inject IJSRuntime JS
next, a function to do everything:
#inject IJSRuntime JS
#code {
public async System.Threading.Tasks.Task<string> TakeImage(string id)
{
return await JS.InvokeAsync<string>("takeScreenshot", id);
}
}
This function will return a data URL of an image taken from an element specified by the id parameter.
Sample usage with a button to take image:
#page "/screenshot"
#inject IJSRuntime JS
#code {
string image_url = "";
string child_id = "child";
public async System.Threading.Tasks.Task<string> TakeImage(string id)
{
return await JS.InvokeAsync<string>("takeScreenshot", id);
}
public async System.Threading.Tasks.Task ButtonHandler()
{
image_url = await TakeImage(child_id);
}
}
<button #onclick="ButtonHandler">Take image</button>
<h3>Actual component:</h3>
<div id=#child_id>
<ChildComponent />
</div>
<h3>Image:</h3>
<img src=#image_url />
<br />
<br />
<br />
<p>URL: #image_url</p>
Pressing the button will download the image, show it, show the raw URL, and save the URL to variable image_url.
You can shorten System.Threading.Tasks.Task to Task by adding #using System.Threading.Tasks to _Imports.razor file.
You can remove auto-download functionality by removing these 4 lines in the JS function:
var d = document.createElement("a");
d.href = img;
d.download = "image.png";
d.click();
If you want to take an image of the entire page automatically and download it without any user interaction:
modify the JS function and set the query selector to body and remove the id parameter:
<script>
window.takeScreenshot = async function() {
var img = "";
await html2canvas(document.querySelector("body")).then(canvas => img = canvas.toDataURL("image/png"));
var d = document.createElement("a");
d.href = img;
d.download = "image.png";
d.click();
return img;
}
</script
set the function to run when the document loads:
#inject IJSRuntime JS
#page "/component"
#code {
string image_url = "";
public async System.Threading.Tasks.Task<string> TakeImage()
{
return await JS.InvokeAsync<string>("takeScreenshot");
}
protected override async System.Threading.Tasks.Task OnAfterRenderAsync(bool firstRender)
{
if(firstRender) //Ensure this is the page load, not any page rerender
{
image_url = await TakeImage();
}
}
}
Special URL to automatically download image:
To generate a URL, which when loaded will download the image as the previous part, but only when that special URL is loaded, not the normal page URL.
To do this, I'm gonna use query strings, so the special URL will be like this: http://localhost:5001/page?img=true
For that, we need to get the URI, using NavigationManager, which can be injected like the IJSRuntime. For parsing the URI, we can use QueryHelpers class.
The final code will look like this:
#inject IJSRuntime JS
#inject NavigationManager Nav
#page "/component"
#code {
string image_url = "";
public async System.Threading.Tasks.Task<string> TakeImage()
{
return await JS.InvokeAsync<string>("takeScreenshot");
}
protected override async System.Threading.Tasks.Task OnAfterRenderAsync(bool firstRender)
{
if(firstRender) //Ensure this is the page load, not any page rerender
{
var uri = Nav.ToAbsoluteUri(Nav.Uri);
if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("img", out var isImg))
{
if (System.Convert.ToBoolean(isImg.ToString()))
{
image_url = await TakeImage();
}
}
}
}
}
Now you can add ?img=true to the component's URL and you will get a screenshot of it.
Note that if the body/parent of the div has a background, and you want it to be in the image, you need to add background: inherit; to the CSS rules of the div containing the child component.
Related
I have a Razor app where Lightbox2 was previously used for image galleries in the app. Now, I want to make a change to use PhotoSwipe instead. I started off by following the documentation on the official site: https://photoswipe.com/getting-started/. My issue is that when I click on any thumbnail, the full image is opened in a new tab instead of opening the image in the gallery component of PhotoSwipe. The documentation seemed like a pretty straightforward setup, but in this case I am not sure what else I am missing. Thanks in advance for all your help.
PhotoSwipe initialization (init-photoswipe.js):
import PhotoSwipeLightbox from '../photoswipe-dist/photoswipe-lightbox.esm.js';
const lightbox = new PhotoSwipeLightbox({
gallery: '#gallery--custom-html-markup',
children: 'a',
pswpModule: () => import('../photoswipe-dist/photoswipe.esm.js')
});
lightbox.init();
Code snippet of Razor component:
<ContentTemplate>
#if (IsLoading)
{
<PlaceholderLoading />
}
else
{
<div class="card-body pb-1">
<h5 class="card-title pr-2">
#(Observation.Question ?? Observation.Name)
</h5>
</div>
#if (ThumbnailUrl is PhotoNotTaken or ImageCannotBeDisplayed)
{
<img class="card-img-bottom" src="#ThumbnailUrl" alt="No photo available" />
}
else
{
<div class="pswp-gallery" id="gallery--custom-html-markup">
<a href="#PhotoUrl" data-pswp-width="1669" data-pswp-height="2500" target="_blank">
<img class="card-img-bottom" src="#ThumbnailUrl" alt="#(Observation.Question ?? Observation.Name)" />
</a>
</div>
}
}
</ContentTemplate>
Snippet of _Host.cshtml file:
<!-- PhotoSwipe CSS -->
<link rel="stylesheet" href="~/_content/ProjectComponents/js/photoswipe-dist/photoswipe.css">
<!-- PhotoSwipe initialization -->
<script src="~/_content/ProjectComponents/js/custom/init-photoswipe.js" type="module"></script>
Note: The _Host.cshtml file is in a separate project for the actual website. The PhotoSwipe initialization and Razor component code are in another project for Shared Components. Both projects are part of the same solution, so not sure if this will cause some issues with PhotoSwipe.
I run into the same issue; it appears to be the dynamic content isn't picked up when lightbox.init() first initialized; to get that worked around we need to
assign the lightbox initialization function to a global object something like following (note the window.MyJS object):
import PhotoSwipeLightbox from '/libs/photoswipe/photoswipe-lightbox.esm.min.js';
const lightbox = new PhotoSwipeLightbox({
gallery: '#gallery--custom-html-markup',
children: 'a',
pswpModule: () => import('/libs/photoswipe/photoswipe.esm.min.js')
});
window.MyJS = {
initializeGallery: function () {
lightbox.init();
}
}
Later inside of the gallery component we should do the following:
#inject IJSRuntime JS
protected async override Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await IJS.InvokeVoidAsync("MyJS.initializeGallery");
}
}
Hope that helps
I have a problem right now and I hope you can help me.
My problem is that I have a Controller in which I generate a file as byteArray (without saving it on hard disk), which I return to download.
These action takes up to 5-6 seconds until its generated. Now I would like to display a busy indicator for the user, but I don't know, how can I identify, when my controller is done!?
Here is my code:
This is the code to call my Controller action in my "View.cshtml":
<span class="fa fa-bar-chart"></span>
This my Controller code:
public ActionResult DownloadAnalysis()
{
var response = StartAnalysis();
if (response.AnalysisByteArray != null && response.AnalysisByteArray.Length > 0)
{
var cd = new System.Net.Mime.ContentDisposition
{
FileName = response.FileName,
Inline = false,
};
Response.AppendHeader("Content-Disposition", cd.ToString());
return File(response.AnalysisByteArray , "text/csv");
}
return null;
}
I thank you in advance for your help!
Best Regards
Instead of the anchor tag use a button and onclick event to call the controller.
your html should be like
<button title="Get analysis file" onclick="getFile()"><span class="fa fa-bar-chart"></span></button>
and in JavaScript use window.location to call the controller action
<script>
function getFile(){
//here you can add script to change cursor to progress of add some html element to show loading.
window.location.href = '/Analysis/DownloadAnalysis/';
}
</script>
=============== UPDATE ===============
Apologies, but with all the chopping and changing, commenting out and back in again, to try and get this working, I had accidentally stopped the form calling the script in Create.cshtml. The form of the View file should actually read as follows:
<form asp-controller="Documents" asp-action="Create" method="post" enctype="multipart/form-data" onSubmit="AJAXSubmit(this); return false;">
I haven't closed the question, because the script in Create.cshtml is receiving a 400 (Bad Request). This appears to be the real problem - but, I cannot understand why it's happening.
If and when I get to the bottom of it, I will update the question. Hopefully, it will be a useful answer to somebody.
=============== END UPDATE ===============
I've been following https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-3.1#upload-large-files-with-streaming to upload large files to a web app. Unfortunately, when I come to use the MultipartReader (reader.ReadNextSectionAsync), I get the error unexpected end of stream, the content may have already been read by another component.
I've downloaded the sample app (https://github.com/dotnet/AspNetCore.Docs/tree/master/aspnetcore/mvc/models/file-uploads/samples/3.x/SampleApp) and that works fine, but they're using Razor pages and my project isn't.
One notable difference: when running the demo app from github, when I click Upload, to upload the large file, the Controller method is called immediately. However, when I click to upload the document on my app, the browser shows me the file being uploaded, then the Controller method is called.
I'm fairly new to ASP.NET, and I've been banging my head against a brick wall for a couple of days, so any help would be much appreciated.
My relevant code snippets are as follows:
GenerateAntiforgeryTokenCookieAttribute: adds antiforgery token to cookie, copied exactly from the example docs
DisableFormValueModelBindingAttribute: attribute class to prevent form binding, copied exactly from the example docs
MultipartRequestHelper: copied exactly from the example docs
startup.cs: this is maybe where my problem is, as my project is not using Razor pages, and is using Feature folders, so I've deviated from the example. Plus, my project is using Feature folders.
public void ConfigureServices(IServiceCollection services)
{
...
services.AddMvc(o => o.Conventions.Add(new FeatureConvention()))
// first part is for feature folders
.AddRazorOptions(options =>
{
options.ViewLocationFormats.Clear();
options.ViewLocationFormats.Add("/Features/{3}/{1}/{0}.cshtml");
options.ViewLocationFormats.Add("/Features/{3}/{0}.cshtml");
options.ViewLocationFormats.Add("/Features/Shared/{0}.cshtml");
options.ViewLocationExpanders.Add(new FeatureViewLocationExpander());
})
// FeatureConvention and FeatureViewLocationExpander taken from https://learn.microsoft.com/en-us/archive/msdn-magazine/2016/september/asp-net-core-feature-slices-for-asp-net-core-mvc#feature-folders-in-aspnet-core-mvc
// next part is to prevent form value binding and to add the anti forgery token to the cookie
.AddRazorPagesOptions(options =>
{
options.Conventions.AddPageApplicationModelConvention(
"/Documents/Create",
model =>
{
model.Filters.Add(new GenerateAntiForgeryTokenCookieAttribute());
model.Filters.Add(new DisableFormValueModelBindingAttribute());
}
);
});
...
}
My folder structure is as follows:
root
|
`-- Features
|
`-- Documents
|
`-- Create.cshtml
`-- DocumentsController.cs
Create.cshtml
#model DocumentCreateViewModel
<form asp-controller="Documents" asp-action="Create" method="post" enctype="multipart/form-data">
<div>
<label asp-for="filename"></label>
<input asp-for="filename""><input/>
</div>
<div>
<label asp-for="file"></label>
<input asp-for="file"><input/>
</div>
<div>
<button type="submit">submit</button>
</div>
</form>
#section Scripts {
<script>
"use strict";
async function AJAXSubmit (oFormElement) {
const formData = new FormData(oFormElement);
try {
const response = await fetch(oFormElement.action, {
method: 'POST',
headers: {
'RequestVerificationToken': getCookie('RequestVerificationToken')
},
body: formData
});
oFormElement.elements.namedItem("result").value =
'Result: ' + response.status + ' ' + response.statusText;
} catch (error) {
console.error('Error:', error);
}
}
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length == 2) return parts.pop().split(";").shift();
}
</script>
}
DocumentCreateViewModel.cs
public class DocumentCreateViewModel
{
[Required]
public string Name { get; set; }
[Required]
public IFormFile File { get; set; }
}
DocumentsController.cs: is the contents of UploadPhysical, copied exactly from the example docs, UploadPhysical is renamed to Create
[HttpPost]
[DisableFormValueModelBinding]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create()
{
if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
{
ModelState.AddModelError("File",
$"The request couldn't be processed (Error 1).");
// Log error
return BadRequest(ModelState);
}
var boundary = MultipartRequestHelper.GetBoundary(
MediaTypeHeaderValue.Parse(Request.ContentType),
_defaultFormOptions.MultipartBoundaryLengthLimit);
var reader = new MultipartReader(boundary, HttpContext.Request.Body);
var section = await reader.ReadNextSectionAsync();
// ^^^^ this is where the "unexpected end of stream" exception is thrown
Firstly, thank you to #Brando Zhang for getting me back on track.
The problem, the 400 (Bad Request), was due to DocumentsController attempting to verify an anti-forgery token that didn't exist - which was down to me unsuccessfully porting the Razor example into MVC.
In the example code, in startup.c, the following section is only applicable to Razor pages - not MVC:
.AddRazorPagesOptions(options =>
{
options.Conventions.AddPageApplicationModelConvention(
"/Documents/Create",
model =>
{
model.Filters.Add(new GenerateAntiForgeryTokenCookieAttribute());
model.Filters.Add(new DisableFormValueModelBindingAttribute());
}
);
});
Therefore, that could be removed. However, now there is not anti-forgery token being generated, so when a POST is made to Create() in DocumentsController, no token is present, so an HTTP 400 is returned.
The solution is a simple one (although it took me long enough to find it!): add [GenerateAntiForgeryTokenCookie] to the GET method that presents the form in DocumentsController:
[HttpGet]
[GenerateAntiForgeryTokenCookie]
public IActionResult Index()
{
return View("Create");
}
[HttpPost]
[DisableFormValueModelBinding]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create()
{
if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
...
}
Hope it helps!
I am trying to have a HTML file in my assets folder that is nothing but some header tags, dates and feature lists to serve as release notes for our website. I have an angular modal component that I want to read this file each time its route is called, rather than the alternative of having the HTML in the component itself which would require us to redeploy anytime we updated the release notes.
As mentioned I originally had this as part of my components HTML file but this was then being compiled into javascript each time and unable to be updated without a redeploy. Everything I have tried to search for doing something similar seems to be pointing me to just doing it that way.
ReleaseNotes.html
<!DOCTYPE html>
<html lang='en' xmlns='http://www.w3.org/1999/xhtml'>
<body>
<h1>Example header one</h1>
<h3>03/01/2019</h3>
<h4>Patch 1.03 Title</h4>
<ul>
<li>Feature that was added</li>
<li>Feature that was added</li>
<li>Feature that was added</li>
</ul>
<hr>
release-notes-modal.component.ts
export class ReleaseNotesModalComponent implements OnInit {
faTimesCircle = faTimesCircle;
contents: string;
constructor(public dialogRef: MatDialogRef<ReleaseNotesModalComponent>) {
//this.contents = System.IO.File.ReadAllText("ReleaseNotes.html");
}
ngOnInit() {
}
close() {
this.dialogRef.close();
}
}
There are a few ways you can accomplish this. This is how I've done this in the past.
In a Controller in the c# application, you would read the html file and return it:
[HttpGet]
[Route("releasenotes")]
public async Task<IActionResult> ReadReleaseNotes()
{
var viewPath = Path.GetFullPath(Path.Combine(HostingEnvironment.WebRootPath, $#"..\Views\Home\releasenotes.html"));
var viewContents = await System.IO.File.ReadAllTextAsync(viewPath).ConfigureAwait(false);
return Content(viewContents, "text/html");
}
Then in the angular application in a service you would call this method and retrieve this file as follows:
getReleaseNotes(): Observable<string> {
return this.http
.get([INSERT_BASE_URL_HERE] + '/releasenotes', { responseType: 'text' });
}
You can then utilize that in the ReleaseNotesModalComponent through something like this:
#Component({
template: '<span [innerHTML]="contents"></span>'
})
export class ReleaseNotesModalComponent implements OnInit {
faTimesCircle = faTimesCircle;
contents: string;
constructor(public dialogRef: MatDialogRef<ReleaseNotesModalComponent>, private service: ReleaseNotesService) {
service.getReleaseNotes(html => this.contents = html);
}
ngOnInit() {
}
close() {
this.dialogRef.close();
}
}
For the Angular side of things, I created a StackBlitz example.
In my application I have a folder structure where you can upload all kinds of files (jpg, png, pdf, txt etc.) You can download them directly as a file or you can view the file in the browser.
To display the file in the browser, I use the folowing controller action:
public virtual ActionResult FileDisplay(int fileId, bool isFile)
{
var viewmodel = _presenter.GetDocumentDisplayViewModel(fileId, isFile);
return base.File(viewmodel.Data, viewmodel.MediaType);
}
The file is displayed in the browser like this:
https://i.stack.imgur.com/TXmTy.jpg
The browser tab shows my controller name "FileDisplay" and (for this example) the dimensions of the image.
Question:
How can I display the filename in the brower tab title in stead of the controller name? This for all file types.
1. Content-Dispostion header
I have found several posts where they say to add the Content-Disposition header to the response with the filename:
Returning a file to View/Download in ASP.NET MVC
Make a file open in browser instead of downloading it
But this doesn't work.
2. Add filename to the File constructor
return base.File(viewmodel.Data, viewmodel.MediaType, viewmodel.FileName);
If I do this, the file is downloaded in stead of displayed in the browser.
3. PDF file title
I have found out that sometimes the browser tab title is correct! If I display a PDF file in the browser that has a file title (title in pdf properties, not the file name) the browser title is correct:
https://i.stack.imgur.com/orAZS.jpg
Can someone help me with this?
Workaround for now:
I have created a new action and view with a fullscreen iframe so I can set the title of this container view. The IFrame source is calling my File directly.
public virtual ActionResult FileDisplay(string fileName, int fileId, bool isFile)
{
var viewModel = new IFrameDocumentDisplayViewModel
{
FileName = fileName,
FileId = fileId,
IsFile = isFile
};
return PartialView("IFrameFileDisplayView", viewModel);
}
public virtual ActionResult GetFile(int fileId, bool isFile)
{
var viewmodel = _presenter.GetDocumentDisplayViewModel(fileId, isFile);
return base.File(viewmodel.Data, viewmodel.MediaType);
}
#model OnView.Models.ViewModels.Document.IFrameDocumentDisplayViewModel
#{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>#Model.FileName</title>
</head>
<body>
<iframe style="border: 0; position:absolute; top:0; left:0; right:0; bottom:0; width:100%; height:100%" src="#Html.Raw(Url.Action("GetFile", "Document", new { fileId = Model.FileId, isFile = Model.IsFile}))"></iframe>
</body>
</html>
So for now I can set the page title. But I am still curious how I can accomplish it without IFrame.
What the browser display as the tab title is the last part of the URL (the browser has no idea what is your controller name), so you could reformat your url like this :
localhost/OnView.Web/Document/FileDisplay/1685/True/MyTabName
Then the tab title would be MyTabName.
But changing a whole URL schema for such purpose also seems like a workaround...
Sidenote : I have the same problem, but my url is like this : FileDisplay/3?isFile=true and my tab title is 3 (in chrome. For IE, the tab title is the domain).
Try This:-
public virtual ActionResult FileDisplay(int fileId, bool isFile)
{
var viewmodel = _presenter.GetDocumentDisplayViewModel(fileId, isFile);
ViewBag.Title= viewmodel.FileName;
return base.File(viewmodel.Data, viewmodel.MediaType);
}