@ckeditor/ckeditor5-engine
Version:
The editing engine of CKEditor 5 – the best browser-based rich text editor.
1,034 lines (1,033 loc) • 38.2 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module engine/view/element
*/
import { ViewNode } from './node.js';
import { ViewText } from './text.js';
import { ViewTextProxy } from './textproxy.js';
import { isIterable, toMap } from '@ckeditor/ckeditor5-utils';
import { Matcher, isPatternMatched } from './matcher.js';
import { StylesMap } from './stylesmap.js';
import { ViewTokenList } from './tokenlist.js';
// @if CK_DEBUG_ENGINE // const { convertMapToTags } = require( '../dev-utils/utils' );
/**
* View element.
*
* The editing engine does not define a fixed semantics of its elements (it is "DTD-free").
* This is why the type of the {@link module:engine/view/element~ViewElement} need to
* be defined by the feature developer. When creating an element you should use one of the following methods:
*
* * {@link module:engine/view/downcastwriter~ViewDowncastWriter#createContainerElement `downcastWriter#createContainerElement()`}
* in order to create a {@link module:engine/view/containerelement~ViewContainerElement},
* * {@link module:engine/view/downcastwriter~ViewDowncastWriter#createAttributeElement `downcastWriter#createAttributeElement()`}
* in order to create a {@link module:engine/view/attributeelement~ViewAttributeElement},
* * {@link module:engine/view/downcastwriter~ViewDowncastWriter#createEmptyElement `downcastWriter#createEmptyElement()`}
* in order to create a {@link module:engine/view/emptyelement~ViewEmptyElement}.
* * {@link module:engine/view/downcastwriter~ViewDowncastWriter#createUIElement `downcastWriter#createUIElement()`}
* in order to create a {@link module:engine/view/uielement~ViewUIElement}.
* * {@link module:engine/view/downcastwriter~ViewDowncastWriter#createEditableElement `downcastWriter#createEditableElement()`}
* in order to create a {@link module:engine/view/editableelement~ViewEditableElement}.
*
* Note that for view elements which are not created from the model, like elements from mutations, paste or
* {@link module:engine/controller/datacontroller~DataController#set data.set} it is not possible to define the type of the element.
* In such cases the {@link module:engine/view/upcastwriter~ViewUpcastWriter#createElement `UpcastWriter#createElement()`} method
* should be used to create generic view elements.
*/
export class ViewElement extends ViewNode {
/**
* Name of the element.
*/
name;
/**
* A list of attribute names that should be rendered in the editing pipeline even though filtering mechanisms
* implemented in the {@link module:engine/view/domconverter~ViewDomConverter} (for instance,
* {@link module:engine/view/domconverter~ViewDomConverter#shouldRenderAttribute}) would filter them out.
*
* These attributes can be specified as an option when the element is created by
* the {@link module:engine/view/downcastwriter~ViewDowncastWriter}. To check whether an unsafe an attribute should
* be permitted, use the {@link #shouldRenderUnsafeAttribute} method.
*
* @internal
*/
_unsafeAttributesToRender = [];
/**
* Map of attributes, where attributes names are keys and attributes values are values.
*/
_attrs;
/**
* Array of child nodes.
*/
_children;
/**
* Map of custom properties.
* Custom properties can be added to element instance, will be cloned but not rendered into DOM.
*/
_customProperties = new Map();
/**
* Set of classes associated with element instance.
*
* Note that this is just an alias for `this._attrs.get( 'class' );`
*/
get _classes() {
return this._attrs.get('class');
}
/**
* Normalized styles.
*
* Note that this is just an alias for `this._attrs.get( 'style' );`
*/
get _styles() {
return this._attrs.get('style');
}
/**
* Creates a view element.
*
* Attributes can be passed in various formats:
*
* ```ts
* new Element( viewDocument, 'div', { class: 'editor', contentEditable: 'true' } ); // object
* new Element( viewDocument, 'div', [ [ 'class', 'editor' ], [ 'contentEditable', 'true' ] ] ); // map-like iterator
* new Element( viewDocument, 'div', mapOfAttributes ); // map
* ```
*
* @internal
* @param document The document instance to which this element belongs.
* @param name Node name.
* @param attrs Collection of attributes.
* @param children A list of nodes to be inserted into created element.
*/
constructor(document, name, attrs, children) {
super(document);
this.name = name;
this._attrs = this._parseAttributes(attrs);
this._children = [];
if (children) {
this._insertChild(0, children);
}
}
/**
* Number of element's children.
*/
get childCount() {
return this._children.length;
}
/**
* Is `true` if there are no nodes inside this element, `false` otherwise.
*/
get isEmpty() {
return this._children.length === 0;
}
/**
* Gets child at the given index.
*
* @param index Index of child.
* @returns Child node.
*/
getChild(index) {
return this._children[index];
}
/**
* Gets index of the given child node. Returns `-1` if child node is not found.
*
* @param node Child node.
* @returns Index of the child node.
*/
getChildIndex(node) {
return this._children.indexOf(node);
}
/**
* Gets child nodes iterator.
*
* @returns Child nodes iterator.
*/
getChildren() {
return this._children[Symbol.iterator]();
}
/**
* Returns an iterator that contains the keys for attributes. Order of inserting attributes is not preserved.
*
* @returns Keys for attributes.
*/
*getAttributeKeys() {
// This is yielded in this specific order to maintain backward compatibility of data.
// Otherwise, we could simply just have the `for` loop only inside this method.
if (this._classes) {
yield 'class';
}
if (this._styles) {
yield 'style';
}
for (const key of this._attrs.keys()) {
if (key != 'class' && key != 'style') {
yield key;
}
}
}
/**
* Returns iterator that iterates over this element's attributes.
*
* Attributes are returned as arrays containing two items. First one is attribute key and second is attribute value.
* This format is accepted by native `Map` object and also can be passed in `Node` constructor.
*/
*getAttributes() {
for (const [name, value] of this._attrs.entries()) {
yield [name, String(value)];
}
}
/**
* Gets attribute by key. If attribute is not present - returns undefined.
*
* @param key Attribute key.
* @returns Attribute value.
*/
getAttribute(key) {
return this._attrs.has(key) ? String(this._attrs.get(key)) : undefined;
}
/**
* Returns a boolean indicating whether an attribute with the specified key exists in the element.
*
* @param key Attribute key.
* @returns `true` if attribute with the specified key exists in the element, `false` otherwise.
*/
hasAttribute(key, token) {
if (!this._attrs.has(key)) {
return false;
}
if (token !== undefined) {
if (usesStylesMap(this.name, key) || usesTokenList(this.name, key)) {
return this._attrs.get(key).has(token);
}
else {
return this._attrs.get(key) === token;
}
}
return true;
}
/**
* Checks if this element is similar to other element.
* Both elements should have the same name and attributes to be considered as similar. Two similar elements
* can contain different set of children nodes.
*/
isSimilar(otherElement) {
if (!(otherElement instanceof ViewElement)) {
return false;
}
// If exactly the same Element is provided - return true immediately.
if (this === otherElement) {
return true;
}
// Check element name.
if (this.name != otherElement.name) {
return false;
}
// Check number of attributes, classes and styles.
if (this._attrs.size !== otherElement._attrs.size) {
return false;
}
// Check if attributes are the same.
for (const [key, value] of this._attrs) {
const otherValue = otherElement._attrs.get(key);
if (otherValue === undefined) {
return false;
}
if (typeof value == 'string' || typeof otherValue == 'string') {
if (otherValue !== value) {
return false;
}
}
else if (!value.isSimilar(otherValue)) {
return false;
}
}
return true;
}
/**
* Returns true if class is present.
* If more then one class is provided - returns true only when all classes are present.
*
* ```ts
* element.hasClass( 'foo' ); // Returns true if 'foo' class is present.
* element.hasClass( 'foo', 'bar' ); // Returns true if 'foo' and 'bar' classes are both present.
* ```
*/
hasClass(...className) {
for (const name of className) {
if (!this._classes || !this._classes.has(name)) {
return false;
}
}
return true;
}
/**
* Returns iterator that contains all class names.
*/
getClassNames() {
const array = this._classes ? this._classes.keys() : [];
// This is overcomplicated because we need to be backward compatible for use cases when iterator is expected.
const iterator = array[Symbol.iterator]();
return Object.assign(array, {
next: iterator.next.bind(iterator)
});
}
/**
* Returns style value for the given property name.
* If the style does not exist `undefined` is returned.
*
* **Note**: This method can work with normalized style names if
* {@link module:engine/controller/datacontroller~DataController#addStyleProcessorRules a particular style processor rule is enabled}.
* See {@link module:engine/view/stylesmap~StylesMap#getAsString `StylesMap#getAsString()`} for details.
*
* For an element with style set to `'margin:1px'`:
*
* ```ts
* // Enable 'margin' shorthand processing:
* editor.data.addStyleProcessorRules( addMarginStylesRules );
*
* const element = view.change( writer => {
* const element = writer.createElement();
* writer.setStyle( 'margin', '1px' );
* writer.setStyle( 'margin-bottom', '3em' );
*
* return element;
* } );
*
* element.getStyle( 'margin' ); // -> 'margin: 1px 1px 3em;'
* ```
*/
getStyle(property) {
return this._styles && this._styles.getAsString(property);
}
/**
* Returns a normalized style object or single style value.
*
* For an element with style set to: margin:1px 2px 3em;
*
* ```ts
* element.getNormalizedStyle( 'margin' ) );
* ```
*
* will return:
*
* ```ts
* {
* top: '1px',
* right: '2px',
* bottom: '3em',
* left: '2px' // a normalized value from margin shorthand
* }
* ```
*
* and reading for single style value:
*
* ```ts
* styles.getNormalizedStyle( 'margin-left' );
* ```
*
* Will return a `2px` string.
*
* **Note**: This method will return normalized values only if
* {@link module:engine/controller/datacontroller~DataController#addStyleProcessorRules a particular style processor rule is enabled}.
* See {@link module:engine/view/stylesmap~StylesMap#getNormalized `StylesMap#getNormalized()`} for details.
*
* @param property Name of CSS property
*/
getNormalizedStyle(property) {
return this._styles && this._styles.getNormalized(property);
}
/**
* Returns an array that contains all style names.
*
* @param expand Expand shorthand style properties and return all equivalent style representations.
*/
getStyleNames(expand) {
return this._styles ? this._styles.getStyleNames(expand) : [];
}
/**
* Returns true if style keys are present.
* If more then one style property is provided - returns true only when all properties are present.
*
* ```ts
* element.hasStyle( 'color' ); // Returns true if 'border-top' style is present.
* element.hasStyle( 'color', 'border-top' ); // Returns true if 'color' and 'border-top' styles are both present.
* ```
*/
hasStyle(...property) {
for (const name of property) {
if (!this._styles || !this._styles.has(name)) {
return false;
}
}
return true;
}
/**
* Returns ancestor element that match specified pattern.
* Provided patterns should be compatible with {@link module:engine/view/matcher~Matcher Matcher} as it is used internally.
*
* @see module:engine/view/matcher~Matcher
* @param patterns Patterns used to match correct ancestor. See {@link module:engine/view/matcher~Matcher}.
* @returns Found element or `null` if no matching ancestor was found.
*/
findAncestor(...patterns) {
const matcher = new Matcher(...patterns);
let parent = this.parent;
while (parent && !parent.is('documentFragment')) {
if (matcher.match(parent)) {
return parent;
}
parent = parent.parent;
}
return null;
}
/**
* Returns the custom property value for the given key.
*/
getCustomProperty(key) {
return this._customProperties.get(key);
}
/**
* Returns an iterator which iterates over this element's custom properties.
* Iterator provides `[ key, value ]` pairs for each stored property.
*/
*getCustomProperties() {
yield* this._customProperties.entries();
}
/**
* Returns identity string based on element's name, styles, classes and other attributes.
* Two elements that {@link #isSimilar are similar} will have same identity string.
* It has the following format:
*
* ```ts
* 'name class="class1,class2" style="style1:value1;style2:value2" attr1="val1" attr2="val2"'
* ```
*
* For example:
*
* ```ts
* const element = writer.createContainerElement( 'foo', {
* banana: '10',
* apple: '20',
* style: 'color: red; border-color: white;',
* class: 'baz'
* } );
*
* // returns 'foo class="baz" style="border-color:white;color:red" apple="20" banana="10"'
* element.getIdentity();
* ```
*
* **Note**: Classes, styles and other attributes are sorted alphabetically.
*/
getIdentity() {
const classes = this._classes ? this._classes.keys().sort().join(',') : '';
const styles = this._styles && String(this._styles);
const attributes = Array.from(this._attrs)
.filter(([key]) => key != 'style' && key != 'class')
.map(i => `${i[0]}="${i[1]}"`)
.sort().join(' ');
return this.name +
(classes == '' ? '' : ` class="${classes}"`) +
(!styles ? '' : ` style="${styles}"`) +
(attributes == '' ? '' : ` ${attributes}`);
}
/**
* Decides whether an unsafe attribute is whitelisted and should be rendered in the editing pipeline even though filtering mechanisms
* like {@link module:engine/view/domconverter~ViewDomConverter#shouldRenderAttribute} say it should not.
*
* Unsafe attribute names can be specified when creating an element via {@link module:engine/view/downcastwriter~ViewDowncastWriter}.
*
* @param attributeName The name of the attribute to be checked.
*/
shouldRenderUnsafeAttribute(attributeName) {
return this._unsafeAttributesToRender.includes(attributeName);
}
/**
* Clones provided element.
*
* @internal
* @param deep If set to `true` clones element and all its children recursively. When set to `false`,
* element will be cloned without any children.
* @returns Clone of this element.
*/
_clone(deep = false) {
const childrenClone = [];
if (deep) {
for (const child of this.getChildren()) {
childrenClone.push(child._clone(deep));
}
}
// ViewContainerElement and ViewAttributeElement should be also cloned properly.
const cloned = new this.constructor(this.document, this.name, this._attrs, childrenClone);
// Clone custom properties.
cloned._customProperties = new Map(this._customProperties);
// Clone filler offset method.
// We can't define this method in a prototype because it's behavior which
// is changed by e.g. toWidget() function from ckeditor5-widget. Perhaps this should be one of custom props.
cloned.getFillerOffset = this.getFillerOffset;
// Clone unsafe attributes list.
cloned._unsafeAttributesToRender = this._unsafeAttributesToRender;
return cloned;
}
/**
* {@link module:engine/view/element~ViewElement#_insertChild Insert} a child node or a list of child nodes at the end of this node
* and sets the parent of these nodes to this element.
*
* @see module:engine/view/downcastwriter~ViewDowncastWriter#insert
* @internal
* @param items Items to be inserted.
* @fires change
* @returns Number of appended nodes.
*/
_appendChild(items) {
return this._insertChild(this.childCount, items);
}
/**
* Inserts a child node or a list of child nodes on the given index and sets the parent of these nodes to
* this element.
*
* @internal
* @see module:engine/view/downcastwriter~ViewDowncastWriter#insert
* @param index Position where nodes should be inserted.
* @param items Items to be inserted.
* @fires change
* @returns Number of inserted nodes.
*/
_insertChild(index, items) {
this._fireChange('children', this, { index });
let count = 0;
const nodes = normalize(this.document, items);
for (const node of nodes) {
// If node that is being added to this element is already inside another element, first remove it from the old parent.
if (node.parent !== null) {
node._remove();
}
node.parent = this;
node.document = this.document;
this._children.splice(index, 0, node);
index++;
count++;
}
return count;
}
/**
* Removes number of child nodes starting at the given index and set the parent of these nodes to `null`.
*
* @see module:engine/view/downcastwriter~ViewDowncastWriter#remove
* @internal
* @param index Number of the first node to remove.
* @param howMany Number of nodes to remove.
* @fires change
* @returns The array of removed nodes.
*/
_removeChildren(index, howMany = 1) {
this._fireChange('children', this, { index });
for (let i = index; i < index + howMany; i++) {
this._children[i].parent = null;
}
return this._children.splice(index, howMany);
}
/**
* Adds or overwrite attribute with a specified key and value.
*
* @see module:engine/view/downcastwriter~ViewDowncastWriter#setAttribute
* @internal
* @param key Attribute key.
* @param value Attribute value.
* @param overwrite Whether tokenized attribute should override the attribute value or just add a token.
* @fires change
*/
_setAttribute(key, value, overwrite = true) {
this._fireChange('attributes', this);
if (usesStylesMap(this.name, key) || usesTokenList(this.name, key)) {
let currentValue = this._attrs.get(key);
if (!currentValue) {
currentValue = usesStylesMap(this.name, key) ?
new StylesMap(this.document.stylesProcessor) :
new ViewTokenList();
this._attrs.set(key, currentValue);
}
if (overwrite) {
// If reset is set then value have to be a string to tokenize.
currentValue.setTo(String(value));
}
else if (usesStylesMap(this.name, key)) {
if (Array.isArray(value)) {
currentValue.set(value[0], value[1]);
}
else {
currentValue.set(value);
}
}
else { // TokenList.
currentValue.set(typeof value == 'string' ? value.split(/\s+/) : value);
}
}
else {
this._attrs.set(key, String(value));
}
}
/**
* Removes attribute from the element.
*
* @see module:engine/view/downcastwriter~ViewDowncastWriter#removeAttribute
* @internal
* @param key Attribute key.
* @param tokens Attribute value tokens to remove. The whole attribute is removed if not specified.
* @returns Returns true if an attribute existed and has been removed.
* @fires change
*/
_removeAttribute(key, tokens) {
this._fireChange('attributes', this);
if (tokens !== undefined && (usesStylesMap(this.name, key) || usesTokenList(this.name, key))) {
const currentValue = this._attrs.get(key);
if (!currentValue) {
return false;
}
if (usesTokenList(this.name, key) && typeof tokens == 'string') {
tokens = tokens.split(/\s+/);
}
currentValue.remove(tokens);
if (currentValue.isEmpty) {
return this._attrs.delete(key);
}
return false;
}
return this._attrs.delete(key);
}
/**
* Adds specified class.
*
* ```ts
* element._addClass( 'foo' ); // Adds 'foo' class.
* element._addClass( [ 'foo', 'bar' ] ); // Adds 'foo' and 'bar' classes.
* ```
*
* @see module:engine/view/downcastwriter~ViewDowncastWriter#addClass
* @internal
* @fires change
*/
_addClass(className) {
this._setAttribute('class', className, false);
}
/**
* Removes specified class.
*
* ```ts
* element._removeClass( 'foo' ); // Removes 'foo' class.
* element._removeClass( [ 'foo', 'bar' ] ); // Removes both 'foo' and 'bar' classes.
* ```
*
* @see module:engine/view/downcastwriter~ViewDowncastWriter#removeClass
* @internal
* @fires change
*/
_removeClass(className) {
this._removeAttribute('class', className);
}
_setStyle(property, value) {
if (typeof property != 'string') {
this._setAttribute('style', property, false);
}
else {
this._setAttribute('style', [property, value], false);
}
}
/**
* Removes specified style.
*
* ```ts
* element._removeStyle( 'color' ); // Removes 'color' style.
* element._removeStyle( [ 'color', 'border-top' ] ); // Removes both 'color' and 'border-top' styles.
* ```
*
* **Note**: This method can work with normalized style names if
* {@link module:engine/controller/datacontroller~DataController#addStyleProcessorRules a particular style processor rule is enabled}.
* See {@link module:engine/view/stylesmap~StylesMap#remove `StylesMap#remove()`} for details.
*
* @see module:engine/view/downcastwriter~ViewDowncastWriter#removeStyle
* @internal
* @fires change
*/
_removeStyle(property) {
this._removeAttribute('style', property);
}
/**
* Used by the {@link module:engine/view/matcher~Matcher Matcher} to collect matching attribute tuples
* (attribute name and optional token).
*
* Normalized patterns can be used in following ways:
* - to match any attribute name with any or no value:
*
* ```ts
* patterns: [
* [ true, true ]
* ]
* ```
*
* - to match a specific attribute with any value:
*
* ```ts
* patterns: [
* [ 'required', true ]
* ]
* ```
*
* - to match an attribute name with a RegExp with any value:
*
* ```ts
* patterns: [
* [ /h[1-6]/, true ]
* ]
* ```
*
* - to match a specific attribute with the exact value:
*
* ```ts
* patterns: [
* [ 'rel', 'nofollow' ]
* ]
* ```
*
* - to match a specific attribute with a value matching a RegExp:
*
* ```ts
* patterns: [
* [ 'src', /^https/ ]
* ]
* ```
*
* - to match an attribute name with a RegExp and the exact value:
*
* ```ts
* patterns: [
* [ /^data-property-/, 'foobar' ],
* ]
* ```
*
* - to match an attribute name with a RegExp and match a value with another RegExp:
*
* ```ts
* patterns: [
* [ /^data-property-/, /^foo/ ]
* ]
* ```
*
* - to match a specific style property with the value matching a RegExp:
*
* ```ts
* patterns: [
* [ 'style', 'font-size', /px$/ ]
* ]
* ```
*
* - to match a specific class (class attribute is tokenized so it matches tokens individually):
*
* ```ts
* patterns: [
* [ 'class', 'foo' ]
* ]
* ```
*
* @internal
* @param patterns An array of normalized patterns (tuples of 2 or 3 items depending on if tokenized attribute value match is needed).
* @param match An array to populate with matching tuples.
* @param exclude Array of attribute names to exclude from match.
* @returns `true` if element matches all patterns. The matching tuples are pushed to the `match` array.
*/
_collectAttributesMatch(patterns, match, exclude) {
for (const [keyPattern, tokenPattern, valuePattern] of patterns) {
let hasKey = false;
let hasValue = false;
for (const [key, value] of this._attrs) {
if (exclude && exclude.includes(key) || !isPatternMatched(keyPattern, key)) {
continue;
}
hasKey = true;
if (typeof value == 'string') {
if (isPatternMatched(tokenPattern, value)) {
match.push([key]);
hasValue = true;
}
else if (!(keyPattern instanceof RegExp)) {
return false;
}
}
else {
const tokenMatch = value._getTokensMatch(tokenPattern, valuePattern || true);
if (tokenMatch) {
hasValue = true;
for (const tokenMatchItem of tokenMatch) {
match.push([key, tokenMatchItem]);
}
}
else if (!(keyPattern instanceof RegExp)) {
return false;
}
}
}
if (!hasKey || !hasValue) {
return false;
}
}
return true;
}
/**
* Used by the {@link module:engine/conversion/viewconsumable~ViewConsumable} to collect the
* {@link module:engine/view/element~ViewNormalizedConsumables} for the element.
*
* When `key` and `token` parameters are provided the output is filtered for the specified attribute and it's tokens and related tokens.
*
* @internal
* @param key Attribute name.
* @param token Reference token to collect all related tokens.
*/
_getConsumables(key, token) {
const attributes = [];
if (key) {
const value = this._attrs.get(key);
if (value !== undefined) {
if (typeof value == 'string') {
attributes.push([key]);
}
else {
for (const prop of value._getConsumables(token)) {
attributes.push([key, prop]);
}
}
}
}
else {
for (const [key, value] of this._attrs) {
if (typeof value == 'string') {
attributes.push([key]);
}
else {
for (const prop of value._getConsumables()) {
attributes.push([key, prop]);
}
}
}
}
return {
name: !key,
attributes
};
}
/**
* Verify if the given element can be merged without conflicts into the element.
*
* Note that this method is extended by the {@link module:engine/view/attributeelement~ViewAttributeElement} implementation.
*
* This method is used by the {@link module:engine/view/downcastwriter~ViewDowncastWriter} while down-casting
* an {@link module:engine/view/attributeelement~ViewAttributeElement} to merge it with other ViewAttributeElement.
*
* @internal
* @returns Returns `true` if elements can be merged.
*/
_canMergeAttributesFrom(otherElement) {
if (this.name != otherElement.name) {
return false;
}
for (const [key, otherValue] of otherElement._attrs) {
const value = this._attrs.get(key);
if (value === undefined) {
continue;
}
if (typeof value == 'string' || typeof otherValue == 'string') {
if (value !== otherValue) {
return false;
}
}
else if (!value._canMergeFrom(otherValue)) {
return false;
}
}
return true;
}
/**
* Merges attributes of a given element into the element.
* This includes also tokenized attributes like style and class.
*
* Note that you should make sure there are no conflicts before merging (see {@link #_canMergeAttributesFrom}).
*
* This method is used by the {@link module:engine/view/downcastwriter~ViewDowncastWriter} while down-casting
* an {@link module:engine/view/attributeelement~ViewAttributeElement} to merge it with other ViewAttributeElement.
*
* @internal
*/
_mergeAttributesFrom(otherElement) {
this._fireChange('attributes', this);
// Move all attributes/classes/styles from wrapper to wrapped ViewAttributeElement.
for (const [key, otherValue] of otherElement._attrs) {
const value = this._attrs.get(key);
if (value === undefined || typeof value == 'string' || typeof otherValue == 'string') {
this._setAttribute(key, otherValue);
}
else {
value._mergeFrom(otherValue);
}
}
}
/**
* Verify if the given element attributes can be fully subtracted from the element.
*
* Note that this method is extended by the {@link module:engine/view/attributeelement~ViewAttributeElement} implementation.
*
* This method is used by the {@link module:engine/view/downcastwriter~ViewDowncastWriter} while down-casting
* an {@link module:engine/view/attributeelement~ViewAttributeElement} to unwrap the ViewAttributeElement.
*
* @internal
* @returns Returns `true` if elements attributes can be fully subtracted.
*/
_canSubtractAttributesOf(otherElement) {
if (this.name != otherElement.name) {
return false;
}
for (const [key, otherValue] of otherElement._attrs) {
const value = this._attrs.get(key);
if (value === undefined) {
return false;
}
if (typeof value == 'string' || typeof otherValue == 'string') {
if (value !== otherValue) {
return false;
}
}
else if (!value._isMatching(otherValue)) {
return false;
}
}
return true;
}
/**
* Removes (subtracts) corresponding attributes of the given element from the element.
* This includes also tokenized attributes like style and class.
* All attributes, classes and styles from given element should be present inside the element being unwrapped.
*
* Note that you should make sure all attributes could be subtracted before subtracting them (see {@link #_canSubtractAttributesOf}).
*
* This method is used by the {@link module:engine/view/downcastwriter~ViewDowncastWriter} while down-casting
* an {@link module:engine/view/attributeelement~ViewAttributeElement} to unwrap the ViewAttributeElement.
*
* @internal
*/
_subtractAttributesOf(otherElement) {
this._fireChange('attributes', this);
for (const [key, otherValue] of otherElement._attrs) {
const value = this._attrs.get(key);
if (typeof value == 'string' || typeof otherValue == 'string') {
this._attrs.delete(key);
}
else {
value.remove(otherValue.keys());
if (value.isEmpty) {
this._attrs.delete(key);
}
}
}
}
/**
* Sets a custom property. Unlike attributes, custom properties are not rendered to the DOM,
* so they can be used to add special data to elements.
*
* @see module:engine/view/downcastwriter~ViewDowncastWriter#setCustomProperty
* @internal
*/
_setCustomProperty(key, value) {
this._customProperties.set(key, value);
}
/**
* Removes the custom property stored under the given key.
*
* @see module:engine/view/downcastwriter~ViewDowncastWriter#removeCustomProperty
* @internal
* @returns Returns true if property was removed.
*/
_removeCustomProperty(key) {
return this._customProperties.delete(key);
}
/**
* Parses attributes provided to the element constructor before they are applied to an element. If attributes are passed
* as an object (instead of `Iterable`), the object is transformed to the map. Attributes with `null` value are removed.
* Attributes with non-`String` value are converted to `String`.
*
* @param attrs Attributes to parse.
* @returns Parsed attributes.
*/
_parseAttributes(attrs) {
const attrsMap = toMap(attrs);
for (const [key, value] of attrsMap) {
if (value === null) {
attrsMap.delete(key);
}
else if (usesStylesMap(this.name, key)) {
// This is either an element clone so we need to clone styles map, or a new instance which requires value to be parsed.
const newValue = value instanceof StylesMap ?
value._clone() :
new StylesMap(this.document.stylesProcessor).setTo(String(value));
attrsMap.set(key, newValue);
}
else if (usesTokenList(this.name, key)) {
// This is either an element clone so we need to clone token list, or a new instance which requires value to be parsed.
const newValue = value instanceof ViewTokenList ?
value._clone() :
new ViewTokenList().setTo(String(value));
attrsMap.set(key, newValue);
}
else if (typeof value != 'string') {
attrsMap.set(key, String(value));
}
}
return attrsMap;
}
}
// The magic of type inference using `is` method is centralized in `TypeCheckable` class.
// Proper overload would interfere with that.
ViewElement.prototype.is = function (type, name) {
if (!name) {
return type === 'element' || type === 'view:element' ||
// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
type === 'node' || type === 'view:node';
}
else {
return name === this.name && (type === 'element' || type === 'view:element');
}
};
/**
* Converts strings to Text and non-iterables to arrays.
*/
function normalize(document, nodes) {
// Separate condition because string is iterable.
if (typeof nodes == 'string') {
return [new ViewText(document, nodes)];
}
if (!isIterable(nodes)) {
nodes = [nodes];
}
const normalizedNodes = [];
for (const node of nodes) {
if (typeof node == 'string') {
normalizedNodes.push(new ViewText(document, node));
}
else if (node instanceof ViewTextProxy) {
normalizedNodes.push(new ViewText(document, node.data));
}
else {
normalizedNodes.push(node);
}
}
return normalizedNodes;
}
/**
* Returns `true` if an attribute on a given element should be handled as a TokenList.
*/
function usesTokenList(elementName, key) {
return key == 'class' || elementName == 'a' && key == 'rel';
}
/**
* Returns `true` if an attribute on a given element should be handled as a StylesMap.
*/
function usesStylesMap(elementName, key) {
return key == 'style';
}