plugin.es6.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. /**
  2. * @file
  3. * Drupal Image Caption plugin.
  4. *
  5. * This alters the existing CKEditor image2 widget plugin, which is already
  6. * altered by the Drupal Image plugin, to:
  7. * - allow for the data-caption and data-align attributes to be set
  8. * - mimic the upcasting behavior of the caption_filter filter.
  9. *
  10. * @ignore
  11. */
  12. (function(CKEDITOR) {
  13. /**
  14. * Finds an element by its name.
  15. *
  16. * Function will check first the passed element itself and then all its
  17. * children in DFS order.
  18. *
  19. * @param {CKEDITOR.htmlParser.element} element
  20. * The element to search.
  21. * @param {string} name
  22. * The element name to search for.
  23. *
  24. * @return {?CKEDITOR.htmlParser.element}
  25. * The found element, or null.
  26. */
  27. function findElementByName(element, name) {
  28. if (element.name === name) {
  29. return element;
  30. }
  31. let found = null;
  32. element.forEach(el => {
  33. if (el.name === name) {
  34. found = el;
  35. // Stop here.
  36. return false;
  37. }
  38. }, CKEDITOR.NODE_ELEMENT);
  39. return found;
  40. }
  41. CKEDITOR.plugins.add('drupalimagecaption', {
  42. requires: 'drupalimage',
  43. beforeInit(editor) {
  44. // Disable default placeholder text that comes with CKEditor's image2
  45. // plugin: it has an inferior UX (it requires the user to manually delete
  46. // the place holder text).
  47. editor.lang.image2.captionPlaceholder = '';
  48. // Drupal.t() will not work inside CKEditor plugins because CKEditor loads
  49. // the JavaScript file instead of Drupal. Pull translated strings from the
  50. // plugin settings that are translated server-side.
  51. const placeholderText =
  52. editor.config.drupalImageCaption_captionPlaceholderText;
  53. // Override the image2 widget definition to handle the additional
  54. // data-align and data-caption attributes.
  55. editor.on(
  56. 'widgetDefinition',
  57. event => {
  58. const widgetDefinition = event.data;
  59. if (widgetDefinition.name !== 'image') {
  60. return;
  61. }
  62. // Only perform the downcasting/upcasting for to the enabled filters.
  63. const captionFilterEnabled =
  64. editor.config.drupalImageCaption_captionFilterEnabled;
  65. const alignFilterEnabled =
  66. editor.config.drupalImageCaption_alignFilterEnabled;
  67. // Override default features definitions for drupalimagecaption.
  68. CKEDITOR.tools.extend(
  69. widgetDefinition.features,
  70. {
  71. caption: {
  72. requiredContent: 'img[data-caption]',
  73. },
  74. align: {
  75. requiredContent: 'img[data-align]',
  76. },
  77. },
  78. true,
  79. );
  80. // Extend requiredContent & allowedContent.
  81. // CKEDITOR.style is an immutable object: we cannot modify its
  82. // definition to extend requiredContent. Hence we get the definition,
  83. // modify it, and pass it to a new CKEDITOR.style instance.
  84. const requiredContent = widgetDefinition.requiredContent.getDefinition();
  85. requiredContent.attributes['data-align'] = '';
  86. requiredContent.attributes['data-caption'] = '';
  87. widgetDefinition.requiredContent = new CKEDITOR.style(
  88. requiredContent,
  89. );
  90. widgetDefinition.allowedContent.img.attributes['!data-align'] = true;
  91. widgetDefinition.allowedContent.img.attributes[
  92. '!data-caption'
  93. ] = true;
  94. // Override allowedContent setting for the 'caption' nested editable.
  95. // This must match what caption_filter enforces.
  96. // @see \Drupal\filter\Plugin\Filter\FilterCaption::process()
  97. // @see \Drupal\Component\Utility\Xss::filter()
  98. widgetDefinition.editables.caption.allowedContent =
  99. 'a[!href]; em strong cite code br';
  100. // Override downcast(): ensure we *only* output <img>, but also ensure
  101. // we include the data-entity-type, data-entity-uuid, data-align and
  102. // data-caption attributes.
  103. const originalDowncast = widgetDefinition.downcast;
  104. widgetDefinition.downcast = function(element) {
  105. const img = findElementByName(element, 'img');
  106. originalDowncast.call(this, img);
  107. const caption = this.editables.caption;
  108. const captionHtml = caption && caption.getData();
  109. const attrs = img.attributes;
  110. if (captionFilterEnabled) {
  111. // If image contains a non-empty caption, serialize caption to the
  112. // data-caption attribute.
  113. if (captionHtml) {
  114. attrs['data-caption'] = captionHtml;
  115. }
  116. }
  117. if (alignFilterEnabled) {
  118. if (this.data.align !== 'none') {
  119. attrs['data-align'] = this.data.align;
  120. }
  121. }
  122. // If img is wrapped with a link, we want to return that link.
  123. if (img.parent.name === 'a') {
  124. return img.parent;
  125. }
  126. return img;
  127. };
  128. // We want to upcast <img> elements to a DOM structure required by the
  129. // image2 widget. Depending on a case it may be:
  130. // - just an <img> tag (non-captioned, not-centered image),
  131. // - <img> tag in a paragraph (non-captioned, centered image),
  132. // - <figure> tag (captioned image).
  133. // We take the same attributes into account as downcast() does.
  134. const originalUpcast = widgetDefinition.upcast;
  135. widgetDefinition.upcast = function(element, data) {
  136. if (
  137. element.name !== 'img' ||
  138. !element.attributes['data-entity-type'] ||
  139. !element.attributes['data-entity-uuid']
  140. ) {
  141. return;
  142. }
  143. // Don't initialize on pasted fake objects.
  144. if (element.attributes['data-cke-realelement']) {
  145. return;
  146. }
  147. element = originalUpcast.call(this, element, data);
  148. const attrs = element.attributes;
  149. if (element.parent.name === 'a') {
  150. element = element.parent;
  151. }
  152. let retElement = element;
  153. let caption;
  154. // We won't need the attributes during editing: we'll use widget.data
  155. // to store them (except the caption, which is stored in the DOM).
  156. if (captionFilterEnabled) {
  157. caption = attrs['data-caption'];
  158. delete attrs['data-caption'];
  159. }
  160. if (alignFilterEnabled) {
  161. data.align = attrs['data-align'];
  162. delete attrs['data-align'];
  163. }
  164. data['data-entity-type'] = attrs['data-entity-type'];
  165. delete attrs['data-entity-type'];
  166. data['data-entity-uuid'] = attrs['data-entity-uuid'];
  167. delete attrs['data-entity-uuid'];
  168. if (captionFilterEnabled) {
  169. // Unwrap from <p> wrapper created by HTML parser for a captioned
  170. // image. The captioned image will be transformed to <figure>, so we
  171. // don't want the <p> anymore.
  172. if (element.parent.name === 'p' && caption) {
  173. let index = element.getIndex();
  174. const splitBefore = index > 0;
  175. const splitAfter = index + 1 < element.parent.children.length;
  176. if (splitBefore) {
  177. element.parent.split(index);
  178. }
  179. index = element.getIndex();
  180. if (splitAfter) {
  181. element.parent.split(index + 1);
  182. }
  183. element.parent.replaceWith(element);
  184. retElement = element;
  185. }
  186. // If this image has a caption, create a full <figure> structure.
  187. if (caption) {
  188. const figure = new CKEDITOR.htmlParser.element('figure');
  189. caption = new CKEDITOR.htmlParser.fragment.fromHtml(
  190. caption,
  191. 'figcaption',
  192. );
  193. const captionFilter = new CKEDITOR.filter(
  194. widgetDefinition.editables.caption.allowedContent,
  195. );
  196. captionFilter.applyTo(caption);
  197. // Use Drupal's data-placeholder attribute to insert a CSS-based,
  198. // translation-ready placeholder for empty captions. Note that it
  199. // also must to be done for new instances (see
  200. // widgetDefinition._createDialogSaveCallback).
  201. caption.attributes['data-placeholder'] = placeholderText;
  202. element.replaceWith(figure);
  203. figure.add(element);
  204. figure.add(caption);
  205. figure.attributes.class = editor.config.image2_captionedClass;
  206. retElement = figure;
  207. }
  208. }
  209. if (alignFilterEnabled) {
  210. // If this image doesn't have a caption (or the caption filter is
  211. // disabled), but it is centered, make sure that it's wrapped with
  212. // <p>, which will become a part of the widget.
  213. if (
  214. data.align === 'center' &&
  215. (!captionFilterEnabled || !caption)
  216. ) {
  217. const p = new CKEDITOR.htmlParser.element('p');
  218. element.replaceWith(p);
  219. p.add(element);
  220. // Apply the class for centered images.
  221. p.addClass(editor.config.image2_alignClasses[1]);
  222. retElement = p;
  223. }
  224. }
  225. // Return the upcasted element (<img>, <figure> or <p>).
  226. return retElement;
  227. };
  228. // Protected; keys of the widget data to be sent to the Drupal dialog.
  229. // Append to the values defined by the drupalimage plugin.
  230. // @see core/modules/ckeditor/js/plugins/drupalimage/plugin.js
  231. CKEDITOR.tools.extend(widgetDefinition._mapDataToDialog, {
  232. align: 'data-align',
  233. 'data-caption': 'data-caption',
  234. hasCaption: 'hasCaption',
  235. });
  236. // Override Drupal dialog save callback.
  237. const originalCreateDialogSaveCallback =
  238. widgetDefinition._createDialogSaveCallback;
  239. widgetDefinition._createDialogSaveCallback = function(
  240. editor,
  241. widget,
  242. ) {
  243. const saveCallback = originalCreateDialogSaveCallback.call(
  244. this,
  245. editor,
  246. widget,
  247. );
  248. return function(dialogReturnValues) {
  249. // Ensure hasCaption is a boolean. image2 assumes it always works
  250. // with booleans; if this is not the case, then
  251. // CKEDITOR.plugins.image2.stateShifter() will incorrectly mark
  252. // widget.data.hasCaption as "changed" (e.g. when hasCaption === 0
  253. // instead of hasCaption === false). This causes image2's "state
  254. // shifter" to enter the wrong branch of the algorithm and blow up.
  255. dialogReturnValues.attributes.hasCaption = !!dialogReturnValues
  256. .attributes.hasCaption;
  257. const actualWidget = saveCallback(dialogReturnValues);
  258. // By default, the template of captioned widget has no
  259. // data-placeholder attribute. Note that it also must be done when
  260. // upcasting existing elements (see widgetDefinition.upcast).
  261. if (dialogReturnValues.attributes.hasCaption) {
  262. actualWidget.editables.caption.setAttribute(
  263. 'data-placeholder',
  264. placeholderText,
  265. );
  266. // Some browsers will add a <br> tag to a newly created DOM
  267. // element with no content. Remove this <br> if it is the only
  268. // thing in the caption. Our placeholder support requires the
  269. // element be entirely empty. See filter-caption.css.
  270. const captionElement = actualWidget.editables.caption.$;
  271. if (
  272. captionElement.childNodes.length === 1 &&
  273. captionElement.childNodes.item(0).nodeName === 'BR'
  274. ) {
  275. captionElement.removeChild(captionElement.childNodes.item(0));
  276. }
  277. }
  278. };
  279. };
  280. // Low priority to ensure drupalimage's event handler runs first.
  281. },
  282. null,
  283. null,
  284. 20,
  285. );
  286. },
  287. afterInit(editor) {
  288. const disableButtonIfOnWidget = function(evt) {
  289. const widget = editor.widgets.focused;
  290. if (widget && widget.name === 'image') {
  291. this.setState(CKEDITOR.TRISTATE_DISABLED);
  292. evt.cancel();
  293. }
  294. };
  295. // Disable alignment buttons if the align filter is not enabled.
  296. if (
  297. editor.plugins.justify &&
  298. !editor.config.drupalImageCaption_alignFilterEnabled
  299. ) {
  300. let cmd;
  301. const commands = [
  302. 'justifyleft',
  303. 'justifycenter',
  304. 'justifyright',
  305. 'justifyblock',
  306. ];
  307. for (let n = 0; n < commands.length; n++) {
  308. cmd = editor.getCommand(commands[n]);
  309. cmd.contextSensitive = 1;
  310. cmd.on('refresh', disableButtonIfOnWidget, null, null, 4);
  311. }
  312. }
  313. },
  314. });
  315. })(CKEDITOR);