UNPKG

@ckeditor/ckeditor5-engine

Version:

The editing engine of CKEditor 5 – the best browser-based rich text editor.

542 lines (541 loc) • 25.6 kB
/** * @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]); } } }