Greasy Fork is available in English.

Discord Context Menu Extender

Adding Discord desktop action buttons in context menu in discord web version

  1. // ==UserScript==
  2. // @name Discord Context Menu Extender
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0.2
  5. // @description Adding Discord desktop action buttons in context menu in discord web version
  6. // @author DoctorDeathDDracula & Sticky
  7. // @supportURL https://discord.gg/sHj5UauJZ4
  8. // @license MIT
  9. // @match *://discord.com/*
  10. // @icon https://www.google.com/s2/favicons?sz=64&domain=discord.com
  11. // @grant unsafeWindow
  12. // @grant GM_log
  13. // @grant GM_download
  14. // @grant GM_xmlhttpRequest
  15. // @grant GM_setClipboard
  16. // @connect discord.com
  17. // @connect cdn.discordapp.com
  18. // @connect media.discordapp.net
  19. // @connect images-ext-1.discordapp.net
  20. // @connect images-ext-2.discordapp.net
  21. // @run-at document-start
  22. // ==/UserScript==
  23.  
  24.  
  25. (function() {
  26. 'use strict';
  27.  
  28. const console = {
  29. log: GM_log
  30. };
  31.  
  32. class DiscordContextMenuExtender {
  33.  
  34. constructor() {
  35. this.compatibilityItems = ['ClipboardItem', 'MutationObserver'];
  36. this.currentElement = null;
  37. this.style = `.my-focused{background-color:var(--brand-experiment-560);color:#fff;} .my-focused > *{color:#fff !important;}`;
  38. }
  39.  
  40. cc(tag, options = {}, parent = false, init = undefined) {
  41. const children = options.children || [];
  42. delete options.children;
  43. const element = Object.assign(document.createElement(tag), options);
  44. for (const child of children) element.appendChild(child);
  45. if (typeof init == 'function') init(element);
  46. return parent && parent.nodeType === Node.ELEMENT_NODE ? parent.appendChild(element) : element;
  47. }
  48.  
  49. ss(selector, searchIn = document, all = false) {
  50. return all ? searchIn.querySelectorAll(selector) : searchIn.querySelector(selector);
  51. }
  52.  
  53. checkForСompatibility() {
  54. for (let item of this.compatibilityItems) {
  55. if (!(item in unsafeWindow)) return false;
  56. }
  57. return true;
  58. }
  59.  
  60. init() {
  61. if (!this.checkForСompatibility()) return alert('Sorry, your browser does not support this feature');
  62. document.addEventListener('DOMContentLoaded', this.onDOMContentLoaded.bind(this));
  63. unsafeWindow.addEventListener('pointerdown', this.onPointerDown.bind(this));
  64. this.shutDownConsole();
  65. this.setTriggerOnElement('#message', 'addedNodes', this.onContextMenu.bind(this));
  66. }
  67.  
  68. onDOMContentLoaded() {
  69. this.addStyles();
  70. }
  71.  
  72. addStyles() {
  73. this.cc('style', {
  74. textContent: this.style
  75. }, document.head);
  76. }
  77.  
  78. shutDownConsole() {
  79. for (const key in Object.getOwnPropertyDescriptors(unsafeWindow.console)) unsafeWindow.console[key] = () => {};
  80. }
  81.  
  82. onPointerDown(e) {
  83. if (e.which == 3) this.currentElement = e.target;
  84. }
  85.  
  86. onContextMenu(menu) {
  87. const currentMessageBlock = this.currentElement.closest('[id*="chat-messages"]');
  88. const media = this.currentElement.closest('[class*=embedMedia]') || this.currentElement.closest('[class*="messageAttachment"]');
  89. const group = this.ss('[role="group"]', menu, true)[1];
  90.  
  91. if (media !== null) {
  92. let mediaType;
  93. if (mediaType = this.ss('img', media)) {
  94. group.after(this.cc('div', {
  95. role: "group",
  96. className: this.ss('[role="group"]', menu, true).className,
  97. children: [
  98. this.generateSplitLine(),
  99. this.generateContextButton(mediaType, menu, group, 'Copy Image', async() => {
  100. await this.copyImage(mediaType);
  101. menu.remove();
  102. }),
  103. this.generateContextButton(mediaType, menu, group, 'Save Image', async() => {
  104. await this.downloadImage(mediaType);
  105. menu.remove();
  106. }),
  107. this.generateSplitLine(),
  108. this.generateContextButton(mediaType, menu, group, 'Copy Link', () => {
  109. GM_setClipboard(this.getFullOfDiscordImage(mediaType.src).split('?')[0]);
  110. menu.remove();
  111. }),
  112. this.generateContextButton(mediaType, menu, group, 'Open Link', async() => {
  113. window.open(this.getFullOfDiscordImage(mediaType.src).split('?')[0], '_blank');
  114. menu.remove();
  115. })
  116. ]
  117. }));
  118. this.fixMenuPosition(menu);
  119. } else if ((mediaType = this.ss('video:not([aria-label="GIF"])', media))) {
  120. group.after(this.cc('div', {
  121. role: "group",
  122. className: this.ss('[role="group"]', menu, true).className,
  123. children: [
  124. this.generateSplitLine(),
  125. this.generateContextButton(mediaType, menu, group, 'Copy Link', () => {
  126. GM_setClipboard(this.getFullOfDiscordImage(mediaType.src).split('?')[0]);
  127. menu.remove();
  128. }),
  129. this.generateContextButton(mediaType, menu, group, 'Open Link', async() => {
  130. window.open(this.getFullOfDiscordImage(mediaType.src).split('?')[0], '_blank');
  131. menu.remove();
  132. })
  133. ]
  134. }));
  135. this.fixMenuPosition(menu);
  136. } else if ((mediaType = this.ss('video[aria-label="GIF"]', media))) {
  137. group.after(this.cc('div', {
  138. role: "group",
  139. className: this.ss('[role="group"]', menu, true).className,
  140. children: [
  141. this.generateSplitLine(),
  142. this.generateContextButton(mediaType, menu, group, 'Copy Link', () => {
  143. GM_setClipboard(this.getFullOfDiscordImage(mediaType.src).split('?')[0]);
  144. menu.remove();
  145. }),
  146. this.generateContextButton(mediaType, menu, group, 'Open Link', async() => {
  147. window.open(this.getFullOfDiscordImage(mediaType.src).split('?')[0], '_blank');
  148. menu.remove();
  149. })
  150. ]
  151. }));
  152. this.fixMenuPosition(menu);
  153. } else {
  154. console.log('unknown type');
  155. }
  156. } else {
  157. console.log('null');
  158. }
  159. }
  160.  
  161. fixMenuPosition(menu) {
  162. const rectM = menu.getBoundingClientRect();
  163. if (rectM.y + rectM.height > window.innerHeight) {
  164. const def = rectM.y + rectM.height - window.innerHeight;
  165. const top = menu.parentNode.style.top.replace('px', '');
  166. menu.parentNode.style.top = top - def + 'px';
  167. }
  168. }
  169.  
  170. generateSplitLine() {
  171. return this.cc('div', {
  172. role: 'separator',
  173. style: 'box-sizing:border-box;margin:4px;border-bottom:1px solid var(--background-modifier-accent);'
  174. });
  175. }
  176.  
  177. generateContextButton(mediaType, menu, group, text, onclick) {
  178. return this.cc('div', {
  179. role: "group",
  180. children: [
  181. this.cc('div', {
  182. className: group.children[1].className,
  183. children: [
  184. this.cc('div', {
  185. textContent: text,
  186. style: group.children[1].firstChild.className,
  187. })
  188. ],
  189. onclick: onclick,
  190. onmouseenter: function() {
  191. const focused = menu.querySelector('[class*="focused"]');
  192. if (focused) focused.classList.remove(Array.from(focused.classList).find(_class => _class.startsWith('focused')));
  193. this.classList.add('my-focused');
  194. },
  195. onmouseleave: function() {
  196. this.classList.remove('my-focused');
  197. }
  198. })
  199. ]
  200. })
  201. }
  202.  
  203. setTriggerOnElement(selector, action, callback, once, searchIn) {
  204. const observer = new MutationObserver(function(mutations) {
  205. for (const mutation of mutations) {
  206. const nodes = mutation[action] || [];
  207. for (const node of nodes) {
  208. const element = node.matches && node.matches(selector) ? node : (node.querySelector ? node.querySelector(selector) : null);
  209. if (element) {
  210. if (once) {
  211. observer.disconnect();
  212. return callback(element);
  213. } else {
  214. callback(element);
  215. }
  216. }
  217. }
  218. }
  219. });
  220.  
  221. observer.observe(searchIn || document, {
  222. attributes: false,
  223. childList: true,
  224. subtree: true
  225. });
  226.  
  227. return observer;
  228. }
  229.  
  230. async downloadImage(image) {
  231. const blob = await this.requestImage(this.getFullOfDiscordImage(image.src), 'blob');
  232. const dataURL = await this.blobToBase64(blob);
  233. this.cc('a', {
  234. href: dataURL,
  235. download: image.src.split('/').pop().split('?')[0]
  236. }).click();
  237. }
  238.  
  239. async imageToPNGBlob(blob) {
  240. const dataURL = await this.blobToBase64(blob);
  241. console.log(dataURL);
  242. }
  243.  
  244. blobToBase64(blob) {
  245. return new Promise((resolve) => {
  246. const reader = new FileReader();
  247. reader.onloadend = () => resolve(reader.result);
  248. reader.readAsDataURL(blob);
  249. });
  250. }
  251.  
  252. async copyImage(image) {
  253. await (this.getExtension(image.src) != 'png' ? this.copyNonPNGImage(image) : this.copyPNGImage(image));
  254. }
  255.  
  256. async copyNonPNGImage(image) {
  257. const blob = await this.requestImage(image.src, 'blob');
  258. const dataURL = await this.blobToBase64(blob);
  259. const pngBlob = await this.imageDataURLToPNGBlob(dataURL);
  260. this.copyImageFromBlob(pngBlob);
  261. }
  262.  
  263. imageDataURLToPNGBlob(dataURL) {
  264. return new Promise(resolve => {
  265. const img = this.cc('img', {
  266. src: dataURL,
  267. onload: () => {
  268. const canvas = this.cc('canvas', {
  269. style: 'position:fixed;top:0px;left:0px;z-index:1000;',
  270. width: img.width,
  271. height: img.height
  272. });
  273. const context = canvas.getContext('2d');
  274. context.drawImage(img, 0, 0, img.width, img.height);
  275. canvas.toBlob(blob => resolve(blob));
  276. }
  277. });
  278. });
  279. }
  280.  
  281. async copyPNGImage(image) {
  282. const imageBlob = await this.requestImage(this.getFullOfDiscordImage(image.src), 'blob');
  283. this.copyImageFromBlob(imageBlob);
  284. }
  285.  
  286. copyImageFromBlob(blob) {
  287. const item = new unsafeWindow.ClipboardItem({
  288. "image/png": blob
  289. });
  290. navigator.clipboard.write([item]);
  291. }
  292.  
  293. requestImage(imageURL, responseType) {
  294. return new Promise(function(resolve, reject) {
  295. GM_xmlhttpRequest({
  296. methods: "GET",
  297. responseType: responseType,
  298. url: imageURL,
  299. onload: function(data) {
  300. resolve(data.response);
  301. },
  302. onerror: function(e) {
  303. reject(e);
  304. }
  305. });
  306. });
  307. }
  308.  
  309. getFullOfDiscordImage(imageURL) {
  310. return imageURL.replace('media.discordapp.net', 'cdn.discordapp.com');
  311. }
  312.  
  313. getNoneDefaultStyle(node) {
  314. const styles = [];
  315. const supportElement = this.cc(node.tagName, {
  316. visible: false
  317. }, document.body);
  318. const elementStyles = window.getComputedStyle(node);
  319. const defaultStyles = window.getComputedStyle(supportElement);
  320. for (const key of elementStyles) {
  321. if (elementStyles[key] !== defaultStyles[key] && defaultStyles[key] !== '') styles.push([key, elementStyles[key]]);
  322. }
  323. supportElement.remove();
  324. return styles;
  325. }
  326.  
  327. packInlineStyle(style) {
  328. return style.reduce(function(pre, cur) {
  329. return pre + ";" + cur[0] + ":" + cur[1];
  330. }, "") + ";";
  331. }
  332.  
  333. getExtension(filename) {
  334. return filename.split('.').pop().split('?')[0];
  335. }
  336. }
  337.  
  338. const DCME = new DiscordContextMenuExtender();
  339. DCME.init();
  340.  
  341. })();