UNPKG

@ckeditor/ckeditor5-engine

Version:

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

1,101 lines (1,095 loc) 1.75 MB
/** * @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 */ import { logWarning, EmitterMixin, CKEditorError, compareArrays, toArray, toMap, isIterable, ObservableMixin, count, EventInfo, Collection, keyCodes, isText, env, remove as remove$1, insertAt, diff, fastDiff, isNode, isComment, indexOf, global, isValidAttributeName, first, getAncestors, DomEmitterMixin, getCode, isArrowKeyCode, scrollViewportToShowTarget, uid, spliceArray, priorities, isInsideSurrogatePair, isInsideCombinedSymbol, isInsideEmojiSequence } from '@ckeditor/ckeditor5-utils/dist/index.js'; import { clone, isObject, get, merge, set, isPlainObject, extend, debounce, isEqualWith, cloneDeep, isEqual } from 'es-toolkit/compat'; // Each document stores information about its placeholder elements and check functions. const documentPlaceholders = new WeakMap(); let hasDisplayedPlaceholderDeprecationWarning = false; /** * A helper that enables a placeholder on the provided view element (also updates its visibility). * The placeholder is a CSS pseudo–element (with a text content) attached to the element. * * To change the placeholder text, change value of the `placeholder` property in the provided `element`. * * To disable the placeholder, use {@link module:engine/view/placeholder~disableViewPlaceholder `disableViewPlaceholder()`} helper. * * @param options Configuration options of the placeholder. * @param options.view Editing view instance. * @param options.element Element that will gain a placeholder. See `options.isDirectHost` to learn more. * @param options.isDirectHost If set `false`, the placeholder will not be enabled directly * in the passed `element` but in one of its children (selected automatically, i.e. a first empty child element). * Useful when attaching placeholders to elements that can host other elements (not just text), for instance, * editable root elements. * @param options.text Placeholder text. It's **deprecated** and will be removed soon. Use * {@link module:engine/view/placeholder~PlaceholderableViewElement#placeholder `options.element.placeholder`} instead. * @param options.keepOnFocus If set `true`, the placeholder stay visible when the host element is focused. */ function enableViewPlaceholder({ view, element, text, isDirectHost = true, keepOnFocus = false }) { const doc = view.document; // Use a single post fixer per—document to update all placeholders. if (!documentPlaceholders.has(doc)) { documentPlaceholders.set(doc, new Map()); // If a post-fixer callback makes a change, it should return `true` so other post–fixers // can re–evaluate the document again. doc.registerPostFixer((writer)=>updateDocumentPlaceholders(documentPlaceholders.get(doc), writer)); // Update placeholders on isComposing state change since rendering is disabled while in composition mode. doc.on('change:isComposing', ()=>{ view.change((writer)=>updateDocumentPlaceholders(documentPlaceholders.get(doc), writer)); }, { priority: 'high' }); } if (element.is('editableElement')) { element.on('change:placeholder', (evtInfo, evt, text)=>setPlaceholder(text)); } if (element.placeholder) { setPlaceholder(element.placeholder); } else if (text) { setPlaceholder(text); } if (text) { showViewPlaceholderTextDeprecationWarning(); } function setPlaceholder(text) { const config = { text, isDirectHost, keepOnFocus, hostElement: isDirectHost ? element : null }; // Store information about the element placeholder under its document. documentPlaceholders.get(doc).set(element, config); // Update the placeholders right away. view.change((writer)=>updateDocumentPlaceholders([ [ element, config ] ], writer)); } } /** * Disables the placeholder functionality from a given element. * * See {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} to learn more. */ function disableViewPlaceholder(view, element) { const doc = element.document; if (!documentPlaceholders.has(doc)) { return; } view.change((writer)=>{ const placeholders = documentPlaceholders.get(doc); const config = placeholders.get(element); writer.removeAttribute('data-placeholder', config.hostElement); hideViewPlaceholder(writer, config.hostElement); placeholders.delete(element); }); } /** * Shows a placeholder in the provided element by changing related attributes and CSS classes. * * **Note**: This helper will not update the placeholder visibility nor manage the * it in any way in the future. What it does is a one–time state change of an element. Use * {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} and * {@link module:engine/view/placeholder~disableViewPlaceholder `disableViewPlaceholder()`} for full * placeholder functionality. * * **Note**: This helper will blindly show the placeholder directly in the root editable element if * one is passed, which could result in a visual clash if the editable element has some children * (for instance, an empty paragraph). Use {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} * in that case or make sure the correct element is passed to the helper. * * @returns `true`, if any changes were made to the `element`. */ function showViewPlaceholder(writer, element) { if (!element.hasClass('ck-placeholder')) { writer.addClass('ck-placeholder', element); return true; } return false; } /** * Hides a placeholder in the element by changing related attributes and CSS classes. * * **Note**: This helper will not update the placeholder visibility nor manage the * it in any way in the future. What it does is a one–time state change of an element. Use * {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} and * {@link module:engine/view/placeholder~disableViewPlaceholder `disableViewPlaceholder()`} for full * placeholder functionality. * * @returns `true`, if any changes were made to the `element`. */ function hideViewPlaceholder(writer, element) { if (element.hasClass('ck-placeholder')) { writer.removeClass('ck-placeholder', element); return true; } return false; } /** * Checks if a placeholder should be displayed in the element. * * **Note**: This helper will blindly check the possibility of showing a placeholder directly in the * root editable element if one is passed, which may not be the expected result. If an element can * host other elements (not just text), most likely one of its children should be checked instead * because it will be the final host for the placeholder. Use * {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} in that case or make * sure the correct element is passed to the helper. * * @param element Element that holds the placeholder. * @param keepOnFocus Focusing the element will keep the placeholder visible. */ function needsViewPlaceholder(element, keepOnFocus) { if (!element.isAttached()) { return false; } if (hasContent(element)) { return false; } const doc = element.document; const viewSelection = doc.selection; const selectionAnchor = viewSelection.anchor; if (doc.isComposing && selectionAnchor && selectionAnchor.parent === element) { return false; } // Skip the focus check and make the placeholder visible already regardless of document focus state. if (keepOnFocus) { return true; } // If the document is blurred. if (!doc.isFocused) { return true; } // If document is focused and the element is empty but the selection is not anchored inside it. return !!selectionAnchor && selectionAnchor.parent !== element; } /** * Anything but uiElement(s) counts as content. */ function hasContent(element) { for (const child of element.getChildren()){ if (!child.is('uiElement')) { return true; } } return false; } /** * Updates all placeholders associated with a document in a post–fixer callback. * * @returns True if any changes were made to the view document. */ function updateDocumentPlaceholders(placeholders, writer) { const directHostElements = []; let wasViewModified = false; // First set placeholders on the direct hosts. for (const [element, config] of placeholders){ if (config.isDirectHost) { directHostElements.push(element); if (updatePlaceholder(writer, element, config)) { wasViewModified = true; } } } // Then set placeholders on the indirect hosts but only on those that does not already have an direct host placeholder. for (const [element, config] of placeholders){ if (config.isDirectHost) { continue; } const hostElement = getChildPlaceholderHostSubstitute(element); // When not a direct host, it could happen that there is no child element // capable of displaying a placeholder. if (!hostElement) { continue; } // Don't override placeholder if the host element already has some direct placeholder. if (directHostElements.includes(hostElement)) { continue; } // Update the host element (used for setting and removing the placeholder). config.hostElement = hostElement; if (updatePlaceholder(writer, element, config)) { wasViewModified = true; } } return wasViewModified; } /** * Updates a single placeholder in a post–fixer callback. * * @returns True if any changes were made to the view document. */ function updatePlaceholder(writer, element, config) { const { text, isDirectHost, hostElement } = config; let wasViewModified = false; // This may be necessary when updating the placeholder text to something else. if (hostElement.getAttribute('data-placeholder') !== text) { writer.setAttribute('data-placeholder', text, hostElement); wasViewModified = true; } // If the host element is not a direct host then placeholder is needed only when there is only one element. const isOnlyChild = isDirectHost || element.childCount == 1; if (isOnlyChild && needsViewPlaceholder(hostElement, config.keepOnFocus)) { if (showViewPlaceholder(writer, hostElement)) { wasViewModified = true; } } else if (hideViewPlaceholder(writer, hostElement)) { wasViewModified = true; } return wasViewModified; } /** * Gets a child element capable of displaying a placeholder if a parent element can host more * than just text (for instance, when it is a root editable element). The child element * can then be used in other placeholder helpers as a substitute of its parent. */ function getChildPlaceholderHostSubstitute(parent) { if (parent.childCount) { const firstChild = parent.getChild(0); if (firstChild.is('element') && !firstChild.is('uiElement') && !firstChild.is('attributeElement')) { return firstChild; } } return null; } /** * Displays a deprecation warning message in the console, but only once per page load. */ function showViewPlaceholderTextDeprecationWarning() { if (!hasDisplayedPlaceholderDeprecationWarning) { /** * The "text" option in the {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} * function is deprecated and will be removed soon. * * See the {@glink updating/guides/update-to-39#view-element-placeholder Migration to v39} guide for * more information on how to apply this change. * * @error enableViewPlaceholder-deprecated-text-option */ logWarning('enableViewPlaceholder-deprecated-text-option'); } hasDisplayedPlaceholderDeprecationWarning = true; } /** * @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/view/typecheckable */ class ViewTypeCheckable { /* istanbul ignore next -- @preserve */ is() { // There are a lot of overloads above. // Overriding method in derived classes remove them and only `is( type: string ): boolean` is visible which we don't want. // One option would be to copy them all to all classes, but that's ugly. // It's best when TypeScript compiler doesn't see those overloads, except the one in the top base class. // To overload a method, but not let the compiler see it, do after class definition: // `MyClass.prototype.is = function( type: string ) {...}` throw new Error('is() method is abstract'); } } /** * Abstract view node class. * * This is an abstract class. Its constructor should not be used directly. * Use the {@link module:engine/view/downcastwriter~ViewDowncastWriter} or {@link module:engine/view/upcastwriter~ViewUpcastWriter} * to create new instances of view nodes. */ class ViewNode extends /* #__PURE__ */ EmitterMixin(ViewTypeCheckable) { /** * The document instance to which this node belongs. */ document; /** * Parent element. Null by default. Set by {@link module:engine/view/element~ViewElement#_insertChild}. */ parent; /** * Creates a tree view node. * * @param document The document instance to which this node belongs. */ constructor(document){ super(); this.document = document; this.parent = null; } /** * Index of the node in the parent element or null if the node has no parent. * * Accessing this property throws an error if this node's parent element does not contain it. * This means that view tree got broken. */ get index() { let pos; if (!this.parent) { return null; } // No parent or child doesn't exist in parent's children. if ((pos = this.parent.getChildIndex(this)) == -1) { /** * The node's parent does not contain this node. It means that the document tree is corrupted. * * @error view-node-not-found-in-parent */ throw new CKEditorError('view-node-not-found-in-parent', this); } return pos; } /** * Node's next sibling, or `null` if it is the last child. */ get nextSibling() { const index = this.index; return index !== null && this.parent.getChild(index + 1) || null; } /** * Node's previous sibling, or `null` if it is the first child. */ get previousSibling() { const index = this.index; return index !== null && this.parent.getChild(index - 1) || null; } /** * Top-most ancestor of the node. If the node has no parent it is the root itself. */ get root() { // eslint-disable-next-line @typescript-eslint/no-this-alias, consistent-this let root = this; while(root.parent){ root = root.parent; } return root; } /** * Returns true if the node is in a tree rooted in the document (is a descendant of one of its roots). */ isAttached() { return this.root.is('rootElement'); } /** * Gets a path to the node. The path is an array containing indices of consecutive ancestors of this node, * beginning from {@link module:engine/view/node~ViewNode#root root}, down to this node's index. * * ```ts * const abc = downcastWriter.createText( 'abc' ); * const foo = downcastWriter.createText( 'foo' ); * const h1 = downcastWriter.createElement( 'h1', null, downcastWriter.createText( 'header' ) ); * const p = downcastWriter.createElement( 'p', null, [ abc, foo ] ); * const div = downcastWriter.createElement( 'div', null, [ h1, p ] ); * foo.getPath(); // Returns [ 1, 3 ]. `foo` is in `p` which is in `div`. `p` starts at offset 1, while `foo` at 3. * h1.getPath(); // Returns [ 0 ]. * div.getPath(); // Returns []. * ``` * * @returns The path. */ getPath() { const path = []; // eslint-disable-next-line @typescript-eslint/no-this-alias, consistent-this let node = this; while(node.parent){ path.unshift(node.index); node = node.parent; } return path; } /** * Returns ancestors array of this node. * * @param options Options object. * @param options.includeSelf When set to `true` this node will be also included in parent's array. * @param options.parentFirst When set to `true`, array will be sorted from node's parent to root element, * otherwise root element will be the first item in the array. * @returns Array with ancestors. */ getAncestors(options = {}) { const ancestors = []; let parent = options.includeSelf ? this : this.parent; while(parent){ ancestors[options.parentFirst ? 'push' : 'unshift'](parent); parent = parent.parent; } return ancestors; } /** * Returns a {@link module:engine/view/element~ViewElement} or {@link module:engine/view/documentfragment~ViewDocumentFragment} * which is a common ancestor of both nodes. * * @param node The second node. * @param options Options object. * @param options.includeSelf When set to `true` both nodes will be considered "ancestors" too. * Which means that if e.g. node A is inside B, then their common ancestor will be B. */ getCommonAncestor(node, options = {}) { const ancestorsA = this.getAncestors(options); const ancestorsB = node.getAncestors(options); let i = 0; while(ancestorsA[i] == ancestorsB[i] && ancestorsA[i]){ i++; } return i === 0 ? null : ancestorsA[i - 1]; } /** * Returns whether this node is before given node. `false` is returned if nodes are in different trees (for example, * in different {@link module:engine/view/documentfragment~ViewDocumentFragment}s). * * @param node Node to compare with. */ isBefore(node) { // Given node is not before this node if they are same. if (this == node) { return false; } // Return `false` if it is impossible to compare nodes. if (this.root !== node.root) { return false; } const thisPath = this.getPath(); const nodePath = node.getPath(); const result = compareArrays(thisPath, nodePath); switch(result){ case 'prefix': return true; case 'extension': return false; default: return thisPath[result] < nodePath[result]; } } /** * Returns whether this node is after given node. `false` is returned if nodes are in different trees (for example, * in different {@link module:engine/view/documentfragment~ViewDocumentFragment}s). * * @param node Node to compare with. */ isAfter(node) { // Given node is not before this node if they are same. if (this == node) { return false; } // Return `false` if it is impossible to compare nodes. if (this.root !== node.root) { return false; } // In other cases, just check if the `node` is before, and return the opposite. return !this.isBefore(node); } /** * Removes node from parent. * * @internal */ _remove() { this.parent._removeChildren(this.index); } /** * @internal * @param type Type of the change. * @param node Changed node. * @param data Additional data. * @fires change */ _fireChange(type, node, data) { this.fire(`change:${type}`, node, data); if (this.parent) { this.parent._fireChange(type, node, data); } } /** * Custom toJSON method to solve child-parent circular dependencies. * * @returns Clone of this object with the parent property removed. */ toJSON() { const json = clone(this); // Due to circular references we need to remove parent reference. delete json.parent; return json; } } // The magic of type inference using `is` method is centralized in `TypeCheckable` class. // Proper overload would interfere with that. ViewNode.prototype.is = function(type) { return type === 'node' || type === 'view:node'; }; /** * Tree view text node. * * The constructor of this class should not be used directly. To create a new text node instance * use the {@link module:engine/view/downcastwriter~ViewDowncastWriter#createText `ViewDowncastWriter#createText()`} * method when working on data downcasted from the model or the * {@link module:engine/view/upcastwriter~ViewUpcastWriter#createText `ViewUpcastWriter#createText()`} * method when working on non-semantic views. */ class ViewText extends ViewNode { /** * The text content. * * Setting the data fires the {@link module:engine/view/node~ViewNode#event:change:text change event}. */ _textData; /** * Creates a tree view text node. * * @see module:engine/view/downcastwriter~ViewDowncastWriter#createText * @internal * @param document The document instance to which this text node belongs. * @param data The text's data. */ constructor(document, data){ super(document); this._textData = data; } /** * The text content. */ get data() { return this._textData; } /** * The `_data` property is controlled by a getter and a setter. * * The getter is required when using the addition assignment operator on protected property: * * ```ts * const foo = downcastWriter.createText( 'foo' ); * const bar = downcastWriter.createText( 'bar' ); * * foo._data += bar.data; // executes: `foo._data = foo._data + bar.data` * console.log( foo.data ); // prints: 'foobar' * ``` * * If the protected getter didn't exist, `foo._data` will return `undefined` and result of the merge will be invalid. * * The setter sets data and fires the {@link module:engine/view/node~ViewNode#event:change:text change event}. * * @internal */ get _data() { return this.data; } set _data(data) { this._fireChange('text', this); this._textData = data; } /** * Checks if this text node is similar to other text node. * Both nodes should have the same data to be considered as similar. * * @param otherNode Node to check if it is same as this node. */ isSimilar(otherNode) { if (!(otherNode instanceof ViewText)) { return false; } return this === otherNode || this.data === otherNode.data; } /** * Clones this node. * * @internal * @returns Text node that is a clone of this node. */ _clone() { return new ViewText(this.document, this.data); } } // The magic of type inference using `is` method is centralized in `TypeCheckable` class. // Proper overload would interfere with that. ViewText.prototype.is = function(type) { return type === '$text' || type === 'view:$text' || // This are legacy values kept for backward compatibility. type === 'text' || type === 'view:text' || // From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529. type === 'node' || type === 'view:node'; }; /** * ViewTextProxy is a wrapper for substring of {@link module:engine/view/text~ViewText}. Instance of this class is created by * {@link module:engine/view/treewalker~ViewTreeWalker} when only a part of {@link module:engine/view/text~ViewText} needs to be returned. * * `ViewTextProxy` has an API similar to {@link module:engine/view/text~ViewText Text} and allows to do most of the common tasks performed * on view nodes. * * **Note:** Some `ViewTextProxy` instances may represent whole text node, not just a part of it. * See {@link module:engine/view/textproxy~ViewTextProxy#isPartial}. * * **Note:** `ViewTextProxy` is a readonly interface. * * **Note:** `ViewTextProxy` instances are created on the fly basing * on the current state of parent {@link module:engine/view/text~ViewText}. * Because of this it is highly unrecommended to store references to `TextProxy instances because they might get * invalidated due to operations on Document. Also ViewTextProxy is not a {@link module:engine/view/node~ViewNode} so it cannot be * inserted as a child of {@link module:engine/view/element~ViewElement}. * * `ViewTextProxy` instances are created by {@link module:engine/view/treewalker~ViewTreeWalker view tree walker}. * You should not need to create an instance of this class by your own. */ class ViewTextProxy extends ViewTypeCheckable { /** * Reference to the {@link module:engine/view/text~ViewText} element which ViewTextProxy is a substring. */ textNode; /** * Text data represented by this text proxy. */ data; /** * Offset in the `textNode` where this `ViewTextProxy` instance starts. */ offsetInText; /** * Creates a text proxy. * * @internal * @param textNode Text node which part is represented by this text proxy. * @param offsetInText Offset in {@link module:engine/view/textproxy~ViewTextProxy#textNode text node} * from which the text proxy starts. * @param length Text proxy length, that is how many text node's characters, starting from `offsetInText` it represents. */ constructor(textNode, offsetInText, length){ super(); this.textNode = textNode; if (offsetInText < 0 || offsetInText > textNode.data.length) { /** * Given offsetInText value is incorrect. * * @error view-textproxy-wrong-offsetintext */ throw new CKEditorError('view-textproxy-wrong-offsetintext', this); } if (length < 0 || offsetInText + length > textNode.data.length) { /** * Given length value is incorrect. * * @error view-textproxy-wrong-length */ throw new CKEditorError('view-textproxy-wrong-length', this); } this.data = textNode.data.substring(offsetInText, offsetInText + length); this.offsetInText = offsetInText; } /** * Offset size of this node. */ get offsetSize() { return this.data.length; } /** * Flag indicating whether `ViewTextProxy` instance covers only part of the original {@link module:engine/view/text~ViewText text node} * (`true`) or the whole text node (`false`). * * This is `false` when text proxy starts at the very beginning of {@link module:engine/view/textproxy~ViewTextProxy#textNode textNode} * ({@link module:engine/view/textproxy~ViewTextProxy#offsetInText offsetInText} equals `0`) and text proxy sizes is equal to * text node size. */ get isPartial() { return this.data.length !== this.textNode.data.length; } /** * Parent of this text proxy, which is same as parent of text node represented by this text proxy. */ get parent() { return this.textNode.parent; } /** * Root of this text proxy, which is same as root of text node represented by this text proxy. */ get root() { return this.textNode.root; } /** * {@link module:engine/view/document~ViewDocument View document} that owns this text proxy, or `null` if the text proxy is inside * {@link module:engine/view/documentfragment~ViewDocumentFragment document fragment}. */ get document() { return this.textNode.document; } /** * Returns ancestors array of this text proxy. * * @param options Options object. * @param options.includeSelf When set to `true`, textNode will be also included in parent's array. * @param options.parentFirst When set to `true`, array will be sorted from text proxy parent to * root element, otherwise root element will be the first item in the array. * @returns Array with ancestors. */ getAncestors(options = {}) { const ancestors = []; let parent = options.includeSelf ? this.textNode : this.parent; while(parent !== null){ ancestors[options.parentFirst ? 'push' : 'unshift'](parent); parent = parent.parent; } return ancestors; } } // The magic of type inference using `is` method is centralized in `TypeCheckable` class. // Proper overload would interfere with that. ViewTextProxy.prototype.is = function(type) { return type === '$textProxy' || type === 'view:$textProxy' || // This are legacy values kept for backward compatibility. type === 'textProxy' || type === 'view:textProxy'; }; /** * 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. * ``` */ 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 */ 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,