/** * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ import Node from './node.js'; import NodeList from './nodelist.js'; import Text from './text.js'; import isIterable from '../../utils/isiterable.js'; /** * Model element. Type of {@link engine.model.Node node} that has a {@link engine.model.Element#name name} and * {@link engine.model.Element#getChildren child nodes}. * * **Important**: see {@link engine.model.Node} to read about restrictions using `Element` and `Node` API. * * @memberOf engine.model */ export default class Element extends Node { /** * Creates a model element. * * @param {String} name Element's name. * @param {Object} [attrs] Element's attributes. See {@link utils.toMap} for a list of accepted values. * @param {engine.model.Node|Iterable.} [children] One or more nodes to be inserted as children of * created element. */ constructor( name, attrs, children ) { super( attrs ); /** * Element name. * * @member {String} engine.model.Element#name */ this.name = name; /** * List of children nodes. * * @private * @member {engine.model.NodeList} engine.model.Element#_children */ this._children = new NodeList(); if ( children ) { this.insertChildren( 0, children ); } } /** * Number of this element's children. * * @readonly * @type {Number} */ get childCount() { return this._children.length; } /** * Sum of {engine.model.Node#offsetSize offset sizes} of all of this element's children. * * @readonly * @type {Number} */ get maxOffset() { return this._children.maxOffset; } /** * Is `true` if there are no nodes inside this element, `false` otherwise. * * @readonly * @type {Boolean} */ get isEmpty() { return this.childCount === 0; } /** * Gets the child at the given index. * * @param {Number} index Index of child. * @returns {engine.model.Node} Child node. */ getChild( index ) { return this._children.getNode( index ); } /** * Returns an iterator that iterates over all of this element's children. * * @returns {Iterable.} */ getChildren() { return this._children[ Symbol.iterator ](); } /** * Returns an index of the given child node. Returns `null` if given node is not a child of this element. * * @param {engine.model.Node} node Child node to look for. * @returns {Number} Child node's index in this element. */ getChildIndex( node ) { return this._children.getNodeIndex( node ); } /** * Returns the starting offset of given child. Starting offset is equal to the sum of * {engine.model.Node#offsetSize offset sizes} of all node's siblings that are before it. Returns `null` if * given node is not a child of this element. * * @param {engine.model.Node} node Child node to look for. * @returns {Number} Child node's starting offset. */ getChildStartOffset( node ) { return this._children.getNodeStartOffset( node ); } /** * Creates a copy of this element and returns it. Created element has same name and attributes as original element. * If clone is not deep, children of copied element are references to the same nodes as in original element. * If clone is deep, original element's children are also cloned. * * @param {Boolean} [deep=false] Decides whether children of this element should also be cloned (`true`) or not (`false`). */ clone( deep = false ) { const children = deep ? Array.from( this._children ).map( ( node ) => node.clone() ) : Array.from( this._children ); return new Element( this.name, this.getAttributes(), children ); } /** * Returns index of a node that occupies given offset. If given offset is too low, returns `0`. If given offset is * too high, returns {@link engine.model.Element#getChildCount index after last child}. * * const textNode = new Text( 'foo' ); * const pElement = new Element( 'p' ); * const divElement = new Element( [ textNode, pElement ] ); * divElement.offsetToIndex( -1 ); // Returns 0, because offset is too low. * divElement.offsetToIndex( 0 ); // Returns 0, because offset 0 is taken by `textNode` which is at index 0. * divElement.offsetToIndex( 1 ); // Returns 0, because `textNode` has `offsetSize` equal to 3, so it occupies offset 1 too. * divElement.offsetToIndex( 2 ); // Returns 0. * divElement.offsetToIndex( 3 ); // Returns 1. * divElement.offsetToIndex( 4 ); // Returns 2. There are no nodes at offset 4, so last available index is returned. * * @param {Number} offset Offset to look for. * @returns {Number} */ offsetToIndex( offset ) { return this._children.offsetToIndex( offset ); } /** * {@link engine.model.Element#insertChildren Inserts} one or more nodes at the end of this element. * * @param {engine.model.Node|Iterable.} nodes Nodes to be inserted. */ appendChildren( nodes ) { this.insertChildren( this.childCount, nodes ); } /** * Inserts one or more nodes at the given index and sets {@link engine.model.Node#parent parent} of these nodes * to this element. * * @param {Number} index Index at which nodes should be inserted. * @param {engine.model.Node|Iterable.} nodes Nodes to be inserted. */ insertChildren( index, nodes ) { nodes = normalize( nodes ); for ( let node of nodes ) { node.parent = this; } this._children.insertNodes( index, nodes ); } /** * Removes one or more nodes starting at the given index and sets {@link engine.model.Node#parent parent} of these nodes to `null`. * * @param {Number} index Index of the first node to remove. * @param {Number} [howMany=1] Number of nodes to remove. * @returns {Array.} Array containing removed nodes. */ removeChildren( index, howMany = 1 ) { const nodes = this._children.removeNodes( index, howMany ); for ( let node of nodes ) { node.parent = null; } return nodes; } /** * Returns a descendant node by its path relative to this element. * * // ac * this.getNodeByPath( [ 0 ] ); // -> "a" * this.getNodeByPath( [ 1 ] ); // -> * this.getNodeByPath( [ 1, 0 ] ); // -> "c" * * @param {Array.} relativePath Path of the node to find, relative to this element. * @returns {engine.model.Node} */ getNodeByPath( relativePath ) { let node = this; for ( const index of relativePath ) { node = node.getChild( index ); } return node; } /** * Converts `Element` instance to plain object and returns it. Takes care of converting all of this element's children. * * @returns {Object} `Element` instance converted to plain object. */ toJSON() { let json = super.toJSON(); json.name = this.name; if ( this._children.length > 0 ) { json.children = []; for ( let node of this._children ) { json.children.push( node.toJSON() ); } } return json; } /** * Creates an `Element` instance from given plain object (i.e. parsed JSON string). * Converts `Element` children to proper nodes. * * @param {Object} json Plain object to be converted to `Element`. * @returns {engine.model.Element} `Element` instance created using given plain object. */ static fromJSON( json ) { let children = null; if ( json.children ) { children = []; for ( let child of json.children ) { if ( child.name ) { // If child has name property, it is an Element. children.push( Element.fromJSON( child ) ); } else { // Otherwise, it is a Text node. children.push( Text.fromJSON( child ) ); } } } return new Element( json.name, json.attributes, children ); } } // Converts strings to Text and non-iterables to arrays. // // @param {String|engine.model.Node|Iterable.} // @return {Iterable.} function normalize( nodes ) { // Separate condition because string is iterable. if ( typeof nodes == 'string' ) { return [ new Text( nodes ) ]; } if ( !isIterable( nodes ) ) { nodes = [ nodes ]; } // Array.from to enable .map() on non-arrays. return Array.from( nodes ).map( ( node ) => typeof node == 'string' ? new Text( node ) : node ); }