├── .npmrc ├── .rgignore ├── .gitignore ├── icon-128x128.png ├── icon-16x16.png ├── icon-32x32.png ├── icon-48x48.png ├── icon-64x64.png ├── .editorconfig ├── .github ├── workflows │ └── pre-commit.yml └── ISSUE_TEMPLATE.md ├── bin └── translation-check ├── renovate.json ├── LICENSE ├── .pre-commit-config.yaml ├── manifest.json ├── README.md ├── _locales ├── en │ └── messages.json └── zh │ └── messages.json ├── options ├── options.css ├── options.html └── options.js ├── eslint.config.js ├── background.js ├── package.json ├── icon.svg ├── content.js └── browser-polyfill.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.rgignore: -------------------------------------------------------------------------------- 1 | browser-polyfill.js 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.zip 2 | /.claude/ 3 | /node_modules/ 4 | -------------------------------------------------------------------------------- /icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sblask-webextensions/webextension-enhanced-image-viewer/HEAD/icon-128x128.png -------------------------------------------------------------------------------- /icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sblask-webextensions/webextension-enhanced-image-viewer/HEAD/icon-16x16.png -------------------------------------------------------------------------------- /icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sblask-webextensions/webextension-enhanced-image-viewer/HEAD/icon-32x32.png -------------------------------------------------------------------------------- /icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sblask-webextensions/webextension-enhanced-image-viewer/HEAD/icon-48x48.png -------------------------------------------------------------------------------- /icon-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sblask-webextensions/webextension-enhanced-image-viewer/HEAD/icon-64x64.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [{*.js,*.json}] 10 | indent_size = 4 11 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | pre-commit: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: 13 | - ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | - uses: actions/setup-python@v6 17 | - uses: pre-commit/action@v3.0.1 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### New Issue Checklist (tick off like this: `[x]`) 2 | 3 | - [ ] I checked existing issues (open and closed) for possible duplicates 4 | - [ ] I can reproduce the problem on the latest stable version (not nightly!) 5 | 6 | ### What is your browser? 7 | 8 | - [ ] Firefox 9 | - [ ] Chrome 10 | 11 | ### What is your operating system? 12 | 13 | - [ ] Linux 14 | - [ ] Mac 15 | - [ ] Windows 16 | 17 | ### Description (please include examples/screenshots where applicable) 18 | 19 | 20 | -------------------------------------------------------------------------------- /bin/translation-check: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit -o nounset -o pipefail -o xtrace 4 | 5 | SCRIPT_DIRECTORY=$( cd "$( dirname "${BASH_SOURCE:-$0}" )" && pwd ) 6 | ROOT_DIRECTORY=$SCRIPT_DIRECTORY/.. 7 | 8 | REGEXP='browser.i18n.getMessage\("\K([^"]+)|"__MSG_\K([^_]+)|data-i18n="\K([^"]+)' 9 | FILES=$(find $ROOT_DIRECTORY -not -path '*node_modules*' -name '*.js*' -o -not -path '*node_modules*' -name '*.html') 10 | 11 | for language_directory in "$ROOT_DIRECTORY"/_locales/* 12 | do 13 | echo "" 14 | echo "$language_directory" 15 | diff \ 16 | --unified \ 17 | <(grep --perl-regexp --only-matching --no-filename $REGEXP $FILES 2>/dev/null | sort --unique) \ 18 | <(jq --raw-output 'keys[]' "$language_directory/messages.json") \ 19 | 20 | done 21 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "automerge": true, 9 | "automergeType": "branch", 10 | "groupName": "actions", 11 | "matchPackageNames": [ 12 | "actions/checkout", 13 | "actions/setup-python" 14 | ] 15 | }, 16 | { 17 | "automerge": true, 18 | "automergeType": "branch", 19 | "extends": [ 20 | "schedule:monthly" 21 | ], 22 | "groupName": "eslint", 23 | "matchPackageNames": [ 24 | "@eslint/js", 25 | "@stylistic/eslint-plugin", 26 | "eslint" 27 | ] 28 | } 29 | ], 30 | "pre-commit": { 31 | "enabled": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Sebastian Blask 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 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | 3 | - repo: local 4 | hooks: 5 | 6 | - id: eslint 7 | name: eslint 8 | entry: eslint 9 | language: node 10 | types_or: 11 | - javascript 12 | additional_dependencies: 13 | - "@eslint/js@9.39.1" 14 | - "@stylistic/eslint-plugin@5.6.1" 15 | - eslint@9.39.1 16 | - globals@16.3.0 17 | 18 | - id: translation-check 19 | name: translation-check 20 | entry: ./bin/translation-check 21 | language: system 22 | always_run: true 23 | 24 | - id: web-ext lint 25 | name: web-ext lint 26 | entry: web-ext lint 27 | language: node 28 | types_or: 29 | - javascript 30 | pass_filenames: false 31 | additional_dependencies: 32 | - web-ext@8.9.0 33 | 34 | - repo: https://github.com/rhysd/actionlint 35 | rev: v1.7.7 36 | hooks: 37 | - id: actionlint 38 | 39 | - repo: https://github.com/editorconfig-checker/editorconfig-checker.python 40 | rev: 3.4.0 41 | hooks: 42 | - id: editorconfig-checker 43 | exclude: (browser-polyfill.js) 44 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "applications": { 3 | "gecko": { 4 | "id": "enhancedimageviewer@sblask", 5 | "strict_min_version": "57.0" 6 | } 7 | }, 8 | "author": "Sebastian Blask", 9 | "background": { 10 | "scripts": [ 11 | "browser-polyfill.js", 12 | "background.js" 13 | ] 14 | }, 15 | "browser_action": { 16 | "browser_style": false, 17 | "default_icon": "icon.svg", 18 | "default_popup": "options/options.html", 19 | "default_title": "__MSG_browserActionLabelDefault__" 20 | }, 21 | "description": "__MSG_extensionDescription__", 22 | "homepage_url": "https://github.com/sblask/webextension-enhanced-image-viewer", 23 | "icons": { 24 | "16": "icon-16x16.png", 25 | "32": "icon-32x32.png", 26 | "48": "icon-48x48.png", 27 | "64": "icon-64x64.png", 28 | "128": "icon-128x128.png" 29 | }, 30 | "manifest_version": 2, 31 | "name": "__MSG_extensionName__", 32 | "options_ui": { 33 | "page": "options/options.html" 34 | }, 35 | "permissions": [ 36 | "", 37 | "storage", 38 | "webNavigation", 39 | "webRequest" 40 | ], 41 | "default_locale": "en", 42 | "version": "1.1.1" 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pre-commit Status](https://github.com/sblask/webextension-enhanced-image-viewer/actions/workflows/pre-commit.yml/badge.svg)](https://github.com/sblask/webextension-enhanced-image-viewer/actions/workflows/pre-commit.yml) 2 | 3 | Enhanced Image Viewer 4 | ===================== 5 | 6 | Enhances the browser's default image viewer with: 7 | 8 | - more scaling modes (configurable in the preferences - click toolbar button) 9 | to cycle through when left-clicking 10 | - easy rotation (in 90° steps) using `l` and `r` for left and right rotation 11 | respectively 12 | - scaling mode and rotation can be remembered so they are applied when opening 13 | other images 14 | - configurable background color 15 | - image information and information about scaling mode and rotation are 16 | briefly shown when opening an image and can be toggled by pressing `i` 17 | 18 | Default browser zoom (ctrl-scroll/ctrl-plus/ctrl-minus) can still be used, but 19 | probably only works as expected in natural size scaling mode. If the chosen 20 | scaling mode is fitting the image to width or height, zooming does not change 21 | the image size. 22 | 23 | Privacy Policy 24 | -------------- 25 | 26 | This extension does not collect or send data of any kind to third parties. 27 | 28 | Feedback 29 | -------- 30 | 31 | You can report bugs or make feature requests on 32 | [Github](https://github.com/sblask/webextension-enhanced-image-viewer) 33 | 34 | Patches are welcome. 35 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "backgroundColor": { 3 | "message": "Background Color" 4 | }, 5 | "browserActionLabelDefault": { 6 | "message": "Enhanced Image Viewer" 7 | }, 8 | "extensionDescription": { 9 | "message": "Enhances the browser's default image viewer" 10 | }, 11 | "extensionName": { 12 | "message": "Enhanced Image Viewer" 13 | }, 14 | "fit": { 15 | "message": "Fit image to browser window (respecting the aspect ratio)" 16 | }, 17 | "fitModesSectionTitle": { 18 | "message": "Left-clicking cycles forward through the modes selected here" 19 | }, 20 | "fitToHeight": { 21 | "message": "Fit image to window height" 22 | }, 23 | "fitToHeightUnlessSmaller": { 24 | "message": "Fit image to window height unless it's smaller" 25 | }, 26 | "fitToWidth": { 27 | "message": "Fit image to window width" 28 | }, 29 | "fitToWidthUnlessSmaller": { 30 | "message": "Fit image to window width unless it's smaller" 31 | }, 32 | "fitUnlessSmaller": { 33 | "message": "Fit image to browser window unless it's smaller (respecting the aspect ratio)" 34 | }, 35 | "noFit": { 36 | "message": "Natural size" 37 | }, 38 | "optionsTitle": { 39 | "message": "Options" 40 | }, 41 | "rememberLastRotation": { 42 | "message": " Remember last rotation" 43 | }, 44 | "rememberLastSizeState": { 45 | "message": "Remember last mode" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /_locales/zh/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "backgroundColor": { 3 | "message": "背景颜色", 4 | "hash": "58cdb633d6a3f6951e4858c4a8f04497" 5 | }, 6 | "browserActionLabelDefault": { 7 | "message": "增强型图片查看器", 8 | "hash": "84a0d0874c1894bacbac67d2899aac51" 9 | }, 10 | "extensionDescription": { 11 | "message": "对浏览器的默认图片查看器进行增强", 12 | "hash": "e041a64512d413d461edce262924d227" 13 | }, 14 | "extensionName": { 15 | "message": "增强型图片查看器", 16 | "hash": "84a0d0874c1894bacbac67d2899aac51" 17 | }, 18 | "fit": { 19 | "message": "匹配浏览器窗口 (宽高比保持不变)", 20 | "hash": "6909c8d6b9e4e7fb622a464429953d89" 21 | }, 22 | "fitModesSectionTitle": { 23 | "message": "左键点击 可以在此处选中的模式中按顺序循环切换\n", 24 | "hash": "257aa513c419cd02cb5f51b361c6e07c" 25 | }, 26 | "fitToHeight": { 27 | "message": "匹配窗口高度", 28 | "hash": "04a2e5c45343ad165d821bd97ce4c2cc" 29 | }, 30 | "fitToHeightUnlessSmaller": { 31 | "message": "匹配窗口高度且不放大", 32 | "hash": "76e161611331aa478c49514eec300465" 33 | }, 34 | "fitToWidth": { 35 | "message": "匹配窗口宽度", 36 | "hash": "17040bdc162f589ef6bf1069993023dd" 37 | }, 38 | "fitToWidthUnlessSmaller": { 39 | "message": "匹配窗口宽度且不放大", 40 | "hash": "078e1e2a3b112417e668ae922cc87c10" 41 | }, 42 | "fitUnlessSmaller": { 43 | "message": "匹配浏览器窗口且不放大 (宽高比保持不变)", 44 | "hash": "309b2d162831b4c7fe627a442392cc46" 45 | }, 46 | "noFit": { 47 | "message": "原始大小", 48 | "hash": "c2363e8a96f85018e396eb707332e6a6" 49 | }, 50 | "optionsTitle": { 51 | "message": "选项", 52 | "hash": "531dad0078a72193602b18495f0ad4e9" 53 | }, 54 | "rememberLastRotation": { 55 | "message": "记住上次的图片朝向", 56 | "hash": "f82a266925ffd1eedfdc7f1ac2d39867" 57 | }, 58 | "rememberLastSizeState": { 59 | "message": "记住上次的缩放模式", 60 | "hash": "43b20541f0dc1b75bdd48621fa624225" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /options/options.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | } 5 | body { 6 | min-width: 400px; 7 | padding: 10px; 8 | } 9 | form > fieldset { 10 | border: 1px solid; 11 | display: flex; 12 | flex-direction: column; 13 | padding: 10px; 14 | width: 100%; 15 | } 16 | form > fieldset:not(:first-child) { 17 | margin-top: 10px; 18 | } 19 | form > fieldset > label:not(:first-child), 20 | form > fieldset > fieldset > label:not(:first-child) { 21 | display: inline-block; 22 | margin-top: 10px; 23 | } 24 | form > fieldset > fieldset { 25 | border-color: #DDDDDD; 26 | padding: 5px; 27 | } 28 | form > fieldset > fieldset > label:not(:first-child), 29 | form > fieldset > fieldset > fieldset > label:not(:first-child) { 30 | display: inline-block; 31 | margin-top: 10px; 32 | } 33 | form > fieldset > fieldset > fieldset { 34 | border-color: #DDDDDD; 35 | padding: 5px; 36 | } 37 | button, 38 | input[type=file], 39 | input[type=number], 40 | input[type=text] { 41 | width: 100%; 42 | } 43 | input[type=checkbox], 44 | input[type=radio] { 45 | display: inline-block; 46 | vertical-align: -2px; 47 | } 48 | select { 49 | padding: 3px; 50 | width: 100%; 51 | } 52 | textarea { 53 | height: 100px; 54 | padding: 3px; 55 | width: 100%; 56 | } 57 | textarea.error { 58 | border: 1px solid red; 59 | } 60 | textarea:focus.error { 61 | outline-color: red; 62 | } 63 | .list-error { 64 | color: red; 65 | } 66 | .box-wrapper { 67 | margin-top: 5px; 68 | } 69 | .box-wrapper > .box-wrapper { 70 | margin-left: 25px; 71 | } 72 | .box-wrapper > label, .color-wrapper .label { 73 | display: block; 74 | line-height: 20px; 75 | margin: -20px 0 0 25px; 76 | } 77 | .color-wrapper { 78 | position: relative; 79 | } 80 | .color-wrapper > input { 81 | display: none; 82 | } 83 | .color-wrapper .color-picker { 84 | background-color: black; 85 | border-radius: 3px; 86 | display: block; 87 | height: 20px; 88 | width: 20px; 89 | } 90 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const globals = require("globals"); 2 | const js = require("@eslint/js"); 3 | const stylistic = require("@stylistic/eslint-plugin"); 4 | 5 | module.exports = [ 6 | { 7 | ignores: [ 8 | "browser-polyfill.js", 9 | ], 10 | }, 11 | { 12 | languageOptions: { 13 | globals: { 14 | ...globals.browser, 15 | ...globals.commonjs, 16 | ...globals.es6, 17 | ...globals.jquery, 18 | ...globals.webextensions, 19 | }, 20 | parserOptions: { 21 | ecmaVersion: 2020, 22 | }, 23 | }, 24 | plugins: { 25 | stylistic: stylistic, 26 | }, 27 | rules:{ 28 | ...js.configs.recommended.rules, 29 | "no-restricted-syntax": [ 30 | "error", 31 | "ForInStatement", 32 | ], 33 | "no-unused-vars": [ 34 | "error", 35 | { 36 | "args": "all", 37 | "argsIgnorePattern": "^_[^_]", 38 | "caughtErrorsIgnorePattern": "^_[^_]", 39 | "varsIgnorePattern": "^_[^_]", 40 | }, 41 | ], 42 | "no-var": "error", 43 | "prefer-const": "error", 44 | "stylistic/array-bracket-newline": [ 45 | "error", 46 | "consistent", 47 | ], 48 | "stylistic/array-bracket-spacing": [ 49 | "error", 50 | "never", 51 | ], 52 | "stylistic/comma-dangle": [ 53 | "error", 54 | { 55 | "arrays": "always-multiline", 56 | "exports": "always-multiline", 57 | "functions": "only-multiline", 58 | "imports": "always-multiline", 59 | "objects": "always-multiline", 60 | }, 61 | ], 62 | "stylistic/indent": [ 63 | "error", 64 | 4, 65 | { 66 | "SwitchCase": 1, 67 | }, 68 | ], 69 | "stylistic/linebreak-style": [ 70 | "error", 71 | "unix", 72 | ], 73 | "stylistic/no-console": [ 74 | "off", 75 | ], 76 | "stylistic/object-curly-spacing": [ 77 | "error", 78 | "never", 79 | ], 80 | "stylistic/quotes": [ 81 | "error", 82 | "double", 83 | ], 84 | "stylistic/semi": [ 85 | "error", 86 | "always", 87 | ], 88 | }, 89 | }, 90 | ]; 91 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | const OPTION_BACKGROUND_COLOR = "backgroundColor"; 2 | const OPTION_SIZE_STATES = "sizeStates"; 3 | 4 | const OPTION_REMEMBER_LAST_ROTATION = "rememberLastRotation"; 5 | const OPTION_REMEMBER_LAST_SIZE_STATE = "rememberLastSizeState"; 6 | 7 | const AVAILABLE_SIZE_STATES = [ 8 | "fitUnlessSmaller", 9 | "noFit", 10 | "fit", 11 | "fitToHeight", 12 | "fitToHeightUnlessSmaller", 13 | "fitToWidth", 14 | "fitToWidthUnlessSmaller", 15 | ]; 16 | 17 | const IMAGE_FILE_URL = /file:\/\/.+\.(gif|gifv|jpg|jpeg|png|svg|webm)/; 18 | 19 | const knownImageURLs = new Set(); 20 | 21 | browser.storage.local.get([ 22 | OPTION_BACKGROUND_COLOR, 23 | OPTION_REMEMBER_LAST_ROTATION, 24 | OPTION_REMEMBER_LAST_SIZE_STATE, 25 | OPTION_SIZE_STATES, 26 | ]) 27 | .then( 28 | (result) => { 29 | if (result[OPTION_SIZE_STATES] === undefined) { 30 | browser.storage.local.set({[OPTION_SIZE_STATES]: AVAILABLE_SIZE_STATES}); 31 | } 32 | if (result[OPTION_BACKGROUND_COLOR] === undefined) { 33 | browser.storage.local.set({[OPTION_BACKGROUND_COLOR]: "#000000"}); 34 | } 35 | if (result[OPTION_REMEMBER_LAST_ROTATION] === undefined) { 36 | browser.storage.local.set({[OPTION_REMEMBER_LAST_ROTATION]: true}); 37 | } 38 | if (result[OPTION_REMEMBER_LAST_SIZE_STATE] === undefined) { 39 | browser.storage.local.set({[OPTION_REMEMBER_LAST_SIZE_STATE]: true}); 40 | } 41 | } 42 | ); 43 | 44 | // not fired for file:// URLs 45 | browser.webRequest.onHeadersReceived.addListener( 46 | checkForImageURL, 47 | { 48 | types: ["main_frame"], 49 | urls: [""], 50 | }, 51 | ["responseHeaders"] 52 | ); 53 | 54 | browser.webNavigation.onCommitted.addListener(maybeModifyTab); 55 | 56 | function checkForImageURL(details) { 57 | if (knownImageURLs.has(details.url)) { 58 | return; 59 | } 60 | 61 | for (const header of details.responseHeaders) { 62 | if (header.name.toLowerCase() === "content-type" && header.value.indexOf("image/") === 0) { 63 | knownImageURLs.add(details.url); 64 | return; 65 | } 66 | } 67 | } 68 | 69 | function maybeModifyTab(details) { 70 | if (!knownImageURLs.has(details.url) && !details.url.match(IMAGE_FILE_URL) || details.frameId !== 0) { 71 | return; 72 | } 73 | 74 | modifyTab(details.tabId); 75 | } 76 | 77 | function modifyTab(tabId) { 78 | browser.tabs.executeScript( 79 | tabId, 80 | { 81 | file: "browser-polyfill.js", 82 | runAt: "document_start", 83 | } 84 | ).then( 85 | browser.tabs.executeScript( 86 | tabId, 87 | { 88 | file: "content.js", 89 | runAt: "document_start", 90 | } 91 | )); 92 | } 93 | -------------------------------------------------------------------------------- /options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |
11 |
12 | 13 | 17 |
18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 | 51 | 52 |
53 |
54 | 55 | 56 |
57 |
58 | 59 | 60 |
61 |
62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Sebastian Blask", 3 | "devDependencies": { 4 | "@eslint/js": "9.39.1", 5 | "@stylistic/eslint-plugin": "5.6.1", 6 | "eslint": "9.39.1", 7 | "globals": "16.3.0", 8 | "web-ext": "8.9.0" 9 | }, 10 | "license": "MIT", 11 | "name": "enhancedimageviewer", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/sblask-webextensions/webextension-enhanced-image-viewer" 15 | }, 16 | "scripts": { 17 | "artifact-create:chrome": "web-ext build --artifacts-dir . --ignore-files bin screenshots package.json icon.fodg", 18 | "artifact-create:firefox": "web-ext build --artifacts-dir . --ignore-files bin screenshots package.json icon.fodg *.png browser-polyfill.js", 19 | "artifact-rename:chrome": "VERSION=$(jq --raw-output '.version' manifest.json); rename \"s/.zip$/-chrome.zip/\" *$VERSION.zip", 20 | "artifact-rename:firefox": "VERSION=$(jq --raw-output '.version' manifest.json); rename \"s/.zip$/-firefox.zip/\" *$VERSION.zip", 21 | "build": "git add --all; rm *.zip; npm run modify:chrome && npm run artifact-create:chrome && npm run artifact-rename:chrome && git checkout -- . && npm run modify:firefox && npm run artifact-create:firefox && npm run artifact-rename:firefox && git checkout -- .", 22 | "help": "web-ext --help", 23 | "icons": "for size in 16x16 32x32 48x48 64x64 128x128 440x280; do convert -background none -density 1000 -resize ${size} -extent ${size} -gravity center icon.svg icon-${size}.png; done", 24 | "lint": "eslint --debug $(find . -not -path '*node_modules*' -name '*.js')", 25 | "modify:chrome": "jq --indent 4 '. | del(.applications) | del(.browser_action.default_icon)' manifest.json | sponge manifest.json", 26 | "modify:firefox": "npm run modify:manifest:firefox && npm run modify:remove-polyfill-references", 27 | "modify:manifest:firefox": "jq --indent 4 '. | .background.scripts |= map(select(. != \"browser-polyfill.js\")) | if has(\"icons\") then .icons |= {\"48\": \"icon.svg\"} else . end | del(.options_ui.open_in_tab)' manifest.json | sponge manifest.json", 28 | "modify:remove-polyfill-references": "sed --in-place --regexp-extended '/\"(\\.\\.\\/)?browser-polyfill.js\"/d' $(find . -not -path '*node_modules*' -name '*.js' -o -name '*.html')", 29 | "readme": "cat README.md | sed -r 's/\\[.+\\]\\((.+)\\)/\\1/' | sed -r '/\\[/{ N; s/\\[.+\\]\\((.+)\\)/\\n\\1/ }' | sed -r '/```/,/```/d' | vim -", 30 | "release": "VERSION=$(jq --raw-output '.version' manifest.json); hub release create $(for file in *$VERSION*.zip; do echo \" -a ${file} \"; done) -m $VERSION $VERSION", 31 | "start": "GTK_THEME=Greybird web-ext run --verbose --firefox firefox --url $(jq --raw-output '.homepage_url' manifest.json)", 32 | "start:german": "GTK_THEME=Greybird web-ext run --verbose --firefox firefox --pref=general.useragent.locale=de-DE --pref=intl.locale.matchOS=false --url $(jq --raw-output '.homepage_url' manifest.json)", 33 | "start:nightly": "GTK_THEME=Greybird web-ext run --verbose --firefox firefox-trunk --url $(jq --raw-output '.homepage_url' manifest.json)" 34 | }, 35 | "title": "Enhanced Image Viewer", 36 | "version": "1.1.1" 37 | } 38 | -------------------------------------------------------------------------------- /options/options.js: -------------------------------------------------------------------------------- 1 | const OPTION_BACKGROUND_COLOR = "backgroundColor"; 2 | const OPTION_SIZE_STATES = "sizeStates"; 3 | 4 | const OPTION_REMEMBER_LAST_ROTATION = "rememberLastRotation"; 5 | const OPTION_REMEMBER_LAST_SIZE_STATE = "rememberLastSizeState"; 6 | 7 | function restoreOptions() { 8 | browser.storage.local.get([ 9 | OPTION_BACKGROUND_COLOR, 10 | OPTION_REMEMBER_LAST_ROTATION, 11 | OPTION_REMEMBER_LAST_SIZE_STATE, 12 | OPTION_SIZE_STATES, 13 | ]).then( 14 | result => { 15 | for (const state of result[OPTION_SIZE_STATES]) { 16 | setBooleanValue(state, true); 17 | } 18 | 19 | setTextValue("backgroundColor", result[OPTION_BACKGROUND_COLOR]); 20 | document.getElementById("backgroundColorPicker").style.backgroundColor = result[OPTION_BACKGROUND_COLOR]; 21 | setBooleanValue("rememberLastRotation", result[OPTION_REMEMBER_LAST_ROTATION]); 22 | setBooleanValue("rememberLastSizeState", result[OPTION_REMEMBER_LAST_SIZE_STATE]); 23 | } 24 | ); 25 | } 26 | 27 | function enableAutosave() { 28 | for (const input of document.querySelectorAll("input:not([type=radio]):not([type=checkbox]), textarea")) { 29 | input.addEventListener("input", saveOptions); 30 | } 31 | for (const input of document.querySelectorAll("input[type=radio], input[type=checkbox]")) { 32 | input.addEventListener("change", saveOptions); 33 | } 34 | } 35 | 36 | function loadTranslations() { 37 | for (const element of document.querySelectorAll("[data-i18n]")) { 38 | const translationKey = element.getAttribute("data-i18n"); 39 | if (typeof browser === "undefined" || !browser.i18n.getMessage(translationKey)) { 40 | element.textContent = element.getAttribute("data-i18n"); 41 | } else { 42 | element.innerHTML = browser.i18n.getMessage(translationKey); 43 | } 44 | } 45 | } 46 | 47 | function setTextValue(elementID, newValue) { 48 | const oldValue = document.getElementById(elementID).value; 49 | 50 | if (oldValue !== newValue) { 51 | document.getElementById(elementID).value = newValue; 52 | } 53 | } 54 | 55 | function setBooleanValue(elementID, newValue) { 56 | document.getElementById(elementID).checked = newValue; 57 | } 58 | 59 | function saveOptions(event) { 60 | event.preventDefault(); 61 | 62 | let sizeStates = [].map.call(document.querySelectorAll("#modes input:checked"), (node) => node.id); 63 | if (sizeStates.length == 0) 64 | sizeStates = [document.querySelector("#modes input").id]; 65 | 66 | browser.storage.local.set({ 67 | [OPTION_BACKGROUND_COLOR]: document.getElementById("backgroundColor").value, 68 | [OPTION_SIZE_STATES]: sizeStates, 69 | [OPTION_REMEMBER_LAST_ROTATION]: document.getElementById("rememberLastRotation").checked, 70 | [OPTION_REMEMBER_LAST_SIZE_STATE]: document.getElementById("rememberLastSizeState").checked, 71 | }); 72 | } 73 | 74 | document.addEventListener("DOMContentLoaded", restoreOptions); 75 | document.addEventListener("DOMContentLoaded", enableAutosave); 76 | document.addEventListener("DOMContentLoaded", loadTranslations); 77 | document.querySelector("form").addEventListener("submit", saveOptions); 78 | 79 | browser.storage.onChanged.addListener(restoreOptions); 80 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 19 | 23 | 27 | 28 | 30 | 34 | 38 | 39 | 41 | 45 | 49 | 50 | 52 | 56 | 60 | 61 | 64 | 68 | 72 | 73 | 81 | 90 | 99 | 108 | 109 | 111 | 112 | 114 | image/svg+xml 115 | 117 | 118 | 119 | 120 | 121 | 124 | 131 | 135 | 139 | 143 | 147 | 148 | 152 | 159 | 163 | 164 | -------------------------------------------------------------------------------- /content.js: -------------------------------------------------------------------------------- 1 | const OPTION_BACKGROUND_COLOR = "backgroundColor"; 2 | const OPTION_SIZE_STATES = "sizeStates"; 3 | 4 | const OPTION_LAST_ROTATION = "lastRotation"; 5 | const OPTION_REMEMBER_LAST_ROTATION = "rememberLastRotation"; 6 | 7 | const OPTION_LAST_SIZE_STATE = "lastSizeState"; 8 | const OPTION_REMEMBER_LAST_SIZE_STATE = "rememberLastSizeState"; 9 | 10 | const DEFAULT_INFO_FONT_SIZE = 16; 11 | 12 | const IMAGE = document.getElementsByTagName("img")[0]; 13 | 14 | const IMAGE_STYLE = makeStyle(); 15 | const INFO = makeInfo(); 16 | const SCROLLBAR_WIDTH = getScrollbarWidth(); 17 | 18 | const SIZES = { 19 | fitUnlessSmaller: { 20 | cssOriginalOrientation: () => { return "img { max-width: 100%; max-height: 100%; }"; }, 21 | cssChangedOrientation: () => { return getRotatedCSS(...getFitDimensions(true)); }, 22 | description: browser.i18n.getMessage("fitUnlessSmaller"), 23 | }, 24 | 25 | noFit: { 26 | cssOriginalOrientation: () => { return "body { display: flex; height: 100%; } img { position: unset; flex-shrink: 0; }"; }, 27 | cssChangedOrientation: () => { return getRotatedCSS(IMAGE.naturalWidth, IMAGE.naturalHeight, window.innerWidth, window.innerHeight); }, 28 | description: browser.i18n.getMessage("noFit"), 29 | }, 30 | 31 | fit: { 32 | cssOriginalOrientation: () => { 33 | const imageAspectRatio = IMAGE.naturalWidth / IMAGE.naturalHeight; 34 | const windowAspectRatio = window.innerWidth / window.innerHeight; 35 | if (imageAspectRatio < windowAspectRatio) { 36 | return SIZES.fitToHeight.cssOriginalOrientation(); 37 | } else { 38 | return SIZES.fitToWidth.cssOriginalOrientation(); 39 | } 40 | }, 41 | cssChangedOrientation: () => { 42 | const imageAspectRatio = IMAGE.naturalHeight / IMAGE.naturalWidth; 43 | const windowAspectRatio = window.innerWidth / window.innerHeight; 44 | if (imageAspectRatio < windowAspectRatio) { 45 | return SIZES.fitToHeight.cssChangedOrientation(); 46 | } else { 47 | return SIZES.fitToWidth.cssChangedOrientation(); 48 | } 49 | }, 50 | description: browser.i18n.getMessage("fit"), 51 | }, 52 | 53 | fitToWidthUnlessSmaller: { 54 | cssOriginalOrientation: () => { return "body { display: flex; height: 100%; } img { max-width: 100%; position: unset; flex-shrink: 0; }"; }, 55 | cssChangedOrientation: () => { return getRotatedCSS(...getFitToWidthDimensions(true)); }, 56 | description: browser.i18n.getMessage("fitToWidthUnlessSmaller"), 57 | }, 58 | 59 | fitToWidth: { 60 | cssOriginalOrientation: () => { return "body { display: flex; height: 100%; } img { width: 100%; position: unset; flex-shrink: 0; }"; }, 61 | cssChangedOrientation: () => { return getRotatedCSS(...getFitToWidthDimensions()); }, 62 | description: browser.i18n.getMessage("fitToWidth"), 63 | }, 64 | 65 | fitToHeightUnlessSmaller: { 66 | cssOriginalOrientation: () => { return "img { max-height: 100%; }"; }, 67 | cssChangedOrientation: () => { return getRotatedCSS(...getFitToHeightDimensions(true)); }, 68 | description: browser.i18n.getMessage("fitToHeightUnlessSmaller"), 69 | }, 70 | 71 | fitToHeight: { 72 | cssOriginalOrientation: () => { return "img { height: 100%; }"; }, 73 | cssChangedOrientation: () => { return getRotatedCSS(...getFitToHeightDimensions()); }, 74 | description: browser.i18n.getMessage("fitToHeight"), 75 | }, 76 | }; 77 | 78 | let infoTimeout = undefined; 79 | let justGainedFocus = false; 80 | 81 | let backgroundColor = undefined; 82 | let rotation = undefined; 83 | let sizeStates = undefined; 84 | let currentSizeState = undefined; 85 | 86 | let relativeClickX = 0; 87 | let relativeClickY = 0; 88 | 89 | function handleClick(event) { 90 | if (event.button !== 0) { 91 | return; 92 | } 93 | 94 | event.stopImmediatePropagation(); 95 | event.stopPropagation(); 96 | event.preventDefault(); 97 | 98 | const clickX = event.pageX - IMAGE.offsetLeft; 99 | const clickY = event.pageY - IMAGE.offsetTop; 100 | 101 | relativeClickX = clickX / IMAGE.width; 102 | relativeClickY = clickY / IMAGE.height; 103 | 104 | if (justGainedFocus) { 105 | justGainedFocus = false; 106 | return; 107 | } 108 | 109 | let direction = 1; 110 | if (event.shiftKey) { 111 | direction = -1; 112 | } 113 | 114 | const newIndex = (sizeStates.indexOf(currentSizeState) + sizeStates.length + direction) % sizeStates.length; 115 | currentSizeState = sizeStates[newIndex]; 116 | browser.storage.local.set({[OPTION_LAST_SIZE_STATE]: currentSizeState}); 117 | 118 | updateImageStyle(); 119 | adjustScroll(); 120 | flashInfo(); 121 | } 122 | 123 | function adjustScroll() { 124 | if (rotation === 90 || rotation === 270) { 125 | window.scrollTo(0, 0); 126 | return; 127 | } 128 | 129 | const {left, top} = IMAGE.getBoundingClientRect(); 130 | const offsetLeft = left + window.scrollX; 131 | const offsetTop = top + window.scrollY; 132 | 133 | const centerX = offsetLeft + IMAGE.width * relativeClickX; 134 | const centerY = offsetTop + IMAGE.height * relativeClickY; 135 | 136 | window.scrollTo(centerX - window.innerWidth / 2, centerY - window.innerHeight / 2); 137 | 138 | relativeClickX = 0; 139 | relativeClickY = 0; 140 | } 141 | 142 | function handleKey(event) { 143 | if (event.ctrlKey) { 144 | return; 145 | } 146 | 147 | switch (event.key) { 148 | case "i": 149 | toggleInfo(); 150 | break; 151 | case "r": 152 | rotation = (rotation + 90 + 360) % 360; 153 | browser.storage.local.set({[OPTION_LAST_ROTATION]: rotation}); 154 | break; 155 | case "l": 156 | rotation = (rotation - 90 + 360) % 360; 157 | browser.storage.local.set({[OPTION_LAST_ROTATION]: rotation}); 158 | break; 159 | } 160 | } 161 | 162 | function getFitDimensions(maxNatural=false) { 163 | let [newImageWidth, newImageHeight, viewportWidth, viewportHeight] = getFitToWidthDimensions(maxNatural); 164 | 165 | if (newImageWidth > window.innerHeight) { 166 | [newImageWidth, newImageHeight, viewportWidth, viewportHeight] = getFitToHeightDimensions(maxNatural); 167 | } 168 | 169 | return [newImageWidth, newImageHeight, viewportWidth, viewportHeight]; 170 | } 171 | 172 | function getFitToWidthDimensions(maxNatural=false) { 173 | function newHeight(viewportWidth) { 174 | if (maxNatural) { 175 | return Math.min(IMAGE.naturalHeight, viewportWidth); 176 | } else { 177 | return viewportWidth; 178 | } 179 | } 180 | 181 | const imageAspectRatio = IMAGE.naturalWidth / IMAGE.naturalHeight; 182 | 183 | let viewportWidth = window.innerWidth; 184 | const viewportHeight = window.innerHeight; 185 | 186 | let newImageHeight = newHeight(viewportWidth); 187 | let newImageWidth = newImageHeight * imageAspectRatio; 188 | 189 | if (newImageWidth > viewportHeight) { 190 | viewportWidth = viewportWidth - SCROLLBAR_WIDTH; 191 | newImageHeight = newHeight(viewportWidth); 192 | newImageWidth = newImageHeight * imageAspectRatio; 193 | } 194 | 195 | return [newImageWidth, newImageHeight, viewportWidth, viewportHeight]; 196 | } 197 | 198 | function getFitToHeightDimensions(maxNatural=false) { 199 | function newWidth(viewportHeight) { 200 | if (maxNatural) { 201 | return Math.min(IMAGE.naturalWidth, viewportHeight); 202 | } else { 203 | return viewportHeight; 204 | } 205 | } 206 | 207 | const imageAspectRatio = IMAGE.naturalWidth / IMAGE.naturalHeight; 208 | 209 | const viewportWidth = window.innerWidth; 210 | let viewportHeight = window.innerHeight; 211 | 212 | let newImageWidth = newWidth(viewportHeight); 213 | let newImageHeight = newImageWidth / imageAspectRatio; 214 | 215 | if (newImageHeight > viewportWidth) { 216 | viewportHeight = viewportHeight - SCROLLBAR_WIDTH; 217 | newImageWidth = newWidth(viewportHeight); 218 | newImageHeight = newImageWidth / imageAspectRatio; 219 | } 220 | 221 | return [newImageWidth, newImageHeight, viewportWidth, viewportHeight]; 222 | } 223 | 224 | function getScrollbarWidth() { 225 | const css = ` 226 | .scrollbar-measure { 227 | height: 100px; 228 | overflow: scroll; 229 | position: absolute; 230 | top: -9999px; 231 | width: 100px; 232 | } 233 | `; 234 | 235 | const style = makeStyle(); 236 | style.appendChild(document.createTextNode(css)); 237 | 238 | const div = document.createElement("div"); 239 | div.className = "scrollbar-measure"; 240 | document.body.appendChild(div); 241 | 242 | const scrollbarWidth = div.offsetWidth - div.clientWidth; 243 | 244 | document.body.removeChild(div); 245 | document.head.removeChild(style); 246 | return scrollbarWidth; 247 | } 248 | 249 | function makeStyle() { 250 | const style = document.createElement("style"); 251 | style.type = "text/css"; 252 | document.head.appendChild(style); 253 | return style; 254 | } 255 | 256 | function updateImageStyle() { 257 | if (IMAGE.naturalWidth == 0) { 258 | setTimeout(updateImageStyle, 100); 259 | return; 260 | } 261 | 262 | while (IMAGE_STYLE.hasChildNodes()) { 263 | IMAGE_STYLE.removeChild(IMAGE_STYLE.firstChild); 264 | } 265 | 266 | IMAGE_STYLE.appendChild(document.createTextNode(makeImageCSS())); 267 | 268 | updateInfo(); 269 | } 270 | 271 | function initInfoStyle() { 272 | const style = makeStyle(); 273 | 274 | const zoomIndepependentWindowHeight = window.innerHeight * window.devicePixelRatio; 275 | const relativeFontSize = DEFAULT_INFO_FONT_SIZE / zoomIndepependentWindowHeight * 100; 276 | const css = ` 277 | #info { 278 | background: black; 279 | border-radius: ${relativeFontSize}vh; 280 | border: ${0.1 * relativeFontSize}vh solid #555; 281 | color: white; 282 | font-size: ${relativeFontSize}vh; 283 | opacity: 0; 284 | padding: ${0.3 * relativeFontSize}vh ${0.6 * relativeFontSize}vh; 285 | position: fixed; 286 | right: ${relativeFontSize}vh; 287 | top: ${relativeFontSize}vh; 288 | transition: opacity .5s ease-in-out; 289 | } 290 | #info.show { 291 | opacity: 1; 292 | } 293 | `; 294 | 295 | style.appendChild(document.createTextNode(css)); 296 | return style; 297 | } 298 | 299 | function makeInfo() { 300 | initInfoStyle(); 301 | 302 | const info = document.createElement("div"); 303 | info.id = "info"; 304 | document.body.appendChild(info); 305 | 306 | return info; 307 | } 308 | 309 | function updateInfo() { 310 | let text = ""; 311 | text += SIZES[currentSizeState].description; 312 | text += " "; 313 | text += `(${IMAGE.naturalWidth}x${IMAGE.naturalHeight} to ${IMAGE.width}x${IMAGE.height})`; 314 | if (rotation) { 315 | text += ` / ${rotation}°`; 316 | } 317 | 318 | INFO.textContent = text; 319 | } 320 | 321 | function flashInfo() { 322 | if (infoTimeout) { 323 | clearTimeout(infoTimeout); 324 | } 325 | 326 | showInfo(); 327 | infoTimeout = setTimeout(hideInfo, 2000); 328 | } 329 | 330 | function showInfo() { 331 | INFO.classList.add("show"); 332 | } 333 | 334 | function hideInfo() { 335 | INFO.classList.remove("show"); 336 | } 337 | 338 | function toggleInfo() { 339 | INFO.classList.toggle("show"); 340 | } 341 | 342 | function makeImageCSS() { 343 | let cssOverride; 344 | 345 | if (rotation === 0 || rotation === 180) { 346 | cssOverride = SIZES[currentSizeState].cssOriginalOrientation(); 347 | } else { 348 | cssOverride = SIZES[currentSizeState].cssChangedOrientation(); 349 | } 350 | 351 | return ` 352 | body, html { 353 | all: unset; 354 | background: ${backgroundColor}; 355 | } 356 | img { 357 | all: unset; 358 | bottom: 0; 359 | cursor: default; 360 | height: auto; 361 | left: 0; 362 | margin: auto; 363 | position: absolute; 364 | right: 0; 365 | top: 0; 366 | transform-origin: center; 367 | transform: perspective(999px) rotate(${rotation}deg); 368 | width: auto; 369 | } 370 | ${cssOverride} 371 | `; 372 | } 373 | 374 | function getRotatedCSS(newImageWidth, newImageHeight, viewportWidth, viewportHeight) { 375 | const rotationAdjust = Math.abs(newImageHeight - newImageWidth) / 2; 376 | const horizontalSpace = Math.max(0, (viewportWidth - newImageHeight) / 2); 377 | const verticalSpace = Math.max(0, (viewportHeight - newImageWidth) / 2); 378 | if (newImageHeight > newImageWidth) { 379 | return ` 380 | img { 381 | height: ${newImageHeight}px; 382 | left: ${ rotationAdjust + horizontalSpace}px; 383 | margin: 0; 384 | top: ${-rotationAdjust + verticalSpace}px; 385 | } 386 | `; 387 | } else { 388 | return ` 389 | img { 390 | height: ${newImageHeight}px; 391 | left: ${-rotationAdjust + horizontalSpace}px; 392 | margin: 0; 393 | top: ${ rotationAdjust + verticalSpace}px; 394 | } 395 | `; 396 | } 397 | } 398 | 399 | function onPreferencesChanged(changes) { 400 | browser.storage.local.get([ 401 | OPTION_LAST_SIZE_STATE, 402 | OPTION_REMEMBER_LAST_SIZE_STATE, 403 | ]).then( 404 | (result) => { 405 | if (changes[OPTION_BACKGROUND_COLOR]) { 406 | backgroundColor = changes[OPTION_BACKGROUND_COLOR].newValue; 407 | } 408 | if (changes[OPTION_LAST_ROTATION]) { 409 | rotation = changes[OPTION_LAST_ROTATION].newValue; 410 | } 411 | 412 | if (changes[OPTION_SIZE_STATES]) { 413 | const lastSizeState = changes[OPTION_LAST_SIZE_STATE] ? 414 | changes[OPTION_LAST_SIZE_STATE].newValue : result[OPTION_LAST_SIZE_STATE]; 415 | const rememberLastSizeState = changes[OPTION_REMEMBER_LAST_SIZE_STATE] ? 416 | changes[OPTION_REMEMBER_LAST_SIZE_STATE].newValue : result[OPTION_REMEMBER_LAST_SIZE_STATE]; 417 | 418 | sizeStates = changes[OPTION_SIZE_STATES].newValue; 419 | if (rememberLastSizeState && sizeStates.indexOf(lastSizeState) >= 0) { 420 | currentSizeState = lastSizeState; 421 | } else { 422 | currentSizeState = sizeStates[0]; 423 | browser.storage.local.set({[OPTION_LAST_SIZE_STATE]: currentSizeState}); 424 | } 425 | } 426 | updateImageStyle(); 427 | flashInfo(); 428 | } 429 | ); 430 | } 431 | 432 | function initFromPreferences() { 433 | browser.storage.local.get([ 434 | OPTION_BACKGROUND_COLOR, 435 | OPTION_LAST_ROTATION, 436 | OPTION_LAST_SIZE_STATE, 437 | OPTION_REMEMBER_LAST_ROTATION, 438 | OPTION_REMEMBER_LAST_SIZE_STATE, 439 | OPTION_SIZE_STATES, 440 | ]).then( 441 | (result) => { 442 | backgroundColor = result[OPTION_BACKGROUND_COLOR]; 443 | sizeStates = result[OPTION_SIZE_STATES]; 444 | 445 | if (result[OPTION_REMEMBER_LAST_SIZE_STATE] && sizeStates.indexOf(result[OPTION_LAST_SIZE_STATE]) >= 0) { 446 | currentSizeState = result[OPTION_LAST_SIZE_STATE]; 447 | } else { 448 | currentSizeState = sizeStates[0]; 449 | browser.storage.local.set({[OPTION_LAST_SIZE_STATE]: currentSizeState}); 450 | } 451 | 452 | if (result[OPTION_REMEMBER_LAST_ROTATION] && result[OPTION_LAST_ROTATION] !== undefined) { 453 | rotation = result[OPTION_LAST_ROTATION]; 454 | } else { 455 | rotation = 0; 456 | browser.storage.local.set({[OPTION_LAST_ROTATION]: rotation}); 457 | } 458 | 459 | updateImageStyle(); 460 | flashInfo(); 461 | } 462 | ); 463 | } 464 | 465 | browser.storage.onChanged.addListener(onPreferencesChanged); 466 | initFromPreferences(); 467 | 468 | document.addEventListener("click", handleClick, true); 469 | document.addEventListener("keyup", handleKey); 470 | 471 | window.addEventListener("focus", () => { justGainedFocus = true; }, true); 472 | window.addEventListener("resize", updateImageStyle, true); 473 | 474 | const imageAttributeObserver = new MutationObserver(function(mutations) { 475 | mutations.forEach(function(mutation) { 476 | IMAGE.removeAttribute(mutation.attributeName); 477 | }); 478 | }); 479 | imageAttributeObserver.observe(IMAGE, {attributes: true}); 480 | 481 | IMAGE.removeAttribute("class"); 482 | IMAGE.removeAttribute("height"); 483 | IMAGE.removeAttribute("width"); 484 | IMAGE.removeAttribute("style"); 485 | 486 | const bodyAttributeObserver = new MutationObserver(function(mutations) { 487 | mutations.forEach(function(mutation) { 488 | document.body.removeAttribute(mutation.attributeName); 489 | }); 490 | }); 491 | bodyAttributeObserver.observe(document.body, {attributes: true}); 492 | 493 | document.body.removeAttribute("style"); 494 | -------------------------------------------------------------------------------- /browser-polyfill.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | if (typeof define === "function" && define.amd) { 3 | define("webextension-polyfill", ["module"], factory); 4 | } else if (typeof exports !== "undefined") { 5 | factory(module); 6 | } else { 7 | var mod = { 8 | exports: {} 9 | }; 10 | factory(mod); 11 | global.browser = mod.exports; 12 | } 13 | })(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function (module) { 14 | /* webextension-polyfill - v0.6.0 - Mon Dec 23 2019 12:32:53 */ 15 | 16 | /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ 17 | 18 | /* vim: set sts=2 sw=2 et tw=80: */ 19 | 20 | /* This Source Code Form is subject to the terms of the Mozilla Public 21 | * License, v. 2.0. If a copy of the MPL was not distributed with this 22 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 23 | "use strict"; 24 | 25 | if (typeof browser === "undefined" || Object.getPrototypeOf(browser) !== Object.prototype) { 26 | const CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE = "The message port closed before a response was received."; 27 | const SEND_RESPONSE_DEPRECATION_WARNING = "Returning a Promise is the preferred way to send a reply from an onMessage/onMessageExternal listener, as the sendResponse will be removed from the specs (See https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage)"; // Wrapping the bulk of this polyfill in a one-time-use function is a minor 28 | // optimization for Firefox. Since Spidermonkey does not fully parse the 29 | // contents of a function until the first time it's called, and since it will 30 | // never actually need to be called, this allows the polyfill to be included 31 | // in Firefox nearly for free. 32 | 33 | const wrapAPIs = extensionAPIs => { 34 | // NOTE: apiMetadata is associated to the content of the api-metadata.json file 35 | // at build time by replacing the following "include" with the content of the 36 | // JSON file. 37 | const apiMetadata = { 38 | "alarms": { 39 | "clear": { 40 | "minArgs": 0, 41 | "maxArgs": 1 42 | }, 43 | "clearAll": { 44 | "minArgs": 0, 45 | "maxArgs": 0 46 | }, 47 | "get": { 48 | "minArgs": 0, 49 | "maxArgs": 1 50 | }, 51 | "getAll": { 52 | "minArgs": 0, 53 | "maxArgs": 0 54 | } 55 | }, 56 | "bookmarks": { 57 | "create": { 58 | "minArgs": 1, 59 | "maxArgs": 1 60 | }, 61 | "get": { 62 | "minArgs": 1, 63 | "maxArgs": 1 64 | }, 65 | "getChildren": { 66 | "minArgs": 1, 67 | "maxArgs": 1 68 | }, 69 | "getRecent": { 70 | "minArgs": 1, 71 | "maxArgs": 1 72 | }, 73 | "getSubTree": { 74 | "minArgs": 1, 75 | "maxArgs": 1 76 | }, 77 | "getTree": { 78 | "minArgs": 0, 79 | "maxArgs": 0 80 | }, 81 | "move": { 82 | "minArgs": 2, 83 | "maxArgs": 2 84 | }, 85 | "remove": { 86 | "minArgs": 1, 87 | "maxArgs": 1 88 | }, 89 | "removeTree": { 90 | "minArgs": 1, 91 | "maxArgs": 1 92 | }, 93 | "search": { 94 | "minArgs": 1, 95 | "maxArgs": 1 96 | }, 97 | "update": { 98 | "minArgs": 2, 99 | "maxArgs": 2 100 | } 101 | }, 102 | "browserAction": { 103 | "disable": { 104 | "minArgs": 0, 105 | "maxArgs": 1, 106 | "fallbackToNoCallback": true 107 | }, 108 | "enable": { 109 | "minArgs": 0, 110 | "maxArgs": 1, 111 | "fallbackToNoCallback": true 112 | }, 113 | "getBadgeBackgroundColor": { 114 | "minArgs": 1, 115 | "maxArgs": 1 116 | }, 117 | "getBadgeText": { 118 | "minArgs": 1, 119 | "maxArgs": 1 120 | }, 121 | "getPopup": { 122 | "minArgs": 1, 123 | "maxArgs": 1 124 | }, 125 | "getTitle": { 126 | "minArgs": 1, 127 | "maxArgs": 1 128 | }, 129 | "openPopup": { 130 | "minArgs": 0, 131 | "maxArgs": 0 132 | }, 133 | "setBadgeBackgroundColor": { 134 | "minArgs": 1, 135 | "maxArgs": 1, 136 | "fallbackToNoCallback": true 137 | }, 138 | "setBadgeText": { 139 | "minArgs": 1, 140 | "maxArgs": 1, 141 | "fallbackToNoCallback": true 142 | }, 143 | "setIcon": { 144 | "minArgs": 1, 145 | "maxArgs": 1 146 | }, 147 | "setPopup": { 148 | "minArgs": 1, 149 | "maxArgs": 1, 150 | "fallbackToNoCallback": true 151 | }, 152 | "setTitle": { 153 | "minArgs": 1, 154 | "maxArgs": 1, 155 | "fallbackToNoCallback": true 156 | } 157 | }, 158 | "browsingData": { 159 | "remove": { 160 | "minArgs": 2, 161 | "maxArgs": 2 162 | }, 163 | "removeCache": { 164 | "minArgs": 1, 165 | "maxArgs": 1 166 | }, 167 | "removeCookies": { 168 | "minArgs": 1, 169 | "maxArgs": 1 170 | }, 171 | "removeDownloads": { 172 | "minArgs": 1, 173 | "maxArgs": 1 174 | }, 175 | "removeFormData": { 176 | "minArgs": 1, 177 | "maxArgs": 1 178 | }, 179 | "removeHistory": { 180 | "minArgs": 1, 181 | "maxArgs": 1 182 | }, 183 | "removeLocalStorage": { 184 | "minArgs": 1, 185 | "maxArgs": 1 186 | }, 187 | "removePasswords": { 188 | "minArgs": 1, 189 | "maxArgs": 1 190 | }, 191 | "removePluginData": { 192 | "minArgs": 1, 193 | "maxArgs": 1 194 | }, 195 | "settings": { 196 | "minArgs": 0, 197 | "maxArgs": 0 198 | } 199 | }, 200 | "commands": { 201 | "getAll": { 202 | "minArgs": 0, 203 | "maxArgs": 0 204 | } 205 | }, 206 | "contextMenus": { 207 | "remove": { 208 | "minArgs": 1, 209 | "maxArgs": 1 210 | }, 211 | "removeAll": { 212 | "minArgs": 0, 213 | "maxArgs": 0 214 | }, 215 | "update": { 216 | "minArgs": 2, 217 | "maxArgs": 2 218 | } 219 | }, 220 | "cookies": { 221 | "get": { 222 | "minArgs": 1, 223 | "maxArgs": 1 224 | }, 225 | "getAll": { 226 | "minArgs": 1, 227 | "maxArgs": 1 228 | }, 229 | "getAllCookieStores": { 230 | "minArgs": 0, 231 | "maxArgs": 0 232 | }, 233 | "remove": { 234 | "minArgs": 1, 235 | "maxArgs": 1 236 | }, 237 | "set": { 238 | "minArgs": 1, 239 | "maxArgs": 1 240 | } 241 | }, 242 | "devtools": { 243 | "inspectedWindow": { 244 | "eval": { 245 | "minArgs": 1, 246 | "maxArgs": 2, 247 | "singleCallbackArg": false 248 | } 249 | }, 250 | "panels": { 251 | "create": { 252 | "minArgs": 3, 253 | "maxArgs": 3, 254 | "singleCallbackArg": true 255 | } 256 | } 257 | }, 258 | "downloads": { 259 | "cancel": { 260 | "minArgs": 1, 261 | "maxArgs": 1 262 | }, 263 | "download": { 264 | "minArgs": 1, 265 | "maxArgs": 1 266 | }, 267 | "erase": { 268 | "minArgs": 1, 269 | "maxArgs": 1 270 | }, 271 | "getFileIcon": { 272 | "minArgs": 1, 273 | "maxArgs": 2 274 | }, 275 | "open": { 276 | "minArgs": 1, 277 | "maxArgs": 1, 278 | "fallbackToNoCallback": true 279 | }, 280 | "pause": { 281 | "minArgs": 1, 282 | "maxArgs": 1 283 | }, 284 | "removeFile": { 285 | "minArgs": 1, 286 | "maxArgs": 1 287 | }, 288 | "resume": { 289 | "minArgs": 1, 290 | "maxArgs": 1 291 | }, 292 | "search": { 293 | "minArgs": 1, 294 | "maxArgs": 1 295 | }, 296 | "show": { 297 | "minArgs": 1, 298 | "maxArgs": 1, 299 | "fallbackToNoCallback": true 300 | } 301 | }, 302 | "extension": { 303 | "isAllowedFileSchemeAccess": { 304 | "minArgs": 0, 305 | "maxArgs": 0 306 | }, 307 | "isAllowedIncognitoAccess": { 308 | "minArgs": 0, 309 | "maxArgs": 0 310 | } 311 | }, 312 | "history": { 313 | "addUrl": { 314 | "minArgs": 1, 315 | "maxArgs": 1 316 | }, 317 | "deleteAll": { 318 | "minArgs": 0, 319 | "maxArgs": 0 320 | }, 321 | "deleteRange": { 322 | "minArgs": 1, 323 | "maxArgs": 1 324 | }, 325 | "deleteUrl": { 326 | "minArgs": 1, 327 | "maxArgs": 1 328 | }, 329 | "getVisits": { 330 | "minArgs": 1, 331 | "maxArgs": 1 332 | }, 333 | "search": { 334 | "minArgs": 1, 335 | "maxArgs": 1 336 | } 337 | }, 338 | "i18n": { 339 | "detectLanguage": { 340 | "minArgs": 1, 341 | "maxArgs": 1 342 | }, 343 | "getAcceptLanguages": { 344 | "minArgs": 0, 345 | "maxArgs": 0 346 | } 347 | }, 348 | "identity": { 349 | "launchWebAuthFlow": { 350 | "minArgs": 1, 351 | "maxArgs": 1 352 | } 353 | }, 354 | "idle": { 355 | "queryState": { 356 | "minArgs": 1, 357 | "maxArgs": 1 358 | } 359 | }, 360 | "management": { 361 | "get": { 362 | "minArgs": 1, 363 | "maxArgs": 1 364 | }, 365 | "getAll": { 366 | "minArgs": 0, 367 | "maxArgs": 0 368 | }, 369 | "getSelf": { 370 | "minArgs": 0, 371 | "maxArgs": 0 372 | }, 373 | "setEnabled": { 374 | "minArgs": 2, 375 | "maxArgs": 2 376 | }, 377 | "uninstallSelf": { 378 | "minArgs": 0, 379 | "maxArgs": 1 380 | } 381 | }, 382 | "notifications": { 383 | "clear": { 384 | "minArgs": 1, 385 | "maxArgs": 1 386 | }, 387 | "create": { 388 | "minArgs": 1, 389 | "maxArgs": 2 390 | }, 391 | "getAll": { 392 | "minArgs": 0, 393 | "maxArgs": 0 394 | }, 395 | "getPermissionLevel": { 396 | "minArgs": 0, 397 | "maxArgs": 0 398 | }, 399 | "update": { 400 | "minArgs": 2, 401 | "maxArgs": 2 402 | } 403 | }, 404 | "pageAction": { 405 | "getPopup": { 406 | "minArgs": 1, 407 | "maxArgs": 1 408 | }, 409 | "getTitle": { 410 | "minArgs": 1, 411 | "maxArgs": 1 412 | }, 413 | "hide": { 414 | "minArgs": 1, 415 | "maxArgs": 1, 416 | "fallbackToNoCallback": true 417 | }, 418 | "setIcon": { 419 | "minArgs": 1, 420 | "maxArgs": 1 421 | }, 422 | "setPopup": { 423 | "minArgs": 1, 424 | "maxArgs": 1, 425 | "fallbackToNoCallback": true 426 | }, 427 | "setTitle": { 428 | "minArgs": 1, 429 | "maxArgs": 1, 430 | "fallbackToNoCallback": true 431 | }, 432 | "show": { 433 | "minArgs": 1, 434 | "maxArgs": 1, 435 | "fallbackToNoCallback": true 436 | } 437 | }, 438 | "permissions": { 439 | "contains": { 440 | "minArgs": 1, 441 | "maxArgs": 1 442 | }, 443 | "getAll": { 444 | "minArgs": 0, 445 | "maxArgs": 0 446 | }, 447 | "remove": { 448 | "minArgs": 1, 449 | "maxArgs": 1 450 | }, 451 | "request": { 452 | "minArgs": 1, 453 | "maxArgs": 1 454 | } 455 | }, 456 | "runtime": { 457 | "getBackgroundPage": { 458 | "minArgs": 0, 459 | "maxArgs": 0 460 | }, 461 | "getPlatformInfo": { 462 | "minArgs": 0, 463 | "maxArgs": 0 464 | }, 465 | "openOptionsPage": { 466 | "minArgs": 0, 467 | "maxArgs": 0 468 | }, 469 | "requestUpdateCheck": { 470 | "minArgs": 0, 471 | "maxArgs": 0 472 | }, 473 | "sendMessage": { 474 | "minArgs": 1, 475 | "maxArgs": 3 476 | }, 477 | "sendNativeMessage": { 478 | "minArgs": 2, 479 | "maxArgs": 2 480 | }, 481 | "setUninstallURL": { 482 | "minArgs": 1, 483 | "maxArgs": 1 484 | } 485 | }, 486 | "sessions": { 487 | "getDevices": { 488 | "minArgs": 0, 489 | "maxArgs": 1 490 | }, 491 | "getRecentlyClosed": { 492 | "minArgs": 0, 493 | "maxArgs": 1 494 | }, 495 | "restore": { 496 | "minArgs": 0, 497 | "maxArgs": 1 498 | } 499 | }, 500 | "storage": { 501 | "local": { 502 | "clear": { 503 | "minArgs": 0, 504 | "maxArgs": 0 505 | }, 506 | "get": { 507 | "minArgs": 0, 508 | "maxArgs": 1 509 | }, 510 | "getBytesInUse": { 511 | "minArgs": 0, 512 | "maxArgs": 1 513 | }, 514 | "remove": { 515 | "minArgs": 1, 516 | "maxArgs": 1 517 | }, 518 | "set": { 519 | "minArgs": 1, 520 | "maxArgs": 1 521 | } 522 | }, 523 | "managed": { 524 | "get": { 525 | "minArgs": 0, 526 | "maxArgs": 1 527 | }, 528 | "getBytesInUse": { 529 | "minArgs": 0, 530 | "maxArgs": 1 531 | } 532 | }, 533 | "sync": { 534 | "clear": { 535 | "minArgs": 0, 536 | "maxArgs": 0 537 | }, 538 | "get": { 539 | "minArgs": 0, 540 | "maxArgs": 1 541 | }, 542 | "getBytesInUse": { 543 | "minArgs": 0, 544 | "maxArgs": 1 545 | }, 546 | "remove": { 547 | "minArgs": 1, 548 | "maxArgs": 1 549 | }, 550 | "set": { 551 | "minArgs": 1, 552 | "maxArgs": 1 553 | } 554 | } 555 | }, 556 | "tabs": { 557 | "captureVisibleTab": { 558 | "minArgs": 0, 559 | "maxArgs": 2 560 | }, 561 | "create": { 562 | "minArgs": 1, 563 | "maxArgs": 1 564 | }, 565 | "detectLanguage": { 566 | "minArgs": 0, 567 | "maxArgs": 1 568 | }, 569 | "discard": { 570 | "minArgs": 0, 571 | "maxArgs": 1 572 | }, 573 | "duplicate": { 574 | "minArgs": 1, 575 | "maxArgs": 1 576 | }, 577 | "executeScript": { 578 | "minArgs": 1, 579 | "maxArgs": 2 580 | }, 581 | "get": { 582 | "minArgs": 1, 583 | "maxArgs": 1 584 | }, 585 | "getCurrent": { 586 | "minArgs": 0, 587 | "maxArgs": 0 588 | }, 589 | "getZoom": { 590 | "minArgs": 0, 591 | "maxArgs": 1 592 | }, 593 | "getZoomSettings": { 594 | "minArgs": 0, 595 | "maxArgs": 1 596 | }, 597 | "highlight": { 598 | "minArgs": 1, 599 | "maxArgs": 1 600 | }, 601 | "insertCSS": { 602 | "minArgs": 1, 603 | "maxArgs": 2 604 | }, 605 | "move": { 606 | "minArgs": 2, 607 | "maxArgs": 2 608 | }, 609 | "query": { 610 | "minArgs": 1, 611 | "maxArgs": 1 612 | }, 613 | "reload": { 614 | "minArgs": 0, 615 | "maxArgs": 2 616 | }, 617 | "remove": { 618 | "minArgs": 1, 619 | "maxArgs": 1 620 | }, 621 | "removeCSS": { 622 | "minArgs": 1, 623 | "maxArgs": 2 624 | }, 625 | "sendMessage": { 626 | "minArgs": 2, 627 | "maxArgs": 3 628 | }, 629 | "setZoom": { 630 | "minArgs": 1, 631 | "maxArgs": 2 632 | }, 633 | "setZoomSettings": { 634 | "minArgs": 1, 635 | "maxArgs": 2 636 | }, 637 | "update": { 638 | "minArgs": 1, 639 | "maxArgs": 2 640 | } 641 | }, 642 | "topSites": { 643 | "get": { 644 | "minArgs": 0, 645 | "maxArgs": 0 646 | } 647 | }, 648 | "webNavigation": { 649 | "getAllFrames": { 650 | "minArgs": 1, 651 | "maxArgs": 1 652 | }, 653 | "getFrame": { 654 | "minArgs": 1, 655 | "maxArgs": 1 656 | } 657 | }, 658 | "webRequest": { 659 | "handlerBehaviorChanged": { 660 | "minArgs": 0, 661 | "maxArgs": 0 662 | } 663 | }, 664 | "windows": { 665 | "create": { 666 | "minArgs": 0, 667 | "maxArgs": 1 668 | }, 669 | "get": { 670 | "minArgs": 1, 671 | "maxArgs": 2 672 | }, 673 | "getAll": { 674 | "minArgs": 0, 675 | "maxArgs": 1 676 | }, 677 | "getCurrent": { 678 | "minArgs": 0, 679 | "maxArgs": 1 680 | }, 681 | "getLastFocused": { 682 | "minArgs": 0, 683 | "maxArgs": 1 684 | }, 685 | "remove": { 686 | "minArgs": 1, 687 | "maxArgs": 1 688 | }, 689 | "update": { 690 | "minArgs": 2, 691 | "maxArgs": 2 692 | } 693 | } 694 | }; 695 | 696 | if (Object.keys(apiMetadata).length === 0) { 697 | throw new Error("api-metadata.json has not been included in browser-polyfill"); 698 | } 699 | /** 700 | * A WeakMap subclass which creates and stores a value for any key which does 701 | * not exist when accessed, but behaves exactly as an ordinary WeakMap 702 | * otherwise. 703 | * 704 | * @param {function} createItem 705 | * A function which will be called in order to create the value for any 706 | * key which does not exist, the first time it is accessed. The 707 | * function receives, as its only argument, the key being created. 708 | */ 709 | 710 | 711 | class DefaultWeakMap extends WeakMap { 712 | constructor(createItem, items = undefined) { 713 | super(items); 714 | this.createItem = createItem; 715 | } 716 | 717 | get(key) { 718 | if (!this.has(key)) { 719 | this.set(key, this.createItem(key)); 720 | } 721 | 722 | return super.get(key); 723 | } 724 | 725 | } 726 | /** 727 | * Returns true if the given object is an object with a `then` method, and can 728 | * therefore be assumed to behave as a Promise. 729 | * 730 | * @param {*} value The value to test. 731 | * @returns {boolean} True if the value is thenable. 732 | */ 733 | 734 | 735 | const isThenable = value => { 736 | return value && typeof value === "object" && typeof value.then === "function"; 737 | }; 738 | /** 739 | * Creates and returns a function which, when called, will resolve or reject 740 | * the given promise based on how it is called: 741 | * 742 | * - If, when called, `chrome.runtime.lastError` contains a non-null object, 743 | * the promise is rejected with that value. 744 | * - If the function is called with exactly one argument, the promise is 745 | * resolved to that value. 746 | * - Otherwise, the promise is resolved to an array containing all of the 747 | * function's arguments. 748 | * 749 | * @param {object} promise 750 | * An object containing the resolution and rejection functions of a 751 | * promise. 752 | * @param {function} promise.resolve 753 | * The promise's resolution function. 754 | * @param {function} promise.rejection 755 | * The promise's rejection function. 756 | * @param {object} metadata 757 | * Metadata about the wrapped method which has created the callback. 758 | * @param {integer} metadata.maxResolvedArgs 759 | * The maximum number of arguments which may be passed to the 760 | * callback created by the wrapped async function. 761 | * 762 | * @returns {function} 763 | * The generated callback function. 764 | */ 765 | 766 | 767 | const makeCallback = (promise, metadata) => { 768 | return (...callbackArgs) => { 769 | if (extensionAPIs.runtime.lastError) { 770 | promise.reject(extensionAPIs.runtime.lastError); 771 | } else if (metadata.singleCallbackArg || callbackArgs.length <= 1 && metadata.singleCallbackArg !== false) { 772 | promise.resolve(callbackArgs[0]); 773 | } else { 774 | promise.resolve(callbackArgs); 775 | } 776 | }; 777 | }; 778 | 779 | const pluralizeArguments = numArgs => numArgs == 1 ? "argument" : "arguments"; 780 | /** 781 | * Creates a wrapper function for a method with the given name and metadata. 782 | * 783 | * @param {string} name 784 | * The name of the method which is being wrapped. 785 | * @param {object} metadata 786 | * Metadata about the method being wrapped. 787 | * @param {integer} metadata.minArgs 788 | * The minimum number of arguments which must be passed to the 789 | * function. If called with fewer than this number of arguments, the 790 | * wrapper will raise an exception. 791 | * @param {integer} metadata.maxArgs 792 | * The maximum number of arguments which may be passed to the 793 | * function. If called with more than this number of arguments, the 794 | * wrapper will raise an exception. 795 | * @param {integer} metadata.maxResolvedArgs 796 | * The maximum number of arguments which may be passed to the 797 | * callback created by the wrapped async function. 798 | * 799 | * @returns {function(object, ...*)} 800 | * The generated wrapper function. 801 | */ 802 | 803 | 804 | const wrapAsyncFunction = (name, metadata) => { 805 | return function asyncFunctionWrapper(target, ...args) { 806 | if (args.length < metadata.minArgs) { 807 | throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`); 808 | } 809 | 810 | if (args.length > metadata.maxArgs) { 811 | throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`); 812 | } 813 | 814 | return new Promise((resolve, reject) => { 815 | if (metadata.fallbackToNoCallback) { 816 | // This API method has currently no callback on Chrome, but it return a promise on Firefox, 817 | // and so the polyfill will try to call it with a callback first, and it will fallback 818 | // to not passing the callback if the first call fails. 819 | try { 820 | target[name](...args, makeCallback({ 821 | resolve, 822 | reject 823 | }, metadata)); 824 | } catch (cbError) { 825 | console.warn(`${name} API method doesn't seem to support the callback parameter, ` + "falling back to call it without a callback: ", cbError); 826 | target[name](...args); // Update the API method metadata, so that the next API calls will not try to 827 | // use the unsupported callback anymore. 828 | 829 | metadata.fallbackToNoCallback = false; 830 | metadata.noCallback = true; 831 | resolve(); 832 | } 833 | } else if (metadata.noCallback) { 834 | target[name](...args); 835 | resolve(); 836 | } else { 837 | target[name](...args, makeCallback({ 838 | resolve, 839 | reject 840 | }, metadata)); 841 | } 842 | }); 843 | }; 844 | }; 845 | /** 846 | * Wraps an existing method of the target object, so that calls to it are 847 | * intercepted by the given wrapper function. The wrapper function receives, 848 | * as its first argument, the original `target` object, followed by each of 849 | * the arguments passed to the original method. 850 | * 851 | * @param {object} target 852 | * The original target object that the wrapped method belongs to. 853 | * @param {function} method 854 | * The method being wrapped. This is used as the target of the Proxy 855 | * object which is created to wrap the method. 856 | * @param {function} wrapper 857 | * The wrapper function which is called in place of a direct invocation 858 | * of the wrapped method. 859 | * 860 | * @returns {Proxy} 861 | * A Proxy object for the given method, which invokes the given wrapper 862 | * method in its place. 863 | */ 864 | 865 | 866 | const wrapMethod = (target, method, wrapper) => { 867 | return new Proxy(method, { 868 | apply(targetMethod, thisObj, args) { 869 | return wrapper.call(thisObj, target, ...args); 870 | } 871 | 872 | }); 873 | }; 874 | 875 | let hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty); 876 | /** 877 | * Wraps an object in a Proxy which intercepts and wraps certain methods 878 | * based on the given `wrappers` and `metadata` objects. 879 | * 880 | * @param {object} target 881 | * The target object to wrap. 882 | * 883 | * @param {object} [wrappers = {}] 884 | * An object tree containing wrapper functions for special cases. Any 885 | * function present in this object tree is called in place of the 886 | * method in the same location in the `target` object tree. These 887 | * wrapper methods are invoked as described in {@see wrapMethod}. 888 | * 889 | * @param {object} [metadata = {}] 890 | * An object tree containing metadata used to automatically generate 891 | * Promise-based wrapper functions for asynchronous. Any function in 892 | * the `target` object tree which has a corresponding metadata object 893 | * in the same location in the `metadata` tree is replaced with an 894 | * automatically-generated wrapper function, as described in 895 | * {@see wrapAsyncFunction} 896 | * 897 | * @returns {Proxy} 898 | */ 899 | 900 | const wrapObject = (target, wrappers = {}, metadata = {}) => { 901 | let cache = Object.create(null); 902 | let handlers = { 903 | has(proxyTarget, prop) { 904 | return prop in target || prop in cache; 905 | }, 906 | 907 | get(proxyTarget, prop, receiver) { 908 | if (prop in cache) { 909 | return cache[prop]; 910 | } 911 | 912 | if (!(prop in target)) { 913 | return undefined; 914 | } 915 | 916 | let value = target[prop]; 917 | 918 | if (typeof value === "function") { 919 | // This is a method on the underlying object. Check if we need to do 920 | // any wrapping. 921 | if (typeof wrappers[prop] === "function") { 922 | // We have a special-case wrapper for this method. 923 | value = wrapMethod(target, target[prop], wrappers[prop]); 924 | } else if (hasOwnProperty(metadata, prop)) { 925 | // This is an async method that we have metadata for. Create a 926 | // Promise wrapper for it. 927 | let wrapper = wrapAsyncFunction(prop, metadata[prop]); 928 | value = wrapMethod(target, target[prop], wrapper); 929 | } else { 930 | // This is a method that we don't know or care about. Return the 931 | // original method, bound to the underlying object. 932 | value = value.bind(target); 933 | } 934 | } else if (typeof value === "object" && value !== null && (hasOwnProperty(wrappers, prop) || hasOwnProperty(metadata, prop))) { 935 | // This is an object that we need to do some wrapping for the children 936 | // of. Create a sub-object wrapper for it with the appropriate child 937 | // metadata. 938 | value = wrapObject(value, wrappers[prop], metadata[prop]); 939 | } else if (hasOwnProperty(metadata, "*")) { 940 | // Wrap all properties in * namespace. 941 | value = wrapObject(value, wrappers[prop], metadata["*"]); 942 | } else { 943 | // We don't need to do any wrapping for this property, 944 | // so just forward all access to the underlying object. 945 | Object.defineProperty(cache, prop, { 946 | configurable: true, 947 | enumerable: true, 948 | 949 | get() { 950 | return target[prop]; 951 | }, 952 | 953 | set(value) { 954 | target[prop] = value; 955 | } 956 | 957 | }); 958 | return value; 959 | } 960 | 961 | cache[prop] = value; 962 | return value; 963 | }, 964 | 965 | set(proxyTarget, prop, value, receiver) { 966 | if (prop in cache) { 967 | cache[prop] = value; 968 | } else { 969 | target[prop] = value; 970 | } 971 | 972 | return true; 973 | }, 974 | 975 | defineProperty(proxyTarget, prop, desc) { 976 | return Reflect.defineProperty(cache, prop, desc); 977 | }, 978 | 979 | deleteProperty(proxyTarget, prop) { 980 | return Reflect.deleteProperty(cache, prop); 981 | } 982 | 983 | }; // Per contract of the Proxy API, the "get" proxy handler must return the 984 | // original value of the target if that value is declared read-only and 985 | // non-configurable. For this reason, we create an object with the 986 | // prototype set to `target` instead of using `target` directly. 987 | // Otherwise we cannot return a custom object for APIs that 988 | // are declared read-only and non-configurable, such as `chrome.devtools`. 989 | // 990 | // The proxy handlers themselves will still use the original `target` 991 | // instead of the `proxyTarget`, so that the methods and properties are 992 | // dereferenced via the original targets. 993 | 994 | let proxyTarget = Object.create(target); 995 | return new Proxy(proxyTarget, handlers); 996 | }; 997 | /** 998 | * Creates a set of wrapper functions for an event object, which handles 999 | * wrapping of listener functions that those messages are passed. 1000 | * 1001 | * A single wrapper is created for each listener function, and stored in a 1002 | * map. Subsequent calls to `addListener`, `hasListener`, or `removeListener` 1003 | * retrieve the original wrapper, so that attempts to remove a 1004 | * previously-added listener work as expected. 1005 | * 1006 | * @param {DefaultWeakMap} wrapperMap 1007 | * A DefaultWeakMap object which will create the appropriate wrapper 1008 | * for a given listener function when one does not exist, and retrieve 1009 | * an existing one when it does. 1010 | * 1011 | * @returns {object} 1012 | */ 1013 | 1014 | 1015 | const wrapEvent = wrapperMap => ({ 1016 | addListener(target, listener, ...args) { 1017 | target.addListener(wrapperMap.get(listener), ...args); 1018 | }, 1019 | 1020 | hasListener(target, listener) { 1021 | return target.hasListener(wrapperMap.get(listener)); 1022 | }, 1023 | 1024 | removeListener(target, listener) { 1025 | target.removeListener(wrapperMap.get(listener)); 1026 | } 1027 | 1028 | }); // Keep track if the deprecation warning has been logged at least once. 1029 | 1030 | 1031 | let loggedSendResponseDeprecationWarning = false; 1032 | const onMessageWrappers = new DefaultWeakMap(listener => { 1033 | if (typeof listener !== "function") { 1034 | return listener; 1035 | } 1036 | /** 1037 | * Wraps a message listener function so that it may send responses based on 1038 | * its return value, rather than by returning a sentinel value and calling a 1039 | * callback. If the listener function returns a Promise, the response is 1040 | * sent when the promise either resolves or rejects. 1041 | * 1042 | * @param {*} message 1043 | * The message sent by the other end of the channel. 1044 | * @param {object} sender 1045 | * Details about the sender of the message. 1046 | * @param {function(*)} sendResponse 1047 | * A callback which, when called with an arbitrary argument, sends 1048 | * that value as a response. 1049 | * @returns {boolean} 1050 | * True if the wrapped listener returned a Promise, which will later 1051 | * yield a response. False otherwise. 1052 | */ 1053 | 1054 | 1055 | return function onMessage(message, sender, sendResponse) { 1056 | let didCallSendResponse = false; 1057 | let wrappedSendResponse; 1058 | let sendResponsePromise = new Promise(resolve => { 1059 | wrappedSendResponse = function (response) { 1060 | if (!loggedSendResponseDeprecationWarning) { 1061 | console.warn(SEND_RESPONSE_DEPRECATION_WARNING, new Error().stack); 1062 | loggedSendResponseDeprecationWarning = true; 1063 | } 1064 | 1065 | didCallSendResponse = true; 1066 | resolve(response); 1067 | }; 1068 | }); 1069 | let result; 1070 | 1071 | try { 1072 | result = listener(message, sender, wrappedSendResponse); 1073 | } catch (err) { 1074 | result = Promise.reject(err); 1075 | } 1076 | 1077 | const isResultThenable = result !== true && isThenable(result); // If the listener didn't returned true or a Promise, or called 1078 | // wrappedSendResponse synchronously, we can exit earlier 1079 | // because there will be no response sent from this listener. 1080 | 1081 | if (result !== true && !isResultThenable && !didCallSendResponse) { 1082 | return false; 1083 | } // A small helper to send the message if the promise resolves 1084 | // and an error if the promise rejects (a wrapped sendMessage has 1085 | // to translate the message into a resolved promise or a rejected 1086 | // promise). 1087 | 1088 | 1089 | const sendPromisedResult = promise => { 1090 | promise.then(msg => { 1091 | // send the message value. 1092 | sendResponse(msg); 1093 | }, error => { 1094 | // Send a JSON representation of the error if the rejected value 1095 | // is an instance of error, or the object itself otherwise. 1096 | let message; 1097 | 1098 | if (error && (error instanceof Error || typeof error.message === "string")) { 1099 | message = error.message; 1100 | } else { 1101 | message = "An unexpected error occurred"; 1102 | } 1103 | 1104 | sendResponse({ 1105 | __mozWebExtensionPolyfillReject__: true, 1106 | message 1107 | }); 1108 | }).catch(err => { 1109 | // Print an error on the console if unable to send the response. 1110 | console.error("Failed to send onMessage rejected reply", err); 1111 | }); 1112 | }; // If the listener returned a Promise, send the resolved value as a 1113 | // result, otherwise wait the promise related to the wrappedSendResponse 1114 | // callback to resolve and send it as a response. 1115 | 1116 | 1117 | if (isResultThenable) { 1118 | sendPromisedResult(result); 1119 | } else { 1120 | sendPromisedResult(sendResponsePromise); 1121 | } // Let Chrome know that the listener is replying. 1122 | 1123 | 1124 | return true; 1125 | }; 1126 | }); 1127 | 1128 | const wrappedSendMessageCallback = ({ 1129 | reject, 1130 | resolve 1131 | }, reply) => { 1132 | if (extensionAPIs.runtime.lastError) { 1133 | // Detect when none of the listeners replied to the sendMessage call and resolve 1134 | // the promise to undefined as in Firefox. 1135 | // See https://github.com/mozilla/webextension-polyfill/issues/130 1136 | if (extensionAPIs.runtime.lastError.message === CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE) { 1137 | resolve(); 1138 | } else { 1139 | reject(extensionAPIs.runtime.lastError); 1140 | } 1141 | } else if (reply && reply.__mozWebExtensionPolyfillReject__) { 1142 | // Convert back the JSON representation of the error into 1143 | // an Error instance. 1144 | reject(new Error(reply.message)); 1145 | } else { 1146 | resolve(reply); 1147 | } 1148 | }; 1149 | 1150 | const wrappedSendMessage = (name, metadata, apiNamespaceObj, ...args) => { 1151 | if (args.length < metadata.minArgs) { 1152 | throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`); 1153 | } 1154 | 1155 | if (args.length > metadata.maxArgs) { 1156 | throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`); 1157 | } 1158 | 1159 | return new Promise((resolve, reject) => { 1160 | const wrappedCb = wrappedSendMessageCallback.bind(null, { 1161 | resolve, 1162 | reject 1163 | }); 1164 | args.push(wrappedCb); 1165 | apiNamespaceObj.sendMessage(...args); 1166 | }); 1167 | }; 1168 | 1169 | const staticWrappers = { 1170 | runtime: { 1171 | onMessage: wrapEvent(onMessageWrappers), 1172 | onMessageExternal: wrapEvent(onMessageWrappers), 1173 | sendMessage: wrappedSendMessage.bind(null, "sendMessage", { 1174 | minArgs: 1, 1175 | maxArgs: 3 1176 | }) 1177 | }, 1178 | tabs: { 1179 | sendMessage: wrappedSendMessage.bind(null, "sendMessage", { 1180 | minArgs: 2, 1181 | maxArgs: 3 1182 | }) 1183 | } 1184 | }; 1185 | const settingMetadata = { 1186 | clear: { 1187 | minArgs: 1, 1188 | maxArgs: 1 1189 | }, 1190 | get: { 1191 | minArgs: 1, 1192 | maxArgs: 1 1193 | }, 1194 | set: { 1195 | minArgs: 1, 1196 | maxArgs: 1 1197 | } 1198 | }; 1199 | apiMetadata.privacy = { 1200 | network: { 1201 | "*": settingMetadata 1202 | }, 1203 | services: { 1204 | "*": settingMetadata 1205 | }, 1206 | websites: { 1207 | "*": settingMetadata 1208 | } 1209 | }; 1210 | return wrapObject(extensionAPIs, staticWrappers, apiMetadata); 1211 | }; 1212 | 1213 | if (typeof chrome != "object" || !chrome || !chrome.runtime || !chrome.runtime.id) { 1214 | throw new Error("This script should only be loaded in a browser extension."); 1215 | } // The build process adds a UMD wrapper around this file, which makes the 1216 | // `module` variable available. 1217 | 1218 | 1219 | module.exports = wrapAPIs(chrome); 1220 | } else { 1221 | module.exports = browser; 1222 | } 1223 | }); 1224 | //# sourceMappingURL=browser-polyfill.js.map 1225 | --------------------------------------------------------------------------------