Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Ken Auenson
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Ken Auenson@KenAuenson )

Loading Text File Content With FileReader During A Drag-And-Drop Interaction In Angular 7.2.12

By Ben Nadel on

For the last few weeks, I've been digging into file-handling in Angular 7. First, with the uploading of single files using the HttpClient service; and then, with the uploading of multiple files using FormData. To continue on with this file-handling exploration, I wanted to see I could read-in the contents of a Text File that was dragged-and-dropped onto my Angular application. At first, I wanted to try this in the context of CSV Parsing (something I will be doing at work); but, in an effort to simplify the scope of this "code kata", I'm just reading in the Text File and rendering its content to the App component view in Angular 7.2.12.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

For this exploration, as the user drags a Text File onto the browser, I want to activate a drop zone component with a call-to-action to drop the file. Then, if the user drops the Text File onto the drop zone component, I want the component to read in the text-content of the file and emit it as an "output" event. This output event will be picked-up by the App component, which will simply render the emitted file content to the App view.

The drag-and-drop event workflow is an adventure! The "drag" event fires continuously. And, unlike the "mouseenter" and "mouseleave" events, the "dragenter" and dragleave" events both bubble-up in the DOM. This makes it significantly more challenging to figure out if any given element is the target of an active drag operation.

There's no "right way" to handle this complexity. Some people use timers to try and reduce all of the DOM events down into a single event. Other people increment and decrement counters in order to eliminate the noise from the descendant nodes. For this demo, I'm going to attempt to side-step the issue by using a "drag shield". Essentially, the moment that I see a drag operation start, I'm going to render an empty, "fixed position" element that sits above all other elements and covers the entire viewport. This way, there is literally only a single "leaf" DOM (Document Object Model) node that needs to respond to "dragover", "drop", and "dragleave" events.

Of course, even if I simplify the drag-and-drop model, the adventure isn't over. The current drag event API isn't cross-browser compliant - at least, not as far as I can see. Some browsers implement a new event API, "DataTransfer.items", while others only implement the old event API, "DataTransfer.files". And, some browsers (Chrome and Firefox) have access to the selected file meta-data during the drag operation, while other browsers (Safari and Internet Explorer) only have access to the file meta-data during the drop operation.

Altogether, this made it hard for me to create a single, consistent drag-and-drop experience. That said, let's look at what I did. Let's start with the App Component as it is simple and sets up the context for the demo. The App Component does nothing but consume the FileDropComponent and render the emitted (textDrop) event:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.less" ],
  • template:
  • `
  • <p>
  • Drag-and-drop your <strong>text files</strong> to read them into the browser.
  • </p>
  •  
  • <ng-template [ngIf]="fileContent">
  • <h2>
  • Dropped File Content
  • </h2>
  •  
  • <code class="file-content">
  • <pre>{{ fileContent }}</pre>
  • </code>
  • </ng-template>
  •  
  • <!--
  • In this exploration, I'm deferring the initiating "drag" event to the drop
  • zone itself. By default, it is hidden, but it is listening to the WINDOW for
  • drag events. Then, when the user drags a file over the window, the drop zone
  • will bring itself into full view.
  • -->
  • <my-file-drop (textDrop)="renderFileContent( $event )">
  • Drop Your File ( like it's hot )
  • </my-file-drop>
  • `
  • })
  • export class AppComponent {
  •  
  • public fileContent: string;
  •  
  • // I initialize the app component.
  • constructor() {
  •  
  • this.fileContent = "";
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called whenever change detection is triggered on this component.
  • // --
  • // NOTE: We are logging this in order to make sure that the MASSIVE NUMBER of drag
  • // and drop events are not triggering unnecessary change-detection in the app.
  • public ngDoCheck() : void {
  •  
  • console.log( "App component ngDoCheck()" );
  •  
  • }
  •  
  •  
  • // When the user drops a text-file on the file-drop component, it will emit the
  • // file-content as an event. I render the emitted file-content to the view.
  • public renderFileContent( value: string ) : void {
  •  
  • this.fileContent = value;
  •  
  • }
  •  
  • }

As you may have noticed, the App component doesn't listen for any drag-and-drop related events. Instead, it is deferring to the FileDropComponent to listen for all drag-related events. This includes the drag event on the global Window object. Essentially, the FileDropComponent is always present; and, knows how to show and hide itself in response to the user's interactions.

This may seem like a strange separation of concerns. After all, shouldn't the rendering context - the App component - know when to show and hide the drop zone? Theoretically, yes. But, since the drag-and-drop event workflow is so challenging [for me], I found it easier to coordinate events if I kept all the logic in one place. And, of course, this is just for the constraints of this particular demo.

With that said, let's look at the FileDropComponent. The view-template for this component is extremely simple, containing little more than the "drag shield"; but, the code-behind for this component is complex.

The FileDropComponent shows and hides itself based on an "isActive" property. This "isActive" property controls some simple CSS that toggles between "display:none" and "display:flex". By using "display:none," as opposed to something like [ngIf], it allows the FileDropComponent to be present in the App at all times.

The general event workflow for the FileDropComponent is as follows:

  • Bind drag events to the window.
  • If the user drags a file into the window, immediately unbind the drag events from the window and bind new drag events to the "drag shield".
  • Render the drag shield over the entire browser viewport so that it captures all subsequent drag events.
  • If the user drags the file out of the drag shield (which covers the entire browser viewport), immediately unbind the drag events from the drag shield and bind new drag events to the window.

As the user drags the file over the application, the "drag" event fires continuously. On its own, this wouldn't be an issue. But, since Angular automatically triggers change-detection digests on bound events, we have to be careful to bind our event-handlers outside of the Angular Zone. This way, the "drag" event can fire-off without much processing overhead.

Once our first event-handler is bound outside of the Angular Zone, any subsequent event-handlers that are bound in response to said event are also bound outside of the Angular Zone. This is why you will see only one instance of the .runOutsideAngular() method being called, on the first event-binding. After that, we only ever need to dip back into the Angular Zone when we trigger a change in the view-model.

Like I said, there's a lot of code here. So I wasn't sure how to walk through this code incrementally. But, I've tried to add a lot of comments in order to make it easier to understand. Just start at the ngOnInit() method and explore from there:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  • import { ElementRef } from "@angular/core";
  • import { EventEmitter } from "@angular/core";
  • import { NgZone } from "@angular/core";
  • import { ViewChild } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-file-drop",
  • outputs: [ "textDropEvents: textDrop" ],
  • host: {
  • "[class.active]": "isActive"
  • },
  • queries: {
  • "dragShieldRef": new ViewChild( "dragShieldRef" )
  • },
  • styleUrls: [ "./file-drop.component.less" ],
  • template:
  • `
  • <div #dragShieldRef class="drag-shield">
  • <!--
  • Drag-and-Drop events are notoriously hard to work with. So, instead of
  • trying to be clever, I'm just going to side-step the whole issue by
  • creating a "shield" that sits above (zIndex) the rest of the elements
  • and has no children. This way the "leave" event is easy to reason about
  • since it doesn't have to deal with bubbling-up events.
  • -->
  • </div>
  •  
  • <span class="call-to-action">
  • <ng-content></ng-content>
  • </span>
  • `
  • })
  • export class FileDropComponent {
  •  
  • public isActive: boolean;
  • public dragShieldRef: ElementRef;
  • public textDropEvents: EventEmitter<string>;
  •  
  • private zone: NgZone;
  •  
  • // I initialize the file-drop component.
  • constructor(
  • dragShieldRef: ElementRef,
  • zone: NgZone
  • ) {
  •  
  • this.dragShieldRef = dragShieldRef;
  • this.zone = zone;
  •  
  • this.isActive = false;
  • this.textDropEvents = new EventEmitter();
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I handle the user dragging away from the drop zone / window.
  • public handleShieldDragLeave = ( event: DragEvent ) : void => {
  •  
  • // When the user leaves the drop zone, it means they have left the WINDOW as
  • // well. As such, let's close the drop zone and start listening for initiating
  • // drag events on the window again.
  • // --
  • // NOTE: We are still outside of the Angular Zone.
  • this.teardownDragShieldEvents();
  • this.setupWindowEvents();
  •  
  • this.zone.run(
  • () => {
  •  
  • this.isActive = false;
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I handle the user dragging over the drop zone.
  • public handleShieldDragOver = ( event: DragEvent ) : void => {
  •  
  • // NOTE: This event-handler exists purely to call the .preventDefault() method.
  • // Without this call, the browser will attempt to open any file that is dropped
  • // on the target.
  • event.preventDefault();
  •  
  • }
  •  
  •  
  • // I handle the user dropping a file on the drop zone.
  • public handleShieldDrop = ( event: DragEvent ) : void => {
  •  
  • // Stop the browser from attempting to open the file.
  • event.preventDefault();
  •  
  • // After the user drops her file, we are going to close the drop zone. As such,
  • // we need to start listening for initiating drag events on the window again.
  • // --
  • // NOTE: We are still outside of the Angular Zone.
  • this.teardownDragShieldEvents();
  • this.setupWindowEvents();
  •  
  • this.zone.run(
  • () => {
  •  
  • this.isActive = false;
  • this.emitDroppedFileContent( event );
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I handle the user dragging a file onto the window (ie, the initiating drag event).
  • public handleWindowDragOver = ( event: DragEvent ) : void => {
  •  
  • // If the event doesn't contain text files, then it isn't a drag event that we
  • // are concerned with. Ignore it.
  • // --
  • // CAUTION: I'm only doing this for the sake of the exploration. Since this isn't
  • // something that works well in a CROSS BROWSER way (not all drag events have
  • // access to the attached files), we should probably just always show the drop
  • // zone and then validate them on drop. But, I'm just trying to learn more about
  • // how files work.
  • if ( ! this.shouldRespondToDragEvent( event ) ) {
  •  
  • return;
  •  
  • }
  •  
  • // NOTE: We don't need to cancel this default behavior because the WINDOW is not
  • // the actual drop target - we will cancel this later on in the drop zone.
  • // --
  • // event.preventDefault();
  •  
  • // When the user drags a file over the window, we will activate the drop zone.
  • // This means we can stop listening for window events and start listening for
  • // drop zone events.
  • // --
  • // NOTE: We are still outside of the Angular Zone.
  • this.teardownWindowEvents();
  • this.setupDragShieldEvents();
  •  
  • this.zone.run(
  • () => {
  •  
  • this.isActive = true;
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I get called once when the component is being destroyed.
  • public ngDestroy() : void {
  •  
  • this.teardownDragShieldEvents();
  • this.teardownWindowEvents();
  •  
  • // CAUTION: I BELIEVE there is a possibility that a FileReader operation is still
  • // running as this component is being destroyed. Handling this is a bit outside
  • // the scope of this demo; but, if we stored a reference to the FileReader, we
  • // could do something like this:
  • // --
  • // ( this.fileReader ) && this.fileReader.abort();
  •  
  • }
  •  
  •  
  • // I get called once after the inputs have been bound for the first time.
  • public ngOnInit() : void {
  •  
  • this.setupWindowEvents();
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I attempt to read the file in the given drop event and emit its content.
  • private emitDroppedFileContent( event: DragEvent ) : void {
  •  
  • var file = this.getFileFromDropEvent( event );
  •  
  • if ( ! file ) {
  •  
  • return;
  •  
  • }
  •  
  • // NOTE: Since Safari and IE can't access the file prior to the DROP event, we
  • // have to do one final check of the mime-type before we attempt to read-in the
  • // content.
  • if ( ! this.isTextMimeType( file.type ) ) {
  •  
  • return;
  •  
  • }
  •  
  • var reader = new FileReader();
  •  
  • // NOTE: At this point (see "drop" event handler), we are BACK INSIDE the Angular
  • // Zone. As such, the following event-handlers will automatically be invokes
  • // inside the Angular Zone as well.
  • reader.onload = () => {
  •  
  • this.textDropEvents.emit( reader.result as string );
  •  
  • };
  • reader.onerror = ( error ) => {
  •  
  • console.warn( "Error reading dropped file:" );
  • console.error( error );
  •  
  • };
  • reader.onloadend = () => {
  •  
  • reader = null;
  •  
  • };
  •  
  • reader.readAsText( file );
  •  
  • }
  •  
  •  
  • // I attempt to get the first File in the given drag event. Returns null if a file
  • // cannot be found.
  • private getFileFromDropEvent( event: DragEvent ) : File | null {
  •  
  • // "Items" is the most modern interface spec.
  • if ( event.dataTransfer.items && event.dataTransfer.items.length ) {
  •  
  • for ( var item of Array.from( event.dataTransfer.items ) ) {
  •  
  • if ( item.kind === "file" ) {
  •  
  • return( item.getAsFile() );
  •  
  • }
  •  
  • }
  •  
  • // If the "items" interface was defined, we don't want to fall-back to
  • // checking the "files" interface. The "files" interface is legacy and
  • // should only be consulted if "items" is unavailable.
  • return( null );
  •  
  • }
  •  
  • // "Files" is an old interface spec.
  • if ( event.dataTransfer.files && event.dataTransfer.files.length ) {
  •  
  • return( event.dataTransfer.files[ 0 ] );
  •  
  • }
  •  
  • return( null );
  •  
  • }
  •  
  •  
  • // I attempt to get the mime-type of the first file in drag event. Returns null if no
  • // file types can be identified.
  • // --
  • // CAUTION: Not all browsers can access file type meta-data during a DRAG event.
  • private getFileTypeFromDragEvent( event: DragEvent ) : string | null {
  •  
  • // "Items" is the most modern interface spec.
  • if ( event.dataTransfer.items && event.dataTransfer.items.length ) {
  •  
  • for ( var item of Array.from( event.dataTransfer.items ) ) {
  •  
  • if ( item.kind === "file" ) {
  •  
  • // CAUTION: DataTransferItem.getAsFile() returns "null" if this is a
  • // "drag" event. I BELIEVE it only returns the file in the "drop"
  • // event. As such, we have to use the "type" on the item itself.
  • return( item.type );
  •  
  • }
  •  
  • }
  •  
  • // If the "items" interface was defined, we don't want to fall-back to
  • // checking the "files" interface. The "files" interface is legacy and
  • // should only be consulted if "items" is unavailable.
  • return( null );
  •  
  • }
  •  
  • // "Files" is an old interface spec.
  • if ( event.dataTransfer.files && event.dataTransfer.files.length ) {
  •  
  • return( event.dataTransfer.files[ 0 ].type );
  •  
  • }
  •  
  • return( null );
  •  
  • }
  •  
  •  
  • // I determine if the given mime-type represents a file that contains text content
  • // that we can [likely] load into the application.
  • private isTextMimeType( mimeType: string ) : boolean {
  •  
  • if ( mimeType.startsWith( "text/" ) ) {
  •  
  • return( true );
  •  
  • }
  •  
  • switch ( mimeType ) {
  • case "application/json":
  • case "application/x-json":
  • return( true );
  • break
  • default:
  • return( false );
  • break;
  • }
  •  
  • }
  •  
  •  
  • // I attach the handlers for the drop zone events.
  • private setupDragShieldEvents() : void {
  •  
  • // NOTE: We are still outside of the Angular Zone.
  • this.dragShieldRef.nativeElement.addEventListener( "dragover", this.handleShieldDragOver, false );
  • this.dragShieldRef.nativeElement.addEventListener( "dragleave", this.handleShieldDragLeave, false );
  • this.dragShieldRef.nativeElement.addEventListener( "drop", this.handleShieldDrop, false );
  •  
  • }
  •  
  •  
  • // I attach the handlers for the initiating drag event on the window.
  • private setupWindowEvents() : void {
  •  
  • // NOTE: Since we are attaching the initiating event-handler OUTSIDE of the
  • // Angular Zone, it means that any event-handlers that we bind in response to the
  • // following event will ALSO be bound OUTSIDE of the Angular Zone. This is why we
  • // only need to use this approach once.
  • this.zone.runOutsideAngular(
  • () => {
  •  
  • window.addEventListener( "dragover", this.handleWindowDragOver, false );
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I determine if the application should respond to the given initiating drag event.
  • // --
  • // NOTE: In reality, this can't be done effectively in a cross-browser way since not
  • // all browsers have access to the File objects, mid-drag. As such, in a real app, I
  • // would always response to the drag event and then validate the file on drop.
  • private shouldRespondToDragEvent( event: DragEvent ) : boolean {
  •  
  • var fileType = this.getFileTypeFromDragEvent( event );
  •  
  • // CAUTION: Since not all browsers (ex, Safari and Internet Explorer) can access
  • // the file meta-data during the DRAG operation, we have to assume that if the
  • // DRAG event doesn't contain any accessible items or files, it may still have
  • // items in the subsequent DROP event.
  • if ( ! fileType ) {
  •  
  • return( true );
  •  
  • }
  •  
  • return( this.isTextMimeType( fileType ) );
  •  
  • }
  •  
  •  
  • // I detach the handlers for the drop zone events.
  • private teardownDragShieldEvents() : void {
  •  
  • this.dragShieldRef.nativeElement.removeEventListener( "dragover", this.handleShieldDragOver, false );
  • this.dragShieldRef.nativeElement.removeEventListener( "dragleave", this.handleShieldDragLeave, false );
  • this.dragShieldRef.nativeElement.removeEventListener( "drop", this.handleShieldDrop, false );
  •  
  • }
  •  
  •  
  • // I detach the handlers for the initiating window drag events.
  • private teardownWindowEvents() : void {
  •  
  • window.removeEventListener( "dragover", this.handleWindowDragOver, false );
  •  
  • }
  •  
  • }

For the sake of the demo (and a better understanding the event APIs), I attempt to only respond to Text Files during the initial drag operation. This only works, however, in Chrome and Firefox. As such, in Safari and IE, I have to show the drop zone for all drag events. In reality, this latter approach is probably better. Not only is it more cross-browser compliant; but, it gives you a chance to handle "invalid files" within the drop logic rather than simply ignoring them (and potentially letting the browser open the dropped file).

If we run this Angular application in the browser and then drag-and-drop a Text File, we get the following output:


 
 
 

 
 Loading text file content with FileReader when the user drags-and-drops a file onto an Angular 7.2.12 application. 
 
 
 

As you can see (from the pink border), the FileDropComponent's "drag shield" covers the entire browser viewport. This allows it to be the one-and-only DOM node in the entire DOM tree that responds to drag events during the drag operation. This greatly simplifies the complexity of the code which, as you can see, is already fairly complex.

As a final note, I should say that I am fairly new to the File APIs and to the drag-and-drop events. As such, this was more of a "code kata" for me than it was a definitive guide on how to handle these situations. I hope I didn't get too much wrong in my explanation. If nothing else, maybe I've shed some light on possible file-handling workflows in Angular 7.2.12.



Reader Comments

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.