├── .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 |
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 |
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 |
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 |
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 | URL |
61 | Short Name |
62 | |
63 |
64 |
65 |
66 | {list.map((stylesheet, index) => {
67 | return (
68 |
74 | );
75 | })}
76 |
77 |
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 |
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 |
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 |
46 |
47 |
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 |
--------------------------------------------------------------------------------