/** * @license Copyright (c) 2003-2017, 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 Collection from '@ckeditor/ckeditor5-utils/src/collection'; 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 log from '@ckeditor/ckeditor5-utils/src/log'; const xhtmlNs = 'http://www.w3.org/1999/xhtml'; /** * A basic Template class. It renders DOM HTMLElement or Text from {@link module:ui/template~TemplateDefinition} and supports * element attributes, children, bindings to {@link module:utils/observablemixin~ObservableMixin} instances and DOM events * propagation. For example: * * new Template( { * tag: 'p', * attributes: { * class: 'foo', * style: { * backgroundColor: 'yellow' * } * }, * children: [ * 'A paragraph.' * ] * } ).render(); * * will render the following HTMLElement: * *
A paragraph.
* * 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; /** * Tag of this template, i.e. `div`, indicating that the instance will render * to an HTMLElement. * * @member {String} #tag */ /** * Text of this template, indicating that the instance will render to a DOM Text. * * @member {Array.* Span *
* * @param {module:ui/template~Template} template Existing Template instance to be extended. * @param {module:ui/template~TemplateDefinition} def An extension to existing an template instance. */ static extend( template, def ) { if ( template._isRendered ) { /** * Extending a template after rendering may not work as expected. To make sure * the {@link #extend extending} works for the rendered element, perform it * 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 `HTMLElement` 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 "tag" and "text" properties at the same time. * Node definition must have either "tag" or "text" when rendering new Node. * * @error ui-template-wrong-syntax */ throw new CKEditorError( 'ui-template-wrong-syntax: Node definition must have either "tag" or "text" when rendering new Node.' ); } if ( this.text ) { return this._renderText( data ); } else { return this._renderElement( data ); } } /** * Renders an `HTMLElement` 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 an `HTMLElement` 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 `style` attribute of an `HTMLElement` based on {@link module:ui/template~Template#attributes}. * * 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: `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 ( let 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 `HTMLElement` 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 ( let child of this.children ) { if ( isViewCollection( child ) ) { if ( !isApplying ) { child.setParent( node ); for ( let view of child ) { container.appendChild( view.element ); } } } else if ( isView( child ) ) { if ( !isApplying ) { container.appendChild( child.element ); } } 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` listeners in the {@link module:ui/template~TemplateDefinition} * on a passed `HTMLElement`. * * @protected * @param {module:ui/template~RenderData} data Rendering data. */ _setUpListeners( data ) { if ( !this.eventListeners ) { return; } for ( let 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 given {@link module:ui/template~TemplateValueSchema} containing {@link module:ui/template~TemplateBinding} it activates the * binding and sets its initial value. * * Note: {@link module:ui/template~TemplateValueSchema} can be for HTMLElement 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 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 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 ( let 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 ( let revertBinding of binding ) { revertBinding(); } } if ( revertData.text ) { node.textContent = revertData.text; return; } for ( let 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 {@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 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~EmitterMixin} instance used by the binding * to (either): * * * listen to the attribute change in the {@link module:ui/template~TemplateBinding#observable}, * * listen to the event in the DOM. * * @member {module:utils/emittermixin~EmitterMixin} module:ui/template~TemplateBinding#emitter */ /** * The name of the attribute of {@link module:ui/template~TemplateBinding#observable} which is observed. * * @member {String} module:ui/template~TemplateBinding#attribute */ /** * A custom function to process the value of {@link module:ui/template~TemplateBinding#attribute}. * * @member {Function} [module:ui/template~TemplateBinding#callback] */ } /** * Returns the value of the binding, which is the value of {@link module:ui/template~TemplateBinding#attribute} in * {@link module:ui/template~TemplateBinding#observable}. * * @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 for the changes of {@link module:ui/template~TemplateBinding#attribute} in * {@link module:ui/template~TemplateBinding#observable}, which then updates the DOM with the aggregated * value of {@link module:ui/template~TemplateValueSchema}. * * For instance, the `class` attribute of the `Template` element can be be bound to * the observable `foo` attribute in `ObservableMixin` instance. * * @param {module:ui/template~TemplateValueSchema} schema A full schema to generate an attribute or text in DOM. * @param {Function} updater A DOM updater function used to update 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 {@link module:utils/observablemixin~ObservableMixin} * * or a native DOM event binding * * created by {@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 A name of the native DOM event. * @param {String} domSelector A selector in 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 {@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/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 an 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 bound Observable attribute changes, which updates DOM with a value // constructed from {@link module:ui/template~TemplateValueSchema}. // // @param {module:ui/template~TemplateValueSchema} schema // @param {Function} updater A function which updates 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 = new Collection(); if ( def.children ) { if ( isViewCollection( def.children ) ) { children.add( def.children ); } else { for ( let child of def.children ) { if ( isTemplate( child ) || isView( child ) ) { children.add( child ); } else { children.add( 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 ( let 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 ( let 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 ( let 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 ( let childDef of def.children ) { extendTemplate( template.children.get( 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 {@link module:ui/template~Template}. * See: {@link module:ui/template~TemplateValueSchema}. * * new Template( { * tag: 'p', * children: [ * { * tag: 'span', * attributes: { ... }, * children: [ ... ], * ... * }, * { * text: 'static–text' * }, * 'also-static–text', * <{@link module:ui/view~View} instance> * <{@link module:ui/template~Template} instance> * ... * ], * 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} * 'keyup@.some-class': {@link module:ui/template~TemplateListenerSchema}, * ... * } * } ); * * // An entire view collection can be used as a child in the definition. * new Template( { * tag: 'p', * children: <{@link module:ui/viewcollection~ViewCollection} instance> * } ); * * @typedef module:ui/template~TemplateDefinition * @type Object * @property {String} tag * @property {Array....
gets bound to `observableInstance#b`; always `toUpperCase()`. * { text: bind.to( 'b', ( value, node ) => value.toUpperCase() ) } * ], * on: { * click: [ * // "clicked" event will be fired on `observableInstance` when "click" fires in DOM. * bind.to( 'clicked' ), * * // A custom callback function will be executed when "click" fires in DOM. * bind.to( () => { * ... * } ) * ] * } * } ).render(); * * const bind = Template.bind( observableInstance, emitterInstance ); * * new Template( { * tag: 'p', * } ).render(); * * @method #to * @param {String|Function} eventNameOrFunctionOrAttribute An attribute name of * {@link module:utils/observablemixin~ObservableMixin} or a DOM event name or an event callback. * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments. * @return {module:ui/template~TemplateBinding} */ /** * Binds {@link module:utils/observablemixin~ObservableMixin} to HTMLElement attribute or Text Node `textContent` * so remains in sync with the Model when it changes. Unlike {@link module:ui/template~BindChain#to}, * it controls the presence of the attribute/`textContent` depending on the "falseness" of * {@link module:utils/observablemixin~ObservableMixin} attribute. * * const bind = Template.bind( observableInstance, emitterInstance ); * * new Template( { * tag: 'input', * attributes: { * // when `observableInstance#a` is not undefined/null/false/'' * // when `observableInstance#a` is undefined/null/false * checked: bind.if( 'a' ) * }, * children: [ * { * // "b-is-not-set" when `observableInstance#b` is undefined/null/false/'' * // when `observableInstance#b` is not "falsy" * text: bind.if( 'b', 'b-is-not-set', ( value, node ) => !value ) * } * ] * } ).render(); * * @method #if * @param {String} attribute An attribute name of {@link module:utils/observablemixin~ObservableMixin} used in the binding. * @param {String} [valueIfTrue] Value set when {@link module:utils/observablemixin~ObservableMixin} attribute is not * undefined/null/false/''. * @param {Function} [callback] Allows 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's 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 */