UNPKG

@ckeditor/ckeditor5-html-support

Version:

HTML Support feature for CKEditor 5.

662 lines (661 loc) • 27 kB
/** * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /** * @module html-support/datafilter */ import { Plugin } from 'ckeditor5/src/core'; import { Matcher } from 'ckeditor5/src/engine'; import { CKEditorError, priorities, isValidAttributeName } from 'ckeditor5/src/utils'; import { Widget } from 'ckeditor5/src/widget'; import { viewToModelObjectConverter, toObjectWidgetConverter, createObjectView, viewToAttributeInlineConverter, attributeToViewInlineConverter, viewToModelBlockAttributeConverter, modelToViewBlockAttributeConverter } from './converters'; import { default as DataSchema } from './dataschema'; import { getHtmlAttributeName } from './utils'; import { isPlainObject, pull as removeItemFromArray } from 'lodash-es'; import '../theme/datafilter.css'; /** * Allows to validate elements and element attributes registered by {@link module:html-support/dataschema~DataSchema}. * * To enable registered element in the editor, use {@link module:html-support/datafilter~DataFilter#allowElement} method: * * ```ts * dataFilter.allowElement( 'section' ); * ``` * * You can also allow or disallow specific element attributes: * * ```ts * // Allow `data-foo` attribute on `section` element. * dataFilter.allowAttributes( { * name: 'section', * attributes: { * 'data-foo': true * } * } ); * * // Disallow `color` style attribute on 'section' element. * dataFilter.disallowAttributes( { * name: 'section', * styles: { * color: /[\s\S]+/ * } * } ); * ``` * * To apply the information about allowed and disallowed attributes in custom integration plugin, * use the {@link module:html-support/datafilter~DataFilter#processViewAttributes `processViewAttributes()`} method. */ export default class DataFilter extends Plugin { constructor(editor) { super(editor); this._dataSchema = editor.plugins.get('DataSchema'); this._allowedAttributes = new Matcher(); this._disallowedAttributes = new Matcher(); this._allowedElements = new Set(); this._disallowedElements = new Set(); this._dataInitialized = false; this._coupledAttributes = null; this._registerElementsAfterInit(); this._registerElementHandlers(); this._registerCoupledAttributesPostFixer(); this._registerAssociatedHtmlAttributesPostFixer(); } /** * @inheritDoc */ static get pluginName() { return 'DataFilter'; } /** * @inheritDoc */ static get requires() { return [DataSchema, Widget]; } /** * Load a configuration of one or many elements, where their attributes should be allowed. * * **Note**: Rules will be applied just before next data pipeline data init or set. * * @param config Configuration of elements that should have their attributes accepted in the editor. */ loadAllowedConfig(config) { for (const pattern of config) { // MatcherPattern allows omitting `name` to widen the search of elements. // Let's keep it consistent and match every element if a `name` has not been provided. const elementName = pattern.name || /[\s\S]+/; const rules = splitRules(pattern); this.allowElement(elementName); rules.forEach(pattern => this.allowAttributes(pattern)); } } /** * Load a configuration of one or many elements, where their attributes should be disallowed. * * **Note**: Rules will be applied just before next data pipeline data init or set. * * @param config Configuration of elements that should have their attributes rejected from the editor. */ loadDisallowedConfig(config) { for (const pattern of config) { // MatcherPattern allows omitting `name` to widen the search of elements. // Let's keep it consistent and match every element if a `name` has not been provided. const elementName = pattern.name || /[\s\S]+/; const rules = splitRules(pattern); // Disallow element itself if there is no other rules. if (rules.length == 0) { this.disallowElement(elementName); } else { rules.forEach(pattern => this.disallowAttributes(pattern)); } } } /** * Allow the given element in the editor context. * * This method will only allow elements described by the {@link module:html-support/dataschema~DataSchema} used * to create data filter. * * **Note**: Rules will be applied just before next data pipeline data init or set. * * @param viewName String or regular expression matching view name. */ allowElement(viewName) { for (const definition of this._dataSchema.getDefinitionsForView(viewName, true)) { this._addAllowedElement(definition); // Reset cached map to recalculate it on the next usage. this._coupledAttributes = null; } } /** * Disallow the given element in the editor context. * * This method will only disallow elements described by the {@link module:html-support/dataschema~DataSchema} used * to create data filter. * * @param viewName String or regular expression matching view name. */ disallowElement(viewName) { for (const definition of this._dataSchema.getDefinitionsForView(viewName, false)) { this._disallowedElements.add(definition.view); } } /** * Allow the given attributes for view element allowed by {@link #allowElement} method. * * @param config Pattern matching all attributes which should be allowed. */ allowAttributes(config) { this._allowedAttributes.add(config); } /** * Disallow the given attributes for view element allowed by {@link #allowElement} method. * * @param config Pattern matching all attributes which should be disallowed. */ disallowAttributes(config) { this._disallowedAttributes.add(config); } /** * Processes all allowed and disallowed attributes on the view element by consuming them and returning the allowed ones. * * This method applies the configuration set up by {@link #allowAttributes `allowAttributes()`} * and {@link #disallowAttributes `disallowAttributes()`} over the given view element by consuming relevant attributes. * It returns the allowed attributes that were found on the given view element for further processing by integration code. * * ```ts * dispatcher.on( 'element:myElement', ( evt, data, conversionApi ) => { * // Get rid of disallowed and extract all allowed attributes from a viewElement. * const viewAttributes = dataFilter.processViewAttributes( data.viewItem, conversionApi ); * // Do something with them, i.e. store inside a model as a dictionary. * if ( viewAttributes ) { * conversionApi.writer.setAttribute( 'htmlAttributesOfMyElement', viewAttributes, data.modelRange ); * } * } ); * ``` * * @see module:engine/conversion/viewconsumable~ViewConsumable#consume * * @returns Object with following properties: * - attributes Set with matched attribute names. * - styles Set with matched style names. * - classes Set with matched class names. */ processViewAttributes(viewElement, conversionApi) { // Make sure that the disabled attributes are handled before the allowed attributes are called. // For example, for block images the <figure> converter triggers conversion for <img> first and then for other elements, i.e. <a>. consumeAttributes(viewElement, conversionApi, this._disallowedAttributes); return consumeAttributes(viewElement, conversionApi, this._allowedAttributes); } /** * Adds allowed element definition and fires registration event. */ _addAllowedElement(definition) { if (this._allowedElements.has(definition)) { return; } this._allowedElements.add(definition); // For attribute based integrations (table figure, document lists, etc.) register related element definitions. if ('appliesToBlock' in definition && typeof definition.appliesToBlock == 'string') { for (const relatedDefinition of this._dataSchema.getDefinitionsForModel(definition.appliesToBlock)) { if (relatedDefinition.isBlock) { this._addAllowedElement(relatedDefinition); } } } // We need to wait for all features to be initialized before we can register // element, so we can access existing features model schemas. // If the data has not been initialized yet, _registerElementsAfterInit() method will take care of // registering elements. if (this._dataInitialized) { // Defer registration to the next data pipeline data set so any disallow rules could be applied // even if added after allow rule (disallowElement). this.editor.data.once('set', () => { this._fireRegisterEvent(definition); }, { // With the highest priority listener we are able to register elements right before // running data conversion. priority: priorities.highest + 1 }); } } /** * Registers elements allowed by {@link module:html-support/datafilter~DataFilter#allowElement} method * once {@link module:engine/controller/datacontroller~DataController editor's data controller} is initialized. */ _registerElementsAfterInit() { this.editor.data.on('init', () => { this._dataInitialized = true; for (const definition of this._allowedElements) { this._fireRegisterEvent(definition); } }, { // With highest priority listener we are able to register elements right before // running data conversion. Also: // * Make sure that priority is higher than the one used by `RealTimeCollaborationClient`, // as RTC is stopping event propagation. // * Make sure no other features hook into this event before GHS because otherwise the // downcast conversion (for these features) could run before GHS registered its converters // (https://github.com/ckeditor/ckeditor5/issues/11356). priority: priorities.highest + 1 }); } /** * Registers default element handlers. */ _registerElementHandlers() { this.on('register', (evt, definition) => { const schema = this.editor.model.schema; // Object element should be only registered for new features. // If the model schema is already registered, it should be handled by // #_registerBlockElement() or #_registerObjectElement() attribute handlers. if (definition.isObject && !schema.isRegistered(definition.model)) { this._registerObjectElement(definition); } else if (definition.isBlock) { this._registerBlockElement(definition); } else if (definition.isInline) { this._registerInlineElement(definition); } else { /** * The definition cannot be handled by the data filter. * * Make sure that the registered definition is correct. * * @error data-filter-invalid-definition */ throw new CKEditorError('data-filter-invalid-definition', null, definition); } evt.stop(); }, { priority: 'lowest' }); } /** * Registers a model post-fixer that is removing coupled GHS attributes of inline elements. Those attributes * are removed if a coupled feature attribute is removed. * * For example, consider following HTML: * * ```html * <a href="foo.html" id="myId">bar</a> * ``` * * Which would be upcasted to following text node in the model: * * ```html * <$text linkHref="foo.html" htmlA="{ attributes: { id: 'myId' } }">bar</$text> * ``` * * When the user removes the link from that text (using UI), only `linkHref` attribute would be removed: * * ```html * <$text htmlA="{ attributes: { id: 'myId' } }">bar</$text> * ``` * * The `htmlA` attribute would stay in the model and would cause GHS to generate an `<a>` element. * This is incorrect from UX point of view, as the user wanted to remove the whole link (not only `href`). */ _registerCoupledAttributesPostFixer() { const model = this.editor.model; model.document.registerPostFixer(writer => { const changes = model.document.differ.getChanges(); let changed = false; const coupledAttributes = this._getCoupledAttributesMap(); for (const change of changes) { // Handle only attribute removals. if (change.type != 'attribute' || change.attributeNewValue !== null) { continue; } // Find a list of coupled GHS attributes. const attributeKeys = coupledAttributes.get(change.attributeKey); if (!attributeKeys) { continue; } // Remove the coupled GHS attributes on the same range as the feature attribute was removed. for (const { item } of change.range.getWalker({ shallow: true })) { for (const attributeKey of attributeKeys) { if (item.hasAttribute(attributeKey)) { writer.removeAttribute(attributeKey, item); changed = true; } } } } return changed; }); } /** * Removes `html*Attributes` attributes from incompatible elements. * * For example, consider the following HTML: * * ```html * <heading2 htmlH2Attributes="...">foobar[]</heading2> * ``` * * Pressing `enter` creates a new `paragraph` element that inherits * the `htmlH2Attributes` attribute from `heading2`. * * ```html * <heading2 htmlH2Attributes="...">foobar</heading2> * <paragraph htmlH2Attributes="...">[]</paragraph> * ``` * * This postfixer ensures that this doesn't happen, and that elements can * only have `html*Attributes` associated with them, * e.g.: `htmlPAttributes` for `<p>`, `htmlDivAttributes` for `<div>`, etc. * * With it enabled, pressing `enter` at the end of `<heading2>` will create * a new paragraph without the `htmlH2Attributes` attribute. * * ```html * <heading2 htmlH2Attributes="...">foobar</heading2> * <paragraph>[]</paragraph> * ``` */ _registerAssociatedHtmlAttributesPostFixer() { const model = this.editor.model; model.document.registerPostFixer(writer => { const changes = model.document.differ.getChanges(); let changed = false; for (const change of changes) { if (change.type !== 'insert' || change.name === '$text') { continue; } for (const attr of change.attributes.keys()) { if (!attr.startsWith('html') || !attr.endsWith('Attributes')) { continue; } if (!model.schema.checkAttribute(change.name, attr)) { writer.removeAttribute(attr, change.position.nodeAfter); changed = true; } } } return changed; }); } /** * Collects the map of coupled attributes. The returned map is keyed by the feature attribute name * and coupled GHS attribute names are stored in the value array. */ _getCoupledAttributesMap() { if (this._coupledAttributes) { return this._coupledAttributes; } this._coupledAttributes = new Map(); for (const definition of this._allowedElements) { if (definition.coupledAttribute && definition.model) { const attributeNames = this._coupledAttributes.get(definition.coupledAttribute); if (attributeNames) { attributeNames.push(definition.model); } else { this._coupledAttributes.set(definition.coupledAttribute, [definition.model]); } } } return this._coupledAttributes; } /** * Fires `register` event for the given element definition. */ _fireRegisterEvent(definition) { if (definition.view && this._disallowedElements.has(definition.view)) { return; } this.fire(definition.view ? `register:${definition.view}` : 'register', definition); } /** * Registers object element and attribute converters for the given data schema definition. */ _registerObjectElement(definition) { const editor = this.editor; const schema = editor.model.schema; const conversion = editor.conversion; const { view: viewName, model: modelName } = definition; schema.register(modelName, definition.modelSchema); /* istanbul ignore next: paranoid check -- @preserve */ if (!viewName) { return; } schema.extend(definition.model, { allowAttributes: [getHtmlAttributeName(viewName), 'htmlContent'] }); // Store element content in special `$rawContent` custom property to // avoid editor's data filtering mechanism. editor.data.registerRawContentMatcher({ name: viewName }); conversion.for('upcast').elementToElement({ view: viewName, model: viewToModelObjectConverter(definition), // With a `low` priority, `paragraph` plugin auto-paragraphing mechanism is executed. Make sure // this listener is called before it. If not, some elements will be transformed into a paragraph. // `+ 2` is used to take priority over `_addDefaultH1Conversion` in the Heading plugin. converterPriority: priorities.low + 2 }); conversion.for('upcast').add(viewToModelBlockAttributeConverter(definition, this)); conversion.for('editingDowncast').elementToStructure({ model: { name: modelName, attributes: [getHtmlAttributeName(viewName)] }, view: toObjectWidgetConverter(editor, definition) }); conversion.for('dataDowncast').elementToElement({ model: modelName, view: (modelElement, { writer }) => { return createObjectView(viewName, modelElement, writer); } }); conversion.for('dataDowncast').add(modelToViewBlockAttributeConverter(definition)); } /** * Registers block element and attribute converters for the given data schema definition. */ _registerBlockElement(definition) { const editor = this.editor; const schema = editor.model.schema; const conversion = editor.conversion; const { view: viewName, model: modelName } = definition; if (!schema.isRegistered(definition.model)) { schema.register(definition.model, definition.modelSchema); if (!viewName) { return; } conversion.for('upcast').elementToElement({ model: modelName, view: viewName, // With a `low` priority, `paragraph` plugin auto-paragraphing mechanism is executed. Make sure // this listener is called before it. If not, some elements will be transformed into a paragraph. // `+ 2` is used to take priority over `_addDefaultH1Conversion` in the Heading plugin. converterPriority: priorities.low + 2 }); conversion.for('downcast').elementToElement({ model: modelName, view: viewName }); } if (!viewName) { return; } schema.extend(definition.model, { allowAttributes: getHtmlAttributeName(viewName) }); conversion.for('upcast').add(viewToModelBlockAttributeConverter(definition, this)); conversion.for('downcast').add(modelToViewBlockAttributeConverter(definition)); } /** * Registers inline element and attribute converters for the given data schema definition. * * Extends `$text` model schema to allow the given definition model attribute and its properties. */ _registerInlineElement(definition) { const editor = this.editor; const schema = editor.model.schema; const conversion = editor.conversion; const attributeKey = definition.model; // This element is stored in the model as an attribute on a block element, for example DocumentLists. if (definition.appliesToBlock) { return; } schema.extend('$text', { allowAttributes: attributeKey }); if (definition.attributeProperties) { schema.setAttributeProperties(attributeKey, definition.attributeProperties); } conversion.for('upcast').add(viewToAttributeInlineConverter(definition, this)); conversion.for('downcast').attributeToElement({ model: attributeKey, view: attributeToViewInlineConverter(definition) }); } } /** * Matches and consumes the given view attributes. */ function consumeAttributes(viewElement, conversionApi, matcher) { const matches = consumeAttributeMatches(viewElement, conversionApi, matcher); const { attributes, styles, classes } = mergeMatchResults(matches); const viewAttributes = {}; // Remove invalid DOM element attributes. if (attributes.size) { for (const key of attributes) { if (!isValidAttributeName(key)) { attributes.delete(key); } } } if (attributes.size) { viewAttributes.attributes = iterableToObject(attributes, key => viewElement.getAttribute(key)); } if (styles.size) { viewAttributes.styles = iterableToObject(styles, key => viewElement.getStyle(key)); } if (classes.size) { viewAttributes.classes = Array.from(classes); } if (!Object.keys(viewAttributes).length) { return null; } return viewAttributes; } /** * Consumes matched attributes. * * @returns Array with match information about found attributes. */ function consumeAttributeMatches(viewElement, { consumable }, matcher) { const matches = matcher.matchAll(viewElement) || []; const consumedMatches = []; for (const match of matches) { removeConsumedAttributes(consumable, viewElement, match); // We only want to consume attributes, so element can be still processed by other converters. delete match.match.name; consumable.consume(viewElement, match.match); consumedMatches.push(match); } return consumedMatches; } /** * Removes attributes from the given match that were already consumed by other converters. */ function removeConsumedAttributes(consumable, viewElement, match) { for (const key of ['attributes', 'classes', 'styles']) { const attributes = match.match[key]; if (!attributes) { continue; } // Iterating over a copy of an array so removing items doesn't influence iteration. for (const value of Array.from(attributes)) { if (!consumable.test(viewElement, ({ [key]: [value] }))) { removeItemFromArray(attributes, value); } } } } /** * Merges the result of {@link module:engine/view/matcher~Matcher#matchAll} method. * * @param matches * @returns Object with following properties: * - attributes Set with matched attribute names. * - styles Set with matched style names. * - classes Set with matched class names. */ function mergeMatchResults(matches) { const matchResult = { attributes: new Set(), classes: new Set(), styles: new Set() }; for (const match of matches) { for (const key in matchResult) { const values = match.match[key] || []; values.forEach(value => (matchResult[key]).add(value)); } } return matchResult; } /** * Converts the given iterable object into an object. */ function iterableToObject(iterable, getValue) { const attributesObject = {}; for (const prop of iterable) { const value = getValue(prop); if (value !== undefined) { attributesObject[prop] = getValue(prop); } } return attributesObject; } /** * Matcher by default has to match **all** patterns to count it as an actual match. Splitting the pattern * into separate patterns means that any matched pattern will be count as a match. * * @param pattern Pattern to split. * @param attributeName Name of the attribute to split (e.g. 'attributes', 'classes', 'styles'). */ function splitPattern(pattern, attributeName) { const { name } = pattern; const attributeValue = pattern[attributeName]; if (isPlainObject(attributeValue)) { return Object.entries(attributeValue).map(([key, value]) => ({ name, [attributeName]: { [key]: value } })); } if (Array.isArray(attributeValue)) { return attributeValue.map(value => ({ name, [attributeName]: [value] })); } return [pattern]; } /** * Rules are matched in conjunction (AND operation), but we want to have a match if *any* of the rules is matched (OR operation). * By splitting the rules we force the latter effect. */ function splitRules(rules) { const { name, attributes, classes, styles } = rules; const splittedRules = []; if (attributes) { splittedRules.push(...splitPattern({ name, attributes }, 'attributes')); } if (classes) { splittedRules.push(...splitPattern({ name, classes }, 'classes')); } if (styles) { splittedRules.push(...splitPattern({ name, styles }, 'styles')); } return splittedRules; }