@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
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
*/
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,