-
-
Notifications
You must be signed in to change notification settings - Fork 76
Expand file tree
/
Copy pathfile_upload_controller.js
More file actions
217 lines (182 loc) · 7.72 KB
/
Copy pathfile_upload_controller.js
File metadata and controls
217 lines (182 loc) · 7.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import { Controller } from '@hotwired/stimulus'
import { DirectUpload } from '@rails/activestorage'
import { collapse } from 'utils/collapse_utils'
// Connects to data-controller='ui--forms--file-upload'
// Shows the selected filename (or a count for multiple files) in the field, previews an
// image pick, and frames the controls as a drop target while a file is dragged over the page.
// With a url value, uploads the pick straight to storage and posts its signed blob id.
export default class extends Controller {
static targets = ['input', 'filename', 'dropZone', 'preview', 'previewImage', 'signedId']
// stall: how long without progress before the upload is treated as dead
static values = {
placeholder: String,
url: String,
uploading: String,
failed: String,
stall: { type: Number, default: 30000 }
}
connect () {
this.boundHold = this.hold.bind(this)
this.form?.addEventListener('submit', this.boundHold)
// The field posts its own bytes until this runs, which is what makes the form work
// without JS - once we're uploading, the signed id is what the form carries instead.
if (this.urlValue) this.inputTarget.removeAttribute('name')
}
disconnect () {
this.form?.removeEventListener('submit', this.boundHold)
clearTimeout(this.stallTimer)
this.releaseObjectUrl()
}
get form () {
return this.element.closest('form')
}
// Both buttons open the one input; `capture` is what sends it to the camera.
takePicture () {
this.inputTarget.setAttribute('capture', 'environment')
this.inputTarget.click()
}
// Runs before the label's own activation forwards the click to the input.
chooseFile () {
this.inputTarget.removeAttribute('capture')
}
dragOver (event) {
if (!draggingFile(event)) return
event.preventDefault() // without this the browser opens the file instead
this.dropZoneTarget.dataset.dragging = 'true'
}
// Bound to both dragleave and drop. dragleave fires for every element crossed, but
// relatedTarget is null only on leaving the window -- and on a drop, which ends it too.
endDrag (event) {
if (event.relatedTarget) return
event.preventDefault()
delete this.dropZoneTarget.dataset.dragging
this.unhighlightDropZone()
}
highlightDropZone () {
this.dropZoneTarget.dataset.over = 'true'
}
// The frame wraps the controls, so dragging onto one of them leaves the frame
// in the event's terms -- only a relatedTarget outside it is a real exit.
unhighlightDropZone (event) {
if (event?.relatedTarget && this.dropZoneTarget.contains(event.relatedTarget)) return
delete this.dropZoneTarget.dataset.over
}
drop (event) {
event.preventDefault()
const dropped = [...event.dataTransfer.files]
if (dropped.length === 0) return
// Assigning a FileList is the only way to fill a file input; `multiple`
// decides how much of the drop it can hold.
const transfer = new window.DataTransfer()
;(this.inputTarget.multiple ? dropped : dropped.slice(0, 1)).forEach((file) => transfer.items.add(file))
this.inputTarget.files = transfer.files
// Assigning files fires nothing. Picking a file natively fires both, and both
// have listeners: `input` drives display(), `change` is what callers bind to.
this.inputTarget.dispatchEvent(new Event('input', { bubbles: true }))
this.inputTarget.dispatchEvent(new Event('change', { bubbles: true }))
}
display () {
const { files } = this.inputTarget
this.filenameTarget.textContent =
files.length === 0
? this.placeholderValue
: files.length === 1 ? files[0].name : `${files.length} files`
this.showPreview(files[0])
if (this.urlValue && files[0]) this.upload(files[0])
}
// Reads the file the browser already holds, so the preview lands on the pick rather than
// on a round trip -- and shows the original rather than a processed copy of it.
showPreview (file) {
this.releaseObjectUrl()
// An empty pick leaves whatever is attached on screen, since that's still what will submit
if (!file) return
const preview = this.previewTarget
if (!file.type.startsWith('image/')) return collapse('hide', preview)
const image = this.previewImageTarget
this.objectUrl = URL.createObjectURL(file)
// Assigned rather than added, so a re-pick replaces these instead of stacking them.
// collapse animates to the natural height, which isn't known until the image decodes --
// and a file that won't decode shouldn't leave the previous preview on screen.
image.onload = () => collapse('show', preview)
image.onerror = () => collapse('hide', preview)
image.src = this.objectUrl
preview.href = this.objectUrl
}
// Each object url pins the file in memory until it's revoked
releaseObjectUrl () {
if (!this.objectUrl) return
URL.revokeObjectURL(this.objectUrl)
this.objectUrl = null
}
// Only reached with a direct_upload_url, where the form carries the blob's signed id
// rather than the bytes.
upload (file) {
this.abortUpload() // Picking again shouldn't leave the discarded file uploading
this.signedIdTarget.value = ''
this.uploadingFile = file
this.status(file, this.uploadingValue)
const upload = new DirectUpload(file, this.urlValue, this)
this.currentUpload = upload
this.pending = new Promise((resolve) => { this.settle = resolve })
upload.create((error, blob) => {
if (this.currentUpload !== upload) return // A newer pick owns the field now
if (!error) this.signedIdTarget.value = blob.signed_id
this.status(file, error && this.failedValue)
this.finish()
})
this.watchForStall()
}
// Every ending runs through here, because a submit waiting on `pending` only moves when
// it settles - including the endings DirectUpload never reports.
finish () {
clearTimeout(this.stallTimer)
this.currentUpload = null
this.pending = null
this.settle?.()
this.settle = null
}
// DirectUpload listens for load and error, not abort, so an aborted upload never reaches
// its callback - without settling it here a held submit would wait on it forever.
abortUpload () {
if (!this.currentUpload) return
this.xhr?.abort()
this.finish()
}
// A connection that stops moving never errors, so nothing else would end the upload -
// the form would sit disabled behind a spinner until the page was reloaded.
watchForStall () {
clearTimeout(this.stallTimer)
this.stallTimer = setTimeout(() => {
this.signedIdTarget.value = ''
this.status(this.uploadingFile, this.failedValue)
this.abortUpload()
}, this.stallValue)
}
status (file, suffix) {
this.filenameTarget.textContent = suffix ? `${file.name} — ${suffix}` : file.name
}
// DirectUpload delegate hook - the handle that makes a discarded upload cancellable
directUploadWillStoreFileWithXHR (xhr) {
this.xhr = xhr
xhr.upload.addEventListener('progress', (event) => this.showProgress(event))
}
// Bytes moving is also what proves the connection is alive, so this restarts the stall watch
showProgress (event) {
this.watchForStall()
if (!event.lengthComputable) return
const percent = Math.round((event.loaded / event.total) * 100)
this.status(this.uploadingFile, `${this.uploadingValue} ${percent}%`)
}
// Submitting mid-upload would drop the file, so hold the form until the blob lands.
// A failed upload submits anyway - the rest of the form matters more.
async hold (event) {
if (!this.pending) return
event.preventDefault()
await this.pending
this.form.requestSubmit()
}
}
// Dragged text and page elements fire these events too; only files matter here.
function draggingFile (event) {
return event.dataTransfer?.types?.includes('Files')
}