diff --git a/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js b/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js index 42d9e93..f4743a4 100644 --- a/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js +++ b/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js @@ -7,78 +7,133 @@ (function (CKEDITOR) { - "use strict"; - - CKEDITOR.plugins.add('drupalimagecaption', { - requires: 'widget', - init: function (editor) { - - /** - * Override drupalimage plugin's image insertion mechanism with our own, to - * ensure a widget is inserted, rather than a simple image (Widget's auto- - * discovery only runs upon init). - */ - editor.on('drupalimageinsert', function (event) { - editor.execCommand('widgetDrupalimagecaption'); - event.cancel(); - }); +"use strict"; + +CKEDITOR.plugins.add('drupalimagecaption', { + requires: 'widget', + init: function (editor) { + + /** + * Override drupalimage plugin's image insertion mechanism with our own, to + * ensure a widget is inserted, rather than a simple image (Widget's auto- + * discovery only runs upon init). + */ + editor.on('drupalimageinsert', function (event) { + editor.execCommand('widgetDrupalimagecaption'); + event.cancel(); + }); + + // Register the widget with a unique name "drupalimagecaption". + editor.widgets.add('drupalimagecaption', { + allowedContent: 'img[!src,alt,width,height,!data-caption,!data-align]', + template: '', + parts: { + image: 'img' + }, + + // Parse all attributes, ignoring CKEditor's own additions. + _parseNonCKEAttributes: function (node) { + var data = {}; + var attrs = node.attributes; + for (var i = 0; i < attrs.length; i++) { + var attr = attrs.item(i); + // Ignore CKEditor's 'data-widget' and data-cke-saved-' attributes. + if (attr.name === 'data-widget' || attr.name.substr(0, 15) === 'data-cke-saved-') { + continue; + } + data[attr.name] = attr.value; + } + // Ignore CKEditor's 'cke_widget_element' class. + if (data.class) { + data.class = data.class.replace('cke_widget_element', '').trim(); + if (data.class.length === 0) { + delete data.class; + } + } - // Register the widget with a unique name "drupalimagecaption". - editor.widgets.add('drupalimagecaption', { - allowedContent: 'img[!src,alt,width,height,!data-caption,!data-align]', - template: '', - parts: { - image: 'img' - }, + return data; + }, + + // Initialization method called for every widget instance being + // upcasted. + init: function () { + var image = this.parts.image; + + // Parse all attributes; we want to persist all attributes, not just the + // ones this plugin handles! + var data = this._parseNonCKEAttributes(image.$); + + // Transform the 'data-caption' and 'data-align' attributes into the + // form expected by this CKEditor plugin. + // @todo Ideally, migrate away from the "data_something" variants. + data['data_caption'] = null; + if (data['data-caption']) { + data['data_caption'] = data['data-caption']; + delete data['data-caption']; + } + data['data_align'] = null; + if (data['data-align']) { + data['data_align'] = data['data-align']; + delete data['data-align']; + } + data.hasCaption = image.hasAttribute('data-caption'); - // Initialization method called for every widget instance being - // upcasted. - init: function () { - var image = this.parts.image; - - // Save the initial widget data. - this.setData({ - 'data-editor-file-uuid': image.getAttribute('data-editor-file-uuid'), - src: image.getAttribute('src'), - width: image.getAttribute('width') || '', - height: image.getAttribute('height') || '', - alt: image.getAttribute('alt') || '', - data_caption: image.getAttribute('data-caption'), - data_align: image.getAttribute('data-align'), - hasCaption: image.hasAttribute('data-caption') - }); + // Save the initial widget data. + this.setData(data); + + image.removeStyle('float'); + }, + // Called after initialization and on "data" changes. + data: function () { + if (this.data['data-editor-file-uuid'] !== null) { + this.parts.image.setAttribute('data-editor-file-uuid', this.data['data-editor-file-uuid']); + this.parts.image.setAttribute('data-cke-saved-data-editor-file-uuid', this.data['data-editor-file-uuid']); + } + this.parts.image.setAttribute('src', this.data.src); + this.parts.image.setAttribute('data-cke-saved-src', this.data.src); + this.parts.image.setAttribute('alt', this.data.alt); + this.parts.image.setAttribute('data-cke-saved-alt', this.data.alt); + this.parts.image.setAttribute('width', this.data.width); + this.parts.image.setAttribute('data-cke-saved-width', this.data.width); + this.parts.image.setAttribute('height', this.data.height); + this.parts.image.setAttribute('data-cke-saved-height', this.data.height); + if (this.data.hasCaption) { + this.parts.image.setAttribute('data-caption', this.data.data_caption); + this.parts.image.setAttribute('data-cke-saved-data-caption', this.data.data_caption); + } + else { + this.parts.image.removeAttributes(['data-caption', 'data-cke-saved-data-caption']); + } + if (this.data.data_align !== null) { + this.parts.image.setAttribute('data-align', this.data.data_align); + this.parts.image.setAttribute('data-cke-saved-data-align', this.data.data_align); + } + else { + this.parts.image.removeAttributes(['data-align', 'data-cke-saved-data-align']); + } image.removeStyle('float'); }, - // Called after initialization and on "data" changes. - data: function () { - if (this.data['data-editor-file-uuid'] !== null) { - this.parts.image.setAttribute('data-editor-file-uuid', this.data['data-editor-file-uuid']); - this.parts.image.setAttribute('data-cke-saved-data-editor-file-uuid', this.data['data-editor-file-uuid']); - } - this.parts.image.setAttribute('src', this.data.src); - this.parts.image.setAttribute('data-cke-saved-src', this.data.src); - this.parts.image.setAttribute('alt', this.data.alt); - this.parts.image.setAttribute('data-cke-saved-alt', this.data.alt); - this.parts.image.setAttribute('width', this.data.width); - this.parts.image.setAttribute('data-cke-saved-width', this.data.width); - this.parts.image.setAttribute('height', this.data.height); - this.parts.image.setAttribute('data-cke-saved-height', this.data.height); - if (this.data.hasCaption) { - this.parts.image.setAttribute('data-caption', this.data.data_caption); - this.parts.image.setAttribute('data-cke-saved-data-caption', this.data.data_caption); - } - else { - this.parts.image.removeAttributes(['data-caption', 'data-cke-saved-data-caption']); - } - if (this.data.data_align !== null) { - this.parts.image.setAttribute('data-align', this.data.data_align); - this.parts.image.setAttribute('data-cke-saved-data-align', this.data.data_align); - } - else { - this.parts.image.removeAttributes(['data-align', 'data-cke-saved-data-align']); + + // Convert the element back to its desired output representation. + downcast: function (el) { + // Set all known attributes. + for (var key in this.data) { + if (this.data.hasOwnProperty(key)) { + // Skip over the 'data_caption', 'data_align' and 'hasCaption' + // values, because those don't map 1:1 to attributes. + // @todo Ideally, get rid of these. + if (key === 'data_align' || key === 'data_caption' || key === 'hasCaption') { + continue; + } + el.attributes[key] = this.data[key]; } + } + + if (this.data.hasCaption) { + el.attributes['data-caption'] = this.data.data_caption; + } // Float the wrapper too. if (this.data.data_align === null) { @@ -168,7 +223,7 @@ html += ' />'; var el = new CKEDITOR.dom.element.createFromHtml(html, editor.document); editor.insertElement(editor.widgets.wrapElement(el, 'drupalimagecaption')); - + // Save snapshot for undo support. editor.fire('saveSnapshot'); @@ -196,16 +251,11 @@ var saveCallback = function (returnValues) { editor.fire('saveSnapshot'); // Set the updated widget data. - that.setData({ - 'data-editor-file-uuid': returnValues.attributes['data-editor-file-uuid'], - src: returnValues.attributes.src, - width: returnValues.attributes.width, - height: returnValues.attributes.height, - alt: returnValues.attributes.alt, - hasCaption: !!returnValues.hasCaption, - data_caption: returnValues.hasCaption ? that.data.data_caption : '', - data_align: returnValues.attributes.data_align === 'none' ? null : returnValues.attributes.data_align - }); + var data = returnValues.attributes; + data.hasCaption = !!returnValues.hasCaption; + data.data_caption = returnValues.hasCaption ? that.data.data_caption : ''; + data.data_align = returnValues.attributes.data_align === 'none' ? null : returnValues.attributes.data_align; + that.setData(data); // Save snapshot for undo support. editor.fire('saveSnapshot'); }; diff --git a/core/modules/editor/lib/Drupal/editor/Form/EditorImageDialog.php b/core/modules/editor/lib/Drupal/editor/Form/EditorImageDialog.php index b3512b5..176f3e6 100644 --- a/core/modules/editor/lib/Drupal/editor/Form/EditorImageDialog.php +++ b/core/modules/editor/lib/Drupal/editor/Form/EditorImageDialog.php @@ -130,6 +130,8 @@ public function buildForm(array $form, array &$form_state, FilterFormat $filter_ '#parents' => array('attributes', 'height'), ); + $filters = $filter_format->filters()->getAll(); + // When Drupal core's filter_caption is being used, the text editor may // offer the ability to change the alignment. if (isset($image_element['data_align'])) { @@ -178,6 +180,9 @@ public function buildForm(array $form, array &$form_state, FilterFormat $filter_ $form['actions']['save_modal'] = array( '#type' => 'submit', '#value' => $this->t('Save'), + '#validate' => array( + array($this, 'validateForm'), + ), // No regular submit-handler. This form only works via JavaScript. '#submit' => array(), '#ajax' => array( @@ -192,9 +197,7 @@ public function buildForm(array $form, array &$form_state, FilterFormat $filter_ /** * {@inheritdoc} */ - public function submitForm(array &$form, array &$form_state) { - $response = new AjaxResponse(); - + public function validateForm(array &$form, array &$form_state) { // Convert any uploaded files from the FID values to data-editor-file-uuid // attributes. if (!empty($form_state['values']['fid'][0])) { @@ -206,6 +209,13 @@ public function submitForm(array &$form, array &$form_state) { $form_state['values']['attributes']['src'] = $file_url; $form_state['values']['attributes']['data-editor-file-uuid'] = $file->uuid(); } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, array &$form_state) { + $response = new AjaxResponse(); if (form_get_errors($form_state)) { unset($form['#prefix'], $form['#suffix']); diff --git a/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterCaption.php b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterCaption.php index 5b5ce63..e91b7c4 100644 --- a/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterCaption.php +++ b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterCaption.php @@ -13,7 +13,7 @@ use Drupal\filter\Plugin\FilterBase; /** - * Provides a filter to display image captions and align images. + * Provides a filter to caption and align images (as well as other elements). * * @Filter( * id = "filter_caption", diff --git a/core/modules/image/css/image.admin.css b/core/modules/image/css/image.admin.css index 3e1f901..b398bcd 100644 --- a/core/modules/image/css/image.admin.css +++ b/core/modules/image/css/image.admin.css @@ -16,6 +16,7 @@ .image-style-preview .preview-image { margin: auto; position: relative; + display: block; } .image-style-preview .preview-image .width { border: 1px solid #666; diff --git a/core/modules/image/image.admin.inc b/core/modules/image/image.admin.inc index 9675e98..d982d52 100644 --- a/core/modules/image/image.admin.inc +++ b/core/modules/image/image.admin.inc @@ -136,7 +136,7 @@ function theme_image_style_preview($variables) { '#attributes' => $original_image, ); $output .= '
'; - $output .= t('original') . ' (' . l(t('view actual size'), $original_url) . ')'; + $output .= t('original') . '
(' . l(t('view actual size'), $original_url) . ')'; $output .= '
'; $output .= '' . drupal_render($image) . ''; $output .= '
' . $original_image['height'] . 'px
'; @@ -154,7 +154,7 @@ function theme_image_style_preview($variables) { '#attributes' => $preview_image, ); $output .= '
'; - $output .= check_plain($style->label()) . ' (' . l(t('view actual size'), file_create_url($preview_file) . '?' . time()) . ')'; + $output .= check_plain($style->label()) . '
(' . l(t('view actual size'), file_create_url($preview_file) . '?' . time()) . ')'; $output .= '
'; $output .= '' . drupal_render($image) . ''; $output .= '
' . $preview_image['height'] . 'px
'; diff --git a/core/modules/image/image.module b/core/modules/image/image.module index e5f52ed..7c1e99b 100644 --- a/core/modules/image/image.module +++ b/core/modules/image/image.module @@ -566,3 +566,104 @@ function image_field_instance_delete(FieldInstanceInterface $field_instance) { \Drupal::service('file.usage')->delete($file, 'image', 'default_image', $field_instance->uuid); } } + +/** + * Implements hook_form_FORM_ID_alter() for EditorImageDialog. + * + * Alters the image dialog form for text editor, to allow the user to select an + * image style. + * + * @see \Drupal\editor\Form\EditorImageDialog::buildForm() + */ +function image_form_editor_image_dialog_alter(&$form, $form_state) { + $filter_format = $form_state['build_info']['args'][0]; + $filters = $filter_format->filters()->getAll(); + + $image_element = $form_state['image_element']; + + // When image style functionality is available, disallow the user from + // specifying the dimensions manually, only allow image styles to be picked. + if (isset($filters['filter_imagestyle']) && $filters['filter_imagestyle']->status) { + // Hide the default width/height form items. + $form['dimensions']['#access'] = FALSE; + + $image_options = image_style_options(FALSE); + $image_options_keys = array_keys($image_options); + $form['image_style'] = array( + '#type' => 'item', + '#field_prefix' => '
', + '#field_suffix' => '
', + ); + $form['image_style']['selection'] = array( + '#title' => t('Image style'), + '#type' => 'select', + '#default_value' => isset($image_element['data-image-style']) ? $image_element['data-image-style'] : $image_options_keys[0], + '#options' => $image_options, + '#required' => TRUE, + '#wrapper_attributes' => array('class' => array('container-inline')), + '#attributes' => array('class' => array('container-inline')), + '#parents' => array('attributes', 'data-image-style'), + ); + $form['image_style']['preview_toggle'] = array( + '#type' => 'checkbox', + '#title' => t('Show preview'), + ); + $image_styles = entity_load_multiple('image_style'); + foreach ($image_styles as $id => $image_style) { + $preview_arguments = array( + '#theme' => 'image_style_preview', + '#style' => $image_style, + ); + $form['image_style']['preview_' . $id] = array( + '#type' => 'fieldset', + '#title' => t('Preview of %image-style image style', array('%image-style' => $image_style->label())), + '#markup' => drupal_render($preview_arguments), + '#states' => array( + 'visible' => array( + ':input[name="image_style[preview_toggle]"]' => array('checked' => TRUE), + ':input[name="attributes[data-image-style]"]' => array('value' => $id), + ), + ), + ); + } + $form['#attached']['css'][drupal_get_path('module', 'image') . '/css/image.admin.css'] = array(); + + $form['actions']['save_modal']['#validate'][] = 'image_form_editor_image_dialog_validate'; + } +} + +/** + * Form validation handler for EditorImageDialog. + * + * Ensures the image shown in the text editor matches the chosen image style. + * + * @see \Drupal\editor\Form\EditorImageDialog::buildForm() + * @see \Drupal\editor\Form\EditorImageDialog::validateForm() + * @see image_form_editor_image_dialog_alter() + */ +function image_form_editor_image_dialog_validate(array &$form, array &$form_state) { + $attributes = &$form_state['values']['attributes']; + if (!empty($form_state['values']['fid'][0])) { + $image_style = entity_load('image_style', $attributes['data-image-style']); + $file = file_load($form_state['values']['fid'][0]); + $uri = $file->getFileUri(); + + // Set the 'src' attribute to the image style URL. FilterImageStyle will + // look at the 'data-editor-file-uuid' attribute, not the 'src' attribute to + // render the appropriate output. + $attributes['src'] = $image_style->buildUrl($uri); + + // Set the 'width' and 'height' attributes to the image style's transformed + // dimensions. + $image = \Drupal::service('image.factory')->get($uri); + if ($image->isSupported()) { + $dimensions = array( + 'width' => $image->getWidth(), + 'height' => $image->getHeight() + ); + $image_style->transformDimensions($dimensions); + $attributes['width'] = $dimensions['width']; + $attributes['height'] = $dimensions['height']; + } + } +} diff --git a/core/modules/image/lib/Drupal/image/Plugin/Filter/FilterImageStyle.php b/core/modules/image/lib/Drupal/image/Plugin/Filter/FilterImageStyle.php new file mode 100644 index 0000000..3b1286a --- /dev/null +++ b/core/modules/image/lib/Drupal/image/Plugin/Filter/FilterImageStyle.php @@ -0,0 +1,117 @@ +query('//*[@data-editor-file-uuid and @data-image-style]') as $node) { + $file_uuid = $node->getAttribute('data-editor-file-uuid'); + $node->removeAttribute('data-editor-file-uuid'); + $image_style_id = $node->getAttribute('data-image-style'); + $node->removeAttribute('data-image-style'); + + // If the image style is not a valid one, then don't transform the HTML. + if (empty($file_uuid) || !in_array($image_style_id, array_keys($image_styles))) { + continue; + } + + $file = entity_load_by_uuid('file', $file_uuid); + + // Determine width/height of the source image. + $width = $height = NULL; + $image = \Drupal::service('image.factory')->get($file->getFileUri()); + if ($image->isSupported()) { + $width = $image->getWidth(); + $height = $image->getHeight(); + } + + // Make sure all non-regenerated attributes are retained. + $node->removeAttribute('width'); + $node->removeAttribute('height'); + $node->removeAttribute('src'); + $attributes = array(); + for ($i = 0; $i < $node->attributes->length; $i++) { + $attr = $node->attributes->item($i); + $attributes[$attr->name] = $attr->value; + } + + // Re-render as an image style. + $image = array( + '#theme' => 'image_style', + '#style_name' => $image_style_id, + '#uri' => $file->getFileUri(), + '#width' => $width, + '#height' => $height, + '#attributes' => $attributes, + ); + $altered_html = drupal_render($image); + + // Load the altered HTML into a new DOMDocument and retrieve the element. + $updated_node = filter_dom_load($altered_html)->getElementsByTagName('body') + ->item(0) + ->childNodes + ->item(0); + + // Import the updated node from the new DOMDocument into the original + // one, importing also the child nodes of the updated node. + $updated_node = $dom->importNode($updated_node, TRUE); + // Finally, replace the original image node with the new image node! + $node->parentNode->replaceChild($updated_node, $node); + } + + return filter_dom_serialize($dom); + } + + return $text; + } + + /** + * {@inheritdoc} + */ + public function tips($long = FALSE) { + if ($long) { + $image_styles = entity_load_multiple('image_style'); + $list = '' . implode(', ', array_keys($image_styles)) . ''; + return t(' +

You can display images using site-wide styles by adding a data-image-style attribute, whose values is one of the image style machine names: !image-style-machine-name-list.

', array('!image-style-machine-name-list' => $list)); + } + else { + return t('You can display images using site-wide styles by adding a data-image-style attribute.'); + } + } +} diff --git a/core/modules/picture/lib/Drupal/picture/Plugin/Filter/FilterPictureMapping.php b/core/modules/picture/lib/Drupal/picture/Plugin/Filter/FilterPictureMapping.php new file mode 100644 index 0000000..ccca8fa --- /dev/null +++ b/core/modules/picture/lib/Drupal/picture/Plugin/Filter/FilterPictureMapping.php @@ -0,0 +1,154 @@ +query('//*[@data-editor-file-uuid and @data-picture-mapping]') as $node) { + $file_uuid = $node->getAttribute('data-editor-file-uuid'); + $node->removeAttribute('data-editor-file-uuid'); + $picture_mapping_id = $node->getAttribute('data-picture-mapping'); + $node->removeAttribute('data-picture-mapping'); + + // If the picture mapping is not a valid one, then don't transform the + // HTML. + if (empty($file_uuid) || !in_array($picture_mapping_id, array_keys($picture_mappings))) { + continue; + } + + // @todo get rid of this, see https://drupal.org/node/2123251 + $breakpoint_styles = array(); + $fallback_image_style = ''; + $picture_mapping = $picture_mappings[$picture_mapping_id]; + foreach ($picture_mapping->mappings as $breakpoint_name => $multipliers) { + // Make sure there are multipliers. + if (!empty($multipliers)) { + // Make sure that the breakpoint exists and is enabled. + // @todo add the following when breakpoint->status is added again: + // $picture_mapping->breakpointGroup->breakpoints[$breakpoint_name]->status + if (isset($picture_mapping->breakpointGroup->breakpoints[$breakpoint_name])) { + $breakpoint = $picture_mapping->breakpointGroup->breakpoints[$breakpoint_name]; + + // Determine the enabled multipliers. + $multipliers = array_intersect_key($multipliers, $breakpoint->multipliers); + foreach ($multipliers as $multiplier => $image_style) { + // Make sure the multiplier still exists. + if (!empty($image_style)) { + // First mapping found is used as fallback. + if (empty($fallback_image_style)) { + $fallback_image_style = $image_style; + } + if (!isset($breakpoint_styles[$breakpoint_name])) { + $breakpoint_styles[$breakpoint_name] = array(); + } + $breakpoint_styles[$breakpoint_name][$multiplier] = $image_style; + } + } + } + } + } + + $file = entity_load_by_uuid('file', $file_uuid); + + // Determine width/height of images that don't have such attributes set. + $width = $node->getAttribute('width'); + $height = $node->getAttribute('height'); + if (empty($width) || empty($height)) { + $image = \Drupal::service('image.factory')->get($file->getFileUri()); + if ($image->isSupported()) { + if (empty($width)) { + $width = $image->getWidth(); + } + if (empty($height)) { + $height = $image->getHeight(); + } + } + } + + // Make sure all non-regenerated attributes are retained. + // @todo: theme_picture() does not yet support this, see https://drupal.org/node/2123251 + + // Re-render as a responsive image. + $picture = array( + '#theme' => 'picture', + '#attached' => array('library' => array( + array('picture', 'picturefill'), + )), + '#uri' => $file->getFileUri(), + '#width' => $width, + '#height' => $height, + '#alt' => $node->getAttribute('alt'), + '#breakpoints' => $breakpoint_styles, + '#style_name' => $fallback_image_style, + ); + $altered_html = drupal_render($picture); + + // Load the altered HTML into a new DOMDocument and retrieve the element. + $updated_node = filter_dom_load($altered_html)->getElementsByTagName('body') + ->item(0) + ->childNodes + ->item(0); + + // Import the updated node from the new DOMDocument into the original + // one, importing also the child nodes of the updated node. + $updated_node = $dom->importNode($updated_node, TRUE); + // Finally, replace the original image node with the new image node! + $node->parentNode->replaceChild($updated_node, $node); + } + + return filter_dom_serialize($dom); + } + + return $text; + } + + /** + * {@inheritdoc} + */ + public function tips($long = FALSE) { + if ($long) { + $picture_mappings = entity_load_multiple('picture_mapping'); + $list = '' . implode(', ', array_keys($picture_mappings)) . ''; + return t(' +

You can make images responsive by adding a data-picture-mapping attribute, whose values is one of the picture mapping machine names: !picture-mapping-machine-name-list.

', array('!picture-mapping-machine-name-list' => $list)); + } + else { + return t('You can make images responsive by adding a data-picture-mapping attribute.'); + } + } +} diff --git a/core/modules/picture/picture.module b/core/modules/picture/picture.module index 8fd25d4..ce54246 100644 --- a/core/modules/picture/picture.module +++ b/core/modules/picture/picture.module @@ -380,3 +380,164 @@ function picture_get_image_dimensions($variables) { return $dimensions; } + +/** + * Implements hook_form_FORM_ID_alter() for EditorImageDialog. + * + * Alters the image dialog form for text editor, to allow the user to select a + * picture mapping. + * + * @see \Drupal\editor\Form\EditorImageDialog::buildForm() + */ +function picture_form_editor_image_dialog_alter(&$form, $form_state) { + $filter_format = $form_state['build_info']['args'][0]; + $filters = $filter_format->filters()->getAll(); + + $image_element = $form_state['image_element']; + + // When responsive image functionality is available, disallow the user from + // specifying the dimensions manually, and from selecting an image style, only + // allowing a picture mapping to be selected. + if (isset($filters['filter_picturemapping']) && $filters['filter_picturemapping']->status) { + // Hide the default width/height form items. + $form['dimensions']['#access'] = FALSE; + + // Remove the image style selection, if it exists; it does not make sense to + // use FilterImageStyle when already using FilterPictureMapping! + if (isset($form['image_style'])) { + unset($form['image_style']); + // Remove its #validate callback as well. + $validators = &$form['actions']['save_modal']['#validate']; + $index = array_search('image_form_editor_image_dialog_validate', $validators); + if ($index !== FALSE) { + unset($validators[$index]); + } + } + + $image_options = image_style_options(FALSE); + $form['picture_mapping'] = array( + '#type' => 'item', + '#field_prefix' => '
', + '#field_suffix' => '
', + ); + $picture_options = array(); + $picture_mappings = entity_load_multiple('picture_mapping'); + if ($picture_mappings && !empty($picture_mappings)) { + foreach ($picture_mappings as $machine_name => $picture_mapping) { + if ($picture_mapping->hasMappings()) { + $picture_options[$machine_name] = $picture_mapping->label(); + } + } + } + $picture_options_keys = array_keys($picture_options); + $form['picture_mapping']['selection'] = array( + '#title' => t('Image style'), + '#type' => 'select', + '#default_value' => isset($image_element['data-picture-mapping']) ? $image_element['data-picture-mapping'] : $picture_options_keys[0], + '#options' => $picture_options, + '#required' => TRUE, + '#wrapper_attributes' => array('class' => array('container-inline')), + '#attributes' => array('class' => array('container-inline')), + '#parents' => array('attributes', 'data-picture-mapping'), + ); + $form['picture_mapping']['preview_toggle'] = array( + '#type' => 'checkbox', + '#title' => t('Show preview'), + ); + foreach ($picture_mappings as $machine_name => $picture_mapping) { + if ($picture_mapping->hasMappings()) { + $form['picture_mapping']['preview_' . $machine_name] = array( + '#type' => 'fieldset', + '#title' => t('Preview of %picture-mapping picture mapping', array('%picture-mapping' => $picture_mapping->label())), + '#states' => array( + 'visible' => array( + ':input[name="picture_mapping[preview_toggle]"]' => array('checked' => TRUE), + ':input[name="attributes[data-picture-mapping]"]' => array('value' => $machine_name), + ), + ), + ); + + // Generate breakpoint preview selector. + $breakpoint_preview_options = drupal_map_assoc(array_keys($picture_mapping->mappings)); + foreach ($breakpoint_preview_options as $key => $value) { + $breakpoint = entity_load('breakpoint', $key); + $breakpoint_preview_options[$key] = $breakpoint->label(); + } + $form['picture_mapping']['preview_'. $machine_name]['breakpoint_selection'] = array( + '#type' => 'select', + '#title' => t('Breakpoint'), + '#options' => $breakpoint_preview_options, + '#wrapper_attributes' => array('class' => array('container-block')), + ); + + // Show preview of the picture mapping at a specific breakpoint, but + // always at multiplier 1x to limit the complexity in the UI. + foreach ($picture_mapping->mappings as $breakpoint_machine_name => $image_style_by_multiplier) { + $image_style = entity_load('image_style', $image_style_by_multiplier['1x']); + $preview_arguments = array( + '#theme' => 'image_style_preview', + '#style' => $image_style, + ); + $form['picture_mapping']['preview_'. $machine_name]['breakpoint_' . $breakpoint_machine_name] = array( + '#type' => 'item', + '#markup' => drupal_render($preview_arguments), + '#states' => array( + 'visible' => array( + ':input[name="picture_mapping[preview_toggle]"]' => array('checked' => TRUE), + ':input[name="attributes[data-picture-mapping]"]' => array('value' => $machine_name), + ':input[name="picture_mapping[preview_' . $machine_name . '][breakpoint_selection]"]' => array('value' => $breakpoint_machine_name), + ), + ), + ); + } + } + } + $form['#attached']['css'][drupal_get_path('module', 'image') . '/css/image.admin.css'] = array(); + + $form['actions']['save_modal']['#validate'][] = 'picture_form_editor_image_dialog_validate'; + } +} + +/** + * Form validation handler for EditorImageDialog. + * + * Ensures the image shown in the text editor matches the chosen picture mapping + * at the smallest breakpoint. + * + * @see \Drupal\editor\Form\EditorImageDialog::buildForm() + * @see \Drupal\editor\Form\EditorImageDialog::validateForm() + * @see picture_form_editor_image_dialog_alter() + */ +function picture_form_editor_image_dialog_validate(array &$form, array &$form_state) { + $attributes = &$form_state['values']['attributes']; + if (!empty($form_state['values']['fid'][0])) { + $picture_mapping = entity_load('picture_mapping', $attributes['data-picture-mapping']); + $file = file_load($form_state['values']['fid'][0]); + $uri = $file->getFileUri(); + + // Select the first (i.e. smallest) breakpoint and the 1x multiplier. We + // choose to show the image in the editor as if it were being viewed in the + // narrowest viewport, so that when the user starts to edit this content + // again on a mobile device, it will work fine. + $breakpoint_machine_names = array_keys($picture_mapping->mappings); + $image_style = entity_load('image_style', $picture_mapping->mappings[$breakpoint_machine_names[0]]['1x']); + + // Set the 'src' attribute to the image style URL. FilterImageStyle will + // look at the 'data-editor-file-uuid' attribute, not the 'src' attribute to + // render the appropriate output. + $attributes['src'] = $image_style->buildUrl($uri); + + // Set the 'width' and 'height' attributes to the image style's transformed + // dimensions. + $image = \Drupal::service('image.factory')->get($uri); + if ($image->isSupported()) { + $dimensions = array( + 'width' => $image->getWidth(), + 'height' => $image->getHeight() + ); + $image_style->transformDimensions($dimensions); + $attributes['width'] = $dimensions['width']; + $attributes['height'] = $dimensions['height']; + } + } +} diff --git a/core/modules/system/css/system.module.css b/core/modules/system/css/system.module.css index 3227d50..d2d4107 100644 --- a/core/modules/system/css/system.module.css +++ b/core/modules/system/css/system.module.css @@ -254,6 +254,10 @@ tr .ajax-progress-throbber .throbber { .container-inline .details-wrapper { display: block; } +/* Allow items inside inline items to render themselves as blocks. */ +.container-inline .container-block { + display: block; +} /** * Prevent text wrapping. diff --git a/core/modules/system/css/system.theme.css b/core/modules/system/css/system.theme.css index 9a0153d..9ab1fcb 100644 --- a/core/modules/system/css/system.theme.css +++ b/core/modules/system/css/system.theme.css @@ -106,10 +106,12 @@ abbr.form-required, abbr.tabledrag-changed, abbr.ajax-changed { .container-inline label:after { content: ':'; } -.form-type-radios .container-inline label:after { +.form-type-radios .container-inline label:after, +.container-inline .form-type-checkbox label:after { content: none; } -.form-type-radios .container-inline .form-type-radio { +.form-type-radios .container-inline .form-type-radio, +.container-inline .form-type-checkbox { margin: 0 1em; } .container-inline .form-actions, diff --git a/core/profiles/standard/config/filter.format.basic_html.yml b/core/profiles/standard/config/filter.format.basic_html.yml index ccb72a1..5e2b925 100644 --- a/core/profiles/standard/config/filter.format.basic_html.yml +++ b/core/profiles/standard/config/filter.format.basic_html.yml @@ -16,6 +16,12 @@ filters: allowed_html: '
    1. ' filter_html_help: false filter_html_nofollow: false + filter_picturemapping: + id: filter_picturemapping + module: filter + status: 1 + weight: 7 + settings: { } filter_caption: id: filter_caption provider: filter diff --git a/core/profiles/standard/config/filter.format.full_html.yml b/core/profiles/standard/config/filter.format.full_html.yml index 37daecc..dc2f590 100644 --- a/core/profiles/standard/config/filter.format.full_html.yml +++ b/core/profiles/standard/config/filter.format.full_html.yml @@ -7,6 +7,12 @@ roles: - administrator cache: true filters: + filter_picturemapping: + id: filter_picturemapping + module: filter + status: 1 + weight: 8 + settings: { } filter_caption: id: filter_caption provider: filter diff --git a/core/profiles/standard/config/picture.mappings.standard_responsive_image.yml b/core/profiles/standard/config/picture.mappings.standard_responsive_image.yml new file mode 100644 index 0000000..25af37d --- /dev/null +++ b/core/profiles/standard/config/picture.mappings.standard_responsive_image.yml @@ -0,0 +1,13 @@ +id: standard_responsive_image +uuid: 789bae80-c66c-4e38-bf00-bb450f4bac33 +label: 'Standard responsive image' +mappings: + module.toolbar.narrow: + 1x: thumbnail + module.toolbar.standard: + 1x: medium + module.toolbar.wide: + 1x: large +breakpointGroup: module.toolbar.toolbar +status: true +langcode: en diff --git a/core/profiles/standard/standard.info.yml b/core/profiles/standard/standard.info.yml index ba41dd5..0054462 100644 --- a/core/profiles/standard/standard.info.yml +++ b/core/profiles/standard/standard.info.yml @@ -25,6 +25,7 @@ dependencies: - number - options - path + - picture - taxonomy - dblog - search