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

Alexander Poshtaruk
Angular In Depth
Published in
9 min readOct 16, 2019

And enhancing its functionality with custom logic.

Photo by Kira auf der Heide

AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!

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

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 add 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-demong 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 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 want 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)

"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/ngx-markjs-demo",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"aot": false,
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": []
},

#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:

import {Directive} from '@angular/core';


declare var require: any;
const Mark = require('mark.js');


@Directive({
selector: '[markjsHighlight]'
})
export class MarkjsHighlightDirective {

constructor() {}

}

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

2. 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>

3. 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!

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 —tweet about it and follow me on Twitter.

*It was originally published in codementor.io community blog.

Special thanks to Tim Deschryver and Lars Gyrup Brink Nielsen for reviewing this article.

--

--

Alexander Poshtaruk
Angular In Depth

Senior Front-End dev in Tonic, 'Hands-on RxJS for web development' video-course author — https://bit.ly/2AzDgQC, codementor.io JS, Angular and RxJS mentor