├── LICENSE ├── README.md ├── distrib ├── extra_contextlet_1-0.1.1-an+fx.xpi ├── extra_contextlet_2-0.1.1-an+fx.xpi ├── extra_contextlet_3-0.1.1-an+fx.xpi ├── extra_contextlet_4-0.1.1-an+fx.xpi ├── extra_contextlet_5-0.1.1-an+fx.xpi ├── extra_contextlet_6-0.1.1-an+fx.xpi ├── extra_contextlet_7-0.1.1-an+fx.xpi ├── extra_contextlet_8-0.1.1-an+fx.xpi └── extra_contextlet_9-0.1.1-an+fx.xpi ├── examples ├── README.md ├── dance.contextlet.json ├── rehost-image-on-imgur.contextlet.json ├── search-wikipedia-for-selection.contextlet.json └── visit-example-com.contextlet.json ├── extra ├── 1 │ ├── background.js │ └── manifest.json ├── 2 │ ├── background.js │ └── manifest.json ├── 3 │ ├── background.js │ └── manifest.json ├── 4 │ ├── background.js │ └── manifest.json ├── 5 │ ├── background.js │ └── manifest.json ├── 6 │ ├── background.js │ └── manifest.json ├── 7 │ ├── background.js │ └── manifest.json ├── 8 │ ├── background.js │ └── manifest.json └── 9 │ ├── background.js │ └── manifest.json ├── media ├── icon-16.png ├── icon-32.png ├── icon-48.png └── icon-64.png └── src ├── background.js ├── content.js ├── docs ├── reference.css ├── reference.html └── reference.js ├── images ├── contextlets-16.png └── contextlets-64.png ├── manifest.json └── options ├── options.css ├── options.html └── options.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 David Hammond 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Contextlets 2 | A WebExtension for adding custom context menu items that execute JavaScript. 3 | 4 | This extension was written for Firefox and hasn't yet been adapted for other browsers. 5 | -------------------------------------------------------------------------------- /distrib/extra_contextlet_1-0.1.1-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/distrib/extra_contextlet_1-0.1.1-an+fx.xpi -------------------------------------------------------------------------------- /distrib/extra_contextlet_2-0.1.1-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/distrib/extra_contextlet_2-0.1.1-an+fx.xpi -------------------------------------------------------------------------------- /distrib/extra_contextlet_3-0.1.1-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/distrib/extra_contextlet_3-0.1.1-an+fx.xpi -------------------------------------------------------------------------------- /distrib/extra_contextlet_4-0.1.1-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/distrib/extra_contextlet_4-0.1.1-an+fx.xpi -------------------------------------------------------------------------------- /distrib/extra_contextlet_5-0.1.1-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/distrib/extra_contextlet_5-0.1.1-an+fx.xpi -------------------------------------------------------------------------------- /distrib/extra_contextlet_6-0.1.1-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/distrib/extra_contextlet_6-0.1.1-an+fx.xpi -------------------------------------------------------------------------------- /distrib/extra_contextlet_7-0.1.1-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/distrib/extra_contextlet_7-0.1.1-an+fx.xpi -------------------------------------------------------------------------------- /distrib/extra_contextlet_8-0.1.1-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/distrib/extra_contextlet_8-0.1.1-an+fx.xpi -------------------------------------------------------------------------------- /distrib/extra_contextlet_9-0.1.1-an+fx.xpi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/distrib/extra_contextlet_9-0.1.1-an+fx.xpi -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Contextlet Examples 2 | 3 | You can import these files into the Contextlets extension by using the "Import" button in the Contextlets Preferences page. 4 | -------------------------------------------------------------------------------- /examples/dance.contextlet.json: -------------------------------------------------------------------------------- 1 | {"code":"var imageURLs = [\n 'https://i.imgur.com/uxVZvt3.gif',\n 'https://i.imgur.com/MoIorqi.gif',\n 'https://i.imgur.com/VgWkq4T.gif',\n 'https://i.imgur.com/rmpntsz.gif',\n 'https://i.imgur.com/3TN3Fsj.gif',\n 'https://i.imgur.com/gvW1b10.gif',\n 'https://i.imgur.com/5cKPSOw.gif',\n 'https://i.imgur.com/o5DYHES.gif',\n 'https://i.imgur.com/fofCVsM.gif',\n 'https://i.imgur.com/musNzRJ.gif',\n 'https://i.imgur.com/JBAeT1a.gif',\n 'https://i.imgur.com/YdxNB4w.gif',\n 'https://i.imgur.com/4sw1W9w.gif',\n 'https://i.imgur.com/za2Sj7m.gif',\n 'https://i.imgur.com/0Pz197z.gif',\n];\n\nvar startX, startY;\nvar dragging = false\nvar x = Math.floor(Math.random() * (document.body.parentNode.clientWidth - 200)) + document.body.parentNode.scrollLeft;\nvar y = Math.floor(Math.random() * (document.body.parentNode.clientHeight - 200)) + document.body.parentNode.scrollTop;\n\nif (document.DanceContextletQueue == undefined || !document.DanceContextletQueue.length) {\n document.DanceContextletQueue = imageURLs.slice().sort(function (a, b) {\n return Math.random() < .5 ? 1 : -1;\n });\n}\n\nvar img = document.createElement('img');\nimg.src = document.DanceContextletQueue.pop();\nimg.style.MozUserSelect = 'none';\nimg.style.userSelect = 'none';\nimg.style.position = 'absolute';\nimg.style.left = x + 'px';\nimg.style.top = y + 'px';\nimg.style.zIndex = 2147483647;\n\nimg.addEventListener('dragstart', function (e) {\n e.preventDefault();\n});\n\nimg.addEventListener('mousedown', function (e) {\n startX = e.clientX;\n startY = e.clientY;\n dragging = true;\n}, false);\n\ndocument.addEventListener('mousemove', function (e) {\n if (dragging) {\n x += e.clientX - startX;\n y += e.clientY - startY;\n startX = e.clientX;\n startY = e.clientY;\n img.style.left = x + 'px';\n img.style.top = y + 'px';\n }\n}, false);\n\ndocument.addEventListener('mouseup', function (e) {\n dragging = false;\n}, false);\n\ndocument.body.appendChild(img);","contexts":["page","frame","image"],"icons":null,"patterns":"","scope":"content","title":"Dance!","type":"normal"} -------------------------------------------------------------------------------- /examples/rehost-image-on-imgur.contextlet.json: -------------------------------------------------------------------------------- 1 | {"code":"// You must set this to your client ID, which you can obtain via https://api.imgur.com/oauth2/addclient\n\nvar imgurClientId = 'YOUR_CLIENT_ID';\n\n// Let's define some functions...\n\n/**\n * Upload an image by its URL.\n */\nvar uploadByURL = function (url, onError)\n{\n // Create the AJAX request to upload the image by its URL.\n \n var request = createUploadRequest(onError);\n \n // Resolve the URL to an absolute URL, in case it's relative.\n \n var a = document.createElement('a');\n a.href = url;\n url = a.href;\n \n // Send the request (uploading the image URL).\n \n var formData = new FormData();\n formData.append('type', 'URL');\n formData.append('image', url);\n request.send(formData);\n};\n\n/**\n * Find the relevant element by its targetElementId and attempt to\n * upload its image data.\n */\nvar uploadByTargetElementData = function (targetElementId, onError)\n{\n var element = browser.menus.getTargetElement(targetElementId);\n \n if (element == null)\n {\n onError('The image vanished before it could be uploaded.');\n }\n \n if (!(element instanceof HTMLImageElement))\n {\n onError('Unable to read image data from the targeted element.');\n }\n \n var onReady = function ()\n {\n uploadByCanvasImageSource(element, onError);\n };\n \n // Make sure the image is fully loaded in the browser.\n \n if (element.complete)\n {\n onReady();\n }\n else\n {\n element.addEventListener('load', onReady);\n }\n};\n\n/**\n * Upload an image by reading the image data from an img element and\n * uploading that data directly.\n */\nvar uploadByImageElement = function (img, onError)\n{\n // Draw the image to a canvas so we can get its image data.\n \n var canvas = document.createElement('canvas');\n canvas.width = img.naturalWidth;\n canvas.height = img.naturalHeight;\n canvas.getContext('2d').drawImage(img, 0, 0);\n var base64Data = canvas.toDataURL('image/png').replace(/^data:[^,]+,/, '');\n \n // Create the AJAX request to upload the image data.\n \n var request = createUploadRequest(onError);\n \n // Send the request (uploading the image data).\n \n var formData = new FormData();\n formData.append('type', 'base64');\n formData.append('image', base64Data);\n request.send(formData);\n};\n\n/**\n * Fall back from uploading by URL to uploading by image data.\n *\n * We'll call this function if we fail to upload the image by its URL.\n * It's possible that imgur can't access the image URL (for example, if\n * it's behind a login wall or isn't reachable on the Internet). In this\n * case, we'll try to fall back to uploading the image by its data\n * instead.\n */\nvar fallBackToData = function (errorMessage)\n{\n // Do we know what element was clicked?\n \n if (context.info.targetElementId !== undefined)\n {\n // Try uploading the image data instead. The image will end up\n // being reencoded, which unfortunately means that JPEGs will\n // experience some loss of quality or be converted to PNG.\n \n uploadByTargetElementData(context.info.targetElementId, requestErrorHandler);\n }\n else\n {\n requestErrorHandler(errorMessage);\n }\n};\n\n/**\n * Helper function to alerting the user about an error.\n */\nvar requestErrorHandler = function (message)\n{\n console.log(message + ' The XMLHttpRequest object is below.');\n console.log(request);\n window.alert(message);\n};\n\n/**\n * Create an AJAX request for uploading to imgur, but don't send it yet.\n */\nvar createUploadRequest = function (onError)\n{\n var request = new XMLHttpRequest();\n request.open('POST', 'https://api.imgur.com/3/image');\n request.setRequestHeader('Authorization', 'Client-ID ' + imgurClientId);\n \n request.addEventListener('readystatechange', function ()\n {\n if (request.readyState === 4)\n {\n // We've received the response.\n \n if (request.status !== 200)\n {\n onError('Failed to upload to imgur.');\n return;\n }\n \n try\n {\n var result = JSON.parse(request.responseText);\n \n if (result.data == undefined || result.data.link == undefined)\n {\n throw new Error();\n }\n }\n catch (error)\n {\n onError('Received a bad response from imgur.');\n return;\n }\n \n // Success! Send the user to the image URL.\n \n if (context.info.modifiers.indexOf('Ctrl') == -1)\n {\n // Open in the same tab.\n \n document.location = result.data.link;\n }\n else\n {\n // Open in a new tab.\n \n // window.open() is subject to the site's popup blocker settings, so we'll use a background script instead.\n \n context.runAs('background', function ()\n {\n browser.tabs.create({url: this.params.url, active: true});\n }, {url: result.data.link});\n }\n }\n });\n \n return request;\n};\n\n// We're done defining the functions. Here's the initial program logic...\n\n// Put the original \"this\" in a variable so we can use it within functions.\n\nvar context = this;\n\nif (this.info.srcUrl !== undefined)\n{\n // Attempt to upload the image by its src URL.\n \n uploadByURL(this.info.srcUrl, fallBackToData);\n}\nelse\n{\n // The element that was clicked doesn't seem to have a src URL. Try\n // uploading by the element's image data instead, if possible, or\n // display this error message.\n \n fallBackToData('No image found.');\n}\n","contexts":["image"],"icons":null,"patterns":"","scope":"content","title":"Rehost Image on Imgur","type":"normal"} -------------------------------------------------------------------------------- /examples/search-wikipedia-for-selection.contextlet.json: -------------------------------------------------------------------------------- 1 | {"code":"var url = 'https://en.wikipedia.org/wiki/Special:Search?search=' + encodeURIComponent(this.info.selectionText);\n\nif (this.info.modifiers.indexOf('Ctrl') == -1)\n{\n // Open in the same tab.\n \n document.location = url;\n}\nelse\n{\n // Open in a new tab.\n \n // window.open() is subject to the site's popup blocker settings, so we'll use a background script instead.\n \n this.runAs('background', function ()\n {\n browser.tabs.create({url: this.params.url, active: true});\n }, {url: url});\n}","contexts":["selection"],"icons":null,"patterns":"","scope":"content","title":"Search Wikipedia for “%s”","type":"normal"} -------------------------------------------------------------------------------- /examples/visit-example-com.contextlet.json: -------------------------------------------------------------------------------- 1 | {"code":"document.location = 'https://example.com/';","contexts":["page","frame"],"icons":null,"patterns":"","scope":"content","title":"Visit example.com","type":"normal"} -------------------------------------------------------------------------------- /extra/1/background.js: -------------------------------------------------------------------------------- 1 | var itemExtensionIds = {}; 2 | 3 | browser.runtime.onMessageExternal.addListener(function (message, sender) 4 | { 5 | if (message.type !== 'contextlets:items') 6 | { 7 | return; 8 | } 9 | 10 | if (Object.prototype.hasOwnProperty.call(message, 'items')) 11 | { 12 | browser.contextMenus.removeAll(function () 13 | { 14 | itemExtensionIds = {}; 15 | 16 | message.items.forEach(function (item) 17 | { 18 | itemExtensionIds[item.id] = sender.id; 19 | browser.contextMenus.create(item); 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | browser.contextMenus.onClicked.addListener(function (info, tab) 26 | { 27 | if (Object.prototype.hasOwnProperty.call(itemExtensionIds, info.menuItemId)) 28 | { 29 | browser.runtime.sendMessage(itemExtensionIds[info.menuItemId], 30 | { 31 | type: 'contextlets:clicked', 32 | info: info, 33 | tab: tab, 34 | }); 35 | } 36 | }); 37 | 38 | var update = function () 39 | { 40 | browser.runtime.sendMessage('{dcf34dbe-ccd1-11e7-8f66-ff8971474715}', 41 | { 42 | type: 'contextlets:update', 43 | }); 44 | }; 45 | 46 | browser.runtime.onInstalled.addListener(update); 47 | browser.runtime.onStartup.addListener(update); 48 | -------------------------------------------------------------------------------- /extra/1/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Extra Contextlet #1", 4 | "version": "0.1.1", 5 | "description": "Add an additional Contextlet to the top-level context menu. Requires the Contextlets extension.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "contextMenus" 10 | ], 11 | "applications": 12 | { 13 | "gecko": 14 | { 15 | "id": "{02a51210-dd38-11e7-81af-eb782ef9bddb}" 16 | } 17 | }, 18 | "background": 19 | { 20 | "scripts": 21 | [ 22 | "background.js" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extra/2/background.js: -------------------------------------------------------------------------------- 1 | var itemExtensionIds = {}; 2 | 3 | browser.runtime.onMessageExternal.addListener(function (message, sender) 4 | { 5 | if (message.type !== 'contextlets:items') 6 | { 7 | return; 8 | } 9 | 10 | if (Object.prototype.hasOwnProperty.call(message, 'items')) 11 | { 12 | browser.contextMenus.removeAll(function () 13 | { 14 | itemExtensionIds = {}; 15 | 16 | message.items.forEach(function (item) 17 | { 18 | itemExtensionIds[item.id] = sender.id; 19 | browser.contextMenus.create(item); 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | browser.contextMenus.onClicked.addListener(function (info, tab) 26 | { 27 | if (Object.prototype.hasOwnProperty.call(itemExtensionIds, info.menuItemId)) 28 | { 29 | browser.runtime.sendMessage(itemExtensionIds[info.menuItemId], 30 | { 31 | type: 'contextlets:clicked', 32 | info: info, 33 | tab: tab, 34 | }); 35 | } 36 | }); 37 | 38 | var update = function () 39 | { 40 | browser.runtime.sendMessage('{dcf34dbe-ccd1-11e7-8f66-ff8971474715}', 41 | { 42 | type: 'contextlets:update', 43 | }); 44 | }; 45 | 46 | browser.runtime.onInstalled.addListener(update); 47 | browser.runtime.onStartup.addListener(update); 48 | -------------------------------------------------------------------------------- /extra/2/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Extra Contextlet #2", 4 | "version": "0.1.1", 5 | "description": "Add an additional Contextlet to the top-level context menu. Requires the Contextlets extension.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "contextMenus" 10 | ], 11 | "applications": 12 | { 13 | "gecko": 14 | { 15 | "id": "{a01d9252-dd46-11e7-8ee5-f797963881ec}" 16 | } 17 | }, 18 | "background": 19 | { 20 | "scripts": 21 | [ 22 | "background.js" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extra/3/background.js: -------------------------------------------------------------------------------- 1 | var itemExtensionIds = {}; 2 | 3 | browser.runtime.onMessageExternal.addListener(function (message, sender) 4 | { 5 | if (message.type !== 'contextlets:items') 6 | { 7 | return; 8 | } 9 | 10 | if (Object.prototype.hasOwnProperty.call(message, 'items')) 11 | { 12 | browser.contextMenus.removeAll(function () 13 | { 14 | itemExtensionIds = {}; 15 | 16 | message.items.forEach(function (item) 17 | { 18 | itemExtensionIds[item.id] = sender.id; 19 | browser.contextMenus.create(item); 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | browser.contextMenus.onClicked.addListener(function (info, tab) 26 | { 27 | if (Object.prototype.hasOwnProperty.call(itemExtensionIds, info.menuItemId)) 28 | { 29 | browser.runtime.sendMessage(itemExtensionIds[info.menuItemId], 30 | { 31 | type: 'contextlets:clicked', 32 | info: info, 33 | tab: tab, 34 | }); 35 | } 36 | }); 37 | 38 | var update = function () 39 | { 40 | browser.runtime.sendMessage('{dcf34dbe-ccd1-11e7-8f66-ff8971474715}', 41 | { 42 | type: 'contextlets:update', 43 | }); 44 | }; 45 | 46 | browser.runtime.onInstalled.addListener(update); 47 | browser.runtime.onStartup.addListener(update); 48 | -------------------------------------------------------------------------------- /extra/3/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Extra Contextlet #3", 4 | "version": "0.1.1", 5 | "description": "Add an additional Contextlet to the top-level context menu. Requires the Contextlets extension.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "contextMenus" 10 | ], 11 | "applications": 12 | { 13 | "gecko": 14 | { 15 | "id": "{a84eb3c0-dd46-11e7-988d-a7c071c65a7c}" 16 | } 17 | }, 18 | "background": 19 | { 20 | "scripts": 21 | [ 22 | "background.js" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extra/4/background.js: -------------------------------------------------------------------------------- 1 | var itemExtensionIds = {}; 2 | 3 | browser.runtime.onMessageExternal.addListener(function (message, sender) 4 | { 5 | if (message.type !== 'contextlets:items') 6 | { 7 | return; 8 | } 9 | 10 | if (Object.prototype.hasOwnProperty.call(message, 'items')) 11 | { 12 | browser.contextMenus.removeAll(function () 13 | { 14 | itemExtensionIds = {}; 15 | 16 | message.items.forEach(function (item) 17 | { 18 | itemExtensionIds[item.id] = sender.id; 19 | browser.contextMenus.create(item); 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | browser.contextMenus.onClicked.addListener(function (info, tab) 26 | { 27 | if (Object.prototype.hasOwnProperty.call(itemExtensionIds, info.menuItemId)) 28 | { 29 | browser.runtime.sendMessage(itemExtensionIds[info.menuItemId], 30 | { 31 | type: 'contextlets:clicked', 32 | info: info, 33 | tab: tab, 34 | }); 35 | } 36 | }); 37 | 38 | var update = function () 39 | { 40 | browser.runtime.sendMessage('{dcf34dbe-ccd1-11e7-8f66-ff8971474715}', 41 | { 42 | type: 'contextlets:update', 43 | }); 44 | }; 45 | 46 | browser.runtime.onInstalled.addListener(update); 47 | browser.runtime.onStartup.addListener(update); 48 | -------------------------------------------------------------------------------- /extra/4/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Extra Contextlet #4", 4 | "version": "0.1.1", 5 | "description": "Add an additional Contextlet to the top-level context menu. Requires the Contextlets extension.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "contextMenus" 10 | ], 11 | "applications": 12 | { 13 | "gecko": 14 | { 15 | "id": "{b2bec016-dd46-11e7-8f9f-7b99b04f6c31}" 16 | } 17 | }, 18 | "background": 19 | { 20 | "scripts": 21 | [ 22 | "background.js" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extra/5/background.js: -------------------------------------------------------------------------------- 1 | var itemExtensionIds = {}; 2 | 3 | browser.runtime.onMessageExternal.addListener(function (message, sender) 4 | { 5 | if (message.type !== 'contextlets:items') 6 | { 7 | return; 8 | } 9 | 10 | if (Object.prototype.hasOwnProperty.call(message, 'items')) 11 | { 12 | browser.contextMenus.removeAll(function () 13 | { 14 | itemExtensionIds = {}; 15 | 16 | message.items.forEach(function (item) 17 | { 18 | itemExtensionIds[item.id] = sender.id; 19 | browser.contextMenus.create(item); 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | browser.contextMenus.onClicked.addListener(function (info, tab) 26 | { 27 | if (Object.prototype.hasOwnProperty.call(itemExtensionIds, info.menuItemId)) 28 | { 29 | browser.runtime.sendMessage(itemExtensionIds[info.menuItemId], 30 | { 31 | type: 'contextlets:clicked', 32 | info: info, 33 | tab: tab, 34 | }); 35 | } 36 | }); 37 | 38 | var update = function () 39 | { 40 | browser.runtime.sendMessage('{dcf34dbe-ccd1-11e7-8f66-ff8971474715}', 41 | { 42 | type: 'contextlets:update', 43 | }); 44 | }; 45 | 46 | browser.runtime.onInstalled.addListener(update); 47 | browser.runtime.onStartup.addListener(update); 48 | -------------------------------------------------------------------------------- /extra/5/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Extra Contextlet #5", 4 | "version": "0.1.1", 5 | "description": "Add an additional Contextlet to the top-level context menu. Requires the Contextlets extension.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "contextMenus" 10 | ], 11 | "applications": 12 | { 13 | "gecko": 14 | { 15 | "id": "{e0ca5e10-eb3e-4487-8bf7-9e73e27175b2}" 16 | } 17 | }, 18 | "background": 19 | { 20 | "scripts": 21 | [ 22 | "background.js" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extra/6/background.js: -------------------------------------------------------------------------------- 1 | var itemExtensionIds = {}; 2 | 3 | browser.runtime.onMessageExternal.addListener(function (message, sender) 4 | { 5 | if (message.type !== 'contextlets:items') 6 | { 7 | return; 8 | } 9 | 10 | if (Object.prototype.hasOwnProperty.call(message, 'items')) 11 | { 12 | browser.contextMenus.removeAll(function () 13 | { 14 | itemExtensionIds = {}; 15 | 16 | message.items.forEach(function (item) 17 | { 18 | itemExtensionIds[item.id] = sender.id; 19 | browser.contextMenus.create(item); 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | browser.contextMenus.onClicked.addListener(function (info, tab) 26 | { 27 | if (Object.prototype.hasOwnProperty.call(itemExtensionIds, info.menuItemId)) 28 | { 29 | browser.runtime.sendMessage(itemExtensionIds[info.menuItemId], 30 | { 31 | type: 'contextlets:clicked', 32 | info: info, 33 | tab: tab, 34 | }); 35 | } 36 | }); 37 | 38 | var update = function () 39 | { 40 | browser.runtime.sendMessage('{dcf34dbe-ccd1-11e7-8f66-ff8971474715}', 41 | { 42 | type: 'contextlets:update', 43 | }); 44 | }; 45 | 46 | browser.runtime.onInstalled.addListener(update); 47 | browser.runtime.onStartup.addListener(update); 48 | -------------------------------------------------------------------------------- /extra/6/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Extra Contextlet #6", 4 | "version": "0.1.1", 5 | "description": "Add an additional Contextlet to the top-level context menu. Requires the Contextlets extension.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "contextMenus" 10 | ], 11 | "applications": 12 | { 13 | "gecko": 14 | { 15 | "id": "{b27dfc43-eedd-4c19-bece-598e7bbec817}" 16 | } 17 | }, 18 | "background": 19 | { 20 | "scripts": 21 | [ 22 | "background.js" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extra/7/background.js: -------------------------------------------------------------------------------- 1 | var itemExtensionIds = {}; 2 | 3 | browser.runtime.onMessageExternal.addListener(function (message, sender) 4 | { 5 | if (message.type !== 'contextlets:items') 6 | { 7 | return; 8 | } 9 | 10 | if (Object.prototype.hasOwnProperty.call(message, 'items')) 11 | { 12 | browser.contextMenus.removeAll(function () 13 | { 14 | itemExtensionIds = {}; 15 | 16 | message.items.forEach(function (item) 17 | { 18 | itemExtensionIds[item.id] = sender.id; 19 | browser.contextMenus.create(item); 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | browser.contextMenus.onClicked.addListener(function (info, tab) 26 | { 27 | if (Object.prototype.hasOwnProperty.call(itemExtensionIds, info.menuItemId)) 28 | { 29 | browser.runtime.sendMessage(itemExtensionIds[info.menuItemId], 30 | { 31 | type: 'contextlets:clicked', 32 | info: info, 33 | tab: tab, 34 | }); 35 | } 36 | }); 37 | 38 | var update = function () 39 | { 40 | browser.runtime.sendMessage('{dcf34dbe-ccd1-11e7-8f66-ff8971474715}', 41 | { 42 | type: 'contextlets:update', 43 | }); 44 | }; 45 | 46 | browser.runtime.onInstalled.addListener(update); 47 | browser.runtime.onStartup.addListener(update); 48 | -------------------------------------------------------------------------------- /extra/7/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Extra Contextlet #7", 4 | "version": "0.1.1", 5 | "description": "Add an additional Contextlet to the top-level context menu. Requires the Contextlets extension.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "contextMenus" 10 | ], 11 | "applications": 12 | { 13 | "gecko": 14 | { 15 | "id": "{2fab476d-90d0-47d5-bf4a-3b4193ca72d8}" 16 | } 17 | }, 18 | "background": 19 | { 20 | "scripts": 21 | [ 22 | "background.js" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extra/8/background.js: -------------------------------------------------------------------------------- 1 | var itemExtensionIds = {}; 2 | 3 | browser.runtime.onMessageExternal.addListener(function (message, sender) 4 | { 5 | if (message.type !== 'contextlets:items') 6 | { 7 | return; 8 | } 9 | 10 | if (Object.prototype.hasOwnProperty.call(message, 'items')) 11 | { 12 | browser.contextMenus.removeAll(function () 13 | { 14 | itemExtensionIds = {}; 15 | 16 | message.items.forEach(function (item) 17 | { 18 | itemExtensionIds[item.id] = sender.id; 19 | browser.contextMenus.create(item); 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | browser.contextMenus.onClicked.addListener(function (info, tab) 26 | { 27 | if (Object.prototype.hasOwnProperty.call(itemExtensionIds, info.menuItemId)) 28 | { 29 | browser.runtime.sendMessage(itemExtensionIds[info.menuItemId], 30 | { 31 | type: 'contextlets:clicked', 32 | info: info, 33 | tab: tab, 34 | }); 35 | } 36 | }); 37 | 38 | var update = function () 39 | { 40 | browser.runtime.sendMessage('{dcf34dbe-ccd1-11e7-8f66-ff8971474715}', 41 | { 42 | type: 'contextlets:update', 43 | }); 44 | }; 45 | 46 | browser.runtime.onInstalled.addListener(update); 47 | browser.runtime.onStartup.addListener(update); 48 | -------------------------------------------------------------------------------- /extra/8/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Extra Contextlet #8", 4 | "version": "0.1.1", 5 | "description": "Add an additional Contextlet to the top-level context menu. Requires the Contextlets extension.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "contextMenus" 10 | ], 11 | "applications": 12 | { 13 | "gecko": 14 | { 15 | "id": "{423477bb-da5c-4d2b-bd46-f254e1412fcf}" 16 | } 17 | }, 18 | "background": 19 | { 20 | "scripts": 21 | [ 22 | "background.js" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extra/9/background.js: -------------------------------------------------------------------------------- 1 | var itemExtensionIds = {}; 2 | 3 | browser.runtime.onMessageExternal.addListener(function (message, sender) 4 | { 5 | if (message.type !== 'contextlets:items') 6 | { 7 | return; 8 | } 9 | 10 | if (Object.prototype.hasOwnProperty.call(message, 'items')) 11 | { 12 | browser.contextMenus.removeAll(function () 13 | { 14 | itemExtensionIds = {}; 15 | 16 | message.items.forEach(function (item) 17 | { 18 | itemExtensionIds[item.id] = sender.id; 19 | browser.contextMenus.create(item); 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | browser.contextMenus.onClicked.addListener(function (info, tab) 26 | { 27 | if (Object.prototype.hasOwnProperty.call(itemExtensionIds, info.menuItemId)) 28 | { 29 | browser.runtime.sendMessage(itemExtensionIds[info.menuItemId], 30 | { 31 | type: 'contextlets:clicked', 32 | info: info, 33 | tab: tab, 34 | }); 35 | } 36 | }); 37 | 38 | var update = function () 39 | { 40 | browser.runtime.sendMessage('{dcf34dbe-ccd1-11e7-8f66-ff8971474715}', 41 | { 42 | type: 'contextlets:update', 43 | }); 44 | }; 45 | 46 | browser.runtime.onInstalled.addListener(update); 47 | browser.runtime.onStartup.addListener(update); 48 | -------------------------------------------------------------------------------- /extra/9/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Extra Contextlet #9", 4 | "version": "0.1.1", 5 | "description": "Add an additional Contextlet to the top-level context menu. Requires the Contextlets extension.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "contextMenus" 10 | ], 11 | "applications": 12 | { 13 | "gecko": 14 | { 15 | "id": "{33d203ab-419c-4ef6-a512-4b9e597670bd}" 16 | } 17 | }, 18 | "background": 19 | { 20 | "scripts": 21 | [ 22 | "background.js" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /media/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/media/icon-16.png -------------------------------------------------------------------------------- /media/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/media/icon-32.png -------------------------------------------------------------------------------- /media/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/media/icon-48.png -------------------------------------------------------------------------------- /media/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/media/icon-64.png -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (execute) 4 | { 5 | var prefDefaults = 6 | { 7 | items: [], 8 | lineNumbers: false, 9 | validate: true, 10 | }; 11 | 12 | var renderers = []; 13 | var rendererLookup = {}; 14 | 15 | /** 16 | * Create the API for the user's script. 17 | */ 18 | var createAPI = function (message) 19 | { 20 | // Deep-clone the message to ensure that the original 21 | // version is preserved. The messaging API requires 22 | // JSON-serializability anyway. 23 | 24 | var messageClone = JSON.parse(JSON.stringify(message)); 25 | 26 | var api = 27 | { 28 | /** 29 | * Asynchronously run user-provided code in the given scope. 30 | */ 31 | runAs: function (scope, code, params) 32 | { 33 | switch (scope) 34 | { 35 | case 'background': 36 | // Execute the provided code locally. 37 | 38 | window.setTimeout(function () 39 | { 40 | execute.call(createRemoteMessage(message, code, params)); 41 | }, 0); 42 | break; 43 | 44 | case 'content': 45 | // Execute the provided code in the content script. 46 | 47 | browser.tabs.sendMessage(message.tab.id, createRemoteMessage(message, code, params)); 48 | break; 49 | 50 | default: 51 | throw new Error('Unrecognized scope.'); 52 | } 53 | }, 54 | message: messageClone, 55 | }; 56 | 57 | Object.assign(api, messageClone); 58 | return api; 59 | }; 60 | 61 | /** 62 | * Create message to be executed in a different scope. 63 | */ 64 | var createRemoteMessage = function (message, code, params) 65 | { 66 | var remoteMessage = {}; 67 | Object.assign(remoteMessage, message); 68 | 69 | if (typeof code == 'function') 70 | { 71 | // The user provided a function instead of a code string. 72 | // Convert this into a code string so that the function 73 | // will be called when it's evaled (without any of the 74 | // lexical scope). "this" should be inherited from where 75 | // the eval is run. 76 | 77 | code = '('+code+').call(this);'; 78 | } 79 | else 80 | { 81 | code += ''; 82 | } 83 | 84 | remoteMessage.code = code; 85 | remoteMessage.params = params === undefined ? null : params; 86 | return remoteMessage; 87 | }; 88 | 89 | /** 90 | * Set up the context menu items from the user settings. 91 | */ 92 | var update = function () 93 | { 94 | browser.storage.local.get(prefDefaults).then(function (prefs) 95 | { 96 | // Remove all of our context menu items first. 97 | 98 | browser.contextMenus.removeAll(function () 99 | { 100 | renderers.forEach(function (renderer) 101 | { 102 | renderer.items = []; 103 | }); 104 | 105 | prefs.items.forEach(function (item) 106 | { 107 | if (item.contexts.length == 0) 108 | { 109 | return; 110 | } 111 | 112 | // These settings will be used for all types of contexts. 113 | 114 | var commonSettings = 115 | { 116 | checked: item.checked, 117 | enabled: item.enabled, 118 | title: item.title, 119 | type: item.type, 120 | }; 121 | 122 | if (typeof item.icons == 'string') 123 | { 124 | commonSettings.icons = {16: item.icons}; 125 | } 126 | else if (item.icons != null) 127 | { 128 | commonSettings.icons = item.icons; 129 | } 130 | 131 | // Get the list of URL patterns to match on. 132 | 133 | var patterns = item.patterns.replace(/^[\r\n]+|[\r\n]+$/g, ''); 134 | 135 | if (item.patterns == '') 136 | { 137 | patterns = ['']; 138 | } 139 | else 140 | { 141 | patterns = patterns.split(/[\r\n]+/g); 142 | } 143 | 144 | // Depending on the type of context, we'll either want to match 145 | // the URL against the target ("object"-type contexts) or the 146 | // document ("page"-type contexts). Let's organize the contexts 147 | // into these two groups and define the menu items separately. 148 | 149 | var pageContexts = []; 150 | var objectContexts = []; 151 | 152 | item.contexts.forEach(function (context) 153 | { 154 | switch (context) 155 | { 156 | case 'editable': 157 | case 'frame': 158 | case 'page': 159 | case 'password': 160 | case 'selection': 161 | case 'tab': 162 | pageContexts.push(context); 163 | break; 164 | 165 | default: 166 | objectContexts.push(context); 167 | } 168 | }); 169 | 170 | if (item.extensionId == null) 171 | { 172 | item.extensionId = browser.runtime.id; 173 | } 174 | 175 | if (!Object.prototype.hasOwnProperty.call(rendererLookup, item.extensionId)) 176 | { 177 | rendererLookup[item.extensionId] = renderers.length; 178 | renderers.push( 179 | { 180 | extensionId: item.extensionId, 181 | items: [], 182 | }); 183 | } 184 | 185 | var index = rendererLookup[item.extensionId]; 186 | 187 | if (pageContexts.length > 0) 188 | { 189 | renderers[index].items.push(Object.assign( 190 | { 191 | contexts: pageContexts, 192 | documentUrlPatterns: item.documentUrlPatterns === undefined ? patterns : item.documentUrlPatterns, 193 | id: item.id+'-page', 194 | targetUrlPatterns: item.targetUrlPatterns, 195 | }, commonSettings)); 196 | } 197 | 198 | if (objectContexts.length > 0) 199 | { 200 | renderers[index].items.push(Object.assign( 201 | { 202 | contexts: objectContexts, 203 | documentUrlPatterns: item.documentUrlPatterns, 204 | id: item.id+'-object', 205 | targetUrlPatterns: item.targetUrlPatterns === undefined ? patterns : item.targetUrlPatterns, 206 | }, commonSettings)); 207 | } 208 | }); 209 | 210 | var runNextRenderer = function (index) 211 | { 212 | if (index >= renderers.length) 213 | { 214 | return; 215 | } 216 | 217 | var renderer = renderers[index]; 218 | 219 | if (renderer.extensionId === browser.runtime.id) 220 | { 221 | // Local menu items. 222 | 223 | renderer.items.forEach(function (item) 224 | { 225 | browser.contextMenus.create(item); 226 | }); 227 | 228 | runNextRenderer(index + 1); 229 | } 230 | else 231 | { 232 | // Menu items that will be rendered by another extension. 233 | 234 | browser.runtime.sendMessage(renderer.extensionId, 235 | { 236 | type: 'contextlets:items', 237 | items: renderer.items, 238 | }).then(function () 239 | { 240 | runNextRenderer(index + 1); 241 | }); 242 | } 243 | }; 244 | 245 | runNextRenderer(0); 246 | }); 247 | }); 248 | }; 249 | 250 | /** 251 | * Run the script associated with a context menu item. 252 | */ 253 | var menuItemClicked = function (info, tab) 254 | { 255 | browser.storage.local.get(prefDefaults).then(function (prefs) 256 | { 257 | var itemId = (info.menuItemId+'').replace(/-(?:page|object)$/, ''); 258 | var item = prefs.items.find(function (item) 259 | { 260 | return item.id == itemId; 261 | }); 262 | 263 | if (item === undefined) 264 | { 265 | // Invalid item ID passed. 266 | 267 | return; 268 | } 269 | 270 | var message = 271 | { 272 | code: item.code, 273 | info: info, 274 | itemSettings: item, 275 | params: null, 276 | tab: tab, 277 | }; 278 | 279 | // Execute the menu item's code in the configured scope. 280 | 281 | switch (item.scope) 282 | { 283 | case 'background': 284 | execute.call(createAPI(message)); 285 | break; 286 | 287 | case 'content': 288 | browser.tabs.sendMessage(tab.id, message); 289 | break; 290 | 291 | default: 292 | throw new Error('Unrecognized scope.'); 293 | } 294 | }); 295 | }; 296 | 297 | // Events to trigger reloading of the content menu items. 298 | 299 | browser.runtime.onInstalled.addListener(update); 300 | browser.runtime.onStartup.addListener(update); 301 | browser.storage.onChanged.addListener(function (prefs) 302 | { 303 | if (prefs.items) 304 | { 305 | update(); 306 | } 307 | }); 308 | 309 | // Listen for menu item clicks. 310 | 311 | browser.contextMenus.onClicked.addListener(menuItemClicked); 312 | 313 | // Listen for messages coming from the content script. 314 | 315 | browser.runtime.onMessage.addListener(function (message) 316 | { 317 | if (Object.prototype.hasOwnProperty.call(message, 'code')) 318 | { 319 | // We've received a call from the content script. 320 | 321 | execute.call(createAPI(message)); 322 | } 323 | }); 324 | 325 | // Listen for messages coming from other extensions. 326 | 327 | browser.runtime.onMessageExternal.addListener(function (message, sender) 328 | { 329 | if (!Object.prototype.hasOwnProperty.call(rendererLookup, sender.id)) 330 | { 331 | // No items are associated with this extension. Ignore the message. 332 | 333 | return; 334 | } 335 | 336 | switch (message.type) 337 | { 338 | case 'contextlets:clicked': 339 | if (Object.prototype.hasOwnProperty.call(message, 'info') && Object.prototype.hasOwnProperty.call(message, 'tab')) 340 | { 341 | // We've received a click from a helper extension. 342 | 343 | menuItemClicked(message.info, message.tab); 344 | } 345 | 346 | break; 347 | 348 | case 'contextlets:update': 349 | update(); 350 | break; 351 | } 352 | }); 353 | })(function () 354 | { 355 | // This is where the user's code is executed for background scripts. 356 | // The function is defined here so it doesn't inherit any of our 357 | // internal variables. This function should always be called in 358 | // the context of a createAPI() result. 359 | 360 | eval(this.code); 361 | }); 362 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (execute) 4 | { 5 | /** 6 | * Create the API for the user's script. 7 | */ 8 | var createAPI = function (message) 9 | { 10 | // Deep-clone the message to ensure that the original 11 | // version is preserved. The messaging API requires 12 | // JSON-serializability anyway. 13 | 14 | var messageClone = JSON.parse(JSON.stringify(message)); 15 | 16 | var api = 17 | { 18 | /** 19 | * Asynchronously run user-provided code in the given scope. 20 | */ 21 | runAs: function (scope, code, params) 22 | { 23 | switch (scope) 24 | { 25 | case 'background': 26 | // Execute the provided code in the background script. 27 | 28 | browser.runtime.sendMessage(createRemoteMessage(message, code, params)); 29 | break; 30 | 31 | case 'content': 32 | // Execute the provided code locally. 33 | 34 | window.setTimeout(function () 35 | { 36 | execute.call(createRemoteMessage(message, code, params)); 37 | }, 0); 38 | break; 39 | 40 | default: 41 | throw new Error('Unrecognized scope.'); 42 | } 43 | }, 44 | message: messageClone, 45 | }; 46 | 47 | Object.assign(api, messageClone); 48 | return api; 49 | }; 50 | 51 | /** 52 | * Create message to be executed in a different scope. 53 | */ 54 | var createRemoteMessage = function (message, code, params) 55 | { 56 | var remoteMessage = {}; 57 | Object.assign(remoteMessage, message); 58 | 59 | if (typeof code == 'function') 60 | { 61 | // The user provided a function instead of a code string. 62 | // Convert this into a code string so that the function 63 | // will be called when it's evaled (without any of the 64 | // lexical scope). "this" should be inherited from where 65 | // the eval is run. 66 | 67 | code = '('+code+').call(this);'; 68 | } 69 | else 70 | { 71 | code += ''; 72 | } 73 | 74 | remoteMessage.code = code; 75 | remoteMessage.params = params === undefined ? null : params; 76 | return remoteMessage; 77 | }; 78 | 79 | // Listen for messages coming from the background script. 80 | 81 | browser.runtime.onMessage.addListener(function (message) 82 | { 83 | if (message.code !== undefined) 84 | { 85 | // We've received a call from the background script. 86 | 87 | execute.call(createAPI(message)); 88 | } 89 | }); 90 | })(function () 91 | { 92 | // This is where the user's code is executed for content scripts. 93 | // The function is defined here so it doesn't inherit any of our 94 | // internal variables. This function should always be called in 95 | // the context of a createAPI() result. 96 | 97 | eval(this.code); 98 | }); 99 | -------------------------------------------------------------------------------- /src/docs/reference.css: -------------------------------------------------------------------------------- 1 | html 2 | { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | body 8 | { 9 | background: #fff; 10 | color: #000; 11 | font-family: sans-serif; 12 | margin: 0; 13 | padding: 16px; 14 | } 15 | 16 | h1 17 | { 18 | margin-top: 0; 19 | } 20 | 21 | a:link, 22 | a:visited 23 | { 24 | color: #0a8dff; 25 | text-decoration: none; 26 | transition: 27 | color .15s; 28 | } 29 | 30 | a:hover 31 | { 32 | color: #0060df; 33 | text-decoration: underline; 34 | } 35 | 36 | a:active 37 | { 38 | color: #003eaa; 39 | } 40 | 41 | dt 42 | { 43 | font-weight: bold; 44 | } 45 | 46 | p code, var 47 | { 48 | background: #e7e7e7; 49 | font-family: monospace; 50 | font-style: normal; 51 | padding: 0 2px; 52 | } 53 | -------------------------------------------------------------------------------- /src/docs/reference.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Contextlets API Reference 6 | 7 | 8 | 9 | 10 |
11 |
12 |

Contextlets API Reference

13 | 14 |

Content scripts run within a webpage and have direct access to the window object and DOM of the page. However, they don't have access to the JavaScript variables or functions that the page creates, or vice versa. This is a limitation of the WebExtension API. See Content script environment for details.

15 | 16 |

Background scripts run within the browser infrastructure and have direct access to all WebExtension APIs that are available to the Contextlets extension. This includes the following privileged APIs: .

17 | 18 |

All scripts also have access to the following:

19 | 20 |
21 |
this.code
22 |
23 |

The JavaScript code currently being executed.

24 |
25 | 26 |
this.info
27 |
28 |

menus.OnClickData. Information about the item clicked and the context where the click happened.

29 |
30 | 31 |
this.itemSettings
32 |
33 |

A copy of the Contextlets configuration settings for this menu item.

34 |
35 | 36 |
this.params
37 |
38 |

Initially always null. If this code was invoked by this.runAs(), this.params will be whatever was provided in the params argument of the call. Be aware that the data is converted to JSON and back during transit, so only JSON-serializable values will arrive here intact.

39 |
40 | 41 |
this.tab
42 |
43 |

tabs.Tab. The details of the tab where the click took place. If the click did not take place in a tab, this parameter will be missing.

44 |
45 | 46 |
this.message
47 |
48 |

An object containing just the above values, without the functions below.

49 |
50 | 51 |
this.runAs(scope, code, params)
52 |
53 |

Asynchronously executes the given JavaScript code as either a background script or a content script. This works from any kind of script, and it can be useful if part of your code needs access to another scope's APIs. The code will have access to all the same this features listed here, but the values for this.code, this.params, and this.message will correspond to the newly-called code.

54 | 55 |

scope should be either "background" or "content". This indicates where to run the code, and thus what features it will have access to.

56 | 57 |

code may be either a string or a function. If it's a function, it will be serialized using .toSource(). This means it will not inherit any variables from the lexical scope in which it is defined. If you need to pass values to the function, use params.

58 | 59 |

params is optional and can be anything extra you want to pass to the invoked code. If provided, it must be serializable as JSON. If omitted or undefined, it will default to null.

60 |
61 |
62 | 63 |

URL Patterns

64 | 65 |

By default, menu items are not filtered by URL. If you want a menu item to only display on certain URLs, you can add one or more patterns into the "URL Patterns to Match" box, separated by line breaks. These patterns will be interpreted as match patterns.

66 | 67 |

For the "Link", "Image", "Video", and "Audio" contexts, the patterns will be matched against the relevant element's URL (for example, the image's "src" URL). For other contexts, the patterns will be matched against the page's URL. For the "Frame" context, this will be the page that is inside the frame. If any patterns are present, the URL will need to match at least one of them.

68 |
69 |
70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/docs/reference.js: -------------------------------------------------------------------------------- 1 | document.getElementById('privileged-api-list').textContent = browser.runtime.getManifest().permissions.filter(function (value) 2 | { 3 | return !/[^a-zA-Z0-9._]/.test(value); 4 | }).sort().join(', '); 5 | -------------------------------------------------------------------------------- /src/images/contextlets-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/src/images/contextlets-16.png -------------------------------------------------------------------------------- /src/images/contextlets-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmhammond/contextlets/cf9c05eff522604911314a081f7f64c34638d185/src/images/contextlets-64.png -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Contextlets", 4 | "version": "0.4.1", 5 | "description": "Add context menu items that execute custom JavaScript.", 6 | "homepage_url": "https://github.com/davidmhammond/contextlets", 7 | "permissions": 8 | [ 9 | "bookmarks", 10 | "clipboardRead", 11 | "clipboardWrite", 12 | "contextMenus", 13 | "downloads", 14 | "management", 15 | "menus", 16 | "storage", 17 | "tabs", 18 | "" 19 | ], 20 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self';", 21 | "applications": 22 | { 23 | "gecko": 24 | { 25 | "id": "{dcf34dbe-ccd1-11e7-8f66-ff8971474715}" 26 | } 27 | }, 28 | "background": 29 | { 30 | "scripts": 31 | [ 32 | "background.js" 33 | ] 34 | }, 35 | "content_scripts": 36 | [ 37 | { 38 | "matches": 39 | [ 40 | "" 41 | ], 42 | "js": 43 | [ 44 | "content.js" 45 | ] 46 | } 47 | ], 48 | "options_ui": 49 | { 50 | "page": "options/options.html", 51 | "browser_style": true, 52 | "open_in_tab": true 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/options/options.css: -------------------------------------------------------------------------------- 1 | html, body 2 | { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | body 8 | { 9 | background: #f9f9fa; 10 | padding: 16px; 11 | } 12 | 13 | body > div 14 | { 15 | margin-bottom: 16px; 16 | } 17 | 18 | a:link, 19 | a:visited 20 | { 21 | color: #0a8dff; 22 | text-decoration: none; 23 | transition: 24 | color .15s; 25 | } 26 | 27 | a:hover 28 | { 29 | color: #0060df; 30 | text-decoration: underline; 31 | } 32 | 33 | a:active 34 | { 35 | color: #003eaa; 36 | } 37 | 38 | .main-header 39 | { 40 | display: table; 41 | margin-bottom: 16px; 42 | width: 100%; 43 | } 44 | 45 | .main-header > * 46 | { 47 | display: table-cell; 48 | padding: 0 4px; 49 | } 50 | 51 | .main-header > .left:before 52 | { 53 | content: url(/images/contextlets-64.png); 54 | float: left; 55 | } 56 | 57 | .main-header > .left > * 58 | { 59 | margin: 0; 60 | margin-left: 80px; 61 | } 62 | 63 | .main-header > .left h1 64 | { 65 | line-height: 1.4; 66 | padding: 4px 0; 67 | } 68 | 69 | .main-header > .center 70 | { 71 | text-align: center; 72 | vertical-align: bottom; 73 | } 74 | 75 | .main-header > .center a 76 | { 77 | white-space: nowrap; 78 | } 79 | 80 | .main-header > .right 81 | { 82 | text-align: right; 83 | vertical-align: bottom; 84 | } 85 | 86 | .main-options 87 | { 88 | display: inline-block; 89 | text-align: left; 90 | } 91 | 92 | .item 93 | { 94 | position: relative; 95 | top: 0; 96 | transition: 97 | height .5s, 98 | opacity .5s .5s, 99 | top .5s; 100 | } 101 | 102 | .item.fade-in 103 | { 104 | height: 0; 105 | transition: none; 106 | } 107 | 108 | .item.fade-out 109 | { 110 | height: 0; 111 | overflow: hidden; 112 | pointer-events: none; 113 | transition: 114 | height .5s, 115 | top .5s; 116 | } 117 | 118 | .item.moved 119 | { 120 | transition: none; 121 | } 122 | 123 | .item:after 124 | { 125 | content: ""; 126 | display: block; 127 | height: 16px; 128 | } 129 | 130 | .item-content 131 | { 132 | background: #fff; 133 | border-radius: 4px; 134 | box-shadow: 0 1px 4px rgba(12, 12, 13, 0.1); 135 | color: #000; 136 | padding: 16px; 137 | position: relative; 138 | transition: 139 | box-shadow .15s, 140 | opacity .5s; 141 | } 142 | 143 | .item-content:hover 144 | { 145 | box-shadow: 0 1px 4px rgba(12, 12, 13, 0.1), 0 0 0 5px #d7d7db; 146 | } 147 | 148 | .item-content:before 149 | { 150 | bottom: 0; 151 | color: #999; 152 | content: "id: " attr(data-item-id); 153 | line-height: 1.25; 154 | position: absolute; 155 | padding: 6px 8px 6px 8px; 156 | right: 0; 157 | } 158 | 159 | .item.fade-in .item-content 160 | { 161 | opacity: 0; 162 | transition: none; 163 | } 164 | 165 | .item.fade-out .item-content 166 | { 167 | opacity: 0; 168 | } 169 | 170 | .item-content button 171 | { 172 | background: none; 173 | border: 0; 174 | color: #0087ff; 175 | cursor: pointer; 176 | margin: 4px 12px 8px -4px; 177 | padding: 0 4px; 178 | transition: 179 | border .25s, 180 | box-shadow .25s, 181 | color .15s; 182 | } 183 | 184 | .item-content button + button 185 | { 186 | margin-left: 12px; 187 | } 188 | 189 | .item-content button:hover 190 | { 191 | color: #0052cc; 192 | text-decoration: underline; 193 | } 194 | 195 | .cols-2 196 | { 197 | display: flex; 198 | flex-direction: row; 199 | } 200 | 201 | .cols-2 > .left, 202 | .cols-2 > .right 203 | { 204 | flex: auto; 205 | } 206 | 207 | .cols-2 > .right 208 | { 209 | margin-left: 16px; 210 | } 211 | 212 | .cols-2 > .minor 213 | { 214 | flex: 0; 215 | white-space: nowrap; 216 | } 217 | 218 | .prefs > .left > div 219 | { 220 | margin-top: 16px; 221 | } 222 | 223 | .prefs > .left > div:first-child 224 | { 225 | margin-top: 0; 226 | } 227 | 228 | .prefs .field 229 | { 230 | margin-top: 4px; 231 | width: 100%; 232 | } 233 | 234 | .prefs .file-selector 235 | { 236 | margin-top: 4px; 237 | } 238 | 239 | .file-selector input[type=file] 240 | { 241 | display: none; 242 | } 243 | 244 | .file-selector button 245 | { 246 | margin-right: 0; 247 | } 248 | 249 | .item-content .icon-preview 250 | { 251 | background: Menu; 252 | border: 1px dashed #b1b1b1; 253 | border-radius: 4px; 254 | box-sizing: content-box; 255 | display: inline-block; 256 | height: 16px; 257 | width: 16px; 258 | margin: 0 0 6px; 259 | padding: 3px; 260 | vertical-align: middle; 261 | } 262 | 263 | .item-content .icon-preview:focus 264 | { 265 | border-color: #0996f8; 266 | box-shadow: 0 0 0 2px rgba(97, 181, 255, .75); 267 | } 268 | 269 | .item-content .icon-preview::-moz-focus-inner 270 | { 271 | border: 0; 272 | } 273 | 274 | .item-content .icon-preview img 275 | { 276 | display: block; 277 | height: 16px; 278 | width: 16px; 279 | } 280 | 281 | .prefs textarea 282 | { 283 | resize: vertical; 284 | } 285 | 286 | .title-container .cols-2 287 | { 288 | align-items: end; 289 | } 290 | 291 | .title-container > .minor > span 292 | { 293 | visibility: hidden; 294 | } 295 | 296 | .separator-title-container 297 | { 298 | position: relative; 299 | text-align: center; 300 | } 301 | 302 | .separator-title-container:before 303 | { 304 | border: 1px solid #000; 305 | content: ""; 306 | left: 0; 307 | position: absolute; 308 | right: 0; 309 | top: 50%; 310 | } 311 | 312 | .separator-title-container > span 313 | { 314 | background: #fff; 315 | padding: 0 8px; 316 | position: relative; 317 | } 318 | 319 | .code-container 320 | { 321 | position: relative; 322 | } 323 | 324 | .code-container .status 325 | { 326 | display: inline-block; 327 | position: relative; 328 | vertical-align: bottom; 329 | } 330 | 331 | .code-container .status:before 332 | { 333 | bottom: 0; 334 | content: ""; 335 | font-size: 1.25em; 336 | font-weight: bold; 337 | line-height: 1em; 338 | position: absolute; 339 | } 340 | 341 | .code-container .status-valid:before 342 | { 343 | color: #12bc00; 344 | content: "\2714"; 345 | } 346 | 347 | .code-container .status-error:before 348 | { 349 | color: #ff0039; 350 | content: "\2716"; 351 | } 352 | 353 | .code-editor textarea 354 | { 355 | font-family: monospace; 356 | font-size: 1em; 357 | } 358 | 359 | .line-numbers 360 | { 361 | display: none; 362 | } 363 | 364 | .code-editor.with-line-numbers 365 | { 366 | background: #fff; 367 | font-family: monospace; 368 | font-size: 1em; 369 | position: relative; 370 | } 371 | 372 | .code-editor.with-line-numbers textarea 373 | { 374 | background: transparent; 375 | font: inherit; 376 | padding-left: calc(3em + 14px); 377 | position: relative; 378 | white-space: pre; 379 | z-index: 1; 380 | } 381 | 382 | .code-editor.with-line-numbers .line-numbers 383 | { 384 | background: #f9f9fa; 385 | bottom: 7px; 386 | box-sizing: content-box; 387 | color: #999; 388 | display: block; 389 | left: 1px; 390 | overflow: hidden; 391 | padding-right: 8px; 392 | position: absolute; 393 | top: 5px; 394 | width: 3em; 395 | } 396 | 397 | .code-editor.with-line-numbers .line-numbers > span 398 | { 399 | counter-reset: line-number; 400 | display: block; 401 | position: relative; 402 | top: 0; 403 | } 404 | 405 | .code-editor.with-line-numbers .line-numbers > span > span 406 | { 407 | display: block; 408 | } 409 | 410 | .code-editor.with-line-numbers .line-numbers > span > span:before 411 | { 412 | counter-increment: line-number; 413 | content: counter(line-number); 414 | display: block; 415 | text-align: right; 416 | width: 3em; 417 | } 418 | 419 | .item .checkables 420 | { 421 | margin-bottom: 16px; 422 | } 423 | 424 | .checkables ul 425 | { 426 | margin: 4px 0; 427 | padding: 0; 428 | } 429 | 430 | .checkables li 431 | { 432 | display: block; 433 | list-style: none; 434 | } 435 | 436 | .checkables input[type=checkbox], 437 | .checkables input[type=radio] 438 | { 439 | margin: 1px 0; 440 | vertical-align: middle; 441 | } 442 | 443 | .checkables input[type=checkbox]:-moz-focusring, 444 | .checkables input[type=radio]:-moz-focusring 445 | { 446 | outline: 0; 447 | } 448 | 449 | .checkables label:before 450 | { 451 | vertical-align: middle; 452 | } 453 | 454 | .renderer select 455 | { 456 | display: block; 457 | } 458 | 459 | .item .buttons 460 | { 461 | margin-top: 8px; 462 | } 463 | 464 | .main-action-container 465 | { 466 | text-align: center; 467 | } 468 | 469 | #import-result p 470 | { 471 | text-align: center; 472 | } 473 | 474 | #import-result .error 475 | { 476 | color: #d92015; 477 | font-weight: bold; 478 | } 479 | -------------------------------------------------------------------------------- /src/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Contextlets 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |

Contextlets

15 |

By David Hammond

16 |
17 | 18 |
19 | API Reference 20 |
21 | 22 |
23 |
24 |
25 | 26 |
27 | 28 |
29 | 30 |
31 |
32 |
33 |
34 | 35 | 139 | 140 |
141 | 142 |
143 | 144 | 145 | 146 |
147 | 148 |
149 |
150 |
151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /src/options/options.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var prefDefaults = 4 | { 5 | items: [], 6 | lineNumbers: false, 7 | validate: true, 8 | }; 9 | 10 | Promise.all( 11 | [ 12 | browser.storage.local.get(prefDefaults), 13 | browser.management.getAll(), 14 | ]).then(function (values) 15 | { 16 | var [prefs, extensionInfos] = values; 17 | 18 | /** 19 | * Use CSS transitions to display a newly-created box. 20 | */ 21 | var animateFadeIn = function (node, callback) 22 | { 23 | var height = node.offsetHeight; 24 | node.classList.add('fade-in'); 25 | 26 | window.setTimeout(function () 27 | { 28 | node.classList.remove('fade-in'); 29 | node.style.height = height+'px'; 30 | 31 | window.setTimeout(function () 32 | { 33 | node.style.height = null; 34 | 35 | if (callback !== undefined) 36 | { 37 | callback(); 38 | } 39 | }, 500); 40 | }, 0); 41 | }; 42 | 43 | /** 44 | * Use CSS transitions to hide a box prior to removal. 45 | */ 46 | var animateFadeOut = function (node, callback) 47 | { 48 | node.style.height = node.offsetHeight+'px'; 49 | node.style.pointerEvents = 'none'; 50 | 51 | window.setTimeout(function () 52 | { 53 | node.classList.add('fade-out'); 54 | node.style.height = null; 55 | 56 | window.setTimeout(function () 57 | { 58 | if (callback !== undefined) 59 | { 60 | callback(); 61 | } 62 | }, 500); 63 | }, 20); 64 | }; 65 | 66 | /** 67 | * Add a new menu item and its configuration UI. 68 | */ 69 | var createItem = function (properties) 70 | { 71 | ++lastId; 72 | var item = 73 | { 74 | checked: false, 75 | code: '', 76 | contexts: [], 77 | enabled: true, 78 | icons: null, 79 | id: lastId, 80 | patterns: '', 81 | scope: 'content', 82 | title: '', 83 | type: 'normal', 84 | }; 85 | 86 | if (properties !== undefined) 87 | { 88 | Object.assign(item, properties); 89 | } 90 | 91 | prefs.items.push(item); 92 | var itemNode = createItemNode(item); 93 | itemListNode.appendChild(itemNode); 94 | animateFadeIn(itemNode); 95 | save(); 96 | }; 97 | 98 | /** 99 | * Create the UI for configuring a single menu item. 100 | * 101 | * Returns the item node, not yet inserted into the page. 102 | */ 103 | var createItemNode = function (item) 104 | { 105 | var itemNode = templateNode.cloneNode(true); 106 | itemNode.querySelector('.item-content').dataset.itemId = item.id; 107 | itemNode.classList.add('type-'+item.type); 108 | var itemNonce = -1; 109 | 110 | // Adapt the template for the relevant item type (normal/separator/etc.) 111 | 112 | var removeSelectors = []; 113 | 114 | switch (item.type) 115 | { 116 | case 'separator': 117 | removeSelectors.push('.title-container', '.code-container', '.scope-container'); 118 | itemNode.querySelector('.delete-button').textContent = 'Delete Separator'; 119 | break; 120 | } 121 | 122 | if (item.type != 'separator') 123 | { 124 | removeSelectors.push('.separator-title-container'); 125 | } 126 | 127 | if (removeSelectors.length > 0) 128 | { 129 | itemNode.querySelectorAll(removeSelectors.join(', ')).forEach(function (node) 130 | { 131 | node.parentNode.removeChild(node); 132 | }); 133 | } 134 | 135 | // Add save-on-edit events for basic fields. 136 | 137 | itemNode.querySelectorAll('.field').forEach(function (input) 138 | { 139 | input.value = item[input.name]; 140 | 141 | input.addEventListener('change', function () 142 | { 143 | item[input.name] = input.value; 144 | save(); 145 | }); 146 | }); 147 | 148 | // File upload fields. Currently only used for the icon. 149 | 150 | itemNode.querySelectorAll('.file-selector').forEach(function (div) 151 | { 152 | var input = div.querySelector('input[type=file]'); 153 | var iconPreview = input.name == 'icons' ? itemNode.querySelector('.icon-preview') : undefined; 154 | 155 | var setFile = function (data) 156 | { 157 | item[input.name] = data; 158 | updatePreview(); 159 | save(); 160 | }; 161 | 162 | var updatePreview = function () 163 | { 164 | if (iconPreview !== undefined) 165 | { 166 | iconPreview.innerHTML = ''; 167 | 168 | var icon; 169 | 170 | if (typeof item.icons == 'object') 171 | { 172 | if (item.icons !== null) 173 | { 174 | var bestSize; 175 | 176 | Object.getOwnPropertyNames(item.icons).forEach(function (property) 177 | { 178 | if (/^[1-9]\d*$/.test(property)) 179 | { 180 | var size = +property; 181 | 182 | if (icon !== undefined) 183 | { 184 | if (size < 16 || bestSize < 16) 185 | { 186 | if (size <= bestSize) 187 | { 188 | return; 189 | } 190 | } 191 | else if (size >= bestSize) 192 | { 193 | return; 194 | } 195 | } 196 | 197 | icon = item.icons[property]; 198 | bestSize = size; 199 | } 200 | }); 201 | } 202 | } 203 | else 204 | { 205 | icon = item.icons; 206 | } 207 | 208 | if (icon === undefined) 209 | { 210 | return; 211 | } 212 | 213 | var img = document.createElement('img'); 214 | img.alt = 'Icon'; 215 | img.src = icon; 216 | iconPreview.appendChild(img); 217 | } 218 | }; 219 | 220 | updatePreview(); 221 | 222 | input.addEventListener('change', function () 223 | { 224 | var file = input.files[0]; 225 | 226 | if (file === undefined) 227 | { 228 | setFile(null); 229 | return; 230 | } 231 | 232 | var reader = new FileReader(); 233 | 234 | reader.addEventListener('load', function () 235 | { 236 | setFile(reader.result); 237 | }); 238 | 239 | reader.readAsDataURL(file); 240 | }); 241 | 242 | if (iconPreview !== undefined) 243 | { 244 | iconPreview.addEventListener('click', function () 245 | { 246 | input.click(); 247 | }); 248 | } 249 | 250 | div.querySelectorAll('.file-browse-button').forEach(function (button) 251 | { 252 | button.addEventListener('click', function () 253 | { 254 | input.click(); 255 | }); 256 | }); 257 | 258 | div.querySelectorAll('.file-remove-button').forEach(function (button) 259 | { 260 | button.addEventListener('click', function () 261 | { 262 | setFile(null); 263 | }); 264 | }); 265 | }); 266 | 267 | // Apply syntax checking and line number logic/events to the code field. 268 | 269 | itemNode.querySelectorAll('.code-container').forEach(function (container) 270 | { 271 | var timer = null; 272 | var statusNode = container.querySelector('.status'); 273 | var input = container.querySelector('textarea.code'); 274 | var lineNumbersNode = container.querySelector('.line-numbers > span'); 275 | var numDisplayedLines = 0; 276 | var lastError = null; 277 | 278 | var validateSyntax = function () 279 | { 280 | if (timer !== null) 281 | { 282 | window.clearTimeout(timer); 283 | timer = null; 284 | } 285 | 286 | if (statusNode.classList.contains('disabled')) 287 | { 288 | lastError = null; 289 | return; 290 | } 291 | 292 | timer = window.setTimeout(function () 293 | { 294 | timer = null; 295 | 296 | // The user might have disabled syntax checking since 297 | // the timer was created. Double-check the setting. 298 | 299 | if (statusNode.classList.contains('disabled')) 300 | { 301 | lastError = null; 302 | return; 303 | } 304 | 305 | // Syntax checking is enabled. Check the JavaScript syntax of the user's input. 306 | 307 | var error = false; 308 | 309 | try 310 | { 311 | // Create an anonymous function using the input as the body. 312 | // This allows us to check that the syntax is valid without 313 | // actually running the code. 314 | 315 | new Function(input.value); 316 | } 317 | catch (e) 318 | { 319 | if (e instanceof Error) 320 | { 321 | error = e.constructor.name+': '+e.message; 322 | 323 | if (e.lineNumber !== undefined && e.columnNumber !== undefined) 324 | { 325 | error += ' ('+(e.lineNumber - 2)+':'+(e.columnNumber + 1)+')'; 326 | } 327 | } 328 | else 329 | { 330 | error = 'Unexpected value thrown during validation'; 331 | } 332 | } 333 | 334 | if (error !== lastError) 335 | { 336 | lastError = error; 337 | statusNode.classList.toggle('status-error', error !== false); 338 | statusNode.classList.toggle('status-valid', error === false); 339 | statusNode.title = error === false ? 'Syntax OK' : error; 340 | } 341 | }, 100); 342 | }; 343 | 344 | var updateLineNumbers = function () 345 | { 346 | var newlineMatches = input.value.match(/\r?\n/g); 347 | var numLines = newlineMatches === null ? 1 : newlineMatches.length + 1; 348 | 349 | while (numLines > numDisplayedLines) 350 | { 351 | lineNumbersNode.appendChild(document.createElement('span')); 352 | ++numDisplayedLines; 353 | } 354 | 355 | while (numLines < numDisplayedLines) 356 | { 357 | lineNumbersNode.removeChild(lineNumbersNode.lastChild); 358 | --numDisplayedLines; 359 | } 360 | }; 361 | 362 | if (!prefs.validate) 363 | { 364 | // Syntax checking is disabled. 365 | 366 | statusNode.classList.add('disabled'); 367 | } 368 | 369 | if (prefs.lineNumbers) 370 | { 371 | container.querySelector('.code-editor').classList.add('with-line-numbers'); 372 | } 373 | 374 | input.addEventListener('input', validateSyntax); 375 | input.addEventListener('focus', validateSyntax); 376 | input.addEventListener('input', updateLineNumbers); 377 | 378 | input.addEventListener('scroll', function () 379 | { 380 | lineNumbersNode.style.top = -input.scrollTop+'px'; 381 | }); 382 | 383 | updateLineNumbers(); 384 | }); 385 | 386 | // Set up labels and focus style helpers to checkboxes and radios. 387 | 388 | itemNode.querySelectorAll('.checkables input[type=checkbox], .checkables input[type=radio]').forEach(function (input) 389 | { 390 | ++itemNonce; 391 | input.id = 'field-'+item.id+'-'+itemNonce; 392 | var parentNode = input.parentNode; 393 | 394 | input.addEventListener('focus', function () 395 | { 396 | parentNode.classList.add('focused'); 397 | }); 398 | 399 | input.addEventListener('blur', function () 400 | { 401 | parentNode.classList.remove('focused'); 402 | }); 403 | 404 | parentNode.querySelectorAll('label').forEach(function (label) 405 | { 406 | label.htmlFor = input.id; 407 | }); 408 | }); 409 | 410 | // Set up default selections and save events for context checkboxes. 411 | 412 | itemNode.querySelectorAll('.context').forEach(function (input) 413 | { 414 | input.checked = (item.contexts.indexOf(input.value) != -1); 415 | 416 | input.addEventListener('click', function () 417 | { 418 | var index = item.contexts.indexOf(input.value); 419 | 420 | if (input.checked) 421 | { 422 | if (index == -1) 423 | { 424 | item.contexts.push(input.value); 425 | } 426 | } 427 | else 428 | { 429 | if (index != -1) 430 | { 431 | item.contexts.splice(index, 1); 432 | } 433 | } 434 | 435 | save(); 436 | }); 437 | }); 438 | 439 | itemNode.querySelectorAll('.renderer').forEach(function (div) 440 | { 441 | if (item.extensionId == null) 442 | { 443 | item.extensionId = browser.runtime.id; 444 | } 445 | 446 | if (extraExtensions.length == 0 && item.extensionId === browser.runtime.id) 447 | { 448 | div.parentNode.removeChild(div); 449 | return; 450 | } 451 | 452 | div.querySelectorAll('select').forEach(function (select) 453 | { 454 | var selectionExists = false; 455 | 456 | select.addEventListener('focus', function () 457 | { 458 | select.classList.add('focused'); 459 | }); 460 | 461 | select.addEventListener('blur', function () 462 | { 463 | select.classList.remove('focused'); 464 | }); 465 | 466 | select.addEventListener('change', function () 467 | { 468 | item.extensionId = select.value; 469 | item.extensionName = select.selectedOptions[0].textContent; 470 | save(); 471 | }); 472 | 473 | select.querySelectorAll('option').forEach(function (option) 474 | { 475 | option.value = browser.runtime.id; 476 | 477 | if (item.extensionId === browser.runtime.id) 478 | { 479 | option.selected = true; 480 | selectionExists = true; 481 | } 482 | }); 483 | 484 | extraExtensions.forEach(function (extensionInfo) 485 | { 486 | var option = document.createElement('option'); 487 | option.value = extensionInfo.id; 488 | option.textContent = extensionInfo.name; 489 | 490 | if (item.extensionId === extensionInfo.id) 491 | { 492 | option.selected = true; 493 | selectionExists = true; 494 | } 495 | 496 | select.appendChild(option); 497 | }); 498 | 499 | if (!selectionExists) 500 | { 501 | var option = document.createElement('option'); 502 | option.value = item.extensionId; 503 | option.textContent = item.extensionName+' (Inactive)'; 504 | option.selected = true; 505 | select.appendChild(option); 506 | } 507 | }); 508 | }); 509 | 510 | // Set up default selections and save events for scope radios. 511 | 512 | itemNode.querySelectorAll('.scope').forEach(function (input) 513 | { 514 | input.checked = (item.scope == input.value || (item.scope === undefined && input.value == 'background')); 515 | 516 | input.addEventListener('click', function () 517 | { 518 | item.scope = input.value; 519 | save(); 520 | }); 521 | }); 522 | 523 | // Set up the Delete button. 524 | 525 | itemNode.querySelectorAll('.delete-button').forEach(function (button) 526 | { 527 | button.addEventListener('click', function () 528 | { 529 | var hasData = (item.title != '' || item.code != '' || item.patterns != '' || item.contexts.length > 0); 530 | var description; 531 | 532 | switch (item.type) 533 | { 534 | case 'separator': 535 | description = 'separator'; 536 | break; 537 | 538 | default: 539 | description = 'item "'+item.title+'"'; 540 | } 541 | 542 | if (!hasData || window.confirm('This will permanently delete the '+description+'.')) 543 | { 544 | var index = prefs.items.indexOf(item); 545 | 546 | if (index != -1) 547 | { 548 | prefs.items.splice(index, 1); 549 | } 550 | 551 | save(); 552 | itemNode.classList.add('to-delete'); 553 | animateFadeOut(itemNode, function () 554 | { 555 | itemNode.parentNode.removeChild(itemNode); 556 | }); 557 | } 558 | }); 559 | }); 560 | 561 | // Set up the Export button. 562 | 563 | itemNode.querySelectorAll('.export-button').forEach(function (button) 564 | { 565 | button.addEventListener('click', function () 566 | { 567 | var exportItem = 568 | { 569 | code: item.code, 570 | contexts: item.contexts, 571 | icons: item.icons, 572 | patterns: item.patterns, 573 | scope: item.scope, 574 | title: item.title, 575 | type: item.type, 576 | }; 577 | 578 | // Create a reasonable filename with no special characters. 579 | 580 | var letters = 'abcdefghijklmnopqrstuvwxyz'; 581 | var collator = new Intl.Collator('en-US', {caseFirst: 'lower', sensitivity: 'case'}); 582 | var filename = item.title 583 | .replace(/&?%s|&[\S\s]/g, function (match) 584 | { 585 | if (match.substring(match.length - 2) == '%s') 586 | { 587 | return '-selection-'; 588 | } 589 | 590 | return match.substring(1); 591 | }) 592 | .replace(/['\u2019]/g, '') 593 | .toLowerCase() 594 | .replace(/[^a-z0-9]/g, function (match) 595 | { 596 | // Transliterate to ASCII the best we can in vanilla JS. 597 | 598 | if (collator.compare(match, 'a') >= 0 && collator.compare(match, 'Z') < 0) 599 | { 600 | for (var i = letters.length - 1; i >= 0; --i) 601 | { 602 | if (collator.compare(match, letters.charAt(i)) >= 0) 603 | { 604 | return letters.charAt(i); 605 | } 606 | } 607 | } 608 | 609 | return '-'; 610 | }) 611 | .replace(/-+/g, '-') 612 | .replace(/^-+|-+$/g, ''); 613 | 614 | if (filename == '') 615 | { 616 | filename = item.id; 617 | } 618 | 619 | filename += '.contextlet.json'; 620 | 621 | var link = document.createElement('a'); 622 | link.href = 'data:application/json,'+encodeURIComponent(JSON.stringify(exportItem)); 623 | link.download = filename; 624 | link.hidden = true; 625 | link.style.display = 'none'; 626 | document.body.appendChild(link); 627 | link.click(); 628 | document.body.removeChild(link); 629 | }); 630 | }); 631 | 632 | // Set up the Move Up button. 633 | 634 | itemNode.querySelectorAll('.move-up-button').forEach(function (button) 635 | { 636 | button.addEventListener('click', function () 637 | { 638 | var index = prefs.items.indexOf(item); 639 | 640 | if (index > 0) 641 | { 642 | var prevItemNode = itemNode.previousSibling; 643 | 644 | while (prevItemNode.classList.contains('to-delete')) 645 | { 646 | prevItemNode = prevItemNode.previousSibling; 647 | } 648 | 649 | var oldTop = itemNode.offsetTop; 650 | var prevOldTop = prevItemNode.offsetTop; 651 | 652 | itemNode.parentNode.removeChild(itemNode); 653 | prevItemNode.parentNode.insertBefore(itemNode, prevItemNode); 654 | 655 | itemNode.classList.add('moved') 656 | itemNode.style.top = '0'; 657 | var newTop = itemNode.offsetTop; 658 | itemNode.style.top = (oldTop - newTop)+'px'; 659 | 660 | prevItemNode.classList.add('moved') 661 | prevItemNode.style.top = '0'; 662 | var prevNewTop = prevItemNode.offsetTop; 663 | prevItemNode.style.top = (prevOldTop - prevNewTop)+'px'; 664 | 665 | prefs.items.splice(index, 1); 666 | prefs.items.splice(index - 1, 0, item); 667 | save(); 668 | 669 | window.setTimeout(function () 670 | { 671 | itemNode.classList.remove('moved'); 672 | itemNode.style.top = null; 673 | prevItemNode.classList.remove('moved'); 674 | prevItemNode.style.top = null; 675 | }, 0); 676 | } 677 | }); 678 | }); 679 | 680 | // Set up the Move Down button. 681 | 682 | itemNode.querySelectorAll('.move-down-button').forEach(function (button) 683 | { 684 | button.addEventListener('click', function () 685 | { 686 | var index = prefs.items.indexOf(item); 687 | 688 | if (index != -1 && index < prefs.items.length - 1) 689 | { 690 | var nextItemNode = itemNode.nextSibling; 691 | 692 | while (nextItemNode.classList.contains('to-delete')) 693 | { 694 | nextItemNode = nextItemNode.nextSibling; 695 | } 696 | 697 | var oldTop = itemNode.offsetTop; 698 | var nextOldTop = nextItemNode.offsetTop; 699 | 700 | itemNode.parentNode.removeChild(nextItemNode); 701 | itemNode.parentNode.insertBefore(nextItemNode, itemNode); 702 | 703 | itemNode.classList.add('moved') 704 | itemNode.style.top = '0'; 705 | var newTop = itemNode.offsetTop; 706 | itemNode.style.top = (oldTop - newTop)+'px'; 707 | 708 | nextItemNode.classList.add('moved') 709 | nextItemNode.style.top = '0'; 710 | var nextNewTop = nextItemNode.offsetTop; 711 | nextItemNode.style.top = (nextOldTop - nextNewTop)+'px'; 712 | 713 | prefs.items.splice(index, 1); 714 | prefs.items.splice(index + 1, 0, item); 715 | save(); 716 | 717 | window.setTimeout(function () 718 | { 719 | itemNode.classList.remove('moved'); 720 | itemNode.style.top = null; 721 | nextItemNode.classList.remove('moved'); 722 | nextItemNode.style.top = null; 723 | }, 0); 724 | } 725 | }); 726 | }); 727 | 728 | return itemNode; 729 | }; 730 | 731 | /** 732 | * Output a line to the import results. 733 | */ 734 | var addImportResult = function (message, isError) 735 | { 736 | var p = document.createElement('p'); 737 | 738 | if (isError === true) 739 | { 740 | p.className = 'error'; 741 | } 742 | 743 | p.textContent = message; 744 | importResultNode.appendChild(p); 745 | }; 746 | 747 | /** 748 | * Asychronously save all changes to the prefs. 749 | */ 750 | var save = function () 751 | { 752 | browser.storage.local.set(prefs); 753 | }; 754 | 755 | var templateNode = document.querySelector('#item-template .item'); 756 | var importResultNode = document.querySelector('#import-result'); 757 | var itemListNode = document.querySelector('#items'); 758 | var lastId = -1; 759 | var extraExtensions = []; 760 | var supportedContextSet = new Set(); 761 | var supportedScopeSet = new Set(); 762 | 763 | extensionInfos.forEach(function (extensionInfo) 764 | { 765 | if (/^extra contextlet/i.test(extensionInfo.name)) 766 | { 767 | extraExtensions.push(extensionInfo); 768 | } 769 | }); 770 | 771 | extraExtensions.sort(function (a, b) 772 | { 773 | return a.name.replace(/\D+/g) - b.name.replace(/\D+/g); 774 | }); 775 | 776 | // Gather the set of supported contexts. 777 | 778 | templateNode.querySelectorAll('.context-container input.context').forEach(function (input) 779 | { 780 | supportedContextSet.add(input.value); 781 | }); 782 | 783 | // Gather the set of supported scopes. 784 | 785 | templateNode.querySelectorAll('.scope-container input.scope').forEach(function (input) 786 | { 787 | supportedScopeSet.add(input.value); 788 | }); 789 | 790 | // Set up the UI for the global options. 791 | 792 | document.querySelectorAll('.main-options input[type=checkbox]').forEach(function (input) 793 | { 794 | input.checked = prefs[input.name]; 795 | var parentNode = input.parentNode; 796 | 797 | input.addEventListener('focus', function () 798 | { 799 | parentNode.classList.add('focused'); 800 | }); 801 | 802 | input.addEventListener('blur', function () 803 | { 804 | parentNode.classList.remove('focused'); 805 | }); 806 | 807 | input.addEventListener('click', function () 808 | { 809 | prefs[input.name] = input.checked; 810 | 811 | switch (input.name) 812 | { 813 | case 'lineNumbers': 814 | document.querySelectorAll('.code-editor').forEach(function (node) 815 | { 816 | node.classList.toggle('with-line-numbers', input.checked); 817 | }); 818 | 819 | break; 820 | 821 | case 'validate': 822 | document.querySelectorAll('.code-container .status').forEach(function (statusNode) 823 | { 824 | if (input.checked) 825 | { 826 | // Enable syntax checking. 827 | 828 | statusNode.classList.remove('disabled'); 829 | } 830 | else 831 | { 832 | // Disable syntax checking. 833 | 834 | statusNode.classList.add('disabled'); 835 | statusNode.classList.remove('status-error'); 836 | statusNode.classList.remove('status-valid'); 837 | statusNode.title = null; 838 | } 839 | }); 840 | 841 | break; 842 | } 843 | 844 | save(); 845 | }); 846 | }); 847 | 848 | // Create the configuration UI for the existing menu items. 849 | 850 | prefs.items.forEach(function (item, index) 851 | { 852 | // Make sure we're always generating ids with higher 853 | // numbers than any existing format-clashing ids. 854 | 855 | if (/^[1-9]\d*|0$/.test(item.id) && item.id > lastId) 856 | { 857 | lastId = +item.id; 858 | } 859 | 860 | var itemNode = createItemNode(item); 861 | itemListNode.appendChild(itemNode); 862 | }); 863 | 864 | document.querySelectorAll('.add-item-button').forEach(function (button) 865 | { 866 | button.addEventListener('click', function () 867 | { 868 | createItem({type: 'normal'}); 869 | }); 870 | }); 871 | 872 | document.querySelectorAll('.add-separator-button').forEach(function (button) 873 | { 874 | button.addEventListener('click', function () 875 | { 876 | createItem({type: 'separator'}); 877 | }); 878 | }); 879 | 880 | document.querySelectorAll('.import-button').forEach(function (button) 881 | { 882 | button.addEventListener('click', function () 883 | { 884 | importResultNode.textContent = ''; 885 | var numErrors = 0; 886 | var numSuccess = 0; 887 | 888 | var addError = function (message) 889 | { 890 | ++numErrors; 891 | addImportResult(message, true); 892 | }; 893 | 894 | var input = document.createElement('input'); 895 | input.type = 'file'; 896 | input.hidden = true; 897 | input.multiple = true; 898 | input.style.display = 'none'; 899 | 900 | input.addEventListener('change', function () 901 | { 902 | if (input.files.length == 0) 903 | { 904 | return; 905 | } 906 | 907 | var fileIndex = -1; 908 | 909 | var processNextFile = function () 910 | { 911 | ++fileIndex; 912 | 913 | if (fileIndex >= input.files.length) 914 | { 915 | // Import is complete. 916 | 917 | var result = []; 918 | 919 | if (numSuccess > 0) 920 | { 921 | result.push(numSuccess+' item'+(numSuccess == 1 ? '' : 's')+' successfully imported.'); 922 | } 923 | 924 | if (numErrors > 0) 925 | { 926 | result.push(numErrors+' error'+(numErrors == 1 ? '' : 's')+' occurred during import.'); 927 | } 928 | 929 | if (result.length == 0) 930 | { 931 | result.push('No items found to import.'); 932 | } 933 | 934 | addImportResult(result.join(' ')); 935 | return; 936 | } 937 | 938 | var file = input.files[fileIndex]; 939 | var reader = new FileReader(); 940 | 941 | reader.addEventListener('load', function () 942 | { 943 | var isArray; 944 | var itemIndex; 945 | 946 | try 947 | { 948 | var items = JSON.parse(reader.result); 949 | var importItems = []; 950 | isArray = (items instanceof Array); 951 | 952 | if (!isArray) 953 | { 954 | items = [items]; 955 | } 956 | 957 | items.forEach(function (item, index) 958 | { 959 | if (isArray) 960 | { 961 | itemIndex = index; 962 | } 963 | 964 | // Validate the import item. 965 | 966 | var requiredProperties = 967 | [ 968 | 'code', 969 | 'contexts', 970 | 'scope', 971 | 'title', 972 | 'type', 973 | ]; 974 | 975 | requiredProperties.forEach(function (property) 976 | { 977 | if (!Object.prototype.hasOwnProperty.call(item, property)) 978 | { 979 | throw new Error('Missing required property "'+property+'"'); 980 | } 981 | }); 982 | 983 | if (typeof item.code != 'string') 984 | { 985 | throw new Error('"code" must be a string; '+(typeof item.code)+' given'); 986 | } 987 | 988 | if (!(item.contexts instanceof Array)) 989 | { 990 | throw new Error('"contexts" must be an Array; '+(typeof item.contexts)+' given'); 991 | } 992 | 993 | item.contexts.forEach(function (context) 994 | { 995 | if (typeof context != 'string') 996 | { 997 | throw new Error('Context values must be strings; '+(typeof context)+' given'); 998 | } 999 | 1000 | if (!supportedContextSet.has(context)) 1001 | { 1002 | throw new Error('Context value "'+context+'" is not supported'); 1003 | } 1004 | }); 1005 | 1006 | if (typeof item.scope != 'string') 1007 | { 1008 | throw new Error('"scope" must be a string; '+(typeof item.scope)+' given'); 1009 | } 1010 | 1011 | if (!supportedScopeSet.has(item.scope)) 1012 | { 1013 | throw new Error('Scope value "'+item.scope+'" is not supported'); 1014 | } 1015 | 1016 | if (typeof item.title != 'string') 1017 | { 1018 | throw new Error('"title" must be a string; '+(typeof item.title)+' given'); 1019 | } 1020 | 1021 | if (item.type !== 'normal' && item.type !== 'separator') 1022 | { 1023 | throw new Error('"type" must be either "normal" or "separator"'); 1024 | } 1025 | 1026 | var importItem = 1027 | { 1028 | code: item.code, 1029 | contexts: item.contexts, 1030 | scope: item.scope, 1031 | title: item.title, 1032 | type: item.type, 1033 | }; 1034 | 1035 | if (Object.prototype.hasOwnProperty.call(item, 'icons')) 1036 | { 1037 | if (typeof item.icons == 'object') 1038 | { 1039 | if (item.icons !== null) 1040 | { 1041 | Object.getOwnPropertyNames(item.icons).forEach(function (property) 1042 | { 1043 | if (!/^[1-9]\d*$/.test(property)) 1044 | { 1045 | throw new Error('Invalid icon size "'+property+'"'); 1046 | } 1047 | 1048 | if (typeof item.icons[property] != 'string') 1049 | { 1050 | throw new Error('Icon value for size "'+property+'" must be a string; '+(typeof item.icons[property])+' given'); 1051 | } 1052 | 1053 | if (item.icons[property].substring(0, 5) != 'data:') 1054 | { 1055 | throw new Error('Icon value for size "'+property+'" must be a data URI'); 1056 | } 1057 | }); 1058 | } 1059 | } 1060 | else if (typeof item.icons == 'string') 1061 | { 1062 | if (item.icons.substring(0, 5) != 'data:') 1063 | { 1064 | throw new Error('Icon string must be a data URI'); 1065 | } 1066 | } 1067 | else 1068 | { 1069 | throw new Error('When present, "icons" must be a string, object, or null; '+(typeof item.icons)+' given'); 1070 | } 1071 | 1072 | importItem.icons = item.icons; 1073 | } 1074 | 1075 | if (Object.prototype.hasOwnProperty.call(item, 'patterns')) 1076 | { 1077 | if (typeof item.patterns != 'string') 1078 | { 1079 | throw new Error('When present, "patterns" must be a string; '+(typeof item.patterns)+' given'); 1080 | } 1081 | 1082 | importItem.patterns = item.patterns; 1083 | } 1084 | 1085 | importItems.push(importItem); 1086 | }); 1087 | 1088 | importItems.forEach(function (importItem) 1089 | { 1090 | createItem(importItem); 1091 | ++numSuccess; 1092 | }); 1093 | } 1094 | catch (error) 1095 | { 1096 | if (error instanceof SyntaxError) 1097 | { 1098 | addError('File "'+file.name+'" is not a valid JSON file.'); 1099 | } 1100 | else 1101 | { 1102 | var context = error.constructor.name; 1103 | 1104 | if (itemIndex !== undefined) 1105 | { 1106 | context = ' in item index '+itemIndex; 1107 | } 1108 | 1109 | addError('File "'+file.name+'" is not a valid contextlet file ('+context+': '+error.message+').'); 1110 | } 1111 | } 1112 | 1113 | window.setTimeout(processNextFile, 0); 1114 | }); 1115 | 1116 | reader.readAsText(file); 1117 | } 1118 | 1119 | processNextFile(); 1120 | }); 1121 | 1122 | document.body.appendChild(input); 1123 | input.click(); 1124 | document.body.removeChild(input); 1125 | }); 1126 | }); 1127 | }); 1128 | --------------------------------------------------------------------------------