diff --git a/core/modules/ckeditor/js/plugins/drupalimage/plugin.js b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js index 80921c250793365ff07920cd7b2f5620f83bbf4f..c481264473cf43bd0d56c0d41de58c377800757a 100644 --- a/core/modules/ckeditor/js/plugins/drupalimage/plugin.js +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js @@ -65,14 +65,11 @@ } widgetDefinition.allowedContent = new CKEDITOR.style(allowedContentDefinition); - // Override the 'link' part, to completely disable image2's link - // support: http://dev.ckeditor.com/ticket/11341. - widgetDefinition.parts.link = 'This is a nonsensical selector to disable this functionality completely'; - // Override downcast(): since we only accept in our upcast method, // the element is already correct. We only need to update the element's // data-entity-uuid attribute. widgetDefinition.downcast = function (element) { + element.attributes['data-entity-type'] = this.data['data-entity-type']; element.attributes['data-entity-uuid'] = this.data['data-entity-uuid']; }; @@ -176,6 +173,18 @@ return widget; }; }; + + var originalInit = widgetDefinition.init; + widgetDefinition.init = function () { + originalInit.call(this); + + // Update data.link object with attributes if the link has been + // discovered. + // @see plugins/image2/plugin.js/init() in CKEditor; this is similar. + if (this.parts.link) { + this.setData('link', CKEDITOR.plugins.link.parseLinkAttributes(editor, this.parts.link)); + } + }; }); // Add a widget#edit listener to every instance of image2 widget in order @@ -233,25 +242,86 @@ } }, - // Disable image2's integration with the link/drupallink plugins: don't - // allow the widget itself to become a link. Support for that may be added - // by an text filter that adds a data- attribute specifically for that. afterInit: function (editor) { - if (editor.plugins.drupallink) { - var cmd = editor.getCommand('drupallink'); - // Needs to be refreshed on selection changes. - cmd.contextSensitive = 1; - // Disable command and cancel event when the image widget is selected. - cmd.on('refresh', function (evt) { - var widget = editor.widgets.focused; - if (widget && widget.name === 'image') { - this.setState(CKEDITOR.TRISTATE_DISABLED); - evt.cancel(); - } - }); - } + linkCommandIntegrator(editor); } }); + /** + * Integrates the drupalimage widget with the drupallink plugin. + * + * Makes images linkable. + * + * @param {CKEDITOR.editor} editor + * A CKEditor instance. + */ + function linkCommandIntegrator(editor) { + // Nothing to integrate with if the drupallink plugin is not loaded. + if (!editor.plugins.drupallink) { + return; + } + + // Override default behaviour of 'drupalunlink' command. + editor.getCommand('drupalunlink').on('exec', function (evt) { + var widget = getFocusedWidget(editor); + + // Override 'drupalunlink' only when link truly belongs to the widget. If + // wrapped inline widget in a link, let default unlink work. + // @see https://dev.ckeditor.com/ticket/11814 + if (!widget || !widget.parts.link) { + return; + } + + widget.setData('link', null); + + // Selection (which is fake) may not change if unlinked image in focused + // widget, i.e. if captioned image. Let's refresh command state manually + // here. + this.refresh(editor, editor.elementPath()); + + evt.cancel(); + }); + + // Override default refresh of 'drupalunlink' command. + editor.getCommand('drupalunlink').on('refresh', function (evt) { + var widget = getFocusedWidget(editor); + + if (!widget) { + return; + } + + // Note that widget may be wrapped in a link, which + // does not belong to that widget (#11814). + this.setState(widget.data.link || widget.wrapper.getAscendant('a') ? + CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED); + + evt.cancel(); + }); + } + + /** + * Gets the focused widget, if of the type specific for this plugin. + * + * @param {CKEDITOR.editor} editor + * A CKEditor instance. + * + * @return {?CKEDITOR.plugins.widget} + * The focused image2 widget instance, or null. + */ + function getFocusedWidget(editor) { + var widget = editor.widgets.focused; + + if (widget && widget.name === 'image') { + return widget; + } + + return null; + } + + // Expose an API for other plugins to interact with drupalimage widgets. + CKEDITOR.plugins.drupalimage = { + getFocusedWidget: getFocusedWidget + }; + })(jQuery, Drupal, CKEDITOR); diff --git a/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js b/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js index 8dd91b17048c462a2147b8a6d82e81b59b76094f..9fba103c308bc4b22f2c30f3443679541298746e 100644 --- a/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js +++ b/core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.js @@ -71,10 +71,9 @@ // data-caption attributes. var originalDowncast = widgetDefinition.downcast; widgetDefinition.downcast = function (element) { - var img = originalDowncast.call(this, element); - if (!img) { - img = findElementByName(element, 'img'); - } + var img = findElementByName(element, 'img'); + originalDowncast.call(this, img); + var caption = this.editables.caption; var captionHtml = caption && caption.getData(); var attrs = img.attributes; @@ -91,10 +90,14 @@ attrs['data-align'] = this.data.align; } } - attrs['data-entity-type'] = this.data['data-entity-type']; - attrs['data-entity-uuid'] = this.data['data-entity-uuid']; - return img; + // If img is wrapped with a link, we want to return that link. + if (img.parent.name === 'a') { + return img.parent; + } + else { + return img; + } }; // We want to upcast elements to a DOM structure required by the @@ -115,6 +118,11 @@ element = originalUpcast.call(this, element, data); var attrs = element.attributes; + + if (element.parent.name === 'a') { + element = element.parent; + } + var retElement = element; var caption; diff --git a/core/modules/ckeditor/js/plugins/drupallink/plugin.js b/core/modules/ckeditor/js/plugins/drupallink/plugin.js index e9fb555b8eda2bff4100568b7989a55a75f02822..2cc9bc10069f5409be8ee9d7804434ea9c35d3fa 100644 --- a/core/modules/ckeditor/js/plugins/drupallink/plugin.js +++ b/core/modules/ckeditor/js/plugins/drupallink/plugin.js @@ -31,6 +31,8 @@ modes: {wysiwyg: 1}, canUndo: true, exec: function (editor) { + var drupalImageUtils = CKEDITOR.plugins.drupalimage; + var focusedImageWidget = drupalImageUtils && drupalImageUtils.getFocusedWidget(editor); var linkElement = getSelectedLink(editor); var linkDOMElement = null; @@ -56,9 +58,30 @@ existingValues[attributeName] = linkElement.data('cke-saved-' + attributeName) || attribute.nodeValue; } } + // Or, if an image widget is focused, we're editing a link wrapping + // an image widget. + else if (focusedImageWidget && focusedImageWidget.data.link) { + var url = focusedImageWidget.data.link.url; + existingValues.href = url.protocol + url.url; + } // Prepare a save callback to be used upon saving the dialog. var saveCallback = function (returnValues) { + // If an image widget is focused, we're not editing an independent + // link, but we're wrapping an image widget in a link. + if (focusedImageWidget) { + var urlMatch = returnValues.attributes.href.match(urlRegex); + focusedImageWidget.setData('link', { + type: 'url', + url: { + protocol: urlMatch[1], + url: urlMatch[2] + } + }); + editor.fire('saveSnapshot'); + return; + } + editor.fire('saveSnapshot'); // Create a new link element if needed. @@ -256,4 +279,57 @@ return null; } + var urlRegex = /^((?:http|https):\/\/)?(.*)$/; + + /** + * The image2 plugin is currently tightly coupled to the link plugin: it + * calls CKEDITOR.plugins.link.parseLinkAttributes(). + * + * Drupal 8's CKEditor build doesn't include the 'link' plugin. Because it + * includes its own link plugin that integrates with Drupal's dialog system. + * So, to allow images to be linked, we need to duplicate the necessary subset + * of the logic. + * + * @todo Remove once we update to CKEditor 4.5.5. + * @see https://dev.ckeditor.com/ticket/13885 + */ + CKEDITOR.plugins.link = CKEDITOR.plugins.link || { + parseLinkAttributes: function (editor, element) { + var href = (element && (element.data('cke-saved-href') || element.getAttribute('href'))) || ''; + var urlMatch = href.match(urlRegex); + return { + type: 'url', + url: { + protocol: urlMatch[1], + url: urlMatch[2] + } + }; + }, + getLinkAttributes: function (editor, data) { + var set = {}; + + var protocol = (data.url && typeof data.url.protocol !== 'undefined') ? data.url.protocol : 'http://'; + var url = (data.url && CKEDITOR.tools.trim(data.url.url)) || ''; + set['data-cke-saved-href'] = (url.indexOf('/') === 0) ? url : protocol + url; + + // Browser need the "href" fro copy/paste link to work. (#6641) + if (set['data-cke-saved-href']) { + set.href = set['data-cke-saved-href']; + } + + // Remove all attributes which are not currently set. + var removed = {}; + for (var s in set) { + if (set.hasOwnProperty(s)) { + delete removed[s]; + } + } + + return { + set: set, + removed: CKEDITOR.tools.objectKeys(removed) + }; + } + }; + })(jQuery, Drupal, drupalSettings, CKEDITOR); diff --git a/core/modules/filter/src/Plugin/Filter/FilterCaption.php b/core/modules/filter/src/Plugin/Filter/FilterCaption.php index 37c18cc3bc76d66fc9a7d9838f556b16ca3325b4..76a11258303e28f35eeb12d8bd96a54ecdddd090 100644 --- a/core/modules/filter/src/Plugin/Filter/FilterCaption.php +++ b/core/modules/filter/src/Plugin/Filter/FilterCaption.php @@ -55,15 +55,17 @@ public function process($text, $langcode) { // Given the updated node and caption: re-render it with a caption, but // bubble up the value of the class attribute of the captioned element, // this allows it to collaborate with e.g. the filter_align filter. + $tag = $node->tagName; $classes = $node->getAttribute('class'); $node->removeAttribute('class'); + $node = ($node->parentNode->tagName === 'a') ? $node->parentNode : $node; $filter_caption = array( '#theme' => 'filter_caption', // We pass the unsanitized string because this is a text format // filter, and after filtering, we always assume the output is safe. // @see \Drupal\filter\Element\ProcessedText::preRenderText() '#node' => FilteredMarkup::create($node->C14N()), - '#tag' => $node->tagName, + '#tag' => $tag, '#caption' => $caption, '#classes' => $classes, ); @@ -78,7 +80,7 @@ public function process($text, $langcode) { // 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! + // Finally, replace the original node with the new node. $node->parentNode->replaceChild($updated_node, $node); } diff --git a/core/modules/filter/src/Tests/FilterUnitTest.php b/core/modules/filter/src/Tests/FilterUnitTest.php index 54a738dd027f1781d855fb76fe5fff295a8bebb7..801db4e2fc0bd47dd6459a2a49f934c40f907b22 100644 --- a/core/modules/filter/src/Tests/FilterUnitTest.php +++ b/core/modules/filter/src/Tests/FilterUnitTest.php @@ -184,6 +184,13 @@ function testCaptionFilter() { $this->assertIdentical($expected, $output->getProcessedText()); $this->assertIdentical($attached_library, $output->getAttachments()); + // Ensure the caption filter works for linked images. + $input = ''; + $expected = '
' . "\n" . '
Loquacious llama!
'; + $output = $test($input); + $this->assertIdentical($expected, $output->getProcessedText()); + $this->assertIdentical($attached_library, $output->getAttachments()); + // So far we've tested that the caption filter works correctly. But we also // want to make sure that it works well in tandem with the "Limit allowed // HTML tags" filter, which it is typically used with. @@ -301,6 +308,13 @@ function testAlignAndCaptionFilters() { $output = $test($input); $this->assertIdentical($expected, $output->getProcessedText()); $this->assertIdentical($attached_library, $output->getAttachments()); + + // Ensure both filters together work for linked images. + $input = ''; + $expected = '
' . "\n" . '
Loquacious llama!
'; + $output = $test($input); + $this->assertIdentical($expected, $output->getProcessedText()); + $this->assertIdentical($attached_library, $output->getAttachments()); } /**