diff options
author | Dries Buytaert <dries@buytaert.net> | 2005-08-31 18:37:30 +0000 |
---|---|---|
committer | Dries Buytaert <dries@buytaert.net> | 2005-08-31 18:37:30 +0000 |
commit | e03ce2f99670b75a7f3e0709dd8705a2a3f4625e (patch) | |
tree | a7678fa8a55ccd2927cbbc8d0e30bbacf630ad3d | |
parent | 3029da00d62c20a4d97d668931bf9f0c918d1b09 (diff) | |
download | brdo-e03ce2f99670b75a7f3e0709dd8705a2a3f4625e.tar.gz brdo-e03ce2f99670b75a7f3e0709dd8705a2a3f4625e.tar.bz2 |
- Patch #28483 by Steven: JavaScript enabled uploading.
Comment from Steven: It does this by redirecting the submission of the form to a hidden <iframe> when you click "Attach" (we cannot submit data through Ajax directly because you cannot read file contents from JS for security reasons). Once the file is submitted, the upload-section of the form is updated. Things to note:
* The feature degrades back to the current behaviour without JS.
* If there are errors with the uploaded file (disallowed type, too big, ...), they are displayed at the top of the file attachments fieldset.
* Though the hidden-iframe method sounds dirty, it's quite compact and is 100% implemented in .js files. The drupal.js api makes it a snap to use.
* I included some minor improvements to the Drupal JS API and code.
* I added an API drupal_call_js() to bridge the PHP/JS gap: it takes a function name and arguments, and outputs a <script> tag. The kicker is that it preserves the structure and type of arguments, so e.g. PHP associative arrays end up as objects in JS.
* I also included a progressbar widget that I wrote for drumm's ongoing update.php work. It includes Ajax status updating/monitoring, but it is only used as a pure throbber in this patch. But as the code was already written and is going to be used in the near future, I left that part in. It's pretty small ;). If PHP supports ad-hoc upload info in the future like Ruby on Rails, we can implement that in 5 minutes.
-rw-r--r-- | includes/common.inc | 55 | ||||
-rw-r--r-- | misc/autocomplete.js | 5 | ||||
-rw-r--r-- | misc/drupal.css | 22 | ||||
-rw-r--r-- | misc/drupal.js | 60 | ||||
-rw-r--r-- | misc/progress.gif | bin | 0 -> 1254 bytes | |||
-rw-r--r-- | misc/progress.js | 80 | ||||
-rw-r--r-- | misc/upload.js | 59 | ||||
-rw-r--r-- | modules/upload.module | 38 | ||||
-rw-r--r-- | modules/upload/upload.module | 38 |
9 files changed, 344 insertions, 13 deletions
diff --git a/includes/common.inc b/includes/common.inc index 7b6fe29da..2a098aa7c 100644 --- a/includes/common.inc +++ b/includes/common.inc @@ -1461,6 +1461,10 @@ function form_file($title, $name, $size, $description = NULL, $required = FALSE) * The internal name used to refer to the field. * @param $value * The stored data. + * @param $edit + * The array name to prefix to the $name. + * @param $attributes + * An array of HTML attributes for the input tag. * @return * A themed HTML string representing the hidden field. * @@ -1468,8 +1472,8 @@ function form_file($title, $name, $size, $description = NULL, $required = FALSE) * but be sure to validate the data on the receiving page as it is possible for * an attacker to change the value before it is submitted. */ -function form_hidden($name, $value, $edit = 'edit') { - return '<input type="hidden" name="'. $edit .'['. $name .']" value="'. check_plain($value) ."\" />\n"; +function form_hidden($name, $value, $edit = 'edit', $attributes = NULL) { + return '<input type="hidden" name="'. $edit .'['. $name .']" id="'. form_clean_id($edit .'-'. $name) .'" value="'. check_plain($value) .'"'. drupal_attributes($attributes) ." />\n"; } /** @@ -1488,7 +1492,7 @@ function form_hidden($name, $value, $edit = 'edit') { * A themed HTML string representing the button. */ function form_button($value, $name = 'op', $type = 'submit', $attributes = NULL) { - return '<input type="'. $type .'" class="form-'. $type .'" name="'. $name .'" value="'. check_plain($value) .'" '. drupal_attributes($attributes) ." />\n"; + return '<input type="'. $type .'" class="form-'. $type .'" name="'. $name .'" id="'. form_clean_id($name) .'" value="'. check_plain($value) .'" '. drupal_attributes($attributes) ." />\n"; } /** @@ -1806,6 +1810,51 @@ function drupal_add_js($file) { } /** + * Generates a Javascript call, while importing the arguments as is. + * PHP arrays are turned into JS objects to preserve keys. This means the array + * keys must conform to JS's member naming rules. + * + * @param $function + * The name of the function to call. + * @param $arguments + * An array of arguments. + */ +function drupal_call_js($function) { + $arguments = func_get_args(); + array_shift($arguments); + $args = array(); + foreach ($arguments as $arg) { + $args[] = drupal_to_js($arg); + } + $output = '<script type="text/javascript">'. $function .'('. implode(', ', $args) .');</script>'; + return $output; +} + +/** + * Converts a PHP variable into its Javascript equivalent. + */ +function drupal_to_js($var) { + switch (gettype($var)) { + case 'boolean': + case 'integer': + case 'double': + return $var; + case 'resource': + case 'string': + return '"'. str_replace(array("\r", "\n"), array('\r', '\n'), addslashes($var)) .'"'; + case 'array': + case 'object': + $output = array(); + foreach ($var as $k => $v) { + $output[] = $k .': '. drupal_to_js($v); + } + return '{ '. implode(', ', $output) .' }'; + default: + return 'null'; + } +} + +/** * Implode a PHP array into a string that can be decoded by the autocomplete JS routines. * * Items are separated by double pipes. Each item consists of a key-value pair diff --git a/misc/autocomplete.js b/misc/autocomplete.js index 52702909d..0f4eee475 100644 --- a/misc/autocomplete.js +++ b/misc/autocomplete.js @@ -17,10 +17,9 @@ function autocompleteAutoAttach() { if (!acdb[uri]) { acdb[uri] = new ACDB(uri); } - id = input.id.substr(0, input.id.length - 13); - input = document.getElementById(id); + input = $(input.id.substr(0, input.id.length - 13)); input.setAttribute('autocomplete', 'OFF'); - input.form.onsubmit = autocompleteSubmit; + addSubmitEvent(input.form, autocompleteSubmit); new jsAC(input, acdb[uri]); } } diff --git a/misc/drupal.css b/misc/drupal.css index 258fd8791..a8366dd37 100644 --- a/misc/drupal.css +++ b/misc/drupal.css @@ -579,6 +579,28 @@ input.throbbing { } /* +** Progressbar styles +*/ +.progress { + font-weight: bold; +} +.progress .bar { + background: #fff url('progress.gif'); + border: 1px solid #00375a; + height: 1.5em; + margin-top: 0.2em; +} +.progress .filled { + background: #0072b9; + height: 1.33em; + border-bottom: 0.67em solid #004a73; + width: 0%; +} +.progress .percentage { + float: right; +} + +/* ** Collapsing fieldsets */ html.js fieldset.collapsed { diff --git a/misc/drupal.js b/misc/drupal.js index 80414afb0..4007817c4 100644 --- a/misc/drupal.js +++ b/misc/drupal.js @@ -102,6 +102,42 @@ function HTTPPost(uri, object, callbackFunction, callbackParameter) { } /** + * Redirects a button's form submission to a hidden iframe and displays the result + * in a given wrapper. The iframe should contain a call to + * window.parent.iframeHandler() after submission. + */ +function redirectFormButton(uri, button, handler) { + // Insert the iframe + var div = document.createElement('div'); + div.innerHTML = '<iframe name="redirect-target" id="redirect-target" src="" style="width:0px;height:0px;border:0;"></iframe>'; + button.parentNode.appendChild(div); + + // Trap the button + button.onfocus = function() { + button.onclick = function() { + // Prepare vars for use in anonymous function. + var button = this; + var action = button.form.action; + var target = button.form.target; + // Redirect form submission + this.form.action = uri; + this.form.target = 'redirect-target'; + handler.onsubmit(); + // Set iframe handler for later + window.iframeHandler = function (data) { + // Restore form submission + button.form.action = action; + button.form.target = target; + handler.oncomplete(data); + } + } + } + button.onblur = function() { + button.onclick = null; + } +} + +/** * Adds a function to the window onload event */ function addLoadEvent(func) { @@ -118,6 +154,21 @@ function addLoadEvent(func) { } /** + * Adds a function to the window onload event + */ +function addSubmitEvent(form, func) { + var oldSubmit = form.onsubmit; + if (typeof oldSubmit != 'function') { + form.onsubmit = func; + } + else { + form.onsubmit = function() { + return oldSubmit() && func(); + } + } +} + +/** * Retrieves the absolute position of an element on the screen */ function absolutePosition(el) { @@ -196,7 +247,7 @@ function eregReplace(search, replace, subject) { */ function removeNode(node) { if (typeof node == 'string') { - node = document.getElementById(node); + node = $(node); } if (node && node.parentNode) { return node.parentNode.removeChild(node); @@ -205,3 +256,10 @@ function removeNode(node) { return false; } } + +/** + * Wrapper around document.getElementById(). + */ +function $(id) { + return document.getElementById(id); +} diff --git a/misc/progress.gif b/misc/progress.gif Binary files differnew file mode 100644 index 000000000..3564e7b06 --- /dev/null +++ b/misc/progress.gif diff --git a/misc/progress.js b/misc/progress.js new file mode 100644 index 000000000..b4e4c9039 --- /dev/null +++ b/misc/progress.js @@ -0,0 +1,80 @@ +/** + * A progressbar object. Initialized with the given id. Must be inserted into + * the DOM afterwards through progressBar.element. + * + * e.g. pb = new progressBar('myProgressBar'); + * some_element.appendChild(pb.element); + */ +function progressBar(id) { + var pb = this; + this.id = id; + + this.element = document.createElement('div'); + this.element.id = id; + this.element.className = 'progress'; + this.element.innerHTML = '<div class="percentage"></div>'+ + '<div class="status"> </div>'+ + '<div class="bar"><div class="filled"></div></div>'; +} + +/** + * Set the percentage and status message for the progressbar. + */ +progressBar.prototype.setProgress = function (percentage, status) { + var divs = this.element.getElementsByTagName('div'); + for (i in divs) { + if (percentage >= 0) { + if (hasClass(divs[i], 'filled')) { + divs[i].style.width = percentage + '%'; + } + if (hasClass(divs[i], 'percentage')) { + divs[i].innerHTML = percentage + '%'; + } + } + if (hasClass(divs[i], 'status')) { + divs[i].innerHTML = status; + } + } +} + +/** + * Start monitoring progress via Ajax. + */ +progressBar.prototype.startMonitoring = function (uri, delay) { + this.delay = delay; + this.uri = uri; + this.sendPing(); +} + +/** + * Stop monitoring progress via Ajax. + */ +progressBar.prototype.stopMonitoring = function () { + clearTimeout(this.timer); +} + +/** + * Request progress data from server. + */ +progressBar.prototype.sendPing = function () { + if (this.timer) { + clearTimeout(this.timer); + } + HTTPGet(this.uri, this.receivePing, this); +} + +/** + * HTTP callback function. Passes data back to the progressbar and sets a new + * timer for the next ping. + */ +progressBar.prototype.receivePing = function(string, xmlhttp, pb) { + if (xmlhttp.status != 200) { + return alert('An HTTP error '+ xmlhttp.status +' occured.\n'+ pb.uri); + } + // Split into values + var matches = string.length > 0 ? string.split('|') : []; + if (matches.length >= 2) { + pb.setProgress(matches[0], matches[1]); + } + pb.timer = setTimeout(function() { pb.sendPing(); }, pb.delay); +} diff --git a/misc/upload.js b/misc/upload.js new file mode 100644 index 000000000..48f403448 --- /dev/null +++ b/misc/upload.js @@ -0,0 +1,59 @@ +// Global killswitch +if (isJsEnabled()) { + addLoadEvent(uploadAutoAttach); +} + +/** + * Attaches the upload behaviour to the upload form. + */ +function uploadAutoAttach() { + var acdb = []; + var inputs = document.getElementsByTagName('input'); + for (i = 0; input = inputs[i]; i++) { + if (input && hasClass(input, 'upload')) { + var uri = input.value; + var button = input.id.substr(5); + var wrapper = button + '-wrapper'; + var hide = button + '-hide'; + var upload = new jsUpload(uri, button, wrapper, hide); + } + } +} + +/** + * JS upload object. + */ +function jsUpload(uri, button, wrapper, hide) { + var upload = this; + this.button = button; + this.wrapper = wrapper; + this.hide = hide; + redirectFormButton(uri, $(button), this); +} + +/** + * Handler for the form redirection submission. + */ +jsUpload.prototype.onsubmit = function () { + var hide = $(this.hide); + // Insert progressbar and stretch to take the same space. + this.progress = new progressBar('uploadprogress'); + this.progress.setProgress(-1, 'Uploading file...'); + this.progress.element.style.width = '28em'; + this.progress.element.style.height = hide.offsetHeight +'px'; + hide.parentNode.insertBefore(this.progress.element, hide); + // Hide file form + hide.style.display = 'none'; +} + +/** + * Handler for the form redirection completion. + */ +jsUpload.prototype.oncomplete = function (data) { + // Remove progressbar + removeNode(this.progress); + this.progress = null; + // Replace form and re-attach behaviour + $(this.wrapper).innerHTML = data; + uploadAutoAttach(); +}
\ No newline at end of file diff --git a/modules/upload.module b/modules/upload.module index 3cf56d16c..335fd8dfc 100644 --- a/modules/upload.module +++ b/modules/upload.module @@ -60,6 +60,12 @@ function upload_menu($may_cache) { 'access' => user_access('administer site configuration'), 'type' => MENU_NORMAL_ITEM ); + $items[] = array( + 'path' => 'upload/js', + 'callback' => 'upload_js', + 'access' => user_access('upload files'), + 'type' => MENU_CALLBACK + ); } else { // Add handlers for previewing new uploads. @@ -378,8 +384,18 @@ function upload_delete($node) { } function upload_form($node) { + drupal_add_js('misc/progress.js'); + drupal_add_js('misc/upload.js'); + + $output = '<div id="fileop-wrapper">'. _upload_form($node) .'</div>'; + + return '<div class="attachments">'. form_group_collapsible(t('File attachments'), $output, empty($node->files), t('Changes made to the attachments are not permanent until you save this post. The first "listed" file will be included in RSS feeds.')) .'</div>'; +} + +function _upload_form($node) { $header = array(t('Delete'), t('List'), t('Url'), t('Size')); $rows = array(); + $output = ''; if (is_array($node->files)) { foreach ($node->files as $key => $file) { @@ -393,15 +409,19 @@ function upload_form($node) { } if (count($node->files)) { - $output = theme('table', $header, $rows); + $output .= theme('table', $header, $rows); } if (user_access('upload files')) { + $output .= '<div id="fileop-hide">'; $output .= form_file(t('Attach new file'), "upload", 40); $output .= form_button(t('Attach'), 'fileop'); + // The class triggers the js upload behaviour. + $output .= form_hidden('fileop', url('upload/js', NULL, NULL, TRUE), 'edit', array('class' => 'upload')); + $output .= '</div>'; } - return '<div class="attachments">'. form_group_collapsible(t('File attachments'), $output, empty($node->files), t('Changes made to the attachments are not permanent until you save this post. The first "listed" file will be included in RSS feeds.')) .'</div>'; + return $output; } function upload_load($node) { @@ -438,4 +458,16 @@ function _upload_image($file) { return $file; } - +/** + * Menu-callback for JavaScript-based uploads. + */ +function upload_js() { + // We only do the upload.module part of the node validation process. + $node = array2object($_POST['edit']); + upload_nodeapi(&$node, 'validate', NULL); + $output = theme('status_messages') . _upload_form($node); + + // We send the updated file attachments form. + print drupal_call_js('window.parent.iframeHandler', $output); + exit; +} diff --git a/modules/upload/upload.module b/modules/upload/upload.module index 3cf56d16c..335fd8dfc 100644 --- a/modules/upload/upload.module +++ b/modules/upload/upload.module @@ -60,6 +60,12 @@ function upload_menu($may_cache) { 'access' => user_access('administer site configuration'), 'type' => MENU_NORMAL_ITEM ); + $items[] = array( + 'path' => 'upload/js', + 'callback' => 'upload_js', + 'access' => user_access('upload files'), + 'type' => MENU_CALLBACK + ); } else { // Add handlers for previewing new uploads. @@ -378,8 +384,18 @@ function upload_delete($node) { } function upload_form($node) { + drupal_add_js('misc/progress.js'); + drupal_add_js('misc/upload.js'); + + $output = '<div id="fileop-wrapper">'. _upload_form($node) .'</div>'; + + return '<div class="attachments">'. form_group_collapsible(t('File attachments'), $output, empty($node->files), t('Changes made to the attachments are not permanent until you save this post. The first "listed" file will be included in RSS feeds.')) .'</div>'; +} + +function _upload_form($node) { $header = array(t('Delete'), t('List'), t('Url'), t('Size')); $rows = array(); + $output = ''; if (is_array($node->files)) { foreach ($node->files as $key => $file) { @@ -393,15 +409,19 @@ function upload_form($node) { } if (count($node->files)) { - $output = theme('table', $header, $rows); + $output .= theme('table', $header, $rows); } if (user_access('upload files')) { + $output .= '<div id="fileop-hide">'; $output .= form_file(t('Attach new file'), "upload", 40); $output .= form_button(t('Attach'), 'fileop'); + // The class triggers the js upload behaviour. + $output .= form_hidden('fileop', url('upload/js', NULL, NULL, TRUE), 'edit', array('class' => 'upload')); + $output .= '</div>'; } - return '<div class="attachments">'. form_group_collapsible(t('File attachments'), $output, empty($node->files), t('Changes made to the attachments are not permanent until you save this post. The first "listed" file will be included in RSS feeds.')) .'</div>'; + return $output; } function upload_load($node) { @@ -438,4 +458,16 @@ function _upload_image($file) { return $file; } - +/** + * Menu-callback for JavaScript-based uploads. + */ +function upload_js() { + // We only do the upload.module part of the node validation process. + $node = array2object($_POST['edit']); + upload_nodeapi(&$node, 'validate', NULL); + $output = theme('status_messages') . _upload_form($node); + + // We send the updated file attachments form. + print drupal_call_js('window.parent.iframeHandler', $output); + exit; +} |