active_storage_drag_and_drop 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
}
|