f757d5ab18115aebfea440cd904679af3260436039f4ebc48226d38ddfde479f94ac5354267e576b2764e33403a07adbf7e26a637c5b0d8ade6095d37f98fa 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. /* eslint-env amd, node */
  2. // https://github.com/umdjs/umd/blob/master/templates/returnExports.js
  3. (function (root, factory) {
  4. 'use strict';
  5. if (typeof define === 'function' && define.amd) {
  6. // AMD. Register as an anonymous module.
  7. define([], factory);
  8. } else if (typeof module === 'object' && module.exports) {
  9. // Node. Does not work with strict CommonJS, but
  10. // only CommonJS-like environments that support module.exports,
  11. // like Node.
  12. module.exports = factory();
  13. } else {
  14. // Browser globals (root is window)
  15. root.AnchorJS = factory();
  16. root.anchors = new root.AnchorJS();
  17. }
  18. }(this, function () {
  19. 'use strict';
  20. function AnchorJS(options) {
  21. this.options = options || {};
  22. this.elements = [];
  23. /**
  24. * Assigns options to the internal options object, and provides defaults.
  25. * @param {Object} opts - Options object
  26. */
  27. function _applyRemainingDefaultOptions(opts) {
  28. opts.icon = opts.hasOwnProperty('icon') ? opts.icon : '\ue9cb'; // Accepts characters (and also URLs?), like '#', '¶', '❡', or '§'.
  29. opts.visible = opts.hasOwnProperty('visible') ? opts.visible : 'hover'; // Also accepts 'always' & 'touch'
  30. opts.placement = opts.hasOwnProperty('placement') ? opts.placement : 'right'; // Also accepts 'left'
  31. opts.class = opts.hasOwnProperty('class') ? opts.class : ''; // Accepts any class name.
  32. // Using Math.floor here will ensure the value is Number-cast and an integer.
  33. opts.truncate = opts.hasOwnProperty('truncate') ? Math.floor(opts.truncate) : 64; // Accepts any value that can be typecast to a number.
  34. }
  35. _applyRemainingDefaultOptions(this.options);
  36. /**
  37. * Checks to see if this device supports touch. Uses criteria pulled from Modernizr:
  38. * https://github.com/Modernizr/Modernizr/blob/da22eb27631fc4957f67607fe6042e85c0a84656/feature-detects/touchevents.js#L40
  39. * @return {Boolean} - true if the current device supports touch.
  40. */
  41. this.isTouchDevice = function() {
  42. return !!(('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch);
  43. };
  44. /**
  45. * Add anchor links to page elements.
  46. * @param {String|Array|Nodelist} selector - A CSS selector for targeting the elements you wish to add anchor links
  47. * to. Also accepts an array or nodeList containing the relavant elements.
  48. * @return {this} - The AnchorJS object
  49. */
  50. this.add = function(selector) {
  51. var elements,
  52. elsWithIds,
  53. idList,
  54. elementID,
  55. i,
  56. index,
  57. count,
  58. tidyText,
  59. newTidyText,
  60. readableID,
  61. anchor,
  62. visibleOptionToUse,
  63. indexesToDrop = [];
  64. // We reapply options here because somebody may have overwritten the default options object when setting options.
  65. // For example, this overwrites all options but visible:
  66. //
  67. // anchors.options = { visible: 'always'; }
  68. _applyRemainingDefaultOptions(this.options);
  69. visibleOptionToUse = this.options.visible;
  70. if (visibleOptionToUse === 'touch') {
  71. visibleOptionToUse = this.isTouchDevice() ? 'always' : 'hover';
  72. }
  73. // Provide a sensible default selector, if none is given.
  74. if (!selector) {
  75. selector = 'h2, h3, h4, h5, h6';
  76. }
  77. elements = _getElements(selector);
  78. if (elements.length === 0) {
  79. return this;
  80. }
  81. _addBaselineStyles();
  82. // We produce a list of existing IDs so we don't generate a duplicate.
  83. elsWithIds = document.querySelectorAll('[id]');
  84. idList = [].map.call(elsWithIds, function assign(el) {
  85. return el.id;
  86. });
  87. for (i = 0; i < elements.length; i++) {
  88. if (this.hasAnchorJSLink(elements[i])) {
  89. indexesToDrop.push(i);
  90. continue;
  91. }
  92. if (elements[i].hasAttribute('id')) {
  93. elementID = elements[i].getAttribute('id');
  94. } else if (elements[i].hasAttribute('data-anchor-id')) {
  95. elementID = elements[i].getAttribute('data-anchor-id');
  96. } else {
  97. tidyText = this.urlify(elements[i].textContent);
  98. // Compare our generated ID to existing IDs (and increment it if needed)
  99. // before we add it to the page.
  100. newTidyText = tidyText;
  101. count = 0;
  102. do {
  103. if (index !== undefined) {
  104. newTidyText = tidyText + '-' + count;
  105. }
  106. index = idList.indexOf(newTidyText);
  107. count += 1;
  108. } while (index !== -1);
  109. index = undefined;
  110. idList.push(newTidyText);
  111. elements[i].setAttribute('id', newTidyText);
  112. elementID = newTidyText;
  113. }
  114. readableID = elementID.replace(/-/g, ' ');
  115. // The following code builds the following DOM structure in a more effiecient (albeit opaque) way.
  116. // '<a class="anchorjs-link ' + this.options.class + '" href="#' + elementID + '" aria-label="Anchor link for: ' + readableID + '" data-anchorjs-icon="' + this.options.icon + '"></a>';
  117. anchor = document.createElement('a');
  118. anchor.className = 'anchorjs-link ' + this.options.class;
  119. anchor.href = '#' + elementID;
  120. anchor.setAttribute('aria-label', 'Anchor link for: ' + readableID);
  121. anchor.setAttribute('data-anchorjs-icon', this.options.icon);
  122. if (visibleOptionToUse === 'always') {
  123. anchor.style.opacity = '1';
  124. }
  125. if (this.options.icon === '\ue9cb') {
  126. anchor.style.font = '1em/1 anchorjs-icons';
  127. // We set lineHeight = 1 here because the `anchorjs-icons` font family could otherwise affect the
  128. // height of the heading. This isn't the case for icons with `placement: left`, so we restore
  129. // line-height: inherit in that case, ensuring they remain positioned correctly. For more info,
  130. // see https://github.com/bryanbraun/anchorjs/issues/39.
  131. if (this.options.placement === 'left') {
  132. anchor.style.lineHeight = 'inherit';
  133. }
  134. }
  135. if (this.options.placement === 'left') {
  136. anchor.style.position = 'absolute';
  137. anchor.style.marginLeft = '-1em';
  138. anchor.style.paddingRight = '0.5em';
  139. elements[i].insertBefore(anchor, elements[i].firstChild);
  140. } else { // if the option provided is `right` (or anything else).
  141. anchor.style.paddingLeft = '0.375em';
  142. elements[i].appendChild(anchor);
  143. }
  144. }
  145. for (i = 0; i < indexesToDrop.length; i++) {
  146. elements.splice(indexesToDrop[i] - i, 1);
  147. }
  148. this.elements = this.elements.concat(elements);
  149. return this;
  150. };
  151. /**
  152. * Removes all anchorjs-links from elements targed by the selector.
  153. * @param {String|Array|Nodelist} selector - A CSS selector string targeting elements with anchor links,
  154. * OR a nodeList / array containing the DOM elements.
  155. * @return {this} - The AnchorJS object
  156. */
  157. this.remove = function(selector) {
  158. var index,
  159. domAnchor,
  160. elements = _getElements(selector);
  161. for (var i = 0; i < elements.length; i++) {
  162. domAnchor = elements[i].querySelector('.anchorjs-link');
  163. if (domAnchor) {
  164. // Drop the element from our main list, if it's in there.
  165. index = this.elements.indexOf(elements[i]);
  166. if (index !== -1) {
  167. this.elements.splice(index, 1);
  168. }
  169. // Remove the anchor from the DOM.
  170. elements[i].removeChild(domAnchor);
  171. }
  172. }
  173. return this;
  174. };
  175. /**
  176. * Removes all anchorjs links. Mostly used for tests.
  177. */
  178. this.removeAll = function() {
  179. this.remove(this.elements);
  180. };
  181. /**
  182. * Urlify - Refine text so it makes a good ID.
  183. *
  184. * To do this, we remove apostrophes, replace nonsafe characters with hyphens,
  185. * remove extra hyphens, truncate, trim hyphens, and make lowercase.
  186. *
  187. * @param {String} text - Any text. Usually pulled from the webpage element we are linking to.
  188. * @return {String} - hyphen-delimited text for use in IDs and URLs.
  189. */
  190. this.urlify = function(text) {
  191. // Regex for finding the nonsafe URL characters (many need escaping): & +$,:;=?@"#{}|^~[`%!'<>]./()*\
  192. var nonsafeChars = /[& +$,:;=?@"#{}|^~[`%!'<>\]\.\/\(\)\*\\]/g,
  193. urlText;
  194. // The reason we include this _applyRemainingDefaultOptions is so urlify can be called independently,
  195. // even after setting options. This can be useful for tests or other applications.
  196. if (!this.options.truncate) {
  197. _applyRemainingDefaultOptions(this.options);
  198. }
  199. // Note: we trim hyphens after truncating because truncating can cause dangling hyphens.
  200. // Example string: // " ⚡⚡ Don't forget: URL fragments should be i18n-friendly, hyphenated, short, and clean."
  201. urlText = text.trim() // "⚡⚡ Don't forget: URL fragments should be i18n-friendly, hyphenated, short, and clean."
  202. .replace(/\'/gi, '') // "⚡⚡ Dont forget: URL fragments should be i18n-friendly, hyphenated, short, and clean."
  203. .replace(nonsafeChars, '-') // "⚡⚡-Dont-forget--URL-fragments-should-be-i18n-friendly--hyphenated--short--and-clean-"
  204. .replace(/-{2,}/g, '-') // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated-short-and-clean-"
  205. .substring(0, this.options.truncate) // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated-"
  206. .replace(/^-+|-+$/gm, '') // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated"
  207. .toLowerCase(); // "⚡⚡-dont-forget-url-fragments-should-be-i18n-friendly-hyphenated"
  208. return urlText;
  209. };
  210. /**
  211. * Determines if this element already has an AnchorJS link on it.
  212. * Uses this technique: http://stackoverflow.com/a/5898748/1154642
  213. * @param {HTMLElemnt} el - a DOM node
  214. * @return {Boolean} true/false
  215. */
  216. this.hasAnchorJSLink = function(el) {
  217. var hasLeftAnchor = el.firstChild && ((' ' + el.firstChild.className + ' ').indexOf(' anchorjs-link ') > -1),
  218. hasRightAnchor = el.lastChild && ((' ' + el.lastChild.className + ' ').indexOf(' anchorjs-link ') > -1);
  219. return hasLeftAnchor || hasRightAnchor || false;
  220. };
  221. /**
  222. * Turns a selector, nodeList, or array of elements into an array of elements (so we can use array methods).
  223. * It also throws errors on any other inputs. Used to handle inputs to .add and .remove.
  224. * @param {String|Array|Nodelist} input - A CSS selector string targeting elements with anchor links,
  225. * OR a nodeList / array containing the DOM elements.
  226. * @return {Array} - An array containing the elements we want.
  227. */
  228. function _getElements(input) {
  229. var elements;
  230. if (typeof input === 'string' || input instanceof String) {
  231. // See https://davidwalsh.name/nodelist-array for the technique transforming nodeList -> Array.
  232. elements = [].slice.call(document.querySelectorAll(input));
  233. // I checked the 'input instanceof NodeList' test in IE9 and modern browsers and it worked for me.
  234. } else if (Array.isArray(input) || input instanceof NodeList) {
  235. elements = [].slice.call(input);
  236. } else {
  237. throw new Error('The selector provided to AnchorJS was invalid.');
  238. }
  239. return elements;
  240. }
  241. /**
  242. * _addBaselineStyles
  243. * Adds baseline styles to the page, used by all AnchorJS links irregardless of configuration.
  244. */
  245. function _addBaselineStyles() {
  246. // We don't want to add global baseline styles if they've been added before.
  247. if (document.head.querySelector('style.anchorjs') !== null) {
  248. return;
  249. }
  250. var style = document.createElement('style'),
  251. linkRule =
  252. ' .anchorjs-link {' +
  253. ' opacity: 0;' +
  254. ' text-decoration: none;' +
  255. ' -webkit-font-smoothing: antialiased;' +
  256. ' -moz-osx-font-smoothing: grayscale;' +
  257. ' }',
  258. hoverRule =
  259. ' *:hover > .anchorjs-link,' +
  260. ' .anchorjs-link:focus {' +
  261. ' opacity: 1;' +
  262. ' }',
  263. anchorjsLinkFontFace =
  264. ' @font-face {' +
  265. ' font-family: "anchorjs-icons";' + // Icon from icomoon; 10px wide & 10px tall; 2 empty below & 4 above
  266. ' src: url(data:n/a;base64,AAEAAAALAIAAAwAwT1MvMg8yG2cAAAE4AAAAYGNtYXDp3gC3AAABpAAAAExnYXNwAAAAEAAAA9wAAAAIZ2x5ZlQCcfwAAAH4AAABCGhlYWQHFvHyAAAAvAAAADZoaGVhBnACFwAAAPQAAAAkaG10eASAADEAAAGYAAAADGxvY2EACACEAAAB8AAAAAhtYXhwAAYAVwAAARgAAAAgbmFtZQGOH9cAAAMAAAAAunBvc3QAAwAAAAADvAAAACAAAQAAAAEAAHzE2p9fDzz1AAkEAAAAAADRecUWAAAAANQA6R8AAAAAAoACwAAAAAgAAgAAAAAAAAABAAADwP/AAAACgAAA/9MCrQABAAAAAAAAAAAAAAAAAAAAAwABAAAAAwBVAAIAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAMCQAGQAAUAAAKZAswAAACPApkCzAAAAesAMwEJAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAAAAAAQAAg//0DwP/AAEADwABAAAAAAQAAAAAAAAAAAAAAIAAAAAAAAAIAAAACgAAxAAAAAwAAAAMAAAAcAAEAAwAAABwAAwABAAAAHAAEADAAAAAIAAgAAgAAACDpy//9//8AAAAg6cv//f///+EWNwADAAEAAAAAAAAAAAAAAAAACACEAAEAAAAAAAAAAAAAAAAxAAACAAQARAKAAsAAKwBUAAABIiYnJjQ3NzY2MzIWFxYUBwcGIicmNDc3NjQnJiYjIgYHBwYUFxYUBwYGIwciJicmNDc3NjIXFhQHBwYUFxYWMzI2Nzc2NCcmNDc2MhcWFAcHBgYjARQGDAUtLXoWOR8fORYtLTgKGwoKCjgaGg0gEhIgDXoaGgkJBQwHdR85Fi0tOAobCgoKOBoaDSASEiANehoaCQkKGwotLXoWOR8BMwUFLYEuehYXFxYugC44CQkKGwo4GkoaDQ0NDXoaShoKGwoFBe8XFi6ALjgJCQobCjgaShoNDQ0NehpKGgobCgoKLYEuehYXAAAADACWAAEAAAAAAAEACAAAAAEAAAAAAAIAAwAIAAEAAAAAAAMACAAAAAEAAAAAAAQACAAAAAEAAAAAAAUAAQALAAEAAAAAAAYACAAAAAMAAQQJAAEAEAAMAAMAAQQJAAIABgAcAAMAAQQJAAMAEAAMAAMAAQQJAAQAEAAMAAMAAQQJAAUAAgAiAAMAAQQJAAYAEAAMYW5jaG9yanM0MDBAAGEAbgBjAGgAbwByAGoAcwA0ADAAMABAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAH//wAP) format("truetype");' +
  267. ' }',
  268. pseudoElContent =
  269. ' [data-anchorjs-icon]::after {' +
  270. ' content: attr(data-anchorjs-icon);' +
  271. ' }',
  272. firstStyleEl;
  273. style.className = 'anchorjs';
  274. style.appendChild(document.createTextNode('')); // Necessary for Webkit.
  275. // We place it in the head with the other style tags, if possible, so as to
  276. // not look out of place. We insert before the others so these styles can be
  277. // overridden if necessary.
  278. firstStyleEl = document.head.querySelector('[rel="stylesheet"], style');
  279. if (firstStyleEl === undefined) {
  280. document.head.appendChild(style);
  281. } else {
  282. document.head.insertBefore(style, firstStyleEl);
  283. }
  284. style.sheet.insertRule(linkRule, style.sheet.cssRules.length);
  285. style.sheet.insertRule(hoverRule, style.sheet.cssRules.length);
  286. style.sheet.insertRule(pseudoElContent, style.sheet.cssRules.length);
  287. style.sheet.insertRule(anchorjsLinkFontFace, style.sheet.cssRules.length);
  288. }
  289. }
  290. return AnchorJS;
  291. }));