- /* GitHub userscript utilities v0.1.1-alpha
- * Copyright © 2020 Rob Garrison
- * License: MIT
- */
- /* exported
- * $ $$
- * addClass removeClass toggleClass
- * on off make
- * debounce
- * addMenu
- */
- "use strict";
-
- const REGEX = {
- WHITESPACE: /\s+/,
- NAMESPACE: /[.:]/,
- COMMA: /\s*,\s*/
- };
-
- /* DOM utilities */
- const $ = (selector, el) => (el || document).querySelector(selector);
- const $$ = (selector, el) => [...(el || document).querySelectorAll(selector)];
-
- /**
- * Add class name(s) to one or more elements
- * @param {HTMLElements[]|Nodelist|HTMLElement|Node} elements
- * @param {string|array} classes - class name(s) to add; string can contain a
- * comma separated list
- */
- const addClass = (elements, classes) => {
- const classNames = _.getClasses(classes);
- const els = _.createElementArray(elements);
- let index = els.length;
- while (index--) {
- els[index]?.classList.add(...classNames);
- }
- };
-
- /**
- * Remove class name(s) from one or more elements
- * @param {HTMLElements[]|NodeList|HTMLElement|Node} elements
- * @param {string|array} classes - class name(s) to add; string can contain a
- * comma separated list
- */
- const removeClass = (elements, classes) => {
- const classNames = _.getClasses(classes);
- const els = _.createElementArray(elements);
- let index = els.length;
- while (index--) {
- els[index]?.classList.remove(...classNames);
- }
- };
-
- /**
- * Toggle class name of DOM element(s)
- * @param {HTMLElement|HTMLElement[]|NodeList} els
- * @param {string} name - class name to toggle (toggle only accepts one name)
- * @param {boolean} flag - force toggle; true = add class, false = remove class;
- * if undefined, the class will be toggled based on the element's class name
- */
- // flag = true, then add class
- const toggleClass = (elements, className, flag) => {
- const els = _.createElementArray(elements);
- let index = elms.length;
- while (index--) {
- els[index]?.classList.toggle(className, flag);
- }
- };
-
- /**
- * Add/remove event listener
- * @param {HTMLElement|HTMLElement[]|NodeList} els
- * @param {string} name - event name(s) to bind, e.g. "mouseup mousedown"
- * @param {function} handler - event handler
- * @param {options} eventListener options
- */
- const on = (els, name = "", handler, options) => {
- _.eventListener("add", els, name, handler, options);
- };
- const off = (els, name = "", handler, options) => {
- _.eventListener("remove", els, name, handler, options);
- }
-
- const _ = {};
- _.createElementArray = elements => {
- if (Array.isArray(elements)) {
- return elements;
- }
- return elements instanceof NodeList ? [...elements] : [elements];
- };
- _.eventListener = (type, els, name, handler, options) => {
- const events = name.split(REGEX.WHITESPACE);
- _.createElementArray(els).forEach(el => {
- events.forEach(ev => {
- el?.[`${type}EventListener`](ev, handler, options);
- });
- });
- };
- _.getClasses = classes => {
- if (Array.isArray(classes)) {
- return classes;
- }
- const names = classes.toString();
- return names.contains(",") ? names.split(REGEX.COMMA) : [names];
- };
-
- /**
- * Helpers
- */
- const debounce = (fxn, time = 500) => {
- let timer;
- return function() {
- clearTimeout(timer);
- timer = setTimeout(() => {
- fxn.apply(this, arguments);
- }, time);
- }
- }
-
- /**
- * @typedef Utils~makeOptions
- * @type {object}
- * @property {string} el - HTML element tag, e.g. "div" (default)
- * @property {string} appendTo - selector of target element to append menu
- * @property {string} className - CSS classes to add to the element
- * @property {object} attrs - HTML attributes (as key/value paries) to set
- * @property {object} text - string added to el using textContent
- * @property {string} html - html to be added using `innerHTML` (overrides `text`)
- * @property {array} children - array of elements to append to the created element
- */
- /**
- * Create a DOM element
- * @param {Utils~makeOptions}
- * @returns {HTMLElement} (may be already inserted in the DOM)
- * @example
- make({ el: 'ul', className: 'wrapper', appendTo: 'body' }, [
- make({ el: 'li', text: 'item #1' }),
- make({ el: 'li', text: 'item #2' })
- ]);
- */
- const make = (obj, children) => {
- const el = document.createElement(obj.el || "div");
- const xref = {
- className: "className",
- id: "id",
- text: "textContent",
- html: "innerHTML", // overrides text setting
- };
- Object.keys(xref).forEach(key => {
- if (obj[key]) {
- el[xref[key]] = obj[key];
- }
- })
- if (obj.attrs) {
- for (let key in obj.attrs) {
- if (obj.attrs.hasOwnProperty(key)) {
- el.setAttribute(key, obj.attrs[key]);
- }
- }
- }
- if (Array.isArray(children) && children.length) {
- children.forEach(child => el.appendChild(child));
- }
- if (obj.appendTo) {
- const wrap = typeof obj.appendTo === "string" ? $(el) : el;
- if (wrap) {
- wrap.appendChild(el);
- }
- }
- return el;
- }
-
- /* Add GitHub menu
- * Example set up
- ghMenu.open(
- "Popup Title",
- [{
- name: "Title",
- type: "text",
- get: () => GM_getValue("title"),
- set: value => GM_setValue("title", value)
- }, {
- name: "Border width (px)",
- type: "number",
- get: () => GM_getValue("border-width"),
- set: value => GM_setValue("border-width", value)
- }, {
- name: "Is enabled?",
- type: "checkbox",
- get: () => GM_getValue("enabled"),
- set: value => GM_setValue("enabled", value)
- }, {
- name: "Background Color",
- type: "color",
- get: () => GM_getValue("bkg-color"),
- set: value => GM_setValue("bkg-color", value)
- }, {
- name: "Widget enabled",
- type: "checkbox",
- get: () => GM_getValue("widget-is-enabled"),
- set: value => GM_setValue("widget-is-enabled", value)
- }, {
- name: "Image choice",
- type: "select",
- get: () => GM_getValue("img-choice"),
- set: value => GM_setValue("img-choice", value),
- options: [
- { label: "Car", value: "/images/car.jpg" },
- { label: "Jet", value: "/images/jet.jpg" },
- { label: "Cat", value: "/images/cat.jpg" }
- ]
- }]
- );
- */
- const ghMenu = {
- init: () => {
- if (!$("#ghmenu-style")) {
- make({
- el: "style",
- id: "ghmenu-style",
- textContent: `
- #ghmenu, #ghmenu summary { cursor: default; }
- #ghmenu summary:before { cursor: pointer; }
- #ghmenu-inner input[type="color"] { border: 0; padding: 0 }
- #ghmenu-inner ::-webkit-color-swatch-wrapper { border: 0; padding: 0; }
- #ghmenu-inner ::-moz-color-swatch-wrapper { border: 0; padding: 0; }
- }
- `,
- appendTo: "body"
- });
- }
- },
-
- open: (title, options) => {
- if (!$("#ghmenu")) {
- ghMenu._createMenu(title);
- ghMenu._options = options;
- }
- ghMenu._title = title;
- ghMenu._addContent(options);
- },
- close: event => {
- if (event) {
- event.preventDefault();
- }
- const menu = $("#ghmenu");
- if (menu) {
- menu.remove();
- }
- },
- append: options => {
- const menu = $("#ghmenu");
- if (menu) {
- ghMenu._appendContent(options);
- } else {
- ghMenu.open("", options);
- }
- },
- refresh: () => {
- ghMenu._addContent(ghMenu._options);
- },
-
- _types: {
- _input: (type, eventType, opts) => {
- const elm = make({
- el: "input",
- id: `${opts.id}-input`,
- className: `ghmenu-${type} ${type === "checkbox"
- ? "m-2"
- : "form-control input-block width-full"
- }`,
- attrs: {
- type,
- value: opts.get()
- },
- });
- const handler = e => opts.set(type === "checkbox"
- ? e.target.checked
- : e.target.value
- );
- on(elm, eventType, handler);
- return elm;
- },
- text: opts => ghMenu._types._input("text", "input", opts),
- number: opts => ghMenu._types._input("number", "input", opts),
- checkbox: opts => ghMenu._types._input("checkbox", "change", opts),
- color: opts => ghMenu._types._input("color", "change", opts),
- radio: opts => {},
- select: opts => {
- const elm = make({
- el: "select",
- className: "width-full ghmenu-select",
- attrs: {
- value: opts.get()
- }
- }, opts.options.map(obj => (
- make({
- el: "option",
- text: obj.label,
- attrs: {
- value: obj.value
- }
- })
- )));
- on(elm, "change", e => opts.set(e.target.value));
- return elm;
- },
-
- /* TO DO
- * - add multiple?
- * colors: ['#000', '#fff']
- * guideline: { width: '.2', color: '#a00', chars: 80 }
- * - link to more details/docs?
- */
- group: opts => {
- const group = opts.group;
- if (Array.isArray(group) && group.length) {
- const fragment = document.createDocumentFragment();
- fragment.appendChild(make({ el: "strong", text: opts.name }));
- group.forEach(entry => {
- const row = make({
- className: "Box-row d-flex flex-row pr-0"
- }, [
- ghMenu._createLabel(entry.id, entry.name),
- make({
- id,
- className: `ml-2 no-wrap${
- // align checkbox to right edge
- opt.type === "checkbox" ? " d-flex flex-justify-end" : ""
- }`,
- })
- ])
- })
- }
- },
- },
- _options: [],
- _createMenu: () => {
- // create menu
- make({
- el: "details",
- id: "ghmenu",
- className: "details-reset details-overlay details-overlay-dark lh-default text-gray-dark",
- attrs: {
- open: true
- },
- html: `
- <summary role="button" aria-label="Close dialog" />
- <details-dialog
- id="ghmenu-dialog"
- class="Box Box--overlay d-flex flex-column anim-fade-in fast container-xl"
- role="dialog"
- aria-modal="true"
- tab-index="-1"
- >
- <div class="readability-extra d-flex flex-auto flex-column overflow-hidden">
- <div class="Box-header">
- <h2 id="ghmenu-title" class="Box-title"></h2>
- </div>
- <div class="Box-body p-0 overflow-scroll">
- <div class="container-lg p-responsive advanced-search-form">
- <fieldset id="ghmenu-inner" class="pb-2 mb-2 min-width-0" />
- </div>
- </div>
- </div>
- <button id="ghmenu-close-menu" class="Box-btn-octicon m-0 btn-octicon position-absolute right-0 top-0" type="button" aria-label="Close dialog" data-close-dialog="">
- <svg class="octicon octicon-x" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true">
- <path fill-rule="evenodd" d="M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48L7.48 8z" />
- </svg>
- </button>
- </details-dialog>`,
- appendTo: "body"
- });
- on($("#ghmenu-close-menu"), "click", e => ghMenu.close(e), { once: true });
- on($("#ghmenu summary"), "click", e => {
- e.preventDefault();
- e.stopPropagation();
- const target = e.target;
- if (target && !target.closest("#ghmenu-dialog")) {
- ghMenu.close(e);
- }
- });
- },
- _addContent: options => {
- const menu = $("#ghmenu-inner");
- if (menu) {
- menu.innerHTML = "";
- ghMenu._appendContent(options);
- }
- },
- /* <dt><label for="{ID}-input">{NAME}</label></dt> */
- _createLabel: (id, text) => make({
- el: "dt",
- }, [
- make({
- el: "label",
- className: "flex-auto",
- text,
- attrs: {
- for: `${id}-input`
- }
- })
- ]),
- _appendContent: options => {
- const container = $("#ghmenu-inner");
- if (container) {
- // update title, if needed
- $("#ghmenu-title").textContent = ghMenu._title;
-
- const fragment = document.createDocumentFragment();
- options.forEach((opt, indx) => {
- const id = `ghmenu-${opt.name.replace(/\s/g, "")}-${indx}`;
- const output = opt.type === "group"
- ? ghMenu._types.group({ ...opt, id })
- : make({
- el: "dl",
- className: "form-group flattened d-flex d-md-block flex-column border-bottom my-0 py-2",
- }, [
- ghMenu._createLabel(id, opt.name),
- make({
- el: "dd",
- id,
- className: opt.type === "checkbox"
- ? "d-flex flex-justify-end"
- : "",
- }, [
- ghMenu._types[opt.type || "text"]({ ...opt, id })
- ])
- ]);
- fragment.appendChild(output);
- });
- container.appendChild(fragment);
- }
- }
- };