@ckeditor/ckeditor5-html-support
Version:
HTML Support feature for CKEditor 5.
838 lines (837 loc) • 35 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module html-support/datafilter
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { Matcher, StylesMap } from 'ckeditor5/src/engine.js';
import { CKEditorError, priorities, isValidAttributeName } from 'ckeditor5/src/utils.js';
import { Widget } from 'ckeditor5/src/widget.js';
import { viewToModelObjectConverter, toObjectWidgetConverter, createObjectView, viewToAttributeInlineConverter, attributeToViewInlineConverter, emptyInlineModelElementToViewConverter, viewToModelBlockAttributeConverter, modelToViewBlockAttributeConverter } from './converters.js';
import { DataSchema } from './dataschema.js';
import { getHtmlAttributeName } from './utils.js';
import { isPlainObject } from 'es-toolkit/compat';
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 class DataFilter extends Plugin {
/**
* An instance of the {@link module:html-support/dataschema~DataSchema}.
*/
_dataSchema;
/**
* {@link module:engine/view/matcher~Matcher Matcher} instance describing rules upon which
* content attributes should be allowed.
*/
_allowedAttributes;
/**
* {@link module:engine/view/matcher~Matcher Matcher} instance describing rules upon which
* content attributes should be disallowed.
*/
_disallowedAttributes;
/**
* Allowed element definitions by {@link module:html-support/datafilter~DataFilter#allowElement} method.
*/
_allowedElements;
/**
* Disallowed element names by {@link module:html-support/datafilter~DataFilter#disallowElement} method.
*/
_disallowedElements;
/**
* Indicates if {@link module:engine/controller/datacontroller~DataController editor's data controller}
* data has been already initialized.
*/
_dataInitialized;
/**
* Cached map of coupled attributes. Keys are the feature attributes names
* and values are arrays with coupled GHS attributes names.
*/
_coupledAttributes;
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 isOfficialPlugin() {
return true;
}
/**
* @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));
}
}
}
/**
* Load a configuration of one or many elements, where when empty should be allowed.
*
* **Note**: It modifies DataSchema so must be loaded before registering filtering rules.
*
* @param config Configuration of elements that should be preserved even if empty.
*/
loadAllowedEmptyElementsConfig(config) {
for (const elementName of config) {
this.allowEmptyElement(elementName);
}
}
/**
* 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 empty 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**: It modifies DataSchema so must be called before registering filtering rules.
*
* @param viewName String or regular expression matching view name.
*/
allowEmptyElement(viewName) {
for (const definition of this._dataSchema.getDefinitionsForView(viewName, true)) {
if (definition.isInline) {
this._dataSchema.extendInlineElement({ ...definition, allowEmpty: true });
}
}
}
/**
* 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) {
const { consumable } = 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>.
matchAndConsumeAttributes(viewElement, this._disallowedAttributes, consumable);
return prepareGHSAttribute(viewElement, matchAndConsumeAttributes(viewElement, this._allowedAttributes, consumable));
}
/**
* 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;
const selection = model.document.selection;
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()) {
for (const attributeKey of attributeKeys) {
if (item.hasAttribute(attributeKey)) {
writer.removeAttribute(attributeKey, item);
changed = true;
}
}
}
}
return changed;
});
this.listenTo(selection, 'change:attribute', (evt, { attributeKeys }) => {
const removeAttributes = new Set();
const coupledAttributes = this._getCoupledAttributesMap();
for (const attributeKey of attributeKeys) {
// Handle only attribute removals.
if (selection.hasAttribute(attributeKey)) {
continue;
}
// Find a list of coupled GHS attributes.
const coupledAttributeKeys = coupledAttributes.get(attributeKey);
if (!coupledAttributeKeys) {
continue;
}
for (const coupledAttributeKey of coupledAttributeKeys) {
if (selection.hasAttribute(coupledAttributeKey)) {
removeAttributes.add(coupledAttributeKey);
}
}
}
if (removeAttributes.size == 0) {
return;
}
model.change(writer => {
for (const attributeKey of removeAttributes) {
writer.removeSelectionAttribute(attributeKey);
}
});
});
}
/**
* 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)) {
// Do not register converters and empty schema for editor existing feature
// as empty schema won't allow element anywhere in the model.
if (!definition.modelSchema) {
return;
}
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: (modelElement, { writer }) => definition.isEmpty ?
writer.createEmptyElement(viewName) :
writer.createContainerElement(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 Lists.
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)
});
if (!definition.allowEmpty) {
return;
}
schema.setAttributeProperties(attributeKey, { copyFromObject: false });
if (!schema.isRegistered('htmlEmptyElement')) {
schema.register('htmlEmptyElement', {
inheritAllFrom: '$inlineObject'
});
// Helper function to check if an element has any HTML attributes.
const hasHtmlAttributes = (element) => Array
.from(element.getAttributeKeys())
.some(key => key.startsWith('html'));
// Register a post-fixer that removes htmlEmptyElement when its htmlXX attribute is removed.
// See: https://github.com/ckeditor/ckeditor5/issues/18089
editor.model.document.registerPostFixer(writer => {
const changes = editor.model.document.differ.getChanges();
const elementsToRemove = new Set();
for (const change of changes) {
if (change.type === 'remove') {
continue;
}
// Look for removal of html* attributes.
if (change.type === 'attribute' && change.attributeNewValue === null) {
// Find htmlEmptyElement instances in the range that lost their html attribute.
for (const { item } of change.range) {
if (item.is('element', 'htmlEmptyElement') && !hasHtmlAttributes(item)) {
elementsToRemove.add(item);
}
}
}
// Look for insertion of htmlEmptyElement.
if (change.type === 'insert' && change.position.nodeAfter) {
const insertedElement = change.position.nodeAfter;
for (const { item } of writer.createRangeOn(insertedElement)) {
if (item.is('element', 'htmlEmptyElement') && !hasHtmlAttributes(item)) {
elementsToRemove.add(item);
}
}
}
}
for (const element of elementsToRemove) {
writer.remove(element);
}
return elementsToRemove.size > 0;
});
}
editor.data.htmlProcessor.domConverter.registerInlineObjectMatcher(element => {
// Element must be empty and have any attribute.
if (element.name == definition.view &&
element.isEmpty &&
Array.from(element.getAttributeKeys()).length) {
return {
name: true
};
}
return null;
});
conversion.for('editingDowncast')
.elementToElement({
model: 'htmlEmptyElement',
view: emptyInlineModelElementToViewConverter(definition, true)
});
conversion.for('dataDowncast')
.elementToElement({
model: 'htmlEmptyElement',
view: emptyInlineModelElementToViewConverter(definition)
});
}
}
/**
* Matches and consumes matched attributes.
*
* @returns Object with following properties:
* - attributes Array with matched attribute names.
* - classes Array with matched class names.
* - styles Array with matched style names.
*/
function matchAndConsumeAttributes(viewElement, matcher, consumable) {
const matches = matcher.matchAll(viewElement) || [];
const stylesProcessor = viewElement.document.stylesProcessor;
return matches.reduce((result, { match }) => {
for (const [key, token] of match.attributes || []) {
// Verify and consume styles.
if (key == 'style') {
const style = token;
// Check longer forms of the same style as those could be matched
// but not present in the element directly.
// Consider only longhand (or longer than current notation) so that
// we do not include all sides of the box if only one side is allowed.
const sortedRelatedStyles = stylesProcessor.getRelatedStyles(style)
.filter(relatedStyle => relatedStyle.split('-').length > style.split('-').length)
.sort((a, b) => b.split('-').length - a.split('-').length);
for (const relatedStyle of sortedRelatedStyles) {
if (consumable.consume(viewElement, { styles: [relatedStyle] })) {
result.styles.push(relatedStyle);
}
}
// Verify and consume style as specified in the matcher.
if (consumable.consume(viewElement, { styles: [style] })) {
result.styles.push(style);
}
}
// Verify and consume class names.
else if (key == 'class') {
const className = token;
if (consumable.consume(viewElement, { classes: [className] })) {
result.classes.push(className);
}
}
else {
// Verify and consume other attributes.
if (consumable.consume(viewElement, { attributes: [key] })) {
result.attributes.push(key);
}
}
}
return result;
}, {
attributes: [],
classes: [],
styles: []
});
}
/**
* Prepares the GHS attribute value as an object with element attributes' values.
*/
function prepareGHSAttribute(viewElement, { attributes, classes, styles }) {
if (!attributes.length && !classes.length && !styles.length) {
return null;
}
return {
...(attributes.length && {
attributes: getAttributes(viewElement, attributes)
}),
...(styles.length && {
styles: getReducedStyles(viewElement, styles)
}),
...(classes.length && {
classes
})
};
}
/**
* Returns attributes as an object with names and values.
*/
function getAttributes(viewElement, attributes) {
const attributesObject = {};
for (const key of attributes) {
const value = viewElement.getAttribute(key);
if (value !== undefined && isValidAttributeName(key)) {
attributesObject[key] = value;
}
}
return attributesObject;
}
/**
* Returns styles as an object reduced to shorthand notation without redundant entries.
*/
function getReducedStyles(viewElement, styles) {
// Use StyleMap to reduce style value to the minimal form (without shorthand and long-hand notation and duplication).
const stylesMap = new StylesMap(viewElement.document.stylesProcessor);
for (const key of styles) {
const styleValue = viewElement.getStyle(key);
if (styleValue !== undefined) {
stylesMap.set(key, styleValue);
}
}
return Object.fromEntries(stylesMap.getStylesEntries());
}
/**
* 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 splitRules = [];
if (attributes) {
splitRules.push(...splitPattern({ name, attributes }, 'attributes'));
}
if (classes) {
splitRules.push(...splitPattern({ name, classes }, 'classes'));
}
if (styles) {
splitRules.push(...splitPattern({ name, styles }, 'styles'));
}
return splitRules;
}