Delicious Userscript Library

A library for userscripts on AnimeBytes which implements a settings page, among other things. Made by momentary0 on github (https://github.com/momentary0/AB-Userscripts/). I just uploaded here for use in my script since I couldn't get greasyfork to approve my require even with integrity checks.

As of 2022-12-07. See the latest version.

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greatest.deepsurf.us/scripts/456220/1125927/Delicious%20Userscript%20Library.js

  1. /**
  2. * @file Library for userscripts on AnimeBytes.
  3. * @author TheFallingMan
  4. * @version 1.1.0
  5. * @license GPL-3.0
  6. *
  7. * Exports `delicious`, containing `delicious.settings` and
  8. * `delicious.utilities`.
  9. *
  10. * This implements settings, providing functions for storing and setting
  11. * values, and methods to create an organised userscript settings page within
  12. * the user's profile settings.
  13. *
  14. * Additionally, provides several (hopefully) useful functions through
  15. * `delicious.utilities`.
  16. */
  17.  
  18. /* global GM_setValue:false, GM_getValue:false */
  19.  
  20. /**
  21. * @namespace
  22. * Root namespace for the delicious library.
  23. */
  24. var delicious = (function ABDeliciousLibrary(){ // eslint-disable-line no-unused-vars
  25. "use strict";
  26.  
  27. /**
  28. * A helper function for creating a HTML element, defining some properties
  29. * on it and appending child nodes.
  30. *
  31. * @param {string} tagName The type of element to create.
  32. * @param {Object.<string, any>} properties
  33. * An object containing properties to set on the new element.
  34. * Note: does not support nested elements (e.g. "style.width" does _not_ work).
  35. * @param {(Node[]|string[])} children Child nodes and/or text to append.
  36. */
  37. function newElement(tagName, properties, children) {
  38. var elem = document.createElement(tagName);
  39. if (properties) {
  40. for (var key in properties) {
  41. if (properties.hasOwnProperty(key)) {
  42. elem[key] = properties[key];
  43. }
  44. }
  45. }
  46. if (children) {
  47. for (var i = 0; i < children.length; i++) {
  48. if (typeof children[i] === 'string') {
  49. elem.appendChild(document.createTextNode(children[i]));
  50. } else {
  51. elem.appendChild(children[i]);
  52. }
  53. }
  54. }
  55. return elem;
  56. }
  57.  
  58. /**
  59. * Logs a message to the debug console, prefixing it if it is a string.
  60. *
  61. * @param {any} message
  62. */
  63. function log(message) {
  64. console.debug(
  65. typeof message === 'string' ? ('[Delicious] '+message) : message
  66. );
  67. }
  68.  
  69. /**
  70. * Uesful Javascript functions related to AnimeBytes.
  71. */
  72. var utilities = {
  73. /**
  74. * Click handler for those triangles which drop down menus. Toggles
  75. * displaying the associated submenu.
  76. *
  77. * @param {MouseEvent} ev
  78. */
  79. toggleSubnav: function(ev) {
  80. // Begin at the bound element.
  81. var current = ev.target;
  82. // Keep traversing up the node's parents until we find an
  83. // adjacent .subnav element.
  84. while (current
  85. && !(current.nextSibling && current.nextSibling.classList.contains('subnav'))) {
  86. current = current.parentNode;
  87. }
  88. if (!current)
  89. return;
  90. var subnav = current.nextSibling;
  91.  
  92. // Remove already open menus.
  93. var l = document.querySelectorAll('ul.subnav');
  94. for (var i = 0; i < l.length; i++) {
  95. if (l[i] === subnav)
  96. continue;
  97. l[i].style.display = 'none';
  98. }
  99. var k = document.querySelectorAll('li.navmenu.selected');
  100. for (var j = 0; j < k.length; j++) {
  101. k[j].classList.remove('selected');
  102. }
  103.  
  104. // Logic to toggle visibility.
  105. var willShow = (subnav.style.display==='none');
  106. subnav.style.display = willShow?'block':'none';
  107. if (willShow)
  108. subnav.parentNode.classList.add('selected');
  109. else
  110. subnav.parentNode.classList.remove('selected');
  111.  
  112. ev.stopPropagation();
  113. ev.preventDefault();
  114. return false;
  115. },
  116.  
  117. /**
  118. * Applies default options to an object containing possibly
  119. * incomplete options.
  120. *
  121. * @param {Object.<string, any>} options User-specified options.
  122. * @param {Object.<string, any>} defaults Default options.
  123. * @returns {Object.<string, any>} Object containing user-specified
  124. * option if it is present, else the default.
  125. */
  126. applyDefaults: function(options, defaults) {
  127. if (!options)
  128. return defaults;
  129. var newObject = {};
  130. for (var key in defaults) {
  131. if (defaults.hasOwnProperty(key)) {
  132. if (options.hasOwnProperty(key))
  133. newObject[key] = options[key];
  134. else
  135. newObject[key] = defaults[key];
  136. }
  137. }
  138. for (var key2 in options) {
  139. if (!newObject.hasOwnProperty(key2)) {
  140. newObject[key2] = options[key2];
  141. }
  142. }
  143. return newObject;
  144. },
  145.  
  146. /**
  147. * Makes the given text suitable for inserting into HTML as text.
  148. *
  149. * @param {string} text Bare text.
  150. * @returns {string} HTML escaped text.
  151. */
  152. htmlEscape: function(text) {
  153. return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  154. },
  155.  
  156. /** A non-breaking space character. */
  157. nbsp: '\xa0',
  158.  
  159. _bytes_units: ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'],
  160. _bytes_base: 1024,
  161.  
  162. /**
  163. * Parses a string containing a number of bytes (e.g. "4.25 GiB") and
  164. * returns the number of bytes.
  165. *
  166. * Note: uses IEC prefixes (KiB, MiB, etc.).
  167. *
  168. * @param {string} bytesString Bytes as string.
  169. * @returns {number} Number of bytes.
  170. */
  171. parseBytes: function(bytesString) {
  172. var split = bytesString.split(/\s+/);
  173. var significand = parseFloat(split[0]);
  174. var magnitude = this._bytes_units.indexOf(split[1]);
  175. if (magnitude === -1)
  176. throw 'Bytes unit not recognised. Make sure you are using IEC prefixes (KiB, MiB, etc.)';
  177. return significand * Math.pow(this._bytes_base, magnitude);
  178. },
  179.  
  180. /**
  181. * Formats a number of bytes as a string with an appropriate unit.
  182. *
  183. * @param {number} numBytes Number of bytes
  184. * @param {number} [decimals=2] Number of decimal places to use.
  185. * @returns {string} Bytes formatted as string.
  186. */
  187. formatBytes: function(numBytes, decimals) {
  188. // Adapted from https://stackoverflow.com/a/18650828
  189. if (numBytes === 0)
  190. return '0 ' + this._bytes_units[0];
  191. if (decimals === undefined)
  192. decimals = 2;
  193. var magnitude = Math.floor(Math.log(numBytes) / Math.log(this._bytes_base));
  194. // Extra parseFloat is so trailing 0's are removed.
  195. return parseFloat(
  196. (numBytes / Math.pow(this._bytes_base, magnitude)).toFixed(decimals)
  197. ) + ' ' + this._bytes_units[magnitude];
  198. },
  199.  
  200. /**
  201. * Given a element.dataset property name in camelCase, returns the corresponding
  202. * data- attribute name with hyphens.
  203. * @param {string} str JS `dataset` name.
  204. * @returns {string} HTML `data-` name.
  205. */
  206. toDataAttr: function(str) {
  207. return 'data-'+str.replace(/[A-Z]/g, function(a){return '-'+a.toLowerCase();});
  208. }
  209. };
  210.  
  211. var _isSettingsPage = window.location.href.indexOf('/user.php?action=edit') !== -1;
  212.  
  213. /**
  214. * Container for all setting-related functions.
  215. */
  216. var settings = {
  217. /** Prefix used when setting element ID attributes. */
  218. _idPrefix: 'setting_',
  219. /** Event type used when saving. */
  220. _eventName: 'deliciousSave',
  221. /** Data attribute JS name for primary keys. */
  222. _settingKey: 'settingKey',
  223. /** Data attribute JS name for subkeys. */
  224. _settingSubkey: 'settingSubkey',
  225.  
  226. /** HTML attribute name for primary keys. */
  227. _dataSettingKey: utilities.toDataAttr('settingKey'),
  228. /** HTML attribute name for primary subkeys. */
  229. _dataSettingSubkey: utilities.toDataAttr('settingSubkey'),
  230.  
  231. /** Whether this page is a user settings page. */
  232. isSettingsPage: _isSettingsPage,
  233.  
  234. /**
  235. * Creates the delicious settings `div`.
  236. * @returns {HTMLDivElement}
  237. */
  238. _createDeliciousPage: function() {
  239. log('Creating settings page...');
  240. var settingsDiv = document.createElement('div');
  241. settingsDiv.id = 'delicious_settings';
  242.  
  243. var header = document.createElement('div');
  244. header.className = 'head colhead_dark strong';
  245. header.textContent = 'Userscript Settings';
  246. settingsDiv.appendChild(header);
  247.  
  248. var settingsList = document.createElement('ul');
  249. settingsList.className = 'nobullet ue_list';
  250.  
  251. var simpleSection = document.createElement('div');
  252. simpleSection.id = 'delicious_basic_settings';
  253. settingsList.appendChild(simpleSection);
  254.  
  255. settingsDiv.appendChild(settingsList);
  256.  
  257. return settingsDiv;
  258. },
  259.  
  260. /**
  261. * Click handler for user profile tab links. Displays the clicked page
  262. * and hides any other page.
  263. *
  264. * @param {MouseEvent} ev
  265. */
  266. _tabLinkClick: function(ev) {
  267. log('Clicked tab link: ' + ev.target.textContent);
  268. var clickedId = ev.target.getAttribute('href').replace(/^#/, '');
  269. document.querySelector('.ue_tabs .selected').classList.remove('selected');
  270. var tabs = document.querySelectorAll('#tabs > div');
  271. for (var i = 0; i < tabs.length; i++) {
  272. tabs[i].style.display = (tabs[i].id === clickedId)?'block':'none';
  273. }
  274.  
  275. ev.target.classList.add('selected');
  276. ev.stopPropagation();
  277. ev.preventDefault();
  278. return false;
  279. },
  280.  
  281. /**
  282. * Attaches our click handler to the existing tab links.
  283. */
  284. _relinkClickHandlers: function() {
  285. log('Rebinding tab click handlers...');
  286. var tabLinks = document.querySelectorAll('.ue_tabs a');
  287. for (var i = 0; i < tabLinks.length; i++) {
  288. tabLinks[i].addEventListener('click', this._tabLinkClick);
  289. }
  290. },
  291.  
  292. /**
  293. * Inserts the given settings div into the user settings page.
  294. * @param {string} label Name to display for this page.
  295. * @param {HTMLDivElement} settingsPage Element containing the page.
  296. */
  297. insertSettingsPage: function(label, settingsPage) {
  298. log('Inserting a settings page...');
  299. var linkItem = document.createElement('li');
  300. linkItem.appendChild(document.createTextNode('•'));
  301.  
  302. var link = document.createElement('a');
  303. link.href = '#' + settingsPage.id;
  304. link.textContent = label;
  305. linkItem.appendChild(link);
  306.  
  307. document.querySelector('.ue_tabs').appendChild(linkItem);
  308. this._relinkClickHandlers();
  309.  
  310. settingsPage.style.display = 'none';
  311. var tabs = document.querySelector('#tabs');
  312. tabs.insertBefore(settingsPage, tabs.lastElementChild);
  313. },
  314.  
  315. /**
  316. * Inserts the delicious settings page. Attaches a listener to the
  317. * form `submit` event, and temporarily disables the default `onsubmit`
  318. * attribute which is set.
  319. */
  320. _insertDeliciousSettings: function() {
  321. this.insertSettingsPage('Userscript Settings',
  322. this._createDeliciousPage());
  323.  
  324. var userform = document.querySelector('form#userform');
  325. userform.addEventListener('submit', this._deliciousSaveAndSubmit);
  326.  
  327. if (userform.hasAttribute('onsubmit')) {
  328. userform.dataset['onsubmit'] = userform.getAttribute('onsubmit');
  329. userform.removeAttribute('onsubmit');
  330. log('Previous onsubmit: ' + userform.dataset['onsubmit']);
  331. }
  332. },
  333.  
  334. _settingsInserted: !!document.getElementById('delicious_settings'),
  335.  
  336. /**
  337. * Ensures the settings page has been inserted, creating and inserting
  338. * it if the page is a user settings page.
  339. *
  340. * Returns true if on the user settings page, false otherwise.
  341. */
  342. ensureSettingsInserted: function() {
  343. if (!this.isSettingsPage) {
  344. log('Not a profile settings page; doing nothing...');
  345. if (!this.rootSettingsList) {
  346. this._basicSection = newElement('div',
  347. {id: 'delicious_basic_settings',
  348. className: 'dummy'});
  349. this.rootSettingsList = newElement('ul',
  350. {className: 'dummy nobullet ue_list'},
  351. [this._basicSection]);
  352. }
  353. return false;
  354. } else {
  355. if (!this._settingsInserted) {
  356. log('Settings not yet inserted; inserting...');
  357. this._settingsInserted = true;
  358.  
  359. this._insertDeliciousSettings();
  360. } else {
  361. log('Settings already inserted; continuing...');
  362. }
  363. if (!this.rootSettingsList) {
  364. log('Locating settings div...');
  365. this.rootSettingsList = document.querySelector('#delicious_settings .ue_list');
  366. this._basicSection = this.rootSettingsList.querySelector('#delicious_basic_settings');
  367. }
  368. return true;
  369. }
  370. },
  371.  
  372. /**
  373. * Saves the settings and submits the rest of the user settings form.
  374. * @param {Event} ev Form element.
  375. */
  376. _deliciousSaveAndSubmit: function(ev) {
  377. if (settings.saveAllSettings(ev.target)) {
  378. ev.target.removeEventListener('submit', settings._deliciousSaveAndSubmit);
  379. if (ev.target.dataset['onsubmit'])
  380. ev.target.setAttribute('onsubmit', ev.target.dataset['onsubmit']);
  381. ev.target.submit();
  382. } else {
  383. var errorBox = document.querySelector('.error_message');
  384. if (errorBox)
  385. errorBox.scrollIntoView();
  386. ev.stopPropagation();
  387. ev.preventDefault();
  388. }
  389. },
  390.  
  391. /**
  392. * Sends the save event to all elements contained within `rootElement`
  393. * and with the appropriate `data-` settings attribute set.
  394. * @param {HTMLElement} rootElement Root element.
  395. * @returns {boolean} True if all elements saved successfully, false otherwise.
  396. */
  397. saveAllSettings: function(rootElement) {
  398. log('Saving all settings...');
  399. var cancelled = false;
  400. var settingsItems = rootElement.querySelectorAll('['+this._dataSettingKey+']');
  401. for (var i = 0; i < settingsItems.length; i++) {
  402. log('Sending save event for setting key: ' + settingsItems[i].dataset[this._settingKey]);
  403. var saveEvent = new Event(this._eventName, {cancelable: true});
  404. if (!settingsItems[i].dispatchEvent(saveEvent)) {
  405. cancelled = true;
  406. }
  407. }
  408. log('Form submit cancelled: ' + cancelled);
  409. return !cancelled;
  410. },
  411.  
  412. /**
  413. * Saves an element, reading the key from its dataset and
  414. * the value from its `property` attribute.
  415. * @param {HTMLElement} element
  416. * @param {string} property
  417. */
  418. saveOneElement: function(element, property) {
  419. if (element.dataset[this._settingKey])
  420. this.set(element.dataset[this._settingKey], element[property]);
  421. else
  422. log('Skipping blank: ' + element.outerHTML);
  423. },
  424.  
  425. /**
  426. * If `key` is not set, set it to `defaultValue` and returns `defaultValue`.
  427. * Otherwise, returns the stored value.
  428. * @param {string} key
  429. * @param {any} defaultValue
  430. * @returns {any}
  431. */
  432. init: function(key, defaultValue) {
  433. var value = this.get(key, undefined);
  434. if (value === undefined) {
  435. this.set(key, defaultValue);
  436. return defaultValue;
  437. } else {
  438. return value;
  439. }
  440. },
  441.  
  442. /**
  443. * Sets `key` to `value`. Currently uses GM_setValue, storing internally
  444. * as JSON.
  445. */
  446. set: function(key, value) {
  447. GM_setValue(key, JSON.stringify(value));
  448. },
  449.  
  450. /**
  451. * Gets `key`, returns `defaultValue` if it is not set.
  452. * Currently uses GM_getValue, storing internally as JSON.
  453. */
  454. get: function(key, defaultValue) {
  455. var value = GM_getValue(key, undefined);
  456. if (value !== undefined) {
  457. return JSON.parse(value);
  458. } else {
  459. return defaultValue;
  460. }
  461. },
  462.  
  463. /**
  464. * Migrates a string stored in `key` as a bare string to a
  465. * JSON encoded string.
  466. * @param {string} key Setting key.
  467. * @returns {any} String value.
  468. */
  469. _migrateStringSetting: function(key) {
  470. var val;
  471. try {
  472. val = this.get(key);
  473. } catch (exc) {
  474. if (exc instanceof SyntaxError
  475. && GM_getValue(key, undefined) !== undefined) {
  476. // Assume the current variable is a bare string.
  477. // Re-store it as a JSON string.
  478. val = GM_getValue(key);
  479. this.set(key, val);
  480. } else {
  481. throw exc; // Something else happened
  482. }
  483. }
  484. return val;
  485. },
  486.  
  487. /**
  488. * Inserts `newElement` as a chlid of `rootElement` sorted, by comparing
  489. * `newText` to each element's textContent.
  490. *
  491. * If `refElement` is specified, will start _after_ `refElement`.
  492. *
  493. * @param {HTMLElement} newElement Element to insert.
  494. * @param {HTMLElement} rootElement Parent element to insert `newElement` into.
  495. * @param {HTMLElement} [refElement] Reference element to insert after this or later.
  496. */
  497. _insertSorted: function(newElement, rootElement, refElement) {
  498. var current = rootElement.firstElementChild;
  499. if (refElement) {
  500. if (refElement.parentNode !== rootElement)
  501. throw 'refElement is not a direct child of rootElement';
  502. current = refElement.nextElementSibling;
  503. }
  504. while (current && (current.textContent <= newElement.textContent)) {
  505. current = current.nextElementSibling;
  506. }
  507. if (current) {
  508. rootElement.insertBefore(newElement, current);
  509. } else {
  510. rootElement.appendChild(newElement);
  511. }
  512. },
  513.  
  514. /**
  515. * Inserts a checkbox with the given parameters to the basic settings
  516. * section. Returns true if the stored `key` value is true, false otherwise.
  517. *
  518. * @param {string} key Setting key.
  519. * @param {string} label Label for setting, placed in left column.
  520. * @param {string} description Description for setting, placed right of checkbox.
  521. * @returns {boolean} Value of the `key` setting.
  522. * @example
  523. * // Very basic enable/disable script setting.
  524. * if (!delicious.settings.basicScriptCheckbox('EnableHideTreats', 'Hides Treats', 'Hide those hideous treats!')) {
  525. * return;
  526. * }
  527. * // Rest of userscript here.
  528. */
  529. basicScriptCheckbox: function(key, label, description) {
  530. this.init(key, true);
  531. if (this.ensureSettingsInserted()) {
  532. this.addBasicCheckbox(key, label, description);
  533. }
  534. return this.get(key);
  535. },
  536.  
  537. /**
  538. * Inserts a checkbox to the basic section and returns it.
  539. *
  540. * @param {string} key Setting key.
  541. * @param {string} label Left label.
  542. * @param {string} description Right description.
  543. * @param {Object.<string, any>} options Further options for the checkbox.
  544. * @see {settings.createCheckbox} for accepted `options`.
  545. */
  546. addBasicCheckbox: function(key, label, description, options) {
  547. var checkboxLI = this.createCheckbox(
  548. key, label, description, options);
  549. this.insertBasicSetting(checkboxLI);
  550. return checkboxLI;
  551. },
  552.  
  553. /**
  554. * Inserts an element containing a basic setting to the basic settings
  555. * section, above the individual script sections.
  556. * @param {HTMLElement} setting Setting element.
  557. */
  558. insertBasicSetting: function(setting) {
  559. this._insertSorted(setting, this._basicSection);
  560. },
  561.  
  562. /**
  563. * Creates, inserts and returns a script section to the settings page.
  564. * Inserts an Enable/Disable checkbox associated with `key` into
  565. * the section.
  566. * @param {string} key Setting key.
  567. * @param {string} title Section title.
  568. * @param {string} description Basic description.
  569. * @param {Object.<string, any>} options Further options for the checkbox.
  570. */
  571. addScriptSection: function(key, title, description, options) {
  572. options = utilities.applyDefaults(options, {
  573. checkbox: false
  574. });
  575.  
  576. var section = this.createSection(title);
  577.  
  578. if (options['checkbox']) {
  579. var enableBox = this.createCheckbox(key, 'Enable/Disable', description, options);
  580. section.appendChild(enableBox);
  581. }
  582.  
  583. this._insertSorted(section, this.rootSettingsList,
  584. this._basicSection);
  585. return section;
  586. },
  587.  
  588. /**
  589. * Inserts a section into the settings page, placing it after the
  590. * basic settings and sorting it alphabetically.
  591. * @param {HTMLElement} section Setting section.
  592. */
  593. insertSection: function(section) {
  594. this._insertSorted(section, this.rootSettingsList,
  595. this._basicSection);
  596. },
  597.  
  598. _createSettingLI: function(label, rightElements) {
  599. return newElement('li', {}, [
  600. newElement('span', {className: 'ue_left strong'}, [label]),
  601. newElement('span', {className: 'ue_right'}, rightElements),
  602. ]);
  603. },
  604.  
  605. /**
  606. * @param {string} key Setting key.
  607. * @param {string} label Label text.
  608. * @param {string} description Short description.
  609. * @param {Object.<string, any>} options Further options (see source code).
  610. */
  611. createCheckbox: function(key, label, description, options) {
  612. options = utilities.applyDefaults(options, {
  613. default: true, // Default state of checkbox.
  614. onSave: function(ev) {
  615. settings.saveOneElement(ev.target, 'checked');
  616. }
  617. });
  618.  
  619. var checkbox = newElement('input', {type: 'checkbox'});
  620. checkbox.dataset[this._settingKey] = key;
  621. checkbox.id = this._idPrefix + key;
  622.  
  623. var currentValue = options['default'];
  624. if (this.get(key, currentValue))
  625. checkbox.setAttribute('checked', 'checked');
  626.  
  627. if (options['onSave'] !== null) {
  628. checkbox.addEventListener(this._eventName, options['onSave']);
  629. }
  630.  
  631. var li = this._createSettingLI(label, [
  632. checkbox,
  633. ' ',
  634. newElement('label', {htmlFor: this._idPrefix+key}, [description]),
  635. ]);
  636.  
  637. return li;
  638. },
  639.  
  640. /**
  641. * Event handler attached to h3 section heading. Toggles visibility
  642. * of associated section body.
  643. */
  644. _toggleSection: function(ev) {
  645. var sectionBody = ev.currentTarget.parentNode.parentNode.nextElementSibling;
  646. var willShow = sectionBody.style.display === 'none';
  647. sectionBody.style.display = willShow ? 'block' : 'none';
  648.  
  649. var toggleTriangle = ev.currentTarget.firstElementChild;
  650. toggleTriangle.textContent = willShow ? '▼' : '▶';
  651.  
  652. ev.preventDefault();
  653. ev.stopPropagation();
  654. },
  655.  
  656. /**
  657. * Creates a collapsible script section with the given title.
  658. * Clicking the section heading will toggle the visibility of the
  659. * section's settings.
  660. *
  661. * **Important.** Appending directly into the returned div element will
  662. * not work. You must append to the section's body div.
  663. *
  664. * Correct example:
  665. *
  666. * var section = delicious.settings.createCollapsibleSection('Script Name');
  667. * var s = section.querySelector('.settings_section_body');
  668. * s.appendChild(delicious.settings.createCheckbox(...));
  669. * delicious.setttings.insertSection(section);
  670. *
  671. * Incorrect example:
  672. *
  673. * var section = delicious.settings.createCollapsibleSection('Script Name');
  674. * // This will not be able to collapse/expand the section correctly!
  675. * section.appendChild(delicious.settings.createCheckbox(...));
  676. * delicious.setttings.insertSection(section);
  677. *
  678. *
  679. * @param {string} title Script title.
  680. * @param {boolean} defaultState If true, the section will be expanded by default.
  681. * @returns {HTMLDivElement} Script section.
  682. */
  683. createCollapsibleSection: function(title, defaultState) {
  684. var toggleTriangle = newElement('a',
  685. {textContent: (defaultState ? '▼' : '▶')});
  686. var heading = newElement('h3', {}, [toggleTriangle, ' ', title]);
  687. heading.style.cursor = 'pointer';
  688. heading.addEventListener('click', this._toggleSection);
  689.  
  690. var sectionHeading = newElement('div', {className: 'settings_section_heading'},
  691. [newElement('li', {}, [heading]) ]);
  692. sectionHeading.style.marginBottom = '20px';
  693.  
  694. var sectionBody = newElement('div', {className: 'settings_section_body'});
  695. sectionBody.style.display = defaultState ? 'block' : 'none';
  696.  
  697. var section = newElement('div', {className: 'delicious_settings_section'},
  698. [sectionHeading, sectionBody]);
  699. section.style.marginTop = '30px';
  700. return section;
  701. },
  702.  
  703. /**
  704. * Creates a setting section, returns it but does not insert it into
  705. * the page.
  706. */
  707. createSection: function(title) {
  708. var heading = newElement('h3', {}, [title]);
  709. var section = newElement('div', {className: 'delicious_settings_section'}, [
  710. newElement('li', {}, [heading])
  711. ]);
  712. section.style.marginTop = '30px';
  713. return section;
  714. },
  715.  
  716. /**
  717. * @param {string} key Setting key.
  718. * @param {string} label Label text.
  719. * @param {string} description Short description.
  720. * @param {Object.<string, any>} options Further options (see source code).
  721. */
  722. createTextSetting: function(key, label, description, options) {
  723. options = utilities.applyDefaults(options, {
  724. width: null, // CSS 'width' for the text box.
  725. lineBreak: false, // Whether to place the description on its own line.
  726. default: '', // Default text.
  727. required: false, // If true, text cannot be blank.
  728. onSave: function(ev) {
  729. settings.saveOneElement(ev.target, 'value');
  730. }
  731. });
  732.  
  733. var inputElem = newElement('input', {
  734. type: 'text',
  735. id: this._idPrefix+key
  736. });
  737. inputElem.value = this.get(key, options['default']);
  738. inputElem.dataset[this._settingKey] = key;
  739. inputElem.style.width = options['width'];
  740. inputElem.required = options['required'];
  741.  
  742. var li = this._createSettingLI(label, [
  743. inputElem,
  744. (options['lineBreak'] && description) ? newElement('br') : ' ',
  745. newElement('label', {htmlFor: this._idPrefix+key}, [description])
  746. ]);
  747.  
  748. if (options['onSave'] !== null) {
  749. inputElem.addEventListener(this._eventName, options['onSave']);
  750. }
  751.  
  752. return li;
  753. },
  754.  
  755. /**
  756. * Creates and returns a drop-down setting.
  757. *
  758. * `valuesArray` must contain 2-tuples of strings; values will
  759. * be stored as strings.
  760. *
  761. * The default value specified in `options` must be identical to a
  762. * setting value in `valuesArray`.
  763. * @example
  764. * // Creates a drop-down with 2 options, and the second option default.
  765. * delicious.settings.createDropdown('TimeUnit', 'Select time',
  766. * 'Select a time unit to use', [['Hour', '1'], ['Day', '24']],
  767. * {default: '24'})
  768. * @param {string} key Setting key.
  769. * @param {string} label Left label.
  770. * @param {string} description Right description.
  771. * @param {[string, string][]} valuesArray Array of 2-tuples [text, setting value].
  772. * @param {Object.<string, any>} options Further options.
  773. */
  774. createDropDown: function(key, label, description, valuesArray, options) {
  775. options = utilities.applyDefaults(options, {
  776. lineBreak: false, // Whether to place the description on its own line.
  777. default: null, // Default value.
  778. onSave: function(ev) {
  779. settings.saveOneElement(ev.target, 'value');
  780. }
  781. });
  782.  
  783. var select = newElement('select');
  784. select.dataset[this._settingKey] = key;
  785. select.id = this._idPrefix+key;
  786.  
  787. var currentValue = this.get(key, options['default']);
  788.  
  789. for (var i = 0; i < valuesArray .length; i++) {
  790. var newOption = newElement('option', {
  791. value: valuesArray[i][1],
  792. textContent: valuesArray[i][0]
  793. });
  794. if (valuesArray[i][1] === currentValue)
  795. newOption.setAttribute('selected', 'selected');
  796. select.appendChild(newOption);
  797. }
  798.  
  799. var li = this._createSettingLI(label, [
  800. select,
  801. (options['lineBreak'] && description) ? newElement('br') : ' ',
  802. newElement('label', {htmlFor: this._idPrefix+key}, [description])
  803. ]);
  804.  
  805. if (options['onSave'] !== null) {
  806. select.addEventListener(this._eventName, options['onSave']);
  807. }
  808.  
  809. return li;
  810. },
  811.  
  812. /**
  813. * Returns a number setting element. Value is stored as a number.
  814. * Note that an empty input is stored as `null`. Empty input can be
  815. * disallowed by specifying `{required: true}` in `options`.
  816. *
  817. * @param {string} key Setting key.
  818. * @param {string} label Label text.
  819. * @param {string} description Short description.
  820. * @param {Object.<string, any>} options Further options (see source code).
  821. */
  822. createNumberInput: function(key, label, description, options) {
  823. options = utilities.applyDefaults(options, {
  824. lineBreak: false, // Whether to place the description on its own line.
  825. default: '', // Default value.
  826. allowDecimal: true,
  827. allowNegative: false,
  828. required: false, // If true, input cannot be blank.
  829. onSave: function(ev) {
  830. settings.set(key, parseFloat(ev.target.value));
  831. }
  832. });
  833.  
  834. var input = newElement('input');
  835. input.id = this._idPrefix+key;
  836. input.dataset[this._settingKey] = key;
  837. input.type = 'number';
  838. if (options['allowDecimal'])
  839. input.step = 'any';
  840. if (!options['allowNegative'])
  841. input.min = '0';
  842. input.required = options['required'];
  843. input.value = this.get(key, options['default']);
  844.  
  845. var li = this._createSettingLI(label, [
  846. input,
  847. (options['lineBreak'] && description) ? newElement('br') : ' ',
  848. newElement('label', {htmlFor: this._idPrefix+key}, [description])
  849. ]);
  850.  
  851. if (options['onSave'] !== null) {
  852. input.addEventListener(this._eventName, options['onSave']);
  853. }
  854.  
  855. return li;
  856. },
  857.  
  858. /**
  859. * Creates a setting containing many checkboxes. Stores the value as
  860. * an object, with subkeys as keys and true/false as values.
  861. *
  862. * @example
  863. * // Creates a setting with 2 checkboxes,
  864. * delicious.settings.createFieldSetSetting('FLPoolLocations',
  865. * 'Freeleech status locations',
  866. * [['Navbar', 'navbar'], ['User menu', 'usermenu']]);
  867. * // Example stored value
  868. * delicious.settings.get('FLPoolLocations') == {
  869. * 'navbar': true,
  870. * 'usermenu': false
  871. * };
  872. *
  873. * @param {string} key Root setting key.
  874. * @param {string} label Label text.
  875. * @param {[string, string][]} fields Array of 2-tuples of [text, subkey].
  876. * @param {string} description Short description.
  877. * @param {Object.<string, any>} options Further options (see source code).
  878. */
  879. createFieldSetSetting: function(key, label, fields, description, options) {
  880. options = utilities.applyDefaults(options, {
  881. default: [],
  882. onSave: function(ev) {
  883. var obj = {};
  884. var checkboxes = ev.target.querySelectorAll('['+settings._dataSettingSubkey+']');
  885. for (var i = 0; i < checkboxes.length; i++) {
  886. obj[checkboxes[i].dataset[settings._settingSubkey]] = checkboxes[i].checked;
  887. }
  888. settings.set(ev.target.dataset[settings._settingKey], obj);
  889. }
  890. });
  891.  
  892. var fieldset = newElement('span');
  893. fieldset.dataset[this._settingKey] = key;
  894.  
  895. var currentSettings = this.get(key, {});
  896.  
  897. for (var i = 0; i < fields.length; i++) {
  898. var checkbox = newElement('input');
  899. checkbox.type = 'checkbox';
  900. checkbox.id = this._idPrefix+key+'_'+fields[i][1];
  901. checkbox.dataset[this._settingSubkey] = fields[i][1];
  902.  
  903. var current = currentSettings[fields[i][1]];
  904. if (current === undefined)
  905. current = options['default'].indexOf(fields[i][1]) !== -1;
  906.  
  907. if (current)
  908. checkbox.checked = true;
  909.  
  910. var newLabel = newElement('label', {htmlFor: this._idPrefix+key+'_'+fields[i][1]}, [
  911. checkbox, ' ', fields[i][0]
  912. ]);
  913. newLabel.style.marginRight = '15px';
  914.  
  915. fieldset.appendChild(newLabel);
  916. }
  917.  
  918. if (options['onSave'] !== null) {
  919. fieldset.addEventListener(this._eventName, options['onSave']);
  920. }
  921.  
  922. var children = [fieldset];
  923. if (description) {
  924. children.push(newElement('br'));
  925. children.push(description);
  926. }
  927.  
  928. var li = this._createSettingLI(label, children);
  929.  
  930. return li;
  931. },
  932.  
  933. /**
  934. * Event handler to move the containing row up one.
  935. */
  936. _moveRowUp: function(ev) {
  937. var thisRow = ev.target.parentNode;
  938. if (thisRow.previousElementSibling) {
  939. thisRow.parentNode.insertBefore(
  940. thisRow,
  941. thisRow.previousElementSibling
  942. );
  943. }
  944. if (ev.preventDefault) {
  945. ev.preventDefault();
  946. ev.stopPropagation();
  947. }
  948. },
  949.  
  950. /**
  951. * Event handler to move the containing row down one.
  952. * Implemented by moving the row underneath this one up one.
  953. */
  954. _moveRowDown: function(ev) {
  955. var thisRow = ev.target.parentNode;
  956. if (thisRow.nextElementSibling) {
  957. settings._moveRowUp({target: thisRow.nextElementSibling.firstElementChild});
  958. }
  959. ev.preventDefault();
  960. ev.stopPropagation();
  961. },
  962.  
  963. /**
  964. * Event handler to delete a row.
  965. */
  966. _deleteRow: function(ev) {
  967. var row = ev.target.parentNode;
  968. row.parentNode.removeChild(row);
  969. ev.preventDefault();
  970. ev.stopPropagation();
  971. },
  972.  
  973. /**
  974. * Creates a new row for a multi-row setting. Used when clicking
  975. * the new row button.
  976. */
  977. _createRow: function(values, columns, allowSort, allowDelete) {
  978. var row = newElement('div', {className: 'setting_row'});
  979. row.style.marginBottom = '2px';
  980.  
  981. if (allowSort === undefined || allowSort) {
  982. var upButton = newElement('button', {textContent: '▲',
  983. title: 'Move up'});
  984. upButton.addEventListener('click', this._moveRowUp);
  985. row.appendChild(upButton);
  986. row.appendChild(document.createTextNode(' '));
  987.  
  988. var downButton = newElement('button', {textContent: '▼',
  989. title: 'Move down'});
  990. downButton.addEventListener('click', this._moveRowDown);
  991. row.appendChild(downButton);
  992. row.appendChild(document.createTextNode(' '));
  993. }
  994.  
  995. for (var i = 0; i < columns.length; i++) {
  996. var cell = newElement('input', {type: columns[i][2]});
  997. if (cell.type === 'number') {
  998. cell.min = '0';
  999. cell.step = 'any';
  1000. }
  1001. var subkey = columns[i][1];
  1002. cell.dataset[this._settingSubkey] = subkey;
  1003. cell.placeholder = columns[i][0];
  1004. if (values[subkey] !== undefined) {
  1005. cell.value = values[subkey];
  1006. }
  1007. row.appendChild(cell);
  1008. row.appendChild(document.createTextNode(' '));
  1009. }
  1010.  
  1011. if (allowDelete === undefined || allowDelete) {
  1012. var delButton = newElement('button', {textContent: '✖',
  1013. title: 'Delete'});
  1014. delButton.addEventListener('click', this._deleteRow);
  1015. row.appendChild(delButton);
  1016. }
  1017.  
  1018. return row;
  1019. },
  1020.  
  1021. /**
  1022. * Creates and returns a multi-row setting. That is, a setting with
  1023. * certain columns and a variable number of rows.
  1024. *
  1025. * Setting is stored as an array of objects. Every input type except
  1026. * number is stored as a string. Number input allows any non-negative number,
  1027. * possibly blank. If blank, a number input will be stored as null.
  1028. *
  1029. * @example
  1030. * // Returns a row setting with one row by default.
  1031. * delicious.settings.createRowSetting('QuickLinks', 'Quick Links',
  1032. * [['Label', 'label', 'text'], ['Link', 'href', 'text']],
  1033. * {default: [{label: 'Home', href: 'https://animebytes.tv'}]})
  1034. *
  1035. * // Example stored value.
  1036. * delicious.settings.get('QuickLinks') == [
  1037. * {label: 'Home', href: 'https://animebytes.tv'}
  1038. * ];
  1039. *
  1040. * @param {string} key Root setting key.
  1041. * @param {string | HTMLElement} label Left label.
  1042. * @param {string[][]} columns Array of 3-tuples which are
  1043. * [column label, subkey, input type]. Input type is the `type` attribute
  1044. * of the cell's `<input>` element.
  1045. * @param {string | HTMLElement} description Short description, placed above rows.
  1046. * @param {Object} options Further options (see source code).
  1047. */
  1048. createRowSetting: function(key, label, columns, description, options) {
  1049. options = utilities.applyDefaults(options, {
  1050. default: [],
  1051. newButtonText: '+',
  1052. allowSort: true,
  1053. allowDelete: true,
  1054. allowNew: true,
  1055. onSave: function(ev) {
  1056. var list = [];
  1057. var rows = ev.target.querySelectorAll('.setting_row');
  1058. for (var i = 0; i < rows.length; i++) {
  1059. var obj = {};
  1060. var columns = rows[i].querySelectorAll('['+settings._dataSettingSubkey+']');
  1061. for (var j = 0; j < columns.length; j++) {
  1062. var val = columns[j].value;
  1063. if (columns[j].type === 'number')
  1064. val = parseFloat(val);
  1065. obj[columns[j].dataset[settings._settingSubkey]] = val;
  1066. }
  1067. list.push(obj);
  1068. }
  1069. settings.set(key, list);
  1070. }
  1071. });
  1072.  
  1073. var children;
  1074. if (description) {
  1075. children = [description, newElement('br')];
  1076. } else {
  1077. children = undefined;
  1078. }
  1079. var rowDiv = newElement('div', {className: 'ue_right'}, children);
  1080.  
  1081. var rowContainer = newElement('div');
  1082. rowContainer.dataset[this._settingKey] = key;
  1083. rowContainer.className = 'row_container';
  1084. if (options['onSave'] !== null)
  1085. rowContainer.addEventListener(this._eventName, options['onSave']);
  1086. if (description)
  1087. rowContainer.style.marginTop = '5px';
  1088. rowDiv.appendChild(rowContainer);
  1089.  
  1090. var current = this.get(key, options['default']);
  1091. for (var i = 0; i < current.length; i++) {
  1092. rowContainer.appendChild(
  1093. this._createRow(current[i], columns, options['allowSort'],
  1094. options['allowDelete']));
  1095. }
  1096.  
  1097. if (options['allowNew']) {
  1098. var newButton = newElement('button', {textContent: options['newButtonText'], title: 'New'});
  1099. newButton.style.marginTop = '8px';
  1100. newButton.addEventListener('click', function(ev) {
  1101. rowContainer.appendChild(
  1102. settings._createRow({}, columns,
  1103. options['allowSort'],
  1104. options['allowDelete']));
  1105. ev.preventDefault();
  1106. ev.stopPropagation();
  1107. });
  1108. rowDiv.appendChild(newButton);
  1109. }
  1110.  
  1111. var li = newElement('li', {}, [
  1112. newElement('span', {className: 'ue_left strong'}, [label]),
  1113. rowDiv
  1114. ]);
  1115.  
  1116. return li;
  1117. },
  1118.  
  1119. /**
  1120. * Returns a colour input, with optional checkbox to enable/disable
  1121. * the whole setting and reset to default value.
  1122. *
  1123. * If the checkbox is unchecked, a `null` will be stored as the value.
  1124. * Else, the colour will be stored as #rrggbb.
  1125. *
  1126. * @param {string} key Setting key.
  1127. * @param {string} label Left label.
  1128. * @param {string} description Short description on right.
  1129. * @param {Object.<string, any>} options Further options (see source code).
  1130. */
  1131. createColourSetting: function(key, label, description, options) {
  1132. options = utilities.applyDefaults(options, {
  1133. default: '#000000',
  1134. checkbox: true,
  1135. resetButton: true,
  1136. onSave: function(ev) {
  1137. if (options['checkbox'] && !checkbox.checked) {
  1138. settings.set(key, null);
  1139. } else {
  1140. settings.set(key, ev.target.value);
  1141. }
  1142. }
  1143. });
  1144.  
  1145. var currentColour = this.get(key, options['default']);
  1146.  
  1147. var disabled = currentColour === null && options['checkbox'];
  1148. if (options['checkbox']) {
  1149. var checkbox = newElement('input',
  1150. {type: 'checkbox', checked: !disabled});
  1151. checkbox.addEventListener('change', function(ev) {
  1152. colour.disabled = !ev.target.checked;
  1153. if (reset)
  1154. reset.disabled = !ev.target.checked;
  1155. ev.stopPropagation();
  1156. });
  1157. }
  1158.  
  1159. var colour = newElement('input', {type: 'color'});
  1160. colour.dataset[this._settingKey] = key;
  1161. colour.id = this._idPrefix+key;
  1162. colour.disabled = disabled;
  1163.  
  1164. if (currentColour !== null)
  1165. colour.value = currentColour;
  1166. else
  1167. colour.value = options['default'];
  1168.  
  1169. if (options['onSave'] !== null)
  1170. colour.addEventListener(this._eventName, options['onSave']);
  1171.  
  1172. if (options['resetButton']) {
  1173. var reset = newElement('button', {textContent: 'Reset'});
  1174. reset.addEventListener('click', function(ev) {
  1175. colour.value = options['default'];
  1176. ev.preventDefault();
  1177. ev.stopPropagation();
  1178. });
  1179. reset.disabled = disabled;
  1180. }
  1181.  
  1182. var right = [];
  1183. if (options['checkbox']) {
  1184. right.push(checkbox);
  1185. right.push(' ');
  1186. }
  1187. right.push(colour);
  1188. right.push(' ');
  1189. if (options['resetButton']) {
  1190. right.push(reset);
  1191. right.push(' ');
  1192. }
  1193. right.push(newElement('label', {htmlFor: this._idPrefix+key},
  1194. [description]));
  1195. return this._createSettingLI(label, right);
  1196. },
  1197.  
  1198.  
  1199. /**
  1200. * Shows an error message in a friendly red box near the top of the
  1201. * page.
  1202. *
  1203. * `errorId` should be a unique string identifying the type of error.
  1204. * It is used to remove previous errors of the same type before
  1205. * displaying the new error.
  1206. *
  1207. * @param {string | HTMLElement} message
  1208. * @param {string} errorId
  1209. */
  1210. showErrorMessage: function(message, errorId) {
  1211. var errorDiv = newElement('div', {className: 'error_message'},
  1212. [message]);
  1213. if (errorId) {
  1214. errorDiv.dataset['errorId'] = errorId;
  1215. var existing = document.querySelector('[data-error-id="'+errorId+'"');
  1216. if (existing)
  1217. existing.parentNode.removeChild(existing);
  1218. }
  1219. var thinDiv = document.querySelector('div.thin');
  1220. thinDiv.parentNode.insertBefore(errorDiv, thinDiv);
  1221. return errorDiv;
  1222. },
  1223. };
  1224.  
  1225. return {
  1226. settings: settings,
  1227. utilities: utilities
  1228. };
  1229. })();