GM_context

A html5 contextmenu library

当前为 2017-09-21 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/scripts/33034/219427/GM_context.js

  1. // ==UserScript==
  2. // @name GM_context
  3. // @version 0.2.1
  4. // @description A html5 contextmenu library
  5. // @supportURL https://github.com/eight04/GM_context/issues
  6. // @license MIT
  7. // @author eight04 <eight04@gmail.com> (https://github.com/eight04)
  8. // @homepageURL https://github.com/eight04/GM_context
  9. // @compatible firefox >=8
  10. // @grant none
  11. // ==/UserScript==
  12. var GM_context = (function () {
  13. 'use strict';
  14.  
  15. const EDITABLE_INPUT = {text: true, number: true, email: true, search: true, tel: true, url: true};
  16. const PROP_EXCLUDE = {parent: true, items: true, onclick: true, onchange: true};
  17.  
  18. let menus;
  19. let contextEvent;
  20. let contextSelection;
  21. let menuContainer;
  22. let isInit;
  23. let increaseNumber = 1;
  24.  
  25. function objectAssign(target, ref, exclude = {}) {
  26. for (const key in ref) {
  27. if (!exclude[key]) {
  28. target[key] = ref[key];
  29. }
  30. }
  31. return target;
  32. }
  33.  
  34. function init() {
  35. isInit = true;
  36. menus = new Set;
  37. document.addEventListener("contextmenu", e => {
  38. contextEvent = e;
  39. contextSelection = document.getSelection() + "";
  40. const context = getContext(e);
  41. const matchedMenus = [...menus]
  42. .filter(m =>
  43. (!m.context || m.context.some(c => context.has(c))) &&
  44. (!m.oncontext || m.oncontext(e) !== false)
  45. );
  46. if (!matchedMenus.length) return;
  47. const {el: container, destroy: destroyContainer} = createContainer(e);
  48. const removeMenus = [];
  49. for (const menu of matchedMenus) {
  50. if (!menu.isBuilt) {
  51. buildMenu(menu);
  52. }
  53. if (!menu.static) {
  54. updateLabel(menu.items);
  55. }
  56. removeMenus.push(appendMenu(container, menu));
  57. }
  58. setTimeout(() => {
  59. for (const removeMenu of removeMenus) {
  60. removeMenu();
  61. }
  62. destroyContainer();
  63. });
  64. });
  65. }
  66.  
  67. function inc() {
  68. return increaseNumber++;
  69. }
  70.  
  71. // check if there are dynamic label
  72. function checkStatic(menu) {
  73. return checkItems(menu.items);
  74. function checkItems(items) {
  75. for (const item of items) {
  76. if (item.label && item.label.includes("%s")) {
  77. return false;
  78. }
  79. if (item.items && checkItems(item.items)) {
  80. return false;
  81. }
  82. }
  83. return true;
  84. }
  85. }
  86.  
  87. function updateLabel(items) {
  88. for (const item of items) {
  89. if (item.label && item.el) {
  90. item.el.label = buildLabel(item.label);
  91. }
  92. if (item.items) {
  93. updateLabel(item.items);
  94. }
  95. }
  96. }
  97.  
  98. function createContainer(e) {
  99. let el = e.target;
  100. while (!el.contextMenu) {
  101. if (el == document.documentElement) {
  102. if (!menuContainer) {
  103. menuContainer = document.createElement("menu");
  104. menuContainer.type = "context";
  105. menuContainer.id = "gm-context-menu";
  106. document.body.appendChild(menuContainer);
  107. }
  108. el.setAttribute("contextmenu", menuContainer.id);
  109. break;
  110. }
  111. el = el.parentNode;
  112. }
  113. return {
  114. el: el.contextMenu,
  115. destroy() {
  116. if (el.contextMenu == menuContainer) {
  117. el.removeAttribute("contextmenu");
  118. }
  119. }
  120. };
  121. }
  122.  
  123. function getContext(e) {
  124. const el = e.target;
  125. const context = new Set;
  126. if (el.nodeName == "IMG") {
  127. context.add("image");
  128. }
  129. if (el.closest("a")) {
  130. context.add("link");
  131. }
  132. if (el.isContentEditable ||
  133. el.nodeName == "INPUT" && EDITABLE_INPUT[el.type] ||
  134. el.nodeName == "TEXTAREA"
  135. ) {
  136. context.add("editable");
  137. }
  138. if (!document.getSelection().isCollapsed) {
  139. context.add("selection");
  140. }
  141. if (!context.size) {
  142. context.add("page");
  143. }
  144. return context;
  145. }
  146.  
  147. function buildMenu(menu) {
  148. const el = buildItems(null, menu.items);
  149. menu.startEl = document.createComment(`<menu ${menu.id}>`);
  150. el.insertBefore(menu.startEl, el.childNodes[0]);
  151. menu.endEl = document.createComment("</menu>");
  152. el.appendChild(menu.endEl);
  153. if (menu.static == null) {
  154. menu.static = checkStatic(menu);
  155. }
  156. menu.frag = el;
  157. menu.isBuilt = true;
  158. }
  159.  
  160. function buildLabel(s) {
  161. return s.replace(/%s/g, contextSelection);
  162. }
  163.  
  164. // build item's element
  165. function buildItem(parent, item) {
  166. let el;
  167. item.parent = parent;
  168. if (item.type == "submenu") {
  169. el = document.createElement("menu");
  170. objectAssign(el, item, PROP_EXCLUDE);
  171. el.appendChild(buildItems(item, item.items));
  172. } else if (item.type == "separator") {
  173. el = document.createElement("hr");
  174. } else if (item.type == "checkbox") {
  175. el = document.createElement("menuitem");
  176. objectAssign(el, item, PROP_EXCLUDE);
  177. } else if (item.type == "radiogroup") {
  178. el = document.createDocumentFragment();
  179. item.id = `gm-context-radio-${inc()}`;
  180. item.startEl = document.createComment(`<radiogroup ${item.id}>`);
  181. el.appendChild(item.startEl);
  182. el.appendChild(buildItems(item, item.items));
  183. item.endEl = document.createComment("</radiogroup>");
  184. el.appendChild(item.endEl);
  185. } else if (parent && parent.type == "radiogroup") {
  186. el = document.createElement("menuitem");
  187. item.type = "radio";
  188. item.radiogroup = parent.id;
  189. objectAssign(el, item, PROP_EXCLUDE);
  190. } else {
  191. el = document.createElement("menuitem");
  192. objectAssign(el, item, PROP_EXCLUDE);
  193. }
  194. if (item.type !== "radiogroup") {
  195. item.el = el;
  196. buildHandler(item);
  197. }
  198. item.isBuilt = true;
  199. return el;
  200. }
  201.  
  202. function buildHandler(item) {
  203. if (item.type === "radiogroup") {
  204. if (item.onchange) {
  205. item.items.forEach(buildHandler);
  206. }
  207. } else if (item.type === "radio") {
  208. if (!item.el.onclick && (item.parent.onchange || item.onclick)) {
  209. item.el.onclick = () => {
  210. if (item.onclick) {
  211. item.onclick.call(item.el, contextEvent);
  212. }
  213. if (item.parent.onchange) {
  214. item.parent.onchange.call(item.el, contextEvent, item.value);
  215. }
  216. };
  217. }
  218. } else if (item.type === "checkbox") {
  219. if (!item.el.onclick && item.onclick) {
  220. item.el.onclick = () => {
  221. if (item.onclick) {
  222. item.onclick.call(item.el, contextEvent, item.el.checked);
  223. }
  224. };
  225. }
  226. } else {
  227. if (!item.el.onclick && item.onclick) {
  228. item.el.onclick = () => {
  229. if (item.onclick) {
  230. item.onclick.call(item.el, contextEvent);
  231. }
  232. };
  233. }
  234. }
  235. }
  236.  
  237. // build items' element
  238. function buildItems(parent, items) {
  239. const root = document.createDocumentFragment();
  240. for (const item of items) {
  241. root.appendChild(buildItem(parent, item));
  242. }
  243. return root;
  244. }
  245.  
  246. // attach menu to DOM
  247. function appendMenu(container, menu) {
  248. container.appendChild(menu.frag);
  249. return () => {
  250. const range = document.createRange();
  251. range.setStartBefore(menu.startEl);
  252. range.setEndAfter(menu.endEl);
  253. menu.frag = range.extractContents();
  254. };
  255. }
  256.  
  257. // add a menu
  258. function add(menu) {
  259. if (!isInit) {
  260. init();
  261. }
  262. menu.id = inc();
  263. menus.add(menu);
  264. }
  265.  
  266. // remove a menu
  267. function remove(menu) {
  268. menus.delete(menu);
  269. }
  270.  
  271. // update item's properties. If @changes includes an `items` key, it would replace item's children.
  272. function update(item, changes) {
  273. if (changes.type) {
  274. throw new Error("item type is not changable");
  275. }
  276. if (changes.items) {
  277. if (item.isBuilt) {
  278. item.items.forEach(removeElement);
  279. }
  280. item.items.length = 0;
  281. changes.items.forEach(i => addItem(item, i));
  282. delete changes.items;
  283. }
  284. Object.assign(item, changes);
  285. if (item.el) {
  286. buildHandler(item);
  287. objectAssign(item.el, changes, PROP_EXCLUDE);
  288. }
  289. }
  290.  
  291. // add an item to parent
  292. function addItem(parent, item, pos = parent.items.length) {
  293. if (parent.isBuilt) {
  294. const el = buildItem(parent, item);
  295. if (parent.el) {
  296. parent.el.insertBefore(el, parent.el.childNodes[pos]);
  297. } else {
  298. // search from end, so it would be faster to insert multiple item to end
  299. let ref = parent.endEl,
  300. i = pos < 0 ? -pos : parent.items.length - pos;
  301. while (i-- && ref) {
  302. ref = ref.previousSibling;
  303. }
  304. parent.startEl.parentNode.insertBefore(el, ref);
  305. }
  306. }
  307. parent.items.splice(pos, 0, item);
  308. }
  309.  
  310. // remove an item from parent
  311. function removeItem(parent, item) {
  312. const pos = parent.items.indexOf(item);
  313. parent.items.splice(pos, 1);
  314. if (item.isBuilt) {
  315. removeElement(item);
  316. }
  317. }
  318.  
  319. // remove item's element
  320. function removeElement(item) {
  321. if (item.el) {
  322. item.el.remove();
  323. } else {
  324. while (item.startEl.nextSibling != item.endEl) {
  325. item.startEl.nextSibling.remove();
  326. }
  327. item.startEl.remove();
  328. item.endEl.remove();
  329. }
  330. }
  331.  
  332. var GM_context = {add, remove, addItem, removeItem, update, buildMenu};
  333.  
  334. return GM_context;
  335.  
  336. }());