// ==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;
}({}));