This post was initially published on Aurelia’s official blog.
In my book Learning Aurelia, you can see, among other things, how to build an image file picker component, supporting drag and drop and showing a preview of the selected image.
In this post, we’ll use the techniques described in the book to build a multi-select image file picker, also supporting drag and drop, with a gallery-style preview feature.
Let’s start by creating a custom file-picker
element, which will encapsulate
an <input type="file">
element:
resources/elements/file-picker.ts
:
import {customElement, useView, bindable, bindingMode} from 'aurelia-framework';
@customElement('file-picker')
@useView('./file-picker.html')
export class FilePicker {
@bindable accept = '';
@bindable multiple = false;
@bindable({ defaultBindingMode: bindingMode.twoWay }) files: FileList;
input: HTMLInputElement;
filesChanged() {
if (!this.files) {
this.clearSelection();
}
}
private clearSelection() {
this.input.type = '';
this.input.type = 'file';
}
}
This view-model declares three bindable properties:
accept
: will be bound to the input
’s accept
attribute, which is used
to limit the type of files the browser’s dialog will show to the user.multiple
: will be bound to the input
’s multiple
attribute, which
tells the browser’s dialog if it should support selection of multiple files
or not.files
: will be bound to the input
’s files
attribute. This property is
bound two way by default, so the file(s) selected by the user are assigned
back to the bound property.The view-model also declares an input
property, to which the template will
assign a reference on the <input type="file">
element.
Lastly, since the input
’s files
property is read-only and the DOM API
doesn’t expose a method to clear the input
’s file selection (other than
calling the reset
method on the whole surrounding form
), the view-model
uses a hack to clear the selected files when an empty value is assigned
to the file-picker
’s files
property: it sets the input
’s type
to
an empty string then resets it back to file
.
resources/elements/file-picker.html
:
<template>
<input type="file" accept.bind="accept" multiple.bind="multiple"
files.bind="files" ref="input"
style="visibility: hidden; width: 0; height: 0;">
<button class="btn btn-primary" click.delegate="input.click()">
<slot>Select</slot>
</button>
</template>
The file-picker
’s template defines an <input type="file">
element,
styled so it is invisible and so it occupies no space in the DOM.
Its accept
, multiple
, and files
attributes are also properly
bound to their corresponding property on the view-model. Lastly, it
assigns a reference on the input
to the view-model’s input
property.
The template also declares a button
element, styled using Bootstrap’s
classes. Inside it, a default content projection slot, with the
Select text as its default content. Additionally, the button
’s
click
event calls the input
’s click
method. Thanks to this,
the browser’s file dialog will show up when the user clicks the
button, even though the input
element is not visible.
This component basically just replaces the ugly native file picker with a sexier button.
Next, let’s create a custom attribute allowing to transform any element into a file drag and drop target:
resources/attributes/file-drop-target.ts
:
import {customAttribute, bindingMode, autoinject} from 'aurelia-framework';
@customAttribute('file-drop-target', bindingMode.twoWay)
@autoinject
export class FileDropTarget {
value: FileList | (({files: FileList}) => void);
constructor(private element: Element) {}
attached() {
this.element.addEventListener('dragover', this.onDragOver);
this.element.addEventListener('drop', this.onDrop);
this.element.addEventListener('dragend', this.onDragEnd);
}
private onDragOver = (e) => {
e.stopPropagation();
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
};
private onDrop = (e) => {
e.stopPropagation();
e.preventDefault();
if (typeof this.value === 'function') {
this.value({ files: e.dataTransfer.files });
} else {
this.value = e.dataTransfer.files;
}
};
private onDragEnd = (e) => {
e.stopPropagation();
e.preventDefault();
e.dataTransfer.clearData();
};
detached() {
this.element.removeEventListener('dragend', this.onDragEnd);
this.element.removeEventListener('drop', this.onDrop);
this.element.removeEventListener('dragover', this.onDragOver);
}
}
The attribute’s target element will be injected in the view-model’s
constructor. When the attribute is attached
to the DOM, it starts
listening for the dragover
, drop
, and dragend
events on the
target element. When the attribute is detached
from the DOM, the
event listeners are removed.
The attribute is bound two way by default, so the file(s) assigned
to its value
when a user drops them on the target element are
assigned back to the bounded property, if any. However, upon files
being dropped on the target element, the view-model checks if the
value
is a function or not. This means that the attribute can be
used either with the .bind
command, so the dropped files are
assigned to the bound expression, or with the .call
command, so
the bound expression is called and passed the dropped files whenever
a drop
event occurs.
In order to display the selected images as a gallery, we’ll use Bootstrap’s grid system. This means we’ll need to break the files array down in chunks, so we can iterate on chunks to render rows, then on each chunk’s files to render columns.
The best way to do this in Aurelia is with a value converter:
resources/value-converters/chunk.ts
:
import {valueConverter} from 'aurelia-framework';
@valueConverter('chunk')
export class Chunk {
toView(array: any[], size: number): any[][] {
let result = [];
let nbChunks = Math.ceil(array.length / size);
for (let i = 0; i < nbChunks; ++i) {
const offset = i * size;
result.push(array.slice(offset, offset + size));
}
return result;
}
}
The chunk
value converter expects an array and the chunks’ size
as its parameter and returns an array of array.
The last part we’ll need is some way to display a File
instance
inside an img
element. To do this, we’ll leverage the browser’s
URL.createObjectURL
function, which takes a Blob
object as a
parameter and returns a special URL leading to this resource. Our
custom attribute, which will be used essentially on img
elements,
will be bound to a Blob
object, will generate an object URL from it,
and will assign this URL to the img
element’s src
attribute.
Some of you might think that a value converter would be a better fit for
this type of feature, and I would absolutely agree. A value converter
could take as an input a Blob
object and return the object URL. It
could then be used on a binding between an img
element’s src
attribute and a property containing a Blob
object.
However, in this particular case, each object URL must be released after usage in order to prevent memory leaks, and value converters offer no mechanism to be notified when a value is no longer used. On the contrary, HTML behaviors offer a much richer workflow and a wider set of extension points. That’s why we will create a custom attribute instead:
resources/attributes/blob-src.ts
:
import {customAttribute, inject} from 'aurelia-framework';
@customAttribute('blob-src')
@inject(Element)
export class BlobSrc {
private objectUrl: string;
constructor(private element: HTMLImageElement) {}
private disposeObjectUrl() {
if (this.objectUrl) {
this.element.src = '';
URL.revokeObjectURL(this.objectUrl);
this.objectUrl = null;
}
}
valueChanged(value) {
this.disposeObjectUrl();
if (value instanceof Blob) {
this.objectUrl = URL.createObjectURL(value);
this.element.src = this.objectUrl;
}
}
unbind() {
this.disposeObjectUrl();
}
}
Each of the parts we saw up to this point is shown in the book, even though some have been modified to fit the current context.
The last missing piece is the one that brings everything together:
an image-files-picker
custom element.
resources/elements/image-files-picker.html
:
<template>
<div class="jumbotron jumbotron-fluid" file-drop-target.call="add(files)">
<div class="container">
<div class="text-center">
<p>You can drop image files anywhere inside this area</p>
</div>
<div class="row" repeat.for="row of files | chunk:3">
<div class="col-md-4" repeat.for="file of row">
<div class="card card-inverse">
<img class="card-img img-fluid"
alt="Preview for ${file.name & oneTime}"
blob-src.one-time="file">
<div class="card-img-overlay">
<button type="button" class="close" aria-label="Remove"
click.delegate="remove($parent.$index * 3 + $index)">
<span aria-hidden="true">×</span>
</button>
<p class="card-text">
<small class="text-muted">${file.name & oneTime}</small>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<file-picker accept.bind="accept" multiple.one-time="true"
files.bind="selectedFiles"
change.delegate="addSelectedFiles()">
Add
</file-picker>
</template>
The template starts with a jumbotron
container, which acts as a
file-drop-target
. When files are dragged and dropped on this element,
the view-model’s add
method will be called and passed the dropped
files
.
Inside this container, the files
array is rendered on three columns
using the chunk
value converter, each file displayed inside a Bootstrap
card
component. Each card
displays the file in an img
element
using the blob-src
attribute, a button
whose click
event calls the
view-model’s remove
method, and the file’s name
.
Lastly, underneath the image gallery, a file-picker
element allows
the user to select image files. The selected files are bound to the
view-model’s selectedFiles
property, then the change
event
dispatched by the underlying <input type="file">
element and bubbling
up the DOM triggers a call to the addSelectedFiles
method. The
file-picker
’s default projection slot is also overwritten with the text
Add.
resources/elements/image-files-picker.ts
:
import {customElement, useView, bindable, bindingMode} from 'aurelia-framework';
@customElement('image-files-picker')
@useView('./image-files-picker.html')
export class ImageFilesPicker {
@bindable({ defaultBindingMode: bindingMode.twoWay }) files: File[] = [];
selectedFiles: FileList;
add(files: FileList) {
for (let i = 0; i < files.length; ++i) {
const file = files.item(i);
this.files.push(file);
}
}
remove(index) {
this.files.splice(index, 1);
}
addSelectedFiles() {
this.add(this.selectedFiles);
this.selectedFiles = null;
}
}
The view-model declares a files
bindable property, which is bound
two way by default. This property is expected to initially contain
an empty array.
When files are dropped on the drop target element, the add
method
is called and the dropped files
are appended to the files
property.
When the user selects files using the file-picker
, the selected files
are assigned back to the selectedFiles
property, then the change
event handler calls the addSelectedFiles
, which appends the
selectedFiles
to the files
property, and finally assigns null
to
the selectedFiles
.
This last step makes sure that the underlying <input type="file">
element has its selection cleared. Without it, if a user tries to add
the same file twice in a row, the change
event would not be triggered
the second time, because the input
’s value would not change, so the
second file selection would fail from the user’s perspective.
Using the image-files-picker
element is then pretty simple. We first
need to declare a property hosting the array of files on the App
view-model:
app.ts
:
export class App {
files: File[] = [];
}
Next, we simply need to add the custom element in the template of
our App
component:
app.html
:
<template>
<require from="bootstrap/css/bootstrap.min.css"></require>
<section class="container">
<image-files-picker files.bind="files"></image-files-picker>
</section>
</template>
Of course, the various parts need to be loaded, either using
the require
statement in the app.html
template, or in the
resources/index.ts
feature’s configure
function.
At this point, a user can select or drop any type of files using our component. Some logic allowing only image files should be somehow added.
A basic filtering logic, using the same syntax as the <input type="file">
element’s accept
attribute, is implemented in the complete code sample,
which you can find
here.
A more complete solution, showing error messages to the user, can easily be
implemented. I’ll leave this as an exercise to the reader.
Typically, such a component would be used to first select a bunch of image files,
then to upload those files to some remote endpoint. This is pretty easy to do with
Aurelia’s Fetch client and the FormData
class from the Fetch API.
Here’s an example of a client service used to upload an array of File
instances
to some remote endpoint:
import {autoinject} from 'aurelia-framework';
import {HttpClient} from 'aurelia-fetch-client';
@autoinject
export class SomeAPI {
constructor(private http: HttpClient) {}
uploadFiles(files: File[]): Promise<void> {
const body = new FormData();
for (let i = 0; i < files.length; ++i) {
body.append(`files[${i}]`, files[i]);
}
return this.http.fetch('some/url', { method: 'POST', body });
}
}
The Mozilla Developer Network has
some great doc
about the FormData
class.
Once again, Aurelia makes things easy. Its various constructs, such as custom attributes, elements, and value converters, help us decompose a problem and solve each of its parts with a generic, reusable solution, and then recombine them together to address our initial, specific problem. Shameless plug alert: this aspect is one of the many topics addressed in Learning Aurelia. You should definitely give it a look!
Reader @sokratismanolis pointed out in this comment that the code
doesn’t work on IE 11 and Edge. I didn’t have time to find out the core of the issue yet, but
it seems to be caused by a delegated event listener being fired before a data binding instruction
is refreshed. I worked around the issue by removing the delegated event listener and by using
the @observable
attribute on selectedFiles
, as you can see on
this branch.
Comments
comments powered by Disqus