MDL-29766 Add drag and drop upload to filemanager / filepicker elements
authorDavo Smith <git@davosmith.co.uk>
Tue, 8 Nov 2011 20:05:19 +0000 (20:05 +0000)
committerDan Poltawski <dan.poltawski@luns.net.uk>
Mon, 9 Jan 2012 17:27:32 +0000 (17:27 +0000)
lang/en/moodle.php
lib/form/dndupload.js [new file with mode: 0644]
lib/form/filemanager.js
lib/form/filemanager.php
lib/form/filepicker.js
lib/form/filepicker.php
lib/outputrenderers.php
lib/outputrequirementslib.php
theme/base/style/core.css

index 9cae46d..7f8fb12 100644 (file)
@@ -451,6 +451,9 @@ $string['displayingfirst'] = 'Only the first {$a->count} {$a->things} are displa
 $string['displayingrecords'] = 'Displaying {$a} records';
 $string['displayingusers'] = 'Displaying users {$a->start} to {$a->end}';
 $string['displayonpage'] = 'Display on page';
+$string['dndenabled'] = 'You can drag and drop files into this box to upload them';
+$string['dndenabled_help'] = 'You can drag one or more files from your desktop and drop them onto the box below to upload them.<br />Note: this may not work with other web browsers';
+$string['dndenabled_single'] = 'you can drag and drop a file into this box to upload it';
 $string['documentation'] = 'Moodle documentation';
 $string['down'] = 'Down';
 $string['download'] = 'Download';
diff --git a/lib/form/dndupload.js b/lib/form/dndupload.js
new file mode 100644 (file)
index 0000000..990c3a2
--- /dev/null
@@ -0,0 +1,396 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript library for enableing a drag and drop upload interface
+ *
+ * @package    moodlecore
+ * @subpackage form
+ * @copyright  2011 Davo Smith
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+M.form_dndupload = {
+    // YUI object.
+    Y: null,
+    // URL for upload requests
+    url: M.cfg.wwwroot + '/repository/repository_ajax.php?action=upload',
+    // itemid used for repository upload
+    itemid: null,
+    // accepted filetypes accepted by this form passed to repository
+    acceptedtypes: [],
+    // maximum number of files this form allows
+    maxfiles: 0,
+    // maximum size of files allowed in this form
+    maxbytes: 0,
+    // unqiue id of this form field used for html elements
+    clientid: '',
+    // upload repository id, used for upload
+    repositoryid: 0,
+    // container which holds the node which recieves drag events
+    container: null,
+    // filemanager element we are working with
+    filemanager: null,
+    // callback  to filepicker element to refesh when uploaded
+    callback: null,
+    // Nasty hack to distinguish between dragenter(first entry),
+    // dragenter+dragleave(moving between child elements) and dragleave (leaving element)
+    entercount: 0,
+
+
+    /**
+     * Initalise the drag and drop upload interface
+     * Note: one and only one of options.filemanager and options.formcallback must be defined
+     *
+     * @param Y the YUI object
+     * @param object options {
+     *            itemid: itemid used for repository upload in this form
+     *            acceptdtypes: accepted filetypes by this form
+     *            maxfiles: maximum number of files this form allows
+     *            maxbytes: maximum size of files allowed in this form
+     *            clientid: unqiue id of this form field used for html elements
+     *            containerprefix: prefix of htmlid of container
+     *            repositories: array of repository objects passed from filepicker
+     *            filemanager: filemanager element we are working with
+     *            callback: callback  to filepicker element to refesh when uploaded
+     *          }
+     */
+    init: function(Y, options) {
+        this.Y = Y;
+
+        if (!this.browser_supported()) {
+            return; // Browser does not support the required functionality
+        }
+
+        // try and retrieve enabled upload repository
+        this.repositoryid = this.get_upload_repositoryid(options.repositories);
+
+        if (!this.repositoryid) {
+            return; // no upload repository is enabled to upload to
+        }
+
+        this.acceptedtypes = options.acceptedtypes;
+        this.clientid = options.clientid;
+        this.maxfiles = options.maxfiles;
+        this.maxbytes = options.maxbytes;
+        this.itemid = options.itemid;
+        this.container = this.Y.one(options.containerprefix + this.clientid);
+
+        if (options.filemanager) {
+            // Needed to tell the filemanager to redraw when files uploaded
+            // and to check how many files are already uploaded
+            this.filemanager = options.filemanager;
+        } else if (options.formcallback) {
+
+            // Needed to tell the filepicker to update when a new
+            // file is uploaded
+            this.callback = options.formcallback;
+        } else {
+            alert('dndupload: Need to define either options.filemanager or options.callback');
+            return;
+        }
+
+        this.init_events();
+        this.Y.one('#dndenabled-'+this.clientid).setStyle('display', 'inline');
+    },
+
+    /**
+     * Check the browser has the required functionality
+     * @return true if browser supports drag/drop upload
+     */
+    browser_supported: function() {
+
+        if (typeof FileReader=='undefined') {
+            return false;
+        }
+        if (typeof FormData=='undefined') {
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * Get upload repoistory from array of enabled repositories
+     *
+     * @param array repositories repository objects passed from filepicker
+     * @param returns int id of upload repository or false if not found
+     */
+    get_upload_repositoryid: function(repositories) {
+         for (var id in repositories) {
+             if(repositories[id].type == "upload") {
+                 return id;
+             }
+         }
+
+         return false;
+    },
+
+    /**
+     * Initialise drag events on node container, all events need
+     * to be processed for drag and drop to work
+     */
+    init_events: function() {
+        this.Y.on('dragenter', this.drag_enter, this.container, this);
+        this.Y.on('dragleave', this.drag_leave, this.container, this);
+        this.Y.on('dragover',  this.drag_over,  this.container, this);
+        this.Y.on('drop',      this.drop,      this.container, this);
+    },
+
+    /**
+     * Check if the drag contents are valid and then call
+     * preventdefault / stoppropagation to let the browser know
+     * we will handle this drag/drop
+     *
+     * @param e event object
+     * @return boolean true if a valid file drag event
+     */
+    check_drag: function(e) {
+        if (!this.has_files(e)) {
+            return false;
+        }
+
+        if (this.reached_maxfiles()) {
+            return false;
+        }
+
+        e.preventDefault();
+        e.stopPropagation();
+
+        return true;
+    },
+
+    /**
+     * Handle a dragenter event, highlight the destination node
+     * when a suitable drag event occurs
+     */
+    drag_enter: function(e) {
+        if (!this.check_drag(e)) {
+            return true;
+        }
+
+        this.entercount++;
+        if (this.entercount >= 2) {
+            this.entercount = 2; // Just moved over a child element - nothing to do
+            return false;
+        }
+
+        this.show_upload_ready();
+        return false;
+    },
+
+    /**
+     * Handle a dragleave event, Remove the highlight if dragged from
+     * node
+     */
+    drag_leave: function(e) {
+        if (!this.check_drag(e)) {
+            return true;
+        }
+
+        this.entercount--;
+        if (this.entercount == 1) {
+            return false; // Just moved over a child element - nothing to do
+        }
+
+        this.entercount = 0;
+        this.hide_upload_ready();
+        return false;
+    },
+
+    /**
+     * Handle a dragover event. Required to intercept to prevent the browser from
+     * handling the drag and drop event as normal
+     */
+    drag_over: function(e) {
+        if (!this.check_drag(e)) {
+            return true;
+        }
+
+        return false;
+    },
+
+    /**
+     * Handle a drop event.  Remove the highlight and then upload each
+     * of the files (until we reach the file limit, or run out of files)
+     */
+    drop: function(e) {
+        if (!this.check_drag(e)) {
+            return true;
+        }
+
+        this.entercount = 0;
+        this.hide_upload_ready();
+        this.show_progress_spinner();
+
+        var files = e._event.dataTransfer.files;
+        if (this.filemanager) {
+            var currentfilecount = this.filemanager.filecount;
+            for (var i=0, f; f=files[i]; i++) {
+                if (currentfilecount >= this.maxfiles && this.maxfiles != -1) {
+                    break;
+                }
+                if (this.upload_file(f)) {
+                    currentfilecount++;
+                }
+            }
+        } else {
+            if (files.length >= 1) {
+                this.upload_file(files[0]);
+            }
+        }
+
+        return false;
+    },
+
+    /**
+     * Check to see if the drag event has any files in it
+     *
+     * @param e event object
+     * @return boolean true if event has files
+     */
+    has_files: function(e) {
+        var types = e._event.dataTransfer.types;
+        for (var i=0; i<types.length; i++) {
+            if (types[i] == 'Files') {
+                return true;
+            }
+        }
+        return false;
+    },
+
+    /**
+     * Check if reached the maximumum number of allowed files
+     *
+     * @return boolean true if reached maximum number of files
+     */
+    reached_maxfiles: function() {
+        if (this.filemanager) {
+            if (this.filemanager.filecount >= this.maxfiles && this.maxfiles != -1) {
+                return true;
+            }
+        }
+        return false;
+    },
+
+    /**
+     * Highlight the destination node
+     */
+    show_upload_ready: function() {
+        this.container.addClass('dndupload-over');
+    },
+
+    /**
+     * Remove highlight on destination node
+     */
+    hide_upload_ready: function() {
+        this.container.removeClass('dndupload-over');
+    },
+
+    /**
+     * Display a progress spinner in the destination node
+     */
+    show_progress_spinner: function() {
+        // add a loading spinner to show something is happening
+        var loadingspinner = this.Y.Node.create('<div id="dndprogresspinner-'+this.clientid+'" style="text-align: center">');
+        loadingspinner.append('<img src="'+M.util.image_url('i/loading_small')+'" />');
+        this.container.append(loadingspinner);
+    },
+
+    /**
+     * Remove progress spinner in the destination node
+     */
+    hide_progress_spinner: function() {
+        this.Y.one('#dndprogresspinner-'+this.clientid).remove();
+    },
+
+    /**
+     * Tell the attached filemanager element (if any) to refresh on file
+     * upload
+     */
+    update_filemanager: function() {
+        if (this.filemanager) {
+            // update the filemanager that we've uploaded the files
+            this.hide_progress_spinner();
+            this.filemanager.filepicker_callback();
+        }
+    },
+
+    /**
+     * Upload a single file via an AJAX call to the 'upload' repository
+     */
+    upload_file: function(file) {
+        if (file.size > this.maxbytes && this.maxbytes > 0) {
+            // Check filesize before attempting to upload
+            alert(M.util.get_string('uploadformlimit', 'moodle')+"\n'"+file.name+"'");
+            return false;
+        }
+
+        // This would be an ideal place to use the Y.io function
+        // however, this does not support data encoded using the
+        // FormData object, which is needed to transfer data from
+        // the DataTransfer object into an XMLHTTPRequest
+        // This can be converted when the YUI issue has been integrated:
+        // http://yuilibrary.com/projects/yui3/ticket/2531274
+        var xhr = new XMLHttpRequest();
+        var self = this;
+        xhr.onreadystatechange = function() { // Process the server response
+            if (xhr.readyState == 4 && xhr.status == 200) {
+                var result = JSON.parse(xhr.responseText);
+                if (result) {
+                    if (result.error) {
+                        self.hide_progress_spinner();
+                        alert(result.error);
+                    } else if (self.callback) {
+                        // Only update the filepicker if there were no errors
+                        self.hide_progress_spinner();
+                        if (result.event == 'fileexists') {
+                            // Do not worry about this, as we only care about the last
+                            // file uploaded, with the filepicker
+                            result.file = result.newfile.filename;
+                            result.url = result.newfile.url;
+                        }
+                        result.client_id = self.clientid;
+                        self.callback(result);
+                    } else {
+                        self.update_filemanager();
+                    }
+                }
+            }
+        };
+
+        // Prepare the data to send
+        var formdata = new FormData();
+        formdata.append('repo_upload_file', file); // The FormData class allows us to attach a file
+        formdata.append('sesskey', M.cfg.sesskey);
+        formdata.append('repo_id', this.repositoryid);
+        formdata.append('itemid', this.itemid);
+        if (this.filemanager) { // Filepickers do not have folders
+            formdata.append('savepath', this.filemanager.currentpath);
+        }
+
+        if (this.acceptedtypes.constructor == Array) {
+            for (var i=0; i<this.acceptedtypes.length; i++) {
+                formdata.append('accepted_types[]', this.acceptedtypes[i]);
+            }
+        } else {
+            formdata.append('accepted_types[]', this.acceptedtypes);
+        }
+
+        // Send the file & required details
+        xhr.open("POST", this.url, true);
+        xhr.send(formdata);
+        return true;
+    }
+};
index cf6837b..ba2939e 100644 (file)
@@ -763,5 +763,16 @@ M.form_filemanager.init = function(Y, options) {
         item.style.display = '';
     }
 
-    new FileManagerHelper(options);
+    var manager = new FileManagerHelper(options);
+    var dndoptions = {
+        filemanager: manager,
+        acceptedtypes: options.accepted_types,
+        clientid: options.client_id,
+        maxfiles: options.maxfiles,
+        maxbytes: options.maxbytes,
+        itemid: options.itemid,
+        repositories: manager.filepicker_options.repositories,
+        containerprefix: '#filemanager-',
+    };
+    M.form_dndupload.init(Y, dndoptions);
 };
index 3670fef..774a509 100644 (file)
@@ -274,6 +274,7 @@ function form_filemanager_render($options) {
     }
 
     $maxsize = get_string('maxfilesize', 'moodle', display_size(get_max_upload_file_size($CFG->maxbytes, $course_maxbytes, $options->maxbytes)));
+    $strdndenabled = get_string('dndenabled', 'moodle').$OUTPUT->help_icon('dndenabled');
     $html .= <<<FMHTML
 <div class="filemanager-loading mdl-align" id='filemanager-loading-{$client_id}'>
 $icon_progress
@@ -285,6 +286,7 @@ $icon_progress
         <input type="button" class="fm-btn-mkdir" id="btncrt-{$client_id}" onclick="return false" value="{$strmakedir}" />
         <input type="button" class="fm-btn-download" id="btndwn-{$client_id}" onclick="return false" {$extra} value="{$strdownload}" />
         <span> $maxsize </span>
+        <span id="dndenabled-{$client_id}" style="display: none"> - $strdndenabled </span>
     </div>
     <div class="filemanager-container" id="filemanager-{$client_id}">
         <ul id="draftfiles-{$client_id}" class="fm-filelist">
@@ -304,7 +306,7 @@ FMHTML;
     $module = array(
         'name'=>'form_filemanager',
         'fullpath'=>'/lib/form/filemanager.js',
-        'requires' => array('core_filepicker', 'base', 'io-base', 'node', 'json', 'yui2-button', 'yui2-container', 'yui2-layout', 'yui2-menu', 'yui2-treeview'),
+        'requires' => array('core_filepicker', 'base', 'io-base', 'node', 'json', 'yui2-button', 'yui2-container', 'yui2-layout', 'yui2-menu', 'yui2-treeview', 'core_dndupload'),
         'strings' => array(array('loading', 'repository'), array('nomorefiles', 'repository'), array('confirmdeletefile', 'repository'),
              array('add', 'repository'), array('accessiblefilepicker', 'repository'), array('move', 'moodle'),
              array('cancel', 'moodle'), array('download', 'moodle'), array('ok', 'moodle'),
index e27d4af..51d8ef6 100644 (file)
@@ -43,4 +43,16 @@ M.form_filepicker.init = function(Y, options) {
     if (item) {
         item.style.display = '';
     }
+
+    var dndoptions = {
+        clientid: options.client_id,
+        acceptedtypes: options.accepted_types,
+        maxfiles: -1,
+        maxbytes: options.maxbytes,
+        itemid: options.itemid,
+        repositories: options.repositories,
+        formcallback: options.formcallback,
+        containerprefix: '#file_info_',
+    };
+    M.form_dndupload.init(Y, dndoptions);
 };
index c37da6d..771daa1 100644 (file)
@@ -88,7 +88,7 @@ class MoodleQuickForm_filepicker extends HTML_QuickForm_input {
         $html .= $OUTPUT->render($fp);
         $html .= '<input type="hidden" name="'.$elname.'" id="'.$id.'" value="'.$draftitemid.'" class="filepickerhidden"/>';
 
-        $module = array('name'=>'form_filepicker', 'fullpath'=>'/lib/form/filepicker.js', 'requires'=>array('core_filepicker', 'node', 'node-event-simulate'));
+        $module = array('name'=>'form_filepicker', 'fullpath'=>'/lib/form/filepicker.js', 'requires'=>array('core_filepicker', 'node', 'node-event-simulate', 'core_dndupload'));
         $PAGE->requires->js_init_call('M.form_filepicker.init', array($fp->options), true, $module);
 
         $nonjsfilepicker = new moodle_url('/repository/draftfiles_manager.php', array(
index 7288f57..a3dd170 100644 (file)
@@ -1902,6 +1902,7 @@ class core_renderer extends renderer_base {
         $strsaved = get_string('filesaved', 'repository');
         $straddfile = get_string('openpicker', 'repository');
         $strloading  = get_string('loading', 'repository');
+        $strdndenabled = get_string('dndenabled_single', 'moodle');
         $icon_progress = $OUTPUT->pix_icon('i/loading_small', $strloading).'';
 
         $currentfile = $options->currentfile;
@@ -1935,7 +1936,9 @@ $icon_progress
 EOD;
         if ($options->env != 'url') {
             $html .= <<<EOD
-    <div id="file_info_{$client_id}" class="mdl-left filepicker-filelist">$currentfile</div>
+    <div id="file_info_{$client_id}" class="mdl-left filepicker-filelist">
+    $currentfile<span id="dndenabled-{$client_id}" style="display: none"> - $strdndenabled </span>
+    </div>
 EOD;
         }
         $html .= '</div>';
index f216297..0e31bdd 100644 (file)
@@ -464,6 +464,12 @@ class page_requirements_manager {
                                     'fullpath' => '/files/module.js',
                                     'requires' => array('node', 'event', 'overlay', 'io-base', 'json', 'yui2-treeview'));
                     break;
+                case 'core_dndupload':
+                    $module = array('name'     => 'core_dndupload',
+                                    'fullpath' => '/lib/form/dndupload.js',
+                                    'requires' => array('node', 'event', 'json'),
+                                    'strings'  => array(array('uploadformlimit', 'moodle')));
+                    break;
             }
 
         } else {
index 4981c01..d7c95c8 100644 (file)
@@ -518,6 +518,7 @@ body.tag .managelink {padding: 5px;}
 .filemanager-toolbar {margin: 5px 0;}
 .filemanager-toolbar a {border: 1px solid #AACCEE;background: #F4FAFF;color: black;padding: 3px;}
 .filemanager-toolbar a:hover {background: #FFFFFF;}
+.filemanager-toolbar .helplink a {border: 0px; background: transparent;}
 .fm-breadcrumb {margin:0;}
 .filemanager-container {padding: 5px;margin: 6px 0;background: #E9F4FF;border: #AACCEE 1px solid}
 .filemanager-container ul{margin:0;padding:0;}
@@ -531,6 +532,9 @@ body.tag .managelink {padding: 5px;}
 .fm-file-entry{border: 1px solid red;}
 .fm-operation {font-weight: bold;}
 
+.filemanager-container.dndupload-over,
+.filepicker-filelist.dndupload-over {background: #8EF947;}
+
 /*
  * Backup and Restore CSS
  */