Commit 5429044d authored by webchick's avatar webchick

Issue #1932652 by Wim Leers, nirbhasa, quicksketch: Add image uploading to...

Issue #1932652 by Wim Leers, nirbhasa, quicksketch: Add image uploading to WYSIWYGs through editor.module.
parent 595d6d9a
......@@ -314,6 +314,18 @@ function hook_entity_delete(Drupal\Core\Entity\EntityInterface $entity) {
->execute();
}
/**
* Respond to entity revision deletion.
*
* This hook runs after the entity type-specific revision delete hook.
*
* @param Drupal\Core\Entity\EntityInterface $entity
* The entity object for the entity revision that has been deleted.
*/
function hook_entity_revision_delete(Drupal\Core\Entity\EntityInterface $entity) {
// @todo: code example
}
/**
* Alter or execute an Drupal\Core\Entity\Query\EntityQueryInterface.
*
......
......@@ -79,6 +79,20 @@ function ckeditor_library_info() {
array('system', 'underscore')
),
);
$libraries['drupal.ckeditor.drupalimage.admin'] = array(
'title' => 'Only show the "drupalimage" plugin settings when its button is enabled.',
'version' => VERSION,
'js' => array(
$module_path . '/js/ckeditor.drupalimage.admin.js' => array(),
),
'dependencies' => array(
array('system', 'jquery'),
array('system', 'drupal'),
array('system', 'jquery.once'),
array('system', 'drupal.vertical-tabs'),
array('system', 'drupalSettings'),
),
);
$libraries['drupal.ckeditor.stylescombo.admin'] = array(
'title' => 'Only show the "stylescombo" plugin settings when its button is enabled.',
'version' => VERSION,
......@@ -91,8 +105,6 @@ function ckeditor_library_info() {
array('system', 'jquery.once'),
array('system', 'drupal.vertical-tabs'),
array('system', 'drupalSettings'),
// @todo D8 formUpdated event should be debounced already.
array('system', 'drupal.debounce'),
),
);
$libraries['ckeditor'] = array(
......
(function ($, Drupal, drupalSettings) {
"use strict";
/**
* Shows the "drupalimage" plugin settings only when the button is enabled.
*/
Drupal.behaviors.ckeditorDrupalImageSettings = {
attach: function (context) {
var $context = $(context);
var $drupalImageVerticalTab = $('#edit-editor-settings-plugins-drupalimage').data('verticalTab');
// Hide if the "DrupalImage" button is disabled.
if ($('.ckeditor-toolbar-disabled li[data-button-name="DrupalImage"]').length === 1) {
$drupalImageVerticalTab.tabHide();
}
// React to added/removed toolbar buttons.
$context
.find('.ckeditor-toolbar-active')
.on('CKEditorToolbarChanged.ckeditorDrupalImageSettings', function (e, action, button) {
if (button === 'DrupalImage') {
if (action === 'added') {
$drupalImageVerticalTab.tabShow();
}
else {
$drupalImageVerticalTab.tabHide();
}
}
});
}
};
/**
* Provides the summary for the "drupalimage" plugin settings vertical tab.
*/
Drupal.behaviors.ckeditorDrupalImageSettingsSummary = {
attach: function () {
$('#edit-editor-settings-plugins-drupalimage').drupalSetSummary(function (context) {
var root = 'input[name="editor[settings][plugins][drupalimage][image_upload]';
var $status = $(root + '[status]"]');
var $maxFileSize = $(root + '[max_size]"]');
var $maxWidth = $(root + '[max_dimensions][width]"]');
var $maxHeight = $(root + '[max_dimensions][height]"]');
var $scheme = $(root + '[scheme]"]:checked');
var maxFileSize = $maxFileSize.val() ? $maxFileSize.val() : $maxFileSize.attr('placeholder');
var maxDimensions = ($maxWidth.val() && $maxHeight.val()) ? '(' + $maxWidth.val() + 'x' + $maxHeight.val() + ')' : '';
if (!$status.is(':checked')) {
return Drupal.t('Uploads disabled');
}
var output = '';
output += Drupal.t('Uploads enabled, max size: @size @dimensions', { '@size': maxFileSize, '@dimensions': maxDimensions });
if ($scheme.length) {
output += '<br />' + $scheme.attr('data-label');
}
return output;
});
}
};
})(jQuery, Drupal, drupalSettings);
......@@ -8,6 +8,7 @@
namespace Drupal\ckeditor\Plugin\CKEditorPlugin;
use Drupal\ckeditor\CKEditorPluginBase;
use Drupal\ckeditor\CKEditorPluginConfigurableInterface;
use Drupal\ckeditor\Annotation\CKEditorPlugin;
use Drupal\Core\Annotation\Translation;
use Drupal\editor\Entity\Editor;
......@@ -17,11 +18,11 @@
*
* @CKEditorPlugin(
* id = "drupalimage",
* label = @Translation("Drupal image"),
* label = @Translation("Image"),
* module = "ckeditor"
* )
*/
class DrupalImage extends CKEditorPluginBase {
class DrupalImage extends CKEditorPluginBase implements CKEditorPluginConfigurableInterface {
/**
* {@inheritdoc}
......@@ -61,4 +62,36 @@ public function getButtons() {
);
}
/**
* {@inheritdoc}
*
* @see \Drupal\editor\Form\EditorImageDialog
* @see editor_image_upload_settings_form()
*/
public function settingsForm(array $form, array &$form_state, Editor $editor) {
form_load_include($form_state, 'inc', 'editor', 'editor.admin');
$form['image_upload'] = editor_image_upload_settings_form($editor);
$form['image_upload']['#attached']['library'][] = array('ckeditor', 'drupal.ckeditor.drupalimage.admin');
$form['image_upload']['#element_validate'] = array(
array($this, 'validateImageUploadSettings'),
);
return $form;
}
/**
* #element_validate handler for the "image_upload" element in settingsForm().
*
* Moves the text editor's image upload settings from the DrupalImage plugin's
* own settings into $editor->image_upload.
*
* @see \Drupal\editor\Form\EditorImageDialog
* @see editor_image_upload_settings_form()
*/
function validateImageUploadSettings(array $element, array &$form_state) {
$settings = &$form_state['values']['editor']['settings']['plugins']['drupalimage']['image_upload'];
$form_state['editor']->image_upload = $settings;
unset($form_state['values']['editor']['settings']['plugins']['drupalimage']);
}
}
......@@ -112,6 +112,7 @@ public function settingsForm(array $form, array &$form_state, EditorEntity $edit
// CKEditor plugin settings, if any.
$form['plugin_settings'] = array(
'#type' => 'vertical_tabs',
'#title' => t('CKEditor plugin settings'),
);
$this->ckeditorPluginManager->injectPluginSettingsForm($form, $form_state, $editor);
if (count(element_children($form['plugins'])) === 0) {
......
......@@ -12,6 +12,32 @@ editor.editor.*:
label: 'Text editor'
settings:
type: editor.settings.[%parent.editor]
image_upload:
type: mapping
label: 'Image upload settings'
mapping:
status:
type: boolean
label: 'Status'
scheme:
type: string
label: 'File storage'
directory:
type: string
label: 'Upload directory'
max_size:
type: string
label: 'Maximum file size'
max_dimensions:
type: mapping
label: 'Maximum dimensions'
mapping:
width:
type: integer
label: 'Maximum width'
height:
type: integer
label: 'Maximum height'
status:
type: boolean
label: 'Status'
......
<?php
/**
* @file
* Administration functions for editor.module.
*/
use Drupal\editor\Entity\Editor;
/**
* Subform constructor to configure the text editor's image upload settings.
*
* Each text editor plugin that is configured to offer the ability to insert
* images and uses EditorImageDialog for that, should use this form to update
* the text editor's configuration so that EditorImageDialog knows whether it
* should allow the user to upload images.
*
* @param \Drupal\editor\Entity\Editor $editor
* The text editor entity that is being edited.
*
* @return array
* The image upload settings form.
*
* @see \Drupal\editor\Form\EditorImageDialog
* @ingroup forms
*/
function editor_image_upload_settings_form(Editor $editor) {
// Defaults.
$editor->image_upload = isset($editor->image_upload) ? $editor->image_upload : array();
$editor->image_upload += array(
'status' => FALSE,
'scheme' => file_default_scheme(),
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => array('width' => '', 'height' => ''),
);
$form['status'] = array(
'#type' => 'checkbox',
'#title' => t('Enable image uploads'),
'#default_value' => $editor->image_upload['status'],
'#attributes' => array(
'data-editor-image-upload' => 'status',
),
);
$show_if_image_uploads_enabled = array(
'visible' => array(
':input[data-editor-image-upload="status"]' => array('checked' => TRUE),
),
);
// Any visible, writable wrapper can potentially be used for uploads,
// including a remote file system that integrates with a CDN.
$stream_wrappers = file_get_stream_wrappers(STREAM_WRAPPERS_WRITE_VISIBLE);
foreach ($stream_wrappers as $scheme => $info) {
$options[$scheme] = $info['description'];
}
if (!empty($options)) {
$form['scheme'] = array(
'#type' => 'radios',
'#title' => t('File storage'),
'#default_value' => $editor->image_upload['scheme'],
'#options' => $options,
'#states' => $show_if_image_uploads_enabled,
'#access' => count($options) > 1,
);
}
// Set data- attributes with human-readable names for all possible stream
// wrappers, so that drupal.ckeditor.drupalimage.admin's summary rendering
// can use that.
foreach ($stream_wrappers as $scheme => $info) {
$form['scheme'][$scheme]['#attributes']['data-label'] = t('Storage: @name', array('@name' => $info['name']));
}
$form['directory'] = array(
'#type' => 'textfield',
'#default_value' => $editor->image_upload['directory'],
'#title' => t('Upload directory'),
'#description' => t("A directory relative to Drupal's files directory where uploaded images will be stored."),
'#states' => $show_if_image_uploads_enabled,
);
$default_max_size = format_size(file_upload_max_size());
$form['max_size'] = array(
'#type' => 'textfield',
'#default_value' => $editor->image_upload['max_size'],
'#title' => t('Maximum file size'),
'#description' => t('If this is left empty, then the file size will be limited by the PHP maximum upload size of @size.', array('@size' => $default_max_size)),
'#maxlength' => 20,
'#size' => 10,
'#placeholder' => $default_max_size,
'#states' => $show_if_image_uploads_enabled,
);
$form['max_dimensions'] = array(
'#type' => 'item',
'#title' => t('Maximum dimensions'),
'#field_prefix' => '<div class="container-inline clearfix">',
'#field_suffix' => '</div>',
'#description' => t('Images larger than these dimensions will be scaled down.'),
'#states' => $show_if_image_uploads_enabled,
);
$form['max_dimensions']['width'] = array(
'#title' => t('Width'),
'#title_display' => 'invisible',
'#type' => 'number',
'#default_value' => $editor->image_upload['max_dimensions']['width'],
'#size' => 8,
'#maxlength' => 8,
'#min' => 1,
'#max' => 99999,
'#placeholder' => 'width',
'#field_suffix' => ' x ',
'#states' => $show_if_image_uploads_enabled,
);
$form['max_dimensions']['height'] = array(
'#title' => t('Height'),
'#title_display' => 'invisible',
'#type' => 'number',
'#default_value' => $editor->image_upload['max_dimensions']['height'],
'#size' => 8,
'#maxlength' => 8,
'#min' => 1,
'#max' => 99999,
'#placeholder' => 'height',
'#field_suffix' => 'pixels',
'#states' => $show_if_image_uploads_enabled,
);
return $form;
}
......@@ -8,6 +8,8 @@
use Drupal\file\Entity\File;
use Drupal\editor\Entity\Editor;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Entity\EntityInterface;
use Drupal\field\Field;
/**
* Implements hook_help().
......@@ -384,3 +386,182 @@ function editor_pre_render_format($element) {
return $element;
}
/**
* Implements hook_entity_insert().
*/
function editor_entity_insert(EntityInterface $entity) {
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
foreach ($referenced_files_by_field as $field => $uuids) {
_editor_record_file_usage($uuids, $entity);
}
}
/**
* Implements hook_entity_update().
*/
function editor_entity_update(EntityInterface $entity) {
// On new revisions, all files are considered to be a new usage and no
// deletion of previous file usages are necessary.
if (!empty($entity->original) && $entity->getRevisionId() != $entity->original->getRevisionId()) {
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
foreach ($referenced_files_by_field as $field => $uuids) {
_editor_record_file_usage($uuids, $entity);
}
}
// On modified revisions, detect which file references have been added (and
// record their usage) and which ones have been removed (delete their usage).
// File references that existed both in the previous version of the revision
// and in the new one don't need their usage to be updated.
else {
$original_uuids_by_field = _editor_get_file_uuids_by_field($entity->original);
$uuids_by_field = _editor_get_file_uuids_by_field($entity);
// Detect file usages that should be incremented.
foreach ($uuids_by_field as $field => $uuids) {
$added_files = array_diff($uuids_by_field[$field], $original_uuids_by_field[$field]);
_editor_record_file_usage($added_files, $entity);
}
// Detect file usages that should be decremented.
foreach ($original_uuids_by_field as $field => $uuids) {
$removed_files = array_diff($original_uuids_by_field[$field], $uuids_by_field[$field]);
_editor_delete_file_usage($removed_files, $entity, 1);
}
}
}
/**
* Implements hook_entity_delete().
*/
function editor_entity_delete(EntityInterface $entity) {
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
foreach ($referenced_files_by_field as $field => $uuids) {
_editor_delete_file_usage($uuids, $entity, 0);
}
}
/**
* Implements hook_entity_revision_delete().
*/
function editor_entity_revision_delete(EntityInterface $entity) {
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
foreach ($referenced_files_by_field as $field => $uuids) {
_editor_delete_file_usage($uuids, $entity, 1);
}
}
/**
* Records file usage of files referenced by processed text fields.
*
* Every referenced file that does not yet have the FILE_STATUS_PERMANENT state,
* will be given that state.
*
* @param array $uuids
* An array of file entity UUIDs.
* @param EntityInterface $entity
* An entity whose fields to inspect for file references.
*/
function _editor_record_file_usage(array $uuids, EntityInterface $entity) {
foreach ($uuids as $uuid) {
$file = entity_load_by_uuid('file', $uuid);
if ($file->status !== FILE_STATUS_PERMANENT) {
$file->status = FILE_STATUS_PERMANENT;
$file->save();
}
file_usage()->add($file, 'editor', $entity->entityType(), $entity->id());
}
}
/**
* Deletes file usage of files referenced by processed text fields.
*
* @param array $uuids
* An array of file entity UUIDs.
* @param EntityInterface $entity
* An entity whose fields to inspect for file references.
* @param $count
* The number of references to delete. Should be 1 when deleting a single
* revision and 0 when deleting an entity entirely.
*
* @see Drupal\file\FileUsage\FileUsageInterface::delete()
*/
function _editor_delete_file_usage(array $uuids, EntityInterface $entity, $count) {
foreach ($uuids as $uuid) {
$file = entity_load_by_uuid('file', $uuid);
file_usage()->delete($file, 'editor', $entity->entityType(), $entity->id(), $count);
}
}
/**
* Finds all files referenced (data-editor-file-uuid) by processed text fields.
*
* @param EntityInterface $entity
* An entity whose fields to analyze.
*
* @return array
* An array of file entity UUIDs.
*/
function _editor_get_file_uuids_by_field(EntityInterface $entity) {
$uuids = array();
$processed_text_fields = _editor_get_processed_text_fields($entity);
foreach ($processed_text_fields as $processed_text_field) {
$text = $entity->get($processed_text_field)->value;
$uuids[$processed_text_field] = _editor_parse_file_uuids($text);
}
return $uuids;
}
/**
* Determines the text fields on an entity that have text processing enabled.
*
* @param EntityInterface $entity
* An entity whose fields to analyze.
*
* @return array
* The names of the fields on this entity that have text processing enabled.
*/
function _editor_get_processed_text_fields(EntityInterface $entity) {
$properties = $entity->getPropertyDefinitions();
if (empty($properties)) {
return array();
}
// Find all configurable fields, because only they could have a
// text_processing setting.
$configurable_fields = array_keys(array_filter($properties, function ($definition) {
return isset($definition['configurable']) && $definition['configurable'] === TRUE;
}));
if (empty($configurable_fields)) {
return array();
}
// Only return fields that have text processing enabled.
return array_filter($configurable_fields, function ($field) use ($entity) {
$settings = Field::fieldInfo()
->getInstance($entity->entityType(), $entity->bundle(), $field)
->getFieldSettings();
return isset($settings['text_processing']) && $settings['text_processing'] === '1';
});
}
/**
* Parse an HTML snippet for any data-editor-file-uuid attributes.
*
* @param string $text
* The partial (X)HTML snippet to load. Invalid markup will be corrected on
* import.
*
* @return array
* An array of all found UUIDs.
*/
function _editor_parse_file_uuids($text) {
$dom = filter_dom_load($text);
$xpath = new \DOMXPath($dom);
$uuids = array();
foreach ($xpath->query('//*[@data-editor-file-uuid]') as $node) {
$uuids[] = $node->getAttribute('data-editor-file-uuid');
}
return $uuids;
}
......@@ -47,12 +47,19 @@ class Editor extends ConfigEntityBase implements EditorInterface {
public $editor;
/**
* The array of settings for the text editor.
* The array of text editor plugin-specific settings for the text editor.
*
* @var array
*/
public $settings = array();
/**
* The array of image upload settings for the text editor.
*
* @var array
*/
public $image_upload = array();
/**
* Overrides Drupal\Core\Entity\Entity::id().
*/
......
......@@ -13,6 +13,8 @@
use Drupal\Core\Ajax\HtmlCommand;
use Drupal\editor\Ajax\EditorDialogSave;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Drupal\Core\StreamWrapper\LocalStream;
use Drupal\file\FileInterface;
/**
* Provides an image dialog for text editors.
......@@ -42,16 +44,50 @@ public function buildForm(array $form, array &$form_state, FilterFormat $filter_
$form['#prefix'] = '<div id="editor-image-dialog-form">';
$form['#suffix'] = '</div>';
// Everything under the "attributes" key is merged directly into the
// generated img tag's attributes.
$form['attributes']['src'] = array(
'#title' => t('URL'),
'#type' => 'textfield',
'#default_value' => isset($input['src']) ? $input['src'] : '',
'#maxlength' => 2048,
$editor = editor_load($filter_format->format);
// Construct strings to use in the upload validators.
if (!empty($editor->image_upload['dimensions'])) {
$max_dimensions = $editor->image_upload['dimensions']['max_width'] . 'x' . $editor->image_upload['dimensions']['max_height'];
}
else {
$max_dimensions = 0;
}
$max_filesize = min(parse_size($editor->image_upload['max_size']), file_upload_max_size());
$existing_file = isset($input['data-editor-file-uuid']) ? entity_load_by_uuid('file', $input['data-editor-file-uuid']) : NULL;
$fid = $existing_file ? $existing_file->id() : NULL;
$form['fid'] = array(
'#title' => t('Image'),
'#type' => 'managed_file',
'#upload_location' => $editor->image_upload['scheme'] . '://' .$editor->image_upload['directory'],
'#default_value' => $fid ? array($fid) : NULL,
'#upload_validators' => array(
'file_validate_extensions' => array('gif png jpg jpeg'),
'file_validate_size' => array($max_filesize),
'file_validate_image_resolution' => array($max_dimensions),
),
'#required' => TRUE,
);
$form['attributes']['src'] = array(
'#title' => t('URL'),
'#type' => 'textfield',
'#default_value' => isset($input['src']) ? $input['src'] : '',
'#maxlength' => 2048,
'#required' => TRUE,
);
// If the editor has image uploads enabled, show a managed_file form item,
// otherwise show a (file URL) text form item.
if ($editor->image_upload['status'] === '1') {
$form['attributes']['src']['#access'] = FALSE;
}
else {
$form['fid']['#access'] = FALSE;
}
$form['attributes']['alt'] = array(
'#title' => t('Alternative text'),
'#type' => 'textfield',
......@@ -120,6 +156,14 @@ public function validateForm(array &$form, array &$form_state) {
public function submitForm(array &$form, array &$form_state) {
$response = new AjaxResponse();
// Convert any uploaded files from the FID values to data-editor-file-uuid
// attributes.
if (!empty($form_state['values']['fid'][0])) {
$file = file_load($form_state['values']['fid'][0]);
$form_state['values']['attributes']['src'] = file_create_url($file->getFileUri());
$form_state['values']['attributes']['data-editor-file-uuid'] = $file->uuid();
}
if (form_get_errors()) {
unset($form['#prefix'], $form['#suffix']);
$status_messages = array('#theme' => 'status_messages');
......
<?php
/**
* @file
* Contains \Drupal\editor\Tests\EditorFileUsageTest.
*/
namespace Drupal\editor\Tests;
use Drupal\simpletest\DrupalUnitTestBase;
/**
* Unit tests for editor.module's entity hooks to track file usage.
*/
class EditorFileUsageTest extends DrupalUnitTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = array('system', 'editor', 'editor_test', 'filter', 'node', 'entity', 'field', 'text', 'field_sql_storage', 'file');
public static function getInfo() {
return array(
'name' => 'Text Editor file usage',
'description' => 'Tests tracking of file usage by the Text Editor module.',
'group' => 'Text Editor',
);
}
function setUp() {
parent::setUp();
$this->installSchema('system', 'url_alias');
$this->installSchema('node', 'node');
$this->installSchema('node', 'node_access');
$this->installSchema('node', 'node_field_data');
$this->installSchema('node', 'node_field_revision');
$this->installSchema('file', 'file_managed');
$this->installSchema('file', 'file_usage');
// Add text formats.
$filtered_html_format = entity_create('filter_format', array(
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => array(),
));
$filtered_html_format->save();
// Set up text editor.