--- /dev/null
+/**
+ * @file
+ * Attaches behavior for updating filter_html's settings automatically.
+ */
+
+(function ($, Drupal, _, document) {
+ if (Drupal.filterConfiguration) {
+ /**
+ * Implement a live setting parser to prevent text editors from automatically
+ * enabling buttons that are not allowed by this filter's configuration.
+ *
+ * @namespace
+ */
+ Drupal.filterConfiguration.liveSettingParsers.filter_html = {
+
+ /**
+ * @return {Array}
+ * An array of filter rules.
+ */
+ getRules() {
+ const currentValue = $('#edit-filters-filter-html-settings-allowed-html').val();
+ const rules = Drupal.behaviors.filterFilterHtmlUpdating._parseSetting(currentValue);
+
+ // Build a FilterHTMLRule that reflects the hard-coded behavior that
+ // strips all "style" attribute and all "on*" attributes.
+ const rule = new Drupal.FilterHTMLRule();
+ rule.restrictedTags.tags = ['*'];
+ rule.restrictedTags.forbidden.attributes = ['style', 'on*'];
+ rules.push(rule);
+
+ return rules;
+ },
+ };
+ }
+
+ /**
+ * Displays and updates what HTML tags are allowed to use in a filter.
+ *
+ * @type {Drupal~behavior}
+ *
+ * @todo Remove everything but 'attach' and 'detach' and make a proper object.
+ *
+ * @prop {Drupal~behaviorAttach} attach
+ * Attaches behavior for updating allowed HTML tags.
+ */
+ Drupal.behaviors.filterFilterHtmlUpdating = {
+
+ // The form item contains the "Allowed HTML tags" setting.
+ $allowedHTMLFormItem: null,
+
+ // The description for the "Allowed HTML tags" field.
+ $allowedHTMLDescription: null,
+
+ /**
+ * The parsed, user-entered tag list of $allowedHTMLFormItem
+ *
+ * @var {Object.<string, Drupal.FilterHTMLRule>}
+ */
+ userTags: {},
+
+ // The auto-created tag list thus far added.
+ autoTags: null,
+
+ // Track which new features have been added to the text editor.
+ newFeatures: {},
+
+ attach(context, settings) {
+ const that = this;
+ $(context).find('[name="filters[filter_html][settings][allowed_html]"]').once('filter-filter_html-updating').each(function () {
+ that.$allowedHTMLFormItem = $(this);
+ that.$allowedHTMLDescription = that.$allowedHTMLFormItem.closest('.js-form-item').find('.description');
+ that.userTags = that._parseSetting(this.value);
+
+ // Update the new allowed tags based on added text editor features.
+ $(document)
+ .on('drupalEditorFeatureAdded', (e, feature) => {
+ that.newFeatures[feature.name] = feature.rules;
+ that._updateAllowedTags();
+ })
+ .on('drupalEditorFeatureModified', (e, feature) => {
+ if (that.newFeatures.hasOwnProperty(feature.name)) {
+ that.newFeatures[feature.name] = feature.rules;
+ that._updateAllowedTags();
+ }
+ })
+ .on('drupalEditorFeatureRemoved', (e, feature) => {
+ if (that.newFeatures.hasOwnProperty(feature.name)) {
+ delete that.newFeatures[feature.name];
+ that._updateAllowedTags();
+ }
+ });
+
+ // When the allowed tags list is manually changed, update userTags.
+ that.$allowedHTMLFormItem.on('change.updateUserTags', function () {
+ that.userTags = _.difference(that._parseSetting(this.value), that.autoTags);
+ });
+ });
+ },
+
+ /**
+ * Updates the "Allowed HTML tags" setting and shows an informative message.
+ */
+ _updateAllowedTags() {
+ // Update the list of auto-created tags.
+ this.autoTags = this._calculateAutoAllowedTags(this.userTags, this.newFeatures);
+
+ // Remove any previous auto-created tag message.
+ this.$allowedHTMLDescription.find('.editor-update-message').remove();
+
+ // If any auto-created tags: insert message and update form item.
+ if (!_.isEmpty(this.autoTags)) {
+ this.$allowedHTMLDescription.append(Drupal.theme('filterFilterHTMLUpdateMessage', this.autoTags));
+ const userTagsWithoutOverrides = _.omit(this.userTags, _.keys(this.autoTags));
+ this.$allowedHTMLFormItem.val(`${this._generateSetting(userTagsWithoutOverrides)} ${this._generateSetting(this.autoTags)}`);
+ }
+ // Restore to original state.
+ else {
+ this.$allowedHTMLFormItem.val(this._generateSetting(this.userTags));
+ }
+ },
+
+ /**
+ * Calculates which HTML tags the added text editor buttons need to work.
+ *
+ * The filter_html filter is only concerned with the required tags, not with
+ * any properties, nor with each feature's "allowed" tags.
+ *
+ * @param {Array} userAllowedTags
+ * The list of user-defined allowed tags.
+ * @param {object} newFeatures
+ * A list of {@link Drupal.EditorFeature} objects' rules, keyed by
+ * their name.
+ *
+ * @return {Array}
+ * A list of new allowed tags.
+ */
+ _calculateAutoAllowedTags(userAllowedTags, newFeatures) {
+ let featureName;
+ let feature;
+ let featureRule;
+ let filterRule;
+ let tag;
+ const editorRequiredTags = {};
+ // Map the newly added Text Editor features to Drupal.FilterHtmlRule
+ // objects (to allow comparing userTags with autoTags).
+ for (featureName in newFeatures) {
+ if (newFeatures.hasOwnProperty(featureName)) {
+ feature = newFeatures[featureName];
+ for (let f = 0; f < feature.length; f++) {
+ featureRule = feature[f];
+ for (let t = 0; t < featureRule.required.tags.length; t++) {
+ tag = featureRule.required.tags[t];
+ if (!_.has(editorRequiredTags, tag)) {
+ filterRule = new Drupal.FilterHTMLRule();
+ filterRule.restrictedTags.tags = [tag];
+ // @todo Neither Drupal.FilterHtmlRule nor
+ // Drupal.EditorFeatureHTMLRule allow for generic attribute
+ // value restrictions, only for the "class" and "style"
+ // attribute's values to be restricted. The filter_html filter
+ // always disallows the "style" attribute, so we only need to
+ // support "class" attribute value restrictions. Fix once
+ // https://www.drupal.org/node/2567801 lands.
+ filterRule.restrictedTags.allowed.attributes = featureRule.required.attributes.slice(0);
+ filterRule.restrictedTags.allowed.classes = featureRule.required.classes.slice(0);
+ editorRequiredTags[tag] = filterRule;
+ }
+ // The tag is already allowed, add any additionally allowed
+ // attributes.
+ else {
+ filterRule = editorRequiredTags[tag];
+ filterRule.restrictedTags.allowed.attributes = _.union(filterRule.restrictedTags.allowed.attributes, featureRule.required.attributes);
+ filterRule.restrictedTags.allowed.classes = _.union(filterRule.restrictedTags.allowed.classes, featureRule.required.classes);
+ }
+ }
+ }
+ }
+ }
+
+ // Now compare userAllowedTags with editorRequiredTags, and build
+ // autoAllowedTags, which contains:
+ // - any tags in editorRequiredTags but not in userAllowedTags (i.e. tags
+ // that are additionally going to be allowed)
+ // - any tags in editorRequiredTags that already exists in userAllowedTags
+ // but does not allow all attributes or attribute values
+ const autoAllowedTags = {};
+ for (tag in editorRequiredTags) {
+ // If userAllowedTags does not contain a rule for this editor-required
+ // tag, then add it to the list of automatically allowed tags.
+ if (!_.has(userAllowedTags, tag)) {
+ autoAllowedTags[tag] = editorRequiredTags[tag];
+ }
+ // Otherwise, if userAllowedTags already allows this tag, then check if
+ // additional attributes and classes on this tag are required by the
+ // editor.
+ else {
+ const requiredAttributes = editorRequiredTags[tag].restrictedTags.allowed.attributes;
+ const allowedAttributes = userAllowedTags[tag].restrictedTags.allowed.attributes;
+ const needsAdditionalAttributes = requiredAttributes.length && _.difference(requiredAttributes, allowedAttributes).length;
+ const requiredClasses = editorRequiredTags[tag].restrictedTags.allowed.classes;
+ const allowedClasses = userAllowedTags[tag].restrictedTags.allowed.classes;
+ const needsAdditionalClasses = requiredClasses.length && _.difference(requiredClasses, allowedClasses).length;
+ if (needsAdditionalAttributes || needsAdditionalClasses) {
+ autoAllowedTags[tag] = userAllowedTags[tag].clone();
+ }
+ if (needsAdditionalAttributes) {
+ autoAllowedTags[tag].restrictedTags.allowed.attributes = _.union(allowedAttributes, requiredAttributes);
+ }
+ if (needsAdditionalClasses) {
+ autoAllowedTags[tag].restrictedTags.allowed.classes = _.union(allowedClasses, requiredClasses);
+ }
+ }
+ }
+
+ return autoAllowedTags;
+ },
+
+ /**
+ * Parses the value of this.$allowedHTMLFormItem.
+ *
+ * @param {string} setting
+ * The string representation of the setting. For example:
+ * <p class="callout"> <br> <a href hreflang>
+ *
+ * @return {Object.<string, Drupal.FilterHTMLRule>}
+ * The corresponding text filter HTML rule objects, one per tag, keyed by
+ * tag name.
+ */
+ _parseSetting(setting) {
+ let node;
+ let tag;
+ let rule;
+ let attributes;
+ let attribute;
+ const allowedTags = setting.match(/(<[^>]+>)/g);
+ const sandbox = document.createElement('div');
+ const rules = {};
+ for (let t = 0; t < allowedTags.length; t++) {
+ // Let the browser do the parsing work for us.
+ sandbox.innerHTML = allowedTags[t];
+ node = sandbox.firstChild;
+ tag = node.tagName.toLowerCase();
+
+ // Build the Drupal.FilterHtmlRule object.
+ rule = new Drupal.FilterHTMLRule();
+ // We create one rule per allowed tag, so always one tag.
+ rule.restrictedTags.tags = [tag];
+ // Add the attribute restrictions.
+ attributes = node.attributes;
+ for (let i = 0; i < attributes.length; i++) {
+ attribute = attributes.item(i);
+ const attributeName = attribute.nodeName;
+ // @todo Drupal.FilterHtmlRule does not allow for generic attribute
+ // value restrictions, only for the "class" and "style" attribute's
+ // values. The filter_html filter always disallows the "style"
+ // attribute, so we only need to support "class" attribute value
+ // restrictions. Fix once https://www.drupal.org/node/2567801 lands.
+ if (attributeName === 'class') {
+ const attributeValue = attribute.textContent;
+ rule.restrictedTags.allowed.classes = attributeValue.split(' ');
+ }
+ else {
+ rule.restrictedTags.allowed.attributes.push(attributeName);
+ }
+ }
+
+ rules[tag] = rule;
+ }
+ return rules;
+ },
+
+ /**
+ * Generates the value of this.$allowedHTMLFormItem.
+ *
+ * @param {Object.<string, Drupal.FilterHTMLRule>} tags
+ * The parsed representation of the setting.
+ *
+ * @return {Array}
+ * The string representation of the setting. e.g. "<p> <br> <a>"
+ */
+ _generateSetting(tags) {
+ return _.reduce(tags, (setting, rule, tag) => {
+ if (setting.length) {
+ setting += ' ';
+ }
+
+ setting += `<${tag}`;
+ if (rule.restrictedTags.allowed.attributes.length) {
+ setting += ` ${rule.restrictedTags.allowed.attributes.join(' ')}`;
+ }
+ // @todo Drupal.FilterHtmlRule does not allow for generic attribute
+ // value restrictions, only for the "class" and "style" attribute's
+ // values. The filter_html filter always disallows the "style"
+ // attribute, so we only need to support "class" attribute value
+ // restrictions. Fix once https://www.drupal.org/node/2567801 lands.
+ if (rule.restrictedTags.allowed.classes.length) {
+ setting += ` class="${rule.restrictedTags.allowed.classes.join(' ')}"`;
+ }
+
+ setting += '>';
+ return setting;
+ }, '');
+ },
+
+ };
+
+ /**
+ * Theme function for the filter_html update message.
+ *
+ * @param {Array} tags
+ * An array of the new tags that are to be allowed.
+ *
+ * @return {string}
+ * The corresponding HTML.
+ */
+ Drupal.theme.filterFilterHTMLUpdateMessage = function (tags) {
+ let html = '';
+ const tagList = Drupal.behaviors.filterFilterHtmlUpdating._generateSetting(tags);
+ html += '<p class="editor-update-message">';
+ html += Drupal.t('Based on the text editor configuration, these tags have automatically been added: <strong>@tag-list</strong>.', { '@tag-list': tagList });
+ html += '</p>';
+ return html;
+ };
+}(jQuery, Drupal, _, document));