Summary: Ref T5187. This definitely feels a bit flimsy and I'm going to hold it until I cut the release since it changes a couple of things about Workflow in general, but it seems to work OK and most of it is fine. The intent is described in T5187#176236. In practice, most of that works like I describe, then the `phui-file-upload` behavior gets some weird glue to figure out if the input is part of the form. Not the most elegant system, but I think it'll hold until we come up with many reasons to write a lot more Javascript. Test Plan: Used both drag-and-drop and the upload dialog to upload files in Safari, Firefox and Chrome. {F1653716} Reviewers: chad Reviewed By: chad Maniphest Tasks: T5187 Differential Revision: https://secure.phabricator.com/D15953
458 lines
12 KiB
JavaScript
458 lines
12 KiB
JavaScript
/**
|
|
* @requires javelin-install
|
|
* javelin-util
|
|
* javelin-request
|
|
* javelin-dom
|
|
* javelin-uri
|
|
* phabricator-file-upload
|
|
* @provides phabricator-drag-and-drop-file-upload
|
|
* @javelin
|
|
*/
|
|
|
|
JX.install('PhabricatorDragAndDropFileUpload', {
|
|
|
|
construct : function(target) {
|
|
if (JX.DOM.isNode(target)) {
|
|
this._node = target;
|
|
} else {
|
|
this._sigil = target;
|
|
}
|
|
},
|
|
|
|
events : [
|
|
'didBeginDrag',
|
|
'didEndDrag',
|
|
'willUpload',
|
|
'progress',
|
|
'didUpload',
|
|
'didError'],
|
|
|
|
statics : {
|
|
isSupported : function() {
|
|
// TODO: Is there a better capability test for this? This seems okay in
|
|
// Safari, Firefox and Chrome.
|
|
|
|
return !!window.FileList;
|
|
},
|
|
isPasteSupported : function() {
|
|
// TODO: Needs to check if event.clipboardData is available.
|
|
// Works in Chrome, doesn't work in Firefox 10.
|
|
return !!window.FileList;
|
|
}
|
|
},
|
|
|
|
members : {
|
|
_node : null,
|
|
_sigil: null,
|
|
_depth : 0,
|
|
_isEnabled: false,
|
|
|
|
setIsEnabled: function(bool) {
|
|
this._isEnabled = bool;
|
|
return this;
|
|
},
|
|
|
|
getIsEnabled: function() {
|
|
return this._isEnabled;
|
|
},
|
|
|
|
_updateDepth : function(delta) {
|
|
if (this._depth === 0 && delta > 0) {
|
|
this.invoke('didBeginDrag', this._getTarget());
|
|
}
|
|
|
|
this._depth += delta;
|
|
|
|
if (this._depth === 0 && delta < 0) {
|
|
this.invoke('didEndDrag', this._getTarget());
|
|
}
|
|
},
|
|
|
|
_getTarget: function() {
|
|
return this._target || this._node;
|
|
},
|
|
|
|
start : function() {
|
|
|
|
// TODO: move this to JX.DOM.contains()?
|
|
function contains(container, child) {
|
|
do {
|
|
if (child === container) {
|
|
return true;
|
|
}
|
|
child = child.parentNode;
|
|
} while (child);
|
|
|
|
return false;
|
|
}
|
|
|
|
// Firefox has some issues sometimes; implement this click handler so
|
|
// the user can recover. See T5188.
|
|
var on_click = JX.bind(this, function (e) {
|
|
if (!this.getIsEnabled()) {
|
|
return;
|
|
}
|
|
|
|
if (this._depth) {
|
|
e.kill();
|
|
// Force depth to 0.
|
|
this._updateDepth(-this._depth);
|
|
}
|
|
});
|
|
|
|
// We track depth so that the _node may have children inside of it and
|
|
// not become unselected when they are dragged over.
|
|
var on_dragenter = JX.bind(this, function(e) {
|
|
if (!this.getIsEnabled()) {
|
|
return;
|
|
}
|
|
|
|
if (!this._node) {
|
|
var target = e.getNode(this._sigil);
|
|
if (target !== this._target) {
|
|
this._updateDepth(-this._depth);
|
|
this._target = target;
|
|
}
|
|
}
|
|
|
|
if (contains(this._getTarget(), e.getTarget())) {
|
|
this._updateDepth(1);
|
|
}
|
|
|
|
});
|
|
|
|
var on_dragleave = JX.bind(this, function(e) {
|
|
if (!this.getIsEnabled()) {
|
|
return;
|
|
}
|
|
|
|
if (!this._getTarget()) {
|
|
return;
|
|
}
|
|
|
|
if (contains(this._getTarget(), e.getTarget())) {
|
|
this._updateDepth(-1);
|
|
}
|
|
});
|
|
|
|
var on_dragover = JX.bind(this, function(e) {
|
|
if (!this.getIsEnabled()) {
|
|
return;
|
|
}
|
|
|
|
// NOTE: We must set this, or Chrome refuses to drop files from the
|
|
// download shelf.
|
|
e.getRawEvent().dataTransfer.dropEffect = 'copy';
|
|
e.kill();
|
|
});
|
|
|
|
var on_drop = JX.bind(this, function(e) {
|
|
if (!this.getIsEnabled()) {
|
|
return;
|
|
}
|
|
|
|
e.kill();
|
|
|
|
var files = e.getRawEvent().dataTransfer.files;
|
|
for (var ii = 0; ii < files.length; ii++) {
|
|
this.sendRequest(files[ii]);
|
|
}
|
|
|
|
// Force depth to 0.
|
|
this._updateDepth(-this._depth);
|
|
});
|
|
|
|
if (this._node) {
|
|
JX.DOM.listen(this._node, 'click', null, on_click);
|
|
JX.DOM.listen(this._node, 'dragenter', null, on_dragenter);
|
|
JX.DOM.listen(this._node, 'dragleave', null, on_dragleave);
|
|
JX.DOM.listen(this._node, 'dragover', null, on_dragover);
|
|
JX.DOM.listen(this._node, 'drop', null, on_drop);
|
|
} else {
|
|
JX.Stratcom.listen('click', this._sigil, on_click);
|
|
JX.Stratcom.listen('dragenter', this._sigil, on_dragenter);
|
|
JX.Stratcom.listen('dragleave', this._sigil, on_dragleave);
|
|
JX.Stratcom.listen('dragover', this._sigil, on_dragover);
|
|
JX.Stratcom.listen('drop', this._sigil, on_drop);
|
|
}
|
|
|
|
if (JX.PhabricatorDragAndDropFileUpload.isPasteSupported() &&
|
|
this._node) {
|
|
JX.DOM.listen(
|
|
this._node,
|
|
'paste',
|
|
null,
|
|
JX.bind(this, function(e) {
|
|
if (!this.getIsEnabled()) {
|
|
return;
|
|
}
|
|
|
|
var clipboard = e.getRawEvent().clipboardData;
|
|
if (!clipboard) {
|
|
return;
|
|
}
|
|
|
|
// If there's any text on the clipboard, just let the event fire
|
|
// normally, choosing the text over any images. See T5437 / D9647.
|
|
var text = clipboard.getData('text/plain').toString();
|
|
if (text.length) {
|
|
return;
|
|
}
|
|
|
|
// Safari and Firefox have clipboardData, but no items. They
|
|
// don't seem to provide a way to get image data directly yet.
|
|
if (!clipboard.items) {
|
|
return;
|
|
}
|
|
|
|
for (var ii = 0; ii < clipboard.items.length; ii++) {
|
|
var item = clipboard.items[ii];
|
|
if (!/^image\//.test(item.type)) {
|
|
continue;
|
|
}
|
|
var spec = item.getAsFile();
|
|
// pasted files don't have a name; see
|
|
// https://code.google.com/p/chromium/issues/detail?id=361145
|
|
if (!spec.name) {
|
|
spec.name = 'pasted_file';
|
|
}
|
|
this.sendRequest(spec);
|
|
}
|
|
}));
|
|
}
|
|
|
|
this.setIsEnabled(true);
|
|
},
|
|
|
|
sendRequest : function(spec) {
|
|
var file = new JX.PhabricatorFileUpload()
|
|
.setRawFileObject(spec)
|
|
.setName(spec.name)
|
|
.setTotalBytes(spec.size);
|
|
|
|
var threshold = this.getChunkThreshold();
|
|
if (threshold && (file.getTotalBytes() > threshold)) {
|
|
// This is a large file, so we'll go through allocation so we can
|
|
// pick up support for resume and chunking.
|
|
this._allocateFile(file);
|
|
} else {
|
|
// If this file is smaller than the chunk threshold, skip the round
|
|
// trip for allocation and just upload it directly.
|
|
this._sendDataRequest(file);
|
|
}
|
|
},
|
|
|
|
_allocateFile: function(file) {
|
|
file
|
|
.setStatus('allocate')
|
|
.update();
|
|
|
|
this.invoke('willUpload', file);
|
|
|
|
var alloc_uri = this._getUploadURI(file)
|
|
.setQueryParam('allocate', 1);
|
|
|
|
new JX.Workflow(alloc_uri)
|
|
.setHandler(JX.bind(this, this._didAllocateFile, file))
|
|
.start();
|
|
},
|
|
|
|
_getUploadURI: function(file) {
|
|
var uri = JX.$U(this.getURI())
|
|
.setQueryParam('name', file.getName())
|
|
.setQueryParam('length', file.getTotalBytes());
|
|
|
|
if (this.getViewPolicy()) {
|
|
uri.setQueryParam('viewPolicy', this.getViewPolicy());
|
|
}
|
|
|
|
if (file.getAllocatedPHID()) {
|
|
uri.setQueryParam('phid', file.getAllocatedPHID());
|
|
}
|
|
|
|
return uri;
|
|
},
|
|
|
|
_didAllocateFile: function(file, r) {
|
|
var phid = r.phid;
|
|
var upload = r.upload;
|
|
|
|
if (!upload) {
|
|
if (phid) {
|
|
this._completeUpload(file, r);
|
|
} else {
|
|
this._failUpload(file, r);
|
|
}
|
|
return;
|
|
} else {
|
|
if (phid) {
|
|
// Start or resume a chunked upload.
|
|
file.setAllocatedPHID(phid);
|
|
this._loadChunks(file);
|
|
} else {
|
|
// Proceed with non-chunked upload.
|
|
this._sendDataRequest(file);
|
|
}
|
|
}
|
|
},
|
|
|
|
_loadChunks: function(file) {
|
|
file
|
|
.setStatus('chunks')
|
|
.update();
|
|
|
|
var chunks_uri = this._getUploadURI(file)
|
|
.setQueryParam('querychunks', 1);
|
|
|
|
new JX.Workflow(chunks_uri)
|
|
.setHandler(JX.bind(this, this._didLoadChunks, file))
|
|
.start();
|
|
},
|
|
|
|
_didLoadChunks: function(file, r) {
|
|
file.setChunks(r);
|
|
this._uploadNextChunk(file);
|
|
},
|
|
|
|
_uploadNextChunk: function(file) {
|
|
var chunks = file.getChunks();
|
|
var chunk;
|
|
for (var ii = 0; ii < chunks.length; ii++) {
|
|
chunk = chunks[ii];
|
|
if (!chunk.complete) {
|
|
this._uploadChunk(file, chunk);
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
|
|
_uploadChunk: function(file, chunk, callback) {
|
|
file
|
|
.setStatus('upload')
|
|
.update();
|
|
|
|
var chunkup_uri = this._getUploadURI(file)
|
|
.setQueryParam('uploadchunk', 1)
|
|
.setQueryParam('__upload__', 1)
|
|
.setQueryParam('byteStart', chunk.byteStart)
|
|
.toString();
|
|
|
|
var callback = JX.bind(this, this._didUploadChunk, file, chunk);
|
|
|
|
var req = new JX.Request(chunkup_uri, callback);
|
|
|
|
var seen_bytes = 0;
|
|
var onprogress = JX.bind(this, function(progress) {
|
|
file
|
|
.addUploadedBytes(progress.loaded - seen_bytes)
|
|
.update();
|
|
|
|
seen_bytes = progress.loaded;
|
|
this.invoke('progress', file);
|
|
});
|
|
|
|
req.listen('error', JX.bind(this, this._onUploadError, req, file));
|
|
req.listen('uploadprogress', onprogress);
|
|
|
|
var blob = file.getRawFileObject().slice(chunk.byteStart, chunk.byteEnd);
|
|
|
|
req
|
|
.setRawData(blob)
|
|
.send();
|
|
},
|
|
|
|
_didUploadChunk: function(file, chunk, r) {
|
|
file.didCompleteChunk(chunk);
|
|
|
|
if (r.complete) {
|
|
this._completeUpload(file, r);
|
|
} else {
|
|
this._uploadNextChunk(file);
|
|
}
|
|
},
|
|
|
|
_sendDataRequest: function(file) {
|
|
file
|
|
.setStatus('uploading')
|
|
.update();
|
|
|
|
this.invoke('willUpload', file);
|
|
|
|
var up_uri = this._getUploadURI(file)
|
|
.setQueryParam('__upload__', 1)
|
|
.toString();
|
|
|
|
var onupload = JX.bind(this, function(r) {
|
|
if (r.error) {
|
|
this._failUpload(file, r);
|
|
} else {
|
|
this._completeUpload(file, r);
|
|
}
|
|
});
|
|
|
|
var req = new JX.Request(up_uri, onupload);
|
|
|
|
var onprogress = JX.bind(this, function(progress) {
|
|
file
|
|
.setTotalBytes(progress.total)
|
|
.setUploadedBytes(progress.loaded)
|
|
.update();
|
|
|
|
this.invoke('progress', file);
|
|
});
|
|
|
|
req.listen('error', JX.bind(this, this._onUploadError, req, file));
|
|
req.listen('uploadprogress', onprogress);
|
|
|
|
req
|
|
.setRawData(file.getRawFileObject())
|
|
.send();
|
|
},
|
|
|
|
_completeUpload: function(file, r) {
|
|
file
|
|
.setID(r.id)
|
|
.setPHID(r.phid)
|
|
.setURI(r.uri)
|
|
.setMarkup(r.html)
|
|
.setStatus('done')
|
|
.setTargetNode(this._getTarget())
|
|
.update();
|
|
|
|
this.invoke('didUpload', file);
|
|
},
|
|
|
|
_failUpload: function(file, r) {
|
|
file
|
|
.setStatus('error')
|
|
.setError(r.error)
|
|
.update();
|
|
|
|
this.invoke('didError', file);
|
|
},
|
|
|
|
_onUploadError: function(req, file, error) {
|
|
file.setStatus('error');
|
|
|
|
if (error) {
|
|
file.setError(error.code + ': ' + error.info);
|
|
} else {
|
|
var xhr = req.getTransport();
|
|
if (xhr.responseText) {
|
|
file.setError('Server responded: ' + xhr.responseText);
|
|
}
|
|
}
|
|
|
|
file.update();
|
|
this.invoke('didError', file);
|
|
}
|
|
|
|
},
|
|
properties: {
|
|
URI: null,
|
|
activatedClass: null,
|
|
viewPolicy: null,
|
|
chunkThreshold: null
|
|
}
|
|
});
|