Skip to main content
Ben Nadel at Scotch On The Rocks (SOTR) 2011 (Edinburgh) with: Heiko Wagner
Ben Nadel at Scotch On The Rocks (SOTR) 2011 (Edinburgh) with: Heiko Wagner

Creating A Medium-Inspired Text Selection Directive In Angular 5.2.10

By
Published in Comments (25)

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.

Want to use code from this post? Check out the license.

Reader Comments

15,912 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.

449 Comments

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

449 Comments

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

15,912 Comments

@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

449 Comments

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!

15,912 Comments

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

449 Comments

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'?

15,912 Comments

@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 :)

449 Comments

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.

2 Comments

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.

1 Comments

We've just implemented this within our app and it works great, thank you. One issue we found was to do with the css positioning of the container element (containing the textSelect method) and how that influences the final overlaid position of the Share popup.

If this container element doesn't have "position: relative;" set, then the popup position doesn't take into consideration elements that appear 'above' the parent element on screen. So if there was a div that appeared above the containing element, and it had a height of 100px, the final position of the popup will be positioned 100px apart from the selected text.

Setting the container element to have a position: relative; fixes this issue.

Thank you again.

5 Comments

On Selecting text i need to disaply menu with click event ,code inside "indicator__cta"

the simple click is not happening inside the tooltip, could you please help me on this?

Regards,
Prakash T

15,912 Comments

@Prakash,

I am not sure what you are asking -- what you are describing is, I think, what my demo is doing; it is showing a tooltip and then allowing the tooltip to be clicked to trigger an action (in this case, I'm just logging the selected text to the console).

5 Comments

Thanks for the response

Here I have used anchor click inside tooltip and when am selecting the text n clicking inside tooltip link it's not triggering ...DOM for tooltip menu s completely removing when we click outside or inside tooltip ...

Please guide me how to add click event inside tooltip.

15,912 Comments

@Prakash,

In my demo, in order to keep the tooltip open when the user clicks in the tooltip, I have to prevent the propagation and default behavior of the click:

(mousedown)="$event.preventDefault()"
(mouseup)="$event.stopPropagation(); shareSelection()"

In my particular configuration, if I didn't do this, the tooltip would disappear the moment I moused-down inside the tooltip, since the very act of clicking would change the selection of text. If you copied this approach, that is likely why your anchor tag isn't working - the default behavior and propagation are being canceled.

If you want an anchor in the tooltip, you'll have to find a way to keep the native event behavior. Or perhaps capture the click and then programmatically execute the anchor with something like Element.scrollIntoView(). Hope that helps.

2 Comments

I like your sample and for me its a very good starting point what I want to achieve.

My basic q: How to inject into the selected text some HTML-Tag, e.g. <span id="1234">... </span>

With this additional styling, references and ... could be done quite easily.

2 Comments

About my last comment: Or even better than HTML tag would be to replace it with an Angular component directly. i.e. to have span-component instead of the HTML tag. The advantage of the span-component would be to have an additional handler for further processing.

1 Comments

Hi,

thank you for your code. Nice work!

I only must extend TextSelectEvent with range:Range variable becose i need work with cursor positons.

15,912 Comments

@Roland,

That's a fascinating question! I've done things like that back in my jQuery days (where you could just parse / re-save things with the .html() method). But, I've never manipulated HTML content like that in Angular ... at least, not yet. I think part of that discussion would come down to how and where is the content being persisted?

For example, after you highlight the text and wrap it in a <span> tag, does that tag get persisted with the content? Or, would the "offset+length" of the selection get persisted separately?

I don't really have good answers to your question - only more questions :D That said, it is a fascinating question; I'll have to noodle on it some more.

15,912 Comments

@Vasatko,

Very cool -- I'm glad you found this helpful; and, that you were able to extend it to meet your needs. That's awesome :D

449 Comments

Sorry guys, but I had chip in here. I was so inspired by Ben's Medium Inspired Text Selector, that I created one in Vanilla JS.

It actually uses some of Ben's event listener methodology.

http://mushroom-man.com/blog/article/the-blue-light/

If you are logged in, and you click on the text selection tooltip, it actually takes you to the comments section, which is what Medium's does, if you choose Respond. The text selection does not work on the summary paragraph at the beginning of the article.

You can also change the highlight color from the menu at the bottom.

I makes heavy use of the:

Window.getSelection()

API

Which is incredibly powerful!

I have to say my version is a bit buggy, because I wasn't 100% clear on what I was doing. And I decided not to allow text selection across paragraph boundaries. Trying to do otherwise is very complicated.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel