├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── dist ├── icons │ ├── 128x128.png │ ├── 48x48.png │ └── 64x64.png ├── manifest.json ├── options.css ├── options.html ├── popup.css └── popup.html ├── jest.config.ts ├── package.json ├── preview.png ├── src ├── Stylesheet.ts ├── SuperCSSInject.ts ├── common │ └── If.tsx ├── options │ ├── Options.tsx │ ├── OptionsReducer.test.ts │ ├── OptionsReducer.ts │ ├── components │ │ ├── ConfigModal.tsx │ │ ├── EditModal.tsx │ │ ├── StylesheetForm.tsx │ │ ├── StylesheetItemTableRow.tsx │ │ └── StylesheetListTable.tsx │ └── index.tsx ├── popup │ ├── Popup.tsx │ ├── PopupEmptyMessage.tsx │ ├── PopupHeader.tsx │ ├── PopupPreferences.tsx │ ├── PopupReducer.test.ts │ ├── PopupReducer.ts │ ├── PopupSearch.tsx │ ├── StylesheetItem.tsx │ ├── StylesheetList.tsx │ └── index.tsx ├── storage.ts ├── types.d.ts ├── utils.ts └── worker │ └── background.ts ├── tsconfig.json ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:react/jsx-runtime", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "overrides": [ ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": "latest", 17 | "sourceType": "module" 18 | }, 19 | "plugins": [ 20 | "react", 21 | "@typescript-eslint" 22 | ], 23 | "rules": { 24 | "array-bracket-newline": [ 25 | "error", 26 | "consistent" 27 | ], 28 | "array-bracket-spacing": [ 29 | "error", 30 | "always" 31 | ], 32 | "array-element-newline": [ 33 | "error", 34 | "consistent" 35 | ], 36 | "indent": [ 37 | "warn", 38 | 4, 39 | { 40 | "ignoredNodes": [ 41 | "VariableDeclaration[declarations.length=0]" 42 | ] 43 | } 44 | ], 45 | "keyword-spacing": [ 46 | "error", 47 | { 48 | "before": true 49 | } 50 | ], 51 | "newline-before-return": "error", 52 | "no-unused-vars": "off", 53 | "@typescript-eslint/no-unused-vars": [ 54 | "warn", 55 | { 56 | "argsIgnorePattern": "^_" 57 | } 58 | ], 59 | "object-curly-newline": [ 60 | "error", 61 | { 62 | "multiline": true 63 | } 64 | ], 65 | "object-curly-spacing": [ 66 | "error", 67 | "always" 68 | ], 69 | // "object-property-newline": "error", 70 | "padding-line-between-statements": [ 71 | "error", 72 | { 73 | "blankLine": "always", 74 | "next": "block-like", 75 | "prev": "*" 76 | }, 77 | { 78 | "blankLine": "always", 79 | "next": "*", 80 | "prev": "block-like" 81 | } 82 | ], 83 | "quotes": [ 84 | "error", 85 | "double" 86 | ], 87 | "semi": [ 88 | "error", 89 | "always" 90 | ], 91 | "space-before-function-paren": [ 92 | "error", 93 | "always" 94 | ] 95 | }, 96 | "settings": { 97 | "react": { 98 | "version": "detect" 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build-*.zip 2 | *.crx 3 | *.pem 4 | node_modules 5 | package-lock.json 6 | .stylelintrc.json 7 | test 8 | dist/js/ 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "eslint.enable": true, 4 | "eslint.run": "onSave", 5 | "editor.formatOnSave": false, 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll": "explicit", 8 | "source.organizeImports": "explicit" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nelson Rodrigues 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Super CSS Inject 2 | 3 | Keep multiple stylesheets ready to inject and change on the fly. Works with **LiveReload**. 4 | Compatible with Chrome and Firefox. 5 | 6 | 7 | 8 | ### How to install (Chrome) 9 | 10 | 1. Clone or download the repository zip file (extract it to a folder) 11 | 2. Open Chrome extensions page 12 | 3. Enable Developer Mode 13 | 4. Click on Load Unpacked Extension 14 | 5. Select the `dist` folder inside the extension folder 15 | 16 | The extension icon should be now visible in Chrome menu. 17 | 18 | ### How to Use? 19 | 20 | 1. First, add a stylesheet URL to the list by using the Options page, acessible via the Popup page. 21 | 2. On the web page where you want to inject the stylesheet, click on the extension icon to open the popup, click on one or more stylesheets from the list to inject them on your web page. 22 | 3. If there's more than one stylesheet selected, they will be injected in the order of your selection. 23 | 24 | ### Terminology 25 | 26 | #### Endpoints 27 | 28 | The extension is composed of multiple parts, you can think of them as being endpoints that can communicate with each other, these endpoints are: 29 | 30 | - Content Script 31 | - Popup Page 32 | - Options Page 33 | - Background Worker 34 | 35 | **Content Script** 36 | 37 | The JavaScript file that gets injected into the web page and the final responsible for managing the HTML necessary to inject or remove the injected stylesheet from the web page. 38 | 39 | **Popup Page** 40 | 41 | The web page that shows up when you click on the extension icon on the browser. The popup page is responsible for listing the available stylesheets and also responsible for managing the injected stylesheets per tab. Each tab can have multiple stylesheets injected at once. 42 | 43 | **Options Page** 44 | 45 | The web page responsible for managing the available stylesheets, where you can **add**, **edit** or **remove** stylesheets. 46 | 47 | **Background Worker** 48 | 49 | A [web worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) responsible for managing the communication between all the other endpoints. It also acts as a kind of session storage to keep track of all injected stylesheets in all browser tabs. 50 | -------------------------------------------------------------------------------- /dist/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonr/super-css-inject/23aecf3b5209da68e5dfbfe0a898d369cf05ba7b/dist/icons/128x128.png -------------------------------------------------------------------------------- /dist/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonr/super-css-inject/23aecf3b5209da68e5dfbfe0a898d369cf05ba7b/dist/icons/48x48.png -------------------------------------------------------------------------------- /dist/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonr/super-css-inject/23aecf3b5209da68e5dfbfe0a898d369cf05ba7b/dist/icons/64x64.png -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Super CSS Inject", 3 | "version": "1.5.0", 4 | "description": "Keep multiple stylesheets ready to inject and change on the fly!", 5 | "manifest_version": 3, 6 | "permissions": ["activeTab", "storage"], 7 | "icons": { 8 | "48": "icons/48x48.png", 9 | "64": "icons/64x64.png", 10 | "128": "icons/128x128.png" 11 | }, 12 | "background": { 13 | "service_worker": "js/background.js" 14 | }, 15 | "content_scripts": [ 16 | { 17 | "js": ["js/SuperCSSInject.js"], 18 | "matches": ["http://*/*", "https://*/*"], 19 | "run_at": "document_end" 20 | } 21 | ], 22 | "action": { 23 | "default_title": "Enable Super CSS Inject", 24 | "default_icon": "icons/48x48.png", 25 | "default_popup": "popup.html" 26 | }, 27 | "options_ui": { 28 | "page": "options.html", 29 | "open_in_tab": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /dist/options.css: -------------------------------------------------------------------------------- 1 | /* ============================================= */ 2 | /* CSS Variables */ 3 | /* ============================================= */ 4 | 5 | :root { 6 | --main-width: 800px; 7 | 8 | /* Color */ 9 | --color-primary: #F03738; 10 | --color-primary-shade-1: #F98484; 11 | --color-primary-shade-2: #BC0303; 12 | 13 | --color-green: #16C18E; 14 | --color-green-shade-1: #14B082; 15 | 16 | --color-red: #C13016; 17 | --color-red-shade-1: #912511; 18 | 19 | --color-neutral-0: #FFF; 20 | --color-neutral-1: #F1F1F1; 21 | --color-neutral-2: #E4E4E4; 22 | --color-neutral-3: #D6D6D6; 23 | --color-neutral-4: #B0B0B0; 24 | --color-neutral-5: #909090; 25 | --color-neutral-6: #595959; 26 | --color-neutral-7: #424242; 27 | --color-neutral-8: #222; 28 | 29 | /* Border Radius */ 30 | --border-radius: 4px; 31 | 32 | /* Spacing (4-point grid system, kinda) */ 33 | --space-1: 4px; 34 | --space-2: 8px; 35 | --space-3: 12px; 36 | --space-4: 16px; 37 | --space-6: 24px; 38 | --space-8: 32px; 39 | 40 | /* Font Sizes */ 41 | --font-size-1: 4px; 42 | --font-size-2: 8px; 43 | --font-size-3: 12px; 44 | --font-size-4: 16px; 45 | --font-size-6: 24px; 46 | --font-size-8: 32px; 47 | } 48 | 49 | /* ============================================= */ 50 | /* Base Styles */ 51 | /* ============================================= */ 52 | 53 | * { 54 | box-sizing: border-box; 55 | } 56 | 57 | svg { 58 | fill: currentColor; 59 | } 60 | 61 | input[type="text"] { 62 | padding: var(--space-2) var(--space-3); 63 | border-radius: var(--border-radius); 64 | border: 1px solid var(--color-neutral-4); 65 | font-size: 14px; 66 | font-weight: 300; 67 | height: 40px; 68 | width: 100%; 69 | } 70 | 71 | input[type="text"]:focus { 72 | outline: none; 73 | border-color: var(--color-neutral-6); 74 | } 75 | 76 | input[type="text"].not-valid { 77 | border-color: var(--color-red); 78 | } 79 | 80 | a { 81 | color: var(--color-primary); 82 | text-underline-offset: 2px; 83 | text-decoration: none; 84 | } 85 | 86 | a:hover { 87 | text-decoration: underline; 88 | } 89 | 90 | code { 91 | background-color: var(--color-neutral-2); 92 | padding-inline: var(--space-1); 93 | } 94 | 95 | /* ============================================= */ 96 | /* Layout */ 97 | /* ============================================= */ 98 | 99 | body { 100 | height: 100%; 101 | margin: 0; 102 | padding: 0; 103 | background-color: var(--color-neutral-1); 104 | font-size: 16px; 105 | font-family: Roboto, system-ui, sans-serif; 106 | padding-top: 60px; 107 | } 108 | 109 | .column { 110 | max-width: var(--main-width); 111 | margin: auto; 112 | padding-inline: var(--space-4); 113 | } 114 | 115 | .menu { 116 | flex: 1; 117 | display: flex; 118 | justify-content: flex-end; 119 | align-items: center; 120 | } 121 | 122 | /* ============================================= */ 123 | /* Form */ 124 | /* ============================================= */ 125 | 126 | .form-field { 127 | margin-bottom: var(--space-4); 128 | } 129 | 130 | .form-field label { 131 | display: block; 132 | color: var(--color-neutral-6); 133 | font-size: var(--font-size-3); 134 | margin-bottom: var(--space-1); 135 | text-transform: uppercase; 136 | } 137 | 138 | .form-actions { 139 | margin-top: var(--space-6); 140 | text-align: right; 141 | } 142 | 143 | /* Not Valid */ 144 | 145 | .validation-message { 146 | display: none; 147 | color: var(--color-red); 148 | font-size: var(--font-size-3); 149 | margin-top: var(--space-1); 150 | } 151 | 152 | .form-field--not-valid input[type="text"] { 153 | border-color: var(--color-red); 154 | } 155 | 156 | .form-field--not-valid .validation-message { 157 | display: block; 158 | } 159 | 160 | /* ============================================= */ 161 | /* Header */ 162 | /* ============================================= */ 163 | 164 | header { 165 | background-color: var(--color-neutral-7); 166 | color: var(--color-neutral-0); 167 | padding: var(--space-3); 168 | padding-inline: 0; 169 | position: fixed; 170 | top: 0; 171 | left: 0; 172 | width: 100%; 173 | z-index: 1; 174 | } 175 | 176 | header .column { 177 | display: flex; 178 | align-items: center; 179 | text-align: center; 180 | } 181 | 182 | header .title { 183 | display: inline-block; 184 | margin: 0; 185 | margin-left: var(--space-4); 186 | font-weight: normal; 187 | } 188 | 189 | /* ============================================= */ 190 | /* Main */ 191 | /* ============================================= */ 192 | 193 | main { 194 | height: 100%; 195 | display: flex; 196 | flex-direction: column; 197 | align-items: center; 198 | text-align: center; 199 | } 200 | 201 | /* ============================================= */ 202 | /* Buttons */ 203 | /* ============================================= */ 204 | 205 | button, 206 | .button { 207 | background-color: var(--color-neutral-2); 208 | cursor: pointer; 209 | text-transform: uppercase; 210 | font-weight: bold; 211 | font-size: 12px; 212 | border: 0; 213 | padding-inline: var(--space-4); 214 | border-radius: var(--border-radius); 215 | height: 40px; 216 | } 217 | 218 | .button:hover { 219 | background-color: var(--color-neutral-3); 220 | } 221 | 222 | button + button { 223 | margin-left: var(--space-4); 224 | } 225 | 226 | /* Buttons > Success */ 227 | 228 | .button--success { 229 | color: var(--color-neutral-0); 230 | background-color: var(--color-green); 231 | border-color: var(--color-green); 232 | } 233 | 234 | .button--success:hover { 235 | background-color: var(--color-green-shade-1); 236 | border-color: var(--color-green-shade-1); 237 | } 238 | 239 | .button--success:active { 240 | box-shadow: inset 0px 3px 5px rgba(0, 0, 0, 0.3); 241 | } 242 | 243 | /* Buttons > Danger */ 244 | 245 | .button--danger { 246 | color: var(--color-neutral-0); 247 | background-color: var(--color-red); 248 | } 249 | 250 | .button--danger:hover { 251 | background-color: var(--color-red-shade-1); 252 | } 253 | 254 | /* Buttons > Small */ 255 | 256 | .button--small { 257 | display: inline-flex; 258 | justify-content: center; 259 | align-items: center; 260 | min-width: 32px; 261 | height: 32px; 262 | border-radius: var(--border-radius); 263 | padding-inline: var(--space-4); 264 | margin-left: var(--space-2); 265 | position: relative; 266 | outline: 0; 267 | cursor: pointer; 268 | box-shadow: none; 269 | } 270 | 271 | .button--small:active { 272 | box-shadow: inset 0px 3px 5px rgba(0, 0, 0, 0.3); 273 | } 274 | 275 | .button--small:first-child { 276 | margin-left: 0; 277 | } 278 | 279 | /* Buttons > Icons */ 280 | 281 | .button--icon { 282 | width: 32px; 283 | padding: 0; 284 | } 285 | 286 | .button--icon svg { 287 | width: 20px; 288 | height: auto; 289 | } 290 | 291 | /* ============================================= */ 292 | /* Table */ 293 | /* ============================================= */ 294 | 295 | 296 | table { 297 | width: 100%; 298 | border-collapse: collapse; 299 | } 300 | 301 | table td, 302 | table th { 303 | padding: var(--space-2); 304 | } 305 | 306 | table th { 307 | font-size: 12px; 308 | text-transform: uppercase; 309 | border-bottom: 1px solid var(--color-neutral-2); 310 | } 311 | 312 | table tbody tr td { 313 | border-bottom: 1px solid var(--color-neutral-2); 314 | } 315 | 316 | table tbody tr td:first-child { 317 | overflow: auto; 318 | } 319 | 320 | table tr:last-child td { 321 | border-bottom: 0; 322 | } 323 | 324 | /* ============================================= */ 325 | /* Stylesheet Form */ 326 | /* ============================================= */ 327 | 328 | .stylesheets-form { 329 | display: flex; 330 | flex-direction: column; 331 | gap: var(--space-2); 332 | width: 100%; 333 | margin-top: var(--space-6); 334 | } 335 | 336 | .stylesheets-form__group { 337 | display: flex; 338 | gap: var(--space-2); 339 | } 340 | 341 | .stylesheets-form input[type='text'] { 342 | flex: 1; 343 | } 344 | 345 | .stylesheets-form .note { 346 | margin-top: var(--space-2); 347 | } 348 | 349 | /* ============================================= */ 350 | /* Stylesheet Empty Message */ 351 | /* ============================================= */ 352 | 353 | .stylesheets-message { 354 | margin-top: var(--space-6); 355 | letter-spacing: 0.6px; 356 | } 357 | 358 | /* ============================================= */ 359 | /* Stylesheet List */ 360 | /* ============================================= */ 361 | 362 | .stylesheets-list { 363 | padding: var(--space-2); 364 | margin-top: var(--space-6); 365 | width: 100%; 366 | text-align: left; 367 | background-color: var(--color-neutral-0); 368 | border-radius: var(--border-radius); 369 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 5px 0 rgba(0, 0, 0, 0.12); 370 | } 371 | 372 | /* ============================================= */ 373 | /* Stylesheet Item */ 374 | /* ============================================= */ 375 | 376 | .stylesheet { 377 | gap: var(--space-2); 378 | height: 50px; 379 | padding: var(--space-2); 380 | padding-inline: var(--space-3); 381 | margin-top: var(--space-4); 382 | background-color: var(--color-neutral-0); 383 | color: var(--color-neutral-8); 384 | font-size: 14px; 385 | font-weight: 300; 386 | border-radius: var(--border-radius); 387 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 5px 0 rgba(0, 0, 0, 0.12); 388 | } 389 | 390 | .stylesheet:first-child { 391 | margin-top: 0; 392 | } 393 | 394 | .stylesheet__url { 395 | flex: 1; 396 | overflow: hidden; 397 | text-overflow: ellipsis; 398 | text-align: left; 399 | } 400 | 401 | .stylesheet__actions { 402 | display: flex; 403 | justify-content: space-between; 404 | align-items: center; 405 | } 406 | 407 | .stylesheet input[type="text"] { 408 | height: 32px; 409 | } 410 | 411 | /* ============================================= */ 412 | /* Modal */ 413 | /* ============================================= */ 414 | 415 | .modal { 416 | position: fixed; 417 | top: 0; 418 | left: 0; 419 | width: 100%; 420 | height: 100%; 421 | display: flex; 422 | justify-content: center; 423 | align-items: center; 424 | flex-direction: column; 425 | z-index: 10; 426 | opacity: 0; 427 | pointer-events: none; 428 | text-align: left; 429 | } 430 | 431 | .modal__overlay { 432 | position: absolute; 433 | top: 0; 434 | left: 0; 435 | width: 100%; 436 | height: 100%; 437 | background-color: rgba(0, 0, 0, 0.3); 438 | } 439 | 440 | .modal__main { 441 | color: var(--color-neutral-7); 442 | background-color: #FFF; 443 | padding: var(--space-4); 444 | position: relative; 445 | z-index: 1; 446 | opacity: 0; 447 | border-radius: var(--border-radius); 448 | width: 600px; 449 | } 450 | 451 | .modal--show { 452 | opacity: 1; 453 | pointer-events: all; 454 | } 455 | 456 | .modal--show .modal__main { 457 | animation: fadeUp 300ms ease-out forwards; 458 | } 459 | 460 | @keyframes fadeUp { 461 | from { 462 | transform: translateY(20px); 463 | opacity: 0; 464 | } 465 | 466 | to { 467 | transform: translateY(0); 468 | opacity: 1; 469 | } 470 | } 471 | 472 | /* ============================================= */ 473 | /* Utilities */ 474 | /* ============================================= */ 475 | 476 | .hidden { 477 | display: none; 478 | } 479 | 480 | .note { 481 | font-size: 12px; 482 | color: var(--color-neutral-7); 483 | margin-block: var(--space-2); 484 | } 485 | 486 | .text-help { 487 | font-size: 14px; 488 | text-align: left; 489 | } 490 | 491 | .text-error { 492 | color: var(--color-red); 493 | font-size: 12px; 494 | text-align: left; 495 | } 496 | 497 | .text-align-end { 498 | text-align: end; 499 | } 500 | 501 | .whitespace-nowrap { 502 | white-space: nowrap; 503 | } 504 | -------------------------------------------------------------------------------- /dist/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Super CSS Inject Options 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /dist/popup.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | :root { 6 | --primary-color: #F03738; 7 | --primary-color-light: #F98484; 8 | --primary-color-dark: #BC0303; 9 | 10 | --green: #16C18E; 11 | --green-shade-1: #14B082; 12 | 13 | --red: #C13016; 14 | --red-shade-1: #912511; 15 | 16 | --gray: #D6D6D6; 17 | --gray-shade-1: #B0B0B0; 18 | --gray-shade-2: #C9C9C9; 19 | --gray-shade-3: #424242; 20 | 21 | --background: #F2F2F2; 22 | } 23 | 24 | .hidden { 25 | display: none !important; 26 | } 27 | 28 | body { 29 | margin: 0; 30 | background: var(--background); 31 | width: 300px; 32 | font-family: Roboto, sans-serif; 33 | box-sizing: border-box; 34 | padding-inline: 4px; 35 | } 36 | 37 | .preferences { 38 | display: flex; 39 | justify-content: center; 40 | align-items: center; 41 | position: absolute; 42 | top: 10px; 43 | right: 10px; 44 | border: 1px solid var(--gray); 45 | width: 24px; 46 | height: 24px; 47 | border-radius: 4px; 48 | background: var(--gray); 49 | cursor: pointer; 50 | } 51 | 52 | .preferences svg { 53 | width: 16px; 54 | height: auto; 55 | fill: var(--gray-shade-3); 56 | } 57 | 58 | .preferences:hover { 59 | background-color: var(--gray-shade-1); 60 | border-color: var(--gray-shade-1); 61 | } 62 | 63 | header { 64 | padding: 10px; 65 | padding-top: 14px; 66 | } 67 | 68 | header .column { 69 | display: flex; 70 | align-items: center; 71 | } 72 | 73 | header .logo { 74 | display: flex; 75 | justify-content: center; 76 | align-items: center; 77 | width: 45px; 78 | height: 45px; 79 | background: rgba(215, 215, 215, 0.35); 80 | border-radius: 50%; 81 | } 82 | 83 | header .title { 84 | font-size: 14px; 85 | color: #131313; 86 | margin: 0; 87 | margin-left: 10px; 88 | } 89 | 90 | .title-wrapper { 91 | display: flex; 92 | flex-direction: column; 93 | justify-content: center; 94 | height: 45px; 95 | } 96 | 97 | .search { 98 | position: relative; 99 | margin: 5px 10px; 100 | width: 145px; 101 | } 102 | 103 | .search .icon-search { 104 | width: 12px; 105 | height: 12px; 106 | position: absolute; 107 | top: 50%; 108 | left: 12px; 109 | transform: translate(-50%, -50%); 110 | } 111 | 112 | .search .icon-search path { 113 | fill: var(--gray-shade-1); 114 | } 115 | 116 | .search .icon-cross { 117 | width: 8px; 118 | height: 8px; 119 | padding: 4px; 120 | position: absolute; 121 | top: 50%; 122 | right: 4px; 123 | transform: translateY(-50%); 124 | cursor: pointer; 125 | } 126 | 127 | .search .icon-cross path { 128 | fill: var(--gray-shade-1); 129 | } 130 | 131 | .search .icon-cross:hover path { 132 | fill: var(--gray-shade-3); 133 | } 134 | 135 | .search input { 136 | border-radius: 3px; 137 | border: 1px solid var(--gray-shade-2); 138 | padding: 4px 6px; 139 | padding-left: 20px; 140 | padding-right: 18px; 141 | font-size: 12px; 142 | width: 100%; 143 | box-sizing: border-box; 144 | } 145 | 146 | .search input:focus { 147 | outline: none; 148 | } 149 | 150 | .stylesheets-message { 151 | letter-spacing: 0.6px; 152 | display: flex; 153 | flex-direction: column; 154 | justify-content: center; 155 | align-items: center; 156 | height: 60px; 157 | padding: 0 10px; 158 | margin-bottom: 20px; 159 | text-transform: uppercase; 160 | color: #C3C3C3; 161 | font-size: 12px; 162 | } 163 | 164 | .stylesheets-message button { 165 | font-size: 12px; 166 | font-weight: bold; 167 | height: 30px; 168 | line-height: 1; 169 | background-color: var(--green); 170 | border-color: var(--green); 171 | color: #FFF; 172 | cursor: pointer; 173 | text-transform: uppercase; 174 | border: 0; 175 | margin-top: 10px; 176 | padding: 10px 12px; 177 | border-radius: 3px; 178 | box-sizing: border-box; 179 | } 180 | 181 | .stylesheets-message button:hover { 182 | background-color: var(--green-shade-1); 183 | border-color: var(--green-shade-1); 184 | } 185 | 186 | .stylesheets-message button:active { 187 | box-shadow: inset 0px 3px 5px rgba(0, 0, 0, 0.3); 188 | } 189 | 190 | .stylesheets-message button:focus { 191 | outline: none; 192 | border-color: var(--green); 193 | } 194 | 195 | .stylesheets-list { 196 | padding: 10px; 197 | padding-top: 2px; 198 | max-height: 200px; 199 | overflow: auto; 200 | } 201 | 202 | .stylesheet { 203 | display: flex; 204 | justify-content: space-between; 205 | align-items: center; 206 | height: 30px; 207 | padding: 10px; 208 | margin-bottom: 10px; 209 | background-color: #FFF; 210 | color: #9D9D9D; 211 | font-family: 'Roboto Condensed', Roboto, sans-serif; 212 | font-size: 14px; 213 | font-weight: 300; 214 | border-radius: 4px; 215 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 5px 0 rgba(0, 0, 0, 0.12); 216 | box-sizing: border-box; 217 | cursor: pointer; 218 | gap: 8px; 219 | } 220 | 221 | .stylesheet--active, 222 | .stylesheet:hover { 223 | color: #222; 224 | } 225 | 226 | .stylesheet--active button.stylesheet__toggle { 227 | background-color: var(--green); 228 | } 229 | 230 | .stylesheet__url { 231 | flex: 1; 232 | overflow: hidden; 233 | text-overflow: ellipsis; 234 | text-align: start; 235 | white-space: nowrap; 236 | } 237 | 238 | .stylesheet__actions { 239 | display: flex; 240 | justify-content: space-between; 241 | } 242 | 243 | .stylesheet button { 244 | width: 20px; 245 | height: 20px; 246 | background-color: var(--gray); 247 | color: #FFF; 248 | border-radius: 4px; 249 | margin-left: 10px; 250 | position: relative; 251 | border: transparent; 252 | padding: 2px; 253 | display: flex; 254 | justify-content: center; 255 | align-items: center; 256 | outline: 0; 257 | cursor: pointer; 258 | box-shadow: none; 259 | } 260 | 261 | .stylesheet:hover button { 262 | background-color: var(--gray-shade-1); 263 | } 264 | 265 | .stylesheet--active:hover button.stylesheet__toggle { 266 | background-color: var(--green-shade-1); 267 | } 268 | 269 | .stylesheet:active button, 270 | .stylesheet button:active { 271 | box-shadow: inset 0px 3px 5px rgba(0, 0, 0, 0.3); 272 | } 273 | 274 | .stylesheet button:first-child { 275 | margin-left: 0; 276 | } 277 | 278 | .stylesheet--show-order .stylesheet__toggle { 279 | font-size: 0.9em; 280 | line-height: 0; 281 | } 282 | 283 | .stylesheet--show-order .stylesheet__toggle:after { 284 | display: none; 285 | } 286 | 287 | .stylesheets-list--emoji .stylesheet--show-order .stylesheet__toggle { 288 | font-size: 1em; 289 | } 290 | -------------------------------------------------------------------------------- /dist/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Super CSS Inject Popup 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import chrome from "sinon-chrome"; 2 | 3 | const config = { 4 | roots: [ 5 | "/src" 6 | ], 7 | testMatch: [ 8 | "**/__tests__/**/*.+(ts|tsx|js)", 9 | "**/?(*.)+(spec|test).+(ts|tsx|js)" 10 | ], 11 | transform: { "^.+\\.(ts|tsx)$": "ts-jest" }, 12 | globals: { chrome } 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "webpack --config webpack.prod.js", 7 | "dev": "webpack -w --config webpack.dev.js", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0" 13 | }, 14 | "devDependencies": { 15 | "@types/chrome": "^0.0.197", 16 | "@types/eslint": "^8.4.6", 17 | "@types/firefox-webext-browser": "^94.0.1", 18 | "@types/jest": "^29.5.3", 19 | "@types/node": "^18.7.14", 20 | "@types/react": "^18.0.17", 21 | "@types/react-dom": "^18.0.6", 22 | "@types/sinon-chrome": "^2.2.11", 23 | "@typescript-eslint/eslint-plugin": "^5.36.2", 24 | "@typescript-eslint/parser": "^5.36.2", 25 | "eslint": "^8.24.0", 26 | "eslint-plugin-react": "^7.31.8", 27 | "jest": "^29.6.1", 28 | "sinon-chrome": "^3.0.1", 29 | "ts-jest": "^29.1.1", 30 | "ts-loader": "^9.4.1", 31 | "ts-node": "^10.9.1", 32 | "typescript": "^4.8.3", 33 | "webpack": "^5.65.0", 34 | "webpack-cli": "^4.9.1", 35 | "webpack-merge": "^5.8.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonr/super-css-inject/23aecf3b5209da68e5dfbfe0a898d369cf05ba7b/preview.png -------------------------------------------------------------------------------- /src/Stylesheet.ts: -------------------------------------------------------------------------------- 1 | import { getStylesheetName } from "./utils"; 2 | 3 | export class Stylesheet { 4 | url: string; 5 | shortname: string; 6 | 7 | constructor (url: string, shortname = "") { 8 | this.url = url; 9 | this.shortname = shortname; 10 | } 11 | 12 | get name () { 13 | return this.shortname || getStylesheetName(this.url); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/SuperCSSInject.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "./types"; 2 | import { env } from "./utils"; 3 | 4 | let liveReloadSocket: WebSocket; 5 | let liveReloadConnectionAttempts = 0; 6 | let liveReloadIsConnected = false; 7 | const liveReloadMaxAttempts = 3; 8 | 9 | function main () { 10 | env.runtime.onMessage.addListener((message: Message) => { 11 | const { action, urlList, webSocketServerURL } = message; 12 | 13 | if (action == "inject") { 14 | updateInjectedStylesheets(urlList); 15 | 16 | if (urlList.length > 0) { 17 | if (!liveReloadSocket || liveReloadSocket.readyState === WebSocket.CLOSED) { 18 | if (liveReloadConnectionAttempts < liveReloadMaxAttempts) { 19 | console.log(`[SuperCSSInject]: Attempting to connect to Live Reload server on: "${webSocketServerURL}"`); 20 | listenToLiveReload(webSocketServerURL); 21 | } 22 | } 23 | } 24 | } 25 | }); 26 | 27 | env.runtime.sendMessage({ action: "load" }); 28 | maintainStylesheetsOrder(); 29 | } 30 | 31 | function listenToLiveReload (websocketServerURL: string) { 32 | liveReloadSocket = new WebSocket(websocketServerURL); 33 | 34 | liveReloadSocket.addEventListener("open", () => { 35 | console.log("[SuperCSSInject]: Connected successfully to Live Reload server:", websocketServerURL); 36 | liveReloadIsConnected = true; 37 | env.runtime.sendMessage({ action: "livereload_connect" }); 38 | }); 39 | 40 | liveReloadSocket.addEventListener("error", () => { 41 | liveReloadConnectionAttempts++; 42 | console.log("[SuperCSSInject]: Failed to connect to Live Reload server."); 43 | console.log("[SuperCSSInject]: Attempts remaining:", liveReloadMaxAttempts - liveReloadConnectionAttempts); 44 | }); 45 | 46 | liveReloadSocket.addEventListener("message", () => { 47 | console.log("[SuperCSSInject]: Injected stylesheets changed, refreshing..."); 48 | const injectedStylesheets: NodeListOf = document.head.querySelectorAll(".SuperCSSInject"); 49 | 50 | injectedStylesheets.forEach((stylesheet: HTMLLinkElement) => { 51 | // eslint-disable-next-line no-self-assign 52 | stylesheet.href = stylesheet.href; 53 | }); 54 | }); 55 | 56 | liveReloadSocket.addEventListener("close", () => { 57 | if (liveReloadIsConnected) { 58 | console.log("[SuperCSSInject]: Connection to Live Reload server was closed."); 59 | liveReloadIsConnected = false; 60 | liveReloadConnectionAttempts = 0; 61 | } 62 | }); 63 | } 64 | 65 | function updateInjectedStylesheets (urlList: string[]) { 66 | const links: NodeListOf = document.querySelectorAll("link.SuperCSSInject"); 67 | const currentList = Array.from(links).map((link) => link.href); 68 | 69 | if (currentList.length > urlList.length) { 70 | for (const url of currentList) { 71 | if (!urlList.includes(url)) { 72 | clearStylesheet(url); 73 | } 74 | } 75 | } else { 76 | for (const url of urlList) { 77 | if (!currentList.includes(url)) { 78 | injectStylesheet(url); 79 | } 80 | } 81 | } 82 | } 83 | 84 | function clearStylesheet (url: string) { 85 | const link = document.querySelector(`link[href="${url}"].SuperCSSInject`); 86 | link && link.remove(); 87 | } 88 | 89 | function injectStylesheet (url: string) { 90 | const link = createLinkElement(url); 91 | document.head.append(link); 92 | } 93 | 94 | function createLinkElement (url: string) { 95 | const link = document.createElement("link"); 96 | 97 | link.rel = "stylesheet"; 98 | link.type = "text/css"; 99 | link.href = url; 100 | link.classList.add("SuperCSSInject"); 101 | 102 | return link; 103 | } 104 | 105 | /** 106 | * Make sure the injected stylesheets are always placed last on the DOM 107 | * 108 | * This handles SPAs where is common for additional assets to be loaded after 109 | * the initial page load and ensures the injected styles retain priority. 110 | */ 111 | function maintainStylesheetsOrder () { 112 | const observer = new MutationObserver(() => { 113 | const injectedLinks: NodeListOf = document.head.querySelectorAll("link.SuperCSSInject"); 114 | 115 | if (injectedLinks.length > 0) { 116 | const links: NodeListOf = document.head.querySelectorAll("link[rel='stylesheet']"); 117 | const lastLink: HTMLLinkElement = links[links.length - 1]; 118 | const isInjectedStylesheetLast = lastLink.className === "SuperCSSInject"; 119 | 120 | if (!isInjectedStylesheetLast) { 121 | observer.disconnect(); 122 | moveInjectedStylesheets(); 123 | } 124 | } 125 | }); 126 | 127 | observer.observe(document.head, { childList: true }); 128 | } 129 | 130 | function moveInjectedStylesheets () { 131 | const links: NodeListOf = document.head.querySelectorAll("link.SuperCSSInject"); 132 | 133 | for (const link of links) { 134 | document.head.appendChild(link); 135 | } 136 | 137 | maintainStylesheetsOrder(); 138 | } 139 | 140 | window.addEventListener("load", main); 141 | 142 | // This is just to make the TS compiler happy 143 | export { }; 144 | -------------------------------------------------------------------------------- /src/common/If.tsx: -------------------------------------------------------------------------------- 1 | interface IProps { 2 | children: JSX.Element | JSX.Element[] | string; 3 | condition: boolean; 4 | } 5 | 6 | function If (props: IProps) { 7 | const { children, condition } = props; 8 | 9 | if (condition) { 10 | return <>{children}; 11 | } 12 | 13 | return null; 14 | } 15 | 16 | export default If; 17 | -------------------------------------------------------------------------------- /src/options/Options.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer, useState } from "react"; 2 | import { Stylesheet } from "../Stylesheet"; 3 | import { updateStorage } from "../storage"; 4 | import { Config, StorageData } from "../types"; 5 | import { OptionsReducer } from "./OptionsReducer"; 6 | import { ConfigModal } from "./components/ConfigModal"; 7 | import { StylesheetForm } from "./components/StylesheetForm"; 8 | import { StylesheetListTable } from "./components/StylesheetListTable"; 9 | 10 | interface IProps { 11 | initialState: StorageData; 12 | } 13 | 14 | function Options (props: IProps) { 15 | const [ state, setState ] = useReducer(OptionsReducer, props.initialState); 16 | const [ showConfigModal, setShowConfigModal ] = useState(false); 17 | 18 | useEffect(() => { 19 | updateStorage(state); 20 | }, [ state ]); 21 | 22 | const addStylesheet = (url: string) => { 23 | setState({ 24 | type: "add", 25 | url: url, 26 | }); 27 | }; 28 | 29 | const updateStylesheet = (prevStylesheet: Stylesheet, newStylesheet: Stylesheet) => { 30 | setState({ 31 | type: "update", 32 | prevStyleheet: prevStylesheet, 33 | newStylesheet: newStylesheet 34 | }); 35 | }; 36 | 37 | const removeStylesheet = (url: string) => { 38 | setState({ 39 | type: "remove", 40 | url: url 41 | }); 42 | }; 43 | 44 | const updateConfig = (config: Config) => { 45 | setState({ 46 | type: "updateConfig", 47 | config: config 48 | }); 49 | setShowConfigModal(false); 50 | }; 51 | 52 | return ( 53 | <> 54 |
55 |
56 | 57 |

Super CSS Inject Options

58 | 59 |
60 | 61 | {showConfigModal && ( 62 | setShowConfigModal(false)} 65 | > 66 | )} 67 |
68 |
69 |
70 | 71 | 72 | 77 | 78 | ); 79 | } 80 | 81 | export default Options; 82 | -------------------------------------------------------------------------------- /src/options/OptionsReducer.test.ts: -------------------------------------------------------------------------------- 1 | import { Stylesheet } from "../Stylesheet"; 2 | import { StorageData } from "../types"; 3 | import { OptionsReducer } from "./OptionsReducer"; 4 | 5 | const webSocketServerURL = "ws://localhost:35729/livereload"; 6 | 7 | const states: Record = { 8 | empty: { 9 | stylesheets: [], 10 | injected: {}, 11 | config: { webSocketServerURL: webSocketServerURL } 12 | 13 | }, 14 | oneStylesheet: { 15 | stylesheets: [ 16 | new Stylesheet("http://127.0.0.1:3000/public/css/theme-A.css"), 17 | ], 18 | injected: {}, 19 | config: { webSocketServerURL: webSocketServerURL } 20 | 21 | }, 22 | renamedStyleSheet: { 23 | stylesheets: [ 24 | new Stylesheet("http://127.0.0.1:3000/public/css/theme-B.css"), 25 | ], 26 | injected: {}, 27 | config: { webSocketServerURL: webSocketServerURL } 28 | 29 | }, 30 | oneInjectedStylesheet: { 31 | stylesheets: [ 32 | new Stylesheet("http://127.0.0.1:3000/public/css/theme-A.css"), 33 | ], 34 | injected: { 35 | "1010977386": [ 36 | "http://127.0.0.1:3000/public/css/theme-A.css", 37 | ] 38 | }, 39 | config: { webSocketServerURL: webSocketServerURL } 40 | 41 | }, 42 | multipleTabsInjected: { 43 | stylesheets: [ 44 | new Stylesheet("http://127.0.0.1:3000/public/css/theme-A.css"), 45 | ], 46 | injected: { 47 | "1010977386": [ 48 | "http://127.0.0.1:3000/public/css/theme-A.css", 49 | ], 50 | "2021021202": [ 51 | "http://127.0.0.1:3000/public/css/theme-A.css", 52 | ] 53 | }, 54 | config: { webSocketServerURL: webSocketServerURL } 55 | 56 | }, 57 | multipleInjectedStylesheets: { 58 | stylesheets: [ 59 | new Stylesheet("http://127.0.0.1:3000/public/css/theme-A.css"), 60 | new Stylesheet("http://127.0.0.1:3000/public/css/theme-B.css"), 61 | ], 62 | injected: { 63 | "1010977386": [ 64 | "http://127.0.0.1:3000/public/css/theme-A.css", 65 | "http://127.0.0.1:3000/public/css/theme-B.css" 66 | ] 67 | }, 68 | 69 | config: { webSocketServerURL: webSocketServerURL } 70 | 71 | }, 72 | }; 73 | 74 | describe("Adding and updating stylesheets", () => { 75 | test("Adds a stylesheet", () => { 76 | const urlToAdd = "http://127.0.0.1:3000/public/css/theme-A.css"; 77 | const updatedState = OptionsReducer(states.empty, { 78 | type: "add", 79 | url: urlToAdd 80 | }); 81 | 82 | expect(updatedState).toStrictEqual(states.oneStylesheet); 83 | }); 84 | 85 | test("Renames a stylesheet", () => { 86 | const prevStylesheet = new Stylesheet("http://127.0.0.1:3000/public/css/theme-A.css"); 87 | const newStylesheet = new Stylesheet("http://127.0.0.1:3000/public/css/theme-B.css"); 88 | 89 | const updatedState = OptionsReducer(states.oneStylesheet, { 90 | type: "update", 91 | prevStyleheet: prevStylesheet, 92 | newStylesheet: newStylesheet 93 | }); 94 | 95 | expect(updatedState).toStrictEqual(states.renamedStyleSheet); 96 | }); 97 | }); 98 | 99 | describe("Removing stylesheets", () => { 100 | test("Removes a stylesheet", () => { 101 | const urlToRemove = "http://127.0.0.1:3000/public/css/theme-A.css"; 102 | const updatedState = OptionsReducer(states.oneStylesheet, { 103 | type: "remove", 104 | url: urlToRemove 105 | }); 106 | 107 | expect(updatedState).toStrictEqual(states.empty); 108 | }); 109 | 110 | test("Clears from the injected stylesheets when removed", () => { 111 | const urlToRemove = "http://127.0.0.1:3000/public/css/theme-A.css"; 112 | const updatedState = OptionsReducer(states.oneInjectedStylesheet, { 113 | type: "remove", 114 | url: urlToRemove 115 | }); 116 | 117 | expect(updatedState).toStrictEqual(states.empty); 118 | }); 119 | 120 | test("Clears the stylesheet from all injected browser tabs", () => { 121 | const urlToRemove = "http://127.0.0.1:3000/public/css/theme-A.css"; 122 | const updatedState = OptionsReducer(states.multipleTabsInjected, { 123 | type: "remove", 124 | url: urlToRemove 125 | }); 126 | 127 | expect(updatedState).toStrictEqual(states.empty); 128 | }); 129 | 130 | test("Leaves other injected stylesheets untouched", () => { 131 | const urlToRemove = "http://127.0.0.1:3000/public/css/theme-B.css"; 132 | const updatedState = OptionsReducer(states.multipleInjectedStylesheets, { 133 | type: "remove", 134 | url: urlToRemove 135 | }); 136 | 137 | expect(updatedState).toStrictEqual(states.oneInjectedStylesheet); 138 | }); 139 | }); 140 | 141 | export { }; 142 | -------------------------------------------------------------------------------- /src/options/OptionsReducer.ts: -------------------------------------------------------------------------------- 1 | import { Stylesheet } from "../Stylesheet"; 2 | import { Config, InjectedTabs, StorageData } from "../types"; 3 | import { cond, validateURL } from "../utils"; 4 | 5 | type Action = 6 | | { type: "add"; url: string; } 7 | | { type: "update"; prevStyleheet: Stylesheet; newStylesheet: Stylesheet; } 8 | | { type: "remove"; url: string; } 9 | | { type: "updateConfig"; config: Config; }; 10 | 11 | export function OptionsReducer (state: StorageData, action: Action): StorageData { 12 | switch (action.type) { 13 | case "add": 14 | return add(state, action.url); 15 | 16 | case "update": 17 | return update(state, action.prevStyleheet, action.newStylesheet); 18 | 19 | case "remove": 20 | return remove(state, action.url); 21 | 22 | case "updateConfig": 23 | return updateConfig(state, action.config); 24 | 25 | default: 26 | return state; 27 | } 28 | } 29 | 30 | function add (state: StorageData, url: string): StorageData { 31 | const urlExists = state.stylesheets.find((stylesheet: Stylesheet) => stylesheet.url === url); 32 | const isValid = validateURL(url); 33 | 34 | if (urlExists || !isValid) { 35 | return state; 36 | } 37 | 38 | return { 39 | ...state, 40 | stylesheets: [ 41 | ...state.stylesheets, 42 | (new Stylesheet(url)) 43 | ] 44 | }; 45 | } 46 | 47 | function update (state: StorageData, prevStylesheet: Stylesheet, newStylesheet: Stylesheet): StorageData { 48 | const isDuplicated = state.stylesheets.find((item) => { return item.url === newStylesheet.url; }) && prevStylesheet.url !== newStylesheet.url; 49 | 50 | // If the new URL already exists, do nothing 51 | if (isDuplicated) { 52 | return state; 53 | } 54 | 55 | const updateStylesheet = (item: Stylesheet) => cond((item.url === prevStylesheet.url), newStylesheet, item); 56 | const stylesheets = state.stylesheets.map(updateStylesheet); 57 | const injected = updateInjectedURL(state.injected, prevStylesheet.url, newStylesheet.url); 58 | 59 | return { ...state, stylesheets, injected }; 60 | } 61 | 62 | function remove (state: StorageData, url: string): StorageData { 63 | const stylesheets = state.stylesheets.filter((item: Stylesheet) => item.url !== url); 64 | const injected = removeInjectedURL(state.injected, url); 65 | 66 | return { ...state, stylesheets, injected }; 67 | } 68 | 69 | function updateInjectedURL (injected: InjectedTabs, urlToUpdate: string, newURL: string): InjectedTabs { 70 | const updatedTabs = Object 71 | .entries(injected) 72 | .map(([ tabId, urlList ]) => { 73 | return [ 74 | tabId, 75 | urlList.map((url: string) => cond((url === urlToUpdate), newURL, url)) 76 | ]; 77 | }) 78 | .filter(([ _tabId, urlList ]) => urlList.length > 0); 79 | 80 | return Object.fromEntries(updatedTabs); 81 | } 82 | 83 | function removeInjectedURL (injected: InjectedTabs, urlToRemove: string): InjectedTabs { 84 | const updatedTabs = Object 85 | .entries(injected) 86 | .map(([ tabId, urlList ]) => { 87 | return [ 88 | tabId, 89 | urlList.filter((url: string) => url !== urlToRemove) 90 | ]; 91 | }) 92 | .filter(([ _tabId, urlList ]) => urlList.length > 0); 93 | 94 | return Object.fromEntries(updatedTabs); 95 | } 96 | 97 | function updateConfig (state: StorageData, config: Config): StorageData { 98 | console.log("Update config:", config); 99 | 100 | return { ...state, config: config }; 101 | } 102 | -------------------------------------------------------------------------------- /src/options/components/ConfigModal.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, FormEvent, useState } from "react"; 2 | import { defaultConfig } from "../../storage"; 3 | import { Config } from "../../types"; 4 | import { getClassName, validateWebSocketURL } from "../../utils"; 5 | 6 | interface IProps { 7 | config: Config; 8 | onUpdate: (config: Config) => unknown; 9 | onCancel: () => unknown; 10 | } 11 | 12 | export function ConfigModal (props: IProps) { 13 | const { config, onUpdate, onCancel } = props; 14 | const [ url, setURL ] = useState(config.webSocketServerURL); 15 | const [ formValidations, setFormValidations ] = useState({ 16 | websocketServerURL: { 17 | isValid: true, 18 | validationMessage: "" 19 | }, 20 | }); 21 | 22 | const validateForm = () => { 23 | const validations = structuredClone(formValidations); 24 | 25 | if (url.length > 0 && validateWebSocketURL(url)) { 26 | validations.websocketServerURL.isValid = true; 27 | validations.websocketServerURL.validationMessage = ""; 28 | } else { 29 | validations.websocketServerURL.isValid = false; 30 | validations.websocketServerURL.validationMessage = "The URL is not valid."; 31 | } 32 | 33 | setFormValidations(validations); 34 | 35 | return validations.websocketServerURL.isValid; 36 | }; 37 | 38 | const onSave = (ev: FormEvent) => { 39 | ev.preventDefault(); 40 | 41 | if (validateForm()) { 42 | onUpdate({ ...config, webSocketServerURL: url }); 43 | } 44 | }; 45 | 46 | const onResetDefaults = () => { 47 | setURL(defaultConfig.webSocketServerURL); 48 | }; 49 | 50 | const onEditURL = (ev: ChangeEvent) => { 51 | setURL(ev.target.value); 52 | }; 53 | 54 | const classNames = { 55 | websocketServerURL: getClassName([ 56 | "form-field", 57 | (formValidations.websocketServerURL.isValid ? "" : "form-field--not-valid") 58 | ]), 59 | }; 60 | 61 | return ( 62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 | 70 | 71 |
{formValidations.websocketServerURL.validationMessage}
72 |
The URL for the WebSocket server that Super CSS Inject will attempt to connect to enable live reload whenever there's a change to the injected stylesheets.
73 |
74 | 75 |
76 | 77 | 78 | 79 |
80 |
81 |
82 |
83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/options/components/EditModal.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, FormEvent, useState } from "react"; 2 | import { Stylesheet } from "../../Stylesheet"; 3 | import { assign, getClassName, validateURL } from "../../utils"; 4 | 5 | interface IProps { 6 | stylesheet: Stylesheet; 7 | onUpdate: (stylesheet: Stylesheet) => unknown; 8 | onCancel: () => unknown; 9 | } 10 | 11 | export function EditModal (props: IProps) { 12 | const { stylesheet, onUpdate, onCancel } = props; 13 | const [ editStylesheet, setEditStylesheet ] = useState(stylesheet); 14 | const [ formValidations, setFormValidations ] = useState({ 15 | url: { 16 | isValid: true, 17 | validationMessage: "" 18 | }, 19 | shortname: { 20 | isValid: true, 21 | validationMessage: "" 22 | } 23 | }); 24 | 25 | const validateForm = () => { 26 | const validations = structuredClone(formValidations); 27 | 28 | if (editStylesheet.url.length > 0 && validateURL(editStylesheet.url)) { 29 | validations.url.isValid = true; 30 | validations.url.validationMessage = ""; 31 | } else { 32 | validations.url.isValid = false; 33 | validations.url.validationMessage = "The URL is not valid."; 34 | } 35 | 36 | setFormValidations(validations); 37 | 38 | if (validations.url.isValid && validations.shortname.isValid) { 39 | return true; 40 | } 41 | 42 | return false; 43 | }; 44 | 45 | const onSave = (ev: FormEvent) => { 46 | ev.preventDefault(); 47 | 48 | if (validateForm()) { 49 | onUpdate(editStylesheet); 50 | } 51 | }; 52 | 53 | const onEdit = (key: string, value: string) => setEditStylesheet(assign(editStylesheet, key, value)); 54 | const onEditURL = (ev: ChangeEvent) => onEdit("url", ev.target.value); 55 | const onEditShortname = (ev: ChangeEvent) => onEdit("shortname", ev.target.value); 56 | 57 | const classNames = { 58 | formFieldURL: getClassName([ 59 | "form-field", 60 | (formValidations.url.isValid ? "" : "form-field--not-valid") 61 | ]), 62 | formFieldShortname: getClassName([ 63 | "form-field", 64 | (formValidations.shortname.isValid ? "" : "form-field--not-valid") 65 | ]) 66 | }; 67 | 68 | return ( 69 |
70 |
71 | 72 |
73 |
74 |
75 |
76 | 77 | 78 |
{formValidations.url.validationMessage}
79 |
80 | 81 |
82 | 83 | 84 |
{formValidations.shortname.validationMessage}
85 |
86 | 87 |
88 | 89 | 90 |
91 |
92 |
93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/options/components/StylesheetForm.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useState } from "react"; 2 | import { getClassName, validateURL } from "../../utils"; 3 | 4 | const isFirefox = /Firefox\/\d{1,2}/.test(navigator.userAgent); 5 | const mixedContentURL = "https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content#Warnings_in_Web_Console"; 6 | 7 | interface IProps { 8 | onSubmit: (url: string) => unknown; 9 | } 10 | 11 | export function StylesheetForm (props: IProps) { 12 | const { onSubmit } = props; 13 | const [ url, setURL ] = useState(""); 14 | const [ isFormValid, setValidForm ] = useState(true); 15 | 16 | const handleSubmit = (ev: FormEvent) => { 17 | ev.preventDefault(); 18 | 19 | const newURL = url.trim(); 20 | 21 | if (newURL.length === 0) { 22 | return false; 23 | } 24 | 25 | const isValid = validateURL(newURL); 26 | 27 | if (isValid) { 28 | setURL(""); 29 | onSubmit(newURL); 30 | setValidForm(true); 31 | } else { 32 | setValidForm(false); 33 | } 34 | }; 35 | 36 | const inputClassName = isFormValid ? "" : "not-valid"; 37 | 38 | const errorClassName = getClassName([ 39 | "text-error", 40 | isFormValid ? "hidden" : "", 41 | ]); 42 | 43 | const mixedContentClassName = getClassName([ 44 | "text-help", 45 | isFirefox ? "" : "hidden", 46 | ]); 47 | 48 | return ( 49 |
50 |
51 | setURL(ev.target.value)} 56 | placeholder="Add a CSS file URL here..." 57 | className={inputClassName} 58 | /> 59 | 64 |
65 | 66 |
67 | Please provide a valid URL. Example: http://localhost/my-theme.css 68 |
69 | 70 |
71 | Note: Firefox uses http://127.0.0.1 instead of http://localhost. 72 | Click here for more info. 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/options/components/StylesheetItemTableRow.tsx: -------------------------------------------------------------------------------- 1 | import { Stylesheet } from "../../Stylesheet"; 2 | 3 | interface IProps { 4 | stylesheet: Stylesheet; 5 | onEdit: (stylesheet: Stylesheet) => unknown; 6 | onRemove: (url: string) => unknown; 7 | } 8 | 9 | const iconEdit = ; 10 | const iconDelete = ; 11 | 12 | export function StylesheetItemTableRow (props: IProps) { 13 | const { stylesheet, onEdit, onRemove } = props; 14 | 15 | const handleRemove = () => onRemove(stylesheet.url); 16 | const handleEdit = () => onEdit(stylesheet); 17 | 18 | return ( 19 | <> 20 | 21 | 22 | 23 | {stylesheet.url} 24 | 25 | 26 | 27 | {stylesheet.shortname !== "" && ( 28 | 29 | {stylesheet.shortname} 30 | 31 | )} 32 | 33 | {stylesheet.shortname === "" && ( 34 | 35 | )} 36 | 37 | 38 | 45 | 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/options/components/StylesheetListTable.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Stylesheet } from "../../Stylesheet"; 3 | import If from "../../common/If"; 4 | import { getClassName } from "../../utils"; 5 | import { EditModal } from "./EditModal"; 6 | import { StylesheetItemTableRow } from "./StylesheetItemTableRow"; 7 | 8 | interface IProps { 9 | list: Stylesheet[]; 10 | onRemove: (url: string) => unknown; 11 | onUpdate: (prevStylesheet: Stylesheet, newStylesheet: Stylesheet) => unknown; 12 | } 13 | 14 | export function StylesheetListTable (props: IProps) { 15 | const { list, onRemove, onUpdate } = props; 16 | const [ isEdit, setIsEdit ] = useState(false); 17 | const [ editStylesheet, setEditStylesheet ] = useState(); 18 | 19 | const handleEdit = (stylesheet: Stylesheet) => { 20 | setEditStylesheet(stylesheet); 21 | setIsEdit(true); 22 | }; 23 | 24 | const handleCancel = () => setIsEdit(false); 25 | 26 | const handleUpdate = (newStylesheet: Stylesheet) => { 27 | if (editStylesheet) { 28 | onUpdate(editStylesheet, newStylesheet); 29 | setIsEdit(false); 30 | } 31 | }; 32 | 33 | const messageClassName = getClassName([ 34 | "stylesheets-message", 35 | (list.length > 0 ? "hidden" : "") 36 | ]); 37 | 38 | const showEditModal = () => { 39 | if (editStylesheet && isEdit) { 40 | return ( 41 | 46 | ); 47 | } 48 | 49 | return <>; 50 | }; 51 | 52 | return ( 53 | <> 54 |
No stylesheets added yet.
55 | 0}> 56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | {list.map((stylesheet, index) => { 67 | return ( 68 | 74 | ); 75 | })} 76 | 77 |
URLShort Name
78 | 79 | {showEditModal()} 80 |
81 |
82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { loadStorage } from "../storage"; 4 | import { StorageData } from "../types"; 5 | import Options from "./Options"; 6 | 7 | loadStorage().then((state: StorageData) => { 8 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 9 | 10 | 11 | 12 | ); 13 | }); 14 | -------------------------------------------------------------------------------- /src/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer, useState } from "react"; 2 | import { updateStorage } from "../storage"; 3 | import { PopupState } from "../types"; 4 | import { sendInjectMessageToTab, updateBadgesCount } from "../utils"; 5 | import { PopupEmptyMessage } from "./PopupEmptyMessage"; 6 | import { PopupHeader } from "./PopupHeader"; 7 | import { PopupPreferences } from "./PopupPreferences"; 8 | import { PopupReducer } from "./PopupReducer"; 9 | import { PopupSearch } from "./PopupSearch"; 10 | import { StylesheetList } from "./StylesheetList"; 11 | 12 | interface IProps { 13 | initialState: PopupState; 14 | } 15 | 16 | function Popup (props: IProps) { 17 | const [ state, setState ] = useReducer(PopupReducer, props.initialState); 18 | const [ searchValue, setSearchValue ] = useState(""); 19 | 20 | useEffect(() => { 21 | if (state.tabId) { 22 | const tabId = state.tabId; 23 | 24 | updateStorage(state).then(() => { 25 | sendInjectMessageToTab({ 26 | tabId: tabId, 27 | urlList: (state.injected[tabId] || []), 28 | webSocketServerURL: state.config.webSocketServerURL 29 | }); 30 | updateBadgesCount(); 31 | }); 32 | } 33 | }, [ state ]); 34 | 35 | const handleSelection = (isActive: boolean, url: string) => { 36 | if (state.tabId) { 37 | console.log("Toggle active Stylesheet"); 38 | 39 | setState({ 40 | type: isActive ? "inject" : "clear", 41 | tabId: state.tabId, 42 | url: url, 43 | }); 44 | } 45 | }; 46 | 47 | const activeStylesheets = (): string[] => { 48 | if (state.tabId) { 49 | return state.injected[state.tabId] || []; 50 | } 51 | 52 | return []; 53 | }; 54 | 55 | const renderStylesheetsList = () => { 56 | if (state.stylesheets.length > 0) { 57 | return ( 58 | 64 | ); 65 | } 66 | 67 | return ; 68 | }; 69 | 70 | const renderSearch = () => { 71 | if (state.stylesheets.length >= 6) { 72 | return ( 73 | 74 | ); 75 | } 76 | 77 | return null; 78 | }; 79 | 80 | return ( 81 | <> 82 | 83 | {renderSearch()} 84 | {renderStylesheetsList()} 85 | 86 | ); 87 | } 88 | 89 | export default Popup; 90 | -------------------------------------------------------------------------------- /src/popup/PopupEmptyMessage.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "../utils"; 2 | 3 | export function PopupEmptyMessage () { 4 | const openOptionsPage = () => env.runtime.openOptionsPage(); 5 | 6 | return ( 7 |
8 |
No stylesheets added yet.
9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/popup/PopupHeader.tsx: -------------------------------------------------------------------------------- 1 | interface IProps { 2 | children: JSX.Element | JSX.Element[] | null; 3 | } 4 | 5 | export function PopupHeader (props: IProps) { 6 | const { children } = props; 7 | 8 | return ( 9 |
10 |
11 |
12 | 13 |
14 | 15 |
16 |

Super CSS Inject

17 | {children} 18 |
19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/popup/PopupPreferences.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "../utils"; 2 | 3 | export function PopupPreferences () { 4 | const openOptionsPage = () => env.runtime.openOptionsPage(); 5 | 6 | return ( 7 |
13 | 14 | 15 | 16 | 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/popup/PopupReducer.test.ts: -------------------------------------------------------------------------------- 1 | import { PopupState } from "../types"; 2 | import { PopupReducer } from "./PopupReducer"; 3 | 4 | const tabId = 1010977386; 5 | const stylesheetA = "http://127.0.0.1:3000/public/css/theme-A.css"; 6 | const stylesheetB = "http://127.0.0.1:3000/public/css/theme-B.css"; 7 | const webSocketServerURL = "ws://localhost:35729/livereload"; 8 | 9 | const states: Record = { 10 | noInjectedStylesheet: { 11 | tabId: tabId, 12 | stylesheets: [ 13 | stylesheetA, 14 | stylesheetB, 15 | ], 16 | injected: {}, 17 | config: { webSocketServerURL: webSocketServerURL } 18 | }, 19 | oneInjectedStylesheet: { 20 | tabId: tabId, 21 | stylesheets: [ 22 | stylesheetA, 23 | stylesheetB, 24 | ], 25 | injected: { 26 | [tabId]: [ 27 | stylesheetA, 28 | ] 29 | }, 30 | config: { webSocketServerURL: webSocketServerURL } 31 | }, 32 | multipleInjectedStylesheets: { 33 | tabId: tabId, 34 | stylesheets: [ 35 | stylesheetA, 36 | stylesheetB, 37 | ], 38 | injected: { 39 | [tabId]: [ 40 | stylesheetA, 41 | stylesheetB, 42 | ] 43 | }, 44 | config: { webSocketServerURL: webSocketServerURL } 45 | }, 46 | }; 47 | 48 | describe("Injecting stylesheets", () => { 49 | test("Injects one stylesheet", () => { 50 | const initialState = states.noInjectedStylesheet; 51 | const expectedState = states.oneInjectedStylesheet; 52 | 53 | const updatedState = PopupReducer(initialState, { 54 | type: "inject", 55 | tabId: tabId, 56 | url: stylesheetA 57 | }); 58 | 59 | expect(updatedState).toEqual(expectedState); 60 | }); 61 | 62 | test("Injects additional stylesheets", () => { 63 | const initialState = states.oneInjectedStylesheet; 64 | const expectedState = states.multipleInjectedStylesheets; 65 | 66 | const updatedState = PopupReducer(initialState, { 67 | type: "inject", 68 | tabId: tabId, 69 | url: stylesheetB 70 | }); 71 | 72 | expect(updatedState).toEqual(expectedState); 73 | }); 74 | }); 75 | 76 | describe("Clearing injected stylesheets", () => { 77 | test("Clears one injected stylesheet", () => { 78 | const initialState = states.multipleInjectedStylesheets; 79 | const expectedState = states.oneInjectedStylesheet; 80 | 81 | const updatedState = PopupReducer(initialState, { 82 | type: "clear", 83 | tabId: tabId, 84 | url: stylesheetB 85 | }); 86 | 87 | expect(updatedState).toEqual(expectedState); 88 | }); 89 | 90 | test("Clears remaining injected stylesheet", () => { 91 | const initialState = states.oneInjectedStylesheet; 92 | const expectedState = states.noInjectedStylesheet; 93 | 94 | const updatedState = PopupReducer(initialState, { 95 | type: "clear", 96 | tabId: tabId, 97 | url: stylesheetA 98 | }); 99 | 100 | expect(updatedState).toEqual(expectedState); 101 | }); 102 | }); 103 | 104 | export { }; 105 | -------------------------------------------------------------------------------- /src/popup/PopupReducer.ts: -------------------------------------------------------------------------------- 1 | import { PopupState } from "../types"; 2 | 3 | type Action = 4 | | { type: "inject"; url: string; tabId: number; } 5 | | { type: "clear"; url: string; tabId: number; }; 6 | 7 | export function PopupReducer (state: PopupState, action: Action): PopupState { 8 | switch (action.type) { 9 | case "inject": 10 | return inject(state, action.tabId, action.url); 11 | 12 | case "clear": 13 | return clear(state, action.tabId, action.url); 14 | 15 | default: 16 | return state; 17 | } 18 | } 19 | 20 | function inject (state: PopupState, tabId: number, url: string): PopupState { 21 | const { injected } = structuredClone(state); 22 | 23 | if (injected[tabId]) { 24 | if (!injected[tabId]?.includes(url)) { 25 | injected[tabId]?.push(url); 26 | } 27 | } else { 28 | injected[tabId] = [ url ]; 29 | } 30 | 31 | return { 32 | ...state, 33 | injected 34 | }; 35 | } 36 | 37 | function clear (state: PopupState, tabId: number, url: string): PopupState { 38 | const { injected } = structuredClone(state); 39 | 40 | if (injected[tabId]) { 41 | injected[tabId] = injected[tabId]?.filter((_url: string) => _url !== url); 42 | 43 | if (injected[tabId]?.length == 0) { 44 | delete injected[tabId]; 45 | } 46 | } 47 | 48 | return { 49 | ...state, 50 | injected 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/popup/PopupSearch.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { getClassName } from "../utils"; 3 | 4 | interface IProps { 5 | search: string; 6 | onChange: (search: string) => unknown; 7 | } 8 | 9 | export function PopupSearch (props: IProps) { 10 | const { search, onChange } = props; 11 | const searchInputEl = useRef(null); 12 | 13 | const handleOnChange = () => { 14 | if (searchInputEl.current) { 15 | onChange(searchInputEl.current.value); 16 | } 17 | }; 18 | 19 | const clearInput = () => { 20 | if (searchInputEl.current) { 21 | searchInputEl.current.focus(); 22 | onChange(""); 23 | } 24 | }; 25 | 26 | const iconClassName = getClassName([ 27 | "icon-cross", 28 | search.length > 0 ? "" : "hidden", 29 | ]); 30 | 31 | return ( 32 |
33 | 40 | 45 | 46 | 47 | 55 | 59 | 60 | 61 | 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/popup/StylesheetItem.tsx: -------------------------------------------------------------------------------- 1 | import { Stylesheet } from "../Stylesheet"; 2 | import { getClassName } from "../utils"; 3 | 4 | interface IProps { 5 | stylesheet: Stylesheet; 6 | isSelected: boolean; 7 | isHidden: boolean; 8 | selectionOrder: string | null; 9 | onActiveToggle: (active: boolean) => unknown; 10 | } 11 | 12 | const iconCheck = ; 13 | 14 | export function StylesheetItem (props: IProps) { 15 | const { stylesheet, isSelected, selectionOrder, isHidden, onActiveToggle } = props; 16 | const handleActiveChange = () => onActiveToggle(!isSelected); 17 | const stylesheetName = stylesheet.name; 18 | 19 | const className = getClassName([ 20 | "stylesheet", 21 | isHidden ? "hidden" : "", 22 | isSelected ? "stylesheet--active" : "", 23 | selectionOrder !== null ? "stylesheet--show-order" : "", 24 | ]); 25 | 26 | return ( 27 |
28 |
29 | 30 |
31 |
32 | {stylesheetName} 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/popup/StylesheetList.tsx: -------------------------------------------------------------------------------- 1 | import { Stylesheet } from "../Stylesheet"; 2 | import { 3 | getClassName, 4 | getSelectionOrder, 5 | maxSelectionCount 6 | } from "../utils"; 7 | import { StylesheetItem } from "./StylesheetItem"; 8 | 9 | interface IProps { 10 | list: Stylesheet[]; 11 | activeList: string[]; 12 | search: string; 13 | onSelectionChange: (isActive: boolean, url: string) => unknown; 14 | } 15 | 16 | export function StylesheetList (props: IProps) { 17 | const { list, activeList, search, onSelectionChange } = props; 18 | 19 | const searchIsEmpty = search.trim().length === 0; 20 | const searchRegex = new RegExp(search.trim(), "gi"); 21 | 22 | const stylesheets = list.map((stylesheet: Stylesheet, index: number) => { 23 | const isSelected = activeList.includes(stylesheet.url); 24 | const selectionOrder = getSelectionOrder(stylesheet.url, activeList); 25 | const isFiltered = !searchIsEmpty && stylesheet.name.match(searchRegex) === null; 26 | 27 | const handleActiveChange = (isActive: boolean) => { 28 | return onSelectionChange(isActive, stylesheet.url); 29 | }; 30 | 31 | return ( 32 | 40 | ); 41 | }); 42 | 43 | const listClassName = getClassName([ 44 | "stylesheets-list", 45 | activeList.length > maxSelectionCount ? "stylesheets-list--emoji" : "", 46 | ]); 47 | 48 | return
{stylesheets}
; 49 | } 50 | -------------------------------------------------------------------------------- /src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { loadStorage } from "../storage"; 4 | import { PopupState } from "../types"; 5 | import { getCurrentTab } from "../utils"; 6 | import Popup from "./Popup"; 7 | 8 | async function getInitialPopupState (): Promise { 9 | const [ storage, currentTab ] = await Promise.all([ 10 | loadStorage(), 11 | getCurrentTab() 12 | ]); 13 | 14 | return { 15 | stylesheets: storage.stylesheets, 16 | injected: storage.injected, 17 | config: storage.config, 18 | tabId: currentTab?.id, 19 | }; 20 | } 21 | 22 | getInitialPopupState().then((state: PopupState) => { 23 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 24 | 25 | 26 | 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import { Stylesheet } from "./Stylesheet"; 2 | import { Config, StorageData } from "./types"; 3 | import { env, sortByName } from "./utils"; 4 | 5 | export const defaultConfig: Config = { webSocketServerURL: "ws://localhost:35729/livereload" }; 6 | 7 | export async function loadStorage (): Promise { 8 | const { SuperCSSInject } = await env.storage.local.get("SuperCSSInject"); 9 | const { stylesheets, injected, config } = SuperCSSInject || {}; 10 | 11 | return { 12 | stylesheets: importStylesheets(stylesheets || []), 13 | injected: injected || {}, 14 | config: config ? { ...defaultConfig, ...config } : {} 15 | }; 16 | } 17 | 18 | export function updateStorage (data: StorageData): Promise { 19 | return env.storage.local.set({ SuperCSSInject: data }); 20 | } 21 | 22 | function importStylesheets (stylesheets: Stylesheet[] | string[]): Stylesheet[] { 23 | return stylesheets.map((stylesheet: Stylesheet | string) => { 24 | if (typeof stylesheet === "string") { 25 | return new Stylesheet(stylesheet); 26 | } 27 | 28 | return new Stylesheet(stylesheet.url, stylesheet.shortname); 29 | }).sort(sortByName); 30 | } 31 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | export type Tab = chrome.tabs.Tab | undefined; 2 | export type TabId = number | undefined; 3 | 4 | export interface Config { 5 | webSocketServerURL: string; 6 | } 7 | 8 | export interface InjectedTabs { 9 | [id: number]: string[] | undefined; 10 | } 11 | 12 | export interface StorageData { 13 | stylesheets: Stylesheet[]; 14 | injected: InjectedTabs; 15 | config: Config; 16 | } 17 | 18 | export interface PopupState extends StorageData { 19 | tabId: TabId; 20 | } 21 | 22 | export interface MessageData { 23 | tabId: number; 24 | urlList: string[]; 25 | webSocketServerURL: string; 26 | } 27 | 28 | export interface Message extends MessageData { 29 | action: "inject"; 30 | } 31 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Stylesheet } from "./Stylesheet"; 2 | import { loadStorage } from "./storage"; 3 | import { MessageData, Tab } from "./types"; 4 | 5 | /** 6 | * Alias for accessing the browser extension API. 7 | * 8 | * Chrome uses `chrome`. Firefox uses `browser`. 9 | */ 10 | export const env = chrome || browser; 11 | 12 | /** 13 | * Extracts the last portion of the URL, if the last part of the URL 14 | * is a filename (eg.: `theme.css`) it will return that as result. 15 | * 16 | * Example: "http://localhost/my-theme.css" => "my-theme.css" 17 | * 18 | * @param url A stylesheet URL 19 | * @returns The last portion of the URL 20 | */ 21 | export function getStylesheetName (url: string) { 22 | const urlParts = url.split("/"); 23 | 24 | return urlParts[urlParts.length - 1]; 25 | } 26 | 27 | /** 28 | * Sorts URLs by the last part, aka the filename. 29 | * 30 | * @param stylesheetA Stylesheet 31 | * @param stylesheetB Stylesheet 32 | * @returns Order between the two strings 33 | */ 34 | export function sortByName (stylesheetA: Stylesheet, stylesheetB: Stylesheet) { 35 | const nameA = stylesheetA.name.toLowerCase(); 36 | const nameB = stylesheetB.name.toLowerCase(); 37 | 38 | if (nameA < nameB) { 39 | return -1; 40 | } else if (nameA > nameB) { 41 | return 1; 42 | } 43 | 44 | return 0; 45 | } 46 | 47 | /** 48 | * Get the injected stylesheets for a browser tab 49 | * 50 | * @returns Object with list of stylesheets active per browser tab 51 | */ 52 | // export async function getInjectedStylesheets (tabId: number): Promise { 53 | // const storage = await loadStorage(); 54 | 55 | // return storage.injected[tabId] || []; 56 | // } 57 | 58 | /** 59 | * Tries to get the current active browser tab (if any). 60 | * It returns either a Tab object or a undefined result, wrapped in a Promise. 61 | * 62 | * @returns Tab information wrapped in a Promise 63 | */ 64 | export async function getCurrentTab (): Promise { 65 | const queryOptions = { 66 | active: true, 67 | currentWindow: true 68 | }; 69 | 70 | // `tab` will either be a `tabs.Tab` instance or `undefined`. 71 | const [ tab ] = await env.tabs.query(queryOptions); 72 | 73 | return tab; 74 | } 75 | 76 | /** 77 | * Helper function to create a CSS classes string from an array. 78 | * 79 | * Example: `["class-a", "class-b"]` => `"class-a class-b"` 80 | * 81 | * @param classes An array of CSS classes 82 | * @returns A string with CSS classes separated by spaces 83 | */ 84 | export function getClassName (classes: string[]): string { 85 | return classes.join(" ").trim(); 86 | } 87 | 88 | /** 89 | * Sets the badge text for the extension icon on a specific browser tab. 90 | * Used mainly to highlight the current number of injected Stylesheets on a tab. 91 | * 92 | * @param tabId Browser tab identifier 93 | * @param text The text content for the badge 94 | */ 95 | export function updateBadgeText (tabId: number, text: string) { 96 | env.action.setBadgeText({ 97 | tabId: tabId, 98 | text: text 99 | }); 100 | } 101 | 102 | export function updateBadgeCount (injected: string[], tabId: number) { 103 | if (injected && injected.length > 0) { 104 | console.log("Update count in Tab:", tabId); 105 | updateBadgeText(tabId, injected.length.toString()); 106 | } else { 107 | console.log("Clear count in Tab:", tabId); 108 | updateBadgeText(tabId, ""); 109 | } 110 | } 111 | 112 | export async function updateBadgesCount () { 113 | const { injected } = await loadStorage(); 114 | const tabs: chrome.tabs.Tab[] = await env.tabs.query({}); 115 | 116 | for (const tab of tabs) { 117 | const tabId = tab.id; 118 | 119 | if (tabId) { 120 | updateBadgeCount(injected[tabId] || [], tabId); 121 | } 122 | } 123 | } 124 | 125 | /** 126 | * Validates an URL. 127 | * 128 | * @param url The URL string to validate 129 | * @returns true or false 130 | */ 131 | export function validateURL (url: string): boolean { 132 | try { 133 | new URL(url); 134 | } catch (error) { 135 | return false; 136 | } 137 | 138 | return true; 139 | } 140 | 141 | /** 142 | * Validates an WebSocket URL. 143 | * 144 | * @param url The URL string to validate 145 | * @returns true or false 146 | */ 147 | export function validateWebSocketURL (url: string): boolean { 148 | return url.match(/^ws:\/\/|wss:\/\//) !== null; 149 | } 150 | 151 | /** 152 | * Max number of selected stylesheets in a single browser tab. 153 | * This is only for the purpose of displaying the selection order in the popup. 154 | * If you're injecting 10 or more stylesheets at once, 155 | * you probably need to re-think something. 156 | */ 157 | export const maxSelectionCount = 9; 158 | 159 | /** 160 | * Get the selection order of a stylesheet URL when there's more than 161 | * one stylesheet selected in a browser tab. 162 | * 163 | * @param url The current stylesheet URL 164 | * @param selectedList A Set of the selected stylesheets for the current browser tab 165 | * @returns A string with the order or null if there's only a single tab selected 166 | */ 167 | export function getSelectionOrder (url: string, selectedList: string[]) { 168 | if (selectedList.includes(url) && selectedList.length > 1) { 169 | // I mean... 170 | if (selectedList.length > maxSelectionCount) { 171 | return "🤔"; 172 | } 173 | 174 | const order = [ ...selectedList ].indexOf(url) + 1; 175 | 176 | return `#${order}`; 177 | } 178 | 179 | return null; 180 | } 181 | 182 | /** 183 | * Sends an "inject" message to a browser tab with a list of stylesheet URLs. 184 | * Sent by: Background Worker 185 | * 186 | * @param tabId Browser tab identifier 187 | * @param urlList List of stylesheet URLs 188 | * @returns Promise 189 | */ 190 | export function sendInjectMessageToTab (data: MessageData) { 191 | return env.tabs.sendMessage(data.tabId, { action: "inject", ...data }); 192 | } 193 | 194 | export function cond (cond: boolean, trueValue: A, falseValue: B) { 195 | return cond ? trueValue : falseValue; 196 | } 197 | 198 | export function assign (obj: T, key: string, value: unknown) { 199 | return { ...obj, [key]: value }; 200 | } 201 | -------------------------------------------------------------------------------- /src/worker/background.ts: -------------------------------------------------------------------------------- 1 | import { loadStorage, updateStorage } from "../storage"; 2 | import { TabId } from "../types"; 3 | import { env, sendInjectMessageToTab, updateBadgeCount } from "../utils"; 4 | 5 | async function onPageLoad (tabId: number) { 6 | const storage = await loadStorage(); 7 | const injected = storage.injected[tabId] || []; 8 | 9 | sendInjectMessageToTab({ 10 | tabId: tabId, 11 | urlList: injected, 12 | webSocketServerURL: storage.config.webSocketServerURL 13 | }); 14 | updateBadgeCount(injected, tabId); 15 | } 16 | 17 | env.runtime.onMessage.addListener((message, sender) => { 18 | const tabId: TabId = message.tabId || sender.tab?.id; 19 | 20 | console.log("Message: ", message); 21 | 22 | if (message.action === "load" && tabId) { 23 | onPageLoad(tabId); 24 | } 25 | }); 26 | 27 | env.tabs.onRemoved.addListener(async (tabId) => { 28 | const storage = await loadStorage(); 29 | const hasTab = storage.injected[tabId] !== undefined; 30 | 31 | if (hasTab) { 32 | delete storage.injected[tabId]; 33 | updateStorage(storage); 34 | console.log("Tab closed:", tabId); 35 | } 36 | }); 37 | 38 | 39 | // This is just to make the TS compiler happy 40 | export { }; 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "DOM", 6 | "DOM.Iterable", 7 | "ESNext" 8 | ], 9 | "allowJs": false, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "module": "ESNext", 16 | "moduleResolution": "Node", 17 | "noFallthroughCasesInSwitch": true, 18 | "resolveJsonModule": true, 19 | "skipLibCheck": true, 20 | "rootDir": "./src", 21 | "outDir": "./dist/js", 22 | "strict": true, 23 | }, 24 | "include": [ 25 | "src" 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const path = require("path"); 4 | 5 | module.exports = { 6 | entry: { 7 | background: path.join(__dirname, "src/worker/background.ts"), 8 | options: path.join(__dirname, "src/options/index.tsx"), 9 | popup: path.join(__dirname, "src/popup/index.tsx"), 10 | SuperCSSInject: path.join(__dirname, "src/SuperCSSInject.ts"), 11 | }, 12 | output: { 13 | path: path.join(__dirname, "dist/js"), 14 | filename: "[name].js", 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | exclude: /node_modules/, 20 | test: /\.tsx?$/, 21 | use: "ts-loader", 22 | }, 23 | { 24 | test: /\.css$/, 25 | use: ["css-loader"], 26 | }, 27 | ], 28 | }, 29 | // Setup @src path resolution for TypeScript files 30 | resolve: { 31 | extensions: [".ts", ".tsx", ".js"], 32 | alias: { 33 | "@src": path.resolve(__dirname, "src/"), 34 | }, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const { merge } = require("webpack-merge"); 4 | const common = require("./webpack.common.js"); 5 | 6 | module.exports = merge(common, { 7 | mode: "development", 8 | devtool: "inline-source-map", 9 | }); 10 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const { merge } = require("webpack-merge"); 4 | const common = require("./webpack.common.js"); 5 | 6 | module.exports = merge(common, { 7 | mode: "production", 8 | }); 9 | --------------------------------------------------------------------------------