barefoot.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. "use strict";
  2. var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
  3. var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
  4. function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
  5. var BareFoot = function () {
  6. /**
  7. * @param {Object} options [Options to configure the script]
  8. * @constructor
  9. */
  10. function BareFoot() {
  11. var _this = this;
  12. var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0];
  13. _classCallCheck(this, BareFoot);
  14. var DEFAULTS = {
  15. scope: 'body',
  16. divFootnotesQuery: ".footnotes",
  17. footnotesQuery: "[id^='fn']",
  18. supQuery: 'a[href^="#fnref"]',
  19. fnButtonMarkup: "<button class=\"footnote-button\" id=\"{{FOOTNOTEREFID}}\" data-footnote=\"{{FOOTNOTEID}}\" alt=\"See Footnote {{FOOTNOTENUMBER}}\" rel=\"footnote\" data-fn-number=\"{{FOOTNOTENUMBER}}\" data-fn-content=\"{{FOOTNOTECONTENT}}\"></button>",
  20. fnContentMarkup: "<div class=\"bf-footnote\" id=\"{{FOOTNOTEID}}\"><div class=\"footnote-wrapper\"><div class=\"footnote-content\" tabindex=\"0\">{{FOOTNOTECONTENT}}</div></div><div class=\"footnote-tooltip\" aria-hidden=\"true\"></div>",
  21. activeCallback: null,
  22. activeBtnClass: 'is-active',
  23. activeFnClass: 'footnote-is-active',
  24. backdropClass: 'footnote-backdrop',
  25. buttonClass: 'footnote-button',
  26. fnContainer: 'footnote-container',
  27. fnClass: 'bf-footnote',
  28. fnContentClass: 'footnote-content',
  29. fnWrapperClass: 'footnote-wrapper',
  30. tooltipClass: 'footnote-tooltip',
  31. fnOnTopClass: 'footnote-is-top'
  32. };
  33. // Merges defaults with custom options
  34. this.config = _extends({}, DEFAULTS, options);
  35. // A selector could select multiple containers
  36. this.divFootnotes = [].slice.call(document.querySelectorAll(this.config.divFootnotesQuery));
  37. // Returns if no container
  38. if (!this.divFootnotes) return false;
  39. // Groups all footnotes within every group.
  40. this.footnotes = this.divFootnotes.map(function (el) {
  41. return el.querySelectorAll(_this.config.footnotesQuery);
  42. });
  43. // Polyfill for Element.matches()
  44. // Based on https://davidwalsh.name/element-matches-selector
  45. Element.prototype.matches = Element.prototype.matches || Element.prototype.mozMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.webkitMatchesSelector || function (s) {
  46. return [].indexOf.call(document.querySelectorAll(s), this) !== -1;
  47. };
  48. // Polyfill for Element.closest()
  49. // Based on http://stackoverflow.com/questions/18663941/finding-closest-element-without-jquery
  50. Element.prototype.closest = Element.prototype.closest || function (s) {
  51. var el = this;
  52. while (el !== null) {
  53. var parent = el.parentElement;
  54. if (parent !== null && parent.matches(s)) {
  55. return parent;
  56. }
  57. el = parent;
  58. }
  59. return null;
  60. };
  61. // Calculate vertical scrollbar width
  62. // Inspired by https://davidwalsh.name/detect-scrollbar-width
  63. var scrollDiv = document.createElement('div');
  64. scrollDiv.style.cssText = 'width: 100px; height: 100px; overflow: scroll; position: absolute; top: -9999px; visibility: hidden;';
  65. document.body.appendChild(scrollDiv);
  66. this.scrollBarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth;
  67. document.body.removeChild(scrollDiv);
  68. }
  69. /**
  70. * Footnotes often have a link to return to the sup, before moving the contents to each individual footnote, we gotta remove this hook to get a clean content.
  71. * @param {String} fnHtml [Html from the footnote]
  72. * @param {String} backId [ID this footnote refers to]
  73. * @return {String} [Clean Html]
  74. */
  75. _createClass(BareFoot, [{
  76. key: "removeBackLinks",
  77. value: function removeBackLinks(fnHtml, backId) {
  78. if (backId.indexOf(' ') >= 0) {
  79. backId = backId.trim().replace(/\s+/g, "|").replace(/(.*)/g, "($1)");
  80. }
  81. if (backId.indexOf('#') === 0) {
  82. backId = backId.slice(1);
  83. }
  84. var regex = new RegExp("(\\s|&nbsp;)*<\\s*a[^#<]*#" + backId + "[^>]*>(.*?)<\\s*/\\s*a>", "g");
  85. return fnHtml.replace(regex, "").replace("[]", "");
  86. }
  87. /**
  88. * Builds the buttons for each footnote based on the configured template.
  89. * @param {String} ref [ID this element refers to]
  90. * @param {String} id [ID for this element]
  91. * @param {String} n [Number that illustrates the footnote]
  92. * @param {String} content [Footnote content]
  93. * @return {String} [Html Markup]
  94. */
  95. }, {
  96. key: "buildButton",
  97. value: function buildButton(ref, id, n, content) {
  98. return this.config.fnButtonMarkup.replace(/\{\{FOOTNOTEREFID\}\}/g, ref).replace(/\{\{FOOTNOTEID\}\}/g, id).replace(/\{\{FOOTNOTENUMBER\}\}/g, n).replace(/\{\{FOOTNOTECONTENT\}\}/g, content);
  99. }
  100. /**
  101. * Builds the content for each footnote based on the configured template.
  102. * @param {String} id [ID from the parent of this element]
  103. * @param {String} content [Footnote content]
  104. * @return {String} [Html Markup]
  105. */
  106. }, {
  107. key: "buildContent",
  108. value: function buildContent(id, content) {
  109. return this.config.fnContentMarkup.replace(/\{\{FOOTNOTEID\}\}/g, id).replace(/\{\{FOOTNOTECONTENT\}\}/g, content);
  110. }
  111. /**
  112. * Triggers whenever an user clicks a footnote button and is responsible to coordinate all the necessary steps to show and position the footnotes.
  113. * @param {Event} e [Event]
  114. */
  115. }, {
  116. key: "clickAction",
  117. value: function clickAction(e) {
  118. var btn = void 0,
  119. content = void 0,
  120. id = void 0,
  121. fnHtml = void 0,
  122. fn = void 0,
  123. windowHeight = void 0,
  124. scrollHeight = void 0,
  125. returnOnDismiss = void 0;
  126. btn = e.target;
  127. content = btn.getAttribute('data-fn-content');
  128. id = btn.getAttribute("data-footnote");
  129. returnOnDismiss = btn.classList.contains('is-active');
  130. // We calculate the document.documentElement.scrollHeight before inserting the footnote, so later (at the calculateSpacing function to be more specific), we can check if there's any overflow to the bottom of the page, if so it flips the footnote to the top.
  131. scrollHeight = this.getScrollHeight();
  132. this.dismissFootnotes();
  133. if (returnOnDismiss) {
  134. return;
  135. }
  136. fnHtml = this.buildContent(id, content);
  137. btn.insertAdjacentHTML('afterend', fnHtml);
  138. fn = btn.nextElementSibling;
  139. // Position and flip the footnote on demand.
  140. this.calculateOffset(fn, btn);
  141. this.calculateSpacing(fn, scrollHeight);
  142. btn.classList.add(this.config.activeBtnClass);
  143. fn.classList.add(this.config.activeFnClass);
  144. // Focus is set on the footnote content, this looks kinda ugly but allows keyboard navigation and scrolling when the content overflow. I have a gut feeling this is good, so I'm sticking to it. All the help to improve accessibility is welcome.
  145. fn.querySelector("." + this.config.fnContentClass).focus();
  146. // As far as I recall, touch devices require a tweak to dismiss footnotes when you tap the body outside the footnote, this is the tweak.
  147. if ('ontouchstart' in document.documentElement) {
  148. document.body.classList.add(this.config.backdropClass);
  149. }
  150. // Triggers the activeCallback if there's any. I never used and never tested this, but I'm passing the button and the footnote as parameters because I think that's all you may expect.
  151. if (this.config.activeCallback) {
  152. this.config.activeCallback(btn, fn);
  153. }
  154. }
  155. /**
  156. * Mathematical Hell. This function repositions the footnote according to the edges of the screen. The goal is to never (gonna give you up) overflow content. Also, remember when we calculated the scrollBarWidth? This is where we use it in case the footnote overflows to the right.
  157. * @param {Element} fn [Footnote Node]
  158. * @param {Element} btn [Button Node]
  159. */
  160. }, {
  161. key: "calculateOffset",
  162. value: function calculateOffset(fn, btn) {
  163. var tooltip = void 0,
  164. container = void 0,
  165. btnOffset = void 0,
  166. btnWidth = void 0,
  167. contWidth = void 0,
  168. contOffset = void 0,
  169. wrapWidth = void 0,
  170. wrapMove = void 0,
  171. wrapOffset = void 0,
  172. tipWidth = void 0,
  173. tipOffset = void 0,
  174. windowWidth = void 0;
  175. btn = btn || fn.previousElementSibling;
  176. btnOffset = btn.offsetLeft;
  177. btnWidth = btn.offsetWidth;
  178. tooltip = fn.querySelector("." + this.config.tooltipClass);
  179. tipWidth = tooltip.clientWidth;
  180. container = fn.parentNode;
  181. contWidth = container.clientWidth;
  182. contOffset = container.offsetLeft;
  183. wrapWidth = fn.offsetWidth;
  184. wrapMove = -(wrapWidth / 2 - contWidth / 2);
  185. windowWidth = window.innerWidth || window.availWidth;
  186. // Footnote overflows to the left
  187. if (contOffset + wrapMove < 0) {
  188. wrapMove = wrapMove - (contOffset + wrapMove);
  189. }
  190. // Footnote overflows to the right
  191. else if (contOffset + wrapMove + wrapWidth + this.scrollBarWidth > windowWidth) {
  192. wrapMove = wrapMove - (contOffset + wrapMove + wrapWidth + this.scrollBarWidth + contWidth / 2 - windowWidth);
  193. }
  194. fn.style.left = wrapMove + "px";
  195. wrapOffset = contOffset + wrapMove;
  196. tipOffset = contOffset - wrapOffset + contWidth / 2 - tipWidth / 2;
  197. tooltip.style.left = tipOffset + "px";
  198. }
  199. /**
  200. * Removes element, mostly used for footnotes.
  201. * @param {Element} el
  202. */
  203. }, {
  204. key: "removeFootnoteChild",
  205. value: function removeFootnoteChild(el) {
  206. return el.parentNode.removeChild(el);
  207. }
  208. /**
  209. * Delays and withholds function triggering in events. Based on https://davidwalsh.name/javascript-debounce-function
  210. * @param {Function} func [The function to after the delays]
  211. * @param {Number} wait [The delay in milliseconds]
  212. * @param {Boolean} immediate [if true, triggers the function on the leading edge rather than the trailing]
  213. * @return {Function} [It's a closure, what did you expect?]
  214. */
  215. }, {
  216. key: "debounce",
  217. value: function debounce(func, wait, immediate) {
  218. var timeout;
  219. return function () {
  220. var _this2 = this;
  221. for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) {
  222. args[_key] = arguments[_key];
  223. }
  224. var later = function later() {
  225. timeout = null;
  226. if (!immediate) func.apply(_this2, args);
  227. };
  228. clearTimeout(timeout);
  229. timeout = setTimeout(later, wait);
  230. if (immediate && !timeout) func.apply(this, args);
  231. };
  232. }
  233. /**
  234. * Action to be attached to the resize event and recalculate the position of the active footnotes.
  235. */
  236. }, {
  237. key: "resizeAction",
  238. value: function resizeAction() {
  239. var _this3 = this;
  240. var footnotes = document.querySelectorAll("." + this.config.activeFnClass);
  241. if (footnotes.length) {
  242. [].forEach.call(footnotes, function (fn) {
  243. _this3.calculateOffset(fn);
  244. _this3.calculateSpacing(fn);
  245. });
  246. }
  247. }
  248. /**
  249. * Returns the height of the document. Used to find out if the footnote overflows the content
  250. * @return {Number} [see description]
  251. */
  252. }, {
  253. key: "getScrollHeight",
  254. value: function getScrollHeight() {
  255. return document.documentElement.scrollHeight;
  256. }
  257. /**
  258. * Calculates if the footnote should appear above or below the button
  259. * @param {Element} fn [The footnote in question]
  260. * @param {Number} height [By now the footnote is about to show up and we use the previous value, this one, to check if the footnote is overflow the document]
  261. */
  262. }, {
  263. key: "calculateSpacing",
  264. value: function calculateSpacing(fn, height) {
  265. var bcr = void 0,
  266. bch = void 0,
  267. bcb = void 0,
  268. margins = void 0,
  269. windowHeight = void 0;
  270. margins = this.calculateMargins(fn);
  271. windowHeight = window.innerHeight || window.availHeight;
  272. bcr = fn.getBoundingClientRect();
  273. bch = bcr.height;
  274. bcb = bcr.bottom;
  275. if (height < this.getScrollHeight() || bcb > windowHeight - margins.bottom) {
  276. fn.classList.add(this.config.fnOnTopClass);
  277. } else if (windowHeight - (bch + margins.top) > bcb && fn.classList.contains(this.config.fnOnTopClass)) {
  278. fn.classList.remove(this.config.fnOnTopClass);
  279. }
  280. }
  281. /**
  282. * Action to be attached to the scroll event to verify if we should change the position of the footnote using the available space.
  283. */
  284. }, {
  285. key: "scrollAction",
  286. value: function scrollAction() {
  287. var _this4 = this;
  288. var footnotes = document.querySelectorAll("." + this.config.activeFnClass);
  289. if (footnotes.length) {
  290. var windowHeight = window.innerHeight || window.availHeight,
  291. margins = this.calculateMargins(footnotes[0]);
  292. [].forEach.call(footnotes, function (el) {
  293. _this4.calculateSpacing(el);
  294. });
  295. }
  296. }
  297. /**
  298. * Returns the computed margins of an element, used to calculate the position and spacing.
  299. * @param {Element} fn [The footnote]
  300. * @return {Object} [An object containing all margins]
  301. */
  302. }, {
  303. key: "calculateMargins",
  304. value: function calculateMargins(fn) {
  305. var computedStyle = window.getComputedStyle(fn, null);
  306. return {
  307. top: parseFloat(computedStyle.marginTop),
  308. right: parseFloat(computedStyle.marginRight),
  309. bottom: parseFloat(computedStyle.marginBottom),
  310. left: parseFloat(computedStyle.marginLeft)
  311. };
  312. }
  313. /**
  314. * This is set on click and touchend events for the body and removes the footnotes when you click/tap outside them
  315. * @param {Event}
  316. */
  317. }, {
  318. key: "documentAction",
  319. value: function documentAction(ev) {
  320. if (!ev.target.closest("." + this.config.fnContainer)) this.dismissFootnotes();
  321. }
  322. /**
  323. * Dismisses active footnotes when the ESC key is hit and the current active element is a footnote. Returns focus to the footnote button.
  324. * @param {Event} e
  325. */
  326. }, {
  327. key: "dismissOnEsc",
  328. value: function dismissOnEsc(ev) {
  329. if (ev.keyCode === 27 && document.activeElement.matches("." + this.config.fnContentClass)) {
  330. document.activeElement.closest("." + this.config.activeFnClass).previousElementSibling.focus();
  331. return this.dismissFootnotes();
  332. }
  333. }
  334. /**
  335. * Removes all open footnotes (and also the backdrop, remember it?)
  336. */
  337. }, {
  338. key: "dismissFootnotes",
  339. value: function dismissFootnotes() {
  340. var _this5 = this;
  341. var footnotes = document.querySelectorAll("." + this.config.activeFnClass);
  342. if (footnotes.length) {
  343. [].forEach.call(footnotes, function (el) {
  344. el.previousElementSibling.classList.remove(_this5.config.activeBtnClass);
  345. el.addEventListener('transitionend', _this5.removeFootnoteChild(el), false);
  346. el.classList.remove(_this5.config.activeFnClass);
  347. });
  348. }
  349. if (document.body.classList.contains(this.config.backdropClass)) document.body.classList.remove(this.config.backdropClass);
  350. }
  351. /**
  352. * Opens pandora's box. This function crosses every footnote and makes all the replacements and then sets up every eventListener for the script to work.
  353. */
  354. }, {
  355. key: "init",
  356. value: function init() {
  357. var _this6 = this;
  358. [].forEach.call(this.footnotes, function (fns, i) {
  359. var currentScope = fns[0].closest(_this6.config.scope);
  360. [].forEach.call(fns, function (fn, i) {
  361. var fnContent = void 0,
  362. fnHrefId = void 0,
  363. fnId = void 0,
  364. ref = void 0,
  365. fnRefN = void 0,
  366. footnote = void 0;
  367. fnRefN = i + 1;
  368. fnHrefId = fn.querySelector(_this6.config.supQuery).getAttribute('href');
  369. fnContent = _this6.removeBackLinks(fn.innerHTML.trim(), fnHrefId);
  370. fnContent = fnContent.replace(/"/g, "&quot;").replace(/&lt;/g, "&ltsym;").replace(/&gt;/g, "&gtsym;");
  371. if (fnContent.indexOf("<") !== 0) fnContent = "<p>" + fnContent + "</p>";
  372. // Gotta escape `:` used within a querySelector so JS doesn't think you're looking for a pseudo-element.
  373. ref = currentScope.querySelector(fnHrefId.replace(':', '\\:'));
  374. footnote = "<div class=\"" + _this6.config.fnContainer + "\">" + _this6.buildButton(fnHrefId, fn.id, fnRefN, fnContent) + "</div>";
  375. ref.insertAdjacentHTML('afterend', footnote);
  376. ref.parentNode.removeChild(ref);
  377. });
  378. });
  379. // Setting up events
  380. [].forEach.call(document.querySelectorAll("." + this.config.buttonClass), function (el) {
  381. el.addEventListener("click", _this6.clickAction.bind(_this6));
  382. });
  383. window.addEventListener("resize", this.debounce(this.resizeAction.bind(this), 100));
  384. window.addEventListener("scroll", this.debounce(this.scrollAction.bind(this), 100));
  385. window.addEventListener("keyup", this.dismissOnEsc.bind(this));
  386. document.body.addEventListener("click", this.documentAction.bind(this));
  387. document.body.addEventListener("touchend", this.documentAction.bind(this));
  388. this.divFootnotes.forEach(function (el) {
  389. return el.parentNode.removeChild(el);
  390. });
  391. }
  392. }]);
  393. return BareFoot;
  394. }();
  395. // initiate
  396. var lf = new BareFoot({
  397. scope: "article"
  398. });
  399. lf.init();