Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at BFusion / BFLEX 2009 (Bloomington, Indiana) with: Andy Matthews and Steve Withington and Rob Huddleston
Ben Nadel at BFusion / BFLEX 2009 (Bloomington, Indiana) with: Andy Matthews@commadelimited ) , Steve Withington@stevewithington ) , and Rob Huddleston

Creating A Medium-Inspired Text Selection Directive In Angular 5.2.10

By Ben Nadel on

The other week, I started experimenting with the browser's Selection API, using it to draw outlines around the selected text based on the DOM (Document Object Model) Rectangles emitted by the embedded Range objects. As a follow-up to that experiment, I thought it would be fun to try and build a Medium-inspired Angular 5 directive that would encapsulate the selection logic; emit selection events; and, help translate the viewport-based coordinates of the ranges into host-local coordinates that can be used to render a call-to-action.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

For those who aren't familiar with the text-selection feature of the Medium platform, when the reader highlights a section of text in the main article, Medium presents the reader with various calls-to-action. For example, the reader can share the selected quote with their social graph; or, the reader can add a comment in the sidebar in order to spark a discussion with other readers.

I wanted to try and build something similar in Angular 5.2.10. And, in order to keep a clean separation of concerns, I decided to isolate the text-selection logic from the reaction to the selection event. To do this, I created an attribute Directive that exposes an EventEmitter - (textSelect) - which will emit text-selection events along with viewport-relative and host-relative positional information. The [textSelect] attribute also acts as the Directive's selector. So, in order to start consuming text-selection events, all you have to do is bind to the textSelect output:

<div (textSelect)="handleSelection( $event )"> .... </div>

This will start consuming selection events that are wholly-contained within the given host element. Meaning, if a selection bleeds into or out of the given DIV, no selection event will fire. This constraint allows us to translate the position of the text-selection rectangles from the viewport to the host container where they can be used to render absolutely-positioned elements relative to the host container.

To see this in action, let's look at an Angular app component that consumes the (textSelect) event and uses it to render a "Share With Friends" call-to-action right above the selected content:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • // Import the application components and services.
  • import { TextSelectEvent } from "./text-select.directive";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • interface SelectionRectangle {
  • left: number;
  • top: number;
  • width: number;
  • height: number;
  • }
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.less" ],
  • template:
  • `
  • <div>
  • <p *ngFor="let i of [ 1, 2, 3 ]">
  • This is some text before the active selection zone.
  • </p>
  • </div>
  •  
  • <div (textSelect)="renderRectangles( $event )" class="container">
  •  
  • <p *ngFor="let i of [ 1, 2, 3, 4, 5, 6 ]">
  • Do I still Love you? Absolutely. There is not a doubt in my mind. Through
  • all my mind, my ego&hellip; I was always faithful in my Love for you.
  • That I made you doubt it, that is the great mistake of a Life full of
  • mistakes. The truth doesn't set us free, Robin. I can tell you I Love you
  • as many times as you can stand to hear it and all that does, the only
  • thing, is remind us&hellip; that Love is not enough. Not even close.
  • </p>
  •  
  • <!--
  • The host rectangle has to be contained WITHIN the element that has the
  • [textSelect] directive because the rectangle will be absolutely
  • positioned relative to said element.
  • -->
  • <div
  • *ngIf="hostRectangle"
  • class="indicator"
  • [style.left.px]="hostRectangle.left"
  • [style.top.px]="hostRectangle.top"
  • [style.width.px]="hostRectangle.width"
  • [style.height.px]="0">
  •  
  • <div class="indicator__cta">
  • <!--
  • NOTE: Because we DON'T WANT the selected text to get deselected
  • when we click on the call-to-action, we have to PREVENT THE
  • DEFAULT BEHAVIOR and STOP PROPAGATION on some of the events. The
  • byproduct of this is that the (click) event won't fire. As such,
  • we then have to consume the click-intent by way of the (mouseup)
  • event.
  • -->
  • <a
  • (mousedown)="$event.preventDefault()"
  • (mouseup)="$event.stopPropagation(); shareSelection()"
  • class="indicator__cta-link">
  • Share With Friends
  • </a>
  • </div>
  •  
  • </div>
  •  
  • </div>
  •  
  • <div>
  • <p *ngFor="let i of [ 1, 2, 3 ]">
  • This is some text after the active selection zone.
  • </p>
  • </div>
  • `
  • })
  • export class AppComponent {
  •  
  • public hostRectangle: SelectionRectangle | null;
  •  
  • private selectedText: string;
  •  
  • // I initialize the app-component.
  • constructor() {
  •  
  • this.hostRectangle = null;
  • this.selectedText = "";
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I render the rectangles emitted by the [textSelect] directive.
  • public renderRectangles( event: TextSelectEvent ) : void {
  •  
  • console.group( "Text Select Event" );
  • console.log( "Text:", event.text );
  • console.log( "Viewport Rectangle:", event.viewportRectangle );
  • console.log( "Host Rectangle:", event.hostRectangle );
  • console.groupEnd();
  •  
  • // If a new selection has been created, the viewport and host rectangles will
  • // exist. Or, if a selection is being removed, the rectangles will be null.
  • if ( event.hostRectangle ) {
  •  
  • this.hostRectangle = event.hostRectangle;
  • this.selectedText = event.text;
  •  
  • } else {
  •  
  • this.hostRectangle = null;
  • this.selectedText = "";
  •  
  • }
  •  
  • }
  •  
  •  
  • // I share the selected text with friends :)
  • public shareSelection() : void {
  •  
  • console.group( "Shared Text" );
  • console.log( this.selectedText );
  • console.groupEnd();
  •  
  • // Now that we've shared the text, let's clear the current selection.
  • document.getSelection().removeAllRanges();
  • // CAUTION: In modern browsers, the above call triggers a "selectionchange"
  • // event, which implicitly calls our renderRectangles() callback. However,
  • // in IE, the above call doesn't appear to trigger the "selectionchange"
  • // event. As such, we need to remove the host rectangle explicitly.
  • this.hostRectangle = null;
  • this.selectedText = "";
  •  
  • }
  •  
  • }

As you can see, inside of the App component, we have a DIV that binds to the (textSelect) event. The (textSelect) event handler then uses the "hostRectangle" property of the emitted event in order to render a "Share With Friends" call-to-action (CTA). The markup is a little complicated because we don't want to the user's interaction with the CTA link to inadvertently clear the selection. As such, we have to prevent the default-behavior of a several mouse events in order to keep the selection in place while the user clicks on the CTA interface.

If we run which page and select some of the text in the "container", we get the following output:


 
 
 

 
 Creating a Medium-inspired text selection directive in Angular that emits textSelect events with selection location rectangles. 
 
 
 

As you can see, we were able to take the "hostRectangle" emitted by the "textSelect" event and use it to render our "Share With Friends" call-to-action directly above the selected text content.

Now, let's take a quick look at the [textSelect] directive to see how this works:

  • // Import the core angular services.
  • import { Directive } from "@angular/core";
  • import { ElementRef } from "@angular/core";
  • import { EventEmitter } from "@angular/core";
  • import { OnDestroy } from "@angular/core";
  • import { OnInit } from "@angular/core";
  • import { NgZone } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • export interface TextSelectEvent {
  • text: string;
  • viewportRectangle: SelectionRectangle | null;
  • hostRectangle: SelectionRectangle | null;
  • }
  •  
  • interface SelectionRectangle {
  • left: number;
  • top: number;
  • width: number;
  • height: number;
  • }
  •  
  • @Directive({
  • selector: "[textSelect]",
  • outputs: [ "textSelectEvent: textSelect" ]
  • })
  • export class TextSelectDirective implements OnInit, OnDestroy {
  •  
  • public textSelectEvent: EventEmitter<TextSelectEvent>;
  •  
  • private elementRef: ElementRef;
  • private hasSelection: boolean;
  • private zone: NgZone;
  •  
  • // I initialize the text-select directive.
  • constructor(
  • elementRef: ElementRef,
  • zone: NgZone
  • ) {
  •  
  • this.elementRef = elementRef;
  • this.zone = zone;
  •  
  • this.hasSelection = false;
  • this.textSelectEvent = new EventEmitter();
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called once when the directive is being unmounted.
  • public ngOnDestroy() : void {
  •  
  • // Unbind all handlers, even ones that may not be bounds at this moment.
  • this.elementRef.nativeElement.removeEventListener( "mousedown", this.handleMousedown, false );
  • document.removeEventListener( "mouseup", this.handleMouseup, false );
  • document.removeEventListener( "selectionchange", this.handleSelectionchange, false );
  •  
  • }
  •  
  •  
  • // I get called once after the inputs have been bound for the first time.
  • public ngOnInit() : void {
  •  
  • // Since not all interactions will lead to an event that is meaningful to the
  • // calling context, we want to setup the DOM bindings outside of the Angular
  • // Zone. This way, we don't trigger any change-detection digests until we know
  • // that we have a computed event to emit.
  • this.zone.runOutsideAngular(
  • () => {
  •  
  • // While there are several ways to create a selection on the page, this
  • // directive is only going to be concerned with selections that were
  • // initiated by MOUSE-based selections within the current element.
  • this.elementRef.nativeElement.addEventListener( "mousedown", this.handleMousedown, false );
  •  
  • // While the mouse-even takes care of starting new selections within the
  • // current element, we need to listen for the selectionchange event in
  • // order to pick-up on selections being removed from the current element.
  • document.addEventListener( "selectionchange", this.handleSelectionchange, false );
  •  
  • }
  • );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I get the deepest Element node in the DOM tree that contains the entire range.
  • private getRangeContainer( range: Range ) : Node {
  •  
  • var container = range.commonAncestorContainer;
  •  
  • // If the selected node is a Text node, climb up to an element node - in Internet
  • // Explorer, the .contains() method only works with Element nodes.
  • while ( container.nodeType !== Node.ELEMENT_NODE ) {
  •  
  • container = container.parentNode;
  •  
  • }
  •  
  • return( container );
  •  
  • }
  •  
  •  
  • // I handle mousedown events inside the current element.
  • private handleMousedown = () : void => {
  •  
  • document.addEventListener( "mouseup", this.handleMouseup, false );
  •  
  • }
  •  
  •  
  • // I handle mouseup events anywhere in the document.
  • private handleMouseup = () : void => {
  •  
  • document.removeEventListener( "mouseup", this.handleMouseup, false );
  •  
  • this.processSelection();
  •  
  • }
  •  
  •  
  • // I handle selectionchange events anywhere in the document.
  • private handleSelectionchange = () : void => {
  •  
  • // We are using the mousedown / mouseup events to manage selections that are
  • // initiated from within the host element. But, we also have to account for
  • // cases in which a selection outside the host will cause a local, existing
  • // selection (if any) to be removed. As such, we'll only respond to the generic
  • // "selectionchange" event when there is a current selection that is in danger
  • // of being removed.
  • if ( this.hasSelection ) {
  •  
  • this.processSelection();
  •  
  • }
  •  
  • }
  •  
  •  
  • // I determine if the given range is fully contained within the host element.
  • private isRangeFullyContained( range: Range ) : boolean {
  •  
  • var hostElement = this.elementRef.nativeElement;
  • var selectionContainer = range.commonAncestorContainer;
  •  
  • // If the selected node is a Text node, climb up to an element node - in Internet
  • // Explorer, the .contains() method only works with Element nodes.
  • while ( selectionContainer.nodeType !== Node.ELEMENT_NODE ) {
  •  
  • selectionContainer = selectionContainer.parentNode;
  •  
  • }
  •  
  • return( hostElement.contains( selectionContainer) );
  •  
  • }
  •  
  •  
  • // I inspect the document's current selection and check to see if it should be
  • // emitted as a TextSelectEvent within the current element.
  • private processSelection() : void {
  •  
  • var selection = document.getSelection();
  •  
  • // If there is a new selection and an existing selection, let's clear out the
  • // existing selection first.
  • if ( this.hasSelection ) {
  •  
  • // Since emitting event may cause the calling context to change state, we
  • // want to run the .emit() inside of the Angular Zone. This way, it can
  • // trigger change detection and update the views.
  • this.zone.runGuarded(
  • () => {
  •  
  • this.hasSelection = false;
  • this.textSelectEvent.next({
  • text: "",
  • viewportRectangle: null,
  • hostRectangle: null
  • });
  •  
  • }
  • );
  •  
  • }
  •  
  • // If the new selection is empty (for example, the user just clicked somewhere
  • // in the document), then there's no new selection event to emit.
  • if ( ! selection.rangeCount || ! selection.toString() ) {
  •  
  • return;
  •  
  • }
  •  
  • var range = selection.getRangeAt( 0 );
  • var rangeContainer = this.getRangeContainer( range );
  •  
  • // We only want to emit events for selections that are fully contained within the
  • // host element. If the selection bleeds out-of or in-to the host, then we'll
  • // just ignore it since we don't control the outer portions.
  • if ( this.elementRef.nativeElement.contains( rangeContainer ) ) {
  •  
  • var viewportRectangle = range.getBoundingClientRect();
  • var localRectangle = this.viewportToHost( viewportRectangle, rangeContainer );
  •  
  • // Since emitting event may cause the calling context to change state, we
  • // want to run the .emit() inside of the Angular Zone. This way, it can
  • // trigger change detection and update the views.
  • this.zone.runGuarded(
  • () => {
  •  
  • this.hasSelection = true;
  • this.textSelectEvent.emit({
  • text: selection.toString(),
  • viewportRectangle: {
  • left: viewportRectangle.left,
  • top: viewportRectangle.top,
  • width: viewportRectangle.width,
  • height: viewportRectangle.height
  • },
  • hostRectangle: {
  • left: localRectangle.left,
  • top: localRectangle.top,
  • width: localRectangle.width,
  • height: localRectangle.height
  • }
  • });
  •  
  • }
  • );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I convert the given viewport-relative rectangle to a host-relative rectangle.
  • // --
  • // NOTE: This algorithm doesn't care if the host element has a position - it simply
  • // walks up the DOM tree looking for offsets.
  • private viewportToHost(
  • viewportRectangle: SelectionRectangle,
  • rangeContainer: Node
  • ) : SelectionRectangle {
  •  
  • var host = this.elementRef.nativeElement;
  • var hostRectangle = host.getBoundingClientRect();
  •  
  • // Both the selection rectangle and the host rectangle are calculated relative to
  • // the browser viewport. As such, the local position of the selection within the
  • // host element should just be the delta of the two rectangles.
  • var localLeft = ( viewportRectangle.left - hostRectangle.left );
  • var localTop = ( viewportRectangle.top - hostRectangle.top );
  •  
  • var node = rangeContainer;
  • // Now that we have the local position, we have to account for any scrolling
  • // being performed within the host element. Let's walk from the range container
  • // up to the host element and add any relevant scroll offsets to the calculated
  • // local position.
  • do {
  •  
  • localLeft += ( <Element>node ).scrollLeft;
  • localTop += ( <Element>node ).scrollTop;
  •  
  • } while ( ( node !== host ) && ( node = node.parentNode ) );
  •  
  • return({
  • left: localLeft,
  • top: localTop,
  • width: viewportRectangle.width,
  • height: viewportRectangle.height
  • });
  •  
  • }
  •  
  • }

One of the more interesting parts of this directive is its use of the NgZone service. Since there isn't a one-to-one ratio of document interactions to (textSelect) events, I need to take care not to bind the event-handlers in the Angular zone. Doing so would trigger an unnecessary number of change-detection digests. To get around this, I bind the underlying event-handlers outside of the Angular zone; then, when I need to emit a (textSelect) event, I dip back into the Angular zone before calling my EventEmitter. This ensures that this directive doesn't trigger change detection until there is a context in which the application's view-state could reasonably be altered.

The rest of the directive is just concerned with looking at the text selections, determining if they are contained within the host element, and then calculating a host-relative position for the selection rectangles.

Dealing with text selection is tricky because consuming the selection can inadvertently clear the selection. As such, as much as I wanted to create a clean separation of concerns, it seems that the concept of a "selection" needs to bleed across both the [textSelect] directive and the consuming context. Perhaps there is a better way. But for the time-being, this was just a fun experiment in Angular 5.2.10.



Looking For A New Job?

Ooops, there are no jobs. Post one now for only $29 and own this real estate!

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

@All,

As I was recording the video, it occurred to me that I'm not sure if an event-handler, bound outside of the NgZone, will implicitly keep subsequent event-handlers outside of the NgZone as well. Or, if all event handlers have to be explicitly bound outside of the NgZone (if you want to side-step change detection). I'll do a follow-up post on that in order to get my mind straight on it.

Also, I see that I use both .next() and .emit() on the EventEmitter. Those are supposed to both be .emit(). The .next() only works by coincidence -- .emit() is what is officially on the EventEmitter API documentation.

Reply to this Comment

Really great post. It's interesting, because I was using the Medium App today, and used the text selection highlighter completely by accident. It is a seriously cool feature. In fact, the Medium App is pretty slick all round as far as blogging platforms go.

It's going to take me a few rereads to get my head around your post, as node selection stuff is pretty alien to me. But, Directives are very nice features, as far as modularity is concerned. I know Tippy tooltips use a Directive for their Angular version.

One thing I did want to ask, is what does the following do?

this.zone.runOutsideAngular

Reply to this Comment

Just to let you know, when I use your demo in Mobile Safari, the native iOS 'copy|select|share' toolbar pops up, when I select text in the active text zone. In the Medium App version, the Angular based share toolbar works as expected.

Maybe, it is not possible to override the iOS version, unless it is created in a native app, using Objective-C/Swift? I will try the Medium version in Mobile Safari & see if there one works...

Reply to this Comment

@All,

Just a quick follow-up on the NgZone / Angular Zone stuff -- I did a quick sanity check and this approach checks-out:

https://www.bennadel.com/blog/3440-sanity-check-manipulating-event-handlers-and-timers-outside-of-ngzone-in-angular-5-2-10.htm

Handlers abound inside of handlers that were bound outside of the Angular Zone will also operate outside of the Angular Zone. So, once you're in a zone, it persists across all asynchronous events.

Reply to this Comment

@Charles,

Yeah, Medium is a really polished platform. I'm kind of jealous of a number of their features. My blog is all home-grown over the last decade, and parts of it are really starting to age :D

As far as the runOutsideAngular() stuff -- Angular uses Zone.js to facilitate change-detection. Essentially, a "zone", and I know I'll butcher this explanation, is like a context for execution. By default, all of the Angular components and services execute in the same zone -- the Angular Zone, the one injectable as the NgZone service provider. Because of this, you almost never have to tell Angular when a change as occurred -- Angular is already aware of all the callbacks, timeouts, and AJAX calls (for example) that are running in the Angular Zone. As such, it knows to perform a change-detection digest whenever a setTimeout() executes or an AJAX call returns.

Of course, if you need to do some low-level stuff, like creating a custom event-type (ex, "mousedownOutside"), you don't necessarily want Angular to do the magic. Taking the "mousedownOutside" example, you don't necessarily have a one-to-one ratio of user interaction to "mousedownOutside" events. As such, you won't want every "mousedown" event to trigger change-detection -- only the "mousedown" events that are "outside" the host element (on which the directive is bound). To accomplish this side-stepping, you can bind the core "mousedown" event outside the Angular Zone using the runOutsideAngular() method. This way, your "mousedown" event callback won't trigger change detection.

Internally to your plug-in, you can then calculate the right time to trigger a "mousedownOutside" event. And, in that case, you do want Angular to work its magic; so, you trigger the "mousedownOutside" event BACK INSIDE the Angular Zone using the .run() or .runGuarded() methods.

Mostly, this seems to be helpful for low-level stuff. Most of the time, you don't need. Hope that helps a little bit -- I'm still trying to wrap my head around them as well :D

Reply to this Comment

Thanks Ben, for the explanation. It makes sense. I only understand your explanation because I spent last night reading up on Angular Zones & ChangeDetectionStrategy!

Now. I am building a websocket based chat app. User to Admin based.
I would like to transfer the highlighted text. Now I know how to send stuff using socket.io, but my question is:

When a text selection is highlighted, if I was to inspect the HTML using Firebug, would I see some kind of tag around the selection. If so, I can just transfer the text with the new 'highlight' HTML tags!

Reply to this Comment

@Charles,

To the best of my understanding, the selected text does not actually affect the DOM structure in any way. It's purely meta-information about the state of the DOM, not the structure. That said, I am sure you could dynamically wrap the selected content in new tags. Kind of like the way jQuery's old .wrap() method worked -- http://api.jquery.com/wrap/ -- but, at a more content-level, as opposed to an Element level.

The problem to consider is that a selection may cross over multiple Elements. For example, a user could select across several P-Tags. And, since you can't wrap a P-Tag in a Span-Tag (for example), you'd have to go into each P-Tag and wrap each highlighted portion in its own Span-Tag.

That sounds like a fun experiment! I'm gonna put that on my backlog and see if I can actually make that work. If nothing else, it will teach me more about the Selection API.

Reply to this Comment

Looking forward to this experiment!

I think JQuery can easily handle stuff like this.
Just add a class to the highlighted <p> tags and then use wrapInner():

$( ".highlight" ).wrapInner( "<span></span>" );

The big question is, can it be done the Angular way using 'renderer2'?

Reply to this Comment

@Charles,

Ugg, I've all but abandoned the Renderer2 service. Every time I try to use it, I can only get like 50% done of what I'm trying to do. The rest seems impossible. That said, I'll see what I can do :)

Reply to this Comment

From what I know, renderer2 incorporates about a dozen JQuery-like/Native DOM helper methods like setAttribute & setStyle etc. I agree, it is a bit limited but I think the idea is that if you use renderer2, then the code is cross compatible, if you plan to use it for non browser related delivery.

Reply to this Comment

Thanks for this, Ben. It got me started on completing a feature that's a bit different than what you'd intended but it's mostly working.

The only trouble I'm having is that, for some reason, the viewportRectangle is consistently returning 0s for every dimension. More specifically, it looks like `range.getBoundingClientRect()` is returning those values.

Do you have an idea why this might be happening? The big difference is, I think, that my selected text is living inside a text input field. I'm not sure what else may be causing it.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.