ItemSelector

An gui for users to select items from given standardized json

สคริปต์นี้ไม่ควรถูกติดตั้งโดยตรง มันเป็นคลังสำหรับสคริปต์อื่น ๆ เพื่อบรรจุด้วยคำสั่งเมทา // @require https://update.greasyfork.org/scripts/458132/1138364/ItemSelector.js

/* eslint-disable no-multi-spaces */
/* eslint-disable dot-notation */

// ==UserScript==
// @name               ItemSelector
// @namespace          ItemSelector
// @version            0.3.4
// @description        An gui for users to select items from given standardized json
// @author             PY-DNG
// @license            GPL-v3
// ==/UserScript==

/* global structuredClone */
let ItemSelector = (function() {
	// function DoLog() {}
	// Arguments: level=LogLevel.Info, logContent, trace=false
	const [LogLevel, DoLog] = (function() {
		const LogLevel = {
			None: 0,
			Error: 1,
			Success: 2,
			Warning: 3,
			Info: 4,
		};

		return [LogLevel, DoLog];
		function DoLog() {
			// Get window
			const win = (typeof(unsafeWindow) === 'object' && unsafeWindow !== null) ? unsafeWindow : window;

			const LogLevelMap = {};
			LogLevelMap[LogLevel.None] = {
				prefix: '',
				color: 'color:#ffffff'
			}
			LogLevelMap[LogLevel.Error] = {
				prefix: '[Error]',
				color: 'color:#ff0000'
			}
			LogLevelMap[LogLevel.Success] = {
				prefix: '[Success]',
				color: 'color:#00aa00'
			}
			LogLevelMap[LogLevel.Warning] = {
				prefix: '[Warning]',
				color: 'color:#ffa500'
			}
			LogLevelMap[LogLevel.Info] = {
				prefix: '[Info]',
				color: 'color:#888888'
			}
			LogLevelMap[LogLevel.Elements] = {
				prefix: '[Elements]',
				color: 'color:#000000'
			}

			// Current log level
			DoLog.logLevel = (win.isPY_DNG && win.userscriptDebugging) ? LogLevel.Info : LogLevel.Warning; // Info Warning Success Error

			// Log counter
			DoLog.logCount === undefined && (DoLog.logCount = 0);

			// Get args
			let [level, logContent, trace] = parseArgs([...arguments], [
				[2],
				[1,2],
				[1,2,3]
			], [LogLevel.Info, 'DoLog initialized.', false]);

			// Log when log level permits
			if (level <= DoLog.logLevel) {
				let msg = '%c' + LogLevelMap[level].prefix + (typeof GM_info === 'object' ? `[${GM_info.script.name}]` : '') + (LogLevelMap[level].prefix ? ' ' : '');
				let subst = LogLevelMap[level].color;

				switch (typeof(logContent)) {
					case 'string':
						msg += '%s';
						break;
					case 'number':
						msg += '%d';
						break;
					default:
						msg += '%o';
						break;
				}

				if (++DoLog.logCount > 512) {
					console.clear();
					DoLog.logCount = 0;
				}
				console[trace ? 'trace' : 'log'](msg, subst, logContent);
			}
		}
	}) ();

	return ItemSelector;

	function ItemSelector(useWrapper=true) {
		const IS = this;
		const DATA = {
			showing: false, json: null, data: null, options: null
		};
		const elements = IS.elements = {};
		defineGetter(IS, 'showing', () => DATA.showing);
		defineGetter(IS, 'json', () => MakeReadonlyObj(DATA.json));
		defineGetter(IS, 'data', () => MakeReadonlyObj(DATA.data));
		defineGetter(IS, 'options', () => MakeReadonlyObj(DATA.options));
		IS.show = show;
		IS.close = close;
		IS.setTheme = setTheme;
		IS.getSelectedItems = getSelectedItems;
		init();

		function init() {
			const wrapperDoc = elements.wrapperDoc = useWrapper ? (function() {
				const wrapper = elements.wrapper = $CrE(randstr(4, false, false) + '-' + randstr(4, false, false));
				const shadow = wrapper.attachShadow({mode: 'closed'});
				wrapper.style.display = 'block';
				wrapper.style.zIndex = 99999999;
				document.body.appendChild(wrapper);
				return shadow;
			}) () : document;
			const wrapper = elements.wrapper = useWrapper ? wrapperDoc : wrapperDoc.body;
			const container = elements.container = $CrE('div');
			const header = elements.header = $CrE('div');
			const body = elements.body = $CrE('div');
			const footer = elements.footer = $CrE('div');
			container.classList.add('itemselector-container');
			header.classList.add('itemselector-header');
			body.classList.add('itemselector-body');
			footer.classList.add('itemselector-footer');
			container.appendChild(header);
			container.appendChild(body);
			container.appendChild(footer);
			wrapper.appendChild(container);

			const title = elements.title = $CrE('span');
			title.classList.add('itemselector-title');
			header.appendChild(title);

			const bglist = elements.bglist = $CrE('div');
			bglist.classList.add('itemselector-bglist');
			body.appendChild(bglist);

			const list = elements.list = $CrE('pre');
			list.classList.add('itemselector-list');
			body.appendChild(list);

			const btnOK = $CrE('button');
			const btnCancel = $CrE('button');
			const btnClose = $CrE('button');
			btnOK.innerText = 'OK';
			btnCancel.innerText = 'Cancel';
			btnClose.innerText = 'x';
			btnOK.className = 'itemselector-button itemselector-button-ok';
			btnCancel.className = 'itemselector-button itemselector-button-cancel';
			btnClose.className = 'itemselector-button itemselector-button-close';
			$AEL(btnOK, 'click', ok_onClick);
			$AEL(btnCancel, 'click', cancel_onClick);
			$AEL(btnClose, 'click', close_onClick);
			header.appendChild(btnClose);
			footer.appendChild(btnCancel);
			footer.appendChild(btnOK);
			elements.button = {btnOK, btnCancel, btnClose};

			const cssParent = useWrapper ? wrapper : document.head;
			const css = '.itemselector-container {display: none;position: fixed;position: fixed;width: 60vw;height: 60vh;left: 20vw;top: 20vh;border-radius: 1em;padding: 2em;user-select: none;font-family: -apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol!important;}.itemselector-container.itemselector-show {display: block;}.itemselector-header {position: absolute;width: calc(100% - 4em);padding-bottom: 0.3em;}.itemselector-title {position: relative;font-size: 1.3em;}.itemselector-body {position: absolute;top: calc(2em + 20px * 1.3 + 20px * 0.3 + 1px + 0.3em);bottom: calc(2em + 20px + 20px + calc(60vw - 4em) * 2 / 100 + 0.3em);overflow: auto;width: calc(100% - 4em);z-index: -2;}.itemselector-bglist {position: absolute;left: 0;width: 100%;z-index: -1;}.itemselector-footer {position: absolute;bottom: 2em;width: calc(100% - 4em);}.itemselector-button {font-size: 20px;width: 48%;margin: 1%;border: none;border-radius: 3px;padding: 0.5em;font-weight: 500;}.itemselector-button.itemselector-button-close {position: relative;float: right;margin: 0;padding: 0;width: 1.3em;height: 1.3em;text-align: center;font-size: 20px;}.itemselector-list {margin: 0;pointer-events: none;}.itemselector-item {margin: 0;margin-left: 1em;}.itemselector-item-root {margin-left: 0;}.itemselector-item-background {width: 100%;height: 49px;}.itemselector-item-background:first-child {border-top: none;}.itemselector-item-background.itemselector-hide {display: none;}.itemselector-item-self {font-size: 14px;line-height: 34px;padding: 8px;background-color: rgba(0,0,0,0);pointer-events: auto;}.itemselector-toggle {position: relative;visibility: hidden;}.itemselector-toggle.itemselector-show {visibility: visible;}.itemselector-toggle:before {content: "\\25BC";width: 1em;display: inline-block;position: relative;}.itemselector-item-collapsed>.itemselector-item-self>.itemselector-toggle:before {content: "\\25B6";}.itemselector-item-collapsed>.itemselector-item-child>.itemselector-item {display: none;}.itemselector-text {pointer-events: none;margin-left: 0.5em;}.itemselector-container.light {--itemselector-color: #000;--itemselector-bgcolor-1: #dddddd;--itemselector-bgcolor-0: #e2e2e2;--itemselector-bgcolor-2: #cdcdcd;--itemselector-bgcolor-3: #bdbdbd;--itemselector-btnclose-bgcolor: #00bcd4;--itemselector-spliter-color: rgba(0,0,0,0.28);}.itemselector-container.dark {--itemselector-color: #fff;--itemselector-bgcolor-0: #1d1d1d;--itemselector-bgcolor-1: #222222;--itemselector-bgcolor-2: #323232;--itemselector-bgcolor-3: #424242;--itemselector-btnclose-bgcolor: #00bcd4;--itemselector-spliter-color: rgba(255,255,255,0.28);}.itemselector-container {box-shadow: 0 3px 15px rgb(0 0 0 / 20%), 0 6px 6px rgb(0 0 0 / 14%), 0 9px 3px -6px rgb(0 0 0 / 12%);color: var(--itemselector-color);background-color: var(--itemselector-bgcolor-0);}.itemselector-header {border-bottom: 1px solid var(--itemselector-spliter-color);}.itemselector-body {scrollbar-color: var(--itemselector-bgcolor-2) var(--itemselector-bgcolor-1);}.itemselector-body:hover {scrollbar-color: var(--itemselector-bgcolor-3) var(--itemselector-bgcolor-1);}.itemselector-body::-webkit-scrollbar {background-color: var(--itemselector-bgcolor-1);}.itemselector-body::-webkit-scrollbar-corner {background-color: var(--itemselector-bgcolor-1);}.itemselector-body::-webkit-scrollbar-thumb, .itemselector-body::-webkit-scrollbar-button {background-color: var(--itemselector-bgcolor-2);}.itemselector-body::-webkit-scrollbar-thumb:hover, .itemselector-body::-webkit-scrollbar-button:hover {background-color: var(--itemselector-bgcolor-3);}.itemselector-item-background {transition-duration: 0.3s;border-top: 1px solid var(--itemselector-spliter-color);}.itemselector-item-background.itemselector-item-hover {background-color: var(--itemselector-bgcolor-2);}.itemselector-button {background-color: var(--itemselector-btnclose-bgcolor);color: var(--itemselector-color);}.itemselector-button.itemselector-button-close {background-color: var(--itemselector-bgcolor-2);}.itemselector-button.itemselector-button-close:hover {background-color: var(--itemselector-bgcolor-3);}';
			const style = $CrE('style');
			style.innerHTML = css;
			cssParent.appendChild(style);

			function ok_onClick(e) {
				if (!DATA.showing) {
					DoLog(LogLevel.Warning, 'ok_onClick invoked when dialog is not showing');
					return false;
				}
				if (!DATA.options) {
					DoLog(LogLevel.Warning, 'DATA.options missing while ok_onClick invoked');
					return false;
				}
				typeof DATA.options.onok === 'function' && DATA.options.onok.call(this, e, getSelectedItems());
				close();
			}

			function cancel_onClick(e) {
				if (!DATA.showing) {
					DoLog(LogLevel.Warning, 'cancel_onClick invoked when dialog is not showing');
					return false;
				}
				if (!DATA.options) {
					DoLog(LogLevel.Warning, 'DATA.options missing while cancel_onClick invoked');
					return false;
				}
				typeof DATA.options.oncancel === 'function' && DATA.options.oncancel.call(this, e, getSelectedItems());
				close();
			}

			function close_onClick(e) {
				if (!DATA.showing) {
					DoLog(LogLevel.Warning, 'close_onClick invoked when dialog is not showing');
					return false;
				}
				if (!DATA.options) {
					DoLog(LogLevel.Warning, 'DATA.options missing while close_onClick invoked');
					return false;
				}
				typeof DATA.options.onclose === 'function' && DATA.options.onclose.call(this, e, getSelectedItems());
				close();
			}
		}

		function show(json, options={title: ''}) {
			// Status check & update
			if (!json) {
				DoLog(LogLevel.Error, 'json missing');
				return false;
			}
			if (DATA.showing) {
				DoLog(LogLevel.Error, 'show invoked while DATA.showing === true');
				return false;
			}
			DATA.showing = true;
			DATA.options = options;
			DATA.json = structuredClone(json);
			DATA.data = makeData(json);

			// elements
			const {container, header, title, body, footer, bglist, list} = elements;

			// cleanings
			[...list.children].forEach(c => c.remove());
			[...bglist.children].forEach(c => c.remove());

			// make new <ul>
			const ul = makeListItem(json);
			ul.classList.add('itemselector-item-root');
			list.appendChild(ul);

			// configure with options
			options.hasOwnProperty('title') && (title.innerText = options.title);

			// display container
			updateElementSelect();
			container.classList.add('itemselector-show');

			return IS;

			function makeListItem(json_item, path=[]) {
				const item = pathItem(path);
				const hasChild = Array.isArray(item.children);

				// create new div
				const div = item.elements.div = $CrE('div');
				const self_container = item.elements.self_container = $CrE('div');
				const child_container = item.elements.child_container = $CrE('div');
				const background = item.elements.background = $CrE('div');
				div.classList.add('itemselector-item');
				self_container.classList.add('itemselector-item-self');
				child_container.classList.add('itemselector-item-child');
				background.classList.add('itemselector-item-background');
				hasChild && div.classList.add('itemselector-item-parent');
				$AEL(background, 'mouseenter', e => background.classList.add('itemselector-item-hover'));
				$AEL(background, 'mouseleave', e => background.classList.remove('itemselector-item-hover'));
				$AEL(self_container, 'mouseenter', e => background.classList.add('itemselector-item-hover'));
				$AEL(self_container, 'mouseleave', e => background.classList.remove('itemselector-item-hover'));
				bglist.appendChild(background);
				div.appendChild(self_container);
				div.appendChild(child_container);

				// triangle toggle for folder items
				const toggle = item.elements.toggle = $CrE('a');
				toggle.classList.add('itemselector-toggle');
				hasChild && toggle.classList.add('itemselector-show');
				$AEL(toggle, 'click', e => {
					destroyEvent(e);
					const collapsed = [...div.classList].includes('itemselector-item-collapsed');
					div.classList[collapsed ? 'remove' : 'add']('itemselector-item-collapsed');
					toggleBackground(item);

					function toggleBackground(item) {
						if (Array.isArray(item.children)) {
							for (const child of item.children) {
								child.elements.background.classList[collapsed ? 'remove' : 'add']('itemselector-hide');
								toggleBackground(child);
							}
						}
					}
				});
				self_container.appendChild(toggle);

				// checkbox for selecting
				const checkbox = item.elements.checkbox = $CrE('input');
				checkbox.type = 'checkbox';
				checkbox.classList.add('itemselector-checker');
				$AEL(checkbox, 'change', checkbox_onChange);
				self_container.appendChild(checkbox);

				// check checkbox when self_container or background block onclick
				const clickTargets = [self_container, background]
				clickTargets.forEach(elm => $AEL(elm, 'click', function(e) {
					if (clickTargets.includes(e.target)) {
						checkbox.checked = !checkbox.checked;
						checkbox_onChange();
					}
				}));

				// item text
				const text = item.elements.text = $CrE('span');
				text.classList.add('itemselector-text');
				text.innerText = json_item.text;
				self_container.appendChild(text);

				// make child items
				if (hasChild) {
					item.elements.children = [];
					for (let i = 0; i < json_item.children.length; i++) {
						const childItem = makeListItem(json_item.children[i], [...path, i]);
						item.elements.children.push(childItem);
						child_container.appendChild(childItem);
					}
				}

				return div;

				function checkbox_onChange(e) {
					// set select status
					item.selected = checkbox.checked;

					// update element
					updateElementSelect();
				}
			}
		}

		function close() {
			if (!DATA.showing) {
				DoLog(LogLevel.Error, 'show invoked while DATA.showing === false');
				return false;
			}
			DATA.showing = false;
			DATA.options = null;

			elements.container.classList.remove('itemselector-show');
		}

		function setTheme(theme='light') {
			const THEMES = ['light', 'dark'];
			const root = elements.container;
			if (THEMES.includes(theme)) {
				THEMES.filter(t => t !== theme).forEach(t => root.classList.remove(t));
				root.classList.add(theme);
				return true;
			} else {
				return false;
			}
		}

		function updateElementSelect() {
			//const data = DATA.data;
			update(DATA.data);

			function update(item) {
				// item elements
				const elements = item.elements;
				const checkbox = elements.checkbox;

				// props
				checkbox.checked = item.selected;
				checkbox.indeterminate = item.childSelected && !item.selected;

				// update children
				if (Array.isArray(item.children)) {
					for (const child of item.children) {
						update(child);
					}
				}
			}
		}

		function getSelectedItems() {
			const json = structuredClone(DATA.json);
			const data = DATA.data;
			const MARK = Symbol('cut-mark');

			mark(json, data);
			return cut(json);

			function mark(json_item, data_item) {
				if (!data_item.selected && !data_item.childSelected) {
					json_item[MARK] = true;
				} else if (Array.isArray(data_item.children)) {
					for (let i = 0; i < data_item.children.length; i++) {
						mark(json_item.children[i], data_item.children[i]);
					}
				}
			}

			function cut(json_item) {
				if (json_item[MARK]) {
					return null;
				} else {
					const children = json_item.children;
					if (Array.isArray(children)) {
						for (const cutchild of children.filter(child => child[MARK])) {
							children.splice(children.indexOf(cutchild), 1);
						}
						children.forEach((child, i) => {
							children[i] = cut(child);
						});
					}
					return json_item;
				}
			}
		}

		function pathItem(path) {
			return pathObj(DATA.data, path);
		}

		function pathObj(obj, path) {
			let target = obj;
			const _path = [...path];
			while (_path.length) {
				target = target.children[_path.shift()];
			}
			return target;
		}

		function makeData(json) {
			return proxyItemData(makeItemData(json));

			function proxyItemData(data) {
				return typeof data === 'object' && data !== null ? new Proxy(data, {
					get: function(target, property, receiver) {
						const value = target[property];
						const noproxy = typeof value === 'object' && value !== null && value['__NOPROXY__'] === true;
						return noproxy ? value : proxyItemData(value);
					},
					set: function(target, property, value, receiver) {
						switch (property) {
							case 'selected':
								// set item and its children's selected status by rule
								select(target, value, !value);
								break;
							default:
								// setting other props are not allowed
								break;
						}
						return true;

						function select(item, selected) {
							// write item
							item.selected = selected;

							// write children selected
							select_children(item)

							// write parent selected
							select_parent(item);

							// calculate children childSelected
							childSelected_children(item);

							// calculate parent childSelected
							childSelected_parent(item);

							function select_children(item) {
								if (Array.isArray(item.children)) {
									for (const child of item.children) {
										if (child.selected !== selected) {
											child.selected = selected;
											select_children(child, selected);
										}
									}
								}
							}

							function select_parent(item) {
								if (item.parent) {
									const parent = item.parent;
									const selected = parent.children.every(child => child.selected);
									if (parent.selected !== selected) {
										parent.selected = selected;
										select_parent(parent);
									}
								}
							}

							function childSelected_children(item) {
								if (Array.isArray(item.children)) {
									for (const child of item.children) {
										childSelected_children(child);
									}
									item.childSelected = item.children.some(child => child.selected || child.childSelected);
								} else {
									item.childSelected = false;
								}
							}

							function childSelected_parent(item) {
								if (item.parent) {
									const parent = item.parent;
									const childSelected = parent.children.some(child => child.selected || child.childSelected);
									if (parent.childSelected !== childSelected) {
										parent.childSelected = childSelected;
										childSelected_parent(parent);
									}
								}
							}
						}
					}
				}) : data;
			}

			function makeItemData(json, parent=null) {
				const hasChild = Array.isArray(json.children);
				const item = {};
				item.elements = {__NOPROXY__:true};
				item.selected = true;
				item.childSelected = hasChild && json.children.length > 0;
				item.parent = parent !== null && typeof parent === 'object' ? parent : null;
				if (hasChild) {
					item.children = json.children.map(child => makeItemData(child, item));
				}
				return item;
			}
		}

		function defineGetter(obj, prop, getter) {
			Object.defineProperty(obj, prop, {
				get: getter,
				set: v => true,
				configurable: false,
				enumerable: true,
			});
		}
	}

	// Basic functions
	// querySelector
	function $() {
		switch(arguments.length) {
			case 2:
				return arguments[0].querySelector(arguments[1]);
				break;
			default:
				return document.querySelector(arguments[0]);
		}
	}
	// querySelectorAll
	function $All() {
		switch(arguments.length) {
			case 2:
				return arguments[0].querySelectorAll(arguments[1]);
				break;
			default:
				return document.querySelectorAll(arguments[0]);
		}
	}
	// createElement
	function $CrE() {
		switch(arguments.length) {
			case 2:
				return arguments[0].createElement(arguments[1]);
				break;
			default:
				return document.createElement(arguments[0]);
		}
	}
	// addEventListener
	function $AEL(...args) {
		const target = args.shift();
		return target.addEventListener.apply(target, args);
	}

	// Just stopPropagation and preventDefault
	function destroyEvent(e) {
		if (!e) {return false;};
		if (!e instanceof Event) {return false;};
		e.stopPropagation();
		e.preventDefault();
	}

	function parseArgs(args, rules, defaultValues=[]) {
		// args and rules should be array, but not just iterable (string is also iterable)
		if (!Array.isArray(args) || !Array.isArray(rules)) {
			throw new TypeError('parseArgs: args and rules should be array')
		}

		// fill rules[0]
		(!Array.isArray(rules[0]) || rules[0].length === 1) && rules.splice(0, 0, []);

		// max arguments length
		const count = rules.length - 1;

		// args.length must <= count
		if (args.length > count) {
			throw new TypeError(`parseArgs: args has more elements(${args.length}) longer than ruless'(${count})`);
		}

		// rules[i].length should be === i if rules[i] is an array, otherwise it should be a function
		for (let i = 1; i <= count; i++) {
			const rule = rules[i];
			if (Array.isArray(rule)) {
				if (rule.length !== i) {
					throw new TypeError(`parseArgs: rules[${i}](${rule}) should have ${i} numbers, but given ${rules[i].length}`);
				}
				if (!rule.every((num) => (typeof num === 'number' && num <= count))) {
					throw new TypeError(`parseArgs: rules[${i}](${rule}) should contain numbers smaller than count(${count}) only`);
				}
			} else if (typeof rule !== 'function') {
				throw new TypeError(`parseArgs: rules[${i}](${rule}) should be an array or a function.`)
			}
		}

		// Parse
		const rule = rules[args.length];
		let parsed;
		if (Array.isArray(rule)) {
			parsed = [...defaultValues];
			for (let i = 0; i < rule.length; i++) {
				parsed[rule[i]-1] = args[i];
			}
		} else {
			parsed = rule(args, defaultValues);
		}
		return parsed;
	}

	function MakeReadonlyObj(val) {
		return isObject(val) ? new Proxy(val, {
			get: function(target, property, receiver) {
				return MakeReadonlyObj(target[property]);
			},
			set: function(target, property, value, receiver) {
				return true;
			}
		}) : val;

		function isObject(value) {
			return ['object', 'function'].includes(typeof value) && value !== null;
		}
	}

	// Returns a random string
	function randstr(length=16, nums=true, cases=true) {
		const all = 'abcdefghijklmnopqrstuvwxyz' + (nums ? '0123456789' : '') + (cases ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' : '');
		return Array(length).fill(0).reduce(pre => (pre += all.charAt(randint(0, all.length-1))), '');
	}

	function randint(min, max) {
		return Math.floor(Math.random() * (max - min + 1)) + min;
	}
}) ();