filter.filter_html.admin.es6.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. /**
  2. * @file
  3. * Attaches behavior for updating filter_html's settings automatically.
  4. */
  5. (function($, Drupal, _, document) {
  6. if (Drupal.filterConfiguration) {
  7. /**
  8. * Implement a live setting parser to prevent text editors from automatically
  9. * enabling buttons that are not allowed by this filter's configuration.
  10. *
  11. * @namespace
  12. */
  13. Drupal.filterConfiguration.liveSettingParsers.filter_html = {
  14. /**
  15. * @return {Array}
  16. * An array of filter rules.
  17. */
  18. getRules() {
  19. const currentValue = $(
  20. '#edit-filters-filter-html-settings-allowed-html',
  21. ).val();
  22. const rules = Drupal.behaviors.filterFilterHtmlUpdating._parseSetting(
  23. currentValue,
  24. );
  25. // Build a FilterHTMLRule that reflects the hard-coded behavior that
  26. // strips all "style" attribute and all "on*" attributes.
  27. const rule = new Drupal.FilterHTMLRule();
  28. rule.restrictedTags.tags = ['*'];
  29. rule.restrictedTags.forbidden.attributes = ['style', 'on*'];
  30. rules.push(rule);
  31. return rules;
  32. },
  33. };
  34. }
  35. /**
  36. * Displays and updates what HTML tags are allowed to use in a filter.
  37. *
  38. * @type {Drupal~behavior}
  39. *
  40. * @todo Remove everything but 'attach' and 'detach' and make a proper object.
  41. *
  42. * @prop {Drupal~behaviorAttach} attach
  43. * Attaches behavior for updating allowed HTML tags.
  44. */
  45. Drupal.behaviors.filterFilterHtmlUpdating = {
  46. // The form item contains the "Allowed HTML tags" setting.
  47. $allowedHTMLFormItem: null,
  48. // The description for the "Allowed HTML tags" field.
  49. $allowedHTMLDescription: null,
  50. /**
  51. * The parsed, user-entered tag list of $allowedHTMLFormItem
  52. *
  53. * @var {Object.<string, Drupal.FilterHTMLRule>}
  54. */
  55. userTags: {},
  56. // The auto-created tag list thus far added.
  57. autoTags: null,
  58. // Track which new features have been added to the text editor.
  59. newFeatures: {},
  60. attach(context, settings) {
  61. const that = this;
  62. $(context)
  63. .find('[name="filters[filter_html][settings][allowed_html]"]')
  64. .once('filter-filter_html-updating')
  65. .each(function() {
  66. that.$allowedHTMLFormItem = $(this);
  67. that.$allowedHTMLDescription = that.$allowedHTMLFormItem
  68. .closest('.js-form-item')
  69. .find('.description');
  70. that.userTags = that._parseSetting(this.value);
  71. // Update the new allowed tags based on added text editor features.
  72. $(document)
  73. .on('drupalEditorFeatureAdded', (e, feature) => {
  74. that.newFeatures[feature.name] = feature.rules;
  75. that._updateAllowedTags();
  76. })
  77. .on('drupalEditorFeatureModified', (e, feature) => {
  78. if (that.newFeatures.hasOwnProperty(feature.name)) {
  79. that.newFeatures[feature.name] = feature.rules;
  80. that._updateAllowedTags();
  81. }
  82. })
  83. .on('drupalEditorFeatureRemoved', (e, feature) => {
  84. if (that.newFeatures.hasOwnProperty(feature.name)) {
  85. delete that.newFeatures[feature.name];
  86. that._updateAllowedTags();
  87. }
  88. });
  89. // When the allowed tags list is manually changed, update userTags.
  90. that.$allowedHTMLFormItem.on('change.updateUserTags', function() {
  91. that.userTags = _.difference(
  92. that._parseSetting(this.value),
  93. that.autoTags,
  94. );
  95. });
  96. });
  97. },
  98. /**
  99. * Updates the "Allowed HTML tags" setting and shows an informative message.
  100. */
  101. _updateAllowedTags() {
  102. // Update the list of auto-created tags.
  103. this.autoTags = this._calculateAutoAllowedTags(
  104. this.userTags,
  105. this.newFeatures,
  106. );
  107. // Remove any previous auto-created tag message.
  108. this.$allowedHTMLDescription.find('.editor-update-message').remove();
  109. // If any auto-created tags: insert message and update form item.
  110. if (!_.isEmpty(this.autoTags)) {
  111. this.$allowedHTMLDescription.append(
  112. Drupal.theme('filterFilterHTMLUpdateMessage', this.autoTags),
  113. );
  114. const userTagsWithoutOverrides = _.omit(
  115. this.userTags,
  116. _.keys(this.autoTags),
  117. );
  118. this.$allowedHTMLFormItem.val(
  119. `${this._generateSetting(
  120. userTagsWithoutOverrides,
  121. )} ${this._generateSetting(this.autoTags)}`,
  122. );
  123. }
  124. // Restore to original state.
  125. else {
  126. this.$allowedHTMLFormItem.val(this._generateSetting(this.userTags));
  127. }
  128. },
  129. /**
  130. * Calculates which HTML tags the added text editor buttons need to work.
  131. *
  132. * The filter_html filter is only concerned with the required tags, not with
  133. * any properties, nor with each feature's "allowed" tags.
  134. *
  135. * @param {Array} userAllowedTags
  136. * The list of user-defined allowed tags.
  137. * @param {object} newFeatures
  138. * A list of {@link Drupal.EditorFeature} objects' rules, keyed by
  139. * their name.
  140. *
  141. * @return {Array}
  142. * A list of new allowed tags.
  143. */
  144. _calculateAutoAllowedTags(userAllowedTags, newFeatures) {
  145. const editorRequiredTags = {};
  146. // Map the newly added Text Editor features to Drupal.FilterHtmlRule
  147. // objects (to allow comparing userTags with autoTags).
  148. Object.keys(newFeatures || {}).forEach(featureName => {
  149. const feature = newFeatures[featureName];
  150. let featureRule;
  151. let filterRule;
  152. let tag;
  153. for (let f = 0; f < feature.length; f++) {
  154. featureRule = feature[f];
  155. for (let t = 0; t < featureRule.required.tags.length; t++) {
  156. tag = featureRule.required.tags[t];
  157. if (!_.has(editorRequiredTags, tag)) {
  158. filterRule = new Drupal.FilterHTMLRule();
  159. filterRule.restrictedTags.tags = [tag];
  160. // @todo Neither Drupal.FilterHtmlRule nor
  161. // Drupal.EditorFeatureHTMLRule allow for generic attribute
  162. // value restrictions, only for the "class" and "style"
  163. // attribute's values to be restricted. The filter_html filter
  164. // always disallows the "style" attribute, so we only need to
  165. // support "class" attribute value restrictions. Fix once
  166. // https://www.drupal.org/node/2567801 lands.
  167. filterRule.restrictedTags.allowed.attributes = featureRule.required.attributes.slice(
  168. 0,
  169. );
  170. filterRule.restrictedTags.allowed.classes = featureRule.required.classes.slice(
  171. 0,
  172. );
  173. editorRequiredTags[tag] = filterRule;
  174. }
  175. // The tag is already allowed, add any additionally allowed
  176. // attributes.
  177. else {
  178. filterRule = editorRequiredTags[tag];
  179. filterRule.restrictedTags.allowed.attributes = _.union(
  180. filterRule.restrictedTags.allowed.attributes,
  181. featureRule.required.attributes,
  182. );
  183. filterRule.restrictedTags.allowed.classes = _.union(
  184. filterRule.restrictedTags.allowed.classes,
  185. featureRule.required.classes,
  186. );
  187. }
  188. }
  189. }
  190. });
  191. // Now compare userAllowedTags with editorRequiredTags, and build
  192. // autoAllowedTags, which contains:
  193. // - any tags in editorRequiredTags but not in userAllowedTags (i.e. tags
  194. // that are additionally going to be allowed)
  195. // - any tags in editorRequiredTags that already exists in userAllowedTags
  196. // but does not allow all attributes or attribute values
  197. const autoAllowedTags = {};
  198. Object.keys(editorRequiredTags).forEach(tag => {
  199. // If userAllowedTags does not contain a rule for this editor-required
  200. // tag, then add it to the list of automatically allowed tags.
  201. if (!_.has(userAllowedTags, tag)) {
  202. autoAllowedTags[tag] = editorRequiredTags[tag];
  203. }
  204. // Otherwise, if userAllowedTags already allows this tag, then check if
  205. // additional attributes and classes on this tag are required by the
  206. // editor.
  207. else {
  208. const requiredAttributes =
  209. editorRequiredTags[tag].restrictedTags.allowed.attributes;
  210. const allowedAttributes =
  211. userAllowedTags[tag].restrictedTags.allowed.attributes;
  212. const needsAdditionalAttributes =
  213. requiredAttributes.length &&
  214. _.difference(requiredAttributes, allowedAttributes).length;
  215. const requiredClasses =
  216. editorRequiredTags[tag].restrictedTags.allowed.classes;
  217. const allowedClasses =
  218. userAllowedTags[tag].restrictedTags.allowed.classes;
  219. const needsAdditionalClasses =
  220. requiredClasses.length &&
  221. _.difference(requiredClasses, allowedClasses).length;
  222. if (needsAdditionalAttributes || needsAdditionalClasses) {
  223. autoAllowedTags[tag] = userAllowedTags[tag].clone();
  224. }
  225. if (needsAdditionalAttributes) {
  226. autoAllowedTags[tag].restrictedTags.allowed.attributes = _.union(
  227. allowedAttributes,
  228. requiredAttributes,
  229. );
  230. }
  231. if (needsAdditionalClasses) {
  232. autoAllowedTags[tag].restrictedTags.allowed.classes = _.union(
  233. allowedClasses,
  234. requiredClasses,
  235. );
  236. }
  237. }
  238. });
  239. return autoAllowedTags;
  240. },
  241. /**
  242. * Parses the value of this.$allowedHTMLFormItem.
  243. *
  244. * @param {string} setting
  245. * The string representation of the setting. For example:
  246. * <p class="callout"> <br> <a href hreflang>
  247. *
  248. * @return {Object.<string, Drupal.FilterHTMLRule>}
  249. * The corresponding text filter HTML rule objects, one per tag, keyed by
  250. * tag name.
  251. */
  252. _parseSetting(setting) {
  253. let node;
  254. let tag;
  255. let rule;
  256. let attributes;
  257. let attribute;
  258. const allowedTags = setting.match(/(<[^>]+>)/g);
  259. const sandbox = document.createElement('div');
  260. const rules = {};
  261. for (let t = 0; t < allowedTags.length; t++) {
  262. // Let the browser do the parsing work for us.
  263. sandbox.innerHTML = allowedTags[t];
  264. node = sandbox.firstChild;
  265. tag = node.tagName.toLowerCase();
  266. // Build the Drupal.FilterHtmlRule object.
  267. rule = new Drupal.FilterHTMLRule();
  268. // We create one rule per allowed tag, so always one tag.
  269. rule.restrictedTags.tags = [tag];
  270. // Add the attribute restrictions.
  271. attributes = node.attributes;
  272. for (let i = 0; i < attributes.length; i++) {
  273. attribute = attributes.item(i);
  274. const attributeName = attribute.nodeName;
  275. // @todo Drupal.FilterHtmlRule does not allow for generic attribute
  276. // value restrictions, only for the "class" and "style" attribute's
  277. // values. The filter_html filter always disallows the "style"
  278. // attribute, so we only need to support "class" attribute value
  279. // restrictions. Fix once https://www.drupal.org/node/2567801 lands.
  280. if (attributeName === 'class') {
  281. const attributeValue = attribute.textContent;
  282. rule.restrictedTags.allowed.classes = attributeValue.split(' ');
  283. } else {
  284. rule.restrictedTags.allowed.attributes.push(attributeName);
  285. }
  286. }
  287. rules[tag] = rule;
  288. }
  289. return rules;
  290. },
  291. /**
  292. * Generates the value of this.$allowedHTMLFormItem.
  293. *
  294. * @param {Object.<string, Drupal.FilterHTMLRule>} tags
  295. * The parsed representation of the setting.
  296. *
  297. * @return {Array}
  298. * The string representation of the setting. e.g. "<p> <br> <a>"
  299. */
  300. _generateSetting(tags) {
  301. return _.reduce(
  302. tags,
  303. (setting, rule, tag) => {
  304. if (setting.length) {
  305. setting += ' ';
  306. }
  307. setting += `<${tag}`;
  308. if (rule.restrictedTags.allowed.attributes.length) {
  309. setting += ` ${rule.restrictedTags.allowed.attributes.join(' ')}`;
  310. }
  311. // @todo Drupal.FilterHtmlRule does not allow for generic attribute
  312. // value restrictions, only for the "class" and "style" attribute's
  313. // values. The filter_html filter always disallows the "style"
  314. // attribute, so we only need to support "class" attribute value
  315. // restrictions. Fix once https://www.drupal.org/node/2567801 lands.
  316. if (rule.restrictedTags.allowed.classes.length) {
  317. setting += ` class="${rule.restrictedTags.allowed.classes.join(
  318. ' ',
  319. )}"`;
  320. }
  321. setting += '>';
  322. return setting;
  323. },
  324. '',
  325. );
  326. },
  327. };
  328. /**
  329. * Theme function for the filter_html update message.
  330. *
  331. * @param {Array} tags
  332. * An array of the new tags that are to be allowed.
  333. *
  334. * @return {string}
  335. * The corresponding HTML.
  336. */
  337. Drupal.theme.filterFilterHTMLUpdateMessage = function(tags) {
  338. let html = '';
  339. const tagList = Drupal.behaviors.filterFilterHtmlUpdating._generateSetting(
  340. tags,
  341. );
  342. html += '<p class="editor-update-message">';
  343. html += Drupal.t(
  344. 'Based on the text editor configuration, these tags have automatically been added: <strong>@tag-list</strong>.',
  345. { '@tag-list': tagList },
  346. );
  347. html += '</p>';
  348. return html;
  349. };
  350. })(jQuery, Drupal, _, document);