Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created May 1, 2018 12:42
Show Gist options
  • Save bennadel/11410f4a6a1da07c5002ef703c1f4983 to your computer and use it in GitHub Desktop.
Save bennadel/11410f4a6a1da07c5002ef703c1f4983 to your computer and use it in GitHub Desktop.
Creating A Medium-Inspired Text Selection Directive In Angular 5.2.10
// 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 = "";
}
}
// 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
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment