In this blog post, I want to show a use case that demonstrates the power of Angular Directives coupled with Angularโs Dependency Injection system.
Use-case
I am working on a library called Angular Three, which is a THREE.js integration for Angular. Here is a simple 3D Scene and the code for that scene:
A spinning cube with mouse interactions
1@Component({2 selector: "ngt-cube",3 template: `4 <ngt-soba-box5 #sobaBox6 [ngtBoxHelper]="['black']"7 (animateReady)="onAnimateReady(sobaBox.object)"8 (click)="active = !active"9 (pointerover)="hover = true"10 (pointerout)="hover = false"11 [isMaterialArray]="true"12 [scale]="active ? [1.5, 1.5, 1.5] : [1, 1, 1]"13 >14 <ngt-cube-materials [hover]="hover"></ngt-cube-materials>15 </ngt-soba-box>16 `,17 changeDetection: ChangeDetectionStrategy.OnPush,18})19export class CubeComponent {20 hover = false;21 active = false;22
23 onAnimateReady(mesh: THREE.Mesh) {24 mesh.rotation.x = -Math.PI / 2;25 mesh.rotation.z += 0.01;26 }27}
As seen in the GIF, you can see that I can interact with the cube in a couple of ways:
- Hovering changes its colors.
- Clicking changes its scale.
There is no feedback to tell the users that the cube is actionable (no cursor: pointer
). A cube is a 3D object drawn inside an HTMLCanvasElement
, so there is no DOM for this cube, so there is no cursor: pointer
.
We are going to fix that in this blog post.
Naive fix
The naive approach would be to change the style.cursor
on the document.body
, and we can do this because we can listen to (pointerover)
and (pointerout)
events on the cube. Letโs change our code to implement this:
1@Component({2 selector: "ngt-cube",3 template: `4 <ngt-soba-box5 #sobaBox6 ...7 (pointerover)="onPointerOver()"8 (pointerout)="onPointerOut()"9 ...10 >11 <ngt-cube-materials [hover]="hover"></ngt-cube-materials>12 </ngt-soba-box>13 `,14 changeDetection: ChangeDetectionStrategy.OnPush,15})16export class CubeComponent {17 hover = false;18 active = false;19
20 onPointerOver() {21 this.hover = true;22 }23
24 onPointerOut() {25 this.hover = false;26 }27
28 onAnimateReady(mesh: THREE.Mesh) {29 /* ... */30 }31}
Everything should be the same as before. Letโs actually start with the fix
1@Component({2 selector: "ngt-cube",3 template: `4 <ngt-soba-box5 #sobaBox6 ...7 (pointerover)="onPointerOver()"8 (pointerout)="onPointerOut()"9 ...10 >11 <ngt-cube-materials [hover]="hover"></ngt-cube-materials>12 </ngt-soba-box>13 `,14 changeDetection: ChangeDetectionStrategy.OnPush,15})16export class CubeComponent {17 hover = false;18 active = false;19
20 // ๐ inject DOCUMENT (aka document)21 constructor(@Inject(DOCUMENT) private document: Document) {}22
23 onPointerOver() {24 this.hover = true;25 //๐ change to pointer on hover26 this.document.body.style.cursor = "pointer";27 }28
29 onPointerOut() {30 this.hover = false;31 //๐ change to pointer off hover32 this.document.body.style.cursor = "auto";33 }34
35 onAnimateReady(mesh: THREE.Mesh) {36 /* ... */37 }38}
Cursor changes to โpointerโ on hover
Hurray ๐! We now have feedback for users that our cube is actionable.
What if we add the cursor: pointer
fix to different 3D objects that might not be in the same Component? That would be quite a chore to do.
- We would need to listen to two events
(pointerover)
and(pointerout)
even if the object might not need to have โhoverโ interactions. - We would need to inject
DOCUMENT
to change thebody.style
.
Letโs re-assert what we need and think of a different approach ๐ค
- We need to listen to
(pointerover)
and(pointerout)
events on 3D ObjectsThis is only applicable to Angular Three, but the thought process for different projects/use-cases is the same.
- We need to change the style of a global object (
document.body
) - We need to do this for any 3D objects
- We want to be able to declaratively add/remove this functionality (
cursor:pointer
)
With all of these points listed out, an Angular Directive, specifically an Attribute Directive, sounds like what we need to create.
Check Attribute Directive to learn more about it.
Into the Directive lane
In Angular, you can attach Attribute Directives on any elements on the DOM, and if the Directiveโs selector matches, Angular will instantiate the Directive with the appropriate context.
What is this โcontextโ thing?
A context is an environment that the Directive is created with. In other words, what is available for this Directive to access via Angularโs Dependency Injection based on where it is attached.
1@Directive({2 selector: "[some]",3})4export class SomeDirective {5 // ๐ what is available here? This is important6 constructor() {}7}
Hereโs how SomeDirective
might be used/attached:
HTML Elements
1<div some></div>2<button some></button>
When a Directive is attached on an HTMLElement
, the Directive will have access to:
ElementRef<TElement>
: whereTElement
is the actual type of the element. Eg:<button>
isHTMLButtonElement
,<div>
isHTMLDivElement
.ElementRef
is the reference to the DOM element that is rendered, like what you would see in the Element Dev Tool.
1@Directive({2 /*...*/3})4export class SomeDirective {5 constructor(elRef: ElementRef<HTMLButtonElement>) {}6}
Component
1<some-component some></some-component>
When a Directive is attached on a Component, the Directive will have access to:
ElementRef<HTMLElement>
: same as aboveTComponent
: whereTComponent
is the type of the Component. The Directive has access to the instance of the Component that Angular creates.
1@Directive({2 /*...*/3})4export class SomeDirective {5 constructor(6 // ๐ the <some-component> element7 elRef: ElementRef<HTMLElement>,8 // ๐ the SomeComponent instance9 some: SomeComponent,10 ) {}11}
ng-template
1<ng-template some></ng-template>
When a Directive is attached on ng-template
, the Directive will have access to:
ElementRef<Comment>
: When you renderng-template
, Angular will put a comment:<!-- container -->
in its place. This comment will be available for the Directive viaElementRef<Comment>
TemplateRef<any>
: TheTemplateRef
instance.
1@Directive({/*...*/})2export class SomeDirective {3 constructor(4 // ๐ the <!-- container --> comment5 elRef: ElementRef<Comment>,6 // ๐ the TemplateRef instance7 // ๐. ๐ you can use any specific generic here if you know what the Context is8 templateRef: TemplateRef<any>9 ) {10 console.log(elRef.nativeElement.textContent): // logs: 'container'11 }12}
ViewContainerRef
is always available to Attribute Directive
Inheritance
In addition to the above, the Directive also has access to the Hierarchical Injector Tree, where itโs attached. Letโs look at the following examples:
- Other directives
1<input [ngModel]="name" some />
SomeDirective
has access to the NgModel
instance and anything that NgModel
might have inherited (the underlying NgControl
), so you can have a Directive that might do some additional Form logic.
- Componentโs Providers
1<some-component some></some-component>
SomeDirective
has access to anything that is provided in SomeComponent
โs providers
1@Component({2 /*...*/,3 // ๐ this will also be made available to SomeDirective4 providers: [SomeService]5})6export class SomeComponent {}
SomeDirective
also has access to SomeComponent
โs ancestorsโ Injector
1<some-parent>2 <some-component some></some-component>3</some-parent>
In this case, anything that is available in SomeParent
will also be made available to SomeDirective
via Dependency Injection.
- Root/Platform Providers:
ChangeDetectorRef
,NgZone
,ApplicationRef
,@Inject(DOCUMENT)
, etc.
Everything listed here might not be exhaustive, as these are the things that I am aware of. You can quickly check what is available in any specific building block of Angular by injecting the
Injector
and checking it in the console.
Checking
Injector
in the console
The overlooked feature: Directiveโs Selector
One powerful feature of Angular Directives is specifying the selector
like CSS Selectors. Letโs bring back our SomeDirective
1@Directive({2 selector: "[some]",3})4export class SomeDirective {}
Letโs also assume that SomeDirective
will ALWAYS need to be there whenever SomeComponent
is used. In other words, SomeDirective
handles some certain logic for SomeComponent
that is extracted out of the Component to achieve Separation of Concern. Having to do the following is tedious
1<some-component some></some-component>2<some-component some></some-component>3<some-component some></some-component>4<some-component some></some-component>5<some-component some></some-component>
Instead, we can change SomeDirective
โs selector
to some-component
so that it will get instantiated in the same manner as SomeComponent
1@Directive({2 // ๐ nothing really prevents you from doing this3 selector: "some-component",4})5export class SomeDirective {}
This is extremely powerful if you know how to take advantage of it, especially youโre building reusable components and want to get rid of some nuances for your consumers. You can control how your Directives are instantiated:
- Relaxing: make the selectors more relaxed. Like what we did with
SomeDirective
andSomeComponent
- Constraining: make the selectors more constrained. For example,
[some]
might be too broad and might be misused; we can constrain it to justsome-component
by changing the selector tosome-component[some]
- Condition: Directivesโ selectors are like CSS Selectors, you can apply some conditions to them. For example,
[some][foo]:not([bar]),[some][bar]:not([foo]),[some]:not([foo]):not(bar)
. This says:SomeDirective
can be instantiated by itself, with[foo]
input, or with[bar]
input but never[foo]
and[bar]
inputs at the same time.
Now that we have all that information, we can continue on our proper fix to the problem stated at the beginning of this blog post.
Proper fix to the cursor problem
Start by creating a Directive (you can use the Angular CLI if you want to)
1@Directive({2 selector: "[cursorPointer]",3})4export class CursorPointerDirective {5 constructor() {}6}
This is how we would want to use CursorPointerDirective
1<ngt-mesh cursorPointer></ngt-mesh>
The job of CursorPointerDirective
is to listen to the pointersโ events and update document.body
style. Letโs fill the directive up
1@Directive({2 selector: "[cursorPointer]",3})4export class CursorPointerDirective implements OnDestroy {5 // ๐ a Subject to use with takeUntil6 destroyed$ = new Subject();7
8 constructor(9 // ๐ we need the Document so we can change its body's style10 @Inject(DOCUMENT) document: Document,11 // ๐ we use Optional so we can be less disruptive. The consumers might attach [cursorPointer] on elements that are not supposed to be attached to12 // ๐ // ๐ This is arbitrary but in Angular Three, this is where the pointer's events are declared (aka Outputs)13 @Optional() object3dInputsController: NgtObject3dInputsController,14 ) {}15
16 // ๐ a Directive shares the same life-cycle with the Element/Component that it's attached to17 // in this case, when the 3D Object is destroyed, `cursorPointer` is also destroyed18 ngOnDestroy() {19 this.destroyed$.next();20 }21}
That is all the prep work we need for CursorPointerDirective
. Now, the listening part:
1@Directive({2 selector: "[cursorPointer]",3})4export class CursorPointerDirective implements OnDestroy {5 destroyed$ = new Subject();6
7 constructor(8 @Inject(DOCUMENT) document: Document,9 @Optional() object3dInputsController: NgtObject3dInputsController,10 ) {11 // if object3dInputsController is not available, just fail fast12 if (!object3dInputsController) return;13
14 // ๐ EventEmitter (Outputs) is just RxJS Subjects so we can subscribe to them15 // ๐ import { merge } from rxjs;16 merge(17 // ๐ if pointerover, map to true as in hovered: true18 object3dInputsController.pointerover.pipe(mapTo(true)),19 // ๐ if pointerout, map to false as in hovered: false20 object3dInputsController.pointerout.pipe(mapTo(false)),21 )22 .pipe(23 // ๐ clean up24 takeUntil(this.destroyed$),25 )26 .subscribe((hovered) => {27 // ๐ the main logic28 document.body.style.cursor = hovered ? "pointer" : "auto";29 });30 }31
32 ngOnDestroy() {33 this.destroyed$.next();34 }35}
Thatโs it! Now we can use cursorPointer
on ngt-mesh
and see the result:
1@Component({2 selector: "ngt-cube",3 template: `4 <ngt-soba-box5 #sobaBox6 cursorPointer7 [ngtBoxHelper]="['black']"8 (animateReady)="onAnimateReady(sobaBox.object)"9 (click)="active = !active"10 (pointerover)="hover = true"11 (pointerout)="hover = false"12 [isMaterialArray]="true"13 [scale]="active ? [1.5, 1.5, 1.5] : [1, 1,1]"14 >15 <ngt-cube-materials [hover]="hover"></ngt-cube-materials>16 </ngt-soba-box>17 `,18 changeDetection: ChangeDetectionStrategy.OnPush,19})20export class CubeComponent {21 hover = false;22 active = false;23
24 // component code is clean ๐25
26 onAnimateReady(mesh: THREE.Mesh) {27 mesh.rotation.x = -Math.PI / 2;28 mesh.rotation.z += 0.01;29 }30}
Conclusion
With some knowledge about Directives, we made a reusable cursorPointer
directive that other 3D objects can use on any 3D Objects for showing cursor: pointer
. I hope you learn something from this blog post. Have fun and good luck ๐.