Greasy Fork

GM_context

A html5 contextmenu library

目前为 2017-09-21 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.greasyfork.cloud/scripts/33034/219424/GM_context.js

// ==UserScript==
// @name GM_context
// @version 0.2.0
// @description A html5 contextmenu library
// @supportURL https://github.com/eight04/GM_context/issues
// @license MIT
// @author eight04 <[email protected]> (https://github.com/eight04)
// @homepageURL https://github.com/eight04/GM_context
// @compatible firefox >=8
// @grant none
// ==/UserScript==
var GM_context = (function () {
'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)));
		if (!matchedMenus.length) return;
		const {el: container, destroy: destroyContainer} = createContainer(e);
		const removeMenus = [];
		for (const menu of matchedMenus) {
			if (menu.oncontext && menu.oncontext(e) === false) {
				continue;
			}
			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();
	}
}

var GM_context = {add, remove, addItem, removeItem, update, buildMenu};

return GM_context;

}());