active_storage_drag_and_drop 0.4.0 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.babelrc +3 -0
- data/.eslintrc.yml +16 -1
- data/.flowconfig +11 -0
- data/.gitignore +1 -0
- data/.travis.yml +6 -0
- data/Gemfile.lock +47 -47
- data/README.md +80 -30
- data/active_storage_drag_and_drop.gemspec +1 -1
- data/app/assets/javascripts/active_storage_drag_and_drop.js +1 -171
- data/app/assets/stylesheets/active_storage_drag_and_drop.css +88 -6
- data/app/javascript/active_storage_drag_and_drop/default_ui.js +77 -0
- data/app/javascript/active_storage_drag_and_drop/drag_and_drop_form_controller.js +87 -0
- data/app/javascript/active_storage_drag_and_drop/drag_and_drop_upload_controller.js +102 -0
- data/app/javascript/active_storage_drag_and_drop/helpers.js +10 -54
- data/app/javascript/active_storage_drag_and_drop/index.js +5 -2
- data/app/javascript/active_storage_drag_and_drop/ujs.js +88 -108
- data/demo.webp +0 -0
- data/lib/active_storage_drag_and_drop/form_builder.rb +17 -6
- data/lib/active_storage_drag_and_drop/version.rb +1 -1
- data/package.json +23 -7
- data/webpack.config.js +3 -1
- data/yarn.lock +3396 -1098
- metadata +10 -7
- data/app/assets/stylesheets/direct_uploads.css +0 -43
- data/app/javascript/active_storage_drag_and_drop/direct_upload_controller.js +0 -76
- data/app/javascript/active_storage_drag_and_drop/upload_queue_processor.js +0 -113
@@ -1,9 +1,91 @@
|
|
1
|
-
.asdndzone {
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
1
|
+
label.asdndzone * {
|
2
|
+
box-sizing: border-box;
|
3
|
+
}
|
4
|
+
|
5
|
+
label.asdndzone {
|
6
|
+
box-sizing: border-box;
|
7
|
+
display: block;
|
8
|
+
border: 3px solid #c8ce3e;
|
9
|
+
border-radius: 6px;
|
10
|
+
min-height: 220px;
|
11
|
+
max-width: 310px;
|
7
12
|
width: 100%;
|
13
|
+
padding: 99px 20px 20px;
|
14
|
+
text-align: center;
|
15
|
+
font-family: 'Nunito Sans', sans-serif;
|
16
|
+
font-size: 21px;
|
17
|
+
letter-spacing: 0.6px;
|
18
|
+
}
|
19
|
+
|
20
|
+
label.asdndzone .asdndz-highlight {
|
21
|
+
color: #8dc63f;
|
22
|
+
}
|
23
|
+
|
24
|
+
label.asdndzone .asdndz__icon-container {
|
25
|
+
text-align: left;
|
26
|
+
}
|
27
|
+
|
28
|
+
label.asdndzone .direct-upload {
|
29
|
+
display: inline-block;
|
30
|
+
position: relative;
|
31
|
+
border-radius: 10px;
|
32
|
+
height: 18px;
|
33
|
+
min-width: 126px;
|
34
|
+
background-color: #f5f5f5;
|
35
|
+
font-family: 'Nunito Sans', sans-serif;
|
36
|
+
font-size: 11px;
|
37
|
+
letter-spacing: 0.3px;
|
38
|
+
color: #212121;
|
39
|
+
margin-top: 8px;
|
40
|
+
padding: 5px 10px;
|
41
|
+
}
|
42
|
+
|
43
|
+
label.asdndzone .direct-upload__complete {
|
44
|
+
background-color: #c8ce3e;
|
45
|
+
}
|
46
|
+
|
47
|
+
label.asdndzone .direct-upload__progress {
|
48
|
+
opacity: 0.57;
|
49
|
+
background-color: #c8ce3e;
|
50
|
+
border-radius: 10px;
|
51
|
+
position: absolute;
|
52
|
+
top: 0;
|
53
|
+
left: 0;
|
54
|
+
bottom: 0;
|
55
|
+
height: 100%;
|
56
|
+
transition: width 120ms ease-out, opacity 60ms 60ms ease-in;
|
57
|
+
transform: translate3d(0, 0, 0);
|
58
|
+
}
|
59
|
+
|
60
|
+
label.asdndzone .direct-upload__remove {
|
8
61
|
display: inline-block;
|
62
|
+
vertical-align: top;
|
63
|
+
margin-top: 13px;
|
64
|
+
font-family: 'Nunito Sans', sans-serif;
|
65
|
+
font-size: 11px;
|
66
|
+
font-weight: bold;
|
67
|
+
text-decoration: none;
|
68
|
+
color: #212121;
|
69
|
+
}
|
70
|
+
|
71
|
+
label.asdndzone .direct-upload__filename {
|
72
|
+
position: relative;
|
73
|
+
max-width: 190px;
|
74
|
+
overflow: hidden;
|
75
|
+
display: inline-block;
|
76
|
+
text-overflow: ellipsis;
|
77
|
+
white-space: nowrap;
|
78
|
+
}
|
79
|
+
|
80
|
+
label.asdndzone .direct-upload__filesize {
|
81
|
+
position: relative;
|
82
|
+
float: right;
|
83
|
+
font-size: 10px;
|
84
|
+
letter-spacing: 0.1px;
|
85
|
+
color: #707e87;
|
86
|
+
padding-left: 7px;
|
87
|
+
}
|
88
|
+
|
89
|
+
label.asdndzone .direct-upload[data-direct-upload-id='error'] {
|
90
|
+
border: 1px solid red;
|
9
91
|
}
|
@@ -0,0 +1,77 @@
|
|
1
|
+
// @flow
|
2
|
+
|
3
|
+
import { fileSizeSI } from './helpers'
|
4
|
+
|
5
|
+
export function errorUI (event: CustomEvent) {
|
6
|
+
let { id } = event.detail
|
7
|
+
const { error, iconContainer, file } = event.detail
|
8
|
+
if (event.defaultPrevented) return
|
9
|
+
|
10
|
+
if (!id) {
|
11
|
+
id = 'error'
|
12
|
+
window.ActiveStorageDragAndDrop.paintUploadIcon(iconContainer, id, file, false)
|
13
|
+
}
|
14
|
+
const element = document.querySelector(`[data-direct-upload-id="${id}"] .direct-upload`)
|
15
|
+
if (!element) return
|
16
|
+
|
17
|
+
element.setAttribute('title', error)
|
18
|
+
element.classList.add('direct-upload--error')
|
19
|
+
}
|
20
|
+
|
21
|
+
export function endUI (event: CustomEvent) {
|
22
|
+
const { id } = event.detail
|
23
|
+
if (!id || event.defaultPrevented) return
|
24
|
+
|
25
|
+
const element = document.querySelector(`[data-direct-upload-id="${id}"] .direct-upload`)
|
26
|
+
if (!element) return
|
27
|
+
|
28
|
+
const classes = element.classList
|
29
|
+
classes.remove('direct-upload--pending')
|
30
|
+
classes.add('direct-upload--complete')
|
31
|
+
}
|
32
|
+
|
33
|
+
export function initializeUI (event: CustomEvent) {
|
34
|
+
if (event.defaultPrevented) return
|
35
|
+
|
36
|
+
const { id, file, iconContainer } = event.detail
|
37
|
+
window.ActiveStorageDragAndDrop.paintUploadIcon(iconContainer, id, file, false)
|
38
|
+
}
|
39
|
+
|
40
|
+
export function progressUI (event: CustomEvent) {
|
41
|
+
if (event.defaultPrevented) return
|
42
|
+
|
43
|
+
const { id, progress } = event.detail
|
44
|
+
const progressElement = document.querySelector(`[data-direct-upload-id="${id}"] .direct-upload__progress`)
|
45
|
+
if (progressElement) progressElement.style.width = `${progress}%`
|
46
|
+
}
|
47
|
+
|
48
|
+
export function placeholderUI (event: CustomEvent) {
|
49
|
+
if (event.defaultPrevented) return
|
50
|
+
|
51
|
+
const { id, file, iconContainer } = event.detail
|
52
|
+
window.ActiveStorageDragAndDrop.paintUploadIcon(iconContainer, id, file, true)
|
53
|
+
}
|
54
|
+
|
55
|
+
export function cancelUI (event: CustomEvent) {
|
56
|
+
if (event.defaultPrevented) return
|
57
|
+
|
58
|
+
const { id } = event.detail
|
59
|
+
document.querySelectorAll(`[data-direct-upload-id="${id}"]`).forEach(element => {
|
60
|
+
element.remove()
|
61
|
+
})
|
62
|
+
}
|
63
|
+
|
64
|
+
export function paintUploadIcon (iconContainer: HTMLElement, id: string | number, file: File, complete: boolean) {
|
65
|
+
const uploadStatus = (complete ? 'complete' : 'pending')
|
66
|
+
const progress = (complete ? 100 : 0)
|
67
|
+
iconContainer.insertAdjacentHTML('beforeend', `
|
68
|
+
<div data-direct-upload-id="${id}">
|
69
|
+
<div class="direct-upload direct-upload--${uploadStatus}">
|
70
|
+
<div class="direct-upload__progress" style="width: ${progress}%"></div>
|
71
|
+
<span class="direct-upload__filename">${file.name}</span>
|
72
|
+
<span class="direct-upload__filesize">${fileSizeSI(file.size)}</span>
|
73
|
+
</div>
|
74
|
+
<a href='remove' class='direct-upload__remove'>X</a>
|
75
|
+
</div>
|
76
|
+
`)
|
77
|
+
}
|
@@ -0,0 +1,87 @@
|
|
1
|
+
// @flow
|
2
|
+
|
3
|
+
import { dispatchEvent } from './helpers'
|
4
|
+
import { endUI, errorUI, cancelUI } from './default_ui'
|
5
|
+
import { DragAndDropUploadController } from './drag_and_drop_upload_controller'
|
6
|
+
|
7
|
+
export class DragAndDropFormController {
|
8
|
+
form: HTMLFormElement;
|
9
|
+
uploadControllers: Array<DragAndDropUploadController>;
|
10
|
+
|
11
|
+
constructor (form: HTMLFormElement) {
|
12
|
+
this.form = form
|
13
|
+
this.uploadControllers = []
|
14
|
+
}
|
15
|
+
|
16
|
+
start (callback: Function) {
|
17
|
+
const startUploadControllers = () => {
|
18
|
+
const nextUploadController = this.uploadControllers.shift()
|
19
|
+
if (nextUploadController)
|
20
|
+
nextUploadController.start(error => {
|
21
|
+
if (error) {
|
22
|
+
this.dispatchError(error, nextUploadController)
|
23
|
+
callback(error)
|
24
|
+
} else startUploadControllers()
|
25
|
+
})
|
26
|
+
else {
|
27
|
+
callback()
|
28
|
+
const event = this.dispatch('end')
|
29
|
+
endUI(event)
|
30
|
+
}
|
31
|
+
}
|
32
|
+
this.dispatch('start')
|
33
|
+
startUploadControllers()
|
34
|
+
}
|
35
|
+
|
36
|
+
dispatch (name: string, detail: {} = {}) {
|
37
|
+
return dispatchEvent(this.form, `dnd-uploads:${name}`, { detail })
|
38
|
+
}
|
39
|
+
|
40
|
+
dispatchError (error: Error, uploadController: DragAndDropUploadController) {
|
41
|
+
const { file, iconContainer } = uploadController
|
42
|
+
const event = dispatchEvent(
|
43
|
+
this.form,
|
44
|
+
'dnd-upload:error',
|
45
|
+
{ detail: { error, file, iconContainer } }
|
46
|
+
)
|
47
|
+
errorUI(event)
|
48
|
+
}
|
49
|
+
|
50
|
+
queueUpload (input: HTMLInputElement, file: File) {
|
51
|
+
if (!input.getAttribute('multiple')) this.unqueueUploadsPerInput(input)
|
52
|
+
try {
|
53
|
+
this.uploadControllers.push(new DragAndDropUploadController(input, file))
|
54
|
+
} catch (error) {
|
55
|
+
const detail = {
|
56
|
+
id: null,
|
57
|
+
file: file,
|
58
|
+
error: error,
|
59
|
+
iconContainer: document.getElementById(input.dataset.iconContainerId)
|
60
|
+
}
|
61
|
+
dispatchErrorWithoutAttachment(input, detail)
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
unqueueUploadsPerInput (input: HTMLInputElement) {
|
66
|
+
const zone = input.closest('label.asdndzone')
|
67
|
+
if (!zone) return
|
68
|
+
|
69
|
+
zone.querySelectorAll('[data-direct-upload-id]').forEach(element => { element.remove() })
|
70
|
+
this.uploadControllers.splice(0, this.uploadControllers.length)
|
71
|
+
}
|
72
|
+
|
73
|
+
unqueueUpload (id: number) {
|
74
|
+
const index = this.uploadControllers.findIndex(uploader => (uploader.upload.id === id))
|
75
|
+
const uploadController = this.uploadControllers[index]
|
76
|
+
this.uploadControllers.splice(index, 1)
|
77
|
+
const event = uploadController && (uploadController.dispatch instanceof Function)
|
78
|
+
? uploadController.dispatch('cancel')
|
79
|
+
: this.dispatch('cancel', { id: 'error' })
|
80
|
+
cancelUI(event)
|
81
|
+
}
|
82
|
+
}
|
83
|
+
|
84
|
+
function dispatchErrorWithoutAttachment (input, detail) {
|
85
|
+
const event = dispatchEvent(input, 'dnd-upload:error', { detail })
|
86
|
+
errorUI(event)
|
87
|
+
}
|
@@ -0,0 +1,102 @@
|
|
1
|
+
// @flow
|
2
|
+
|
3
|
+
import { dispatchEvent, fileSizeSI } from './helpers'
|
4
|
+
import { endUI, errorUI, initializeUI, progressUI } from './default_ui'
|
5
|
+
import { DirectUpload } from 'activestorage'
|
6
|
+
const eventFamily = 'dnd-upload'
|
7
|
+
|
8
|
+
export class DragAndDropUploadController {
|
9
|
+
input: HTMLInputElement;
|
10
|
+
form: HTMLFormElement;
|
11
|
+
url: string;
|
12
|
+
iconContainer: HTMLElement;
|
13
|
+
file: File;
|
14
|
+
upload: DirectUpload;
|
15
|
+
|
16
|
+
constructor (input: HTMLInputElement, file: File) {
|
17
|
+
validate(input, file)
|
18
|
+
const form = input.closest('form')
|
19
|
+
const iconContainer = document.getElementById(input.dataset.iconContainerId)
|
20
|
+
if (!(form instanceof HTMLFormElement && iconContainer)) return
|
21
|
+
|
22
|
+
this.input = input
|
23
|
+
this.form = form
|
24
|
+
this.url = this.input.dataset.directUploadUrl
|
25
|
+
this.iconContainer = iconContainer
|
26
|
+
this.file = file
|
27
|
+
this.upload = new DirectUpload(this.file, this.url, this)
|
28
|
+
const event = this.dispatch('initialize')
|
29
|
+
initializeUI(event)
|
30
|
+
}
|
31
|
+
|
32
|
+
start (callback: Function) {
|
33
|
+
this.dispatch('start')
|
34
|
+
this.upload.create((error, blob) => {
|
35
|
+
if (error) {
|
36
|
+
// Handle the error
|
37
|
+
this.dispatchError(error)
|
38
|
+
callback(error)
|
39
|
+
} else {
|
40
|
+
// Add an appropriately-named hidden input to the form with a
|
41
|
+
// value of blob.signed_id so that the blob ids will be
|
42
|
+
// transmitted in the normal upload flow
|
43
|
+
const hiddenField = document.createElement('input')
|
44
|
+
hiddenField.setAttribute('type', 'hidden')
|
45
|
+
hiddenField.setAttribute('value', blob.signed_id)
|
46
|
+
hiddenField.setAttribute('name', this.input.getAttribute('name') || '')
|
47
|
+
hiddenField.setAttribute('data-direct-upload-id', this.upload.id)
|
48
|
+
this.form.appendChild(hiddenField)
|
49
|
+
const event = this.dispatch('end')
|
50
|
+
endUI(event)
|
51
|
+
callback(error)
|
52
|
+
}
|
53
|
+
})
|
54
|
+
}
|
55
|
+
|
56
|
+
dispatch (name: string,
|
57
|
+
detail: { file?: File, id?: number, iconContainer?: Element, error?: Error } = {}) {
|
58
|
+
detail.file = this.file
|
59
|
+
detail.id = this.upload.id
|
60
|
+
detail.iconContainer = this.iconContainer
|
61
|
+
return dispatchEvent(this.input, `${eventFamily}:${name}`, { detail })
|
62
|
+
}
|
63
|
+
|
64
|
+
dispatchError (error: Error) {
|
65
|
+
const event = this.dispatch('error', { error })
|
66
|
+
errorUI(event)
|
67
|
+
}
|
68
|
+
|
69
|
+
directUploadWillCreateBlobWithXHR (xhr: XMLHttpRequest) {
|
70
|
+
this.dispatch('before-blob-request', { xhr })
|
71
|
+
}
|
72
|
+
|
73
|
+
directUploadWillStoreFileWithXHR (xhr: XMLHttpRequest) {
|
74
|
+
this.dispatch('before-storage-request', { xhr })
|
75
|
+
xhr.upload.addEventListener('progress', (event: Event) => this.uploadRequestDidProgress(event))
|
76
|
+
}
|
77
|
+
|
78
|
+
uploadRequestDidProgress (uploadEvent: Event) {
|
79
|
+
// $FlowFixMe
|
80
|
+
const progress = uploadEvent.loaded / uploadEvent.total * 100
|
81
|
+
if (!progress) return
|
82
|
+
|
83
|
+
const event = this.dispatch('progress', { progress })
|
84
|
+
progressUI(event)
|
85
|
+
}
|
86
|
+
}
|
87
|
+
|
88
|
+
class ValidationError extends Error {
|
89
|
+
constructor (...args) {
|
90
|
+
super(...args)
|
91
|
+
Error.captureStackTrace(this, ValidationError)
|
92
|
+
}
|
93
|
+
}
|
94
|
+
|
95
|
+
function validate (input, file) {
|
96
|
+
const sizeLimit = parseInt(input.getAttribute('size_limit'))
|
97
|
+
const accept = input.getAttribute('accept')
|
98
|
+
if (accept && !accept.split(', ').includes(file.type))
|
99
|
+
throw new ValidationError('Invalid filetype')
|
100
|
+
else if (sizeLimit && file.size > sizeLimit)
|
101
|
+
throw new ValidationError(`File too large. Can be no larger than ${fileSizeSI(sizeLimit)}`)
|
102
|
+
}
|
@@ -1,59 +1,15 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
1
|
+
// @flow
|
2
|
+
|
3
|
+
export function dispatchEvent (element: Element, type: string,
|
4
|
+
eventInit: { bubbles?: boolean, cancelable?: boolean, detail: {} } = {}) {
|
5
|
+
const { bubbles = true, cancelable = true, detail = {} } = eventInit
|
6
|
+
const event = new CustomEvent(type, { bubbles, cancelable, detail })
|
6
7
|
element.dispatchEvent(event)
|
7
8
|
return event
|
8
9
|
}
|
9
10
|
|
10
|
-
export function
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
element.classList.add('direct-upload--error')
|
15
|
-
element.setAttribute('title', error)
|
16
|
-
}
|
17
|
-
}
|
18
|
-
|
19
|
-
export function defaultEndEventUI (event) {
|
20
|
-
if (!event.defaultPrevented) {
|
21
|
-
const { id } = event.detail
|
22
|
-
const element = document.getElementById(`direct-upload-${id}`)
|
23
|
-
element.classList.remove('direct-upload--pending')
|
24
|
-
element.classList.add('direct-upload--complete')
|
25
|
-
}
|
26
|
-
}
|
27
|
-
|
28
|
-
export function hasClassnameInHeirarchy (element, classname) {
|
29
|
-
if (element && element.classList) {
|
30
|
-
if (element.classList.contains(classname)) {
|
31
|
-
return true
|
32
|
-
} else {
|
33
|
-
return hasClassnameInHeirarchy(element.parentNode, classname)
|
34
|
-
}
|
35
|
-
}
|
36
|
-
}
|
37
|
-
|
38
|
-
export function getClassnameFromHeirarchy (element, classname) {
|
39
|
-
if (element && element.classList) {
|
40
|
-
if (element.classList.contains(classname)) {
|
41
|
-
return element
|
42
|
-
} else {
|
43
|
-
return getClassnameFromHeirarchy(element.parentNode, classname)
|
44
|
-
}
|
45
|
-
}
|
46
|
-
}
|
47
|
-
|
48
|
-
export function fileUploadUIPainter (iconContainer, id, filename, complete) {
|
49
|
-
// the only rule here is that all root level elements must have the data: { direct_upload_id: [id] } attribute ala: 'data-direct-upload-id="${id}"'
|
50
|
-
var cname = (complete ? 'complete' : 'pending')
|
51
|
-
var progress = (complete ? 100 : 0)
|
52
|
-
iconContainer.insertAdjacentHTML('beforeend', `
|
53
|
-
<div id="direct-upload-${id}" class="direct-upload direct-upload--${cname}" data-direct-upload-id="${id}">
|
54
|
-
<div id="direct-upload-progress-${id}" class="direct-upload__progress" style="width: ${progress}%"></div>
|
55
|
-
<span class="direct-upload__filename">${filename}</span>
|
56
|
-
</div>
|
57
|
-
<a href='remove' class='direct-upload__remove' data-dnd-delete='true' data-direct-upload-id="${id}">x</a>
|
58
|
-
`)
|
11
|
+
export function fileSizeSI (bytes: number) {
|
12
|
+
let e = Math.log(bytes) / Math.log(1000) | 0
|
13
|
+
const size = (bytes / Math.pow(1000, e) + 0.5) | 0
|
14
|
+
return size + (e ? 'kMGTPEZY'[--e] + 'B' : ' Bytes')
|
59
15
|
}
|