utils.js

GitHub userscript utilities

Tính đến 07-12-2020. Xem phiên bản mới nhất.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greatest.deepsurf.us/scripts/398877/877686/utilsjs.js

/* 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);
		}
	}
};