@ckeditor/ckeditor5-engine
Version:
The editing engine of CKEditor 5 – the best browser-based rich text editor.
542 lines (541 loc) • 25.6 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 engine/conversion/viewconsumable
*/
import { CKEditorError } from '@ckeditor/ckeditor5-utils';
/**
* Class used for handling consumption of view {@link module:engine/view/element~ViewElement elements},
* {@link module:engine/view/text~ViewText text nodes} and
* {@link module:engine/view/documentfragment~ViewDocumentFragment document fragments}.
* Element's name and its parts (attributes, classes and styles) can be consumed separately. Consuming an element's name
* does not consume its attributes, classes and styles.
* To add items for consumption use {@link module:engine/conversion/viewconsumable~ViewConsumable#add add method}.
* To test items use {@link module:engine/conversion/viewconsumable~ViewConsumable#test test method}.
* To consume items use {@link module:engine/conversion/viewconsumable~ViewConsumable#consume consume method}.
* To revert already consumed items use {@link module:engine/conversion/viewconsumable~ViewConsumable#revert revert method}.
*
* ```ts
* viewConsumable.add( element, { name: true } ); // Adds element's name as ready to be consumed.
* viewConsumable.add( textNode ); // Adds text node for consumption.
* viewConsumable.add( docFragment ); // Adds document fragment for consumption.
* viewConsumable.test( element, { name: true } ); // Tests if element's name can be consumed.
* viewConsumable.test( textNode ); // Tests if text node can be consumed.
* viewConsumable.test( docFragment ); // Tests if document fragment can be consumed.
* viewConsumable.consume( element, { name: true } ); // Consume element's name.
* viewConsumable.consume( textNode ); // Consume text node.
* viewConsumable.consume( docFragment ); // Consume document fragment.
* viewConsumable.revert( element, { name: true } ); // Revert already consumed element's name.
* viewConsumable.revert( textNode ); // Revert already consumed text node.
* viewConsumable.revert( docFragment ); // Revert already consumed document fragment.
* ```
*/
export class ViewConsumable {
/**
* Map of consumable elements. If {@link module:engine/view/element~ViewElement element} is used as a key,
* {@link module:engine/conversion/viewconsumable~ViewElementConsumables ViewElementConsumables} instance is stored as value.
* For {@link module:engine/view/text~ViewText text nodes} and
* {@link module:engine/view/documentfragment~ViewDocumentFragment document fragments} boolean value is stored as value.
*/
_consumables = new Map();
/**
* Adds view {@link module:engine/view/element~ViewElement element}, {@link module:engine/view/text~ViewText text node} or
* {@link module:engine/view/documentfragment~ViewDocumentFragment document fragment} as ready to be consumed.
*
* ```ts
* viewConsumable.add( p, { name: true } ); // Adds element's name to consume.
* viewConsumable.add( p, { attributes: 'name' } ); // Adds element's attribute.
* viewConsumable.add( p, { classes: 'foobar' } ); // Adds element's class.
* viewConsumable.add( p, { styles: 'color' } ); // Adds element's style
* viewConsumable.add( p, { attributes: 'name', styles: 'color' } ); // Adds attribute and style.
* viewConsumable.add( p, { classes: [ 'baz', 'bar' ] } ); // Multiple consumables can be provided.
* viewConsumable.add( textNode ); // Adds text node to consume.
* viewConsumable.add( docFragment ); // Adds document fragment to consume.
* ```
*
* Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `viewconsumable-invalid-attribute` when `class` or `style`
* attribute is provided - it should be handled separately by providing actual style/class.
*
* ```ts
* viewConsumable.add( p, { attributes: 'style' } ); // This call will throw an exception.
* viewConsumable.add( p, { styles: 'color' } ); // This is properly handled style.
* ```
*
* @param consumables Used only if first parameter is {@link module:engine/view/element~ViewElement view element} instance.
* @param consumables.name If set to true element's name will be included.
* @param consumables.attributes Attribute name or array of attribute names.
* @param consumables.classes Class name or array of class names.
* @param consumables.styles Style name or array of style names.
*/
add(element, consumables) {
let elementConsumables;
// For text nodes and document fragments just mark them as consumable.
if (element.is('$text') || element.is('documentFragment')) {
this._consumables.set(element, true);
return;
}
// For elements create new ViewElementConsumables or update already existing one.
if (!this._consumables.has(element)) {
elementConsumables = new ViewElementConsumables(element);
this._consumables.set(element, elementConsumables);
}
else {
elementConsumables = this._consumables.get(element);
}
elementConsumables.add(consumables ? normalizeConsumables(consumables) : element._getConsumables());
}
/**
* Tests if {@link module:engine/view/element~ViewElement view element}, {@link module:engine/view/text~ViewText text node} or
* {@link module:engine/view/documentfragment~ViewDocumentFragment document fragment} can be consumed.
* It returns `true` when all items included in method's call can be consumed. Returns `false` when
* first already consumed item is found and `null` when first non-consumable item is found.
*
* ```ts
* viewConsumable.test( p, { name: true } ); // Tests element's name.
* viewConsumable.test( p, { attributes: 'name' } ); // Tests attribute.
* viewConsumable.test( p, { classes: 'foobar' } ); // Tests class.
* viewConsumable.test( p, { styles: 'color' } ); // Tests style.
* viewConsumable.test( p, { attributes: 'name', styles: 'color' } ); // Tests attribute and style.
* viewConsumable.test( p, { classes: [ 'baz', 'bar' ] } ); // Multiple consumables can be tested.
* viewConsumable.test( textNode ); // Tests text node.
* viewConsumable.test( docFragment ); // Tests document fragment.
* ```
*
* Testing classes and styles as attribute will test if all added classes/styles can be consumed.
*
* ```ts
* viewConsumable.test( p, { attributes: 'class' } ); // Tests if all added classes can be consumed.
* viewConsumable.test( p, { attributes: 'style' } ); // Tests if all added styles can be consumed.
* ```
*
* @param consumables Used only if first parameter is {@link module:engine/view/element~ViewElement view element} instance.
* @param consumables.name If set to true element's name will be included.
* @param consumables.attributes Attribute name or array of attribute names.
* @param consumables.classes Class name or array of class names.
* @param consumables.styles Style name or array of style names.
* @returns Returns `true` when all items included in method's call can be consumed. Returns `false`
* when first already consumed item is found and `null` when first non-consumable item is found.
*/
test(element, consumables) {
const elementConsumables = this._consumables.get(element);
if (elementConsumables === undefined) {
return null;
}
// For text nodes and document fragments return stored boolean value.
if (element.is('$text') || element.is('documentFragment')) {
return elementConsumables;
}
// For elements test consumables object.
return elementConsumables.test(normalizeConsumables(consumables));
}
/**
* Consumes {@link module:engine/view/element~ViewElement view element}, {@link module:engine/view/text~ViewText text node} or
* {@link module:engine/view/documentfragment~ViewDocumentFragment document fragment}.
* It returns `true` when all items included in method's call can be consumed, otherwise returns `false`.
*
* ```ts
* viewConsumable.consume( p, { name: true } ); // Consumes element's name.
* viewConsumable.consume( p, { attributes: 'name' } ); // Consumes element's attribute.
* viewConsumable.consume( p, { classes: 'foobar' } ); // Consumes element's class.
* viewConsumable.consume( p, { styles: 'color' } ); // Consumes element's style.
* viewConsumable.consume( p, { attributes: 'name', styles: 'color' } ); // Consumes attribute and style.
* viewConsumable.consume( p, { classes: [ 'baz', 'bar' ] } ); // Multiple consumables can be consumed.
* viewConsumable.consume( textNode ); // Consumes text node.
* viewConsumable.consume( docFragment ); // Consumes document fragment.
* ```
*
* Consuming classes and styles as attribute will test if all added classes/styles can be consumed.
*
* ```ts
* viewConsumable.consume( p, { attributes: 'class' } ); // Consume only if all added classes can be consumed.
* viewConsumable.consume( p, { attributes: 'style' } ); // Consume only if all added styles can be consumed.
* ```
*
* @param consumables Used only if first parameter is {@link module:engine/view/element~ViewElement view element} instance.
* @param consumables.name If set to true element's name will be included.
* @param consumables.attributes Attribute name or array of attribute names.
* @param consumables.classes Class name or array of class names.
* @param consumables.styles Style name or array of style names.
* @returns Returns `true` when all items included in method's call can be consumed,
* otherwise returns `false`.
*/
consume(element, consumables) {
if (element.is('$text') || element.is('documentFragment')) {
if (!this.test(element, consumables)) {
return false;
}
// For text nodes and document fragments set value to false.
this._consumables.set(element, false);
return true;
}
// For elements - consume consumables object.
const elementConsumables = this._consumables.get(element);
if (elementConsumables === undefined) {
return false;
}
return elementConsumables.consume(normalizeConsumables(consumables));
}
/**
* Reverts {@link module:engine/view/element~ViewElement view element}, {@link module:engine/view/text~ViewText text node} or
* {@link module:engine/view/documentfragment~ViewDocumentFragment document fragment} so they can be consumed once again.
* Method does not revert items that were never previously added for consumption, even if they are included in
* method's call.
*
* ```ts
* viewConsumable.revert( p, { name: true } ); // Reverts element's name.
* viewConsumable.revert( p, { attributes: 'name' } ); // Reverts element's attribute.
* viewConsumable.revert( p, { classes: 'foobar' } ); // Reverts element's class.
* viewConsumable.revert( p, { styles: 'color' } ); // Reverts element's style.
* viewConsumable.revert( p, { attributes: 'name', styles: 'color' } ); // Reverts attribute and style.
* viewConsumable.revert( p, { classes: [ 'baz', 'bar' ] } ); // Multiple names can be reverted.
* viewConsumable.revert( textNode ); // Reverts text node.
* viewConsumable.revert( docFragment ); // Reverts document fragment.
* ```
*
* Reverting classes and styles as attribute will revert all classes/styles that were previously added for
* consumption.
*
* ```ts
* viewConsumable.revert( p, { attributes: 'class' } ); // Reverts all classes added for consumption.
* viewConsumable.revert( p, { attributes: 'style' } ); // Reverts all styles added for consumption.
* ```
*
* @param consumables Used only if first parameter is {@link module:engine/view/element~ViewElement view element} instance.
* @param consumables.name If set to true element's name will be included.
* @param consumables.attributes Attribute name or array of attribute names.
* @param consumables.classes Class name or array of class names.
* @param consumables.styles Style name or array of style names.
*/
revert(element, consumables) {
const elementConsumables = this._consumables.get(element);
if (elementConsumables !== undefined) {
if (element.is('$text') || element.is('documentFragment')) {
// For text nodes and document fragments - set consumable to true.
this._consumables.set(element, true);
}
else {
// For elements - revert items from consumables object.
elementConsumables.revert(normalizeConsumables(consumables));
}
}
}
/**
* Creates {@link module:engine/conversion/viewconsumable~ViewConsumable ViewConsumable} instance from
* {@link module:engine/view/node~ViewNode node} or {@link module:engine/view/documentfragment~ViewDocumentFragment document fragment}.
* Instance will contain all elements, child nodes, attributes, styles and classes added for consumption.
*
* @param from View node or document fragment from which `ViewConsumable` will be created.
* @param instance If provided, given `ViewConsumable` instance will be used
* to add all consumables. It will be returned instead of a new instance.
*/
static createFrom(from, instance) {
if (!instance) {
instance = new ViewConsumable();
}
if (from.is('$text')) {
instance.add(from);
}
else if (from.is('element') || from.is('documentFragment')) {
instance.add(from);
for (const child of from.getChildren()) {
ViewConsumable.createFrom(child, instance);
}
}
return instance;
}
}
/**
* This is a private helper-class for {@link module:engine/conversion/viewconsumable~ViewConsumable}.
* It represents and manipulates consumable parts of a single {@link module:engine/view/element~ViewElement}.
*
* @internal
*/
export class ViewElementConsumables {
element;
/**
* Flag indicating if name of the element can be consumed.
*/
_canConsumeName = null;
/**
* A map of element's consumables.
* * For plain attributes the value is a boolean indicating whether the attribute is available to consume.
* * For token based attributes (like class list and style) the value is a map of tokens to booleans
* indicating whether the token is available to consume on the given attribute.
*/
_attributes = new Map();
/**
* Creates ViewElementConsumables instance.
*
* @param from View element from which `ViewElementConsumables` is being created.
*/
constructor(from) {
this.element = from;
}
/**
* Adds consumable parts of the {@link module:engine/view/element~ViewElement view element}.
* Element's name itself can be marked to be consumed (when element's name is consumed its attributes, classes and
* styles still could be consumed):
*
* ```ts
* consumables.add( { name: true } );
* ```
*
* Attributes classes and styles:
*
* ```ts
* consumables.add( { attributes: [ [ 'title' ], [ 'class', 'foo' ], [ 'style', 'color'] ] } );
* consumables.add( { attributes: [ [ 'title' ], [ 'name' ], [ 'class', 'foo' ], [ 'class', 'bar' ] ] } );
* ```
*
* Note: This method accepts only {@link module:engine/view/element~ViewNormalizedConsumables}.
* You can use {@link module:engine/conversion/viewconsumable~normalizeConsumables} helper to convert from
* {@link module:engine/conversion/viewconsumable~Consumables} to `ViewNormalizedConsumables`.
*
* Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `viewconsumable-invalid-attribute` when `class` or `style`
* attribute is provided - it should be handled separately by providing `style` and `class` in consumables object.
*
* @param consumables Object describing which parts of the element can be consumed.
*/
add(consumables) {
if (consumables.name) {
this._canConsumeName = true;
}
for (const [name, token] of consumables.attributes) {
if (token) {
let attributeTokens = this._attributes.get(name);
if (!attributeTokens || typeof attributeTokens == 'boolean') {
attributeTokens = new Map();
this._attributes.set(name, attributeTokens);
}
attributeTokens.set(token, true);
}
else if (name == 'style' || name == 'class') {
/**
* Class and style attributes should be handled separately in
* {@link module:engine/conversion/viewconsumable~ViewConsumable#add `ViewConsumable#add()`}.
*
* What you have done is trying to use:
*
* ```ts
* consumables.add( { attributes: [ 'class', 'style' ] } );
* ```
*
* While each class and style should be registered separately:
*
* ```ts
* consumables.add( { classes: 'some-class', styles: 'font-weight' } );
* ```
*
* @error viewconsumable-invalid-attribute
*/
throw new CKEditorError('viewconsumable-invalid-attribute', this);
}
else {
this._attributes.set(name, true);
}
}
}
/**
* Tests if parts of the {@link module:engine/view/element~ViewElement view element} can be consumed.
*
* Element's name can be tested:
*
* ```ts
* consumables.test( { name: true } );
* ```
*
* Attributes classes and styles:
*
* ```ts
* consumables.test( { attributes: [ [ 'title' ], [ 'class', 'foo' ], [ 'style', 'color' ] ] } );
* consumables.test( { attributes: [ [ 'title' ], [ 'name' ], [ 'class', 'foo' ], [ 'class', 'bar' ] ] } );
* ```
*
* @param consumables Object describing which parts of the element should be tested.
* @returns `true` when all tested items can be consumed, `null` when even one of the items
* was never marked for consumption and `false` when even one of the items was already consumed.
*/
test(consumables) {
// Check if name can be consumed.
if (consumables.name && !this._canConsumeName) {
return this._canConsumeName;
}
for (const [name, token] of consumables.attributes) {
const value = this._attributes.get(name);
// Return null if attribute is not found.
if (value === undefined) {
return null;
}
// Already consumed.
if (value === false) {
return false;
}
// Simple attribute is not consumed so continue to next attribute.
if (value === true) {
continue;
}
if (!token) {
// Tokenized attribute but token is not specified so check if all tokens are not consumed.
for (const tokenValue of value.values()) {
// Already consumed token.
if (!tokenValue) {
return false;
}
}
}
else {
const tokenValue = value.get(token);
// Return null if token is not found.
if (tokenValue === undefined) {
return null;
}
// Already consumed.
if (!tokenValue) {
return false;
}
}
}
// Return true only if all can be consumed.
return true;
}
/**
* Tests if parts of the {@link module:engine/view/element~ViewElement view element} can be consumed and consumes them if available.
* It returns `true` when all items included in method's call can be consumed, otherwise returns `false`.
*
* Element's name can be consumed:
*
* ```ts
* consumables.consume( { name: true } );
* ```
*
* Attributes classes and styles:
*
* ```ts
* consumables.consume( { attributes: [ [ 'title' ], [ 'class', 'foo' ], [ 'style', 'color' ] ] } );
* consumables.consume( { attributes: [ [ 'title' ], [ 'name' ], [ 'class', 'foo' ], [ 'class', 'bar' ] ] } );
* ```
*
* @param consumables Object describing which parts of the element should be consumed.
* @returns `true` when all tested items can be consumed and `false` when even one of the items could not be consumed.
*/
consume(consumables) {
if (!this.test(consumables)) {
return false;
}
if (consumables.name) {
this._canConsumeName = false;
}
for (const [name, token] of consumables.attributes) {
// `value` must be set, because `this.test()` returned `true`.
const value = this._attributes.get(name);
// Plain (not tokenized) not-consumed attribute.
if (typeof value == 'boolean') {
// Use Element API to collect related attributes.
for (const [toConsume] of this.element._getConsumables(name, token).attributes) {
this._attributes.set(toConsume, false);
}
}
else if (!token) {
// Tokenized attribute but token is not specified so consume all tokens.
for (const token of value.keys()) {
value.set(token, false);
}
}
else {
// Use Element API to collect related attribute tokens.
for (const [, toConsume] of this.element._getConsumables(name, token).attributes) {
value.set(toConsume, false);
}
}
}
return true;
}
/**
* Revert already consumed parts of {@link module:engine/view/element~ViewElement view Element}, so they can be consumed once again.
* Element's name can be reverted:
*
* ```ts
* consumables.revert( { name: true } );
* ```
*
* Attributes classes and styles:
*
* ```ts
* consumables.revert( { attributes: [ [ 'title' ], [ 'class', 'foo' ], [ 'style', 'color' ] ] } );
* consumables.revert( { attributes: [ [ 'title' ], [ 'name' ], [ 'class', 'foo' ], [ 'class', 'bar' ] ] } );
* ```
*
* @param consumables Object describing which parts of the element should be reverted.
*/
revert(consumables) {
if (consumables.name) {
this._canConsumeName = true;
}
for (const [name, token] of consumables.attributes) {
const value = this._attributes.get(name);
// Plain consumed attribute.
if (value === false) {
this._attributes.set(name, true);
continue;
}
// Unknown attribute or not consumed.
if (value === undefined || value === true) {
continue;
}
if (!token) {
// Tokenized attribute but token is not specified so revert all tokens.
for (const token of value.keys()) {
value.set(token, true);
}
}
else {
const tokenValue = value.get(token);
if (tokenValue === false) {
value.set(token, true);
}
// Note that revert of consumed related styles is not handled.
}
}
}
}
/**
* Normalizes a {@link module:engine/conversion/viewconsumable~Consumables} or {@link module:engine/view/matcher~Match}
* to a {@link module:engine/view/element~ViewNormalizedConsumables}.
*
* @internal
*/
export function normalizeConsumables(consumables) {
const attributes = [];
if ('attributes' in consumables && consumables.attributes) {
normalizeConsumablePart(attributes, consumables.attributes);
}
if ('classes' in consumables && consumables.classes) {
normalizeConsumablePart(attributes, consumables.classes, 'class');
}
if ('styles' in consumables && consumables.styles) {
normalizeConsumablePart(attributes, consumables.styles, 'style');
}
return {
name: consumables.name || false,
attributes
};
}
/**
* Normalizes a list of consumable attributes to a common tuple format.
*/
function normalizeConsumablePart(attributes, items, prefix) {
if (typeof items == 'string') {
attributes.push(prefix ? [prefix, items] : [items]);
return;
}
for (const item of items) {
if (Array.isArray(item)) {
attributes.push(item);
}
else {
attributes.push(prefix ? [prefix, item] : [item]);
}
}
}