Printing on the web can become quite overwhelming. In this guide, we will dive deeper into different ways (that I found peace with) to print pretty much anything using Angular.
We will see two ways of performing a print:
- using
<iframe>
- new browser tab printing
For simple trivial web page printing could be tackled by dropping the following hide-print
class on elements that you wouldn't want to show up in a print,
<div class="hide-print">
...
...
</div>
@media print {
.hide-print {
display: none !important;
}
}
However, when things go non-trivial we can feel that this approach doesn't scale out quite well. Then, It's time to think about isolating the printable contents in a different context (Ex: browser tab, iframe, popups, etc...).
The Problem
Let's take a look at the following template,
<ng-template #listHeros let-heros="heros">
<ng-container *ngFor="let hero of heros">
<mat-card class="example-card" [style.background]="hero.color">
<mat-card-header>
<div mat-card-avatar class="example-header-image" [style.backgroundImage]="hero.avatar"></div>
<mat-card-title>{{hero.name}}</mat-card-title>
<mat-card-subtitle>{{hero.breed}}</mat-card-subtitle>
</mat-card-header>
<img mat-card-image [src]="hero.avatar" [alt]="hero.name" />
<mat-card-content>
<p>
{{hero.description}}
</p>
</mat-card-content>
<mat-card-actions>
<button mat-button>LIKE</button>
<button mat-button>SHARE</button>
</mat-card-actions>
</mat-card>
</ng-container>
</ng-template>
I am using
angular-material
for styles. This is for convenience. you can use any ui library of your choice
The above template does a simple thing. Loop through a list of heros
array. and display each item as cards.
In reality, it's common for an application to have a header, a footer, and a side nav along with the main content.
Let's try and print what we have in the following stackblitz,
Hit the PRINT PAGE
button, you should see something like!
We can see that the entire viewport is printed and the content is not scrollable. Ideally, we'd like to see only the main content(the list of cards) to be isolated for printing.
Here is the objective,
We need to be able to print a specific container(can be components,
<div>
,ng-template
) in the DOM. and just that!, No sidenavs, no footers, nothing!.
Angular Portals (a.k.a The Solution)
The portals package provides a flexible system for rendering dynamic content into an application.
The Angular CDK offers Portals
, a way to teleport a piece of UI that can be rendered dynamically anywhere on the page. This becomes very handy when we want to preserve the context of an element regardless of the place it gets rendered.
The idea is simple. We have the following two containers in the DOM
portal
- A Portal is a piece of UI that you want to render somewhere else outside of the angular context.portalHost
- the "open slot" (outside of angular) where the template(portal) needs to get rendered. In our case, aniframe
🐴 This article gives you a very nice overview of how Angular portals work, worth checking it out!
Let us create an iframe
(open slot) where the printable contents will be rendered.
<iframe #iframe></iframe>
we will need the following imports from @angular/cdk/portal
import {
DomPortalOutlet,
PortalOutlet,
TemplatePortal
} from "@angular/cdk/portal";
DomPortalOutlet extends PortalOutlet
A PortalOutlet for attaching portals to an arbitrary DOM element outside of the Angular application context.
TemplatePortal
A TemplatePortal
is a portal that represents some embedded template (TemplateRef).
Let us grab the reference to the printable content and the open slot using ViewChild
@ViewChild("listHeros") listHerosRef; // printable content.
@ViewChild("iframe") iframe; // target host to render the printable content
We will need to hold a PortalOutlet
reference. (this is important for safely disposing of the portal
after use in the destroy hook.)
private portalHost: PortalOutlet;
Our constructor
should inject these Injectables
besides other things.
private componentFactoryResolver: ComponentFactoryResolver,
private injector: Injector,
private appRef: ApplicationRef,
private viewContainerRef: ViewContainerRef
Let's grab the reference to iframe
element.
printMainContent(): void {
const iframe = this.iframe.nativeElement;
}
ready up the portal host for rendering dynamic content by instantiating DomPortalOutlet
this.portalHost = new DomPortalOutlet(
iframe.contentDocument.body,
this.componentFactoryResolver,
this.appRef,
this.injector
);
Now, that the host is ready, let's get the content ready to be loaded.
const portal = new TemplatePortal(
this.listHerosRef,
this.viewContainerRef,
{
heros: this.heros
}
);
🐴 Notice that we pass the context object as the last argument.
Alrighty, We have our host and the content ready. let's pair them up!!
// Attach portal to host
this.portalHost.attach(portal);
Cool, we've reached the climax!
iframe.contentWindow.print()
🎉 🎉
Hmm, I see two problems.
- No Images (very obvious one!)
- There are no styles in the print.
Let's fix the images. The problem is that, we called the iframe.contentWindow.print()
immediately after this.portalHost.attach(portal);
. We need to give some time for the portal to finish rendering in the portal host.
private waitForImageToLoad(iframe: HTMLIFrameElement, done: Function): void {
const interval = setInterval(() => {
const allImages = iframe.contentDocument.body.querySelectorAll(
"img.card-image"
);
const loaded = Array.from({ length: allImages.length }).fill(false);
allImages.forEach((img: HTMLImageElement, idx) => {
loaded[idx] = img.complete && img.naturalHeight !== 0;
});
if (loaded.every(c => c === true)) {
clearInterval(interval);
done();
}
}, 500);
}
The method above does one thing. it simply grabs all the images refs and checks if they(images) are loaded. every 500ms
. After they are loaded, it simply calls the done
.
The intention of the above function is to simulate a real-world use case. the function doesn't consider any edge-cases if you are curious.
🐴 The idea is to know if we have loaded successfully by verifying with the DOM elements. Here, I have taken images as example. please tailor it for your use-cases.
wrap the print call with the waitForImageToLoad
this.waitForImageToLoad(iframe, () => iframe.contentWindow.print());
Alright, hit the PRINT PAGE
Good that we now have the images displayed in the print.
time to address problem 2 we talked about, where are the styles?.
Let us understand why the styles are not visible, the printing happens in a different context (iframe), we are only rendering the elements using angular portals. this doesn't mean the styles are copied as well. so we need to explicitly copy the styles into the iframe
private _attachStyles(targetWindow: Window): void {
// Copy styles from parent window
document.querySelectorAll("style").forEach(htmlElement => {
targetWindow.document.head.appendChild(htmlElement.cloneNode(true));
});
// Copy stylesheet link from parent window
const styleSheetElement = this._getStyleSheetElement();
targetWindow.document.head.appendChild(styleSheetElement);
}
private _getStyleSheetElement() {
const styleSheetElement = document.createElement("link");
document.querySelectorAll("link").forEach(htmlElement => {
if (htmlElement.rel === "stylesheet") {
const absoluteUrl = new URL(htmlElement.href).href;
styleSheetElement.rel = "stylesheet";
styleSheetElement.type = "text/css";
styleSheetElement.href = absoluteUrl;
}
});
console.log(styleSheetElement.sheet);
return styleSheetElement;
}
call _attachStyles
in printMainContent
this._attachStyles(iframe.contentWindow);
and some cleaning the mess work!
...
iframe.contentWindow.onafterprint = () => {
iframe.contentDocument.body.innerHTML = "";
};
...
ngOnDestroy(): void {
this.portalHost.detach();
}
Phew!, the complete printMainContent
printMainContent(): void {
const iframe = this.iframe.nativeElement;
this.portalHost = new DomPortalOutlet(
iframe.contentDocument.body,
this.componentFactoryResolver,
this.appRef,
this.injector
);
const portal = new TemplatePortal(
this.listHerosRef,
this.viewContainerRef,
{
heros: this.heros
}
);
// Attach portal to host
this.portalHost.attach(portal);
iframe.contentWindow.onafterprint = () => {
iframe.contentDocument.body.innerHTML = "";
};
this.waitForImageToLoad(
iframe,
() => iframe.contentWindow.print()
);
}
Lastly, the styles to hide the iframe,
iframe {
position: absolute;
top: -10000px;
left: -10000px;
}
@media print {
.example-card {
page-break-inside: avoid;
}
}
Hit the PRINT PAGE
Now, we're talking! 🏆
Well, if you are not a fan of iframe
s, (optional)
Let's use a new browser tab instead of iframe
.
just replace the const iframe = this.iframe.nativeElement
to
const newWindow = window.open('', '_blank');
and change references from iframe
to newWindow
, that should do the trick.
Gotchas
- The above approach works perfectly fine when your data is not very big. If you are printing a huge amount of data. Like a really long table. Then, you might face performance issues, like rendering blocking the main thread for too long. This is because, both the
iframe
and the new window approach, still uses the same process as your original angular app. We can fix it withnoreferrer,noopener
inwindow.open
and communicate usingBroadcastChannel
instead of passingcontext
obejects but, that's a whole different story. Stay tuned 😉
About Author
Kader is a caring father, loving husband, and freelance javascript developer from India. Focused on Angular, WebAssembly, and all the fun stuff about programming.
Top comments (0)