Home Reference Source

lib/DomObserver.js

  1. /*!
  2. * The MIT License (MIT)
  3. *
  4. * Copyright (c) 2016 Mykhailo Stadnyk <mikhus@gmail.com>
  5. *
  6. * Permission is hereby granted, free of charge, to any person obtaining a copy
  7. * of this software and associated documentation files (the "Software"), to deal
  8. * in the Software without restriction, including without limitation the rights
  9. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. * copies of the Software, and to permit persons to whom the Software is
  11. * furnished to do so, subject to the following conditions:
  12. *
  13. * The above copyright notice and this permission notice shall be included in
  14. * all copies or substantial portions of the Software.
  15. *
  16. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22. * SOFTWARE.
  23. */
  24. /**
  25. * @typedef {{ constructor: function(options: GenericOptions): GaugeInterface, draw: function(): GaugeInterface, destroy: function, update: function(options: GenericOptions) }} GaugeInterface
  26. */
  27. /**
  28. * @typedef {{parse: function, stringify: function}} JSON
  29. * @external {JSON} https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON
  30. */
  31. /**
  32. * @ignore
  33. * @typedef {{MutationObserver: function}} ns
  34. */
  35.  
  36. /**
  37. * DOM Observer.
  38. * It will observe DOM document for a configured element types and
  39. * instantiate associated Types for an existing or newly added DOM elements
  40. *
  41. * @example
  42. * class ProgressBar {
  43. * constructor(options) {}
  44. * draw() {}
  45. * }
  46. *
  47. * // It will observe DOM document for elements <div>
  48. * // having attribute 'data-type="progress"'
  49. * // and instantiate for each new instance of ProgressBar
  50. *
  51. * new DomParser({color: 'red'}, 'div', 'progress', ProgressBar);
  52. *
  53. * // assume we could have HTML like this
  54. * // <div data-type="progress" color="blue"></div>
  55. * // in this case all matching attributes names for a given options will be
  56. * // parsed and bypassed to an instance from HTML attributes
  57. */
  58. export default class DomObserver {
  59.  
  60. /**
  61. * @constructor
  62. * @param {object} options
  63. * @param {string} element
  64. * @param {string} type
  65. */
  66. constructor(options, element, type) {
  67. //noinspection JSUnresolvedVariable
  68. /**
  69. * Default instantiation options for the given type
  70. *
  71. * @type {Object}
  72. */
  73. this.options = options;
  74.  
  75. /**
  76. * Name of an element to lookup/observe
  77. *
  78. * @type {string}
  79. */
  80. this.element = element.toLowerCase();
  81.  
  82. /**
  83. * data-type attribute value to lookup
  84. *
  85. * @type {string}
  86. */
  87. this.type = DomObserver.toDashed(type);
  88.  
  89. /**
  90. * Actual type constructor to instantiate for each found element
  91. *
  92. * @type {Function}
  93. */
  94. this.Type = ns[type];
  95.  
  96. /**
  97. * Signals if mutations observer for this type or not
  98. *
  99. * @type {boolean}
  100. */
  101. this.mutationsObserved = false;
  102.  
  103. /**
  104. * Flag specifies whenever the browser supports observing
  105. * of DOM tree mutations or not
  106. *
  107. * @type {boolean}
  108. */
  109. this.isObservable = !!window.MutationObserver;
  110.  
  111. /* istanbul ignore next: this should be tested with end-to-end tests */
  112. if (!window.GAUGES_NO_AUTO_INIT) {
  113. DomObserver.domReady(this.traverse.bind(this));
  114. }
  115. }
  116.  
  117. /**
  118. * Checks if given node is valid node to process
  119. *
  120. * @param {Node|HTMLElement} node
  121. * @returns {boolean}
  122. */
  123. isValidNode(node) {
  124. //noinspection JSUnresolvedVariable
  125. return !!(
  126. node.tagName &&
  127. node.tagName.toLowerCase() === this.element &&
  128. node.getAttribute('data-type') === this.type
  129. );
  130. }
  131.  
  132. /**
  133. * Traverse entire current DOM tree and process matching nodes.
  134. * Usually it should be called only once on document initialization.
  135. */
  136. traverse() {
  137. let elements = document.getElementsByTagName(this.element);
  138. let i = 0, s = elements.length;
  139.  
  140. /* istanbul ignore next: this should be tested with end-to-end tests */
  141. for (; i < s; i++) {
  142. this.process(elements[i]);
  143. }
  144.  
  145. if (this.isObservable && !this.mutationsObserved) {
  146. new MutationObserver(this.observe.bind(this))
  147. .observe(document.body, {
  148. childList: true,
  149. subtree: true,
  150. attributes: true,
  151. characterData: true,
  152. attributeOldValue: true,
  153. characterDataOldValue: true
  154. });
  155.  
  156. this.mutationsObserved = true;
  157. }
  158. }
  159.  
  160. /**
  161. * Observes given mutation records for an elements to process
  162. *
  163. * @param {MutationRecord[]} records
  164. */
  165. observe(records) {
  166. let i = 0;
  167. let s = records.length;
  168.  
  169. /* istanbul ignore next: this should be tested with end-to-end tests */
  170. for (; i < s; i++) {
  171. let record = records[i];
  172.  
  173. if (record.type === 'attributes' &&
  174. record.attributeName === 'data-type' &&
  175. this.isValidNode(record.target) &&
  176. record.oldValue !== this.type) // skip false-positive mutations
  177. {
  178. setTimeout(this.process.bind(this, record.target));
  179. }
  180.  
  181. else if (record.addedNodes && record.addedNodes.length) {
  182. let ii = 0;
  183. let ss = record.addedNodes.length;
  184.  
  185. for (; ii < ss; ii++) {
  186. setTimeout(this.process.bind(this, record.addedNodes[ii]));
  187. }
  188. }
  189. }
  190. }
  191.  
  192. /**
  193. * Parses given attribute value to a proper JavaScript value.
  194. * For example it will parse some stringified value to a proper type
  195. * value, e.g. 'true' => true, 'null' => null, '{"prop": 20}' => {prop: 20}
  196. *
  197. * @param {*} value
  198. * @return {*}
  199. */
  200. static parse(value) {
  201. // parse boolean
  202. if (value === 'true') return true;
  203. if (value === 'false') return false;
  204.  
  205. // parse undefined
  206. if (value === 'undefined') return undefined;
  207.  
  208. // parse null
  209. if (value === 'null') return null;
  210.  
  211. // Comma-separated strings to array parsing.
  212. // It won't match strings which contains non alphanumeric characters to
  213. // prevent strings like 'rgba(0,0,0,0)' or JSON-like from being parsed.
  214. // Typically it simply allows easily declare arrays as comma-separated
  215. // numbers or plain strings. If something more complicated is
  216. // required it can be declared using JSON format syntax
  217. if (/^[-+#.\w\d\s]+(?:,[-+#.\w\d\s]*)+$/.test(value)) {
  218. return value.split(',');
  219. }
  220.  
  221. // parse JSON
  222. try { return JSON.parse(value); } catch(e) {}
  223.  
  224. // plain value - no need to parse
  225. return value;
  226. }
  227.  
  228. /**
  229. * Processes a given node, instantiating a proper type constructor for it
  230. *
  231. * @param {Node|HTMLElement} node
  232. * @returns {GaugeInterface|null}
  233. */
  234. process(node) {
  235. if (!this.isValidNode(node)) return null;
  236.  
  237. let prop;
  238. let options = JSON.parse(JSON.stringify(this.options));
  239. let instance = null;
  240.  
  241. for (prop in options) {
  242. /* istanbul ignore else: non-testable in most cases */
  243. if (options.hasOwnProperty(prop)) {
  244. let attributeName = DomObserver.toAttributeName(prop);
  245. let attributeValue = DomObserver.parse(
  246. node.getAttribute(attributeName));
  247.  
  248. if (attributeValue !== null && attributeValue !== undefined) {
  249. options[prop] = attributeValue;
  250. }
  251. }
  252. }
  253.  
  254. options.renderTo = node;
  255. instance = new (this.Type)(options);
  256. instance.draw && instance.draw();
  257.  
  258. if (!this.isObservable) return instance;
  259.  
  260. instance.observer = new MutationObserver(records => {
  261. records.forEach(record => {
  262. if (record.type === 'attributes') {
  263. let attr = record.attributeName.toLowerCase();
  264. let type = node.getAttribute(attr).toLowerCase();
  265.  
  266. if (attr === 'data-type' && type && type !== this.type) {
  267. instance.observer.disconnect();
  268. delete instance.observer;
  269. instance.destroy && instance.destroy();
  270. }
  271.  
  272. else if (attr.substr(0, 5) === 'data-') {
  273. let prop = attr.substr(5).split('-').map((part, i) => {
  274. return !i ? part :
  275. part.charAt(0).toUpperCase() + part.substr(1);
  276. }).join('');
  277. let options = {};
  278.  
  279. options[prop] = DomObserver.parse(
  280. node.getAttribute(record.attributeName));
  281.  
  282.  
  283. if (prop === 'value') {
  284. instance && (instance.value = options[prop]);
  285. }
  286.  
  287. else {
  288. instance.update && instance.update(options);
  289. }
  290. }
  291. }
  292. });
  293. });
  294.  
  295. //noinspection JSCheckFunctionSignatures
  296. instance.observer.observe(node, { attributes: true });
  297.  
  298. return instance;
  299. }
  300.  
  301. /**
  302. * Transforms camelCase string to dashed string
  303. *
  304. * @static
  305. * @param {string} camelCase
  306. * @return {string}
  307. */
  308. static toDashed(camelCase) {
  309. let arr = camelCase.split(/(?=[A-Z])/);
  310. let i = 1;
  311. let s = arr.length;
  312. let str = arr[0].toLowerCase();
  313.  
  314. for (; i < s; i++) {
  315. str += '-' + arr[i].toLowerCase();
  316. }
  317.  
  318. return str;
  319. }
  320.  
  321. /**
  322. * Transforms dashed string to CamelCase representation
  323. *
  324. * @param {string} dashed
  325. * @param {boolean} [capitalized]
  326. * @return {string}
  327. */
  328. static toCamelCase(dashed, capitalized = true) {
  329. let arr = dashed.split(/-/);
  330. let i = 0;
  331. let s = arr.length;
  332. let str = '';
  333.  
  334. for (; i < s; i++) {
  335. if (!(i || capitalized)) {
  336. str += arr[i].toLowerCase();
  337. }
  338.  
  339. else {
  340. str += arr[i][0].toUpperCase() + arr[i].substr(1).toLowerCase();
  341. }
  342. }
  343.  
  344. return str;
  345. }
  346.  
  347. /**
  348. * Transforms camel case property name to dash separated attribute name
  349. *
  350. * @static
  351. * @param {string} str
  352. * @returns {string}
  353. */
  354. static toAttributeName(str) {
  355. return 'data-' + DomObserver.toDashed(str);
  356. }
  357.  
  358. /**
  359. * Cross-browser DOM ready handler
  360. *
  361. * @static
  362. * @param {Function} handler
  363. */
  364. static domReady(handler) {
  365. if (/comp|inter|loaded/.test((window.document || {}).readyState + ''))
  366. return handler();
  367.  
  368. if (window.addEventListener)
  369. window.addEventListener('DOMContentLoaded', handler, false);
  370.  
  371. else if (window.attachEvent)
  372. window.attachEvent('onload', handler);
  373. }
  374.  
  375. }
  376.  
  377. module.exports = DomObserver;