Print specific container using Angular
Tags: angular, print, cdkportal, printmaincontent
Published: 2021-05-08 09:52:19

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

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,

```html
<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,

{% stackblitz angular-portal-print view=preview %}

Hit the `PRINT PAGE` button, you should see something like!![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/67ooei2vvgs357dahd61.png)
 

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`

> :horse: This [article](https://juristr.com/blog/2018/05/dynamic-UI-with-cdk-portals/) 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.

```html
<iframe #iframe></iframe>
```
we will need the following imports from `@angular/cdk/portal`

```ts
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`

```typescript
@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.)

```typescript
private portalHost: PortalOutlet;
```

Our `constructor` should inject these `Injectables` besides other things.
```typescript
private componentFactoryResolver: ComponentFactoryResolver,
private injector: Injector,
private appRef: ApplicationRef,
private viewContainerRef: ViewContainerRef
```

Let's grab the reference to `iframe` element.

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

ready up the portal host for rendering dynamic content by instantiating `DomPortalOutlet`

```ts
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.

```ts
const portal = new TemplatePortal(
  this.listHerosRef,
  this.viewContainerRef,
  {
    heros: this.heros
  }
);
```

> :horse: 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!!

```ts
 // Attach portal to host
this.portalHost.attach(portal);
```

Cool, we've reached the climax!

```ts
iframe.contentWindow.print()
```

:tada: :tada:

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v8jc1k44uvm9xkrvm1gz.png)

Psst.
![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s2srt55b2dd4fskc55w9.png)


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.


```ts
  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.

> :horse: 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`
```ts
this.waitForImageToLoad(iframe, () => iframe.contentWindow.print());
```

Alright, hit the `PRINT PAGE`

{% stackblitz angular-portal-print-without-styles view=preview %}


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

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b809hkzi6n40o2jbepcn.png)

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`

```ts
 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`

```ts
this._attachStyles(iframe.contentWindow);
```

and some cleaning the mess work!

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

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

Phew!, the complete `printMainContent`

```typescript

  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`

{% stackblitz angular-portal-print-with-styles view=preview %}

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1k0ak92lcv44ycn38rep.png)
 
 
Now, we're talking! :trophy:


#### 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
```ts
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 with `noreferrer,noopener` in `window.open` and communicate using `BroadcastChannel` instead of passing `context` obejects but, that's a whole different story. Stay tuned :wink:

### 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
- https://medium.com/@saranya.thangaraj/open-angular-component-in-a-new-tab-without-bootstrapping-the-whole-app-again-e329af460e92 (Thank you)