templateManager.ts 24 KB

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