diff --git a/css/media.css b/css/media.css index 2ea3699..2397a17 100644 --- a/css/media.css +++ b/css/media.css @@ -62,30 +62,34 @@ /* Media item display */ .media-item { - background: #FFFFFF; + background: #eee; border: 1px solid #CCCCCC; + box-shadow: inset 0 0 15px rgba(0,0,0,.1), inset 0 0 0 1px rgba(0,0,0,.05); + display: inline-block; padding: 5px; - position: relative; + position: relative; } .media-item img { - margin-bottom: 10px; + display: block; } .media-item .label-wrapper { - text-align: center; - position: absolute; + background: rgba(255,255,255,.8); bottom: 0; - margin-left: auto; - margin-right: auto; - width: 90%; + box-shadow: inset 0 0 0 1px rgba(0,0,0,.15); + left: 0; + max-height: 100%; + overflow: hidden; + position: absolute; + right: 0; + text-align: center; + word-wrap: break-word; } .media-item .label-wrapper label { font-size: 10px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + padding: 5px 10px; } /* Media item lists */ @@ -124,8 +128,8 @@ } .media-list-thumbnails .form-type-checkbox { - bottom: 135px; - left: 8px; + bottom: 117px; + left: 6px; margin: 0; padding: 0; position: relative; @@ -135,26 +139,6 @@ .media-widget .preview { display: inline-block; - vertical-align: middle; -} - -.media-widget .preview a { - text-decoration: none; -} - -.media-widget .preview .media-item { margin-right: 10px; -} - -.media-widget .preview .media-item:hover { - border-color: #058AC5; - cursor: pointer; -} - -.media-widget .preview .media-item .label-wrapper label { - color: #058AC5; -} - -.media-widget .preview .media-item .label-wrapper label:hover { - cursor: pointer; + vertical-align: middle; } diff --git a/includes/media.fields.inc b/includes/media.fields.inc index 29b63a9..65abe28 100644 --- a/includes/media.fields.inc +++ b/includes/media.fields.inc @@ -12,16 +12,15 @@ function media_field_widget_info() { return array( 'media_generic' => array( - 'label' => t('Media file selector'), + 'label' => t('Media browser'), 'field types' => array('file', 'image'), 'settings' => array( - 'progress_indicator' => 'throbber', 'allowed_types' => array('image'), 'browser_plugins' => array(), 'allowed_schemes' => array('public', 'private'), ), 'behaviors' => array( - 'multiple values' => FIELD_BEHAVIOR_DEFAULT, + 'multiple values' => FIELD_BEHAVIOR_CUSTOM, 'default value' => FIELD_BEHAVIOR_NONE, ), ), @@ -77,45 +76,43 @@ function media_field_formatter_view($entity_type, $entity, $field, $instance, $l function media_field_widget_settings_form($field, $instance) { $widget = $instance['widget']; $settings = $widget['settings']; - $form = array(); - $streams = file_get_stream_wrappers(STREAM_WRAPPERS_VISIBLE); + $plugins = media_get_browser_plugin_info(); + $options = array(); + foreach ($plugins as $key => $plugin) { + $options[$key] = check_plain($plugin['title']); + } + + $form['browser_plugins'] = array( + '#type' => 'checkboxes', + '#title' => t('Enabled browser plugins'), + '#options' => $options, + '#default_value' => $settings['browser_plugins'], + '#description' => t('Media browser plugins which are allowed for this field. If no plugins are selected, they will all be available.'), + ); $form['allowed_types'] = array( '#type' => 'checkboxes', - '#title' => t('Allowed remote media types'), + '#title' => t('Allowed file types'), '#options' => file_entity_type_get_names(), '#default_value' => $settings['allowed_types'], - '#description' => t('Media types which are allowed for this field when using remote streams.'), - '#weight' => 1, - '#access' => count(file_get_stream_wrappers(STREAM_WRAPPERS_VISIBLE | STREAM_WRAPPERS_LOCAL)) != count($streams), + '#description' => t('File types which are allowed for this field. If no file types are selected, they will all be available.'), ); + $visible_steam_wrappers = file_get_stream_wrappers(STREAM_WRAPPERS_VISIBLE); $options = array(); - foreach ($streams as $scheme => $data) { - $options[$scheme] = t('@scheme (@name)', array('@scheme' => $scheme . '://', '@name' => $data['name'])); + foreach ($visible_steam_wrappers as $scheme => $information) { + $options[$scheme] = check_plain($information['name']); } + $form['allowed_schemes'] = array( '#type' => 'checkboxes', '#title' => t('Allowed URI schemes'), '#options' => $options, '#default_value' => $settings['allowed_schemes'], - '#description' => t('URI schemes include public:// and private:// which are the Drupal files directories, and may also refer to remote sites.'), - '#weight' => 2, + '#description' => t('URI schemes which are allowed for this field. If no schemes are selected, they will all be available.'), ); - $plugins = media_get_browser_plugin_info(); - $form['browser_plugins'] = array( - '#type' => 'checkboxes', - '#title' => t('Enabled browser plugins'), - '#options' => array(), - '#default_value' => $settings['browser_plugins'], - '#description' => t('If no plugins are selected, they will all be available.'), - ); - foreach ($plugins as $key => $plugin) { - $form['browser_plugins']['#options'][$key] = !empty($plugin['title']) ? $plugin['title'] : $key; - } - return $form; } @@ -123,33 +120,35 @@ function media_field_widget_settings_form($field, $instance) { * Implements hook_field_widget_form(). */ function media_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) { - $field_settings = $instance['settings']; - $widget_settings = $instance['widget']['settings']; - // @todo The Field API supports automatic serialization / unserialization, so - // this should no longer be needed. After verifying with a module that uses - // the 'data' column, remove this. - // @see media_field_widget_value() - $current_value = array(); - if (isset($items[$delta])) { - $current_value = $items[$delta]; - // @todo $items[$delta] is sometimes a loaded media entity (an object) - // rather than an array. This conflicts with Field API expectations (for - // example, it results in fatal errors when previewing a node with a - // multi-valued media field), so should be fixed. In the meantime, don't - // assume that $current_value is an array. - if (is_array($current_value) && isset($current_value['data']) && is_string($current_value['data'])) { - $current_value['data'] = unserialize($current_value['data']); - } + // Add display_field setting to field because media_field_widget_form() assumes it is set. + if (!isset($field['settings']['display_field'])) { + $field['settings']['display_field'] = 0; + } + + $defaults = array( + 'fid' => 0, + 'display' => !empty($field['settings']['display_default']), + 'description' => '', + ); + + // Load the items for form rebuilds from the field state as they might not be + // in $form_state['values'] because of validation limitations. Also, they are + // only passed in as $items when editing existing entities. + $field_state = field_form_get_state($element['#field_parents'], $field['field_name'], $langcode, $form_state); + if (isset($field_state['items'])) { + $items = $field_state['items']; } + $field_settings = $instance['settings']; + $widget_settings = $instance['widget']['settings']; + + // Essentially we use the media type, extended with some enhancements. + $element_info = element_info('media'); $element += array( - // @todo This should be a fieldset, but throws a warning about - // element_children. '#type' => 'media', - '#collapsed' => TRUE, - '#default_value' => $current_value, - '#required' => $instance['required'], + '#value_callback' => 'media_field_widget_value', + '#process' => array_merge($element_info['#process'], array('media_field_widget_process')), '#media_options' => array( 'global' => array( 'types' => array_filter($widget_settings['allowed_types']), @@ -161,57 +160,386 @@ function media_field_widget_form(&$form, &$form_state, $field, $instance, $langc 'uri_scheme' => !empty($field['settings']['uri_scheme']) ? $field['settings']['uri_scheme'] : file_default_scheme(), ), ), + // Allows this field to return an array instead of a single value. + '#extended' => TRUE, ); - if ($field['cardinality'] != 1) { - $element['#title'] = check_plain($instance['label']); - $element['#title_display'] = 'invisible'; + // Add image field specific validators. + if ($field['type'] == 'image') { + if ($field_settings['min_resolution'] || $field_settings['max_resolution']) { + $element['#media_options']['global']['min_resolution'] = $field_settings['min_resolution']; + $element['#media_options']['global']['max_resolution'] = $field_settings['max_resolution']; + } + } + + if ($field['cardinality'] == 1) { + // Set the default value. + $element['#default_value'] = !empty($items) ? $items[0] : $defaults; + // If there's only one field, return it as delta 0. + $elements = array($element); + } + else { + // If there are multiple values, add an element for each existing one. + foreach ($items as $item) { + $elements[$delta] = $element; + $elements[$delta]['#default_value'] = $item; + $elements[$delta]['#weight'] = $delta; + $delta++; + } + // And then add one more empty row for new uploads except when this is a + // programmed form as it is not necessary. + if (($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED || $delta < $field['cardinality']) && empty($form_state['programmed'])) { + $elements[$delta] = $element; + $elements[$delta]['#default_value'] = $defaults; + $elements[$delta]['#weight'] = $delta; + $elements[$delta]['#required'] = ($element['#required'] && $delta == 0); + } + // The group of elements all-together need some extra functionality + // after building up the full list (like draggable table rows). + $elements['#file_upload_delta'] = $delta; + $elements['#theme'] = 'media_widget_multiple'; + $elements['#theme_wrappers'] = array('fieldset'); + $elements['#process'] = array('media_field_widget_process_multiple'); + $elements['#title'] = $element['#title']; + $elements['#description'] = $element['#description']; + $elements['#field_name'] = $element['#field_name']; + $elements['#language'] = $element['#language']; + $elements['#display_field'] = $field['settings']['display_field']; + + // Add some properties that will eventually be added to the file upload + // field. These are added here so that they may be referenced easily through + // a hook_form_alter(). + $elements['#file_upload_title'] = t('Attach media'); + } + + return $elements; +} + +/** + * The #value_callback for the media_generic field element. + */ +function media_field_widget_value($element, $input = FALSE, $form_state) { + if ($input) { + // Checkboxes lose their value when empty. + // If the display field is present make sure its unchecked value is saved. + $field = field_widget_field($element, $form_state); + if (empty($input['display'])) { + $input['display'] = $field['settings']['display_field'] ? 0 : 1; + } } - if ($field['type'] == 'file') { + // We depend on the managed file element to handle uploads. + $return = media_file_value($element, $input, $form_state); + + // Ensure that all the required properties are returned even if empty. + $return += array( + 'fid' => 0, + 'display' => 1, + 'description' => '', + ); + + return $return; +} + +/** + * An element #process callback for the file_generic field type. + * + * Expands the file_generic type to include the description and display fields. + */ +function media_field_widget_process($element, &$form_state, $form) { + $item = $element['#value']; + $item['fid'] = $element['fid']['#value']; + + $field = field_widget_field($element, $form_state); + $instance = field_widget_instance($element, $form_state); + $settings = $instance['widget']['settings']; + + $element['#theme'] = 'media_widget'; + + // Add the display field if enabled. + if (!empty($field['settings']['display_field']) && $item['fid']) { + $element['display'] = array( + '#type' => empty($item['fid']) ? 'hidden' : 'checkbox', + '#title' => t('Include file in display'), + '#value' => isset($item['display']) ? $item['display'] : $field['settings']['display_default'], + '#attributes' => array('class' => array('file-display')), + ); + } + else { $element['display'] = array( - '#type' => 'value', - '#value' => 1, + '#type' => 'hidden', + '#value' => '1', ); } - // Add image field specific validators. - if ($field['type'] == 'image') { - if ($field_settings['min_resolution'] || $field_settings['max_resolution']) { - $element['#media_options']['global']['min_resolution'] = $field_settings['min_resolution']; - $element['#media_options']['global']['max_resolution'] = $field_settings['max_resolution']; + // Add the description field if enabled. + if (!empty($instance['settings']['description_field']) && $item['fid']) { + $element['description'] = array( + '#type' => variable_get('file_description_type', 'textfield'), + '#title' => t('Description'), + '#value' => isset($item['description']) ? $item['description'] : '', + '#maxlength' => variable_get('file_description_length', 128), + '#description' => t('The description may be used as the label of the link to the file.'), + ); + } + + // Adjust the Ajax settings so that on upload and remove of any individual + // file, the entire group of file fields is updated together. + if ($field['cardinality'] != 1) { + $parents = array_slice($element['#array_parents'], 0, -1); + $new_path = 'media/ajax/' . implode('/', $parents) . '/' . $form['form_build_id']['#value']; + $field_element = drupal_array_get_nested_value($form, $parents); + $new_wrapper = $field_element['#id'] . '-ajax-wrapper'; + foreach (element_children($element) as $key) { + if (isset($element[$key]['#ajax'])) { + $element[$key]['#ajax']['path'] = $new_path; + $element[$key]['#ajax']['wrapper'] = $new_wrapper; + } } + unset($element['#prefix'], $element['#suffix']); + } + + // Add another submit handler to the upload and remove buttons, to implement + // functionality needed by the field widget. This submit handler, along with + // the rebuild logic in media_field_widget_form() requires the entire field, + // not just the individual item, to be valid. + foreach (array('attach_button', 'edit_button', 'remove_button') as $key) { + $element[$key]['#submit'][] = 'media_field_widget_submit'; + $element[$key]['#limit_validation_errors'] = array(array_slice($element['#parents'], 0, -1)); } return $element; } /** - * Widget value. + * An element #process callback for a group of file_generic fields. * - * @todo Is this function ever called? If not, remove it. The Field API now - * supports automatic serialization / unserialization, so this should no - * longer be needed. After verifying with a module that uses the 'data' - * column, remove this. + * Adds the weight field to each row so it can be ordered and adds a new Ajax + * wrapper around the entire group so it can be replaced all at once. + */ +function media_field_widget_process_multiple($element, &$form_state, $form) { + $element_children = element_children($element, TRUE); + $count = count($element_children); + + foreach ($element_children as $delta => $key) { + if ($key != $element['#file_upload_delta']) { + $description = _media_field_get_description_from_element($element[$key]); + $element[$key]['_weight'] = array( + '#type' => 'weight', + '#title' => $description ? t('Weight for @title', array('@title' => $description)) : t('Weight for new file'), + '#title_display' => 'invisible', + '#delta' => $count, + '#default_value' => $delta, + ); + } + else { + // The title needs to be assigned to the upload field so that validation + // errors include the correct widget label. + $element[$key]['#title'] = $element['#title']; + $element[$key]['_weight'] = array( + '#type' => 'hidden', + '#default_value' => $delta, + ); + } + } + + // Add a new wrapper around all the elements for Ajax replacement. + $element['#prefix'] = '
'; + $element['#suffix'] = '
'; + + return $element; +} + +/** + * Retrieves the file description from a field field element. + * + * This helper function is used by media_field_widget_process_multiple(). + * + * @param $element + * The element being processed. + * + * @return + * A description of the file suitable for use in the administrative interface. + */ +function _media_field_get_description_from_element($element) { + // Use the actual file description, if it's available. + if (!empty($element['#default_value']['description'])) { + return $element['#default_value']['description']; + } + // Otherwise, fall back to the filename. + if (!empty($element['#default_value']['filename'])) { + return $element['#default_value']['filename']; + } + // This is probably a newly uploaded file; no description is available. + return FALSE; +} + +/** + * Form submission handler for upload/remove button of media_field_widget_form(). + * + * This runs in addition to and after media_field_widget_submit(). * + * @see media_field_widget_submit() * @see media_field_widget_form() + * @see media_field_widget_process() */ -function media_field_widget_value($element, $input, $form_state) { - $return = $input; +function media_field_widget_submit($form, &$form_state) { + // During the form rebuild, media_field_widget_form() will create field item + // widget elements using re-indexed deltas, so clear out $form_state['input'] + // to avoid a mismatch between old and new deltas. The rebuilt elements will + // have #default_value set appropriately for the current state of the field, + // so nothing is lost in doing this. + $parents = array_slice($form_state['triggering_element']['#parents'], 0, -2); + drupal_array_set_nested_value($form_state['input'], $parents, NULL); + + $button = $form_state['triggering_element']; - if (!is_array($return)) { - $return = array(); + // Go one level up in the form, to the widgets container. + $element = drupal_array_get_nested_value($form, array_slice($button['#array_parents'], 0, -1)); + $field_name = $element['#field_name']; + $langcode = $element['#language']; + $parents = $element['#field_parents']; + + $submitted_values = drupal_array_get_nested_value($form_state['values'], array_slice($button['#array_parents'], 0, -2)); + foreach ($submitted_values as $delta => $submitted_value) { + if (!$submitted_value['fid']) { + unset($submitted_values[$delta]); + } } - if (isset($return['data'])) { - $return['data'] = serialize($return['data']); + // Re-index deltas after removing empty items. + $submitted_values = array_values($submitted_values); + + // Update form_state values. + drupal_array_set_nested_value($form_state['values'], array_slice($button['#array_parents'], 0, -2), $submitted_values); + + // Update items. + $field_state = field_form_get_state($parents, $field_name, $langcode, $form_state); + $field_state['items'] = $submitted_values; + field_form_set_state($parents, $field_name, $langcode, $form_state, $field_state); +} + +/** + * Returns HTML for an individual file upload widget. + * + * @param $variables + * An associative array containing: + * - element: A render element representing the widget. + * + * @ingroup themeable + */ +function theme_media_widget($variables) { + $element = $variables['element']; + $output = ''; + + // The "form-media" class is required for proper Ajax functionality. + $output .= '
'; + $output .= drupal_render_children($element); + $output .= '
'; + + return $output; +} + +/** + * Returns HTML for a group of file upload widgets. + * + * @param $variables + * An associative array containing: + * - element: A render element representing the widgets. + * + * @ingroup themeable + */ +function theme_media_widget_multiple($variables) { + $element = $variables['element']; + + // Special ID and classes for draggable tables. + $weight_class = $element['#id'] . '-weight'; + $table_id = $element['#id'] . '-table'; + + // Build up a table of applicable fields. + $headers = array(); + $headers[] = t('File information'); + if ($element['#display_field']) { + $headers[] = array( + 'data' => t('Display'), + 'class' => array('checkbox'), + ); } + $headers[] = t('Weight'); + $headers[] = t('Operations'); - $return += array( - 'fid' => 0, - 'title' => '', - 'data' => NULL, - ); + // Get our list of widgets in order (needed when the form comes back after + // preview or failed validation). + $widgets = array(); + foreach (element_children($element) as $key) { + $widgets[] = &$element[$key]; + } + usort($widgets, '_field_sort_items_value_helper'); - return $return; + $rows = array(); + foreach ($widgets as $key => &$widget) { + // Save the uploading row for last. + if ($widget['#file'] == FALSE) { + $widget['#title'] = $element['#file_upload_title']; + continue; + } + + // Delay rendering of the buttons, so that they can be rendered later in the + // "operations" column. + $operations_elements = array(); + foreach (element_children($widget) as $sub_key) { + if (isset($widget[$sub_key]['#type']) && $widget[$sub_key]['#type'] == 'submit') { + hide($widget[$sub_key]); + $operations_elements[] = &$widget[$sub_key]; + } + } + + // Delay rendering of the "Display" option and the weight selector, so that + // each can be rendered later in its own column. + if ($element['#display_field']) { + hide($widget['display']); + } + hide($widget['_weight']); + + // Render everything else together in a column, without the normal wrappers. + $widget['#theme_wrappers'] = array(); + $information = drupal_render($widget); + + // Render the previously hidden elements, using render() instead of + // drupal_render(), to undo the earlier hide(). + $operations = ''; + foreach ($operations_elements as $operation_element) { + $operations .= render($operation_element); + } + $display = ''; + if ($element['#display_field']) { + unset($widget['display']['#title']); + $display = array( + 'data' => render($widget['display']), + 'class' => array('checkbox'), + ); + } + $widget['_weight']['#attributes']['class'] = array($weight_class); + $weight = render($widget['_weight']); + + // Arrange the row with all of the rendered columns. + $row = array(); + $row[] = $information; + if ($element['#display_field']) { + $row[] = $display; + } + $row[] = $weight; + $row[] = $operations; + $rows[] = array( + 'data' => $row, + 'class' => isset($widget['#attributes']['class']) ? array_merge($widget['#attributes']['class'], array('draggable')) : array('draggable'), + ); + } + + drupal_add_tabledrag($table_id, 'order', 'sibling', $weight_class); + + $output = ''; + $output = empty($rows) ? '' : theme('table', array('header' => $headers, 'rows' => $rows, 'attributes' => array('id' => $table_id))); + $output .= drupal_render_children($element); + return $output; } diff --git a/includes/media.pages.inc b/includes/media.pages.inc deleted file mode 100644 index b22a8b1..0000000 --- a/includes/media.pages.inc +++ /dev/null @@ -1,33 +0,0 @@ -'; + $output .= drupal_render_children($element); + $output .= ''; + return $output; +} + +/** * Add messages to the page. */ function template_preprocess_media_dialog_page(&$variables) { diff --git a/js/media.js b/js/media.js index 04585bd..33e1c5a 100644 --- a/js/media.js +++ b/js/media.js @@ -1,97 +1,64 @@ - /** - * @file - * This file handles the JS for Media Module functions. + * @file + * Provides JavaScript additions to the media field widget. + * + * This file provides support for launching the media browser to select and + * attach existing files. */ (function ($) { -/** - * Loads media browsers and callbacks, specifically for media as a field. - */ -Drupal.behaviors.mediaElement = { - attach: function (context, settings) { - // Options set from media.fields.inc for the types, etc to show in the browser. - - // For each widget (in case of multi-entry) - $('.media-widget', context).once('mediaBrowserLaunch', function () { - var options = settings.media.elements[this.id]; - globalOptions = {}; - if (options.global != undefined) { - var globalOptions = options.global; + /** + * Attach behaviors to media element browse fields. + */ + Drupal.behaviors.media = { + attach: function (context, settings) { + if (settings.media && settings.media.elements) { + $.each(settings.media.elements, function(selector) { + var configuration = settings.media.elements[selector]; + $(selector, context).bind('click', {configuration: configuration}, Drupal.behaviors.media.openBrowser); + }); + } + }, + detach: function (context, settings) { + if (settings.media && settings.media.elements) { + $.each(settings.media.elements, function(selector) { + $(selector, context).unbind('click', Drupal.behaviors.media.openBrowser); + }); } - //options = Drupal.settings.media.fields[this.id]; - var fidField = $('.fid', this); - var previewField = $('.preview', this); - var editButton = $('.edit', this); - var removeButton = $('.remove', this); + }, + openBrowser: function (event) { + var clickedButton = this; + var configuration = event.data.configuration.global; + + // Find the file ID and preview fields. + var fidField = $(this).siblings('.fid'); + var previewField = $(this).siblings('.preview'); - // Hide the edit and remove buttons if there is no file data yet. - if (fidField.val() == 0) { - if (editButton.length) { - editButton.hide(); + // Find the edit and remove buttons. + var editButton = $(this).siblings('.edit'); + var removeButton = $(this).siblings('.remove'); + + // Launch the media browser. + Drupal.media.popups.mediaBrowser(function (mediaFiles) { + // Ensure that there was at least one media file selected. + if (mediaFiles.length < 0) { + return; } - removeButton.hide(); - } - // When someone clicks the link to pick media (or clicks on an existing thumbnail) - $('.launcher', this).bind('click', function (e) { - // Launch the browser, providing the following callback function - // @TODO: This should not be an anomyous function. - Drupal.media.popups.mediaBrowser(function (mediaFiles) { - if (mediaFiles.length < 0) { - return; - } - var mediaFile = mediaFiles[0]; - // Set the value of the filefield fid (hidden) and trigger a change. - fidField.val(mediaFile.fid); - fidField.trigger('change'); - // Set the preview field HTML. - previewField.html(mediaFile.preview); - }, globalOptions); - e.preventDefault(); - }); + // Grab the first of the selected media files. + var mediaFile = mediaFiles[0]; - // When someone clicks the Remove button. - $('.remove', this).bind('click', function (e) { - // Set the value of the filefield fid (hidden) and trigger change. - fidField.val(0); + // Set the value of the hidden file ID field and trigger a change. + fidField.val(mediaFile.fid); fidField.trigger('change'); - // Set the preview field HTML. - previewField.html(''); - e.preventDefault(); - }); - // Show or hide the edit/remove buttons if the field has a file or not. - $('.fid', this).bind('change', function() { - if (fidField.val() == 0) { - if (editButton.length) { - editButton.hide(); - } - removeButton.hide(); - } - else { - if (editButton.length) { - var url = Drupal.settings.basePath + 'file/' + fidField.val() + '/edit'; - $.ajax({ - url: location.protocol + '//' + location.host + url, - type: 'HEAD', - success: function(data) { - editButton.attr('href', editButton.attr('href').replace(/media\/\d+\/edit/, 'media/' + fidField.val() + '/edit')); - // Re-process the edit link through CTools modal behaviors. - editButton.unbind('click'); - editButton.removeClass('ctools-use-modal-processed'); - // @todo Maybe add support for Drupal.detachBehaviors in Drupal.behaviors.ZZCToolsModal? - Drupal.attachBehaviors(editButton.parent(), Drupal.settings); - editButton.show(); - } - }); - } - removeButton.show(); - } - }); - }); - } -}; + // Display a preview of the file using the selected media file's display. + previewField.html(mediaFile.preview); + }, configuration); + + return false; + } + }; })(jQuery); diff --git a/media.module b/media.module index 197f62c..1db30de 100644 --- a/media.module +++ b/media.module @@ -103,6 +103,14 @@ function media_menu() { 'weight' => 10, ); + $items['media/ajax'] = array( + 'page callback' => 'media_ajax_upload', + 'delivery callback' => 'ajax_deliver', + 'access arguments' => array('access content'), + 'theme callback' => 'ajax_base_page_theme', + 'type' => MENU_CALLBACK, + ); + $items['media/browser'] = array( 'title' => 'Media browser', 'description' => 'Media Browser for picking media and uploading new media', @@ -125,18 +133,6 @@ function media_menu() { 'file' => 'includes/media.browser.inc', ); - // We could re-use the file/%file/edit path for the modal callback, but - // it is just easier to use our own namespace here. - $items['media/%file/edit/%ctools_js'] = array( - 'title' => 'Edit', - 'page callback' => 'drupal_get_form', - 'page arguments' => array('media_file_edit_modal', 1, 3), - 'access callback' => 'file_entity_access', - 'access arguments' => array('update', 1), - 'file' => 'includes/media.pages.inc', - 'type' => MENU_CALLBACK, - ); - return $items; } @@ -215,10 +211,84 @@ function media_theme() { 'variables' => array('file' => NULL, 'attributes' => array(), 'style_name' => 'media_thumbnail'), 'file' => 'includes/media.theme.inc', ), + + // media.field.inc. + 'media_widget' => array( + 'render element' => 'element', + ), + 'media_widget_multiple' => array( + 'render element' => 'element', + ), + 'media_formatter_table' => array( + 'variables' => array('items' => NULL), + ), ); } /** + * Menu callback; Shared Ajax callback for file uploads and deletions. + * + * This rebuilds the form element for a particular field item. As long as the + * form processing is properly encapsulated in the widget element the form + * should rebuild correctly using FAPI without the need for additional callbacks + * or processing. + */ +function media_ajax_upload() { + $form_parents = func_get_args(); + $form_build_id = (string) array_pop($form_parents); + + if (empty($_POST['form_build_id']) || $form_build_id != $_POST['form_build_id']) { + // Invalid request. + drupal_set_message(t('An unrecoverable error occurred. The uploaded file likely exceeded the maximum file size (@size) that this server supports.', array('@size' => format_size(file_upload_max_size()))), 'error'); + $commands = array(); + $commands[] = ajax_command_replace(NULL, theme('status_messages')); + return array('#type' => 'ajax', '#commands' => $commands); + } + + list($form, $form_state) = ajax_get_form(); + + if (!$form) { + // Invalid form_build_id. + drupal_set_message(t('An unrecoverable error occurred. Use of this form has expired. Try reloading the page and submitting again.'), 'error'); + $commands = array(); + $commands[] = ajax_command_replace(NULL, theme('status_messages')); + return array('#type' => 'ajax', '#commands' => $commands); + } + + // Get the current element and count the number of files. + $current_element = $form; + foreach ($form_parents as $parent) { + $current_element = $current_element[$parent]; + } + $current_file_count = isset($current_element['#file_upload_delta']) ? $current_element['#file_upload_delta'] : 0; + + // Process user input. $form and $form_state are modified in the process. + drupal_process_form($form['#form_id'], $form, $form_state); + + // Retrieve the element to be rendered. + foreach ($form_parents as $parent) { + $form = $form[$parent]; + } + + // Add the special Ajax class if a new file was added. + if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) { + $form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content'; + } + // Otherwise just add the new content class on a placeholder. + else { + $form['#suffix'] .= ''; + } + + $output = theme('status_messages') . drupal_render($form); + $js = drupal_add_js(); + $settings = call_user_func_array('array_merge_recursive', $js['settings']['data']); + + $commands = array(); + $commands[] = ajax_command_replace(NULL, $output, $settings); + return array('#type' => 'ajax', '#commands' => $commands); +} + +/** * Implements hook_image_default_styles(). */ function media_image_default_styles() { @@ -259,38 +329,12 @@ function media_page_alter(&$page) { } /** - * Implements hook_form_FIELD_UI_FIELD_SETTINGS_FORM_alter(). - * - * @todo: Respect field settings in 7.x-2.x and handle them in the media widget - * UI. - */ -function media_form_field_ui_field_settings_form_alter(&$form, &$form_state) { - // On file fields that use the media widget we need remove specific fields. - if ($form['field']['type']['#value'] == 'file') { - $fields = field_info_instances($form['#entity_type'], $form['#bundle']); - if ($fields[$form['field']['field_name']['#value']]['widget']['type'] == 'media_generic') { - $form['field']['settings']['display_field']['#access'] = FALSE; - $form['field']['settings']['display_default']['#access'] = FALSE; - } - } -} - -/** * Implements hook_form_FIELD_UI_FIELD_EDIT_FORM_alter(). * * @todo: Respect field settings in 7.x-2.x and handle them in the media widget * UI. */ function media_form_field_ui_field_edit_form_alter(&$form, &$form_state) { - // On file fields that use the media widget we need remove specific fields. - if ($form['#field']['type'] == 'file' && $form['instance']['widget']['type']['#value'] == 'media_generic') { - $form['field']['settings']['display_field']['#access'] = FALSE; - $form['field']['settings']['display_default']['#access'] = FALSE; - $form['instance']['settings']['description_field']['#access'] = FALSE; - $form['instance']['settings']['file_extensions']['#title'] = t('Allowed file extensions for uploaded files'); - $form['instance']['settings']['file_extensions']['#maxlength'] = 255; - } - // On image fields using the media widget we remove the alt/title fields. if ($form['#field']['type'] == 'image' && $form['instance']['widget']['type']['#value'] == 'media_generic') { $form['instance']['settings']['alt_field']['#access'] = FALSE; @@ -310,23 +354,6 @@ function media_form_field_ui_field_edit_form_alter(&$form, &$form_state) { } /** - * Implements hook_form_FORM_ID_alter(). - */ -function media_form_file_entity_edit_alter(&$form, &$form_state) { - // Make adjustments to the file edit form when used in a CTools modal. - if (!empty($form_state['ajax'])) { - // Remove the preview and the delete button. - $form['preview']['#access'] = FALSE; - $form['actions']['delete']['#access'] = FALSE; - - // Convert the cancel link to a button which triggers a modal close. - $form['actions']['cancel']['#attributes']['class'][] = 'button'; - $form['actions']['cancel']['#attributes']['class'][] = 'button-no'; - $form['actions']['cancel']['#attributes']['class'][] = 'ctools-close-modal'; - } -} - -/** * Implements hook_form_alter(). */ function media_form_alter(&$form, &$form_state, $form_id) { @@ -561,12 +588,12 @@ function media_element_info() { $types['media'] = array( '#input' => TRUE, '#process' => array('media_element_process'), - // '#value_callback' => 'media_element_value', + '#value_callback' => 'media_file_value', '#element_validate' => array('media_element_validate'), - '#theme_wrappers' => array('container'), - '#progress_indicator' => 'throbber', + '#pre_render' => array('media_element_pre_render'), + '#theme' => 'media_element', + '#theme_wrappers' => array('form_element'), '#extended' => FALSE, - '#required' => FALSE, '#media_options' => array( 'global' => array( // Example: array('image', 'audio'); @@ -575,112 +602,192 @@ function media_element_info() { 'schemes' => array(), ), ), - '#attributes' => array( - 'class' => array('media-widget', 'form-item'), - ), '#attached' => array( 'library' => array( array('media', 'media_browser'), ), ), ); + + $setting = array(); + $setting['media']['global'] = $types['media']['#media_options']; + + $types['media']['#attached']['js'][] = array( + 'type' => 'setting', + 'data' => $setting, + ); + return $types; } /** * Process callback for the media form element. */ -function media_element_process(&$element, &$form_state, $form) { - $fid = isset($element['#value']['fid']) ? $element['#value']['fid'] : 0; - $file = $fid ? file_load($fid) : FALSE; - - // Add the CTools modal JavaScript for the edit button if necessary. - ctools_include('modal'); +function media_element_process($element, &$form_state, $form) { + // Add the CTools modal JavaScript for the edit button. ctools_include('ajax'); + ctools_include('modal'); ctools_modal_add_js(); + $fid = isset($element['#value']['fid']) ? $element['#value']['fid'] : 0; + // Set some default element properties. - $element['#file'] = $file; - - $element['title'] = array( - '#type' => 'item', - '#title' => $element['#title'], - '#description' => $element['#description'], - '#required' => $element['#required'], - '#weight' => -100, + $element['#file'] = $fid ? file_load($fid) : FALSE; + $element['#tree'] = TRUE; + + $ajax_settings = array( + 'path' => 'media/ajax/' . implode('/', $element['#array_parents']) . '/' . $form['form_build_id']['#value'], + 'wrapper' => $element['#id'] . '-ajax-wrapper', + 'effect' => 'fade', + ); + + // Set up the buttons first since we need to check if they were clicked. + $element['attach_button'] = array( + '#name' => implode('_', $element['#parents']) . '_attach_button', + '#type' => 'submit', + '#value' => t('Attach'), + '#validate' => array(), + '#submit' => array('media_file_submit'), + '#limit_validation_errors' => array($element['#parents']), + '#ajax' => $ajax_settings, + '#attributes' => array('class' => array('button', 'attach')), + '#weight' => -5, ); - if (isset($element['#title_display'])) { - $element['title']['#title_display'] = $element['#title_display']; - } - // @todo This should load from the JS in case of a failed form submission. $element['preview'] = array( - '#prefix' => '
', + 'content' => array(), + '#prefix' => '
', '#suffix' => '
', - '#weight' => 0, - 'content' => $file ? media_get_thumbnail_preview($file) : array(), + '#ajax' => $ajax_settings, + '#weight' => -10, ); - // @todo: Perhaps this language logic should be handled by JS since the state - // changes on client side after choosing an item. - $element['select'] = array( + // Substitute the JS preview for a true file thumbnail and disable browsing + // for files once the file is attached. + if ($element['#file']) { + $element['preview']['content'] = media_get_thumbnail_preview($element['#file']); + $element['preview']['#prefix'] = ''; + $element['preview']['#suffix'] = ''; + } + + $element['browse_button'] = array( '#type' => 'link', '#href' => '', - '#title' => t('Select'), - '#attributes' => array('class' => array('button', 'launcher')), + '#title' => t('Browse'), + '#attributes' => array('class' => array('button', 'browse')), '#options' => array('fragment' => FALSE, 'external' => TRUE), - '#weight' => 10, + '#weight' => -9, ); - // @todo Figure out how to update the preview image after the Edit modal is - // closed. - $element['edit'] = array( - '#type' => 'link', - '#href' => 'media/' . $fid . '/edit/nojs', - '#title' => t('Edit'), - '#attributes' => array( - 'class' => array( - // Required for CTools modal to work. - 'ctools-use-modal', 'use-ajax', - 'ctools-modal-media-file-edit', 'button', 'edit', - ), - ), - '#weight' => 20, - '#access' => $file ? file_entity_access('update', $file) : TRUE, // only do perm check for existing files - ); - $element['remove'] = array( - '#type' => 'link', - '#href' => '', - '#title' => t('Remove'), + + // Force the progress indicator for the remove button to be either 'none' or + // 'throbber', even if the upload button is using something else. + $ajax_settings['progress']['type'] = 'throbber'; + $ajax_settings['progress']['message'] = NULL; + $ajax_settings['effect'] = 'none'; + $element['remove_button'] = array( + '#name' => implode('_', $element['#parents']) . '_remove_button', + '#type' => 'submit', + '#value' => t('Remove'), + '#validate' => array(), + '#submit' => array('media_file_submit'), + '#limit_validation_errors' => array($element['#parents']), + '#ajax' => $ajax_settings, '#attributes' => array('class' => array('button', 'remove')), - '#options' => array('fragment' => FALSE, 'external' => TRUE), - '#weight' => 30, + '#weight' => -3, + '#access' => $element['#file'] ? file_entity_access('delete', $element['#file']) : TRUE, // only do perm check for existing files ); $element['fid'] = array( '#type' => 'hidden', '#value' => $fid, '#attributes' => array('class' => array('fid')), - '#weight' => 100, ); // Media browser attach code. $element['#attached']['js'][] = drupal_get_path('module', 'media') . '/js/media.js'; - $setting = array(); - $setting['media']['elements'][$element['#id']] = $element['#media_options']; - - $element['#attached']['js'][] = array( - 'type' => 'setting', - 'data' => $setting, + // Add the media options to the page as JavaScript settings. + $element['browse_button']['#attached']['js'] = array( + array( + 'type' => 'setting', + 'data' => array('media' => array('elements' => array('#' . $element['#id'] . '-browse-button' => $element['#media_options']))) + ) ); - // @todo: Might need to think about this. All settings would likely apply to - // all media in a multi-value, but what about passing the existing fid? module_load_include('inc', 'media', 'includes/media.browser'); media_attach_browser_js($element); + // Prefix and suffix used for Ajax replacement. + $element['#prefix'] = '
'; + $element['#suffix'] = '
'; + return $element; - // @todo: make this work for file and image fields. +} + +/** + * The #value_callback for a media type element. + */ +function media_file_value(&$element, $input = FALSE, $form_state = NULL) { + $fid = 0; + + // Find the current value of this field from the form state. + $form_state_fid = $form_state['values']; + foreach ($element['#parents'] as $parent) { + $form_state_fid = isset($form_state_fid[$parent]) ? $form_state_fid[$parent] : 0; + } + + if ($element['#extended'] && isset($form_state_fid['fid'])) { + $fid = $form_state_fid['fid']; + } + elseif (is_numeric($form_state_fid)) { + $fid = $form_state_fid; + } + + // Process any input and save new uploads. + if ($input !== FALSE) { + $return = $input; + + // Uploads take priority over all other values. + if ($file = file_managed_file_save_upload($element)) { + $fid = $file->fid; + } + else { + // Check for #filefield_value_callback values. + // Because FAPI does not allow multiple #value_callback values like it + // does for #element_validate and #process, this fills the missing + // functionality to allow File fields to be extended through FAPI. + if (isset($element['#file_value_callbacks'])) { + foreach ($element['#file_value_callbacks'] as $callback) { + $callback($element, $input, $form_state); + } + } + // Load file if the FID has changed to confirm it exists. + if (isset($input['fid']) && $file = file_load($input['fid'])) { + $fid = $file->fid; + } + } + } + + // If there is no input, set the default value. + else { + if ($element['#extended']) { + $default_fid = isset($element['#default_value']['fid']) ? $element['#default_value']['fid'] : 0; + $return = isset($element['#default_value']) ? $element['#default_value'] : array('fid' => 0); + } + else { + $default_fid = isset($element['#default_value']) ? $element['#default_value'] : 0; + $return = array('fid' => 0); + } + + // Confirm that the file exists when used as a default value. + if ($default_fid && $file = file_load($default_fid)) { + $fid = $file->fid; + } + } + + $return['fid'] = $fid; + + return $return; } /** @@ -690,20 +797,96 @@ function media_element_process(&$element, &$form_state, $form) { * necessary in order to respect the #required property. */ function media_element_validate(&$element, &$form_state) { - if ($element['#required']) { - $has_value = FALSE; - $widget_parents = $element['#array_parents']; - array_pop($widget_parents); - $items = drupal_array_get_nested_value($form_state['values'], $widget_parents); - foreach ($items as $value) { - if (is_array($value) && !empty($value['fid'])) { - $has_value = TRUE; - } - } - if (!$has_value) { - form_error($element, t('%element_title field is required.', array('%element_title' => $element['#title']))); + $clicked_button = end($form_state['triggering_element']['#parents']); + + // Check required property based on the FID. + if ($element['#required'] && empty($element['fid']['#value']) && !in_array($clicked_button, array('attach_button', 'edit_button', 'remove_button'))) { + form_error($element['browse_button'], t('!name field is required.', array('!name' => $element['#title']))); + } + + // Consolidate the array value of this field to a single FID. + if (!$element['#extended']) { + form_set_value($element, $element['fid']['#value'], $form_state); + } +} + +/** + * Form submission handler for attach / edit / remove buttons of media elements. + * + * @see media_element_process() + */ +function media_file_submit($form, &$form_state) { + // Determine whether it was the attach, edit or remove button that was + // clicked, and set $element to the managed_file element that contains that + // button. + $parents = $form_state['triggering_element']['#array_parents']; + $button_key = array_pop($parents); + $element = drupal_array_get_nested_value($form, $parents); + + // No action is needed here for the attach button, because all file uploads on + // the form are processed by media_file_value() regardless of which button was + // clicked. Action is needed here for the remove button, because we only + // remove a file in response to its remove button being clicked. + if ($button_key == 'remove_button') { + // If it's a temporary file we can safely remove it immediately, otherwise + // it's up to the implementing module to clean up files that are in use. + if ($element['#file'] && $element['#file']->status == 0) { + file_delete($element['#file']); } + // Update both $form_state['values'] and $form_state['input'] to reflect + // that the file has been removed, so that the form is rebuilt correctly. + // $form_state['values'] must be updated in case additional submit handlers + // run, and for form building functions that run during the rebuild, such as + // when the media element is part of a field widget. + // $form_state['input'] must be updated so that media_file_value() has + // correct information during the rebuild. + $values_element = $element['#extended'] ? $element['fid'] : $element; + form_set_value($values_element, NULL, $form_state); + drupal_array_set_nested_value($form_state['input'], $values_element['#parents'], NULL); } + + // Set the form to rebuild so that $form is correctly updated in response to + // processing the file removal. Since this function did not change $form_state + // if the upload button was clicked, a rebuild isn't necessary in that + // situation and setting $form_state['redirect'] to FALSE would suffice. + // However, we choose to always rebuild, to keep the form processing workflow + // consistent between the two buttons. + $form_state['rebuild'] = TRUE; +} + +/** + * #pre_render callback to hide display of the browse/attach or edit/remove + * controls. + * + * Browse/attach controls are hidden when a file is already uploaded. + * Edit/Remove controls are hidden when there is no file attached. Controls are + * hidden here instead of in media_element_process(), because #access for these + * buttons depends on the media element's #value. See the documentation of + * form_builder() for more detailed information about the relationship between + * #process, #value, and #access. + * + * Because #access is set here, it affects display only and does not prevent + * JavaScript or other untrusted code from submitting the form as though access + * were enabled. The form processing functions for these elements should not + * assume that the buttons can't be "clicked" just because they are not + * displayed. + * + * @see media_element_process() + * @see form_builder() + */ +function media_element_pre_render($element) { + // If we already have a file, we don't want to show the browse and attach + // controls. + if (!empty($element['#value']['fid'])) { + $element['browse_button']['#access'] = FALSE; + $element['attach_button']['#access'] = FALSE; + } + // If we don't already have a file, there is nothing to edit or remove. + else { + $element['edit_button']['#access'] = FALSE; + $element['remove_button']['#access'] = FALSE; + } + return $element; } /**