/** * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /** * @module ui/template */ /* global document */ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; import View from './view'; import ViewCollection from './viewcollection'; import cloneDeepWith from '@ckeditor/ckeditor5-utils/src/lib/lodash/cloneDeepWith'; import isObject from '@ckeditor/ckeditor5-utils/src/lib/lodash/isObject'; import isDomNode from '@ckeditor/ckeditor5-utils/src/dom/isdomnode'; import log from '@ckeditor/ckeditor5-utils/src/log'; const xhtmlNs = 'http://www.w3.org/1999/xhtml'; /** * A basic Template class. It renders a DOM HTML element or text from a * {@link module:ui/template~TemplateDefinition definition} and supports element attributes, children, * bindings to {@link module:utils/observablemixin~Observable observables} and DOM event propagation. * * A simple template can look like this: * * const bind = Template.bind( observable, emitter ); * * new Template( { * tag: 'p', * attributes: { * class: 'foo', * style: { * backgroundColor: 'yellow' * } * }, * on: { * click: bind.to( 'clicked' ) * } * children: [ * 'A paragraph.' * ] * } ).render(); * * and it will render the following HTML element: * *

A paragraph.

* * Additionally, the `observable` will always fire `clicked` upon clicking `

` in the DOM. * * See {@link module:ui/template~TemplateDefinition} to know more about templates and complex * template definitions. * * @mixes module:utils/emittermixin~EmitterMixin */ export default class Template { /** * Creates an instance of the {@link ~Template} class. * * @param {module:ui/template~TemplateDefinition} def The definition of the template. */ constructor( def ) { Object.assign( this, normalize( clone( def ) ) ); /** * Indicates whether this particular Template instance has been * {@link #render rendered}. * * @readonly * @protected * @member {Boolean} */ this._isRendered = false; /** * The tag (`tagName`) of this template, e.g. `div`. It also indicates that the template * renders to an HTML element. * * @member {String} #tag */ /** * The text of the template. It also indicates that the template renders to a DOM text node. * * @member {Array.} #text */ /** * The attributes of the template, e.g. `{ id: [ 'ck-id' ] }`, corresponding with * the attributes of an HTML element. * * **Note**: This property only makes sense when {@link #tag} is defined. * * @member {Object} #attributes */ /** * The children of the template. They can be either: * * independent instances of {@link ~Template} (sub–templates), * * native DOM Nodes. * * **Note**: This property only makes sense when {@link #tag} is defined. * * @member {Array.} #children */ /** * The DOM event listeners of the template. * * @member {Object} #eventListeners */ /** * The data used by the {@link #revert} method to restore a node to its original state. * * See: {@link #apply}. * * @readonly * @protected * @member {module:ui/template~RenderData} */ this._revertData = null; } /** * Renders a DOM Node (an HTML element or text) out of the template. * * const domNode = new Template( { ... } ).render(); * * See: {@link #apply}. * * @returns {HTMLElement|Text} */ render() { const node = this._renderNode( { intoFragment: true } ); this._isRendered = true; return node; } /** * Applies the template to an existing DOM Node, either HTML element or text. * * **Note:** No new DOM nodes will be created. Applying extends * {@link module:ui/template~TemplateDefinition attributes} and * {@link module:ui/template~TemplateDefinition event listeners} only. * * **Note:** Existing `class` and `style` attributes are extended when a template * is applied to an HTML element, while other attributes and `textContent` are overridden. * * **Note:** The process of applying a template can be easily reverted using the * {@link module:ui/template~Template#revert} method. * * const element = document.createElement( 'div' ); * const bind = Template.bind( observable, emitter ); * * new Template( { * attrs: { * id: 'first-div', * class: bind.to( 'divClass' ) * }, * on: { * click: bind( 'elementClicked' ) // Will be fired by the observable. * } * children: [ * 'Div text.' * ] * } ).apply( element ); * * element.outerHTML == "

Div text.
" * * @see module:ui/template~Template#render * @see module:ui/template~Template#revert * @param {Node} node Root node for the template to apply. */ apply( node ) { this._revertData = getEmptyRevertData(); this._renderNode( { node, isApplying: true, revertData: this._revertData } ); return node; } /** * Reverts a template {@link module:ui/template~Template#apply applied} to a DOM node. * * @param {Node} node The root node for the template to revert. In most of the cases, it is the * same node used by {@link module:ui/template~Template#apply}. */ revert( node ) { if ( !this._revertData ) { /** * Attempting to revert a template which has not been applied yet. * * @error ui-template-revert-not-applied */ throw new CKEditorError( 'ui-template-revert-not-applied: Attempting to revert a template which has not been applied yet.' ); } this._revertTemplateFromNode( node, this._revertData ); } /** * Returns an iterator which traverses the template in search of {@link module:ui/view~View} * instances and returns them one by one. * * const viewFoo = new View(); * const viewBar = new View(); * const viewBaz = new View(); * const template = new Template( { * tag: 'div', * children: [ * viewFoo, * { * tag: 'div', * children: [ * viewBar * ] * }, * viewBaz * ] * } ); * * // Logs: viewFoo, viewBar, viewBaz * for ( const view of template.getViews() ) { * console.log( view ); * } * * @returns {Iterable.} */ * getViews() { function* search( def ) { if ( def.children ) { for ( const child of def.children ) { if ( isView( child ) ) { yield child; } else if ( isTemplate( child ) ) { yield* search( child ); } } } } yield* search( this ); } /** * An entry point to the interface which binds DOM nodes to * {@link module:utils/observablemixin~Observable observables}. * There are two types of bindings: * * * HTML element attributes or text `textContent` synchronized with attributes of an * {@link module:utils/observablemixin~Observable}. Learn more about {@link module:ui/template~BindChain#to} * and {@link module:ui/template~BindChain#if}. * * const bind = Template.bind( observable, emitter ); * * new Template( { * attrs: { * // Binds the element "class" attribute to observable#classAttribute. * class: bind.to( 'classAttribute' ) * } * } ).render(); * * * DOM events fired on HTML element propagated through * {@link module:utils/observablemixin~Observable}. Learn more about {@link module:ui/template~BindChain#to}. * * const bind = Template.bind( observable, emitter ); * * new Template( { * on: { * // Will be fired by the observable. * click: bind( 'elementClicked' ) * } * } ).render(); * * Also see {@link module:ui/view~View#bindTemplate}. * * @param {module:utils/observablemixin~Observable} observable An observable which provides boundable attributes. * @param {module:utils/emittermixin~Emitter} emitter An emitter that listens to observable attribute * changes or DOM Events (depending on the kind of the binding). Usually, a {@link module:ui/view~View} instance. * @returns {module:ui/template~BindChain} */ static bind( observable, emitter ) { return { to( eventNameOrFunctionOrAttribute, callback ) { return new TemplateToBinding( { eventNameOrFunction: eventNameOrFunctionOrAttribute, attribute: eventNameOrFunctionOrAttribute, observable, emitter, callback } ); }, if( attribute, valueIfTrue, callback ) { return new TemplateIfBinding( { observable, emitter, attribute, valueIfTrue, callback } ); } }; } /** * Extends an existing {@link module:ui/template~Template} instance with some additional content * from another {@link module:ui/template~TemplateDefinition}. * * const bind = Template.bind( observable, emitter ); * * const template = new Template( { * tag: 'p', * attributes: { * class: 'a', * data-x: bind.to( 'foo' ) * }, * children: [ * { * tag: 'span', * attributes: { * class: 'b' * }, * children: [ * 'Span' * ] * } * ] * } ); * * // Instance-level extension. * Template.extend( template, { * attributes: { * class: 'b', * data-x: bind.to( 'bar' ) * }, * children: [ * { * attributes: { * class: 'c' * } * } * ] * } ); * * // Child extension. * Template.extend( template.children[ 0 ], { * attributes: { * class: 'd' * } * } ); * * the `outerHTML` of `template.render()` is: * *

* Span *

* * @param {module:ui/template~Template} template An existing template instance to be extended. * @param {module:ui/template~TemplateDefinition} def Additional definition to be applied to a template. */ static extend( template, def ) { if ( template._isRendered ) { /** * Extending a template after rendering may not work as expected. To make sure * the {@link module:ui/template~Template.extend extending} works for an element, * make sure it happens before {@link #render} is called. * * @error template-extend-render */ log.warn( 'template-extend-render: Attempting to extend a template which has already been rendered.' ); } extendTemplate( template, normalize( clone( def ) ) ); } /** * Renders a DOM Node (either an HTML element or text) out of the template. * * @protected * @param {module:ui/template~RenderData} data Rendering data. */ _renderNode( data ) { let isInvalid; if ( data.node ) { // When applying, a definition cannot have "tag" and "text" at the same time. isInvalid = this.tag && this.text; } else { // When rendering, a definition must have either "tag" or "text": XOR( this.tag, this.text ). isInvalid = this.tag ? this.text : !this.text; } if ( isInvalid ) { /** * Node definition cannot have the "tag" and "text" properties at the same time. * Node definition must have either "tag" or "text" when rendering a new Node. * * @error ui-template-wrong-syntax */ throw new CKEditorError( 'ui-template-wrong-syntax: Node definition must have either "tag" or "text" when rendering a new Node.' ); } if ( this.text ) { return this._renderText( data ); } else { return this._renderElement( data ); } } /** * Renders an HTML element out of the template. * * @protected * @param {module:ui/template~RenderData} data Rendering data. */ _renderElement( data ) { let node = data.node; if ( !node ) { node = data.node = document.createElementNS( this.ns || xhtmlNs, this.tag ); } this._renderAttributes( data ); this._renderElementChildren( data ); this._setUpListeners( data ); return node; } /** * Renders a text node out of {@link module:ui/template~Template#text}. * * @protected * @param {module:ui/template~RenderData} data Rendering data. */ _renderText( data ) { let node = data.node; // Save the original textContent to revert it in #revert(). if ( node ) { data.revertData.text = node.textContent; } else { node = data.node = document.createTextNode( '' ); } // Check if this Text Node is bound to Observable. Cases: // // text: [ Template.bind( ... ).to( ... ) ] // // text: [ // 'foo', // Template.bind( ... ).to( ... ), // ... // ] // if ( hasTemplateBinding( this.text ) ) { this._bindToObservable( { schema: this.text, updater: getTextUpdater( node ), data } ); } // Simply set text. Cases: // // text: [ 'all', 'are', 'static' ] // // text: [ 'foo' ] // else { node.textContent = this.text.join( '' ); } return node; } /** * Renders HTML element attributes out of {@link module:ui/template~Template#attributes}. * * @protected * @param {module:ui/template~RenderData} data Rendering data. */ _renderAttributes( data ) { let attrName, attrValue, domAttrValue, attrNs; if ( !this.attributes ) { return; } const node = data.node; const revertData = data.revertData; for ( attrName in this.attributes ) { // Current attribute value in DOM. domAttrValue = node.getAttribute( attrName ); // The value to be set. attrValue = this.attributes[ attrName ]; // Save revert data. if ( revertData ) { revertData.attributes[ attrName ] = domAttrValue; } // Detect custom namespace: // // class: { // ns: 'abc', // value: Template.bind( ... ).to( ... ) // } // attrNs = ( isObject( attrValue[ 0 ] ) && attrValue[ 0 ].ns ) ? attrValue[ 0 ].ns : null; // Activate binding if one is found. Cases: // // class: [ // Template.bind( ... ).to( ... ) // ] // // class: [ // 'bar', // Template.bind( ... ).to( ... ), // 'baz' // ] // // class: { // ns: 'abc', // value: Template.bind( ... ).to( ... ) // } // if ( hasTemplateBinding( attrValue ) ) { // Normalize attributes with additional data like namespace: // // class: { // ns: 'abc', // value: [ ... ] // } // const valueToBind = attrNs ? attrValue[ 0 ].value : attrValue; // Extend the original value of attributes like "style" and "class", // don't override them. if ( revertData && shouldExtend( attrName ) ) { valueToBind.unshift( domAttrValue ); } this._bindToObservable( { schema: valueToBind, updater: getAttributeUpdater( node, attrName, attrNs ), data } ); } // Style attribute could be an Object so it needs to be parsed in a specific way. // // style: { // width: '100px', // height: Template.bind( ... ).to( ... ) // } // else if ( attrName == 'style' && typeof attrValue[ 0 ] !== 'string' ) { this._renderStyleAttribute( attrValue[ 0 ], data ); } // Otherwise simply set the static attribute: // // class: [ 'foo' ] // // class: [ 'all', 'are', 'static' ] // // class: [ // { // ns: 'abc', // value: [ 'foo' ] // } // ] // else { // Extend the original value of attributes like "style" and "class", // don't override them. if ( revertData && domAttrValue && shouldExtend( attrName ) ) { attrValue.unshift( domAttrValue ); } attrValue = attrValue // Retrieve "values" from: // // class: [ // { // ns: 'abc', // value: [ ... ] // } // ] // .map( val => val ? ( val.value || val ) : val ) // Flatten the array. .reduce( ( prev, next ) => prev.concat( next ), [] ) // Convert into string. .reduce( arrayValueReducer, '' ); if ( !isFalsy( attrValue ) ) { node.setAttributeNS( attrNs, attrName, attrValue ); } } } } /** * Renders the `style` attribute of an HTML element based on * {@link module:ui/template~Template#attributes}. * * A style attribute is an {Object} with static values: * * attributes: { * style: { * color: 'red' * } * } * * or values bound to {@link module:ui/model~Model} properties: * * attributes: { * style: { * color: bind.to( ... ) * } * } * * Note: The `style` attribute is rendered without setting the namespace. It does not seem to be * needed. * * @private * @param {Object} styles Styles located in `attributes.style` of {@link module:ui/template~TemplateDefinition}. * @param {module:ui/template~RenderData} data Rendering data. */ _renderStyleAttribute( styles, data ) { const node = data.node; for ( const styleName in styles ) { const styleValue = styles[ styleName ]; // Cases: // // style: { // color: bind.to( 'attribute' ) // } // if ( hasTemplateBinding( styleValue ) ) { this._bindToObservable( { schema: [ styleValue ], updater: getStyleUpdater( node, styleName ), data } ); } // Cases: // // style: { // color: 'red' // } // else { node.style[ styleName ] = styleValue; } } } /** * Recursively renders HTML element's children from {@link module:ui/template~Template#children}. * * @protected * @param {module:ui/template~RenderData} data Rendering data. */ _renderElementChildren( data ) { const node = data.node; const container = data.intoFragment ? document.createDocumentFragment() : node; const isApplying = data.isApplying; let childIndex = 0; for ( const child of this.children ) { if ( isViewCollection( child ) ) { if ( !isApplying ) { child.setParent( node ); // Note: ViewCollection renders its children. for ( const view of child ) { container.appendChild( view.element ); } } } else if ( isView( child ) ) { if ( !isApplying ) { if ( !child.isRendered ) { child.render(); } container.appendChild( child.element ); } } else if ( isDomNode( child ) ) { container.appendChild( child ); } else { if ( isApplying ) { const revertData = data.revertData; const childRevertData = getEmptyRevertData(); revertData.children.push( childRevertData ); child._renderNode( { node: container.childNodes[ childIndex++ ], isApplying: true, revertData: childRevertData } ); } else { container.appendChild( child.render() ); } } } if ( data.intoFragment ) { node.appendChild( container ); } } /** * Activates `on` event listeners from the {@link module:ui/template~TemplateDefinition} * on an HTML element. * * @protected * @param {module:ui/template~RenderData} data Rendering data. */ _setUpListeners( data ) { if ( !this.eventListeners ) { return; } for ( const key in this.eventListeners ) { const revertBindings = this.eventListeners[ key ].map( schemaItem => { const [ domEvtName, domSelector ] = key.split( '@' ); return schemaItem.activateDomEventListener( domEvtName, domSelector, data ); } ); if ( data.revertData ) { data.revertData.bindings.push( revertBindings ); } } } /** * For a given {@link module:ui/template~TemplateValueSchema} containing {@link module:ui/template~TemplateBinding} * activates the binding and sets its initial value. * * Note: {@link module:ui/template~TemplateValueSchema} can be for HTML element attributes or * text node `textContent`. * * @protected * @param {Object} options Binding options. * @param {module:ui/template~TemplateValueSchema} options.schema * @param {Function} options.updater A function which updates the DOM (like attribute or text). * @param {module:ui/template~RenderData} options.data Rendering data. */ _bindToObservable( { schema, updater, data } ) { const revertData = data.revertData; // Set initial values. syncValueSchemaValue( schema, updater, data ); const revertBindings = schema // Filter "falsy" (false, undefined, null, '') value schema components out. .filter( item => !isFalsy( item ) ) // Filter inactive bindings from schema, like static strings ('foo'), numbers (42), etc. .filter( item => item.observable ) // Once only the actual binding are left, let the emitter listen to observable change:attribute event. // TODO: Reduce the number of listeners attached as many bindings may listen // to the same observable attribute. .map( templateBinding => templateBinding.activateAttributeListener( schema, updater, data ) ); if ( revertData ) { revertData.bindings.push( revertBindings ); } } /** * Reverts {@link module:ui/template~RenderData#revertData template data} from a node to * return it to the original state. * * @protected * @param {HTMLElement|Text} node A node to be reverted. * @param {module:ui/template~RenderData#revertData} revertData Stores information about * what changes have been made by {@link #apply} to the node. */ _revertTemplateFromNode( node, revertData ) { for ( const binding of revertData.bindings ) { // Each binding may consist of several observable+observable#attribute. // like the following has 2: // // class: [ // 'x', // bind.to( 'foo' ), // 'y', // bind.to( 'bar' ) // ] // for ( const revertBinding of binding ) { revertBinding(); } } if ( revertData.text ) { node.textContent = revertData.text; return; } for ( const attrName in revertData.attributes ) { const attrValue = revertData.attributes[ attrName ]; // When the attribute has **not** been set before #apply(). if ( attrValue === null ) { node.removeAttribute( attrName ); } else { node.setAttribute( attrName, attrValue ); } } for ( let i = 0; i < revertData.children.length; ++i ) { this._revertTemplateFromNode( node.childNodes[ i ], revertData.children[ i ] ); } } } mix( Template, EmitterMixin ); /** * Describes a binding created by the {@link module:ui/template~Template.bind} interface. * * @protected */ export class TemplateBinding { /** * Creates an instance of the {@link module:ui/template~TemplateBinding} class. * * @param {module:ui/template~TemplateDefinition} def The definition of the binding. */ constructor( def ) { Object.assign( this, def ); /** * An observable instance of the binding. It either: * * * provides the attribute with the value, * * or passes the event when a corresponding DOM event is fired. * * @member {module:utils/observablemixin~ObservableMixin} module:ui/template~TemplateBinding#observable */ /** * An {@link module:utils/emittermixin~Emitter} used by the binding to: * * * listen to the attribute change in the {@link module:ui/template~TemplateBinding#observable}, * * or listen to the event in the DOM. * * @member {module:utils/emittermixin~EmitterMixin} module:ui/template~TemplateBinding#emitter */ /** * The name of the {@link module:ui/template~TemplateBinding#observable observed attribute}. * * @member {String} module:ui/template~TemplateBinding#attribute */ /** * A custom function to process the value of the {@link module:ui/template~TemplateBinding#attribute}. * * @member {Function} [module:ui/template~TemplateBinding#callback] */ } /** * Returns the value of the binding. It is the value of the {@link module:ui/template~TemplateBinding#attribute} in * {@link module:ui/template~TemplateBinding#observable}. The value may be processed by the * {@link module:ui/template~TemplateBinding#callback}, if such has been passed to the binding. * * @param {Node} [node] A native DOM node, passed to the custom {@link module:ui/template~TemplateBinding#callback}. * @returns {*} The value of {@link module:ui/template~TemplateBinding#attribute} in * {@link module:ui/template~TemplateBinding#observable}. */ getValue( node ) { const value = this.observable[ this.attribute ]; return this.callback ? this.callback( value, node ) : value; } /** * Activates the listener which waits for changes of the {@link module:ui/template~TemplateBinding#attribute} in * {@link module:ui/template~TemplateBinding#observable}, then updates the DOM with the aggregated * value of {@link module:ui/template~TemplateValueSchema}. * * @param {module:ui/template~TemplateValueSchema} schema A full schema to generate an attribute or text in the DOM. * @param {Function} updater A DOM updater function used to update the native DOM attribute or text. * @param {module:ui/template~RenderData} data Rendering data. * @returns {Function} A function to sever the listener binding. */ activateAttributeListener( schema, updater, data ) { const callback = () => syncValueSchemaValue( schema, updater, data ); this.emitter.listenTo( this.observable, 'change:' + this.attribute, callback ); // Allows revert of the listener. return () => { this.emitter.stopListening( this.observable, 'change:' + this.attribute, callback ); }; } } /** * Describes either: * * * a binding to an {@link module:utils/observablemixin~Observable}, * * or a native DOM event binding. * * It is created by the {@link module:ui/template~BindChain#to} method. * * @protected */ export class TemplateToBinding extends TemplateBinding { /** * Activates the listener for the native DOM event, which when fired, is propagated by * the {@link module:ui/template~TemplateBinding#emitter}. * * @param {String} domEvtName The name of the native DOM event. * @param {String} domSelector The selector in the DOM to filter delegated events. * @param {module:ui/template~RenderData} data Rendering data. * @returns {Function} A function to sever the listener binding. */ activateDomEventListener( domEvtName, domSelector, data ) { const callback = ( evt, domEvt ) => { if ( !domSelector || domEvt.target.matches( domSelector ) ) { if ( typeof this.eventNameOrFunction == 'function' ) { this.eventNameOrFunction( domEvt ); } else { this.observable.fire( this.eventNameOrFunction, domEvt ); } } }; this.emitter.listenTo( data.node, domEvtName, callback ); // Allows revert of the listener. return () => { this.emitter.stopListening( data.node, domEvtName, callback ); }; } } /** * Describes a binding to {@link module:utils/observablemixin~ObservableMixin} created by the {@link module:ui/template~BindChain#if} * method. * * @protected */ export class TemplateIfBinding extends TemplateBinding { /** * @inheritDoc */ getValue( node ) { const value = super.getValue( node ); return isFalsy( value ) ? false : ( this.valueIfTrue || true ); } /** * The value of the DOM attribute or text to be set if the {@link module:ui/template~TemplateBinding#attribute} in * {@link module:ui/template~TemplateBinding#observable} is `true`. * * @member {String} [module:ui/template~TemplateIfBinding#valueIfTrue] */ } // Checks whether given {@link module:ui/template~TemplateValueSchema} contains a // {@link module:ui/template~TemplateBinding}. // // @param {module:ui/template~TemplateValueSchema} schema // @returns {Boolean} function hasTemplateBinding( schema ) { if ( !schema ) { return false; } // Normalize attributes with additional data like namespace: // // class: { // ns: 'abc', // value: [ ... ] // } // if ( schema.value ) { schema = schema.value; } if ( Array.isArray( schema ) ) { return schema.some( hasTemplateBinding ); } else if ( schema instanceof TemplateBinding ) { return true; } return false; } // Assembles the value using {@link module:ui/template~TemplateValueSchema} and stores it in a form of // an Array. Each entry of the Array corresponds to one of {@link module:ui/template~TemplateValueSchema} // items. // // @param {module:ui/template~TemplateValueSchema} schema // @param {Node} node DOM Node updated when {@link module:utils/observablemixin~ObservableMixin} changes. // @return {Array} function getValueSchemaValue( schema, node ) { return schema.map( schemaItem => { // Process {@link module:ui/template~TemplateBinding} bindings. if ( schemaItem instanceof TemplateBinding ) { return schemaItem.getValue( node ); } // All static values like strings, numbers, and "falsy" values (false, null, undefined, '', etc.) just pass. return schemaItem; } ); } // A function executed each time the bound Observable attribute changes, which updates the DOM with a value // constructed from {@link module:ui/template~TemplateValueSchema}. // // @param {module:ui/template~TemplateValueSchema} schema // @param {Function} updater A function which updates the DOM (like attribute or text). // @param {Node} node DOM Node updated when {@link module:utils/observablemixin~ObservableMixin} changes. function syncValueSchemaValue( schema, updater, { node } ) { let value = getValueSchemaValue( schema, node ); // Check if schema is a single Template.bind.if, like: // // class: Template.bind.if( 'foo' ) // if ( schema.length == 1 && schema[ 0 ] instanceof TemplateIfBinding ) { value = value[ 0 ]; } else { value = value.reduce( arrayValueReducer, '' ); } if ( isFalsy( value ) ) { updater.remove(); } else { updater.set( value ); } } // Returns an object consisting of `set` and `remove` functions, which // can be used in the context of DOM Node to set or reset `textContent`. // @see module:ui/view~View#_bindToObservable // // @param {Node} node DOM Node to be modified. // @returns {Object} function getTextUpdater( node ) { return { set( value ) { node.textContent = value; }, remove() { node.textContent = ''; } }; } // Returns an object consisting of `set` and `remove` functions, which // can be used in the context of DOM Node to set or reset an attribute. // @see module:ui/view~View#_bindToObservable // // @param {Node} node DOM Node to be modified. // @param {String} attrName Name of the attribute to be modified. // @param {String} [ns=null] Namespace to use. // @returns {Object} function getAttributeUpdater( el, attrName, ns ) { return { set( value ) { el.setAttributeNS( ns, attrName, value ); }, remove() { el.removeAttributeNS( ns, attrName ); } }; } // Returns an object consisting of `set` and `remove` functions, which // can be used in the context of CSSStyleDeclaration to set or remove a style. // @see module:ui/view~View#_bindToObservable // // @param {Node} node DOM Node to be modified. // @param {String} styleName Name of the style to be modified. // @returns {Object} function getStyleUpdater( el, styleName ) { return { set( value ) { el.style[ styleName ] = value; }, remove() { el.style[ styleName ] = null; } }; } // Clones definition of the template. // // @param {module:ui/template~TemplateDefinition} def // @returns {module:ui/template~TemplateDefinition} function clone( def ) { const clone = cloneDeepWith( def, value => { // Don't clone the `Template.bind`* bindings because of the references to Observable // and DomEmitterMixin instances inside, which would also be traversed and cloned by greedy // cloneDeepWith algorithm. There's no point in cloning Observable/DomEmitterMixins // along with the definition. // // Don't clone Template instances if provided as a child. They're simply #render()ed // and nothing should interfere. // // Also don't clone View instances if provided as a child of the Template. The template // instance will be extracted from the View during the normalization and there's no need // to clone it. if ( value && ( value instanceof TemplateBinding || isTemplate( value ) || isView( value ) || isViewCollection( value ) ) ) { return value; } } ); return clone; } // Normalizes given {@link module:ui/template~TemplateDefinition}. // // See: // * {@link normalizeAttributes} // * {@link normalizeListeners} // * {@link normalizePlainTextDefinition} // * {@link normalizeTextDefinition} // // @param {module:ui/template~TemplateDefinition} def // @returns {module:ui/template~TemplateDefinition} Normalized definition. function normalize( def ) { if ( typeof def == 'string' ) { def = normalizePlainTextDefinition( def ); } else if ( def.text ) { normalizeTextDefinition( def ); } if ( def.on ) { def.eventListeners = normalizeListeners( def.on ); // Template mixes EmitterMixin, so delete #on to avoid collision. delete def.on; } if ( !def.text ) { if ( def.attributes ) { normalizeAttributes( def.attributes ); } const children = []; if ( def.children ) { if ( isViewCollection( def.children ) ) { children.push( def.children ); } else { for ( const child of def.children ) { if ( isTemplate( child ) || isView( child ) || isDomNode( child ) ) { children.push( child ); } else { children.push( new Template( child ) ); } } } } def.children = children; } return def; } // Normalizes "attributes" section of {@link module:ui/template~TemplateDefinition}. // // attributes: { // a: 'bar', // b: {@link module:ui/template~TemplateBinding}, // c: { // value: 'bar' // } // } // // becomes // // attributes: { // a: [ 'bar' ], // b: [ {@link module:ui/template~TemplateBinding} ], // c: { // value: [ 'bar' ] // } // } // // @param {Object} attrs function normalizeAttributes( attrs ) { for ( const a in attrs ) { if ( attrs[ a ].value ) { attrs[ a ].value = [].concat( attrs[ a ].value ); } arrayify( attrs, a ); } } // Normalizes "on" section of {@link module:ui/template~TemplateDefinition}. // // on: { // a: 'bar', // b: {@link module:ui/template~TemplateBinding}, // c: [ {@link module:ui/template~TemplateBinding}, () => { ... } ] // } // // becomes // // on: { // a: [ 'bar' ], // b: [ {@link module:ui/template~TemplateBinding} ], // c: [ {@link module:ui/template~TemplateBinding}, () => { ... } ] // } // // @param {Object} listeners // @returns {Object} Object containing normalized listeners. function normalizeListeners( listeners ) { for ( const l in listeners ) { arrayify( listeners, l ); } return listeners; } // Normalizes "string" {@link module:ui/template~TemplateDefinition}. // // "foo" // // becomes // // { text: [ 'foo' ] }, // // @param {String} def // @returns {module:ui/template~TemplateDefinition} Normalized template definition. function normalizePlainTextDefinition( def ) { return { text: [ def ] }; } // Normalizes text {@link module:ui/template~TemplateDefinition}. // // children: [ // { text: 'def' }, // { text: {@link module:ui/template~TemplateBinding} } // ] // // becomes // // children: [ // { text: [ 'def' ] }, // { text: [ {@link module:ui/template~TemplateBinding} ] } // ] // // @param {module:ui/template~TemplateDefinition} def function normalizeTextDefinition( def ) { if ( !Array.isArray( def.text ) ) { def.text = [ def.text ]; } } // Wraps an entry in Object in an Array, if not already one. // // { // x: 'y', // a: [ 'b' ] // } // // becomes // // { // x: [ 'y' ], // a: [ 'b' ] // } // // @param {Object} obj // @param {String} key function arrayify( obj, key ) { if ( !Array.isArray( obj[ key ] ) ) { obj[ key ] = [ obj[ key ] ]; } } // A helper which concatenates the value avoiding unwanted // leading white spaces. // // @param {String} prev // @param {String} cur // @returns {String} function arrayValueReducer( prev, cur ) { if ( isFalsy( cur ) ) { return prev; } else if ( isFalsy( prev ) ) { return cur; } else { return `${ prev } ${ cur }`; } } // Extends one object defined in the following format: // // { // key1: [Array1], // key2: [Array2], // ... // keyN: [ArrayN] // } // // with another object of the same data format. // // @param {Object} obj Base object. // @param {Object} ext Object extending base. // @returns {String} function extendObjectValueArray( obj, ext ) { for ( const a in ext ) { if ( obj[ a ] ) { obj[ a ].push( ...ext[ a ] ); } else { obj[ a ] = ext[ a ]; } } } // A helper for {@link module:ui/template~Template#extend}. Recursively extends {@link module:ui/template~Template} instance // with content from {module:ui/template~TemplateDefinition}. See {@link module:ui/template~Template#extend} to learn more. // // @param {module:ui/template~Template} def A template instance to be extended. // @param {module:ui/template~TemplateDefinition} def A definition which is to extend the template instance. function extendTemplate( template, def ) { if ( def.attributes ) { if ( !template.attributes ) { template.attributes = {}; } extendObjectValueArray( template.attributes, def.attributes ); } if ( def.eventListeners ) { if ( !template.eventListeners ) { template.eventListeners = {}; } extendObjectValueArray( template.eventListeners, def.eventListeners ); } if ( def.text ) { template.text.push( ...def.text ); } if ( def.children && def.children.length ) { if ( template.children.length != def.children.length ) { /** * The number of children in extended definition does not match. * * @error ui-template-extend-children-mismatch */ throw new CKEditorError( 'ui-template-extend-children-mismatch: The number of children in extended definition does not match.' ); } let childIndex = 0; for ( const childDef of def.children ) { extendTemplate( template.children[ childIndex++ ], childDef ); } } } // Checks if value is "falsy". // Note: 0 (Number) is not "falsy" in this context. // // @private // @param {*} value Value to be checked. function isFalsy( value ) { return !value && value !== 0; } // Checks if the item is an instance of {@link module:ui/view~View} // // @private // @param {*} value Value to be checked. function isView( item ) { return item instanceof View; } // Checks if the item is an instance of {@link module:ui/template~Template} // // @private // @param {*} value Value to be checked. function isTemplate( item ) { return item instanceof Template; } // Checks if the item is an instance of {@link module:ui/viewcollection~ViewCollection} // // @private // @param {*} value Value to be checked. function isViewCollection( item ) { return item instanceof ViewCollection; } // Creates an empty skeleton for {@link module:ui/template~Template#revert} // data. // // @private function getEmptyRevertData() { return { children: [], bindings: [], attributes: {} }; } // Checks whether an attribute should be extended when // {@link module:ui/template~Template#apply} is called. // // @private // @param {String} attrName Attribute name to check. function shouldExtend( attrName ) { return attrName == 'class' || attrName == 'style'; } /** * A definition of the {@link module:ui/template~Template}. It describes what kind of * node a template will render (HTML element or text), attributes of an element, DOM event * listeners and children. * * Also see: * * {@link module:ui/template~TemplateValueSchema} to learn about HTML element attributes, * * {@link module:ui/template~TemplateListenerSchema} to learn about DOM event listeners. * * A sample definition on an HTML element can look like this: * * new Template( { * tag: 'p', * children: [ * { * tag: 'span', * attributes: { ... }, * children: [ ... ], * }, * { * text: 'static–text' * }, * 'also-static–text', * ], * attributes: { * class: {@link module:ui/template~TemplateValueSchema}, * id: {@link module:ui/template~TemplateValueSchema}, * style: {@link module:ui/template~TemplateValueSchema} * * // ... * }, * on: { * 'click': {@link module:ui/template~TemplateListenerSchema} * * // Document.querySelector format is also accepted. * 'keyup@a.some-class': {@link module:ui/template~TemplateListenerSchema} * * // ... * } * } ); * * A {@link module:ui/view~View}, another {@link module:ui/template~Template} or a native DOM node * can also become a child of a template. When a view is passed, its {@link module:ui/view~View#element} is used: * * const view = new SomeView(); * const childTemplate = new Template( { ... } ); * const childNode = document.createElement( 'b' ); * * new Template( { * tag: 'p', * * children: [ * // view#element will be added as a child of this

. * view, * * // The output of childTemplate.render() will be added here. * childTemplate, * * // Native DOM nodes are included directly in the rendered output. * childNode * ] * } ); * * An entire {@link module:ui/viewcollection~ViewCollection} can be used as a child in the definition: * * const collection = new ViewCollection(); * collection.add( someView ); * * new Template( { * tag: 'p', * * children: collection * } ); * * @typedef module:ui/template~TemplateDefinition * @type Object * * @property {String} tag See the template {@link module:ui/template~Template#tag} property. * * @property {Array.} [children] * See the template {@link module:ui/template~Template#children} property. * * @property {Object.} [attributes] * See the template {@link module:ui/template~Template#attributes} property. * * @property {String|module:ui/template~TemplateValueSchema|Array.} [text] * See the template {@link module:ui/template~Template#text} property. * * @property {Object.} [on] * See the template {@link module:ui/template~Template#eventListeners} property. */ /** * Describes a value of an HTML element attribute or `textContent`. It allows combining multiple * data sources like static values and {@link module:utils/observablemixin~Observable} attributes. * * Also see: * * {@link module:ui/template~TemplateDefinition} to learn where to use it, * * {@link module:ui/template~Template.bind} to learn how to configure * {@link module:utils/observablemixin~Observable} attribute bindings, * * {@link module:ui/template~Template#render} to learn how to render a template, * * {@link module:ui/template~BindChain#to `to()`} and {@link module:ui/template~BindChain#if `if()`} * methods to learn more about bindings. * * Attribute values can be described in many different ways: * * // Bind helper will create bindings to attributes of the observable. * const bind = Template.bind( observable, emitter ); * * new Template( { * tag: 'p', * attributes: { * // A plain string schema. * 'class': 'static-text', * * // An object schema, binds to the "foo" attribute of the * // observable and follows its value. * 'class': bind.to( 'foo' ), * * // An array schema, combines the above. * 'class': [ * 'static-text', * bind.to( 'bar', () => { ... } ), * * // Bindings can also be conditional. * bind.if( 'baz', 'class-when-baz-is-true' ) * ], * * // An array schema, with a custom namespace, e.g. useful for creating SVGs. * 'class': { * ns: 'http://ns.url', * value: [ * bind.if( 'baz', 'value-when-true' ), * 'static-text' * ] * }, * * // An object schema, specific for styles. * style: { * color: 'red', * backgroundColor: bind.to( 'qux', () => { ... } ) * } * } * } ); * * Text nodes can also have complex values: * * const bind = Template.bind( observable, emitter ); * * // Will render a "foo" text node. * new Template( { * text: 'foo' * } ); * * // Will render a "static text: {observable.foo}" text node. * // The text of the node will be updated as the "foo" attribute changes. * new Template( { * text: [ * 'static text: ', * bind.to( 'foo', () => { ... } ) * ] * } ); * * @typedef module:ui/template~TemplateValueSchema * @type {Object|String|Array} */ /** * Describes an event listener attached to an HTML element. Such listener can propagate DOM events * through an {@link module:utils/observablemixin~Observable} instance, execute custom callbacks * or both, if necessary. * * Also see: * * {@link module:ui/template~TemplateDefinition} to learn more about template definitions, * * {@link module:ui/template~BindChain#to `to()`} method to learn more about bindings. * * Check out different ways of attaching event listeners below: * * // Bind helper will propagate events through the observable. * const bind = Template.bind( observable, emitter ); * * new Template( { * tag: 'p', * on: { * // An object schema. The observable will fire the "clicked" event upon DOM "click". * click: bind.to( 'clicked' ) * * // An object schema. It will work for "click" event on "a.foo" children only. * 'click@a.foo': bind.to( 'clicked' ) * * // An array schema, makes the observable propagate multiple events. * click: [ * bind.to( 'clicked' ), * bind.to( 'executed' ) * ], * * // An array schema with a custom callback. * 'click@a.foo': { * bind.to( 'clicked' ), * bind.to( evt => { * console.log( `${ evt.target } has been clicked!` ); * } } * } * } * } ); * * @typedef module:ui/template~TemplateListenerSchema * @type {Object|String|Array} */ /** * The return value of {@link ~Template.bind `Template.bind()`}. It provides `to()` and `if()` * methods to create the {@link module:utils/observablemixin~Observable observable} attribute and event bindings. * * @interface module:ui/template~BindChain */ /** * Binds an {@link module:utils/observablemixin~Observable observable} to either: * * * an HTML element attribute or a text node `textContent`, so it remains in sync with the observable * attribute as it changes, * * or an HTML element DOM event, so the DOM events are propagated through an observable. * * Some common use cases of `to()` bindings are presented below: * * const bind = Template.bind( observable, emitter ); * * new Template( { * tag: 'p', * attributes: { * // class="..." attribute gets bound to `observable#a` * class: bind.to( 'a' ) * }, * children: [ * //

...

gets bound to observable#b; always `toUpperCase()`. * { * text: bind.to( 'b', ( value, node ) => value.toUpperCase() ) * } * ], * on: { * click: [ * // An observable will fire "clicked" upon "click" in the DOM. * bind.to( 'clicked' ), * * // A custom callback will be executed upon "click" in the DOM. * bind.to( () => { * ... * } ) * ] * } * } ).render(); * * Learn more about using `to()` in the {@link module:ui/template~TemplateValueSchema} and * {@link module:ui/template~TemplateListenerSchema}. * * @method #to * @param {String|Function} eventNameOrFunctionOrAttribute An attribute name of * {@link module:utils/observablemixin~Observable} or a DOM event name or an event callback. * @param {Function} [callback] Allows for processing of the value. Accepts `Node` and `value` as arguments. * @return {module:ui/template~TemplateBinding} */ /** * Binds an {@link module:utils/observablemixin~Observable observable} to an HTML element attribute or a text * node `textContent` so it remains in sync with the observable attribute as it changes. * * Unlike {@link module:ui/template~BindChain#to}, it controls the presence of the attribute or `textContent` * depending on the "falseness" of an {@link module:utils/observablemixin~Observable} attribute. * * const bind = Template.bind( observable, emitter ); * * new Template( { * tag: 'input', * attributes: { * // when `observable#a` is not undefined/null/false/'' * // when `observable#a` is undefined/null/false * checked: bind.if( 'a' ) * }, * children: [ * { * // "b-is-not-set" when `observable#b` is undefined/null/false/'' * // when `observable#b` is not "falsy" * text: bind.if( 'b', 'b-is-not-set', ( value, node ) => !value ) * } * ] * } ).render(); * * Learn more about using `if()` in the {@link module:ui/template~TemplateValueSchema}. * * @method #if * @param {String} attribute An attribute name of {@link module:utils/observablemixin~Observable} used in the binding. * @param {String} [valueIfTrue] Value set when the {@link module:utils/observablemixin~Observable} attribute is not * undefined/null/false/'' (empty string). * @param {Function} [callback] Allows for processing of the value. Accepts `Node` and `value` as arguments. * @return {module:ui/template~TemplateBinding} */ /** * The {@link module:ui/template~Template#_renderNode} configuration. * * @private * @interface module:ui/template~RenderData */ /** * Tells {@link module:ui/template~Template#_renderNode} to render * children into `DocumentFragment` first and then append the fragment * to the parent element. It is a speed optimization. * * @member {Boolean} #intoFragment */ /** * A node which is being rendered. * * @member {HTMLElement|Text} #node */ /** * Indicates whether the {@module:ui/template~RenderNodeOptions#node} has * been provided by {@module:ui/template~Template#apply}. * * @member {Boolean} #isApplying */ /** * An object storing the data that helps {@module:ui/template~Template#revert} * bringing back an element to its initial state, i.e. before * {@module:ui/template~Template#apply} was called. * * @member {Object} #revertData */