template.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /* global document */
  6. 'use strict';
  7. import CKEditorError from '../utils/ckeditorerror.js';
  8. import mix from '../utils/mix.js';
  9. import EmitterMixin from '../utils/emittermixin.js';
  10. import cloneDeepWith from '../utils/lib/lodash/cloneDeepWith.js';
  11. const bindToSymbol = Symbol( 'bindTo' );
  12. const bindIfSymbol = Symbol( 'bindIf' );
  13. /**
  14. * A basic Template class. It renders DOM HTMLElements from {@link ui.TemplateDefinition} and supports
  15. * element attributes, children, bindings to {@link utils.ObservableMixin} instances and DOM events
  16. * propagation. For example:
  17. *
  18. * new Template( {
  19. * tag: 'p',
  20. * attributes: {
  21. * class: 'foo',
  22. * style: {
  23. * backgroundColor: 'yellow'
  24. * }
  25. * },
  26. * children: [
  27. * 'A paragraph.'
  28. * ]
  29. * } ).render();
  30. *
  31. * will render the following HTMLElement:
  32. *
  33. * <p class="foo" style="background-color: yellow;">A paragraph.</p>
  34. *
  35. * See {@link ui.TemplateDefinition} to know more about templates and see complex examples.
  36. *
  37. * @memberOf ui
  38. */
  39. export default class Template {
  40. /**
  41. * Creates an instance of the {@link ui.Template} class.
  42. *
  43. * @param {ui.TemplateDefinition} def The definition of the template.
  44. */
  45. constructor( def ) {
  46. const defClone = clone( def );
  47. normalize( defClone );
  48. /**
  49. * Definition of this template.
  50. *
  51. * @readonly
  52. * @member {ui.TemplateDefinition} ui.Template#definition
  53. */
  54. this.definition = defClone;
  55. }
  56. /**
  57. * Renders DOM Node using {@link ui.Template#definition}.
  58. *
  59. * @see ui.Template#apply
  60. *
  61. * @returns {HTMLElement}
  62. */
  63. render() {
  64. return this._renderNode( this.definition, undefined, true );
  65. }
  66. /**
  67. * Applies template {@link ui.Template#def} to existing DOM tree.
  68. *
  69. * **Note:** No new DOM nodes (elements, text nodes) will be created.
  70. * const element = document.createElement( 'div' );
  71. * const bind = Template.bind( observableInstance, emitterInstance );
  72. *
  73. * const template = new Template( {
  74. * attrs: {
  75. * id: 'first-div',
  76. * class: bind.to( 'divClass' )
  77. * },
  78. * on: {
  79. * click: bind( 'elementClicked' ) // Will be fired by the observableInstance.
  80. * }
  81. * children: [
  82. * 'Div text.'
  83. * ]
  84. * } );
  85. *
  86. * template.apply( element );
  87. *
  88. * element.outerHTML == "<div id="first-div" class="my-div">Div text.</div>"
  89. *
  90. * @see ui.Template#render
  91. * @param {Node} element Root element for template to apply.
  92. */
  93. apply( node ) {
  94. if ( !node ) {
  95. /**
  96. * No DOM Node specified.
  97. *
  98. * @error ui-template-wrong-syntax
  99. */
  100. throw new CKEditorError( 'ui-template-wrong-node: No DOM Node specified.' );
  101. }
  102. return this._renderNode( this.definition, node );
  103. }
  104. /**
  105. * An entry point to the interface which allows binding DOM nodes to {@link utils.ObservableMixin}.
  106. * There are two types of bindings:
  107. *
  108. * * `HTMLElement` attributes or Text Node `textContent` can be synchronized with {@link utils.ObservableMixin}
  109. * instance attributes. See {@link ui.Template.bind#to} and {@link ui.Template.bind#if}.
  110. *
  111. * * DOM events fired on `HTMLElement` can be propagated through {@link utils.ObservableMixin}.
  112. * See {@link ui.Template.bind#to}.
  113. *
  114. * @param {utils.ObservableMixin} observable An instance of ObservableMixin class.
  115. * @param {utils.EmitterMixin} emitter An instance of `EmitterMixin` class. It listens
  116. * to `observable` attribute changes and DOM Events, depending on the binding. Usually {@link ui.View} instance.
  117. * @returns {Object}
  118. */
  119. static bind( observable, emitter ) {
  120. return {
  121. /**
  122. * Binds {@link utils.ObservableMixin} instance to:
  123. * * HTMLElement attribute or Text Node `textContent` so remains in sync with the Observable when it changes:
  124. * * HTMLElement DOM event, so the DOM events are propagated through Observable.
  125. *
  126. * const bind = Template.bind( observableInstance, emitterInstance );
  127. *
  128. * new Template( {
  129. * tag: 'p',
  130. * attributes: {
  131. * // class="..." attribute gets bound to `observableInstance#a`
  132. * 'class': bind.to( 'a' )
  133. * },
  134. * children: [
  135. * // <p>...</p> gets bound to `observableInstance#b`; always `toUpperCase()`.
  136. * { text: bind.to( 'b', ( value, node ) => value.toUpperCase() ) }
  137. * ],
  138. * on: {
  139. * click: [
  140. * // "clicked" event will be fired on `observableInstance` when "click" fires in DOM.
  141. * bind( 'clicked' ),
  142. *
  143. * // A custom callback function will be executed when "click" fires in DOM.
  144. * bind( () => {
  145. * ...
  146. * } )
  147. * ]
  148. * }
  149. * } ).render();
  150. *
  151. * const bind = Template.bind( observableInstance, emitterInstance );
  152. *
  153. * new Template( {
  154. * tag: 'p',
  155. * } ).render();
  156. *
  157. * @static
  158. * @method ui.Template.bind#to
  159. * @param {String} attribute Name of {@link utils.ObservableMixin} used in the binding.
  160. * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
  161. * @return {ui.TemplateBinding}
  162. */
  163. to( eventNameOrFuncionOrAttribute, callback ) {
  164. return {
  165. type: bindToSymbol,
  166. eventNameOrFunction: eventNameOrFuncionOrAttribute,
  167. attribute: eventNameOrFuncionOrAttribute,
  168. observable, emitter, callback
  169. };
  170. },
  171. /**
  172. * Binds {@link utils.ObservableMixin} to HTMLElement attribute or Text Node `textContent`
  173. * so remains in sync with the Model when it changes. Unlike {@link ui.Template.bind#to},
  174. * it controls the presence of the attribute/`textContent` depending on the "falseness" of
  175. * {@link utils.ObservableMixin} attribute.
  176. *
  177. * const bind = Template.bind( observableInstance, emitterInstance );
  178. *
  179. * new Template( {
  180. * tag: 'input',
  181. * attributes: {
  182. * // <input checked> when `observableInstance#a` is not undefined/null/false/''
  183. * // <input> when `observableInstance#a` is undefined/null/false
  184. * checked: bind.if( 'a' )
  185. * },
  186. * children: [
  187. * {
  188. * // <input>"b-is-not-set"</input> when `observableInstance#b` is undefined/null/false/''
  189. * // <input></input> when `observableInstance#b` is not "falsy"
  190. * text: bind.if( 'b', 'b-is-not-set', ( value, node ) => !value )
  191. * }
  192. * ]
  193. * } ).render();
  194. *
  195. * @static
  196. * @method ui.Template.bind#if
  197. * @param {String} attribute An attribute name of {@link utils.ObservableMixin} used in the binding.
  198. * @param {String} [valueIfTrue] Value set when {@link utils.ObservableMixin} attribute is not undefined/null/false/''.
  199. * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
  200. * @return {ui.TemplateBinding}
  201. */
  202. if( attribute, valueIfTrue, callback ) {
  203. return {
  204. type: bindIfSymbol,
  205. observable, emitter, attribute, valueIfTrue, callback
  206. };
  207. }
  208. };
  209. }
  210. /**
  211. * Extends {@link ui.Template} or {@link ui.TemplateDefinition} with additional content.
  212. *
  213. * const bind = Template.bind( observable, emitterInstance );
  214. * const instance = new Template( {
  215. * tag: 'p',
  216. * attributes: {
  217. * class: 'a',
  218. * data-x: bind.to( 'foo' )
  219. * },
  220. * children: [
  221. * {
  222. * tag: 'span',
  223. * attributes: {
  224. * class: 'b'
  225. * },
  226. * children: [
  227. * 'Span'
  228. * ]
  229. * }
  230. * ]
  231. * } );
  232. *
  233. * // Instance-level extension.
  234. * Template.extend( instance, {
  235. * attributes: {
  236. * class: 'b',
  237. * data-x: bind.to( 'bar' )
  238. * },
  239. * children: [
  240. * {
  241. * attributes: {
  242. * class: 'c'
  243. * }
  244. * }
  245. * ]
  246. * } );
  247. *
  248. * // Fragment extension.
  249. * Template.extend( instance.definition.children[ 0 ], {
  250. * attributes: {
  251. * class: 'd'
  252. * }
  253. * } );
  254. *
  255. * the `instance.render().outerHTML` is
  256. *
  257. * <p class="a b" data-x="{ observable.foo } { observable.bar }">
  258. * <span class="b c d">Span</span>
  259. * </p>
  260. *
  261. * @param {ui.Template|ui.TemplateDefinition} instanceOrDef Existing Template instance or definition to be extended.
  262. * @param {ui.TemplateDefinition} extDef An extension to existing instance or definition.
  263. */
  264. static extend( instanceOrDef, extDef ) {
  265. const extDefClone = clone( extDef );
  266. normalize( extDefClone );
  267. if ( instanceOrDef instanceof Template ) {
  268. extendTemplateDefinition( instanceOrDef.definition, extDefClone );
  269. }
  270. // Extend a particular child in existing template instance.
  271. //
  272. // Template.extend( instance.definition.children[ 0 ], {
  273. // attributes: {
  274. // class: 'd'
  275. // }
  276. // } );
  277. //
  278. else {
  279. extendTemplateDefinition( instanceOrDef, extDefClone );
  280. }
  281. }
  282. /**
  283. * Renders a DOM Node from definition.
  284. *
  285. * @protected
  286. * @param {ui.TemplateDefinition} def Definition of a Node.
  287. * @param {Node} applyNode If specified, template `def` will be applied to existing DOM Node.
  288. * @param {Boolean} intoFragment If set, children are rendered into DocumentFragment.
  289. * @returns {HTMLElement} A rendered Node.
  290. */
  291. _renderNode( def, applyNode, intoFragment ) {
  292. let isInvalid;
  293. if ( applyNode ) {
  294. // When applying, a definition cannot have "tag" and "text" at the same time.
  295. isInvalid = def.tag && def.text;
  296. } else {
  297. // When rendering, a definition must have either "tag" or "text": XOR( def.tag, def.text ).
  298. isInvalid = def.tag ? def.text : !def.text;
  299. }
  300. if ( isInvalid ) {
  301. /**
  302. * Node definition cannot have "tag" and "text" properties at the same time.
  303. * Node definition must have either "tag" or "text" when rendering new Node.
  304. *
  305. * @error ui-template-wrong-syntax
  306. */
  307. throw new CKEditorError( 'ui-template-wrong-syntax: Node definition must have either "tag" or "text" when rendering new Node.' );
  308. }
  309. return def.text ?
  310. this._renderText( def, applyNode ) : this._renderElement( def, applyNode, intoFragment );
  311. }
  312. /**
  313. * Renders an HTMLElement from TemplateDefinition.
  314. *
  315. * @protected
  316. * @param {ui.TemplateDefinition} def Definition of an element.
  317. * @param {HTMLElement} applyElement If specified, template `def` will be applied to existing HTMLElement.
  318. * @param {Boolean} intoFragment If set, children are rendered into DocumentFragment.
  319. * @returns {HTMLElement} A rendered element.
  320. */
  321. _renderElement( def, applyElement, intoFragment ) {
  322. const el = applyElement ||
  323. document.createElementNS( def.ns || 'http://www.w3.org/1999/xhtml', def.tag );
  324. this._renderElementAttributes( def, el );
  325. // Invoke children recursively.
  326. if ( intoFragment ) {
  327. const docFragment = document.createDocumentFragment();
  328. this._renderElementChildren( def, docFragment );
  329. el.appendChild( docFragment );
  330. } else {
  331. this._renderElementChildren( def, el, !!applyElement );
  332. }
  333. // Setup DOM bindings event listeners.
  334. this._setUpListeners( def, el );
  335. return el;
  336. }
  337. /**
  338. * Renders a Text from TemplateDefinition or String.
  339. *
  340. * @protected
  341. * @param {TemplateDefinition|String} def Definition of Text or its value.
  342. * @param {HTMLElement} textNode If specified, template `def` will be applied to existing Text Node.
  343. * @returns {Text} A rendered Text.
  344. */
  345. _renderText( valueSchemaOrText, textNode = document.createTextNode( '' ) ) {
  346. // Check if this Text Node is bound to Observable. Cases:
  347. // { text: [ Template.bind( ... ).to( ... ) ] }
  348. // { text: [ 'foo', Template.bind( ... ).to( ... ), ... ] }
  349. if ( hasBinding( valueSchemaOrText.text ) ) {
  350. this._bindToObservable( valueSchemaOrText.text, textNode, getTextUpdater( textNode ) );
  351. }
  352. // Simply set text. Cases:
  353. // { text: [ 'all', 'are', 'static' ] }
  354. // { text: [ 'foo' ] }
  355. else {
  356. textNode.textContent = valueSchemaOrText.text.join( '' );
  357. }
  358. return textNode;
  359. }
  360. /**
  361. * Renders element attributes from definition.
  362. *
  363. * @protected
  364. * @param {ui.TemplateDefinition} def Definition of an element.
  365. * @param {HTMLElement} el Element which is rendered.
  366. */
  367. _renderElementAttributes( { attributes }, el ) {
  368. let attrName, attrValue, attrNs;
  369. if ( !attributes ) {
  370. return;
  371. }
  372. for ( attrName in attributes ) {
  373. attrValue = attributes[ attrName ];
  374. attrNs = attrValue[ 0 ].ns || null;
  375. // Activate binding if one is found. Cases:
  376. // { class: [ Template.bind( ... ).to( ... ) ] }
  377. // { class: [ 'bar', Template.bind( ... ).to( ... ), 'baz' ] }
  378. // { class: { ns: 'abc', value: Template.bind( ... ).to( ... ) } }
  379. if ( hasBinding( attrValue ) ) {
  380. // Normalize attributes with additional data like namespace:
  381. // { class: { ns: 'abc', value: [ ... ] } }
  382. this._bindToObservable(
  383. attrValue[ 0 ].value || attrValue,
  384. el,
  385. getAttributeUpdater( el, attrName, attrNs )
  386. );
  387. }
  388. // Style attribute could be an Object so it needs to be parsed in a specific way.
  389. // style: {
  390. // width: '100px',
  391. // height: Template.bind( ... ).to( ... )
  392. // }
  393. else if ( attrName == 'style' ) {
  394. this._renderStyleAttribute( attrValue[ 0 ].value || attrValue[ 0 ], el );
  395. }
  396. // Otherwise simply set the static attribute.
  397. // { class: [ 'foo' ] }
  398. // { class: [ 'all', 'are', 'static' ] }
  399. // { class: [ { ns: 'abc', value: [ 'foo' ] } ] }
  400. else {
  401. attrValue = attrValue
  402. // Retrieve "values" from { class: [ { ns: 'abc', value: [ ... ] } ] }
  403. .map( v => v ? ( v.value || v ) : v )
  404. // Flatten the array.
  405. .reduce( ( p, n ) => p.concat( n ), [] )
  406. // Convert into string.
  407. .reduce( arrayValueReducer );
  408. el.setAttributeNS( attrNs, attrName, attrValue );
  409. }
  410. }
  411. }
  412. /**
  413. * Renders `style` attribute.
  414. *
  415. * Style attribute is an {Object} with static values:
  416. *
  417. * attributes: {
  418. * style: {
  419. * color: 'red'
  420. * }
  421. * }
  422. *
  423. * or values bound to {@link ui.Model} properties:
  424. *
  425. * attributes: {
  426. * style: {
  427. * color: bind.to( ... )
  428. * }
  429. * }
  430. *
  431. * Note: `style` attribute is rendered without setting namespace. It does not seem to be
  432. * needed.
  433. *
  434. * @private
  435. * @param {ui.TemplateDefinition.attributes.styles} styles Styles definition.
  436. * @param {HTMLElement} el Element which is rendered.
  437. */
  438. _renderStyleAttribute( styles, el ) {
  439. for ( let styleName in styles ) {
  440. const styleValue = styles[ styleName ];
  441. // style: {
  442. // color: bind.to( 'attribute' )
  443. // }
  444. if ( hasBinding( styleValue ) ) {
  445. this._bindToObservable( [ styleValue ], el, getStyleUpdater( el, styleName ) );
  446. }
  447. // style: {
  448. // color: 'red'
  449. // }
  450. else {
  451. el.style[ styleName ] = styleValue;
  452. }
  453. }
  454. }
  455. /**
  456. * Recursively renders element children from definition by
  457. * calling {@link ui.Template#_renderElement}.
  458. *
  459. * @protected
  460. * @param {ui.TemplateDefinition} def Definition of an element.
  461. * @param {HTMLElement} el Element which is rendered.
  462. * @param {Boolean} isApply Traverse existing DOM structure only, don't modify DOM.
  463. */
  464. _renderElementChildren( def, el, isApply ) {
  465. if ( def.children ) {
  466. def.children.forEach( ( childDef, index ) => {
  467. if ( isApply ) {
  468. this._renderNode( childDef, el.childNodes[ index ] );
  469. } else {
  470. el.appendChild( this._renderNode( childDef ) );
  471. }
  472. } );
  473. }
  474. }
  475. /**
  476. * Activates element `on` listeners passed in element definition.
  477. *
  478. * @protected
  479. * @param {ui.TemplateDefinition} def Definition of an element.
  480. * @param {HTMLElement} el Element which is being rendered.
  481. */
  482. _setUpListeners( def, el ) {
  483. if ( !def.on ) {
  484. return;
  485. }
  486. for ( let key in def.on ) {
  487. const [ domEvtName, domSelector ] = key.split( '@' );
  488. for ( let schemaItem of def.on[ key ] ) {
  489. schemaItem.emitter.listenTo( el, domEvtName, ( evt, domEvt ) => {
  490. if ( !domSelector || domEvt.target.matches( domSelector ) ) {
  491. if ( typeof schemaItem.eventNameOrFunction == 'function' ) {
  492. schemaItem.eventNameOrFunction( domEvt );
  493. } else {
  494. schemaItem.observable.fire( schemaItem.eventNameOrFunction, domEvt );
  495. }
  496. }
  497. } );
  498. }
  499. }
  500. }
  501. /**
  502. * For given {@link ui.TemplateValueSchema} containing {@link ui.TemplateBinding} it activates the
  503. * binding and sets its initial value.
  504. *
  505. * Note: {@link ui.TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
  506. *
  507. * @protected
  508. * @param {ui.TemplateValueSchema} valueSchema
  509. * @param {Node} node DOM Node to be updated when {@link utils.ObservableMixin} changes.
  510. * @param {Function} domUpdater A function which updates DOM (like attribute or text).
  511. */
  512. _bindToObservable( valueSchema ) {
  513. valueSchema
  514. // Filter inactive bindings from schema, like static strings, etc.
  515. .filter( item => item.observable )
  516. // Let the emitter listen to observable change:attribute event.
  517. // TODO: Reduce the number of listeners attached as many bindings may listen
  518. // to the same observable attribute.
  519. .forEach( ( { emitter, observable, attribute } ) => {
  520. emitter.listenTo( observable, 'change:' + attribute, () => {
  521. syncBinding( ...arguments );
  522. } );
  523. } );
  524. // Set initial values.
  525. syncBinding( ...arguments );
  526. }
  527. }
  528. mix( Template, EmitterMixin );
  529. // Checks whether given {@link ui.TemplateValueSchema} contains a
  530. // {@link ui.TemplateBinding}.
  531. //
  532. // @param {ui.TemplateValueSchema} valueSchema
  533. // @returns {Boolean}
  534. function hasBinding( valueSchema ) {
  535. if ( !valueSchema ) {
  536. return false;
  537. }
  538. // Normalize attributes with additional data like namespace:
  539. // class: { ns: 'abc', value: [ ... ] }
  540. if ( valueSchema.value ) {
  541. valueSchema = valueSchema.value;
  542. }
  543. if ( Array.isArray( valueSchema ) ) {
  544. return valueSchema.some( hasBinding );
  545. } else if ( valueSchema.observable ) {
  546. return true;
  547. }
  548. return false;
  549. }
  550. // Assembles the value using {@link ui.TemplateValueSchema} and stores it in a form of
  551. // an Array. Each entry of an Array corresponds to one of {@link ui.TemplateValueSchema}
  552. // items.
  553. //
  554. // @param {ui.TemplateValueSchema} valueSchema
  555. // @param {Node} node DOM Node updated when {@link utils.ObservableMixin} changes.
  556. // @return {Array}
  557. function getBindingValue( valueSchema, domNode ) {
  558. return valueSchema.map( schemaItem => {
  559. let { observable, callback, type } = schemaItem;
  560. if ( observable ) {
  561. let modelValue = observable[ schemaItem.attribute ];
  562. // Process the value with the callback.
  563. if ( callback ) {
  564. modelValue = callback( modelValue, domNode );
  565. }
  566. if ( type === bindIfSymbol ) {
  567. return !!modelValue ? schemaItem.valueIfTrue || true : '';
  568. } else {
  569. return modelValue;
  570. }
  571. } else {
  572. return schemaItem;
  573. }
  574. } );
  575. }
  576. // A function executed each time bound Observable attribute changes, which updates DOM with a value
  577. // constructed from {@link ui.TemplateValueSchema}.
  578. //
  579. // @param {ui.TemplateValueSchema} valueSchema
  580. // @param {Node} node DOM Node updated when {@link utils.ObservableMixin} changes.
  581. // @param {Function} domUpdater A function which updates DOM (like attribute or text).
  582. function syncBinding( valueSchema, domNode, domUpdater ) {
  583. let value = getBindingValue( valueSchema, domNode );
  584. let shouldSet;
  585. // Check if valueSchema is a single Template.bind.if, like:
  586. // { class: Template.bind.if( 'foo' ) }
  587. if ( valueSchema.length == 1 && valueSchema[ 0 ].type == bindIfSymbol ) {
  588. value = value[ 0 ];
  589. shouldSet = value !== '';
  590. if ( shouldSet ) {
  591. value = value === true ? '' : value;
  592. }
  593. } else {
  594. value = value.reduce( arrayValueReducer, '' );
  595. shouldSet = value;
  596. }
  597. if ( shouldSet ) {
  598. domUpdater.set( value );
  599. } else {
  600. domUpdater.remove();
  601. }
  602. }
  603. // Returns an object consisting of `set` and `remove` functions, which
  604. // can be used in the context of DOM Node to set or reset `textContent`.
  605. // @see ui.View#_bindToObservable
  606. //
  607. // @param {Node} node DOM Node to be modified.
  608. // @returns {Object}
  609. function getTextUpdater( node ) {
  610. return {
  611. set( value ) {
  612. node.textContent = value;
  613. },
  614. remove() {
  615. node.textContent = '';
  616. }
  617. };
  618. }
  619. // Returns an object consisting of `set` and `remove` functions, which
  620. // can be used in the context of DOM Node to set or reset an attribute.
  621. // @see ui.View#_bindToObservable
  622. //
  623. // @param {Node} node DOM Node to be modified.
  624. // @param {String} attrName Name of the attribute to be modified.
  625. // @param {String} [ns=null] Namespace to use.
  626. // @returns {Object}
  627. function getAttributeUpdater( el, attrName, ns = null ) {
  628. return {
  629. set( value ) {
  630. el.setAttributeNS( ns, attrName, value );
  631. },
  632. remove() {
  633. el.removeAttributeNS( ns, attrName );
  634. }
  635. };
  636. }
  637. // Returns an object consisting of `set` and `remove` functions, which
  638. // can be used in the context of CSSStyleDeclaration to set or remove a style.
  639. // @see ui.View#_bindToObservable
  640. //
  641. // @param {Node} node DOM Node to be modified.
  642. // @param {String} styleName Name of the style to be modified.
  643. // @returns {Object}
  644. function getStyleUpdater( el, styleName ) {
  645. return {
  646. set( value ) {
  647. el.style[ styleName ] = value;
  648. },
  649. remove() {
  650. el.style[ styleName ] = null;
  651. }
  652. };
  653. }
  654. // Clones definition of the template.
  655. //
  656. // @param {ui.TemplateDefinition} def
  657. // @returns {ui.TemplateDefinition}
  658. function clone( def ) {
  659. const clone = cloneDeepWith( def, value => {
  660. // Don't clone Template.bind* bindings because there are references
  661. // to Observable and DOMEmitterMixin instances inside, which are external
  662. // to the Template.
  663. if ( value && value.type ) {
  664. return value;
  665. }
  666. } );
  667. return clone;
  668. }
  669. // Normalizes given {@link ui.TemplateDefinition}.
  670. //
  671. // See:
  672. // * {@link normalizeAttributes}
  673. // * {@link normalizeListeners}
  674. // * {@link normalizeTextString}
  675. // * {@link normalizeTextDefinition}
  676. //
  677. // @param {ui.TemplateDefinition} def
  678. function normalize( def ) {
  679. if ( def.text ) {
  680. normalizeTextDefinition( def );
  681. }
  682. if ( def.attributes ) {
  683. normalizeAttributes( def.attributes );
  684. }
  685. if ( def.on ) {
  686. normalizeListeners( def.on );
  687. }
  688. if ( def.children ) {
  689. // Splicing children array inside so no forEach.
  690. for ( let i = def.children.length; i--; ) {
  691. normalizeTextString( def.children, def.children[ i ], i );
  692. normalize( def.children[ i ] );
  693. }
  694. }
  695. }
  696. // Normalizes "attributes" section of {@link ui.TemplateDefinition}.
  697. //
  698. // attributes: {
  699. // a: 'bar',
  700. // b: {@link ui.TemplateBinding},
  701. // c: {
  702. // value: 'bar'
  703. // }
  704. // }
  705. //
  706. // becomes
  707. //
  708. // attributes: {
  709. // a: [ 'bar' ],
  710. // b: [ {@link ui.TemplateBinding} ],
  711. // c: {
  712. // value: [ 'bar' ]
  713. // }
  714. // }
  715. //
  716. // @param {Object} attrs
  717. function normalizeAttributes( attrs ) {
  718. for ( let a in attrs ) {
  719. if ( attrs[ a ].value ) {
  720. attrs[ a ].value = [].concat( attrs[ a ].value );
  721. }
  722. arrayify( attrs, a );
  723. }
  724. }
  725. // Normalizes "on" section of {@link ui.TemplateDefinition}.
  726. //
  727. // on: {
  728. // a: 'bar',
  729. // b: {@link ui.TemplateBinding},
  730. // c: [ {@link ui.TemplateBinding}, () => { ... } ]
  731. // }
  732. //
  733. // becomes
  734. //
  735. // on: {
  736. // a: [ 'bar' ],
  737. // b: [ {@link ui.TemplateBinding} ],
  738. // c: [ {@link ui.TemplateBinding}, () => { ... } ]
  739. // }
  740. //
  741. // @param {Object} listeners
  742. function normalizeListeners( listeners ) {
  743. for ( let l in listeners ) {
  744. arrayify( listeners, l );
  745. }
  746. }
  747. // Normalizes "string" text in "children" section of {@link ui.TemplateDefinition}.
  748. //
  749. // children: [
  750. // 'abc',
  751. // ]
  752. //
  753. // becomes
  754. //
  755. // children: [
  756. // { text: [ 'abc' ] },
  757. // ]
  758. //
  759. // @param {Array} children
  760. // @param {ui.TemplateDefinition} child
  761. // @param {Number} index
  762. function normalizeTextString( children, child, index ) {
  763. if ( typeof child == 'string' ) {
  764. children.splice( index, 1, {
  765. text: [ child ]
  766. } );
  767. }
  768. }
  769. // Normalizes text {@link ui.TemplateDefinition}.
  770. //
  771. // children: [
  772. // { text: 'def' },
  773. // { text: {@link ui.TemplateBinding} }
  774. // ]
  775. //
  776. // becomes
  777. //
  778. // children: [
  779. // { text: [ 'def' ] },
  780. // { text: [ {@link ui.TemplateBinding} ] }
  781. // ]
  782. //
  783. // @param {ui.TemplateDefinition} def
  784. function normalizeTextDefinition( def ) {
  785. if ( !Array.isArray( def.text ) ) {
  786. def.text = [ def.text ];
  787. }
  788. }
  789. // Wraps an entry in Object in an Array, if not already one.
  790. //
  791. // {
  792. // x: 'y',
  793. // a: [ 'b' ]
  794. // }
  795. //
  796. // becomes
  797. //
  798. // {
  799. // x: [ 'y' ],
  800. // a: [ 'b' ]
  801. // }
  802. //
  803. // @param {Object} obj
  804. // @param {String} key
  805. function arrayify( obj, key ) {
  806. if ( !Array.isArray( obj[ key ] ) ) {
  807. obj[ key ] = [ obj[ key ] ];
  808. }
  809. }
  810. // A helper which concatenates the value avoiding unwanted
  811. // leading white spaces.
  812. //
  813. // @param {String} prev
  814. // @param {String} cur
  815. // @returns {String}
  816. function arrayValueReducer( prev, cur ) {
  817. return prev === '' ?
  818. `${cur}`
  819. :
  820. cur === '' ? `${prev}` : `${prev} ${cur}`;
  821. }
  822. // Extends one object defined in the following format:
  823. //
  824. // {
  825. // key1: [Array1],
  826. // key2: [Array2],
  827. // ...
  828. // keyN: [ArrayN]
  829. // }
  830. //
  831. // with another object of the same data format.
  832. //
  833. // @param {Object} obj Base object.
  834. // @param {Object} ext Object extending base.
  835. // @returns {String}
  836. function extendObjectValueArray( obj, ext ) {
  837. for ( let a in ext ) {
  838. if ( obj[ a ] ) {
  839. obj[ a ].push( ...ext[ a ] );
  840. } else {
  841. obj[ a ] = ext[ a ];
  842. }
  843. }
  844. }
  845. // A helper for {@link ui.Template#extend}. Recursively extends {@link ui.Template#definition}
  846. // with content from another definition. See {@link ui.Template#extend} to learn more.
  847. //
  848. // @param {ui.TemplateDefinition} def A base template definition.
  849. // @param {ui.TemplateDefinition} extDef An extension to existing definition.
  850. function extendTemplateDefinition( def, extDef ) {
  851. if ( extDef.attributes ) {
  852. if ( !def.attributes ) {
  853. def.attributes = {};
  854. }
  855. extendObjectValueArray( def.attributes, extDef.attributes );
  856. }
  857. if ( extDef.on ) {
  858. if ( !def.on ) {
  859. def.on = {};
  860. }
  861. extendObjectValueArray( def.on, extDef.on );
  862. }
  863. if ( extDef.text ) {
  864. def.text.push( ...extDef.text );
  865. }
  866. if ( extDef.children ) {
  867. if ( !def.children || def.children.length != extDef.children.length ) {
  868. /**
  869. * The number of children in extended definition does not match.
  870. *
  871. * @error ui-template-extend-children-mismatch
  872. */
  873. throw new CKEditorError( 'ui-template-extend-children-mismatch: The number of children in extended definition does not match.' );
  874. }
  875. extDef.children.forEach( ( extChildDef, index ) => {
  876. extendTemplateDefinition( def.children[ index ], extChildDef );
  877. } );
  878. }
  879. }
  880. /**
  881. * A definition of {@link ui.Template}.
  882. * See: {@link ui.TemplateValueSchema}.
  883. *
  884. * new Template( {
  885. * tag: 'p',
  886. * children: [
  887. * {
  888. * tag: 'span',
  889. * attributes: { ... },
  890. * children: [ ... ],
  891. * ...
  892. * },
  893. * {
  894. * text: 'static–text'
  895. * },
  896. * 'also-static–text',
  897. * ...
  898. * ],
  899. * attributes: {
  900. * class: {@link ui.TemplateValueSchema},
  901. * id: {@link ui.TemplateValueSchema},
  902. * ...
  903. * },
  904. * on: {
  905. * 'click': {@link ui.TemplateListenerSchema}
  906. * 'keyup@.some-class': {@link ui.TemplateListenerSchema},
  907. * ...
  908. * }
  909. * } );
  910. *
  911. * @typedef ui.TemplateDefinition
  912. * @type Object
  913. * @property {String} tag
  914. * @property {Array.<ui.TemplateDefinition>} [children]
  915. * @property {Object.<String,ui.TemplateValueSchema>} [attributes]
  916. * @property {String|ui.TemplateValueSchema} [text]
  917. * @property {Object.<String,ui.TemplateListenerSchema>} [on]
  918. */
  919. /**
  920. * Describes a value of HTMLElement attribute or `textContent`. See:
  921. * * {@link ui.TemplateDefinition},
  922. * * {@link ui.Template#bind},
  923. *
  924. * const bind = Template.bind( observableInstance, emitterInstance );
  925. *
  926. * new Template( {
  927. * tag: 'p',
  928. * attributes: {
  929. * // Plain String schema.
  930. * class: 'static-text'
  931. *
  932. * // Object schema, an `ObservableMixin` binding.
  933. * class: bind.to( 'foo' )
  934. *
  935. * // Array schema, combines the above.
  936. * class: [
  937. * 'static-text',
  938. * bind.to( 'bar', () => { ... } )
  939. * ],
  940. *
  941. * // Array schema, with custom namespace.
  942. * class: {
  943. * ns: 'http://ns.url',
  944. * value: [
  945. * bind.if( 'baz', 'value-when-true' )
  946. * 'static-text'
  947. * ]
  948. * }
  949. * }
  950. * } );
  951. *
  952. * @typedef ui.TemplateValueSchema
  953. * @type {Object|String|Array}
  954. */
  955. /**
  956. * Describes a listener attached to HTMLElement. See: {@link ui.TemplateDefinition}.
  957. *
  958. * new Template( {
  959. * tag: 'p',
  960. * on: {
  961. * // Plain String schema.
  962. * click: 'clicked'
  963. *
  964. * // Object schema, an `ObservableMixin` binding.
  965. * click: {@link ui.TemplateBinding}
  966. *
  967. * // Array schema, combines the above.
  968. * click: [
  969. * 'clicked',
  970. * {@link ui.TemplateBinding}
  971. * ],
  972. *
  973. * // Array schema, with custom callback.
  974. * // Note: It will work for "click" event on class=".foo" children only.
  975. * 'click@.foo': {
  976. * 'clicked',
  977. * {@link ui.TemplateBinding},
  978. * () => { ... }
  979. * }
  980. * }
  981. * } );
  982. *
  983. * @typedef ui.TemplateListenerSchema
  984. * @type {Object|String|Array}
  985. */
  986. /**
  987. * Describes Model binding created via {@link ui.Template#bind}.
  988. *
  989. * @typedef ui.TemplateBinding
  990. * @type Object
  991. * @property {utils.ObservableMixin} observable
  992. * @property {utils.EmitterMixin} emitter
  993. * @property {Symbol} type
  994. * @property {String} attribute
  995. * @property {String} [valueIfTrue]
  996. * @property {Function} [callback]
  997. */