├── .gitattributes ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── USERGUIDE ├── bun.lock ├── images ├── btc-qr.png ├── btc.png ├── chevron.svg ├── cog.svg ├── download.svg ├── gumroad.png ├── gumroad.svg ├── logo.svg ├── me.jpg ├── open.svg └── times.svg ├── jest.config.js ├── lib ├── htm.js ├── jquery-3.5.1.min.js ├── nouislider.min.css ├── nouislider.min.js ├── react-18.3.1.min.js └── react-dom-18.3.1.min.js ├── manifest.json ├── options.png ├── package.json ├── scripts ├── build.js ├── config.js ├── task.js ├── tasks.js └── watch.js ├── src ├── Options │ ├── About.js │ ├── Options.js │ ├── Options.test.ts │ ├── Support.js │ └── index.html ├── Popup │ ├── AdvancedFilters.js │ ├── DownloadButton.js │ ├── DownloadConfirmation.js │ ├── ImageActions.js │ ├── Images.js │ ├── Popup.js │ ├── Popup.test.ts │ ├── UrlFilterMode.js │ ├── actions.js │ └── index.html ├── background │ └── serviceWorker.js ├── components │ ├── Checkbox.js │ └── ExternalLink.js ├── defaults.js ├── defaults.test.ts ├── hooks │ └── useRunAfterUpdate.js ├── html.js ├── test.ts └── utils.js └── stylesheets └── main.css /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: What seems to be the problem? 4 | title: '' 5 | labels: 'type: bug' 6 | assignees: vdsabev 7 | 8 | --- 9 | 10 | ## Setup 11 | - [ ] OS (for example macOS) 12 | - [ ] Browser version (for example Chrome 90.0.4430.93) 13 | - [ ] Extension version (for example 3.2.2) 14 | 15 | ## Describe the bug 16 | A short description of what the bug is. 17 | 18 | ## URL 19 | URL where you're experiencing the issue (for example https://www.reddit.com) 20 | 21 | ## Screenshots 22 | If applicable, add screenshots to help explain your problem. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for the extension 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the feature 11 | A short description of your idea. 12 | 13 | ## URL 14 | URL where you think this feature would be useful (for example https://www.reddit.com) 15 | 16 | ## Screenshots 17 | If applicable, add screenshots to help explain your idea. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | coverage 3 | node_modules 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | Based on GNU General Public License v3 3 | Copyright © 2012-2025 Vladimir Sabev 4 | 5 | This software is licensed under the GNU General Public License v3 (GPLv3) with an additional restriction on commercial resale and modification. The full GPLv3 text is available at: https://www.gnu.org/licenses/gpl-3.0.en.html. 6 | 7 | ## Summary of License Terms 8 | 9 | ### Permissions 10 | - You may freely **use** this software for any purpose, including personal or business operations. 11 | - You may **copy**, **modify**, and **distribute** the software, provided you comply with this license. 12 | - You may run the software and share it without charge, as long as the source code is included. 13 | 14 | ### Obligations 15 | If you distribute the software or its modifications, you must: 16 | - Include the **source code** (or a written offer to provide it). 17 | - License the software and any modifications under this same license (GPLv3). 18 | - Include a copy of this license and copyright notices. 19 | 20 | Modified versions must be clearly marked as changed and include the modification date. 21 | 22 | ### Additional Restriction on Commercial Resale and Modification 23 | While this software may be used for any purpose, including in commercial or business operations, **you may not distribute, sublicense, or sell the original software or any modified versions for commercial purposes** without explicit written permission from the copyright holder. 24 | 25 | Distribution of modified versions must comply with the GPLv3, including making the source code available under this license. 26 | 27 | ### No Warranty 28 | The software is provided “as is” without any warranty, express or implied. You assume all risks related to its use or performance. 29 | 30 | ### Termination 31 | Violating these terms (e.g., unauthorized commercial distribution) terminates your rights under this license. 32 | 33 | ## Full License 34 | This is a summary of key terms. The complete GNU General Public License v3, which governs this software along with the additional restriction above, is available at: https://www.gnu.org/licenses/gpl-3.0.en.html. By using, modifying, or distributing this software, you agree to both the GPLv3 and the additional restriction. 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Image Downloader logo 3 |  Image Downloader 4 |

5 | 6 |

7 | Browse and download images on the web 8 |
9 |
10 |

11 | 12 | Welcome! If you're here to learn more about how to use this extension check out the [User Guide](USERGUIDE) 13 | 14 | If you're a developer interested in running the extension locally instead of installing it from the Chrome Web Store - keep reading! 15 | 16 | ## Local development 17 | 1. First, install the dependencies: 18 | ```bash 19 | bun install 20 | ``` 21 | 2. Then you can start the development server which watches for file changes automatically: 22 | ```bash 23 | bun start 24 | ``` 25 | Or alternatively - only run the build once: 26 | ```bash 27 | bun run build 28 | ``` 29 | 3. Open the extension list in your browser settings: [chrome://extensions](chrome://extensions) 30 | 4. Enable **Developer mode** 31 | 5. Click the **Load unpacked** button, navigate to the extension root folder and pick the `build` folder 32 | 6. Enjoy! 33 | 34 | ## Test 35 | Run and watch tests related to locally changed files - useful during development: 36 | ```bash 37 | bun test 38 | ``` 39 | 40 | Or run all the tests without watching and generate a coverage report: 41 | ```bash 42 | bun run test.all 43 | ``` 44 | 45 | ## License 46 | See [LICENSE.md](LICENSE.md) 47 | -------------------------------------------------------------------------------- /USERGUIDE: -------------------------------------------------------------------------------- 1 | 🟦 Image Downloader 2 | ━━━━━━━━━━ 3 | To save multiple photos at once, this extension enables you to: 4 | - Browse pictures on the active webpage 5 | - Filter by size, dimensions, or URL 6 | - Download or view any single photo with one click 7 | - Save to a subfolder 8 | - Rename downloaded files 9 | - Download in the background! 10 | 11 | Note: If no default download folder is set, you’ll need to choose a save location for each photo, which may trigger multiple popups. Configure a download directory in your browser settings for a smoother experience. 12 | 13 | ❓ FAQs 14 | ━━━━ 15 | 💭 Why does this extension need access to all site data? 16 | To extract photos from a webpage, Image Downloader must access all content when the popup is activated. No data is sent off your device. See our Privacy Policy for details: https://pactinteractive.github.io/image-downloader 17 | 18 | 💭 Why were some downloaded photos smaller or missing? 19 | Image Downloader only retrieves photos currently displayed on the page, which may be thumbnails rather than full-resolution versions (e.g., in Facebook albums). Some sites, like Instagram, load only a few pictures at a time in carousels to save bandwidth. 20 | 21 | We’re exploring ways to improve Image Downloader’s capabilities. For now, consider using extensions tailored to specific sites (e.g., Facebook) for advanced functionality. 22 | -------------------------------------------------------------------------------- /images/btc-qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/images/btc-qr.png -------------------------------------------------------------------------------- /images/btc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/images/btc.png -------------------------------------------------------------------------------- /images/chevron.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /images/cog.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /images/download.svg: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/gumroad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/images/gumroad.png -------------------------------------------------------------------------------- /images/gumroad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | 23 | 24 | -------------------------------------------------------------------------------- /images/me.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/images/me.jpg -------------------------------------------------------------------------------- /images/open.svg: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /images/times.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // The directory where Jest should store its cached dependency information 12 | // cacheDirectory: "C:\\Users\\vdsab\\AppData\\Local\\Temp\\jest", 13 | 14 | // Automatically clear mock calls and instances between every test 15 | clearMocks: true, 16 | 17 | // Indicates whether the coverage information should be collected while executing the test 18 | // collectCoverage: false, 19 | 20 | // An array of glob patterns indicating a set of files for which coverage information should be collected 21 | // collectCoverageFrom: undefined, 22 | 23 | // The directory where Jest should output its coverage files 24 | coverageDirectory: 'coverage', 25 | 26 | // An array of regexp pattern strings used to skip coverage collection 27 | coveragePathIgnorePatterns: [ 28 | 'build/', 29 | 'lib/', 30 | 'node_modules/', 31 | ], 32 | 33 | // A list of reporter names that Jest uses when writing coverage reports 34 | // coverageReporters: [ 35 | // "json", 36 | // "text", 37 | // "lcov", 38 | // "clover" 39 | // ], 40 | 41 | // An object that configures minimum threshold enforcement for coverage results 42 | // coverageThreshold: undefined, 43 | 44 | // A path to a custom dependency extractor 45 | // dependencyExtractor: undefined, 46 | 47 | // Make calling deprecated APIs throw helpful error messages 48 | // errorOnDeprecated: false, 49 | 50 | // Force coverage collection from ignored files using an array of glob patterns 51 | // forceCoverageMatch: [], 52 | 53 | // A path to a module which exports an async function that is triggered once before all test suites 54 | // globalSetup: undefined, 55 | 56 | // A path to a module which exports an async function that is triggered once after all test suites 57 | // globalTeardown: undefined, 58 | 59 | // A set of global variables that need to be available in all test environments 60 | // globals: {}, 61 | 62 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 63 | // maxWorkers: "50%", 64 | 65 | // An array of directory names to be searched recursively up from the requiring module's location 66 | // moduleDirectories: [ 67 | // "node_modules" 68 | // ], 69 | 70 | // An array of file extensions your modules use 71 | // moduleFileExtensions: [ 72 | // "js", 73 | // "json", 74 | // "jsx", 75 | // "ts", 76 | // "tsx", 77 | // "node" 78 | // ], 79 | 80 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 81 | // moduleNameMapper: {}, 82 | 83 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 84 | // modulePathIgnorePatterns: [], 85 | 86 | // Activates notifications for test results 87 | // notify: false, 88 | 89 | // An enum that specifies notification mode. Requires { notify: true } 90 | // notifyMode: "failure-change", 91 | 92 | // A preset that is used as a base for Jest's configuration 93 | preset: 'ts-jest', 94 | 95 | // Run tests from one or more projects 96 | // projects: undefined, 97 | 98 | // Use this configuration option to add custom reporters to Jest 99 | // reporters: undefined, 100 | 101 | // Automatically reset mock state between every test 102 | // resetMocks: false, 103 | 104 | // Reset the module registry before running each individual test 105 | resetModules: true, 106 | 107 | // A path to a custom resolver 108 | // resolver: undefined, 109 | 110 | // Automatically restore mock state between every test 111 | // restoreMocks: false, 112 | 113 | // The root directory that Jest should scan for tests and modules within 114 | // rootDir: undefined, 115 | 116 | // A list of paths to directories that Jest should use to search for files in 117 | // roots: [ 118 | // "" 119 | // ], 120 | 121 | // Allows you to use a custom runner instead of Jest's default test runner 122 | // runner: "jest-runner", 123 | 124 | // The paths to modules that run some code to configure or set up the testing environment before each test 125 | // setupFiles: [], 126 | 127 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 128 | // setupFilesAfterEnv: [], 129 | 130 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 131 | // snapshotSerializers: [], 132 | 133 | // The test environment that will be used for testing 134 | testEnvironment: 'jsdom', 135 | 136 | // Options that will be passed to the testEnvironment 137 | // testEnvironmentOptions: {}, 138 | 139 | // Adds a location field to test results 140 | // testLocationInResults: false, 141 | 142 | // The glob patterns Jest uses to detect test files 143 | testMatch: [ 144 | '/src/**/__tests__/**/*.[jt]s?(x)', 145 | '/src/**/?(*.)+(spec|test).[tj]s?(x)', 146 | ], 147 | 148 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 149 | // testPathIgnorePatterns: [ 150 | // "\\\\node_modules\\\\" 151 | // ], 152 | 153 | // The regexp pattern or array of patterns that Jest uses to detect test files 154 | // testRegex: [], 155 | 156 | // This option allows the use of a custom results processor 157 | // testResultsProcessor: undefined, 158 | 159 | // This option allows use of a custom test runner 160 | // testRunner: "jasmine2", 161 | 162 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 163 | // testURL: "http://localhost", 164 | 165 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 166 | // timers: "real", 167 | 168 | // A map from regular expressions to paths to transformers 169 | transform: { 170 | '^.+\\.js$': '/node_modules/babel-jest', 171 | }, 172 | 173 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 174 | // transformIgnorePatterns: [ 175 | // "\\\\node_modules\\\\" 176 | // ], 177 | 178 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 179 | // unmockedModulePathPatterns: undefined, 180 | 181 | // Indicates whether each individual test should be reported during the run 182 | // verbose: undefined, 183 | 184 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 185 | // watchPathIgnorePatterns: [], 186 | 187 | // Whether to use watchman for file crawling 188 | // watchman: true, 189 | }; 190 | -------------------------------------------------------------------------------- /lib/htm.js: -------------------------------------------------------------------------------- 1 | var n = function (t, s, r, e) { 2 | var u; 3 | s[0] = 0; 4 | for (var h = 1; h < s.length; h++) { 5 | var p = s[h++], 6 | a = s[h] ? ((s[0] |= p ? 1 : 2), r[s[h++]]) : s[++h]; 7 | 3 === p 8 | ? (e[0] = a) 9 | : 4 === p 10 | ? (e[1] = Object.assign(e[1] || {}, a)) 11 | : 5 === p 12 | ? ((e[1] = e[1] || {})[s[++h]] = a) 13 | : 6 === p 14 | ? (e[1][s[++h]] += a + '') 15 | : p 16 | ? ((u = t.apply(a, n(t, a, r, ['', null]))), 17 | e.push(u), 18 | a[0] ? (s[0] |= 2) : ((s[h - 2] = 0), (s[h] = u))) 19 | : e.push(a); 20 | } 21 | return e; 22 | }, 23 | t = new Map(); 24 | export default function (s) { 25 | var r = t.get(this); 26 | return ( 27 | r || ((r = new Map()), t.set(this, r)), 28 | (r = n( 29 | this, 30 | r.get(s) || 31 | (r.set( 32 | s, 33 | (r = (function (n) { 34 | for ( 35 | var t, 36 | s, 37 | r = 1, 38 | e = '', 39 | u = '', 40 | h = [0], 41 | p = function (n) { 42 | 1 === r && (n || (e = e.replace(/^\s*\n\s*|\s*\n\s*$/g, ''))) 43 | ? h.push(0, n, e) 44 | : 3 === r && (n || e) 45 | ? (h.push(3, n, e), (r = 2)) 46 | : 2 === r && '...' === e && n 47 | ? h.push(4, n, 0) 48 | : 2 === r && e && !n 49 | ? h.push(5, 0, !0, e) 50 | : r >= 5 && 51 | ((e || (!n && 5 === r)) && 52 | (h.push(r, 0, e, s), (r = 6)), 53 | n && (h.push(r, n, 0, s), (r = 6))), 54 | (e = ''); 55 | }, 56 | a = 0; 57 | a < n.length; 58 | a++ 59 | ) { 60 | a && (1 === r && p(), p(a)); 61 | for (var l = 0; l < n[a].length; l++) 62 | (t = n[a][l]), 63 | 1 === r 64 | ? '<' === t 65 | ? (p(), (h = [h]), (r = 3)) 66 | : (e += t) 67 | : 4 === r 68 | ? '--' === e && '>' === t 69 | ? ((r = 1), (e = '')) 70 | : (e = t + e[0]) 71 | : u 72 | ? t === u 73 | ? (u = '') 74 | : (e += t) 75 | : '"' === t || "'" === t 76 | ? (u = t) 77 | : '>' === t 78 | ? (p(), (r = 1)) 79 | : r && 80 | ('=' === t 81 | ? ((r = 5), (s = e), (e = '')) 82 | : '/' === t && (r < 5 || '>' === n[a][l + 1]) 83 | ? (p(), 84 | 3 === r && (h = h[0]), 85 | (r = h), 86 | (h = h[0]).push(2, 0, r), 87 | (r = 0)) 88 | : ' ' === t || 89 | '\t' === t || 90 | '\n' === t || 91 | '\r' === t 92 | ? (p(), (r = 2)) 93 | : (e += t)), 94 | 3 === r && '!--' === e && ((r = 4), (h = h[0])); 95 | } 96 | return p(), h; 97 | })(s)), 98 | ), 99 | r), 100 | arguments, 101 | [], 102 | )).length > 1 103 | ? r 104 | : r[0] 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /lib/nouislider.min.css: -------------------------------------------------------------------------------- 1 | /*! nouislider - 14.7.0 - 4/6/2021 */ 2 | .noUi-target,.noUi-target *{-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;-ms-touch-action:none;touch-action:none;-ms-user-select:none;-moz-user-select:none;user-select:none;-moz-box-sizing:border-box;box-sizing:border-box}.noUi-target{position:relative}.noUi-base,.noUi-connects{width:100%;height:100%;position:relative;z-index:1}.noUi-connects{overflow:hidden;z-index:0}.noUi-connect,.noUi-origin{will-change:transform;position:absolute;z-index:1;top:0;right:0;-ms-transform-origin:0 0;-webkit-transform-origin:0 0;-webkit-transform-style:preserve-3d;transform-origin:0 0;transform-style:flat}.noUi-connect{height:100%;width:100%}.noUi-origin{height:10%;width:10%}.noUi-txt-dir-rtl.noUi-horizontal .noUi-origin{left:0;right:auto}.noUi-vertical .noUi-origin{width:0}.noUi-horizontal .noUi-origin{height:0}.noUi-handle{-webkit-backface-visibility:hidden;backface-visibility:hidden;position:absolute}.noUi-touch-area{height:100%;width:100%}.noUi-state-tap .noUi-connect,.noUi-state-tap .noUi-origin{-webkit-transition:transform .3s;transition:transform .3s}.noUi-state-drag *{cursor:inherit!important}.noUi-horizontal{height:18px}.noUi-horizontal .noUi-handle{width:34px;height:28px;right:-17px;top:-6px}.noUi-vertical{width:18px}.noUi-vertical .noUi-handle{width:28px;height:34px;right:-6px;top:-17px}.noUi-txt-dir-rtl.noUi-horizontal .noUi-handle{left:-17px;right:auto}.noUi-target{background:#FAFAFA;border-radius:4px;border:1px solid #D3D3D3;box-shadow:inset 0 1px 1px #F0F0F0,0 3px 6px -5px #BBB}.noUi-connects{border-radius:3px}.noUi-connect{background:#3FB8AF}.noUi-draggable{cursor:ew-resize}.noUi-vertical .noUi-draggable{cursor:ns-resize}.noUi-handle{border:1px solid #D9D9D9;border-radius:3px;background:#FFF;cursor:default;box-shadow:inset 0 0 1px #FFF,inset 0 1px 7px #EBEBEB,0 3px 6px -3px #BBB}.noUi-active{box-shadow:inset 0 0 1px #FFF,inset 0 1px 7px #DDD,0 3px 6px -3px #BBB}.noUi-handle:after,.noUi-handle:before{content:"";display:block;position:absolute;height:14px;width:1px;background:#E8E7E6;left:14px;top:6px}.noUi-handle:after{left:17px}.noUi-vertical .noUi-handle:after,.noUi-vertical .noUi-handle:before{width:14px;height:1px;left:6px;top:14px}.noUi-vertical .noUi-handle:after{top:17px}[disabled] .noUi-connect{background:#B8B8B8}[disabled] .noUi-handle,[disabled].noUi-handle,[disabled].noUi-target{cursor:not-allowed}.noUi-pips,.noUi-pips *{-moz-box-sizing:border-box;box-sizing:border-box}.noUi-pips{position:absolute;color:#999}.noUi-value{position:absolute;white-space:nowrap;text-align:center}.noUi-value-sub{color:#ccc;font-size:10px}.noUi-marker{position:absolute;background:#CCC}.noUi-marker-sub{background:#AAA}.noUi-marker-large{background:#AAA}.noUi-pips-horizontal{padding:10px 0;height:80px;top:100%;left:0;width:100%}.noUi-value-horizontal{-webkit-transform:translate(-50%,50%);transform:translate(-50%,50%)}.noUi-rtl .noUi-value-horizontal{-webkit-transform:translate(50%,50%);transform:translate(50%,50%)}.noUi-marker-horizontal.noUi-marker{margin-left:-1px;width:2px;height:5px}.noUi-marker-horizontal.noUi-marker-sub{height:10px}.noUi-marker-horizontal.noUi-marker-large{height:15px}.noUi-pips-vertical{padding:0 10px;height:100%;top:0;left:100%}.noUi-value-vertical{-webkit-transform:translate(0,-50%);transform:translate(0,-50%);padding-left:25px}.noUi-rtl .noUi-value-vertical{-webkit-transform:translate(0,50%);transform:translate(0,50%)}.noUi-marker-vertical.noUi-marker{width:5px;height:2px;margin-top:-1px}.noUi-marker-vertical.noUi-marker-sub{width:10px}.noUi-marker-vertical.noUi-marker-large{width:15px}.noUi-tooltip{display:block;position:absolute;border:1px solid #D9D9D9;border-radius:3px;background:#fff;color:#000;padding:5px;text-align:center;white-space:nowrap}.noUi-horizontal .noUi-tooltip{-webkit-transform:translate(-50%,0);transform:translate(-50%,0);left:50%;bottom:120%}.noUi-vertical .noUi-tooltip{-webkit-transform:translate(0,-50%);transform:translate(0,-50%);top:50%;right:120%}.noUi-horizontal .noUi-origin>.noUi-tooltip{-webkit-transform:translate(50%,0);transform:translate(50%,0);left:auto;bottom:10px}.noUi-vertical .noUi-origin>.noUi-tooltip{-webkit-transform:translate(0,-18px);transform:translate(0,-18px);top:auto;right:28px} -------------------------------------------------------------------------------- /lib/nouislider.min.js: -------------------------------------------------------------------------------- 1 | /*! nouislider - 14.7.0 - 4/6/2021 */ 2 | !function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?module.exports=t():window.noUiSlider=t()}(function(){"use strict";var lt="14.7.0";function ut(t){t.parentElement.removeChild(t)}function ct(t){return null!=t}function pt(t){t.preventDefault()}function o(t){return"number"==typeof t&&!isNaN(t)&&isFinite(t)}function ft(t,e,r){0=e[r];)r+=1;return r}function r(t,e,r){if(r>=t.slice(-1)[0])return 100;var n,i,o=f(r,t),s=t[o-1],a=t[o],l=e[o-1],u=e[o];return l+(i=r,p(n=[s,a],n[0]<0?i+Math.abs(n[0]):i-n[0],0)/c(l,u))}function n(t,e,r,n){if(100===n)return n;var i,o,s=f(n,t),a=t[s-1],l=t[s];return r?(l-a)/2this.xPct[i+1];)i++;else t===this.xPct[this.xPct.length-1]&&(i=this.xPct.length-2);r||t!==this.xPct[i+1]||i++;var o=1,s=e[i],a=0,l=0,u=0,c=0;for(n=r?(t-this.xPct[i])/(this.xPct[i+1]-this.xPct[i]):(this.xPct[i+1]-t)/(this.xPct[i+1]-this.xPct[i]);0= 2) required for mode 'count'.");var n=e-1,i=100/n;for(e=[];n--;)e[n]=n*i;e.push(100),t="positions"}return"positions"===t?e.map(function(t){return y.fromStepping(r?y.getStep(t):t)}):"values"===t?r?e.map(function(t){return y.fromStepping(y.getStep(y.toStepping(t)))}):e:void 0}(n,t.values||!1,t.stepped||!1),a=(m=i,g=n,v=s,b={},e=y.xVal[0],r=y.xVal[y.xVal.length-1],S=x=!1,w=0,(v=v.slice().sort(function(t,e){return t-e}).filter(function(t){return!this[t]&&(this[t]=!0)},{}))[0]!==e&&(v.unshift(e),x=!0),v[v.length-1]!==r&&(v.push(r),S=!0),v.forEach(function(t,e){var r,n,i,o,s,a,l,u,c,p,f=t,d=v[e+1],h="steps"===g;if(h&&(r=y.xNumSteps[e]),r||(r=d-f),!1!==f)for(void 0===d&&(d=f),r=Math.max(r,1e-7),n=f;n<=d;n=(n+r).toFixed(7)/1){for(u=(s=(o=y.toStepping(n))-w)/m,p=s/(c=Math.round(u)),i=1;i<=c;i+=1)b[(a=w+i*p).toFixed(5)]=[y.fromStepping(a),0];l=-1r.stepAfter.startValue&&(i=r.stepAfter.startValue-n),o=n>r.thisStep.startValue?r.thisStep.step:!1!==r.stepBefore.step&&n-r.stepBefore.highestStep,100===e?i=null:0===e&&(o=null);var s=y.countStepDecimals();return null!==i&&!1!==i&&(i=Number(i.toFixed(s))),null!==o&&!1!==o&&(o=Number(o.toFixed(s))),[o,i]}return mt(e=h,b.cssClasses.target),0===b.dir?mt(e,b.cssClasses.ltr):mt(e,b.cssClasses.rtl),0===b.ort?mt(e,b.cssClasses.horizontal):mt(e,b.cssClasses.vertical),mt(e,"rtl"===getComputedStyle(e).direction?b.cssClasses.textDirectionRtl:b.cssClasses.textDirectionLtr),l=V(e,b.cssClasses.base),function(t,e){var r=V(e,b.cssClasses.connects);u=[],(s=[]).push(M(r,t[0]));for(var n=0;n>>1,d=a[c];if(0>>1;cD(l,e))fD(g,l)?(a[c]=g,a[f]=e,c=f):(a[c]=l,a[h]=e,c=h);else if(fD(g,e))a[c]=g,a[f]=e,c=f;else break a}}return b} 16 | function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)E(r);else if(b.startTime<=a)E(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}}function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,R(S);else{var b=p(r);null!==b&&T(Q,b.startTime-a)}}function S(a,b){u=!1;z&&(z=!1,ea(A),A=-1);F=!0;var c=k;try{P(b);for(n=p(q);null!==n&&(!(n.expirationTime>b)||a&&!fa());){var m=n.callback;if("function"===typeof m){n.callback=null; 17 | k=n.priorityLevel;var d=m(n.expirationTime<=b);b=v();"function"===typeof d?n.callback=d:n===p(q)&&E(q);P(b)}else E(q);n=p(q)}if(null!==n)var g=!0;else{var h=p(r);null!==h&&T(Q,h.startTime-b);g=!1}return g}finally{n=null,k=c,F=!1}}function fa(){return v()-hae?(a.sortIndex=c,O(r,a),null===p(q)&&a===p(r)&&(z?(ea(A),A=-1):z=!0,T(Q,c-e))):(a.sortIndex=d,O(q,a),u||F||(u=!0,R(S)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b= 24 | k;return function(){var c=k;k=b;try{return a.apply(this,arguments)}finally{k=c}}},unstable_getCurrentPriorityLevel:function(){return k},unstable_shouldYield:fa,unstable_requestPaint:function(){},unstable_continueExecution:function(){u||F||(u=!0,R(S))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)},get unstable_now(){return v},unstable_forceFrameRate:function(a){0>a||125" 28 | ] 29 | } 30 | ], 31 | "permissions": [ 32 | "activeTab", 33 | "scripting", 34 | "downloads" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/options.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "image-downloader", 4 | "version": "4.0.2", 5 | "license": "GPLv3", 6 | "scripts": { 7 | "dev": "bun start", 8 | "start": "bun run build && bun scripts/watch.js", 9 | "build": "bun scripts/build.js && bun run build.zip", 10 | "build.zip": "bun -e \"await Bun.file('../image-downloader.zip').delete().catch(() => {}); import { zip } from 'cross-zip'; await zip('build', '../image-downloader.zip');\"", 11 | "task": "bun scripts/task.js", 12 | "test": "jest --watch", 13 | "test.all": "jest --coverage" 14 | }, 15 | "devDependencies": { 16 | "@types/chrome": "0.0.315", 17 | "@types/jest": "26.0.20", 18 | "@types/jquery": "3.5.1", 19 | "cross-zip": "4.0.1", 20 | "fs-extra": "9.0.1", 21 | "glob": "7.1.6", 22 | "glob-watcher": "5.0.5", 23 | "jest": "26.6.3", 24 | "prettier": "3.5.3", 25 | "react": "18.3.1", 26 | "react-dom": "18.3.1", 27 | "sharp": "0.34.1", 28 | "sneer": "1.0.1", 29 | "ts-jest": "26.1.4", 30 | "typescript": "5.8.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | import { sync } from 'glob'; 2 | 3 | import { filesToCopy } from './config'; 4 | import { clean, copyFile, updateManifest } from './tasks'; 5 | 6 | async function build() { 7 | await clean(); 8 | await updateManifest(); 9 | await Promise.all( 10 | filesToCopy 11 | .map((filePattern) => sync(filePattern)) 12 | .reduce((parent, child) => [...parent, ...child], []) 13 | .map(copyFile) 14 | .map((promise) => 15 | promise.catch((error) => { 16 | if (error.code === 'EEXIST') { 17 | // Ignore already existing file error 18 | } else { 19 | throw error; 20 | } 21 | }), 22 | ), 23 | ); 24 | } 25 | 26 | build(); 27 | -------------------------------------------------------------------------------- /scripts/config.js: -------------------------------------------------------------------------------- 1 | const packagePath = './package.json'; 2 | const manifestPath = './manifest.json'; 3 | 4 | module.exports = { 5 | outputDirectory: 'build', 6 | icons: { 7 | inputSvg: './images/logo.svg', 8 | outputDirectory: 'images', 9 | prefix: 'icon_', 10 | sizes: [16, 32, 48, 128], 11 | }, 12 | filesToCopy: [ 13 | manifestPath, 14 | './images/**/*', 15 | './lib/**/*', 16 | './src/**/!(test.ts|*.test.ts)', 17 | './stylesheets/**/*', 18 | ], 19 | paths: { 20 | package: packagePath, 21 | manifest: manifestPath, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /scripts/task.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Usage: 3 | * ```sh 4 | * node scripts/task.js clean 5 | * ``` 6 | */ 7 | 8 | import tasks from './tasks'; 9 | 10 | const [taskName, ...args] = process.argv.slice(2); 11 | if (!tasks[taskName]) { 12 | const taskNameList = Object.keys(tasks) 13 | .map((task) => `- ${task}`) 14 | .join('\n'); 15 | console.error( 16 | `Unknown task ${taskName} - did you mean to use one of the following:\n${taskNameList}` 17 | ); 18 | } else { 19 | tasks[taskName](...args); 20 | } 21 | -------------------------------------------------------------------------------- /scripts/tasks.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import { join, normalize } from 'path'; 3 | import sharp from 'sharp'; 4 | 5 | import * as config from './config'; 6 | 7 | export async function clean() { 8 | await fs.emptyDir(config.outputDirectory); 9 | } 10 | 11 | export async function updateManifest() { 12 | const [packageJson, manifestJson] = await Promise.all([ 13 | fs.readJson(config.paths.package), 14 | fs.readJson(config.paths.manifest), 15 | ]); 16 | 17 | const icons = await generateIcons(config.icons); 18 | 19 | await fs.writeJson( 20 | config.paths.manifest, 21 | { ...manifestJson, version: packageJson.version, icons }, 22 | { spaces: 2 }, 23 | ); 24 | 25 | return normalize(config.paths.manifest); 26 | } 27 | 28 | export async function copyFile(path) { 29 | await fs.copy(path, join(config.outputDirectory, path), { recursive: true }); 30 | } 31 | 32 | export async function removeFile(path) { 33 | await fs.remove(join(config.outputDirectory, path)); 34 | } 35 | 36 | // See https://wxt.dev/api/config.html 37 | async function generateIcons({ inputSvg, outputDirectory, prefix, sizes }) { 38 | try { 39 | const icons = {}; 40 | 41 | // Ensure output directory exists 42 | await fs.mkdir(join(config.outputDirectory, outputDirectory), { 43 | recursive: true, 44 | }); 45 | 46 | // Check if input SVG exists 47 | try { 48 | await fs.access(inputSvg); 49 | } catch { 50 | throw new Error(`Input SVG file (${inputSvg}) not found.`); 51 | } 52 | 53 | // Generate PNGs for each size 54 | for (const size of sizes) { 55 | const outputPath = join( 56 | config.outputDirectory, 57 | outputDirectory, 58 | `${prefix}${size}.png`, 59 | ); 60 | await sharp(inputSvg) 61 | .resize(size, size, { 62 | fit: 'contain', 63 | background: { r: 0, g: 0, b: 0, alpha: 0 }, 64 | }) 65 | .png() 66 | .toFile(outputPath); 67 | console.log(`Generated ${outputPath}`); 68 | 69 | icons[`${size}`] = `/${outputDirectory}/${prefix}${size}.png`; 70 | } 71 | 72 | return icons; 73 | } catch (error) { 74 | console.error('Error generating icons:', error); 75 | process.exit(1); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /scripts/watch.js: -------------------------------------------------------------------------------- 1 | import watch from 'glob-watcher'; 2 | 3 | import { filesToCopy, paths } from './config'; 4 | import { updateManifest, copyFile, removeFile } from './tasks'; 5 | 6 | const logAndExecute = 7 | (message, fn) => 8 | async (path, ...args) => { 9 | const result = await fn(path, ...args); 10 | console.log( 11 | `[${new Date().toLocaleTimeString()}]`, 12 | message, 13 | result || path, 14 | ); 15 | }; 16 | 17 | watch(paths.package).on('change', updateManifest); 18 | 19 | watch(filesToCopy) 20 | .on('add', logAndExecute('Add', copyFile)) 21 | .on('change', logAndExecute('Update', copyFile)) 22 | .on('unlink', logAndExecute('Remove', removeFile)); 23 | -------------------------------------------------------------------------------- /src/Options/About.js: -------------------------------------------------------------------------------- 1 | import html from '../html.js'; 2 | import { ExternalLink } from '../components/ExternalLink.js'; 3 | 4 | const numberOfActiveUsers = '1,500,000+'; 5 | const years = new Date().getFullYear() - 2012; 6 | 7 | export const About = () => html` 8 | 9 | 19 | Made by @vdsabev 20 |
21 | (🐕 with Ruby's help) 22 |
23 | 24 |

25 | If you're one of the ${numberOfActiveUsers} people using this extension, 26 | over the past ${years} years marketing companies have approached me with 27 | offers to pay in exchange for your private data like: 28 |

29 | 30 |
    31 |
  • what websites you visit
  • 32 |
  • when you visit them
  • 33 |
  • where you visit them from
  • 34 |
35 | 36 |

37 | My response to such offers will always be a resounding NO! 38 |

39 | 40 |

41 | The extension will remain free, open-source, and 42 | without targeted ads or tracking algorithms. 43 | The source code can be found on GitHub:${' '} 44 | <${ExternalLink} 45 | href="https://github.com/PactInteractive/image-downloader" 46 | /> 47 |

48 | `; 49 | -------------------------------------------------------------------------------- /src/Options/Options.js: -------------------------------------------------------------------------------- 1 | import html, { render, useState } from '../html.js'; 2 | 3 | import { Checkbox } from '../components/Checkbox.js'; 4 | import { isNotStrictEqual } from '../utils.js'; 5 | 6 | import { About } from './About.js'; 7 | import { Support } from './Support.js'; 8 | 9 | const initialOptions = Object.keys(localStorage) 10 | .filter((key) => !key.endsWith('_default')) 11 | .reduce((options, key) => ({ ...options, [key]: localStorage[key] }), {}); 12 | 13 | const defaultOptions = Object.keys(localStorage) 14 | .filter((key) => key.endsWith('_default')) 15 | .reduce( 16 | (options, key) => ({ 17 | ...options, 18 | [key.replace('_default', '')]: localStorage[key], 19 | }), 20 | {} 21 | ); 22 | 23 | const useNotifications = (initialNotifications = []) => { 24 | const [notifications, setNotifications] = useState(initialNotifications); 25 | 26 | function addNotification(type, message) { 27 | setNotifications((notifications) => { 28 | const notification = { message, type }; 29 | const removeNotificationAfterMs = 10_000; 30 | 31 | setTimeout(() => { 32 | setNotifications((notifications) => 33 | notifications.filter(isNotStrictEqual(notification)) 34 | ); 35 | }, removeNotificationAfterMs); 36 | 37 | return [notification, ...notifications]; 38 | }); 39 | } 40 | 41 | return { notifications, addNotification }; 42 | }; 43 | 44 | const Options = () => { 45 | const [options, setOptions] = useState(initialOptions); 46 | 47 | const setCheckboxOption = (key) => ({ currentTarget: { checked } }) => { 48 | setOptions((options) => ({ ...options, [key]: checked.toString() })); 49 | }; 50 | 51 | const setValueOption = (key) => ({ currentTarget: { value } }) => { 52 | setOptions((state) => ({ ...state, [key]: value })); 53 | }; 54 | 55 | function saveOptions() { 56 | Object.assign(localStorage, options); 57 | addNotification('success', 'Options saved'); 58 | } 59 | 60 | function resetOptions() { 61 | setOptions(defaultOptions); 62 | addNotification( 63 | 'accent', 64 | 'All options have been reset to their default values. You can now save the changes you made or discard them by closing this page.' 65 | ); 66 | } 67 | 68 | function clearData() { 69 | const userHasConfirmed = window.confirm( 70 | 'This will delete all extension data related to filters, options, and the name of the default folder where files are saved. Continue?' 71 | ); 72 | if (userHasConfirmed) { 73 | localStorage.clear(); 74 | window.location.reload(); 75 | } 76 | } 77 | 78 | const { notifications, addNotification } = useNotifications(); 79 | 80 | return html` 81 |

82 | 83 | Image Downloader 84 | v${chrome.runtime.getManifest().version} 85 |

86 | 87 |
88 | 💭 About 89 | <${About} /> 90 |
91 | 92 |
93 | 🪙 Support 94 | <${Support} /> 95 |
96 | 97 |
98 | ⚙️ General options 99 | 100 | <${Checkbox} 101 | id="show_download_confirmation_checkbox" 102 | title="Requires confirmation when you press the Download button" 103 | checked="${options.show_download_confirmation === 'true'}" 104 | onChange=${setCheckboxOption('show_download_confirmation')} 105 | > 106 | Show download confirmation 107 | 108 | 109 |
110 | <${Checkbox} 111 | id="show_file_renaming_checkbox" 112 | title="Lets you specify a new file name for downloaded files" 113 | checked="${options.show_file_renaming === 'true'}" 114 | onChange=${setCheckboxOption('show_file_renaming')} 115 | > 116 | Show file renaming textbox 117 | 118 |
119 | 120 |
121 | 🖼️ Image options 122 | 123 | <${Checkbox} 124 | id="show_image_url_checkbox" 125 | title="Displays the URL above each image" 126 | checked="${options.show_image_url === 'true'}" 127 | onChange=${setCheckboxOption('show_image_url')} 128 | > 129 | Show the URL on hover 130 | 131 | 132 |
133 | <${Checkbox} 134 | id="show_open_image_button_checkbox" 135 | title="Displays a button next to each image to open it in a new tab" 136 | checked="${options.show_open_image_button === 'true'}" 137 | onChange=${setCheckboxOption('show_open_image_button')} 138 | > 139 | Show the Open button on hover 140 | 141 | 142 |
143 | <${Checkbox} 144 | id="show_download_image_button_checkbox" 145 | title="Displays a button next to each image to individually download it. This download does not require confirmation, even if you've enabled the confirmation option." 146 | checked="${options.show_download_image_button === 'true'}" 147 | onChange=${setCheckboxOption('show_download_image_button')} 148 | > 149 | Show the Download button on hover 150 | 151 | 152 | 153 | 154 | 155 | 166 | 167 | 168 | 171 | 176 | 187 | 188 | 189 | 192 | 197 | 208 | 209 |
156 | 165 |
172 | 175 | 177 | px 186 |
193 | 196 | 198 | px 207 |
210 |
211 | 212 |
213 | 221 | 222 | 231 | 232 | 240 |
241 | 242 |
243 | ${notifications.map( 244 | (notification) => html` 245 |
246 | ${notification.message} 247 |
248 | ` 249 | )} 250 |
251 | `; 252 | }; 253 | 254 | render(html`<${Options} />`, document.querySelector('main')); 255 | -------------------------------------------------------------------------------- /src/Options/Options.test.ts: -------------------------------------------------------------------------------- 1 | import { mockChrome } from '../test'; 2 | 3 | declare var global: any; 4 | 5 | beforeEach(() => { 6 | localStorage.clear(); 7 | global.$ = require('../../lib/jquery-3.5.1.min'); 8 | ($.fn as any).fadeIn = function (duration, fn) { 9 | setTimeout(duration, fn); 10 | return this; 11 | }; 12 | ($.fn as any).fadeOut = function (duration, fn) { 13 | setTimeout(duration, fn); 14 | return this; 15 | }; 16 | document.body.innerHTML = '
'; 17 | }); 18 | 19 | const checkboxOptions = { 20 | prop: 'checked', 21 | values: [true, false], 22 | trigger($el: JQuery, value: any) { 23 | $el.prop('checked', !value).trigger('click'); 24 | }, 25 | }; 26 | 27 | const inputOptions = { 28 | prop: 'value', 29 | trigger($el: JQuery, value: any) { 30 | $el.val(value); 31 | $el[0].dispatchEvent(new CustomEvent('change')); 32 | }, 33 | }; 34 | 35 | const options = [ 36 | { 37 | input: '#show_download_confirmation_checkbox', 38 | key: 'show_download_confirmation', 39 | ...checkboxOptions, 40 | }, 41 | { 42 | input: '#show_file_renaming_checkbox', 43 | key: 'show_file_renaming', 44 | ...checkboxOptions, 45 | }, 46 | { 47 | input: '#show_image_url_checkbox', 48 | key: 'show_image_url', 49 | ...checkboxOptions, 50 | }, 51 | { 52 | input: '#show_open_image_button_checkbox', 53 | key: 'show_open_image_button', 54 | ...checkboxOptions, 55 | }, 56 | { 57 | input: '#show_download_image_button_checkbox', 58 | key: 'show_download_image_button', 59 | ...checkboxOptions, 60 | }, 61 | { 62 | input: '#columns_numberbox', 63 | key: 'columns', 64 | values: ['1', '2', '3'], 65 | ...inputOptions, 66 | }, 67 | { 68 | input: '#image_min_width_numberbox', 69 | key: 'image_min_width', 70 | values: ['100', '200', '300'], 71 | ...inputOptions, 72 | }, 73 | { 74 | input: '#image_max_width_numberbox', 75 | key: 'image_max_width', 76 | values: ['200', '400', '600'], 77 | ...inputOptions, 78 | }, 79 | ]; 80 | 81 | describe(`initialize control values`, () => { 82 | options.forEach((option) => { 83 | option.values.forEach((value) => { 84 | describe(option.key, () => { 85 | it(value.toString(), () => { 86 | localStorage[option.key] = value.toString(); 87 | require('./Options'); 88 | expect($(option.input).prop(option.prop)).toBe(value); 89 | }); 90 | }); 91 | }); 92 | }); 93 | }); 94 | 95 | describe(`save`, () => { 96 | options.forEach((option) => { 97 | describe(option.key, () => { 98 | option.values.forEach((value) => { 99 | it(value.toString(), () => { 100 | require('./Options'); 101 | 102 | option.trigger($(option.input), value); 103 | $('#save_button').trigger('click'); 104 | 105 | expect(localStorage[option.key]).toBe(value.toString()); 106 | }); 107 | }); 108 | }); 109 | }); 110 | }); 111 | 112 | describe(`reset`, () => { 113 | beforeEach(() => { 114 | global.chrome = mockChrome(); 115 | }); 116 | 117 | options.forEach((option) => { 118 | describe(option.key, () => { 119 | option.values.forEach((value) => { 120 | it(value.toString(), () => { 121 | require('../defaults'); 122 | require('./Options'); 123 | 124 | option.trigger($(option.input), value); 125 | $('#reset_button').trigger('click'); 126 | $('#save_button').trigger('click'); 127 | 128 | expect(localStorage[option.key]).toBe( 129 | localStorage[`${option.key}_default`] 130 | ); 131 | }); 132 | }); 133 | }); 134 | }); 135 | }); 136 | 137 | describe(`clear data`, () => { 138 | beforeEach(() => { 139 | global.chrome = mockChrome(); 140 | global.confirm = () => true; 141 | delete global.window.location; 142 | global.window.location = { reload() {} }; 143 | }); 144 | 145 | options.forEach((option) => { 146 | describe(option.key, () => { 147 | option.values.forEach((value) => { 148 | it(value.toString(), () => { 149 | require('../defaults'); 150 | require('./Options'); 151 | 152 | option.trigger($(option.input), value); 153 | $('#save_button').trigger('click'); 154 | $('#clear_data_button').trigger('click'); 155 | 156 | expect(localStorage[option.key]).toBe( 157 | localStorage[`${option.key}_default`] 158 | ); 159 | }); 160 | }); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/Options/Support.js: -------------------------------------------------------------------------------- 1 | import html from '../html.js'; 2 | import { ExternalLink } from '../components/ExternalLink.js'; 3 | 4 | export const Support = () => html` 5 |

6 | Try some of our other projects or support Image Downloader directly by 7 | donating on Gumroad: 8 |

9 | 10 |
17 | <${ExternalLink} 18 | href="https://slidezones.com" 19 | target="_blank" 20 | style=${{ 21 | border: '2px dashed var(--border-color)', 22 | padding: '0 1em 1em 1em', 23 | }} 24 | > 25 |

26 | 30 | SlideZones 31 |

32 |
33 | Time zone converter for seamless global meetings 34 | 35 | 36 | 37 | 38 | 46 |

47 | 48 | CodeBedder.com 49 |

50 |
51 | Simplest code editor on the web 52 | 53 | 54 |
55 | 56 | 64 |

65 | 69 | Code Blue 70 |

71 |
72 | Render blocks of inline code on X/Twitter 73 | 74 |
75 | 78 | 81 |
82 |
83 | 84 | 94 |
95 |

98 | 99 | Gumroad page 100 |

101 |
102 | Support Image Downloader on Gumroad 103 |
104 | 105 | 106 |
107 |
108 | `; 109 | -------------------------------------------------------------------------------- /src/Options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Image Downloader | Options 5 | 6 | 7 | 66 | 67 | 68 | 69 |
70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/Popup/AdvancedFilters.js: -------------------------------------------------------------------------------- 1 | import html, { useEffect, useRef } from '../html.js'; 2 | import { Checkbox } from '../components/Checkbox.js'; 3 | 4 | // Currently a singleton. Should rewrite once we switch to a full-fledged rendering library 5 | export const AdvancedFilters = ({ options, setOptions }) => { 6 | const widthSliderRef = useSlider('width', options, setOptions); 7 | const heightSliderRef = useSlider('height', options, setOptions); 8 | 9 | // TODO: Extract and reuse in `Options.js` and other components 10 | const setCheckboxOption = (key) => ({ currentTarget: { checked } }) => { 11 | setOptions((options) => ({ ...options, [key]: checked.toString() })); 12 | }; 13 | 14 | return html` 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 49 | 50 | 53 | 54 | 76 | 77 | 78 | 79 | 80 | 81 | 103 | 104 | 107 | 108 | 130 | 131 |
Width: 28 | 48 | 51 |
52 |
55 | 75 |
Height: 82 | 102 | 105 |
106 |
109 | 129 |
132 | 133 | <${Checkbox} 134 | title="Only show images from direct links on the page; useful on sites like Reddit" 135 | checked=${options.only_images_from_links === 'true'} 136 | onChange=${setCheckboxOption('only_images_from_links')} 137 | > 138 | Only images from links 139 | 140 |
141 | `; 142 | }; 143 | 144 | const SliderCheckbox = ({ 145 | options, 146 | optionKey, 147 | setCheckboxOption, 148 | ...props 149 | }) => { 150 | const enabled = options[optionKey] === 'true'; 151 | return html` 152 | 158 | `; 159 | }; 160 | 161 | const useSlider = (dimension, options, setOptions) => { 162 | const sliderRef = useRef(null); 163 | 164 | useEffect(() => { 165 | const slider = sliderRef.current; 166 | if (!slider) return; 167 | 168 | noUiSlider.create(slider, { 169 | behaviour: 'extend-tap', 170 | connect: true, 171 | format: { 172 | from: (value) => parseInt(value, 10), 173 | to: (value) => parseInt(value, 10).toString(), 174 | }, 175 | range: { 176 | min: parseInt(options[`filter_min_${dimension}_default`], 10), 177 | max: parseInt(options[`filter_max_${dimension}_default`], 10), 178 | }, 179 | step: 10, 180 | start: [ 181 | options[`filter_min_${dimension}`], 182 | options[`filter_max_${dimension}`], 183 | ], 184 | }); 185 | 186 | slider.noUiSlider.on('update', ([min, max]) => { 187 | setOptions((options) => ({ 188 | ...options, 189 | [`filter_min_${dimension}`]: min, 190 | [`filter_max_${dimension}`]: max, 191 | })); 192 | }); 193 | 194 | return () => { 195 | if (slider.noUiSlider) { 196 | slider.noUiSlider.destroy(); 197 | } 198 | }; 199 | }, []); 200 | 201 | useEffect(() => { 202 | const slider = sliderRef.current; 203 | if (!slider) return; 204 | 205 | if ( 206 | options[`filter_min_${dimension}_enabled`] === 'true' || 207 | options[`filter_max_${dimension}_enabled`] === 'true' 208 | ) { 209 | slider.removeAttribute('disabled'); 210 | } else { 211 | slider.setAttribute('disabled', true); 212 | } 213 | }, [ 214 | options[`filter_min_${dimension}_enabled`], 215 | options[`filter_max_${dimension}_enabled`], 216 | ]); 217 | 218 | useDisableSliderHandle( 219 | () => 220 | sliderRef.current 221 | ? sliderRef.current.querySelectorAll('.noUi-origin')[0] 222 | : undefined, 223 | options[`filter_min_${dimension}_enabled`] 224 | ); 225 | 226 | useDisableSliderHandle( 227 | () => 228 | sliderRef.current 229 | ? sliderRef.current.querySelectorAll('.noUi-origin')[1] 230 | : undefined, 231 | options[`filter_max_${dimension}_enabled`] 232 | ); 233 | 234 | return sliderRef; 235 | }; 236 | 237 | const useDisableSliderHandle = ( 238 | getHandle, 239 | option, 240 | tooltipText = 'Click the checkbox next to this slider to enable it' 241 | ) => { 242 | useEffect(() => { 243 | const handle = getHandle(); 244 | if (!handle) return; 245 | 246 | if (option === 'true') { 247 | handle.removeAttribute('disabled'); 248 | handle.removeAttribute('title'); 249 | } else { 250 | handle.setAttribute('disabled', true); 251 | handle.setAttribute('title', tooltipText); 252 | } 253 | }, [option]); 254 | }; 255 | 256 | const getSliderCheckboxTooltip = (option) => 257 | `Click this checkbox to ${ 258 | option === 'true' ? 'disable' : 'enable' 259 | } filtering by this value`; 260 | -------------------------------------------------------------------------------- /src/Popup/DownloadButton.js: -------------------------------------------------------------------------------- 1 | import html from '../html.js'; 2 | 3 | // TODO: Implement loading animation 4 | export const DownloadButton = ({ disabled, loading, ...props }) => { 5 | const tooltipText = disabled 6 | ? 'Select some images to download first' 7 | : loading 8 | ? 'If you want, you can close the extension popup\nwhile the images are downloading!' 9 | : ''; 10 | 11 | return html` 12 | 20 | `; 21 | }; 22 | -------------------------------------------------------------------------------- /src/Popup/DownloadConfirmation.js: -------------------------------------------------------------------------------- 1 | import html from '../html.js'; 2 | import { Checkbox } from '../components/Checkbox.js'; 3 | 4 | export const DownloadConfirmation = ({ 5 | onCheckboxChange, 6 | onClose, 7 | onConfirm, 8 | style, 9 | ...props 10 | }) => { 11 | return html` 12 |
13 |
14 |
15 |

Take a quick look at your browser settings.

16 |

17 | If the Ask where to save each file before downloading option is 18 | checked, proceeding might open a lot of popup windows. Continue with 19 | the download? 20 |

21 |
22 | 23 |
24 |
25 | <${Checkbox} onChange=${onCheckboxChange}> 26 | Got it, don't show again 27 | 28 |
29 | 30 | 36 | 37 | { 42 | onClose(); 43 | onConfirm(); 44 | }} 45 | /> 46 |
47 |
48 | `; 49 | }; 50 | -------------------------------------------------------------------------------- /src/Popup/ImageActions.js: -------------------------------------------------------------------------------- 1 | import html from '../html.js'; 2 | import * as actions from './actions.js'; 3 | 4 | export const ImageUrlTextbox = (props) => html` 5 | { 9 | e.currentTarget.select(); 10 | }} 11 | ...${props} 12 | /> 13 | `; 14 | 15 | export const OpenImageButton = ({ imageUrl, onClick, ...props }) => { 16 | return html` 17 | 195 | 196 | 203 | 204 | 205 | ${options.show_advanced_filters === 'true' && 206 | html` 207 | <${AdvancedFilters} options=${options} setOptions=${setOptions} /> 208 | `} 209 | 210 | 211 | 216 | 217 | <${Images} 218 | options=${options} 219 | visibleImages=${visibleImages} 220 | selectedImages=${selectedImages} 221 | imagesToDownload=${imagesToDownload} 222 | setSelectedImages=${setSelectedImages} 223 | /> 224 | 225 |
233 | { 239 | const savedSelectionStart = removeSpecialCharacters( 240 | input.value.slice(0, input.selectionStart), 241 | ).length; 242 | 243 | runAfterUpdate(() => { 244 | input.selectionStart = input.selectionEnd = savedSelectionStart; 245 | }); 246 | 247 | setOptions((options) => ({ 248 | ...options, 249 | folder_name: removeSpecialCharacters(input.value), 250 | })); 251 | }} 252 | /> 253 | 254 | ${options.show_file_renaming === 'true' && 255 | html` 256 | { 262 | const savedSelectionStart = removeSpecialCharacters( 263 | input.value.slice(0, input.selectionStart), 264 | ).length; 265 | 266 | runAfterUpdate(() => { 267 | input.selectionStart = input.selectionEnd = savedSelectionStart; 268 | }); 269 | 270 | setOptions((options) => ({ 271 | ...options, 272 | new_file_name: removeSpecialCharacters(input.value), 273 | })); 274 | }} 275 | /> 276 | `} 277 | 278 | <${DownloadButton} 279 | disabled=${imagesToDownload.length === 0} 280 | loading=${downloadIsInProgress} 281 | onClick=${maybeDownloadImages} 282 | /> 283 | 284 | ${downloadConfirmationIsShown && 285 | html` 286 | <${DownloadConfirmation} 287 | onCheckboxChange=${({ currentTarget: { checked } }) => { 288 | setOptions((options) => ({ 289 | ...options, 290 | show_download_confirmation: (!checked).toString(), 291 | })); 292 | }} 293 | onClose=${() => setDownloadConfirmationIsShown(false)} 294 | onConfirm=${downloadImages} 295 | /> 296 | `} 297 |
298 | `; 299 | }; 300 | 301 | function findImages() { 302 | // Source: https://support.google.com/webmasters/answer/2598805?hl=en 303 | const imageUrlRegex = 304 | /(?:([^:\/?#]+):)?(?:\/\/([^\/?#]*))?([^?#]*\.(?:bmp|gif|ico|jfif|jpe?g|png|svg|tiff?|webp|avif))(?:\?([^#]*))?(?:#(.*))?/i; 305 | 306 | function extractImagesFromSelector(selector) { 307 | return unique( 308 | toArray(document.querySelectorAll(selector)) 309 | .map(extractImageFromElement) 310 | .filter(isTruthy) 311 | .map(relativeUrlToAbsolute), 312 | ); 313 | } 314 | 315 | function extractImageFromElement(element) { 316 | if (element.tagName.toLowerCase() === 'img') { 317 | const src = element.src; 318 | const hashIndex = src.indexOf('#'); 319 | return hashIndex >= 0 ? src.substr(0, hashIndex) : src; 320 | } 321 | 322 | if (element.tagName.toLowerCase() === 'image') { 323 | const src = element.getAttribute('xlink:href'); 324 | const hashIndex = src.indexOf('#'); 325 | return hashIndex >= 0 ? src.substr(0, hashIndex) : src; 326 | } 327 | 328 | if (element.tagName.toLowerCase() === 'a') { 329 | const href = element.href; 330 | if (isImageURL(href)) { 331 | return href; 332 | } 333 | } 334 | 335 | const backgroundImage = window.getComputedStyle(element).backgroundImage; 336 | if (backgroundImage) { 337 | const parsedURL = extractURLFromStyle(backgroundImage); 338 | if (isImageURL(parsedURL)) { 339 | return parsedURL; 340 | } 341 | } 342 | } 343 | 344 | function isImageURL(url) { 345 | return url.indexOf('data:image') === 0 || imageUrlRegex.test(url); 346 | } 347 | 348 | function extractURLFromStyle(style) { 349 | return style.replace(/^.*url\(["']?/, '').replace(/["']?\).*$/, ''); 350 | } 351 | 352 | function relativeUrlToAbsolute(url) { 353 | return url.indexOf('/') === 0 ? `${window.location.origin}${url}` : url; 354 | } 355 | 356 | function unique(values) { 357 | return toArray(new Set(values)); 358 | } 359 | 360 | function toArray(values) { 361 | return [...values]; 362 | } 363 | 364 | function isTruthy(value) { 365 | return !!value; 366 | } 367 | 368 | return { 369 | allImages: extractImagesFromSelector('img, image, a, [class], [style]'), 370 | linkedImages: extractImagesFromSelector('a'), 371 | origin: window.location.origin, 372 | }; 373 | } 374 | 375 | render(html`<${Popup} />`, document.querySelector('main')); 376 | -------------------------------------------------------------------------------- /src/Popup/Popup.test.ts: -------------------------------------------------------------------------------- 1 | import { asMockedFunction, mockChrome } from '../test'; 2 | 3 | jest.useFakeTimers(); 4 | 5 | declare var global: any; 6 | 7 | beforeEach(() => { 8 | global.chrome = mockChrome(); 9 | global.this = global; 10 | global.$ = require('../../lib/jquery-3.5.1.min'); 11 | ($.fn as any).fadeIn = function (duration, fn) { 12 | setTimeout(duration, fn); 13 | return this; 14 | }; 15 | ($.fn as any).fadeOut = function (duration, fn) { 16 | setTimeout(duration, fn); 17 | return this; 18 | }; 19 | ($ as any).Link = jest.fn(); 20 | ($.fn as any).noUiSlider = jest.fn(); 21 | document.body.innerHTML = '
'; 22 | require('../defaults'); 23 | require('./Popup'); 24 | }); 25 | 26 | it(`renders images`, () => { 27 | const renderImages = asMockedFunction(chrome.runtime.onMessage.addListener) 28 | .mock.calls[0][0]; 29 | renderImages( 30 | { 31 | allImages: [ 32 | 'http://example.com/image-1.png', 33 | 'http://example.com/image-2.png', 34 | 'http://example.com/image-3.png', 35 | ], 36 | linkedImages: [], 37 | }, 38 | {}, 39 | () => {} 40 | ); 41 | jest.runOnlyPendingTimers(); 42 | 43 | expect(document.querySelectorAll('#images_container img').length).toBe(3); 44 | }); 45 | -------------------------------------------------------------------------------- /src/Popup/UrlFilterMode.js: -------------------------------------------------------------------------------- 1 | import html from '../html.js'; 2 | 3 | export const UrlFilterMode = (props) => html` 4 | 50 | `; 51 | -------------------------------------------------------------------------------- /src/Popup/actions.js: -------------------------------------------------------------------------------- 1 | export const downloadImages = (imagesToDownload, options) => { 2 | return new Promise((resolve) => { 3 | chrome.runtime.sendMessage( 4 | { type: 'downloadImages', imagesToDownload, options }, 5 | resolve 6 | ); 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /src/Popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Image Downloader 5 | 6 | 7 | 8 | 315 | 316 | 317 | 318 |
319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | -------------------------------------------------------------------------------- /src/background/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Handle updates 3 | chrome.runtime.onInstalled.addListener((details) => { 4 | if (details.reason === 'install') { 5 | // Open the options page after install 6 | chrome.tabs.create({ url: 'src/Options/index.html' }); 7 | } 8 | }); 9 | 10 | // Download images 11 | /** @typedef {{ numberOfProcessedImages: number, imagesToDownload: string[], options: any, next: () => void }} Task */ 12 | 13 | /** @type {Set} */ 14 | const tasks = new Set(); 15 | 16 | chrome.runtime.onMessage.addListener(startDownload); 17 | chrome.downloads.onDeterminingFilename.addListener(suggestNewFilename); 18 | 19 | // NOTE: Don't directly use an `async` function as a listener for `onMessage`: 20 | // https://stackoverflow.com/a/56483156 21 | // https://developer.chrome.com/docs/extensions/reference/runtime/#event-onMessage 22 | function startDownload( 23 | /** @type {any} */ message, 24 | /** @type {chrome.runtime.MessageSender} */ sender, 25 | /** @type {(response?: any) => void} */ resolve 26 | ) { 27 | if (!(message && message.type === 'downloadImages')) return; 28 | 29 | downloadImages({ 30 | numberOfProcessedImages: 0, 31 | imagesToDownload: message.imagesToDownload, 32 | options: message.options, 33 | next() { 34 | this.numberOfProcessedImages += 1; 35 | if (this.numberOfProcessedImages === this.imagesToDownload.length) { 36 | tasks.delete(this); 37 | } 38 | }, 39 | }).then(resolve); 40 | 41 | return true; // Keeps the message channel open until `resolve` is called 42 | } 43 | 44 | async function downloadImages(/** @type {Task} */ task) { 45 | tasks.add(task); 46 | for (const image of task.imagesToDownload) { 47 | await new Promise((resolve) => { 48 | chrome.downloads.download({ url: image }, (downloadId) => { 49 | if (downloadId == null) { 50 | if (chrome.runtime.lastError) { 51 | console.error(`${image}:`, chrome.runtime.lastError.message); 52 | } 53 | task.next(); 54 | } 55 | resolve(); 56 | }); 57 | }); 58 | } 59 | } 60 | 61 | // https://developer.chrome.com/docs/extensions/reference/downloads/#event-onDeterminingFilename 62 | /** @type {Parameters[0]} */ 63 | function suggestNewFilename(item, suggest) { 64 | const task = [...tasks][0]; 65 | if (!task) { 66 | suggest(); 67 | return; 68 | } 69 | 70 | let newFilename = ''; 71 | if (task.options.folder_name) { 72 | newFilename += `${task.options.folder_name}/`; 73 | } 74 | if (task.options.new_file_name) { 75 | const regex = /(?:\.([^.]+))?$/; 76 | const extension = regex.exec(item.filename)?.[1]; 77 | const numberOfDigits = task.imagesToDownload.length.toString().length; 78 | const formattedImageNumber = `${task.numberOfProcessedImages + 1}`.padStart( 79 | numberOfDigits, 80 | '0' 81 | ); 82 | newFilename += `${task.options.new_file_name}${formattedImageNumber}.${extension}`; 83 | } else { 84 | newFilename += item.filename; 85 | } 86 | 87 | suggest({ filename: normalizeSlashes(newFilename) }); 88 | task.next(); 89 | } 90 | 91 | function normalizeSlashes(filename) { 92 | return filename.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); 93 | } 94 | -------------------------------------------------------------------------------- /src/components/Checkbox.js: -------------------------------------------------------------------------------- 1 | import html from '../html.js'; 2 | 3 | export const Checkbox = ({ 4 | children, 5 | class: className, 6 | indeterminate, 7 | style, 8 | title = '', 9 | ...props 10 | }) => html` 11 | 20 | `; 21 | 22 | // Source: https://davidwalsh.name/react-indeterminate 23 | const setIndeterminate = (indeterminate) => (element) => { 24 | if (element) { 25 | element.indeterminate = indeterminate; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/ExternalLink.js: -------------------------------------------------------------------------------- 1 | import html from '../html.js'; 2 | 3 | export const ExternalLink = ({ children, ...props }) => html` 4 | 5 | ${children || props.href} 6 | 7 | `; 8 | -------------------------------------------------------------------------------- /src/defaults.js: -------------------------------------------------------------------------------- 1 | // TODO: Convert to a module and split into 2 - the default values and setting `localStorage` based on the default values initially 2 | 3 | const defaults = { 4 | // Filters 5 | folder_name: '', 6 | new_file_name: '', 7 | filter_url: '', 8 | filter_url_mode: 'normal', 9 | filter_min_width: 0, 10 | filter_min_width_enabled: false, 11 | filter_max_width: 3000, 12 | filter_max_width_enabled: false, 13 | filter_min_height: 0, 14 | filter_min_height_enabled: false, 15 | filter_max_height: 3000, 16 | filter_max_height_enabled: false, 17 | only_images_from_links: false, 18 | show_advanced_filters: true, 19 | // Options 20 | // General 21 | show_download_confirmation: true, 22 | show_file_renaming: true, 23 | // Images 24 | show_image_url: true, 25 | show_open_image_button: true, 26 | show_download_image_button: true, 27 | columns: 2, 28 | image_min_width: 50, 29 | image_max_width: 200, 30 | }; 31 | 32 | Object.keys(defaults).forEach((option) => { 33 | if (localStorage[option] === undefined) { 34 | localStorage[option] = defaults[option]; 35 | } 36 | localStorage[`${option}_default`] = defaults[option]; 37 | }); 38 | -------------------------------------------------------------------------------- /src/defaults.test.ts: -------------------------------------------------------------------------------- 1 | import { asMockedFunction, mockChrome } from './test'; 2 | 3 | declare var global: any; 4 | 5 | beforeEach(() => { 6 | global.chrome = mockChrome(); 7 | localStorage.clear(); 8 | }); 9 | 10 | it(`preserves existing options in 'localStorage'`, () => { 11 | localStorage.folder_name = 'test'; 12 | require('./defaults'); 13 | expect(localStorage.folder_name).toBe('test'); 14 | }); 15 | 16 | it(`sets undefined options in 'localStorage' to default`, () => { 17 | localStorage.folder_name = undefined; 18 | require('./defaults'); 19 | expect(localStorage.folder_name).not.toBe(undefined); 20 | }); 21 | 22 | it(`matches 'localStorage' snapshot`, () => { 23 | require('./defaults'); 24 | expect(global.localStorage).toMatchInlineSnapshot(` 25 | Storage { 26 | "columns": "2", 27 | "columns_default": "2", 28 | "filter_max_height": "3000", 29 | "filter_max_height_default": "3000", 30 | "filter_max_height_enabled": "false", 31 | "filter_max_height_enabled_default": "false", 32 | "filter_max_width": "3000", 33 | "filter_max_width_default": "3000", 34 | "filter_max_width_enabled": "false", 35 | "filter_max_width_enabled_default": "false", 36 | "filter_min_height": "0", 37 | "filter_min_height_default": "0", 38 | "filter_min_height_enabled": "false", 39 | "filter_min_height_enabled_default": "false", 40 | "filter_min_width": "0", 41 | "filter_min_width_default": "0", 42 | "filter_min_width_enabled": "false", 43 | "filter_min_width_enabled_default": "false", 44 | "filter_url": "", 45 | "filter_url_default": "", 46 | "filter_url_mode": "normal", 47 | "filter_url_mode_default": "normal", 48 | "folder_name": "", 49 | "folder_name_default": "", 50 | "image_max_width": "200", 51 | "image_max_width_default": "200", 52 | "image_min_width": "50", 53 | "image_min_width_default": "50", 54 | "new_file_name": "", 55 | "new_file_name_default": "", 56 | "only_images_from_links": "false", 57 | "only_images_from_links_default": "false", 58 | "show_advanced_filters": "true", 59 | "show_advanced_filters_default": "true", 60 | "show_download_confirmation": "true", 61 | "show_download_confirmation_default": "true", 62 | "show_download_image_button": "true", 63 | "show_download_image_button_default": "true", 64 | "show_file_renaming": "true", 65 | "show_file_renaming_default": "true", 66 | "show_image_url": "true", 67 | "show_image_url_default": "true", 68 | "show_open_image_button": "true", 69 | "show_open_image_button_default": "true", 70 | } 71 | `); 72 | }); 73 | -------------------------------------------------------------------------------- /src/hooks/useRunAfterUpdate.js: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef } from '../html.js'; 2 | 3 | export const useRunAfterUpdate = () => { 4 | const handlersRef = useRef([]); 5 | 6 | useLayoutEffect(() => { 7 | handlersRef.current.forEach((handler) => handler()); 8 | handlersRef.current = []; 9 | }); 10 | 11 | return (handler) => { 12 | handlersRef.current.push(handler); 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/html.js: -------------------------------------------------------------------------------- 1 | import htm from '../lib/htm.js'; 2 | import '../lib/react-18.3.1.min.js'; 3 | import '../lib/react-dom-18.3.1.min.js'; 4 | 5 | const html = htm.bind(React.createElement); 6 | export default html; 7 | 8 | // React hooks 9 | export const useCallback = React.useCallback; 10 | export const useEffect = React.useEffect; 11 | export const useLayoutEffect = React.useLayoutEffect; 12 | export const useMemo = React.useMemo; 13 | export const useRef = React.useRef; 14 | export const useState = React.useState; 15 | 16 | // React DOM 17 | export const render = ReactDOM.render; 18 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import { mockRecursivePartial } from 'sneer'; 2 | 3 | export const mockChrome = () => 4 | mockRecursivePartial({ 5 | downloads: { 6 | onDeterminingFilename: { 7 | addListener: jest.fn(), 8 | }, 9 | }, 10 | runtime: { 11 | onInstalled: { 12 | addListener: jest.fn(), 13 | }, 14 | onMessage: { 15 | addListener: jest.fn(), 16 | }, 17 | sendMessage: jest.fn(), 18 | }, 19 | tabs: { 20 | create: jest.fn(), 21 | query: jest.fn(), 22 | }, 23 | windows: { 24 | getCurrent: () => ({ id: 'window' }), 25 | }, 26 | }); 27 | 28 | export const asMockedFunction = any>(fn: T) => 29 | fn as jest.MockedFunction; 30 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const isStrictEqual = (value1) => (value2) => value1 === value2; 2 | export const isNotStrictEqual = (value1) => (value2) => value1 !== value2; 3 | 4 | export const isIncludedIn = (array) => (item) => array.includes(item); 5 | 6 | export const stopPropagation = (e) => e.stopPropagation(); 7 | 8 | export const removeSpecialCharacters = (value) => { 9 | return value.replace(/[<>:"|?*]/g, ''); 10 | }; 11 | 12 | export const unique = (values) => [...new Set(values)]; 13 | -------------------------------------------------------------------------------- /stylesheets/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Colors */ 3 | /* Neutral */ 4 | --neutral-lightest: hsl(0, 0%, 100%); 5 | --neutral-lighter: hsl(0, 0%, 95%); 6 | --neutral-light: hsl(300, 11%, 80%); 7 | --neutral: hsl(300, 11%, 60%); 8 | --neutral-dark: hsl(300, 11%, 10%); 9 | --neutral-darker: hsl(300, 11%, 5%); 10 | --neutral-darkest: hsl(300, 11%, 0%); 11 | 12 | --neutral-transparent-5: hsla(0, 0%, 0%, 5%); 13 | --neutral-transparent-15: hsla(0, 0%, 0%, 15%); 14 | --neutral-transparent-20: hsla(0, 0%, 0%, 20%); 15 | 16 | /* Accent */ 17 | --accent: hsl(213, 80%, 50%); 18 | --accent-dark: hsl(213, 80%, 40%); 19 | 20 | /* Success */ 21 | --success-light: hsl(145, 64%, 43%); 22 | --success: hsl(145, 64%, 40%); 23 | 24 | /* Warning */ 25 | --warning-light: hsl(37, 90%, 51%); 26 | --warning: hsl(37, 90%, 48%); 27 | 28 | /* Danger */ 29 | --danger-light: hsl(6, 78%, 57%); 30 | --danger: hsl(6, 78%, 54%); 31 | 32 | /* Foreground */ 33 | --foreground: hsl(0, 0%, 20%); 34 | --foreground-light: hsl(0, 0%, 70%); 35 | --foreground-inverse: hsl(0, 0%, 100%); 36 | 37 | /* Border radii */ 38 | --border-radius-sm: 2px; 39 | --border-radius-md: 4px; 40 | 41 | /* Elevations */ 42 | --elevation-1: 0 2px 4px var(--neutral-transparent-20); 43 | --elevation-2: 0 5px 14px var(--neutral-transparent-15), 44 | 0 3px 5px var(--neutral-transparent-20); 45 | 46 | /* Transitions */ 47 | --transition-timing-ease-out: cubic-bezier(0.25, 0.8, 0.25, 1); 48 | --transition-timing-linear: linear; 49 | 50 | --transition-all: all 200ms var(--transition-timing-ease-out); 51 | --transition-background: background 200ms var(--transition-timing-ease-out); 52 | --transition-border-color: border-color 200ms 53 | var(--transition-timing-ease-out); 54 | --transition-box-shadow: box-shadow 200ms var(--transition-timing-ease-out); 55 | --transition-color: color 200ms var(--transition-timing-ease-out); 56 | --transition-opacity: opacity 200ms var(--transition-timing-linear); /* Timing opacity looks weird */ 57 | --transition-transform: transform 200ms var(--transition-timing-ease-out); 58 | 59 | /* Misc */ 60 | --border-color: var(--neutral-transparent-5); 61 | --image-background: var(--neutral-light); 62 | --images-container-gap: 8px; 63 | --images-container-padding: 12px; 64 | --input-background: var(--neutral-transparent-5); 65 | } 66 | 67 | html { 68 | box-sizing: border-box; 69 | } 70 | 71 | *, 72 | *::before, 73 | *::after { 74 | box-sizing: inherit; 75 | } 76 | 77 | body { 78 | min-width: 400px; 79 | min-height: 600px; 80 | font: 13px/1.5em 'Lucida Grande', Arial, sans-serif; 81 | color: var(--foreground); 82 | } 83 | 84 | /* https://stackoverflow.com/questions/11243337/a-taller-than-its-img-child */ 85 | a > img { 86 | display: block; 87 | } 88 | 89 | img { 90 | max-width: 100%; 91 | } 92 | 93 | svg { 94 | fill: currentColor; 95 | } 96 | 97 | fieldset, 98 | details { 99 | border-color: var(--border-color); 100 | border-width: 0; 101 | border-top-width: 2px; 102 | padding-right: 0; 103 | padding-left: 0; 104 | } 105 | 106 | fieldset, 107 | details { 108 | margin-top: 2rem; 109 | } 110 | 111 | fieldset > p:first-of-type, 112 | details > p:first-of-type { 113 | margin-top: 0; 114 | } 115 | 116 | fieldset > p:last-of-type, 117 | details > p:last-of-type { 118 | margin-bottom: 0; 119 | } 120 | 121 | legend, 122 | summary { 123 | margin-left: -8px; 124 | padding: 0 8px; 125 | font-weight: bold; 126 | } 127 | 128 | summary::before { 129 | content: '\2BC8'; 130 | } 131 | 132 | details[open] summary::before { 133 | content: '\2BC6'; 134 | } 135 | 136 | summary { 137 | cursor: pointer; 138 | display: flex; 139 | align-items: center; 140 | gap: 0.25em; 141 | margin-left: -22px; 142 | margin-right: -6px; 143 | } 144 | 145 | summary::after { 146 | content: ''; 147 | flex: 1; 148 | height: 2px; 149 | margin-left: 0.33em; 150 | background-color: var(--border-color); 151 | } 152 | 153 | .left { 154 | float: left; 155 | } 156 | .right { 157 | float: right; 158 | } 159 | .clear { 160 | clear: both; 161 | } 162 | 163 | .hidden { 164 | display: none; 165 | } 166 | 167 | /* text */ 168 | .accent { 169 | color: var(--accent); 170 | } 171 | .success { 172 | color: var(--success); 173 | } 174 | .warning { 175 | color: var(--warning); 176 | } 177 | .danger { 178 | color: var(--danger); 179 | } 180 | .light { 181 | color: var(--foreground-light); 182 | } 183 | .inverse { 184 | color: var(--foreground-inverse); 185 | } 186 | 187 | /* backgrounds */ 188 | .bg-accent { 189 | background: var(--accent); 190 | } 191 | .bg-success { 192 | background: var(--success); 193 | } 194 | .bg-warning { 195 | background: var(--warning); 196 | } 197 | .bg-danger { 198 | background: var(--danger); 199 | } 200 | 201 | /* links */ 202 | a:link, 203 | a:visited { 204 | color: var(--accent); 205 | text-decoration: none; 206 | } 207 | 208 | a:hover, 209 | a:active { 210 | color: var(--accent-dark); 211 | } 212 | 213 | hr { 214 | border: 1px dotted var(--neutral); 215 | border-bottom: none; 216 | margin: 4px 0; 217 | } 218 | 219 | /* inputs */ 220 | input[type='text'], 221 | input[type='button'], 222 | input[type='number'], 223 | select { 224 | border-radius: var(--border-radius-md); 225 | margin-bottom: 4px; 226 | font-size: 12px; 227 | } 228 | 229 | input[type='text'], 230 | input[type='number'], 231 | select { 232 | border: 0; 233 | background: var(--input-background); 234 | padding: 8px 12px; 235 | } 236 | 237 | input[type='number'] { 238 | padding-right: 4px; 239 | } 240 | 241 | select { 242 | cursor: pointer; 243 | padding: 7px 12px; 244 | } 245 | 246 | input[type='button'], 247 | input[type='checkbox'] { 248 | cursor: pointer; 249 | } 250 | 251 | input[type='button'] { 252 | min-width: 50px; 253 | color: var(--neutral-lightest); 254 | border: 0; 255 | padding: 8px 12px; 256 | transition: var(--transition-background); 257 | } 258 | input[type='button']:disabled { 259 | cursor: help; 260 | opacity: 0.6; 261 | } 262 | input[type='button'].loading:disabled { 263 | cursor: wait; 264 | } 265 | 266 | /* accent */ 267 | input[type='button'].accent { 268 | background: var(--accent); 269 | } 270 | input[type='button'].accent:hover, 271 | input[type='button'].accent:focus, 272 | input[type='button'].accent:active { 273 | background: var(--accent); 274 | } 275 | 276 | input[type='button'].accent.ghost { 277 | border: 2px solid var(--accent); 278 | background: var(--neutral-lightest); 279 | color: var(--accent); 280 | } 281 | 282 | /* success */ 283 | input[type='button'].success { 284 | border: 2px solid var(--success-light); 285 | background: var(--success-light); 286 | } 287 | input[type='button'].success:hover, 288 | input[type='button'].success:focus, 289 | input[type='button'].success:active { 290 | border: 2px solid var(--success); 291 | background: var(--success); 292 | } 293 | 294 | input[type='button'].success.ghost { 295 | border: 2px solid var(--success); 296 | background: var(--neutral-lightest); 297 | color: var(--success); 298 | } 299 | 300 | /* warning */ 301 | input[type='button'].warning { 302 | border: 2px solid var(--warning-light); 303 | background: var(--warning-light); 304 | } 305 | input[type='button'].warning:hover, 306 | input[type='button'].warning:focus, 307 | input[type='button'].warning:active { 308 | border: 2px solid var(--warning); 309 | background: var(--warning); 310 | } 311 | 312 | input[type='button'].warning.ghost { 313 | border: 2px solid var(--warning); 314 | background: var(--neutral-lightest); 315 | color: var(--warning); 316 | } 317 | 318 | /* danger */ 319 | input[type='button'].danger { 320 | border: 2px solid var(--danger-light); 321 | background: var(--danger-light); 322 | } 323 | input[type='button'].danger:hover, 324 | input[type='button'].danger:focus, 325 | input[type='button'].danger:active { 326 | border: 2px solid var(--danger); 327 | background: var(--danger); 328 | } 329 | 330 | input[type='button'].danger.ghost { 331 | border: 2px solid var(--danger); 332 | background: var(--neutral-lightest); 333 | color: var(--danger); 334 | } 335 | 336 | /* neutral */ 337 | input[type='button'].neutral { 338 | border: 2px solid var(--neutral-transparent-20); 339 | background: var(--neutral-transparent-20); 340 | } 341 | 342 | input[type='button'].neutral.ghost { 343 | border: 2px solid var(--neutral-transparent-20); 344 | background: var(--neutral-lightest); 345 | color: var(--foreground); 346 | } 347 | 348 | label { 349 | cursor: pointer; 350 | display: inline-flex; 351 | } 352 | 353 | label:has(input[type='checkbox']) { 354 | margin-left: 3px; 355 | } 356 | --------------------------------------------------------------------------------