aboutsummaryrefslogtreecommitdiffstats
path: root/app/javascript
diff options
context:
space:
mode:
authorJavan Makhmali <javan@javan.us>2017-07-27 19:47:03 -0400
committerDavid Heinemeier Hansson <david@loudthinking.com>2017-07-27 18:47:03 -0500
commit6262891b5669716db7c46dcdcec685d2f55903b5 (patch)
tree51528471efe698fe0e66d0f0359a5d5eaefab995 /app/javascript
parentdc235c4d1366b4f3185d30a8a4e35ad12173fd89 (diff)
downloadrails-6262891b5669716db7c46dcdcec685d2f55903b5.tar.gz
rails-6262891b5669716db7c46dcdcec685d2f55903b5.tar.bz2
rails-6262891b5669716db7c46dcdcec685d2f55903b5.zip
Add JavaScript direct upload support (#81)
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/activestorage/blob_record.js52
-rw-r--r--app/javascript/activestorage/blob_upload.js31
-rw-r--r--app/javascript/activestorage/direct_upload.js43
-rw-r--r--app/javascript/activestorage/direct_upload_controller.js67
-rw-r--r--app/javascript/activestorage/direct_uploads_controller.js50
-rw-r--r--app/javascript/activestorage/file_checksum.js48
-rw-r--r--app/javascript/activestorage/helpers.js42
-rw-r--r--app/javascript/activestorage/index.js11
-rw-r--r--app/javascript/activestorage/ujs.js74
9 files changed, 418 insertions, 0 deletions
diff --git a/app/javascript/activestorage/blob_record.js b/app/javascript/activestorage/blob_record.js
new file mode 100644
index 0000000000..9b7801afd5
--- /dev/null
+++ b/app/javascript/activestorage/blob_record.js
@@ -0,0 +1,52 @@
+import { getMetaValue } from "./helpers"
+
+export class BlobRecord {
+ constructor(file, checksum, url) {
+ this.file = file
+
+ this.attributes = {
+ filename: file.name,
+ content_type: file.type,
+ byte_size: file.size,
+ checksum: checksum
+ }
+
+ this.xhr = new XMLHttpRequest
+ this.xhr.open("POST", url, true)
+ this.xhr.responseType = "json"
+ this.xhr.setRequestHeader("Content-Type", "application/json")
+ this.xhr.setRequestHeader("Accept", "application/json")
+ this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
+ this.xhr.setRequestHeader("X-CSRF-Token", getMetaValue("csrf-token"))
+ this.xhr.addEventListener("load", event => this.requestDidLoad(event))
+ this.xhr.addEventListener("error", event => this.requestDidError(event))
+ }
+
+ create(callback) {
+ this.callback = callback
+ this.xhr.send(JSON.stringify({ blob: this.attributes }))
+ }
+
+ requestDidLoad(event) {
+ const { status, response } = this.xhr
+ if (status >= 200 && status < 300) {
+ this.attributes.signed_id = response.signed_blob_id
+ this.uploadURL = response.upload_to_url
+ this.callback(null, this.toJSON())
+ } else {
+ this.requestDidError(event)
+ }
+ }
+
+ requestDidError(event) {
+ this.callback(`Error creating Blob for "${this.file.name}". Status: ${this.xhr.status}`)
+ }
+
+ toJSON() {
+ const result = {}
+ for (const key in this.attributes) {
+ result[key] = this.attributes[key]
+ }
+ return result
+ }
+}
diff --git a/app/javascript/activestorage/blob_upload.js b/app/javascript/activestorage/blob_upload.js
new file mode 100644
index 0000000000..8c1335c56c
--- /dev/null
+++ b/app/javascript/activestorage/blob_upload.js
@@ -0,0 +1,31 @@
+export class BlobUpload {
+ constructor(blob) {
+ this.blob = blob
+ this.file = blob.file
+
+ this.xhr = new XMLHttpRequest
+ this.xhr.open("PUT", blob.uploadURL, true)
+ this.xhr.setRequestHeader("Content-Type", blob.attributes.content_type)
+ this.xhr.setRequestHeader("Content-MD5", blob.attributes.checksum)
+ this.xhr.addEventListener("load", event => this.requestDidLoad(event))
+ this.xhr.addEventListener("error", event => this.requestDidError(event))
+ }
+
+ create(callback) {
+ this.callback = callback
+ this.xhr.send(this.file)
+ }
+
+ requestDidLoad(event) {
+ const { status, response } = this.xhr
+ if (status >= 200 && status < 300) {
+ this.callback(null, this.xhr.response)
+ } else {
+ this.requestDidError(event)
+ }
+ }
+
+ requestDidError(event) {
+ this.callback(`Error storing "${this.file.name}". Status: ${this.xhr.status}`)
+ }
+}
diff --git a/app/javascript/activestorage/direct_upload.js b/app/javascript/activestorage/direct_upload.js
new file mode 100644
index 0000000000..7bbe4e0fdd
--- /dev/null
+++ b/app/javascript/activestorage/direct_upload.js
@@ -0,0 +1,43 @@
+import { FileChecksum } from "./file_checksum"
+import { BlobRecord } from "./blob_record"
+import { BlobUpload } from "./blob_upload"
+
+let id = 0
+
+export class DirectUpload {
+ constructor(file, options = {}) {
+ this.id = id++
+ this.file = file
+ this.url = options.url
+ this.delegate = options.delegate
+ }
+
+ create(callback) {
+ const fileChecksum = new FileChecksum(this.file)
+ fileChecksum.create((error, checksum) => {
+ const blob = new BlobRecord(this.file, checksum, this.url)
+ notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr)
+ blob.create(error => {
+ if (error) {
+ callback(error)
+ } else {
+ const upload = new BlobUpload(blob)
+ notify(this.delegate, "directUploadWillStoreFileWithXHR", upload.xhr)
+ upload.create(error => {
+ if (error) {
+ callback(error)
+ } else {
+ callback(null, blob.toJSON())
+ }
+ })
+ }
+ })
+ })
+ }
+}
+
+function notify(object, methodName, ...messages) {
+ if (object && typeof object[methodName] == "function") {
+ return object[methodName](...messages)
+ }
+}
diff --git a/app/javascript/activestorage/direct_upload_controller.js b/app/javascript/activestorage/direct_upload_controller.js
new file mode 100644
index 0000000000..a5541c81be
--- /dev/null
+++ b/app/javascript/activestorage/direct_upload_controller.js
@@ -0,0 +1,67 @@
+import { DirectUpload } from "./direct_upload"
+import { dispatchEvent } from "./helpers"
+
+export class DirectUploadController {
+ constructor(input, file) {
+ this.input = input
+ this.file = file
+ this.directUpload = new DirectUpload(this.file, { url: this.url, delegate: this })
+ this.dispatch("initialize")
+ }
+
+ start(callback) {
+ const hiddenInput = document.createElement("input")
+ hiddenInput.type = "hidden"
+ hiddenInput.name = this.input.name
+ this.input.insertAdjacentElement("beforebegin", hiddenInput)
+
+ this.dispatch("start")
+
+ this.directUpload.create((error, attributes) => {
+ if (error) {
+ hiddenInput.parentNode.removeChild(hiddenInput)
+ this.dispatchError(error)
+ } else {
+ hiddenInput.value = attributes.signed_id
+ }
+
+ this.dispatch("end")
+ callback(error)
+ })
+ }
+
+ uploadRequestDidProgress(event) {
+ const progress = event.loaded / event.total * 100
+ if (progress) {
+ this.dispatch("progress", { progress })
+ }
+ }
+
+ get url() {
+ return this.input.getAttribute("data-direct-upload-url")
+ }
+
+ dispatch(name, detail = {}) {
+ detail.file = this.file
+ detail.id = this.directUpload.id
+ return dispatchEvent(this.input, `direct-upload:${name}`, { detail })
+ }
+
+ dispatchError(error) {
+ const event = this.dispatch("error", { error })
+ if (!event.defaultPrevented) {
+ alert(error)
+ }
+ }
+
+ // DirectUpload delegate
+
+ directUploadWillCreateBlobWithXHR(xhr) {
+ this.dispatch("before-blob-request", { xhr })
+ }
+
+ directUploadWillStoreFileWithXHR(xhr) {
+ this.dispatch("before-storage-request", { xhr })
+ xhr.upload.addEventListener("progress", event => this.uploadRequestDidProgress(event))
+ }
+}
diff --git a/app/javascript/activestorage/direct_uploads_controller.js b/app/javascript/activestorage/direct_uploads_controller.js
new file mode 100644
index 0000000000..94b89c9119
--- /dev/null
+++ b/app/javascript/activestorage/direct_uploads_controller.js
@@ -0,0 +1,50 @@
+import { DirectUploadController } from "./direct_upload_controller"
+import { findElements, dispatchEvent, toArray } from "./helpers"
+
+const inputSelector = "input[type=file][data-direct-upload-url]:not([disabled])"
+
+export class DirectUploadsController {
+ constructor(form) {
+ this.form = form
+ this.inputs = findElements(form, inputSelector).filter(input => input.files.length)
+ }
+
+ start(callback) {
+ const controllers = this.createDirectUploadControllers()
+
+ const startNextController = () => {
+ const controller = controllers.shift()
+ if (controller) {
+ controller.start(error => {
+ if (error) {
+ callback(error)
+ this.dispatch("end")
+ } else {
+ startNextController()
+ }
+ })
+ } else {
+ callback()
+ this.dispatch("end")
+ }
+ }
+
+ this.dispatch("start")
+ startNextController()
+ }
+
+ createDirectUploadControllers() {
+ const controllers = []
+ this.inputs.forEach(input => {
+ toArray(input.files).forEach(file => {
+ const controller = new DirectUploadController(input, file)
+ controllers.push(controller)
+ })
+ })
+ return controllers
+ }
+
+ dispatch(name, detail = {}) {
+ return dispatchEvent(this.form, `direct-uploads:${name}`, { detail })
+ }
+}
diff --git a/app/javascript/activestorage/file_checksum.js b/app/javascript/activestorage/file_checksum.js
new file mode 100644
index 0000000000..d7a10b3e55
--- /dev/null
+++ b/app/javascript/activestorage/file_checksum.js
@@ -0,0 +1,48 @@
+import SparkMD5 from "spark-md5"
+
+const fileSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
+
+export class FileChecksum {
+ constructor(file) {
+ this.file = file
+ this.chunkSize = 2097152 // 2MB
+ this.chunkCount = Math.ceil(this.file.size / this.chunkSize)
+ this.chunkIndex = 0
+ }
+
+ create(callback) {
+ this.callback = callback
+ this.md5Buffer = new SparkMD5.ArrayBuffer
+ this.fileReader = new FileReader
+ this.fileReader.addEventListener("load", event => this.fileReaderDidLoad(event))
+ this.fileReader.addEventListener("error", event => this.fileReaderDidError(event))
+ this.readNextChunk()
+ }
+
+ fileReaderDidLoad(event) {
+ this.md5Buffer.append(event.target.result)
+
+ if (!this.readNextChunk()) {
+ const binaryDigest = this.md5Buffer.end(true)
+ const base64digest = btoa(binaryDigest)
+ this.callback(null, base64digest)
+ }
+ }
+
+ fileReaderDidError(event) {
+ this.callback(`Error reading ${this.file.name}`)
+ }
+
+ readNextChunk() {
+ if (this.chunkIndex < this.chunkCount) {
+ const start = this.chunkIndex * this.chunkSize
+ const end = Math.min(start + this.chunkSize, this.file.size)
+ const bytes = fileSlice.call(this.file, start, end)
+ this.fileReader.readAsArrayBuffer(bytes)
+ this.chunkIndex++
+ return true
+ } else {
+ return false
+ }
+ }
+}
diff --git a/app/javascript/activestorage/helpers.js b/app/javascript/activestorage/helpers.js
new file mode 100644
index 0000000000..52fec8f6f1
--- /dev/null
+++ b/app/javascript/activestorage/helpers.js
@@ -0,0 +1,42 @@
+export function getMetaValue(name) {
+ const element = findElement(document.head, `meta[name="${name}"]`)
+ if (element) {
+ return element.getAttribute("content")
+ }
+}
+
+export function findElements(root, selector) {
+ if (typeof root == "string") {
+ selector = root
+ root = document
+ }
+ const elements = root.querySelectorAll(selector)
+ return toArray(elements)
+}
+
+export function findElement(root, selector) {
+ if (typeof root == "string") {
+ selector = root
+ root = document
+ }
+ return root.querySelector(selector)
+}
+
+export function dispatchEvent(element, type, eventInit = {}) {
+ const { bubbles, cancelable, detail } = eventInit
+ const event = document.createEvent("Event")
+ event.initEvent(type, bubbles || true, cancelable || true)
+ event.detail = detail || {}
+ element.dispatchEvent(event)
+ return event
+}
+
+export function toArray(value) {
+ if (Array.isArray(value)) {
+ return value
+ } else if (Array.from) {
+ return Array.from(value)
+ } else {
+ return [].slice.call(value)
+ }
+}
diff --git a/app/javascript/activestorage/index.js b/app/javascript/activestorage/index.js
new file mode 100644
index 0000000000..a340008fb9
--- /dev/null
+++ b/app/javascript/activestorage/index.js
@@ -0,0 +1,11 @@
+import { start } from "./ujs"
+import { DirectUpload } from "./direct_upload"
+export { start, DirectUpload }
+
+function autostart() {
+ if (window.ActiveStorage) {
+ start()
+ }
+}
+
+setTimeout(autostart, 1)
diff --git a/app/javascript/activestorage/ujs.js b/app/javascript/activestorage/ujs.js
new file mode 100644
index 0000000000..a2ce2cfc58
--- /dev/null
+++ b/app/javascript/activestorage/ujs.js
@@ -0,0 +1,74 @@
+import { DirectUploadsController } from "./direct_uploads_controller"
+import { findElement } from "./helpers"
+
+const processingAttribute = "data-direct-uploads-processing"
+let started = false
+
+export function start() {
+ if (!started) {
+ started = true
+ document.addEventListener("submit", didSubmitForm)
+ document.addEventListener("ajax:before", didSubmitRemoteElement)
+ }
+}
+
+function didSubmitForm(event) {
+ handleFormSubmissionEvent(event)
+}
+
+function didSubmitRemoteElement(event) {
+ if (event.target.tagName == "FORM") {
+ handleFormSubmissionEvent(event)
+ }
+}
+
+function handleFormSubmissionEvent(event) {
+ const form = event.target
+
+ if (form.hasAttribute(processingAttribute)) {
+ event.preventDefault()
+ return
+ }
+
+ const controller = new DirectUploadsController(form)
+ const { inputs } = controller
+
+ if (inputs.length) {
+ event.preventDefault()
+ form.setAttribute(processingAttribute, "")
+ inputs.forEach(disable)
+ controller.start(error => {
+ form.removeAttribute(processingAttribute)
+ if (error) {
+ inputs.forEach(enable)
+ } else {
+ submitForm(form)
+ }
+ })
+ }
+}
+
+function submitForm(form) {
+ let button = findElement(form, "input[type=submit]")
+ if (button) {
+ const { disabled } = button
+ button.disabled = false
+ button.click()
+ button.disabled = disabled
+ } else {
+ button = document.createElement("input")
+ button.type = "submit"
+ button.style = "display:none"
+ form.appendChild(button)
+ button.click()
+ form.removeChild(button)
+ }
+}
+
+function disable(input) {
+ input.disabled = true
+}
+
+function enable(input) {
+ input.disabled = false
+}