templateManager.ts 24 KB

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