diff --git a/core/modules/image/css/image.admin.css b/core/modules/image/css/image.admin.css index 9f9878a..1acb185 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.module b/core/modules/image/image.module index 59ab1b8..e04d924 100644 --- a/core/modules/image/image.module +++ b/core/modules/image/image.module @@ -5,7 +5,9 @@ * Exposes global functionality for creating image styles. */ +use Drupal\Component\Utility\SafeMarkup; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\file\Entity\File; use Drupal\field\FieldStorageConfigInterface; @@ -473,3 +475,105 @@ function image_field_config_delete(FieldConfigInterface $field) { \Drupal::service('file.usage')->delete($file, 'image', 'default_image', $field->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, FormStateInterface $form_state) { + + $filter_format = $form_state->getBuildInfo()['args'][0]; + $filters = $filter_format->filters()->getAll(); + + $image_element = $form_state->getStorage()['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' => SafeMarkup::set('
'), + '#field_suffix' => SafeMarkup::set('
'), + ); + $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, FormStateInterface &$form_state) { + $attributes = $form_state->getValue('attributes'); + if (!empty($form_state->getValue('fid')[0])) { + $image_style = entity_load('image_style', $attributes['data-image-style']); + $file = file_load($form_state->getValue('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->isValid()) { + $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/src/Plugin/Filter/FilterImageStyle.php b/core/modules/image/src/Plugin/Filter/FilterImageStyle.php new file mode 100644 index 0000000..d665071 --- /dev/null +++ b/core/modules/image/src/Plugin/Filter/FilterImageStyle.php @@ -0,0 +1,119 @@ +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->isValid()) { + $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 = HTML::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 new FilterProcessResult(HTML::serialize($dom)); + } + + return new FilterProcessResult($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/responsive_image/responsive_image.module b/core/modules/responsive_image/responsive_image.module index 925fe3c..46908ef 100644 --- a/core/modules/responsive_image/responsive_image.module +++ b/core/modules/responsive_image/responsive_image.module @@ -14,6 +14,8 @@ use Drupal\responsive_image\Entity\ResponsiveImageStyle; use Drupal\Core\Image\ImageInterface; use Drupal\breakpoint\BreakpointInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\file\Entity\File; /** * The machine name for the empty image breakpoint image style option. @@ -461,3 +463,164 @@ function _responsive_image_image_style_url($style_name, $path) { } return file_create_url($path); } + +/** + * 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 responsive_image_form_editor_image_dialog_alter(&$form, FormStateInterface $form_state) { + + $filter_format = $form_state->getBuildInfo()['args'][0]; + $filters = $filter_format->filters()->getAll(); + + $image_element = $form_state->getStorage()['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]); + } + } + + $form['picture_mapping'] = array( + '#type' => 'item', + ); + $picture_options = array(); + $picture_mappings = entity_load_multiple('responsive_image_style'); + if ($picture_mappings && !empty($picture_mappings)) { + foreach ($picture_mappings as $machine_name => $picture_mapping) { + if ($picture_mapping->hasImageStyleMappings()) { + $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->hasImageStyleMappings()) { + $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 = array_combine(array_keys($picture_mapping->getImageStyleMappings()), $picture_mapping->getImageStyleMappings()); + $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->getImageStyleMappings() as $mapping) { + if ($mapping['multiplier'] == '1x') { + $breakpoint_id = $mapping['breakpoint_id']; + $image_style = entity_load('image_style', $mapping['image_mapping']); + $preview_arguments = array( + '#theme' => 'image_style_preview', + '#style' => $image_style, + ); + $form['picture_mapping']['preview_' . $machine_name]['breakpoint_' . $breakpoint_id] = 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_id), + ), + ), + ); + } + } + } + } + $form['#attached']['css'][drupal_get_path('module', 'image') . '/css/image.admin.css'] = array(); + + $form['actions']['save_modal']['#validate'][] = 'responsive_image_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 responsive_image_form_editor_image_dialog_validate(array &$form, FormStateInterface $form_state) { + $attributes = &$form_state->getValue('attributes'); + if (!empty($form_state->getValue('fid')[0])) { + $picture_mapping = entity_load('responsive_image_style', $attributes['data-picture-mapping']); + $file = File::load($form_state->getValue('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->getKeyedImageStyleMappings()); + $image_style = entity_load('image_style', $picture_mapping->getKeyedImageStyleMappings()[$breakpoint_machine_names[0]]['1x']['image_mapping']); + + // 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->isValid()) { + $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/responsive_image/src/Plugin/Filter/FilterPictureMapping.php b/core/modules/responsive_image/src/Plugin/Filter/FilterPictureMapping.php new file mode 100644 index 0000000..c51f667 --- /dev/null +++ b/core/modules/responsive_image/src/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->getMappings() 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 = \Drupal::entityManager()->loadEntityByUuid('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->isValid()) { + 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 = Html::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 new FilterProcessResult(Html::serialize($dom)); + } + + return new FilterProcessResult($text); + } + + /** + * {@inheritdoc} + */ + public function tips($long = FALSE) { + if ($long) { + $picture_mappings = entity_load_multiple('responsive_image_style'); + $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/system/css/system.module.css b/core/modules/system/css/system.module.css index b38c5e5..c80ab65 100644 --- a/core/modules/system/css/system.module.css +++ b/core/modules/system/css/system.module.css @@ -306,6 +306,10 @@ tr .ajax-progress-throbber .throbber { clear: right; } +/* 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 179628f..7f4697a 100644 --- a/core/modules/system/css/system.theme.css +++ b/core/modules/system/css/system.theme.css @@ -131,10 +131,12 @@ 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: ''; } -.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/install/filter.format.basic_html.yml b/core/profiles/standard/config/install/filter.format.basic_html.yml index 4d187b7..5a3a18a 100644 --- a/core/profiles/standard/config/install/filter.format.basic_html.yml +++ b/core/profiles/standard/config/install/filter.format.basic_html.yml @@ -18,6 +18,12 @@ filters: id: filter_align provider: filter status: true + weight: 6 + settings: { } + filter_picturemapping: + id: filter_picturemapping + provider: filter + status: true weight: 7 settings: { } filter_caption: diff --git a/core/profiles/standard/config/install/filter.format.full_html.yml b/core/profiles/standard/config/install/filter.format.full_html.yml index cfdf9e2..9544b86 100644 --- a/core/profiles/standard/config/install/filter.format.full_html.yml +++ b/core/profiles/standard/config/install/filter.format.full_html.yml @@ -9,6 +9,12 @@ filters: id: filter_align provider: filter status: true + weight: 7 + settings: { } + filter_picturemapping: + id: filter_picturemapping + provider: filter + status: true weight: 8 settings: { } filter_caption: 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 a356ae8..2262cd2 100644 --- a/core/profiles/standard/standard.info.yml +++ b/core/profiles/standard/standard.info.yml @@ -26,6 +26,7 @@ dependencies: - options - path - page_cache + - responsive_image - taxonomy - dblog - search