GM_context

A html5 contextmenu library

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.greasyfork.org/scripts/33034/705360/GM_context.js

// ==UserScript==
// @name GM_context
// @version 0.2.1
// @description A html5 contextmenu library
// @supportURL https://github.com/eight04/GM_context/issues
// @license MIT
// @author eight04 <eight04@gmail.com> (https://github.com/eight04)
// @homepageURL https://github.com/eight04/GM_context
// @compatible firefox >=8
// @grant none
// @include *
// ==/UserScript==
var GM_context = (function (exports) {
	'use strict';

	const EDITABLE_INPUT = {text: true, number: true, email: true, search: true, tel: true, url: true};
	const PROP_EXCLUDE = {parent: true, items: true, onclick: true, onchange: true};

	let menus;
	let contextEvent;
	let contextSelection;
	let menuContainer;
	let isInit;
	let increaseNumber = 1;

	function objectAssign(target, ref, exclude = {}) {
		for (const key in ref) {
			if (!exclude[key]) {
				target[key] = ref[key];
			}
		}
		return target;
	}

	function init() {
		isInit = true;
		menus = new Set;
		document.addEventListener("contextmenu", e => {
			contextEvent = e;
			contextSelection = document.getSelection() + "";
			const context = getContext(e);
			const matchedMenus = [...menus]
				.filter(m => 
					(!m.context || m.context.some(c => context.has(c))) &&
					(!m.oncontext || m.oncontext(e) !== false)
				);
			if (!matchedMenus.length) return;
			const {el: container, destroy: destroyContainer} = createContainer(e);
			const removeMenus = [];
			for (const menu of matchedMenus) {
				if (!menu.isBuilt) {
					buildMenu(menu);
				}
				if (!menu.static) {
					updateLabel(menu.items);
				}
				removeMenus.push(appendMenu(container, menu));
			}
			setTimeout(() => {
				for (const removeMenu of removeMenus) {
					removeMenu();
				}
				destroyContainer();
			});
		});
	}

	function inc() {
		return increaseNumber++;
	}

	// check if there are dynamic label
	function checkStatic(menu) {
		return checkItems(menu.items);
		
		function checkItems(items) {
			for (const item of items) {
				if (item.label && item.label.includes("%s")) {
					return false;
				}
				if (item.items && checkItems(item.items)) {
					return false;
				}
			}
			return true;
		}
	}

	function updateLabel(items) {
		for (const item of items) {
			if (item.label && item.el) {
				item.el.label = buildLabel(item.label);
			}
			if (item.items) {
				updateLabel(item.items);
			}
		}
	}

	function createContainer(e) {
		let el = e.target;
		while (!el.contextMenu) {
			if (el == document.documentElement) {
				if (!menuContainer) {
					menuContainer = document.createElement("menu");
					menuContainer.type = "context";
					menuContainer.id = "gm-context-menu";
					document.body.appendChild(menuContainer);
				}
				el.setAttribute("contextmenu", menuContainer.id);
				break;
			}
			el = el.parentNode;
		}
		return {
			el: el.contextMenu,
			destroy() {
				if (el.contextMenu == menuContainer) {
					el.removeAttribute("contextmenu");
				}
			}
		};
	}

	function getContext(e) {
		const el = e.target;
		const context = new Set;
		if (el.nodeName == "IMG") {
			context.add("image");
		}
		if (el.closest("a")) {
			context.add("link");
		}
		if (el.isContentEditable ||
			el.nodeName == "INPUT" && EDITABLE_INPUT[el.type] ||
			el.nodeName == "TEXTAREA"
		) {
			context.add("editable");
		}
		if (!document.getSelection().isCollapsed) {
			context.add("selection");
		}
		if (!context.size) {
			context.add("page");
		}
		return context;
	}

	function buildMenu(menu) {
		const el = buildItems(null, menu.items);
		menu.startEl = document.createComment(`<menu ${menu.id}>`);
		el.insertBefore(menu.startEl, el.childNodes[0]);
		menu.endEl = document.createComment("</menu>");
		el.appendChild(menu.endEl);
		if (menu.static == null) {
			menu.static = checkStatic(menu);
		}
		menu.frag = el;
		menu.isBuilt = true;
	}

	function buildLabel(s) {
		return s.replace(/%s/g, contextSelection);
	}

	// build item's element
	function buildItem(parent, item) {
		let el;
		item.parent = parent;
		if (item.type == "submenu") {
			el = document.createElement("menu");
			objectAssign(el, item, PROP_EXCLUDE);
			el.appendChild(buildItems(item, item.items));
		} else if (item.type == "separator") {
			el = document.createElement("hr");
		} else if (item.type == "checkbox") {
			el = document.createElement("menuitem");
			objectAssign(el, item, PROP_EXCLUDE);
		} else if (item.type == "radiogroup") {
			el = document.createDocumentFragment();
			item.id = `gm-context-radio-${inc()}`;
			item.startEl = document.createComment(`<radiogroup ${item.id}>`);
			el.appendChild(item.startEl);
			el.appendChild(buildItems(item, item.items));
			item.endEl = document.createComment("</radiogroup>");
			el.appendChild(item.endEl);
		} else if (parent && parent.type == "radiogroup") {
			el = document.createElement("menuitem");
			item.type = "radio";
			item.radiogroup = parent.id;
			objectAssign(el, item, PROP_EXCLUDE);
		} else {
			el = document.createElement("menuitem");
			objectAssign(el, item, PROP_EXCLUDE);
		}
		if (item.type !== "radiogroup") {
			item.el = el;
			buildHandler(item);
		}
		item.isBuilt = true;
		return el;
	}

	function buildHandler(item) {
		if (item.type === "radiogroup") {
			if (item.onchange) {
				item.items.forEach(buildHandler);
			}
		} else if (item.type === "radio") {
			if (!item.el.onclick && (item.parent.onchange || item.onclick)) {
				item.el.onclick = () => {
					if (item.onclick) {
						item.onclick.call(item.el, contextEvent);
					}
					if (item.parent.onchange) {
						item.parent.onchange.call(item.el, contextEvent, item.value);
					}
				};
			}
		} else if (item.type === "checkbox") {
			if (!item.el.onclick && item.onclick) {
				item.el.onclick = () => {
					if (item.onclick) {
						item.onclick.call(item.el, contextEvent, item.el.checked);
					}
				};
			}
		} else {
			if (!item.el.onclick && item.onclick) {
				item.el.onclick = () => {
					if (item.onclick) {
						item.onclick.call(item.el, contextEvent);
					}
				};
			}
		}
	}

	// build items' element
	function buildItems(parent, items) {
		const root = document.createDocumentFragment();
		for (const item of items) {
			root.appendChild(buildItem(parent, item));
		}
		return root;
	}

	// attach menu to DOM
	function appendMenu(container, menu) {
		container.appendChild(menu.frag);
		return () => {
			const range = document.createRange();
			range.setStartBefore(menu.startEl);
			range.setEndAfter(menu.endEl);
			menu.frag = range.extractContents();
		};
	}

	// add a menu
	function add(menu) {
		if (!isInit) {
			init();
		}
		menu.id = inc();
		menus.add(menu);
	}

	// remove a menu
	function remove(menu) {
		menus.delete(menu);
	}

	// update item's properties. If @changes includes an `items` key, it would replace item's children.
	function update(item, changes) {
		if (changes.type) {
			throw new Error("item type is not changable");
		}
		if (changes.items) {
			if (item.isBuilt) {
				item.items.forEach(removeElement);
			}
			item.items.length = 0;
			changes.items.forEach(i => addItem(item, i));
			delete changes.items;
		}
		Object.assign(item, changes);
		if (item.el) {
			buildHandler(item);
			objectAssign(item.el, changes, PROP_EXCLUDE);
		}
	}

	// add an item to parent
	function addItem(parent, item, pos = parent.items.length) {
		if (parent.isBuilt) {
			const el = buildItem(parent, item);
			if (parent.el) {
				parent.el.insertBefore(el, parent.el.childNodes[pos]);
			} else {
				// search from end, so it would be faster to insert multiple item to end
				let ref = parent.endEl,
					i = pos < 0 ? -pos : parent.items.length - pos;
				while (i-- && ref) {
					ref = ref.previousSibling;
				}
				parent.startEl.parentNode.insertBefore(el, ref);
			}
		}
		parent.items.splice(pos, 0, item);
	}

	// remove an item from parent
	function removeItem(parent, item) {
		const pos = parent.items.indexOf(item);
		parent.items.splice(pos, 1);
		if (item.isBuilt) {
			removeElement(item);
		}
	}

	// remove item's element
	function removeElement(item) {
		if (item.el) {
			item.el.remove();
		} else {
			while (item.startEl.nextSibling != item.endEl) {
				item.startEl.nextSibling.remove();
			}
			item.startEl.remove();
			item.endEl.remove();
		}
	}

	exports.add = add;
	exports.addItem = addItem;
	exports.buildMenu = buildMenu;
	exports.remove = remove;
	exports.removeItem = removeItem;
	exports.update = update;

	return exports;

}({}));