@ckeditor/ckeditor5-html-support
Version:
HTML Support feature for CKEditor 5.
662 lines (661 loc) • 27 kB
JavaScript
/**
* @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;
}