Codementor Events

Wrapping CommonJS library in Angular 8 directive on the example of mark.js

Published Oct 15, 2019Last updated Apr 11, 2020
Wrapping CommonJS library in Angular 8 directive on the example of mark.js

And enhancing its functionality with custom logic.

Prerequisites: you should be familiar with the Angular framework and Angular CLI.

Introduction

Time to time on my daily tasks I have to implement some functionality that was already implemented by someone previously in a neat vanillaJS library, but… no Angular version or even ES6 module of it is available to be able to easily grab it into your Angular 8 application.

Yes, you can attach this lib in index.html with <script> tag but from my point of view, it hardens maintainability. Also, you should do the same for another Angular project where you might use it.

Much better for is create Angular wrapper directive (or component) and publish it as npm package so everyone (and you of course:) can easily re-use it in another project.

One of such libraries is mark.js — quite solid solution for highlighting search text inside a specified webpage section.

mark.jsmark.js

How mark.js works

In original implementation mark.js can be connected to a project in two ways:

$ npm install mark.js --save-dev

// in JS code
const Mark = require('mark.js');
let instance = new Mark(document.querySelector("div.context"));
instance.mark(keyword [, options]);

*OR*

<script src="vendor/mark.js/dist/mark.min.js"></script>

// in JS code
let instance = new Mark(document.querySelector("div.context"));
instance.mark(keyword [, options]);

And the result looks like this:

mark.js run result (taken from [official Mark.js page](https://markjs.io/configurator.html))mark.js run result (taken from official Mark.js page)

You can play with it more on mark.js configurator page.

But can we use it in Angular way? Say, like this

// some Angular module
imports: [
...
MarkjsModule // imports markjsHighlightDirective
...
]

// in some component template
<div class="content_wrapper" 
     [markjsHighlight]="searchValue"
     [markjsConfig]="config"
>

Let's also implement some additional functionality. Say, scroll content_wrapper to first highlighted word:

<div class="content_wrapper" 
     [markjsHighlight]="searchText"
     [markjsConfig]="config"
     [scrollToFirstMarked]="true"
>

Now let's implement and publish Angular library with a demo application that will contain markjsHighlightDirective and its module.
We will name it ngx-markjs.

Planning Angular project structure

To generate an Angular project for our lib we will use Angular CLI.

npm install -g @angular/cli

Now let's create our project and add ngx-markjs lib to it:

ng new ngx-markjs-demo --routing=false --style=scss
// a lot of installations goes here

cd ngx-markjs-demo

ng generate lib ngx-markjs

And now lets add markjsHighlightDirective starter to our ngx-markjs lib

ng generate directive markjsHighlight --project=ngx-markjs

After deleting ngx-markjs.component.ts and ngx-markjs.service.ts in projects/ngx-markjs/src/lib/ folder which were created automatically by Angular CLI we will get next directory structure for our project:

ngx-markjs-demo project with ngx-markjs libngx-markjs-demo project with ngx-markjs lib

To conveniently build our library lets add two more lines in a project package.json file to scripts section:

"scripts": {
  "ng": "ng",
  "start": "ng serve --port 4201",
  "build": "ng build",
  "build:ngx-markjs": "ng build ngx-markjs && npm run copy:lib:dist", // <---
  "copy:lib:dist": "cp -r ./projects/ngx-markjs/src ./dist/ngx-markjs/src",
  "test": "ng test",
  "lint": "ng lint",
  "e2e": "ng e2e"
},

build:ngx-markjs — runs build for ngx-markjs library (but not for parent demo project)

copy:lib:dist — it is convenient to have source files in npm packages as well, so this command will copy library sources to /dist/ngx-markjs folder (where compiled module will be placed after build:ngx-markjs command).

Now time to add implementation code!

Remark: official Angular documentation about creating libraries recommends generating starter without main parent project, like this:
ng new my-workspace — create-application=false
But I decided to keep the main project and make it a demo application just for my convenience.

Connecting commonJS lib into Angular app

We need to do a few preparational steps before we start implementing our directive:

#1. Load mark.js

Mark.js library which we wan to wrap is provided in CommonJS format.

There are two ways to connect script in CommonJS script:

a) Add it with <script> tag to index.html:

<script src="vendor/mark.js/dist/mark.min.js"></script>

b) Add it to angular.json file in a project root so Angular builder will grab and applied it (as if it was included with a <script> tag)

#2. Adding mark.js to lib package.json

Now we should add mark.js lib as a dependency to our library package.json in ***<root>/projects/ngx-markjs/src ***folder (don't mix it up with src/package.json — file for main parent project).
We can add it as ***peerDependencies ***section — in that case, you should install mark.js manually prior to installing our wrapper package.

Or we can add mark.js to dependencies section — then mark.js package will be installed automatically when you run npm i ngx-markjs.

You can read more about the difference between package.json dependencies and peerDependencies in this great article.

#3. Get entity with require call.

const Mark = require('mark.js');

In our case, I would prefer to use require since mark.js code should be present only inside markjsHighlight lib module but not in whole application (until we use actually it there).

Small remark: some tslint configurations prevent using require to stimulate using es6 modules, so in that case just wrap require with tslint disabled comment. Like this:

/* tslint:disable */
const Mark = require('mark.js');
/* tslint:enable */

The project is ready. Now it is time to implement our markjsHighlightDirective.

Wrapping mark.js in a directive

Ok, so lets plan how our markjsHighlightDirective will work:

  1. It should be applied to the element with content — to get HTML element content where the text will be searched. (markjsHighlight input)

  2. It should accept mark.js configuration object (markjsConfig input)

  3. And we should be able to switch on and off 'scroll to marked text' feature (scrollToFirstMarked input)

For example:

<div class="content_wrapper" 
     [markjsHighlight]="searchText"
     [markjsConfig]="config"
     [scrollToFirstMarked]="true"
>

Now it is time to implement these requirements.

Adding mark.js to the library

Install mark.js to our project

npm install mark.js

And create its instance in a projects/ngx-markjs/src/lib/markjs-highlight.directive.ts file:

require Mark.jsrequire Mark.js

To prevent Typescript warnings — I declared require global variable.

Creating a basic directive starter

The very first starter for MarkjsHighlightDirective will be

@Directive({
  selector: '[markjsHighlight]' // our directive
})
export class MarkjsHighlightDirective implements OnChanges {

  @Input() markjsHighlight = '';  // our inputs
  @Input() markjsConfig: any = {};
  @Input() scrollToFirstMarked: boolean = false;

  @Output() getInstance = new EventEmitter<any>();

  markInstance: any;

  constructor(
    private contentElementRef: ElementRef, // host element ref
    private renderer: Renderer2 // we will use it to scroll
  ) {
  }

  ngOnChanges(changes) {  //if searchText is changed - redo marking

    if (!this.markInstance) { // emit mark.js instance (if needeed)**
      this.markInstance = new Mark(this.contentElementRef.nativeElement);
      this.getInstance.emit(this.markInstance);
    }

    this.hightlightText(); // should be implemented**

    if (this.scrollToFirstMarked) {
      this.scrollToFirstMarkedText(); // should be implemented
    }    
  }
}

Ok, so let's go through this starter code:

  1. We defined three inputs for searchText value, config and scrolling on/off functionality (as we planned earlier)

  2. ngOnChanges lifeCycle hook emits instance of Mark.js to parent component (in case you want to implement some additional Mark.js behavior)
    Also, each time searchText is changed we should redo text highlight (since search text is different now) — this functionality will be implemented in this.hightlightText method.
    And if scrollToFirstMarked is set to true — then we should run this.scrollToFirstMarkedText.

Implementing highlight functionality

Our method this.hightlightText should receive searchText value, unmark previous search results and do new text highlighting. It can be successfully done with this code:

hightlightText() {
  this.markjsHighlight = this.markjsHighlight || ''; 

  if (this.markjsHighlight && this.markjsHighlight.length <= 2) {
    this.markInstance.unmark();
    return;

  } else {

    this.markInstance.unmark({
      done: () => {
        this.markInstance.mark((this.markjsHighlight || ''), this.markjsConfig);
      }
    });
  }
}

Code is self-explanatory: we check if markjsHighlight valur is not null or undefined (because with these values Mark.js instances throw the error).

Then check for text length. If it is just one letter or no text at all — we unmark text and return;

Otherwise, we unmark previously highlighted text and start new highlighting process.

Implementing a "scroll to first marked result" feature

One important remark here before we start implementing scroll feature: content wrapper element, where we apply our directive to should have css position set other than static (for example: position: relative). Otherwise offset to be scrolled to will be calculated improperly.

OK, lets code this.scrollToFirstMarkedText method:

constructor(
  private contentElementRef: ElementRef,
  private renderer: Renderer2
) {
}
....

scrollToFirstMarkedText() {
  const content = this.contentElementRef.nativeElement;

// calculating offset to the first marked element
  const firstOffsetTop = (content.querySelector('mark') || {}).offsetTop || 0; 

  this.scrollSmooth**(content, firstOffsetTop); // start scroll
}

scrollSmooth(scrollElement, firstOffsetTop) {
  const renderer = this.renderer;

  if (cancelAnimationId) {
    cancelAnimationFrame(cancelAnimationId);
  }
  const currentScrollTop = scrollElement.scrollTop;
  const delta = firstOffsetTop - currentScrollTop;

  animate({
    duration: 500,
    timing(timeFraction) {
      return timeFraction;
    },
    draw(progress) {
      const nextStep = currentScrollTop + progress * delta;

     // set scroll with Angular renderer
     renderer.setProperty(scrollElement, 'scrollTop', nextStep);
    }
  });
}

...
let cancelAnimationId;

// helper function for smooth scroll
function animate({timing, draw, duration}) {
  const start = performance.now();
  cancelAnimationId = requestAnimationFrame(function animate2(time) {
    // timeFraction goes from 0 to 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) {
      timeFraction = 1;
    }
    // calculate the current animation state
    const progress = timing(timeFraction);
    draw(progress); // draw it
    if (timeFraction < 1) {
      cancelAnimationId = requestAnimationFrame(animate2);
    }
  });
}

How it works:

  1. We get content wrapper element (injected in a constructor by Angular) and query for first highlighted text node (Mark.js to highlight text wrap it in <Mark></Mark> HTML element).

  2. Then start this.scrollSmooth function. scrollSmooth cancels previous scroll (if any), calculates scroll difference, delta (diff between current scroll position and offsetTop of marked element) and call an animated function which will calculate timings for smooth scrolling and do actual scroll (by calling renderer.setProperty(scrollElement, ‘scrollTop’, nextStep)).

  3. Animate function is a helper taken from a very good javascript learning tutorial site javscript.info.

Our directive is ready! You can take a look at a full code here.

The only thing we have to do yet is to add a directive to NgxMarkjsModule module:

import { NgModule } from '@angular/core';
import { MarkjsHighlightDirective } from './markjs-highlight.directive';



@NgModule({
  declarations: [MarkjsHighlightDirective],
  imports: [
  ],
  exports: [MarkjsHighlightDirective]
})
export class NgxMarkjsModule { }

Applying Result

Now let's use it in our demo application:

  1. Import NgxMarkjsModule to app.module.ts:
  ...
   import {NgxMarkjsModule} from 'ngx-markjs';
   
   @NgModule({
     declarations: [
       AppComponent
     ],
     imports: [
       BrowserModule,
       NgxMarkjsModule
     ],
     providers: [],
     bootstrap: [AppComponent]
   })
   export class AppModule { }
  1. I added some content to app.component.html and applied the directive to it:
<div class**="search_input">
    <input placeholder="Search..." #search type="text">
  </div>
  <div class**="content_wrapper"
       [markjsHighlight]="searchText$ | async"
       [markjsConfig]="searchConfig"
       [scrollToFirstMarked]="true"
  >
    <p>Lorem ipsum dolor ssit amet, consectetur...a lot of text futher</p>
  1. In app.component.ts we should subscribe to input change event and feed search text to markjsHighlight directive with async pipe:
  @Component({
     selector: 'app-root',
     templateUrl: './app.component.html',
     styleUrls: ['./app.component.scss']
   })
   export class AppComponent implements AfterViewInit {
     title = 'ngx-markjs-demo';
     @ViewChild('search', {static: false}) searchElemRef: ElementRef;
     searchText$: Observable<string>;
     searchConfig = {separateWordSearch: false};
   
     ngAfterViewInit() {
       // create stream from inpout change event with rxjs 'from' function

       this.searchText$ = fromEvent(this.searchElemRef.nativeElement, 'keyup').pipe(
         map((e: Event) => (*e*.target as HTMLInputElement).value),
         debounceTime(300),
         distinctUntilChanged()
       );
     }
   }

Let's start it and take a look at result:

    ng serve

It works!It works!

We did it!
The last thing to do: we should publish our directive to npm registry:

    npm login
    npm build:ngx-markjs
    cd ./dist/ngx-markjs
    npm publish

And here it is in a registry: ngx-markjs.

Conclusion

Did you meet some neat vanillaJS library which you want to use in Angular? Now you know how to do that!

Pros

  1. Now we can easily import our directive in Angular 8 project.

  2. Additional scroll functionality is quite neat — use it to improve user experience.

Cons

  1. Possibly mark.js implemented only for a browser. So if you plan to use it in some other platforms (Angular allows it — read more about it here) — it may not work.

Related links:

  1. Mark.js

  2. ngx-markjs github repo.

More to read:

  1. Angular Platforms in depth. Part 1.

  2. Creating a Library with Angular CLI.

  3. Making an Angular project mono repo

Hope you enjoyed the article. If yes — follow me on Twitter.

Discover and read more posts from Oleksandr Poshtaruk
get started