Commit 577a41d4 authored by webchick's avatar webchick

Issue #2027181 by Wim Leers, seutje, Bojhan: Use a CKEditor Widget to create a...

Issue #2027181 by Wim Leers, seutje, Bojhan: Use a CKEditor Widget to create a stellar UX for captioning and aligning images.
parent 19df7986
CKEditor 4 Changelog
====================
## CKEditor 4.3
## CKEditor 4.2.1
## CKEditor 4.2
**Important Notes:**
......
/**
/**
* This is a Drupal-optimized build of CKEditor.
*
* You may re-use it at any time at http://ckeditor.com/builder to build
......@@ -25,7 +25,7 @@ var CKBUILDER_CONFIG = {
'contents.css',
'styles.js',
'samples',
'skins/moono/readme.md',
'skins/moono/readme.md'
],
plugins : {
'about' : 1,
......@@ -65,14 +65,7 @@ var CKBUILDER_CONFIG = {
'showborders' : 1,
'tableresize' : 1,
'sharedspace' : 1,
'sourcedialog' : 1
// @todo D8: CKEditor Widgets is not available in 4.1 RC, and we're not yet
// using this, so it's commented out for now. However, it will be readded in
// the nearby future.
// 'widget' : 1,
// 'widgetblockquote' : 1,
// 'widgetcaption' : 1,
// 'widgettime' : 1,
// 'widgetvideo' : 1
'sourcedialog' : 1,
'widget' : 1
}
};
This diff is collapsed.
......@@ -5,6 +5,8 @@
* Provides integration with the CKEditor WYSIWYG editor.
*/
use Drupal\editor\Entity\Editor;
/**
* Implements hook_help().
*/
......@@ -29,7 +31,7 @@ function ckeditor_help($path, $arg) {
}
}
/*
/**
* Implements hook_library_info().
*/
function ckeditor_library_info() {
......@@ -107,9 +109,19 @@ function ckeditor_library_info() {
array('system', 'drupalSettings'),
),
);
$libraries['drupal.ckeditor.drupalimagecaption-theme'] = array(
'title' => 'Theming support for the imagecaption plugin.',
'version' => Drupal::VERSION,
'js' => array(
$module_path . '/js/plugins/drupalimagecaption/theme.js' => array(),
),
'dependencies' => array(
array('ckeditor', 'ckeditor'),
),
);
$libraries['ckeditor'] = array(
'title' => 'Loads the main CKEditor library.',
'version' => '4.2',
'version' => '4.3-dev — d8-imagecaption branch commit 887d81ac1824008b690e439a1b29eb4f13b51212',
'js' => array(
'core/assets/vendor/ckeditor/ckeditor.js' => array(
'preprocess' => FALSE,
......@@ -132,6 +144,25 @@ function ckeditor_theme() {
);
}
/**
* Implements hook_ckeditor_css_alter().
*/
function ckeditor_ckeditor_css_alter(array &$css, Editor $editor) {
$filters = array();
if (!empty($editor->format)) {
$filters = entity_load('filter_format', $editor->format)
->filters()
->getAll();
}
// Add the filter caption CSS if the text format associated with this text
// editor uses the filter_caption filter. This is used by the included
// CKEditor DrupalImageCaption plugin.
if (isset($filters['filter_caption']) && $filters['filter_caption']->status) {
$css[] = drupal_get_path('module', 'filter') . '/css/filter.caption.css';
}
}
/**
* Retrieves the default theme's CKEditor stylesheets defined in the .info file.
*
......
......@@ -15,34 +15,14 @@ CKEDITOR.plugins.add('drupalimage', {
requiredContent: 'img[alt,src,width,height]',
modes: { wysiwyg : 1 },
canUndo: true,
exec: function (editor) {
var imageElement = getSelectedImage(editor);
exec: function (editor, override) {
var imageDOMElement = null;
var existingValues = {};
if (imageElement && imageElement.$) {
imageDOMElement = imageElement.$;
// Width and height are populated by actual dimensions.
existingValues.width = imageDOMElement ? imageDOMElement.width : '';
existingValues.height = imageDOMElement ? imageDOMElement.height : '';
// Populate all other attributes by their specified attribute values.
var attribute = null, attributeName;
for (var key = 0; key < imageDOMElement.attributes.length; key++) {
attribute = imageDOMElement.attributes.item(key);
attributeName = attribute.nodeName.toLowerCase();
// Don't consider data-cke-saved- attributes; they're just there to
// work around browser quirks.
if (attributeName.substring(0, 15) === 'data-cke-saved-') {
continue;
}
// Store the value for this attribute, unless there's a
// data-cke-saved- alternative for it, which will contain the quirk-
// free, original value.
existingValues[attributeName] = imageElement.data('cke-saved-' + attributeName) || attribute.nodeValue;
}
}
var dialogTitle;
var saveCallback = function (returnValues) {
var selection = editor.getSelection();
var imageElement = selection.getSelectedElement();
function saveCallback (returnValues) {
editor.fire('saveSnapshot');
// Create a new image element if needed.
......@@ -75,13 +55,76 @@ CKEDITOR.plugins.add('drupalimage', {
// Save snapshot for undo support.
editor.fire('saveSnapshot');
};
// Allow CKEditor Widget plugins to execute DrupalImage's 'drupalimage'
// command. In this case, they need to provide the DOM element for the
// image (because this plugin wouldn't know where to find it), its
// existing values (because they're stored within the Widget in whatever
// way it sees fit) and a save callback (again because the Widget may
// store the returned values in whatever way it sees fit).
if (override) {
imageDOMElement = override.imageDOMElement;
existingValues = override.existingValues;
dialogTitle = override.dialogTitle;
if (override.saveCallback) {
saveCallback = override.saveCallback;
}
}
// Otherwise, retrieve the selected image and allow it to be edited, or
// if no image is selected: insert a new one.
else {
var selection = editor.getSelection();
var imageElement = selection.getSelectedElement();
// If the 'drupalimage' command is being applied to a CKEditor widget,
// then edit that Widget instead.
if (imageElement && imageElement.type === CKEDITOR.NODE_ELEMENT && imageElement.hasAttribute('data-widget-wrapper')) {
editor.widgets.focused.edit();
return;
}
// Otherwise, check if the 'drupalimage' command is being applied to
// an existing image tag, and then open a dialog to edit it.
else if (isImage(imageElement) && imageElement.$) {
imageDOMElement = imageElement.$;
// Width and height are populated by actual dimensions.
existingValues.width = imageDOMElement ? imageDOMElement.width : '';
existingValues.height = imageDOMElement ? imageDOMElement.height : '';
// Populate all other attributes by their specified attribute values.
var attribute = null, attributeName;
for (var key = 0; key < imageDOMElement.attributes.length; key++) {
attribute = imageDOMElement.attributes.item(key);
attributeName = attribute.nodeName.toLowerCase();
// Don't consider data-cke-saved- attributes; they're just there to
// work around browser quirks.
if (attributeName.substring(0, 15) === 'data-cke-saved-') {
continue;
}
// Store the value for this attribute, unless there's a
// data-cke-saved- alternative for it, which will contain the quirk-
// free, original value.
existingValues[attributeName] = imageElement.data('cke-saved-' + attributeName) || attribute.nodeValue;
}
dialogTitle = editor.config.drupalImage_dialogTitleEdit;
}
// The 'drupalimage' command is being executed to add a new image.
else {
dialogTitle = editor.config.drupalImage_dialogTitleAdd;
// Allow other plugins to override the image insertion: they must
// listen to this event and cancel the event to do so.
if (!editor.fire('drupalimageinsert')) {
return;
}
}
}
// Drupal.t() will not work inside CKEditor plugins because CKEditor
// loads the JavaScript file instead of Drupal. Pull translated strings
// from the plugin settings that are translated server-side.
var dialogSettings = {
title: imageDOMElement ? editor.config.drupalImage_dialogTitleEdit : editor.config.drupalImage_dialogTitleAdd,
title: dialogTitle,
dialogClass: 'editor-image-dialog'
};
......@@ -121,7 +164,7 @@ CKEDITOR.plugins.add('drupalimage', {
// If the "contextmenu" plugin is loaded, register the listeners.
if (editor.contextMenu) {
editor.contextMenu.addListener(function (element, selection) {
if (getSelectedImage(editor, element)) {
if (isImage(element)) {
return { image: CKEDITOR.TRISTATE_OFF };
}
});
......@@ -129,21 +172,8 @@ CKEDITOR.plugins.add('drupalimage', {
}
});
/**
* Finds an img tag anywhere in the current editor selection.
*/
function getSelectedImage (editor, element) {
if (!element) {
var sel = editor.getSelection();
var selectedText = sel.getSelectedText().replace(/^\s\s*/, '').replace(/\s\s*$/, '');
var isElement = sel.getType() === CKEDITOR.SELECTION_ELEMENT;
var isEmptySelection = sel.getType() === CKEDITOR.SELECTION_TEXT && selectedText.length === 0;
element = (isElement || isEmptySelection) && sel.getSelectedElement();
}
if (element && element.is('img') && !element.data('cke-realelement') && !element.isReadOnly()) {
return element;
}
function isImage (element) {
return element && element.is('img') && !element.data('cke-realelement') && !element.isReadOnly();
}
})(jQuery, Drupal, drupalSettings, CKEDITOR);
/**
* @file
* Drupal Image Caption plugin.
*
* Integrates the Drupal Image plugin with the caption_filter filter if enabled.
*/
(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();
});
// Register the widget with a unique name "drupalimagecaption".
editor.widgets.add('drupalimagecaption', {
allowedContent: 'img[!src,alt,width,height,!data-caption,!data-align]',
template: '<img src="" />',
parts: {
image: 'img'
},
// 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')
});
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']);
}
// Float the wrapper too.
if (this.data.data_align === null) {
this.wrapper.removeStyle('float');
this.wrapper.removeStyle('text-align');
}
else if (this.data.data_align === 'center') {
this.wrapper.setStyle('float', 'none');
this.wrapper.setStyle('text-align', 'center');
}
else {
this.wrapper.setStyle('float', this.data.data_align);
this.wrapper.removeStyle('text-align');
}
},
// Check the elements that need to be converted to widgets.
upcast: function (el) {
// Upcast all <img> elements that are alone inside a block element.
if (el.name === 'img') {
if (CKEDITOR.dtd.$block[el.parent.name] && el.parent.children.length === 1) {
return true;
}
}
},
// Convert the element back to its desired output representation.
downcast: function (el) {
if (this.data.hasCaption) {
el.attributes['data-caption'] = this.data.data_caption;
}
if (this.data.data_align) {
el.attributes['data-align'] = this.data.data_align;
}
},
_selectionWillCreateInlineImage: function () {
// Returns node or first of its ancestors
// which is a block or block limit.
function getBlockParent( node, root ) {
var path = new CKEDITOR.dom.elementPath( node, root );
return path.block || path.blockLimit;
}
var range = editor.getSelection().getRanges()[ 0 ],
startEl = getBlockParent( range.startContainer, range.root ),
endEl = getBlockParent( range.endContainer, range.root );
var insideStartEl = range.checkBoundaryOfElement( startEl, CKEDITOR.START );
var insideEndEl = range.checkBoundaryOfElement( endEl, CKEDITOR.END );
return !(insideStartEl && insideEndEl);
},
_insertSaveCallback: function (returnValues) {
// We can't create an image with an empty "src" attribute.
if (returnValues.attributes.src.length === 0) {
return;
}
editor.fire('saveSnapshot');
// Build the HTML for the widget.
var html = '<img ';
for (var attr in returnValues.attributes) {
if (returnValues.attributes.hasOwnProperty(attr) && !attr.match(/^data_/)) {
html += attr + '="' + returnValues.attributes[attr] + '" ';
html += 'data-cke-saved-' + attr + '="' + returnValues.attributes[attr] + '" ';
}
}
if (returnValues.hasCaption) {
html += 'data-caption="" ';
html += ' data-cke-saved-data-caption=""';
}
if (returnValues.attributes.data_align && returnValues.attributes.data_align !== 'none') {
html += 'data-align="' + returnValues.attributes.data_align + '" ';
html += ' data-cke-saved-data-align="' + returnValues.attributes.data_align + '"';
}
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');
// Initialize and focus the widget.
var widget = editor.widgets.initOn(el, 'drupalimagecaption');
widget.focus();
},
insert: function () {
var override = {
imageDOMElement: null,
existingValues: { hasCaption: false, data_align: '' },
saveCallback: this._insertSaveCallback,
dialogTitle: editor.config.drupalImage_dialogTitleAdd
};
if (this._selectionWillCreateInlineImage()) {
override.existingValues.isInline = this._selectionWillCreateInlineImage();
delete override.saveCallback;
}
editor.execCommand('drupalimage', override);
},
edit: function () {
var that = this;
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
});
// Save snapshot for undo support.
editor.fire('saveSnapshot');
};
var override = {
imageDOMElement: this.parts.image.$,
existingValues: this.data,
saveCallback: saveCallback,
dialogTitle: this.data.src === '' ? editor.config.drupalImage_dialogTitleAdd : editor.config.drupalImage_dialogTitleEdit
};
editor.execCommand('drupalimage', override);
}
});
},
afterInit: function (editor) {
function setupAlignCommand (value) {
var command = editor.getCommand('justify' + value);
if (command) {
if (value in { right: 1, left: 1, center: 1 }) {
command.on('exec', function (event) {
var widget = getSelectedWidget(editor);
if (widget && widget.name === 'drupalimagecaption') {
widget.setData({ data_align: value });
event.cancel();
}
});
}
command.on('refresh', function (event) {
var widget = getSelectedWidget(editor),
allowed = { left: 1, center: 1, right: 1 },
align;
if (widget) {
align = widget.data.data_align;
this.setState(
(align === value) ? CKEDITOR.TRISTATE_ON : (value in allowed) ? CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED);
event.cancel();
}
});
}
}
function getSelectedWidget (editor) {
var widget = editor.widgets.focused;
if (widget && widget.name === 'drupalimagecaption') {
return widget;
}
return null;
}
// Customize the behavior of the alignment commands.
setupAlignCommand('left');
setupAlignCommand('right');
setupAlignCommand('center');
}
});
})(CKEDITOR);
/**
* @file
* Drupal Image Caption plugin theme override.
*/
(function (CKEDITOR) {
"use strict";
CKEDITOR.on('instanceCreated', function (event) {
var editor = event.editor;
// Listen to widget definitions and customize them as needed. It's
// basically rewriting parts of the definition.
editor.on('widgetDefinition', function (event) {
var widgetDefinition = event.data;
// Customize the "drupalimagecaption" widget definition.
if (widgetDefinition.name === 'drupalimagecaption') {
widgetDefinition.template =
'<figure class="caption caption-img">' +
'<img src="" data-caption="" data-align="center" />' +
'<figcaption></figcaption>' +
'</figure>';
// Define the editables created by the overridden upcasting.
widgetDefinition.editables = {
caption: 'figcaption'
};
// Define the additional parts created by the overridden upcasting.
widgetDefinition.parts.caption = 'figcaption';
// Override "data" so we can make the new widget structure
// behave according to changes on data.
widgetDefinition.data = CKEDITOR.tools.override(widgetDefinition.data, function (originalDataFn) {
return function () {
// Call the original "data" implementation.
originalDataFn.apply(this, arguments);
// The image is wrapped in <figure>.
if (this.element.is('figure')) {
// The image is wrapped in <figure>, but it should no longer be.
if (!this.data.hasCaption && this.data.data_align === null) {
// Destroy this widget, so we can unwrap the <img>.
editor.widgets.destroy(this);
// Unwrap <img> from <figure>.
this.parts.image.replace(this.element);
// Reinitialize this widget with the current data.
editor.widgets.initOn(this.parts.image, 'drupalimagecaption', this.data);
}
// The image is wrapped in <figure>, as it should be; update it.
else {
// Set the caption visibility.
this.parts.caption.setStyle('display', this.data.hasCaption ? '' : 'none');
// Set the alignment, if any.
this.element.removeClass('caption-left');
this.element.removeClass('caption-center');
this.element.removeClass('caption-right');
if (this.data.data_align) {
this.element.addClass('caption-' + this.data.data_align);
}
}
}
// The image is not wrapped in <figure>.
else if (this.element.is('img')) {
// The image is not wrapped in <figure>, but it should be.
if (this.data.hasCaption || this.data.data_align !== null) {
// Destroy this widget, so we can wrap the <img>.
editor.widgets.destroy(this);
// Replace the widget's element (the <img>) with the template (a
// <figure> wrapping an <img>) and then replace the the template's
// default <img> by our <img> so we won't lose attributes. We must
// do this manually because upcast() won't run.
var figure = CKEDITOR.dom.element.createFromHtml(this.template.output(), editor.document);
figure.replace(this.element);
this.element.replace(figure.findOne('img'));
// Reinitialize this widget with the current data.
editor.widgets.initOn(figure, 'drupalimagecaption', this.data);
}
}
};
});
// Upcast to <figure> if data-caption or data-align is set.
widgetDefinition.upcast = CKEDITOR.tools.override(widgetDefinition.upcast, function (originalUpcastFn) {
return function (el) {
// Execute the original upcast first. If "true", this is an
// element to be upcasted.
if (originalUpcastFn.apply(this, arguments)) {
var figure;
var captionValue = el.attributes['data-caption'];
var alignValue = el.attributes['data-align'];
// Wrap image in <figure> only if data-caption or data-align is set.
if (captionValue !== undefined || alignValue !== undefined) {
var classes = 'caption caption-img';
if (alignValue !== null) {
classes += ' caption-' + alignValue;
}
figure = el.wrapWith(new CKEDITOR.htmlParser.element('figure', { 'class' : classes }));
var caption = CKEDITOR.htmlParser.fragment.fromHtml(captionValue || '', 'figcaption');
figure.add(caption);
}
return figure || el;
}
};
});
// Downcast to <img>.
widgetDefinition.downcast = CKEDITOR.tools.override(widgetDefinition.downcast, function (originalDowncastFn) {
return function (el) {
if (el.name === 'figure') {
// Update data with the current caption.
var caption = el.getFirst('figcaption');
caption = caption ? caption.getHtml() : '';
this.setData({
data_caption: caption
});
// We downcast to just the <img> element.
el = el.getFirst('img');
}
// Call the original downcast to setup the <img>
// meta data accordingly.
return originalDowncastFn.call(this, el) || el;
};
});
// Generate a <figure>-wrapped <img> if either data-caption or data-align
// are set for a newly created image.
widgetDefinition.insert = CKEDITOR.tools.override(widgetDefinition.downcast, function (originalInsertFn) {
return function () {