diff --git a/picture.admin.inc b/picture.admin.inc index d9a1d74..930605e 100644 --- a/picture.admin.inc +++ b/picture.admin.inc @@ -216,3 +216,118 @@ function picture_admin_settings($form, &$form_state) { return system_settings_form($form); } + +/** + * Chooses which picture groups are available in the CKEditor image dialog. + */ +function picture_ckeditor_settings() { + $form = array(); + $picture_groups = picture_get_mapping_options(); + $ckeditor_groups = array(); + + // Check if picture group mappings have been configured before proceeding. + if ($picture_groups) { + // Create a settings form. + $form['description'] = array( + '#type' => 'item', + '#title' => t('Choose which picture groups will be available in the CKEditor image dialog.'), + '#description' => 'See picture_wysiwyg.css for an example of how to style these images in your theme using the selectors suggested below.', + ); + + // Retrieve pre-existing settings. + $ckeditor_groups = variable_get('picture_ckeditor_groups', array()); + + // Loop through each picture group and place a checkbox and weight. + foreach ($picture_groups as $machine_name => $display_name) { + $form[$machine_name] = array( + '#type' => 'fieldset', + '#title' => t('@name picture group', array('@name' => $display_name)), + ); + $form[$machine_name]['enabled'] = array( + '#type' => 'checkbox', + '#default_value' => isset($ckeditor_groups[$machine_name]) ? $ckeditor_groups[$machine_name]['enabled'] : 0, + '#title' => t('Include @name picture group in the CKEditor image dialog', array('@name' => $display_name)), + ); + $form[$machine_name]['css'] = array( + '#type' => 'item', + '#markup' => 'Consider using the selector span[data-picture-group="' . $machine_name . '"] in your theme CSS.', + ); + $form[$machine_name]['weight'] = array( + '#type' => 'select', + '#title' => t('Weight'), + '#options' => drupal_map_assoc(array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)), + '#default_value' => isset($ckeditor_groups[$machine_name]) ? $ckeditor_groups[$machine_name]['weight'] : 1, + '#description' => t('Control the sort order of picture groups in the CKEditor "size" drop-down. Higher weights sink to the bottom of the list.'), + ); + $form[$machine_name]['fallback'] = array( + '#type' => 'select', + '#title' => t('Fallback image style'), + '#options' => drupal_map_assoc(array_keys(image_styles())), + '#default_value' => isset($ckeditor_groups[$machine_name]) ? $ckeditor_groups[$machine_name]['fallback'] : NULL, + ); + } + $form['#tree'] = TRUE; + $form['ckeditor_label'] = array( + '#type' => 'textfield', + '#title' => t('Label in the CKEditor image dialog'), + '#description' => t('This sets the label for the drop-down select box containing these picture groups in the CKEditor image dialog'), + '#default_value' => variable_get('picture_ckeditor_label', 'Image size (required)'), + ); + + $form['submit'] = array( + '#type' => 'submit', + '#value' => 'Save', + ); + } + return $form; +} + +/** + * Validate handler for the picture_ckeditor_settings form. + * It checks that a fallback image style is selected for every + * picture group that has been enabled for the CKEditor image dialog. + */ +function picture_ckeditor_settings_validate($form, &$form_state) { + $picture_groups = picture_mapping_load(); + $ckeditor_groups = array(); + foreach ($picture_groups as $picture_group) { + $machine_name = $picture_group->machine_name; + if ($form_state['values'][$machine_name]['enabled'] == 1) { + if (empty($form_state['values'][$machine_name]['fallback'])) { + form_set_error($machine_name . '][fallback', t('Please choose a fallback image style for this picture group')); + } + } + } +} + +/** + * Submit handler for the picture_ckeditor_settings form. Places chosen picture + * groups into the variables table. + */ +function picture_ckeditor_settings_submit($form, &$form_state) { + $picture_groups = picture_mapping_load(); + $ckeditor_groups = array(); + + // Loop each picture group and record the settings. + foreach ($picture_groups as $picture_group) { + $machine_name = $picture_group->machine_name; + $ckeditor_groups[$machine_name]['enabled'] = $form_state['values'][$machine_name]['enabled']; + $ckeditor_groups[$machine_name]['weight'] = $form_state['values'][$machine_name]['weight']; + $ckeditor_groups[$machine_name]['fallback'] = $form_state['values'][$machine_name]['fallback']; + } + + uasort($ckeditor_groups, 'picture_compare_weights'); + variable_set('picture_ckeditor_groups', $ckeditor_groups); + variable_set('picture_ckeditor_label', $form_state['values']['ckeditor_label']); + drupal_set_message(t('Your settings have been saved')); +} + +/** + * Helper function to sort picture groups for the CKEditor image dialog + */ +function picture_compare_weights($a, $b) { + if ($a['weight'] == $b['weight']) { + return 0; + } + return ($a['weight'] < $b['weight']) ? -1 : 1; +} diff --git a/picture.info b/picture.info index 59cc9da..75d4bbe 100644 --- a/picture.info +++ b/picture.info @@ -4,3 +4,4 @@ core = 7.x dependencies[] = breakpoints configure = admin/config/media/picture package = Picture +stylesheets[all][] = picture_wysiwyg.css diff --git a/picture.module b/picture.module index 523b24c..510ff09 100644 --- a/picture.module +++ b/picture.module @@ -90,6 +90,17 @@ function picture_menu() { } } + $items['admin/config/media/picture/ckeditor'] = array( + 'title' => 'CKEditor', + 'type' => MENU_LOCAL_TASK, + 'description' => 'Choose picture groups to present in the CKEditor image dialog', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('picture_ckeditor_settings'), + 'access arguments' => array('administer pictures'), + 'file' => 'picture.admin.inc', + 'weight' => 0, + ); + return $items; } @@ -687,6 +698,12 @@ function theme_picture($variables) { $attributes['data-' . $key] = $variables[$key]; } } + // Add attributes that are already prefixed by 'data-' + foreach (array('data-picture-group', 'data-picture-align') as $key) { + if (isset($variables[$key])) { + $attributes[$key] = $variables[$key]; + } + } $output[] = ''; // Add source tags to the output. @@ -935,3 +952,281 @@ function picture_file_formatter_picture_settings($form, &$form_state, $settings) return $element; } + +/** + * Implements hook_filter_info(). + */ +function picture_filter_info() { + $filters = array(); + $filters['picture'] = array( + 'title' => t('Make images responsive with the picture module'), + 'description' => t('Replace img tags with markup that contains media width breakpoints. The appropriate image file size will be chosen.'), + 'process callback' => '_picture_filter_process', + 'tips callback' => '_picture_filter_tips', + ); + + return $filters; +} + +/** + * Process callback for inline image filter. + */ +function _picture_filter_process($text, $filter) { + + // Find all img tags with a data-picture-group attribute. + preg_match_all('//i', $text, $images); + + if (!empty($images[0])) { + foreach ($images[0] as $image) { + // Create the render array expected by theme_picture_formatter. + $image_render_array = _picture_filter_prepare_image($image); + if (!$image_render_array) { + return $text; + } + + // Get the responsive markup for this image. + $new_markup = theme('picture_formatter', $image_render_array); + + // Replace the original img tag with the responsive markup. + $text = str_replace($image, $new_markup, $text); + } + } + return $text; +} + +/** + * Prepares a Render Array for theme_picture_formatter(). + * It is similar to picture_field_formatter_view() + * with modifications for inline images. + * + * @param $image + * An img tag + * @see picture_field_formatter_view() + */ +function _picture_filter_prepare_image($image) { + // Parse the tag as xml. + $xml = simplexml_load_string('' . html_entity_decode($image, ENT_QUOTES, "utf-8") . ''); + if (isset($xml->img[0]) && is_object($xml->img[0])) { + $attributes = array(); + foreach ($xml->img[0]->attributes() as $a => $b) { + $attributes[$a] = (string) $b; + } + } + + $image_render_array = array(); + $breakpoint_styles = array(); + $fallback_image_style = ''; + $group_name = $attributes['data-picture-group']; + $mappings = picture_mapping_load($group_name); + + if ($mappings) { + foreach ($mappings->mapping as $breakpoint_name => $multipliers) { + if (!empty($multipliers)) { + foreach ($multipliers as $multiplier => $image_style) { + if (!empty($image_style)) { + 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; + } + } + } + } + } + + if(!isset($attributes['src'])) { + return FALSE; + } + + $src = $attributes['src']; + + $uri = picture_image_uri($src); + if (!$uri) { + return FALSE; + } + + $image_info = image_get_info($uri); + if (!$image_info) { + // It's not an image. + return FALSE; + } + $picture_groups = variable_get('picture_ckeditor_groups', array()); + $image_render_array = array( + '#theme' => 'picture_formatter', + '#item' => array( + 'style_name' => $picture_groups[$attributes['data-picture-group']]['fallback'], + 'uri' => $uri, + 'width' => $image_info['width'], + 'height' => $image_info['height'], + 'data-picture-group' => $attributes['data-picture-group'], + 'data-picture-align' => isset($attributes['data-picture-align']) ? $attributes['data-picture-align'] : '', + 'alt' => isset($attributes['alt']) ? $attributes['alt'] : '', + 'title' => isset($attributes['title']) ? $attributes['title'] : '', + 'filemime' => $image_info['mime_type'], + ), + '#image_style' => $picture_groups[$attributes['data-picture-group']]['fallback'], + '#breakpoints' => $breakpoint_styles, + '#path' => '', + ); + + return $image_render_array; + +} +/** + * Implements picture filter tips callback. + */ +function _picture_filter_tips($filter, $format, $long = FALSE) { + $tips = 'Images with a data-picture-group attribute will be responsive, with a file size appropriate for the browser width.'; + + return $tips; +} + +/** + * Implements hook_page_build(). + * + * Add the image processing javascript to every page. This allows these scripts + * to get included in aggregation, which is probably good since there will be + * pictures needing this javascript on most pages. The library does not get + * added twice, even if it's attached to multiple fields that are also being + * displayed with responsive images. Maybe this should check that the + * page is not an admin theme page? + */ +function picture_page_build(&$page) { + drupal_add_library('picture', 'matchmedia', TRUE); + drupal_add_library('picture', 'picturefill', TRUE); + drupal_add_library('picture', 'picture.ajax', TRUE); +} + +/** + * Helper function to figure out the uri of an image given the + * image src. + * + * @param + * Image src starting with http://, https://, or root relative /. + * It must point to a local file. + */ +function picture_image_uri($src) { + $uri = ''; + // Prepare the src by removing http:// or https://. + $src = preg_replace('/https?:\/\//', '', $src); + // Remove leading or trailing slashes. + $src = trim($src, '/'); + + // List all visible stream wrappers. Make sure they're also local since the + // getDirectoryPath method exists only for classes that extend + // DrupalLocalStreamWrapper. + $local_visible_stream_wrappers = array_intersect_key(file_get_stream_wrappers(STREAM_WRAPPERS_LOCAL), file_get_stream_wrappers(STREAM_WRAPPERS_VISIBLE)); + $needles = array(); + $matches = array(); + foreach ($local_visible_stream_wrappers as $scheme => $data) { + $class = file_stream_wrapper_get_class($scheme); + $stream_wrapper = new $class(); + // Trim leading or trailing slashes since the Directory could be root + // relative. + $needles[$scheme] = trim($stream_wrapper->getDirectoryPath(), '/'); + + // Check whether the file stream directory is at the beginning of + // the image src. Use === since strpos could return false. + if (strpos($src, $needles[$scheme]) === 0) { + $matches[$scheme] = $needles[$scheme]; + } + } + + // If one file scheme directory is a subdirectory of another file + // scheme directory, choose the longer one. This issue is possible with + // the following scenario: + // public file dir: /sites/default/files/ + // private file dir: /sites/default/files/private/ + // image src: /sites/default/files/private/the-image.jpg + // In this example, the intended scheme would be 'private'. + + if (empty($matches)) { + // Can't figure out the Drupal uri. + return FALSE; + } + // Find the length of each matching directory path. + $lengths = array_map('strlen', $matches); + + // Determine the key of the longest one. + $the_scheme = array_search(max($lengths), $lengths); + + // Construct the Drupal uri. + $uri = $the_scheme . '://' . str_replace($matches[$the_scheme], '', $src); + $uri = file_stream_wrapper_uri_normalize($uri); + + return $uri; +} + +/** + * Implements hook_wysiwyg_plugin to modify the CKEditor image dialog for use + * with the picture module. The integration technique here is borrowed from + * imce_wysiwyg. + * + * Normally an array of plugin settings would be returned, but since it's + * problematic to have the WYSIWYG module load a plugin which doesn't have a + * button (an 'extension', in other words), this module takes care of + * loading the plugin itself. As a consequence, this plugin does not appear + * on the WYSIWYG button/plugin configuration page, and is always enabled + * for all WYSIWYG CKEditor profiles when picture groups have been enabled from + * /admin/config/media/picture/ckeditor + * @TODO: low priority - make a checkbox control loading of this extension. The + * imce_wysiwyg module does this by passing a CKEditor options array in the + * extension definition which is loaded only when the extension is enabled on + * the WYSIWYG config page. + * + * example code to get started with a checkbox would be: + * + * return array( + * 'picture_ckeditor' => array( + * 'extensions' => array('picture_ckeditor' => t('Responsive images + * with the Picture module')), + * 'url' => 'http://drupal.org/projects/picture', + * 'load' => FALSE, + * ), + * ); + */ +function picture_wysiwyg_plugin($editor, $version) { + + // Keep track of whether the JS has already been added since this hook is + // called multiple times on a page load. + static $picture_ckeditor_integrated = FALSE; + + if ($editor == 'ckeditor' ) { + // Load our invocation scripts. + if (!$picture_ckeditor_integrated) { + // Create the Javascript array of picture group options. + $picture_groups = picture_get_mapping_options(); + $ckeditor_groups = variable_get('picture_ckeditor_groups', array()); + $groups = array(); + // CKEditor expects an array of options formatted as + // ['Display name', 'machine_name']. + foreach ($picture_groups as $key => $value) { + if ($ckeditor_groups[$key]['enabled'] == 1) { + $groups[] = array($value, $key); + } + } + if (!empty($groups)) { + $groups[] = array('Not Set', 'not_set'); + drupal_add_js(array( + 'picture' => array( + 'groups' => $groups, + 'label' => variable_get('picture_ckeditor_label', 'Image size (required)'), + ), + ), 'setting'); + drupal_add_js(drupal_get_path('module', 'picture') . '/picture_ckeditor.js'); + $picture_ckeditor_integrated = TRUE; + } + } + } +} + +/** + * Implements hook_uninstall(). + */ +function picture_uninstall() { + variable_del('picture_ckeditor_groups'); + variable_del('picture_ckeditor_label'); +} diff --git a/picture_ckeditor.js b/picture_ckeditor.js new file mode 100644 index 0000000..69f5922 --- /dev/null +++ b/picture_ckeditor.js @@ -0,0 +1,181 @@ +/** + * @file picture_ckeditor.js + * Modify the CKEditor image dialog to insert responsive images. + * + * Supports inline responsive images with the Picture module. + * It also simplifies for the CKEditor image dialog. + */ + +CKEDITOR.editorConfig = function(config) +{ + // This disables the browser resize handles since the width will now be set + // in the image dialog. This also prevents CKEditor from automatically adding + // pesky inline width and height styles, although these inline styles are + // still added when an image is dragged and dropped. + // Resize handles are also removed from tables as an unintended consequence. + config.disableObjectResizing = true; + +}; + +// When opening a dialog, a 'definition' is created for it. For +// each editor instance the 'dialogDefinition' event is then +// fired. We can use this event to make customizations to the +// definition of existing dialogs. +CKEDITOR.on('dialogDefinition', function(event) { + // Take the dialog name. + var dialogName = event.data.name; + // The definition holds the structured data that is used to eventually + // build the dialog and we can use it to customize just about anything. + // In Drupal terms, it's sort of like CKEditor's version of a Forms API and + // what we're doing here is a bit like a hook_form_alter. + var dialogDefinition = event.data.definition; + + + // Resources for the following: + // Download: https://github.com/ckeditor/ckeditor-dev + // See /plugins/image/dialogs/image.js + // and refer to http://docs.ckeditor.com/#!/api/CKEDITOR.dialog.definition + // Visit: file:///[path_to_ckeditor-dev]/plugins/devtools/samples/devtools.html + // for an excellent way to find machine names for dialog elements. + if (dialogName == 'image') { + dialogDefinition.removeContents('advanced'); + dialogDefinition.removeContents('Link'); + var infoTab = dialogDefinition.getContents('info'); + var altText = infoTab.get('txtAlt'); + var IMAGE = 1, + LINK = 2, + PREVIEW = 4, + CLEANUP = 8; + // UpdatePreview is copied from ckeditor image plugin. + var updatePreview = function(dialog) { + // Don't load before onShow. + if (!dialog.originalElement || !dialog.preview) { + return 1; + } + + // Read attributes and update imagePreview. + dialog.commitContent(PREVIEW, dialog.preview); + return 0; + }; + // Add the select list for choosing the image width. + infoTab.add({ + type: 'select', + id: 'imageSize', + label: Drupal.settings.picture.label, + items: Drupal.settings.picture.groups, + 'default': 'not_set', + onChange: function() { + var dialog = this.getDialog(); + var element = dialog.originalElement; + element.setAttribute('data-picture-group', this.getValue()); + updatePreview(this.getDialog()); + }, + setup: function(type, element) { + if (type == IMAGE) { + var value = element.getAttribute('data-picture-group'); + this.setValue(value); + } + }, + // Create a custom data-picture-group attribute. + commit: function(type, element) { + if (type == IMAGE) { + if (this.getValue() || this.isChanged()) { + element.setAttribute('data-picture-group', this.getValue()); + } + } else if (type == PREVIEW) { + element.setAttribute('data-picture-group', this.getValue()); + } else if (type == CLEANUP) { + element.setAttribute('data-picture-group', ''); + } + }, + validate: function() { + if (this.getValue() == 'not_set') { + var message = 'Please make a selection from ' + Drupal.settings.picture.label; + alert(message); + return false; + } else { + return true; + } + } + }, + // Position before preview. + 'htmlPreview' + ); + + // Put a title attribute field on the main 'info' tab. + infoTab.add( { + type: 'text', + id: 'txtTitle', + label: 'The title attribute is used as a tooltip when the mouse hovers over the image.', + onChange: function() { + updatePreview(this.getDialog()); + }, + setup: function(type, element) { + if (type == IMAGE) + this.setValue(element.getAttribute('title')); + }, + commit: function(type, element) { + if (type == IMAGE) { + if (this.getValue() || this.isChanged()) + element.setAttribute('title', this.getValue()); + } else if (type == PREVIEW) { + element.setAttribute('title', this.getValue()); + } else if (type == CLEANUP) { + element.removeAttribute('title'); + } + } + }, + // Position before the imageSize select box. + 'htmlPreview' + ); + + // Add a select widget to choose image alignment. + infoTab.add({ + type: 'select', + id: 'imageAlign', + label: 'Image Alignment', + items: [ [ 'Not Set', '' ], [ 'Left', 'left'], + [ 'Right', 'right' ], [ 'Center', 'center'] ], + 'default': '', + onChange: function() { + updatePreview(this.getDialog()); + }, + setup: function(type, element) { + if (type == IMAGE) { + var value = element.getAttribute('data-picture-align'); + this.setValue(value); + } + }, + // Creates a custom data-picture-align attribute since working with classes + // is more difficult. If we used classes, then we'd have to search for + // exisiting alignment classes and remove them before adding a new one. + // With the custom attribute we can always just overwrite it's value. + commit: function(type, element) { + if (type == IMAGE) { + if (this.getValue() || this.isChanged()) { + element.setAttribute('data-picture-align', this.getValue()); + } + } else if (type == PREVIEW) { + element.setAttribute('data-picture-align', this.getValue()); + } else if (type == CLEANUP) { + element.setAttribute('data-picture-align', ''); + } + } + + }, + // Position before imageSize. + 'imageSize' + ); + + // Improve the alt field label. Copied from Drupal's image field. + altText.label = 'The alt attribute may be used by search engines, and screen readers.'; + + // Remove a bunch of extraneous fields. These properties will be set in + // the theme or module CSS. + infoFieldsRemove = [ 'cmbAlign', 'txtWidth', 'txtHeight', 'ratioLock', 'txtBorder', + 'txtHSpace', 'txtVSpace' ]; + for (i = 0; i < infoFieldsRemove.length; i++) { + infoTab.remove(infoFieldsRemove[i]); + } + } +}); diff --git a/picture_wysiwyg.css b/picture_wysiwyg.css new file mode 100644 index 0000000..5b2086b --- /dev/null +++ b/picture_wysiwyg.css @@ -0,0 +1,36 @@ +/* This CSS file needs to be included either in the theme used for +* editing content in order to be included in the WYSIWYG edit iframe, +* or specifically included in the WYSIWYG config page's +* "Define CSS" textfield. +*/ +span[data-picture-align="left"], img[data-picture-align="left"] { + float: left; +} +span[data-picture-align="right"], img[data-picture-align="right"] { + float: right; +} +span[data-picture-align="center"], img[data-picture-align="center"] { + display: block; + margin-left: auto; + margin-right: auto; +} +/* Remove ugly boarders that bunch up in the image dialog table. */ +.cke_dialog_body tr td:last-child { + border-right: 0px; +} +/* The following is an example of what you could put in your theme + * to control the size of images. It is formatted as + * span[data-picture-group="[The machine name of your picture group]" +span[data-picture-group="wide"] { + width: 100%; +} +span[data-picture-group="normal"] { + width: 50%; +} +span[data-picture-group="narrow"] { + width: 33%; +} +span[data-picture-group] img { + width: 100%; + height: auto; +}*/