DEV Community

Cover image for Print specific container using Angular
Kader Mohideen Fasid
Kader Mohideen Fasid

Posted on • Edited on

Print specific container using Angular

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:

  1. using <iframe>
  2. 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;
  }
}


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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!image

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

  1. portal - A Portal is a piece of UI that you want to render somewhere else outside of the angular context.

  2. portalHost - the "open slot" (outside of angular) where the template(portal) needs to get rendered. In our case, an iframe

🐴 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>


Enter fullscreen mode Exit fullscreen mode

we will need the following imports from @angular/cdk/portal



import {
  DomPortalOutlet,
  PortalOutlet,
  TemplatePortal
} from "@angular/cdk/portal";


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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;


Enter fullscreen mode Exit fullscreen mode

Our constructor should inject these Injectables besides other things.



private componentFactoryResolver: ComponentFactoryResolver,
private injector: Injector,
private appRef: ApplicationRef,
private viewContainerRef: ViewContainerRef


Enter fullscreen mode Exit fullscreen mode

Let's grab the reference to iframe element.



printMainContent(): void {
   const iframe = this.iframe.nativeElement;
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

🐴 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);


Enter fullscreen mode Exit fullscreen mode

Cool, we've reached the climax!



iframe.contentWindow.print()


Enter fullscreen mode Exit fullscreen mode

🎉 🎉

image

Psst.
image

Hmm, I see two problems.

  1. No Images (very obvious one!)
  2. 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);
  }


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

Alright, hit the PRINT PAGE

Good that we now have the images displayed in the print.

image

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


Enter fullscreen mode Exit fullscreen mode

call _attachStyles in printMainContent



this._attachStyles(iframe.contentWindow);


Enter fullscreen mode Exit fullscreen mode

and some cleaning the mess work!



...
iframe.contentWindow.onafterprint = () => {
   iframe.contentDocument.body.innerHTML = "";
};
...


Enter fullscreen mode Exit fullscreen mode


ngOnDestroy(): void {
  this.portalHost.detach();
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

Lastly, the styles to hide the iframe,



iframe {
  position: absolute;
  top: -10000px;
  left: -10000px;
}


@media print {
  .example-card {
    page-break-inside: avoid;
  }
}


Enter fullscreen mode Exit fullscreen mode

Hit the PRINT PAGE

image

Now, we're talking! 🏆

Well, if you are not a fan of iframes, (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');


Enter fullscreen mode Exit fullscreen mode

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 with noreferrer,noopener in window.open and communicate using BroadcastChannel instead of passing context 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.

References

Top comments (0)