templateManager.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. import { Observable, IFileRequest, Tools } from 'babylonjs';
  2. import { isUrl, camelToKebab, kebabToCamel } from './helper';
  3. import * as deepmerge from '../assets/deepmerge.min.js';
  4. /**
  5. * A single template configuration object
  6. */
  7. export interface ITemplateConfiguration {
  8. /**
  9. * can be either the id of the template's html element or a URL.
  10. * See - http://doc.babylonjs.com/extensions/the_templating_system#location-vs-html
  11. */
  12. location?: string; // #template-id OR http://example.com/loading.html
  13. /**
  14. * If no location is provided you can provide here the raw html of this template.
  15. * See http://doc.babylonjs.com/extensions/the_templating_system#location-vs-html
  16. */
  17. html?: string; // raw html string
  18. id?: string;
  19. /**
  20. * Parameters that will be delivered to the template and will render it accordingly.
  21. */
  22. params?: { [key: string]: string | number | boolean | object };
  23. /**
  24. * Events to attach to this template.
  25. * event name is the key. the value can either be a boolean (attach to the parent element)
  26. * or a map of html id elements.
  27. *
  28. * See - http://doc.babylonjs.com/extensions/the_templating_system#event-binding
  29. */
  30. events?: {
  31. // pointer events
  32. pointerdown?: boolean | { [id: string]: boolean; };
  33. pointerup?: boolean | { [id: string]: boolean; };
  34. pointermove?: boolean | { [id: string]: boolean; };
  35. pointerover?: boolean | { [id: string]: boolean; };
  36. pointerout?: boolean | { [id: string]: boolean; };
  37. pointerenter?: boolean | { [id: string]: boolean; };
  38. pointerleave?: boolean | { [id: string]: boolean; };
  39. pointercancel?: boolean | { [id: string]: boolean; };
  40. //click, just in case
  41. click?: boolean | { [id: string]: boolean; };
  42. // drag and drop
  43. dragstart?: boolean | { [id: string]: boolean; };
  44. drop?: boolean | { [id: string]: boolean; };
  45. [key: string]: boolean | { [id: string]: boolean; } | undefined;
  46. }
  47. }
  48. /**
  49. * The object sent when an event is triggered
  50. */
  51. export interface EventCallback {
  52. event: Event;
  53. template: Template;
  54. selector: string;
  55. payload?: any;
  56. }
  57. /**
  58. * The template manager, a member of the viewer class, will manage the viewer's templates and generate the HTML.
  59. * The template manager managers a single viewer and can be seen as the collection of all sub-templates of the viewer.
  60. */
  61. export class TemplateManager {
  62. /**
  63. * Will be triggered when any template is initialized
  64. */
  65. public onTemplateInit: Observable<Template>;
  66. /**
  67. * Will be triggered when any template is fully loaded
  68. */
  69. public onTemplateLoaded: Observable<Template>;
  70. /**
  71. * Will be triggered when a template state changes
  72. */
  73. public onTemplateStateChange: Observable<Template>;
  74. /**
  75. * Will be triggered when all templates finished loading
  76. */
  77. public onAllLoaded: Observable<TemplateManager>;
  78. /**
  79. * Will be triggered when any event on any template is triggered.
  80. */
  81. public onEventTriggered: Observable<EventCallback>;
  82. /**
  83. * This template manager's event manager. In charge of callback registrations to native event types
  84. */
  85. public eventManager: EventManager;
  86. private templates: { [name: string]: Template };
  87. constructor(public containerElement: HTMLElement) {
  88. this.templates = {};
  89. this.onTemplateInit = new Observable<Template>();
  90. this.onTemplateLoaded = new Observable<Template>();
  91. this.onTemplateStateChange = new Observable<Template>();
  92. this.onAllLoaded = new Observable<TemplateManager>();
  93. this.onEventTriggered = new Observable<EventCallback>();
  94. this.eventManager = new EventManager(this);
  95. }
  96. /**
  97. * Initialize the template(s) for the viewer. Called bay the Viewer class
  98. * @param templates the templates to be used to initialize the main template
  99. */
  100. public initTemplate(templates: { [key: string]: ITemplateConfiguration }) {
  101. let internalInit = (dependencyMap, name: string, parentTemplate?: Template) => {
  102. //init template
  103. let template = this.templates[name];
  104. let childrenTemplates = Object.keys(dependencyMap).map(childName => {
  105. return internalInit(dependencyMap[childName], childName, template);
  106. });
  107. // register the observers
  108. //template.onLoaded.add(() => {
  109. let addToParent = () => {
  110. let containingElement = parentTemplate && parentTemplate.parent.querySelector(camelToKebab(name)) || this.containerElement;
  111. template.appendTo(containingElement);
  112. this._checkLoadedState();
  113. }
  114. if (parentTemplate && !parentTemplate.parent) {
  115. parentTemplate.onAppended.add(() => {
  116. addToParent();
  117. });
  118. } else {
  119. addToParent();
  120. }
  121. //});
  122. return template;
  123. }
  124. //build the html tree
  125. return this._buildHTMLTree(templates).then(htmlTree => {
  126. if (this.templates['main']) {
  127. internalInit(htmlTree, 'main');
  128. } else {
  129. this._checkLoadedState();
  130. }
  131. return;
  132. });
  133. }
  134. /**
  135. *
  136. * This function will create a simple map with child-dependencies of the template html tree.
  137. * It will compile each template, check if its children exist in the configuration and will add them if they do.
  138. * It is expected that the main template will be called main!
  139. *
  140. * @param templates
  141. */
  142. private _buildHTMLTree(templates: { [key: string]: ITemplateConfiguration }): Promise<object> {
  143. let promises: Array<Promise<Template | boolean>> = Object.keys(templates).map(name => {
  144. // if the template was overridden
  145. if (!templates[name]) return Promise.resolve(false);
  146. // else - we have a template, let's do our job!
  147. let template = new Template(name, templates[name]);
  148. template.onLoaded.add(() => {
  149. this.onTemplateLoaded.notifyObservers(template);
  150. });
  151. template.onStateChange.add(() => {
  152. this.onTemplateStateChange.notifyObservers(template);
  153. });
  154. this.onTemplateInit.notifyObservers(template);
  155. // make sure the global onEventTriggered is called as well
  156. template.onEventTriggered.add(eventData => this.onEventTriggered.notifyObservers(eventData));
  157. this.templates[name] = template;
  158. return template.initPromise;
  159. });
  160. return Promise.all(promises).then(() => {
  161. let templateStructure = {};
  162. // now iterate through all templates and check for children:
  163. let buildTree = (parentObject, name) => {
  164. this.templates[name].isInHtmlTree = true;
  165. let childNodes = this.templates[name].getChildElements().filter(n => !!this.templates[n]);
  166. childNodes.forEach(element => {
  167. parentObject[element] = {};
  168. buildTree(parentObject[element], element);
  169. });
  170. }
  171. if (this.templates['main']) {
  172. buildTree(templateStructure, "main");
  173. }
  174. return templateStructure;
  175. });
  176. }
  177. /**
  178. * Get the canvas in the template tree.
  179. * There must be one and only one canvas inthe template.
  180. */
  181. public getCanvas(): HTMLCanvasElement | null {
  182. return this.containerElement.querySelector('canvas');
  183. }
  184. /**
  185. * Get a specific template from the template tree
  186. * @param name the name of the template to load
  187. */
  188. public getTemplate(name: string): Template | undefined {
  189. return this.templates[name];
  190. }
  191. private _checkLoadedState() {
  192. let done = Object.keys(this.templates).length === 0 || Object.keys(this.templates).every((key) => {
  193. return (this.templates[key].isLoaded && !!this.templates[key].parent) || !this.templates[key].isInHtmlTree;
  194. });
  195. if (done) {
  196. this.onAllLoaded.notifyObservers(this);
  197. }
  198. }
  199. /**
  200. * Dispose the template manager
  201. */
  202. public dispose() {
  203. // dispose all templates
  204. Object.keys(this.templates).forEach(template => {
  205. this.templates[template].dispose();
  206. });
  207. this.templates = {};
  208. this.eventManager.dispose();
  209. this.onTemplateInit.clear();
  210. this.onAllLoaded.clear();
  211. this.onEventTriggered.clear();
  212. this.onTemplateLoaded.clear();
  213. this.onTemplateStateChange.clear();
  214. }
  215. }
  216. import * as Handlebars from '../assets/handlebars.min.js';
  217. import { EventManager } from './eventManager';
  218. // register a new helper. modified https://stackoverflow.com/questions/9838925/is-there-any-method-to-iterate-a-map-with-handlebars-js
  219. Handlebars.registerHelper('eachInMap', function (map, block) {
  220. var out = '';
  221. Object.keys(map).map(function (prop) {
  222. let data = map[prop];
  223. if (typeof data === 'object') {
  224. data.id = data.id || prop;
  225. out += block.fn(data);
  226. } else {
  227. out += block.fn({ id: prop, value: data });
  228. }
  229. });
  230. return out;
  231. });
  232. Handlebars.registerHelper('add', function (a, b) {
  233. var out = a + b;
  234. return out;
  235. });
  236. Handlebars.registerHelper('eq', function (a, b) {
  237. var out = (a == b);
  238. return out;
  239. });
  240. Handlebars.registerHelper('or', function (a, b) {
  241. var out = a || b;
  242. return out;
  243. });
  244. Handlebars.registerHelper('not', function (a) {
  245. var out = !a;
  246. return out;
  247. });
  248. Handlebars.registerHelper('count', function (map) {
  249. return map.length;
  250. });
  251. Handlebars.registerHelper('gt', function (a, b) {
  252. var out = a > b;
  253. return out;
  254. });
  255. /**
  256. * This class represents a single template in the viewer's template tree.
  257. * An example for a template is a single canvas, an overlay (containing sub-templates) or the navigation bar.
  258. * A template is injected using the template manager in the correct position.
  259. * The template is rendered using Handlebars and can use Handlebars' features (such as parameter injection)
  260. *
  261. * For further information please refer to the documentation page, https://doc.babylonjs.com
  262. */
  263. export class Template {
  264. /**
  265. * Will be triggered when the template is loaded
  266. */
  267. public onLoaded: Observable<Template>;
  268. /**
  269. * will be triggered when the template is appended to the tree
  270. */
  271. public onAppended: Observable<Template>;
  272. /**
  273. * Will be triggered when the template's state changed (shown, hidden)
  274. */
  275. public onStateChange: Observable<Template>;
  276. /**
  277. * Will be triggered when an event is triggered on ths template.
  278. * The event is a native browser event (like mouse or pointer events)
  279. */
  280. public onEventTriggered: Observable<EventCallback>;
  281. /**
  282. * is the template loaded?
  283. */
  284. public isLoaded: boolean;
  285. /**
  286. * This is meant to be used to track the show and hide functions.
  287. * This is NOT (!!) a flag to check if the element is actually visible to the user.
  288. */
  289. public isShown: boolean;
  290. /**
  291. * Is this template a part of the HTML tree (the template manager injected it)
  292. */
  293. public isInHtmlTree: boolean;
  294. /**
  295. * The HTML element containing this template
  296. */
  297. public parent: HTMLElement;
  298. /**
  299. * A promise that is fulfilled when the template finished loading.
  300. */
  301. public initPromise: Promise<Template>;
  302. private _fragment: DocumentFragment | Element;
  303. private _addedFragment: DocumentFragment | Element;
  304. private _htmlTemplate: string;
  305. private _rawHtml: string;
  306. private loadRequests: Array<IFileRequest>;
  307. constructor(public name: string, private _configuration: ITemplateConfiguration) {
  308. this.onLoaded = new Observable<Template>();
  309. this.onAppended = new Observable<Template>();
  310. this.onStateChange = new Observable<Template>();
  311. this.onEventTriggered = new Observable<EventCallback>();
  312. this.loadRequests = [];
  313. this.isLoaded = false;
  314. this.isShown = false;
  315. this.isInHtmlTree = false;
  316. let htmlContentPromise = this._getTemplateAsHtml(_configuration);
  317. this.initPromise = htmlContentPromise.then(htmlTemplate => {
  318. if (htmlTemplate) {
  319. this._htmlTemplate = htmlTemplate;
  320. let compiledTemplate = Handlebars.compile(htmlTemplate, { noEscape: (this._configuration.params && this._configuration.params.noEscape) });
  321. let config = this._configuration.params || {};
  322. this._rawHtml = compiledTemplate(config);
  323. try {
  324. this._fragment = document.createRange().createContextualFragment(this._rawHtml);
  325. } catch (e) {
  326. let test = document.createElement(this.name);
  327. test.innerHTML = this._rawHtml;
  328. this._fragment = test;
  329. }
  330. this.isLoaded = true;
  331. this.isShown = true;
  332. this.onLoaded.notifyObservers(this);
  333. }
  334. return this;
  335. });
  336. }
  337. /**
  338. * Some templates have parameters (like background color for example).
  339. * The parameters are provided to Handlebars which in turn generates the template.
  340. * This function will update the template with the new parameters
  341. *
  342. * Note that when updating parameters the events will be registered again (after being cleared).
  343. *
  344. * @param params the new template parameters
  345. */
  346. public updateParams(params: { [key: string]: string | number | boolean | object }, append: boolean = true) {
  347. if (append) {
  348. this._configuration.params = deepmerge(this._configuration.params, params);
  349. } else {
  350. this._configuration.params = params;
  351. }
  352. // update the template
  353. if (this.isLoaded) {
  354. // this.dispose();
  355. }
  356. let compiledTemplate = Handlebars.compile(this._htmlTemplate);
  357. let config = this._configuration.params || {};
  358. this._rawHtml = compiledTemplate(config);
  359. try {
  360. this._fragment = document.createRange().createContextualFragment(this._rawHtml);
  361. } catch (e) {
  362. let test = document.createElement(this.name);
  363. test.innerHTML = this._rawHtml;
  364. this._fragment = test;
  365. }
  366. if (this.parent) {
  367. this.appendTo(this.parent, true);
  368. }
  369. }
  370. /**
  371. * Get the template'S configuration
  372. */
  373. public get configuration(): ITemplateConfiguration {
  374. return this._configuration;
  375. }
  376. /**
  377. * A template can be a parent element for other templates or HTML elements.
  378. * This function will deliver all child HTML elements of this template.
  379. */
  380. public getChildElements(): Array<string> {
  381. let childrenArray: string[] = [];
  382. //Edge and IE don't support frage,ent.children
  383. let children: HTMLCollection | NodeListOf<Element> = this._fragment && this._fragment.children;
  384. if (!this._fragment) {
  385. let fragment = this.parent.querySelector(this.name);
  386. if (fragment) {
  387. children = fragment.querySelectorAll('*');
  388. }
  389. }
  390. if (!children) {
  391. // casting to HTMLCollection, as both NodeListOf and HTMLCollection have 'item()' and 'length'.
  392. children = this._fragment.querySelectorAll('*');
  393. }
  394. for (let i = 0; i < children.length; ++i) {
  395. childrenArray.push(kebabToCamel(children.item(i).nodeName.toLowerCase()));
  396. }
  397. return childrenArray;
  398. }
  399. /**
  400. * Appending the template to a parent HTML element.
  401. * If a parent is already set and you wish to replace the old HTML with new one, forceRemove should be true.
  402. * @param parent the parent to which the template is added
  403. * @param forceRemove if the parent already exists, shoud the template be removed from it?
  404. */
  405. public appendTo(parent: HTMLElement, forceRemove?: boolean) {
  406. if (this.parent) {
  407. if (forceRemove && this._addedFragment) {
  408. /*let fragement = this.parent.querySelector(this.name)
  409. if (fragement)
  410. this.parent.removeChild(fragement);*/
  411. this.parent.innerHTML = '';
  412. } else {
  413. return;
  414. }
  415. }
  416. this.parent = parent;
  417. if (this._configuration.id) {
  418. this.parent.id = this._configuration.id;
  419. }
  420. if (this._fragment) {
  421. this.parent.appendChild(this._fragment);
  422. this._addedFragment = this._fragment;
  423. } else {
  424. this.parent.insertAdjacentHTML("beforeend", this._rawHtml);
  425. }
  426. // appended only one frame after.
  427. setTimeout(() => {
  428. this._registerEvents();
  429. this.onAppended.notifyObservers(this);
  430. });
  431. }
  432. private _isShowing: boolean;
  433. private _isHiding: boolean;
  434. /**
  435. * Show the template using the provided visibilityFunction, or natively using display: flex.
  436. * The provided function returns a promise that should be fullfilled when the element is shown.
  437. * Since it is a promise async operations are more than possible.
  438. * See the default viewer for an opacity example.
  439. * @param visibilityFunction The function to execute to show the template.
  440. */
  441. public show(visibilityFunction?: (template: Template) => Promise<Template>): Promise<Template> {
  442. if (this._isHiding) return Promise.resolve(this);
  443. return Promise.resolve().then(() => {
  444. this._isShowing = true;
  445. if (visibilityFunction) {
  446. return visibilityFunction(this);
  447. } else {
  448. // flex? box? should this be configurable easier than the visibilityFunction?
  449. this.parent.style.display = 'flex';
  450. // support old browsers with no flex:
  451. if (this.parent.style.display !== 'flex') {
  452. this.parent.style.display = '';
  453. }
  454. return this;
  455. }
  456. }).then(() => {
  457. this.isShown = true;
  458. this._isShowing = false;
  459. this.onStateChange.notifyObservers(this);
  460. return this;
  461. });
  462. }
  463. /**
  464. * Hide the template using the provided visibilityFunction, or natively using display: none.
  465. * The provided function returns a promise that should be fullfilled when the element is hidden.
  466. * Since it is a promise async operations are more than possible.
  467. * See the default viewer for an opacity example.
  468. * @param visibilityFunction The function to execute to show the template.
  469. */
  470. public hide(visibilityFunction?: (template: Template) => Promise<Template>): Promise<Template> {
  471. if (this._isShowing) return Promise.resolve(this);
  472. return Promise.resolve().then(() => {
  473. this._isHiding = true;
  474. if (visibilityFunction) {
  475. return visibilityFunction(this);
  476. } else {
  477. // flex? box? should this be configurable easier than the visibilityFunction?
  478. this.parent.style.display = 'none';
  479. return this;
  480. }
  481. }).then(() => {
  482. this.isShown = false;
  483. this._isHiding = false;
  484. this.onStateChange.notifyObservers(this);
  485. return this;
  486. });
  487. }
  488. /**
  489. * Dispose this template
  490. */
  491. public dispose() {
  492. this.onAppended.clear();
  493. this.onEventTriggered.clear();
  494. this.onLoaded.clear();
  495. this.onStateChange.clear();
  496. this.isLoaded = false;
  497. // remove from parent
  498. try {
  499. this.parent.removeChild(this._fragment);
  500. } catch (e) {
  501. //noop
  502. }
  503. this.loadRequests.forEach(request => {
  504. request.abort();
  505. });
  506. if (this._registeredEvents) {
  507. this._registeredEvents.forEach(evt => {
  508. evt.htmlElement.removeEventListener(evt.eventName, evt.function);
  509. });
  510. }
  511. delete this._fragment;
  512. }
  513. private _getTemplateAsHtml(templateConfig: ITemplateConfiguration): Promise<string> {
  514. if (!templateConfig) {
  515. return Promise.reject('No templateConfig provided');
  516. } else if (templateConfig.html !== undefined) {
  517. return Promise.resolve(templateConfig.html);
  518. } else {
  519. let location = this._getTemplateLocation(templateConfig);
  520. if (isUrl(location)) {
  521. return new Promise((resolve, reject) => {
  522. let fileRequest = Tools.LoadFile(location, (data: string) => {
  523. resolve(data);
  524. }, undefined, undefined, false, (request, error: any) => {
  525. reject(error);
  526. });
  527. this.loadRequests.push(fileRequest);
  528. });
  529. } else {
  530. location = location.replace('#', '');
  531. let element = document.getElementById(location);
  532. if (element) {
  533. return Promise.resolve(element.innerHTML);
  534. } else {
  535. return Promise.reject('Template ID not found');
  536. }
  537. }
  538. }
  539. }
  540. private _registeredEvents: Array<{ htmlElement: HTMLElement, eventName: string, function: EventListenerOrEventListenerObject }>;
  541. private _registerEvents() {
  542. this._registeredEvents = this._registeredEvents || [];
  543. if (this._registeredEvents.length) {
  544. // first remove the registered events
  545. this._registeredEvents.forEach(evt => {
  546. evt.htmlElement.removeEventListener(evt.eventName, evt.function);
  547. });
  548. }
  549. if (this._configuration.events) {
  550. for (let eventName in this._configuration.events) {
  551. if (this._configuration.events && this._configuration.events[eventName]) {
  552. let functionToFire = (selector, event) => {
  553. this.onEventTriggered.notifyObservers({ event: event, template: this, selector: selector });
  554. }
  555. // if boolean, set the parent as the event listener
  556. if (typeof this._configuration.events[eventName] === 'boolean') {
  557. let selector = this.parent.id
  558. if (selector) {
  559. selector = '#' + selector
  560. } else {
  561. selector = this.parent.tagName
  562. }
  563. let binding = functionToFire.bind(this, selector);
  564. this.parent.addEventListener(eventName, functionToFire.bind(this, selector), false);
  565. this._registeredEvents.push({
  566. htmlElement: this.parent,
  567. eventName: eventName,
  568. function: binding
  569. });
  570. } else if (typeof this._configuration.events[eventName] === 'object') {
  571. let selectorsArray: Array<string> = Object.keys(this._configuration.events[eventName] || {});
  572. // strict null checl is working incorrectly, must override:
  573. let event = this._configuration.events[eventName] || {};
  574. selectorsArray.filter(selector => event[selector]).forEach(selector => {
  575. if (selector && selector.indexOf('#') !== 0) {
  576. selector = '#' + selector;
  577. }
  578. let htmlElement = <HTMLElement>this.parent.querySelector(selector);
  579. if (htmlElement) {
  580. let binding = functionToFire.bind(this, selector);
  581. htmlElement.addEventListener(eventName, binding, false);
  582. this._registeredEvents.push({
  583. htmlElement: htmlElement,
  584. eventName: eventName,
  585. function: binding
  586. });
  587. }
  588. });
  589. }
  590. }
  591. }
  592. }
  593. }
  594. private _getTemplateLocation(templateConfig): string {
  595. if (!templateConfig || typeof templateConfig === 'string') {
  596. return templateConfig;
  597. } else {
  598. return templateConfig.location;
  599. }
  600. }
  601. }