├── .eslintrc ├── .gitignore ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── doc ├── SCR-20230724-qvt.png ├── SCR-20230724-qzl.png ├── SCR-20230816-ttyt.png ├── SCR-20230816-txif.png ├── SCR-20230816-tyvw.png ├── SCR-20230816-uaac.png ├── SCR-20230816-uaxy.png ├── SCR-20230816-umhx.png ├── SCR-20230816-uoto.png ├── alipay.jpg ├── demo-video.gif ├── screen.jpg └── wechat.jpeg ├── jest.config.js ├── manifest.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── icon-128.png ├── icon-16.png ├── icon-32.png ├── icon-48.png └── vite.svg ├── src ├── assets │ ├── fonts │ │ └── SF-Pro.ttf │ ├── img │ │ ├── active-group.png │ │ ├── cup.png │ │ ├── default-image.png │ │ ├── icon-128.png │ │ ├── image.dark.png │ │ ├── image.light.png │ │ ├── logo.svg │ │ ├── moon.png │ │ ├── pin.png │ │ ├── sun.png │ │ ├── thumb.png │ │ ├── trash.png │ │ └── wand.and.stars.png │ └── style │ │ └── theme.scss ├── common │ ├── keymap.tsx │ ├── lock.tsx │ └── optionsConfig.tsx ├── global.d.ts ├── pages │ ├── background │ │ └── index.tsx │ ├── content │ │ ├── index.tsx │ │ └── style.scss │ ├── options │ │ ├── Options.css │ │ ├── Options.tsx │ │ ├── index.css │ │ ├── index.html │ │ └── index.tsx │ ├── panel │ │ ├── App.tsx │ │ ├── Common.tsx │ │ ├── Group.tsx │ │ ├── NewGroup.tsx │ │ ├── index.html │ │ ├── index.scss │ │ └── main.tsx │ └── popup │ │ ├── App.tsx │ │ ├── Toolbar.tsx │ │ ├── Window.tsx │ │ ├── index.css │ │ ├── index.html │ │ └── main.tsx └── vite-env.d.ts ├── tailwind.config.js ├── test-utils └── jest.setup.js ├── tsconfig.json ├── utils ├── log.ts ├── manifest-parser │ └── index.ts ├── plugins │ ├── add-hmr.ts │ ├── custom-dynamic-import.ts │ ├── make-manifest.ts │ └── watch-rebuild.ts └── reload │ ├── constant.ts │ ├── initReloadClient.ts │ ├── initReloadServer.ts │ ├── injections │ ├── script.ts │ └── view.ts │ ├── interpreter │ ├── index.ts │ └── types.ts │ ├── rollup.config.ts │ └── utils.ts ├── vite.config.ts └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:prettier/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": "latest", 19 | "sourceType": "module" 20 | }, 21 | "plugins": ["react", "@typescript-eslint"], 22 | "settings": { 23 | "react": { 24 | "version": "detect" 25 | } 26 | }, 27 | "rules": { 28 | "react/react-in-jsx-scope": "off" 29 | }, 30 | "globals": { 31 | "chrome": "readonly" 32 | }, 33 | "ignorePatterns": ["watch.js", "dist/**"] 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # build 8 | /dist 9 | 10 | # etc 11 | .DS_Store 12 | .env.local 13 | .idea 14 | 15 | # compiled 16 | utils/reload/*.js 17 | utils/reload/injections/*.js 18 | public/manifest.json 19 | .output -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.12.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "trailingComma": "es5", 4 | "arrowParens": "always" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 sipt 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 | 2 | 3 | [![Stargazers][stars-shield]][stars-url] 4 | [![Issues][issues-shield]][issues-url] 5 | [![MIT License][license-shield]][license-url] 6 | 7 | 8 |
9 |
10 | 11 | Logo 12 | 13 | 14 |

Tab Player

15 | 16 |

17 | 以一种简便的方式来管理你的 Chrome 标签。 18 |
19 |
20 | Install 21 | · 22 | Report Bug 23 | · 24 | Request Feature 25 |

26 |
27 | 28 | - [项目介绍](#项目介绍) 29 | - [安装](#安装) 30 | - [Tab Clean](#tab-clean) 31 | - [Tab 筛选](#tab-筛选) 32 | - [Tab 操作](#tab-操作) 33 | - [暗黑/明亮模式切换](#暗黑明亮模式切换) 34 | - [Tab Group](#tab-group) 35 | - [Tab Group 创建](#tab-group-创建) 36 | - [Tab Group 查看、聚焦、关闭](#tab-group-查看聚焦关闭) 37 | - [Options](#options) 38 | - [Roadmap](#roadmap) 39 | - [赞赏](#赞赏) 40 | - [License](#license) 41 | 42 | 43 | 44 | ## 项目介绍 45 | 46 | 这个插件有两个部分组成:Tab-Clean 和 Tab-Group。 47 | 48 | **Tab-Clean**: 主要面向在使用 Chrome 浏览网页时,会打开很多标签页的场景。这时候使用 Tab Player 可以轻松清理不需要的或目前无用的标签页,使 Chrome 更加清爽。 49 | 50 | **Tab-Group**: 更多是培养标签使用习惯,利用 Chrome 自带的 Group 功能,让一件事情聚焦在一个 Group 内。使用 Tab-Palyer 可以方便创建、切换和关闭 Group。 51 | 52 | 与有相同需求的人分享这个插件。 53 | 54 | ## 安装 55 | 56 | 前往 [Chrome Web Store - Tab Player](https://chrome.google.com/webstore/detail/tab-player/jnmgfgjcefakjoeoinpncbilkdnikbgc) 安装插件 57 | 58 | ## Tab Clean 59 | 60 | ![Screen Shot](doc/screen.jpg) 61 | 62 | [![Tab Player Screen Shot][product-screenshot]](https://github.com/sipt/tab-player) 63 | 64 | 在使用 Chrome 浏览网页时,可能会打开很多标签页,这时候使用 Tab Player 可以轻松清理不需要的或目前无用的标签页,使 Chrome 更加清爽。 65 | 66 | 虽然已经有很多 Chrome 插件可以管理标签页,但试用了很多都不能完全满足我的需求: 67 | 68 | - 可以通过关键词模糊匹配或与标签页当前的状态配合,快速批量选中。 69 | - 可以支持跨窗口选择,但也可以根据窗口来隔离。 70 | - 可以手动选择标签页,或排除一些不想关闭的标签页。 71 | - 可以快捷操作关闭和 Pin,支持全键盘操作。 72 | - 界面更美观。 73 | 74 | **!!! 只在 popup 内使用 !!!** 75 | 76 |

(back to top)

77 | 78 | ### Tab 筛选 79 | 80 | 你可以使用关键词(包含在 title 或 URL 中)进行筛选: 81 | 82 | - 可以使用保留词进行筛选(`@loading`,`@unloaded`,`@complete`,`@pinned`,`@unpinned`,`@audible`)。当使用保留词时,只能使用一个,并且需要放在输入框开头,用空格与后面的关键词分开。 83 | - 可以使用鼠标左键点击标签页来添加额外的标签页或取消选定的标签页。 84 | - 可以使用鼠标左键点击窗口来锁定筛选生效的窗口。 85 | 86 | ![Input Filter](doc/SCR-20230724-qvt.png) 87 | 88 | ### Tab 操作 89 | 90 | 可以在输入框中直接输入 `Enter` 或点击输入框右侧的 Magic 按钮,弹出可操作选项。支持全键盘操作,切换焦点使用 `Tab` 键。目前支持 `Close` 和 `Pin`。 91 | 92 | ![Operate Tab](doc/SCR-20230724-qzl.png) 93 | 94 |

(back to top)

95 | 96 | ### 暗黑/明亮模式切换 97 | 98 | 点击右上角的 月亮 或 太阳 按钮进行切换。 99 | 100 | ## Tab Group 101 | 102 | ![Tab Group ScreenShot](doc/SCR-20230816-umhx.png) 103 | 104 | 如果按照 Tab Group 的使用方法,则可以让你的 Chrome 清晰且有条理。 105 | 106 | 使用理念:把所有的事情聚焦在一个 Group 内。 107 | 108 | - 当有一个新的事情,需要查询网页时。新建一个 Tab Group 来处理。 109 | - 当前事情处理过程中,有优先级高的事情插进来,可以创建一个新的 Tab Group 并聚焦在上面。 110 | - 当前事情解决后,可以关闭这个 Tab Group。 111 | - 像需要放松休息时,需要逛逛论坛社交网站,可以放在一个 常驻 的 Tab Group 中,需要时聚焦在这个 Tab Group 上。 112 | 113 | 以上就是使用的理念,使用 Tab Palyer 可以轻松实现以上动作。 114 | 115 | ### Tab Group 创建 116 | 117 | 有两种创建方式: 118 | 119 | 1. 可以通过 omnibox 使用 `tp` 创建,默认使用 `[[` 切分颜色,如果不设置默认是 grey。(可选颜色:"grey","blue","red","yellow","green","pink","purple","cyan","orange") 120 | ![Omnibox create Tab Group](doc/SCR-20230816-ttyt.png) 121 | 122 | 2. 可以通过 `cmd + shift + o` 呼出 Tab Group 弹窗进行操作。默认使用 `[[` 切分颜色,不设置默认是 `grey`。(可选颜色:"grey","blue","red","yellow","green","pink","purple","cyan","orange";这个弹窗只能在可以运行 content script 的页面呼出) 123 | ![Panel create Tab Group](doc/SCR-20230816-txif.png) 124 | 不输入任何内容,会随机一个名称和颜色,输入 `Enter` 就会创建。 125 | ![Panel create Random Tab Group](doc/SCR-20230816-tyvw.png) 126 | 127 | ### Tab Group 查看、聚焦、关闭 128 | 129 | Tab Group 可以通过关键词搜索: 130 | ![Search Tab Group](doc/SCR-20230816-uaac.png) 131 | 132 | 在列表中右侧的小时钟图标表示当前聚焦,选中其它 item,按下 `Enter`,可以切换聚焦。**!!!当聚焦在一个 Tab Group 时,创建同一个 Window 下新的 Tab 会自动归入聚焦的 Group 中。** 133 | ![Focus Tab Group](doc/SCR-20230816-uaxy.png) 134 | 135 | 在选中一个 Tab 且 焦点在输入框中,按下 `Cmd + Enter` 可以关闭一个 Group。 136 | 137 | ## Options 138 | 139 | ![Tab-Player Options](doc/SCR-20230816-uoto.png) 140 | 141 | 142 | 143 | ## Roadmap 144 | 145 | - [x] 1.0 基础功能支持 146 | - 灵感来源 💡:在使用 Chrome 过程中,个人习惯会打开很多的 Tab。所以想能有个方便的方式批量关闭这些 Tab。 147 | - [x] 支持关键词筛选 148 | - [x] 支持保留词筛选 149 | - [x] 支持 鼠标选择/取消选择 Tab 150 | - [x] 支持 鼠标选择/取消选择 Window 151 | - [x] 支持 关闭/Pin Tab 152 | - [x] 支持关闭 Window 153 | - [x] 支持暗黑/明亮主题 154 | - [x] 1.1 Group Tab 155 | - 灵感来源 💡:有时候在处理一个问题时,会打开很多个 Tab,但他们又归属于一个来源,所以希望它们可以在一个 Group 中。我在处理一个新的问题时,可以再新建一个 Group,原生的交互并不友好。 156 | - [x] Tab Group 快速创建 157 | - [x] Tab Group 查看筛选 158 | - [x] Tab Group 关闭 159 | - [ ] 1.2 Switch Tab 160 | - 灵感来源 💡:Tab 之间的切换也希望可以像操作系统中的 `Alt(Opt)+Tab` 或 Cmd+` 一样丝滑。 161 | - [ ] Tab 切换 162 | - [ ] Group 切换 163 | - [ ] Group 创建与列表 164 | - [ ] 1.3 SmartBox & Options 165 | - [ ] 支持智能规则筛选(SmartBox) 166 | - [ ] Tab Status 167 | - [ ] Tab 长期不活跃 168 | - [ ] 预置策略 169 | - [ ] Options 170 | - [ ] 1.3 待计划。。。 171 | 172 |

(back to top)

173 | 174 | ## 赞赏 175 | 176 | 觉得这个项目不错,给个 Star 或 请我喝杯咖啡: 177 | |微信赞赏|支付宝赞赏| 178 | |---|---| 179 | |![微信赞赏](doc/wechat.jpeg)|![支付宝赞赏](doc/alipay.jpg)| 180 | 181 | 182 | 183 | ## License 184 | 185 | Distributed under the MIT License. See `LICENSE.txt` for more information. 186 | 187 |

(back to top)

188 | 189 | 190 | 191 | 192 | [stars-shield]: https://img.shields.io/github/stars/sipt/tab-player.svg 193 | [stars-url]: https://github.com/sipt/tab-player/stargazers 194 | [issues-shield]: https://img.shields.io/github/issues/sipt/tab-player.svg 195 | [issues-url]: https://github.com/sipt/tab-player/issues 196 | [license-shield]: https://img.shields.io/github/license/sipt/tab-player.svg 197 | [license-url]: https://github.com/sipt/tab-player/blob/master/LICENSE.txt 198 | [product-screenshot]: doc/demo-video.gif 199 | -------------------------------------------------------------------------------- /doc/SCR-20230724-qvt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/SCR-20230724-qvt.png -------------------------------------------------------------------------------- /doc/SCR-20230724-qzl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/SCR-20230724-qzl.png -------------------------------------------------------------------------------- /doc/SCR-20230816-ttyt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/SCR-20230816-ttyt.png -------------------------------------------------------------------------------- /doc/SCR-20230816-txif.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/SCR-20230816-txif.png -------------------------------------------------------------------------------- /doc/SCR-20230816-tyvw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/SCR-20230816-tyvw.png -------------------------------------------------------------------------------- /doc/SCR-20230816-uaac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/SCR-20230816-uaac.png -------------------------------------------------------------------------------- /doc/SCR-20230816-uaxy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/SCR-20230816-uaxy.png -------------------------------------------------------------------------------- /doc/SCR-20230816-umhx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/SCR-20230816-umhx.png -------------------------------------------------------------------------------- /doc/SCR-20230816-uoto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/SCR-20230816-uoto.png -------------------------------------------------------------------------------- /doc/alipay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/alipay.jpg -------------------------------------------------------------------------------- /doc/demo-video.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/demo-video.gif -------------------------------------------------------------------------------- /doc/screen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/screen.jpg -------------------------------------------------------------------------------- /doc/wechat.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/doc/wechat.jpeg -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/gs/f4d067nn1sx7q0x__98xhcmw0000gn/T/jest_dx", 15 | 16 | // Automatically clear mock calls, instances and results before every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: false, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: "coverage", 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: "v8", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // Force coverage collection from ignored files using an array of glob patterns 54 | // forceCoverageMatch: [], 55 | 56 | // A path to a module which exports an async function that is triggered once before all test suites 57 | // globalSetup: undefined, 58 | 59 | // A path to a module which exports an async function that is triggered once after all test suites 60 | // globalTeardown: undefined, 61 | 62 | // A set of global variables that need to be available in all test environments 63 | // globals: {}, 64 | 65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 66 | // maxWorkers: 2, 67 | 68 | // An array of directory names to be searched recursively up from the requiring module's location 69 | // moduleDirectories: [ 70 | // "node_modules" 71 | // ], 72 | 73 | // An array of file extensions your modules use 74 | // moduleFileExtensions: [ 75 | // "js", 76 | // "jsx", 77 | // "ts", 78 | // "tsx", 79 | // "json", 80 | // "node" 81 | // ], 82 | 83 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 84 | moduleNameMapper: { 85 | "^@src(.*)$": "/src$1", 86 | "^@assets(.*)$": "/src/assets$1", 87 | "^@pages(.*)$": "/src/pages$1", 88 | }, 89 | 90 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 91 | // modulePathIgnorePatterns: [], 92 | 93 | // Activates notifications for test results 94 | // notify: false, 95 | 96 | // An enum that specifies notification mode. Requires { notify: true } 97 | // notifyMode: "failure-change", 98 | 99 | // A preset that is used as a base for Jest's configuration 100 | preset: "ts-jest", 101 | 102 | // Run tests from one or more projects 103 | // projects: undefined, 104 | 105 | // Use this configuration option to add custom reporters to Jest 106 | // reporters: undefined, 107 | 108 | // Automatically reset mock state before every test 109 | // resetMocks: false, 110 | 111 | // Reset the module registry before running each individual test 112 | // resetModules: false, 113 | 114 | // A path to a custom resolver 115 | // resolver: undefined, 116 | 117 | // Automatically restore mock state and implementation before every test 118 | // restoreMocks: false, 119 | 120 | // The root directory that Jest should scan for tests and modules within 121 | // rootDir: undefined, 122 | 123 | // A list of paths to directories that Jest should use to search for files in 124 | // roots: [ 125 | // "" 126 | // ], 127 | 128 | // Allows you to use a custom runner instead of Jest's default test runner 129 | // runner: "jest-runner", 130 | 131 | // The paths to modules that run some code to configure or set up the testing environment before each test 132 | setupFiles: ["./test-utils/jest.setup.js"], 133 | 134 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 135 | // setupFilesAfterEnv: [], 136 | 137 | // The number of seconds after which a test is considered as slow and reported as such in the results. 138 | // slowTestThreshold: 5, 139 | 140 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 141 | // snapshotSerializers: [], 142 | 143 | // The test environment that will be used for testing 144 | testEnvironment: "jsdom", 145 | 146 | // Options that will be passed to the testEnvironment 147 | // testEnvironmentOptions: {}, 148 | 149 | // Adds a location field to test results 150 | // testLocationInResults: false, 151 | 152 | // The glob patterns Jest uses to detect test files 153 | // testMatch: [ 154 | // "**/__tests__/**/*.[jt]s?(x)", 155 | // "**/?(*.)+(spec|test).[tj]s?(x)" 156 | // ], 157 | 158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 159 | testPathIgnorePatterns: [ 160 | "/node_modules/", 161 | "/test-utils/", 162 | "/vite.config.ts", 163 | "/jest.config.js", 164 | ], 165 | 166 | // The regexp pattern or array of patterns that Jest uses to detect test files 167 | // testRegex: [], 168 | 169 | // This option allows the use of a custom results processor 170 | // testResultsProcessor: undefined, 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jest-circus/runner", 174 | 175 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 176 | // testURL: "http://localhost", 177 | 178 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 179 | // timers: "real", 180 | 181 | // A map from regular expressions to paths to transformers 182 | // transform: { 183 | // // Use babel-jest to transpile tests with the next/babel preset 184 | // // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object 185 | // "^.+\\.(js|jsx|ts|tsx)$": "babel-jest", 186 | // }, 187 | 188 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 189 | // transformIgnorePatterns: [ 190 | // "/node_modules/", 191 | // "^.+\\.module\\.(css|sass|scss)$", 192 | // ], 193 | 194 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 195 | // unmockedModulePathPatterns: undefined, 196 | 197 | // Indicates whether each individual test should be reported during the run 198 | // verbose: undefined, 199 | 200 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 201 | // watchPathIgnorePatterns: [], 202 | 203 | // Whether to use watchman for file crawling 204 | // watchman: true, 205 | }; 206 | -------------------------------------------------------------------------------- /manifest.ts: -------------------------------------------------------------------------------- 1 | import packageJson from "./package.json"; 2 | 3 | /** 4 | * After changing, please reload the extension at `chrome://extensions` 5 | */ 6 | const manifest: chrome.runtime.ManifestV3 = { 7 | manifest_version: 3, 8 | name: packageJson.title, 9 | version: packageJson.version, 10 | description: packageJson.description, 11 | omnibox: { keyword: "tp" }, 12 | options_page: "src/pages/options/index.html", 13 | background: { 14 | service_worker: "src/pages/background/index.js", 15 | type: "module", 16 | }, 17 | action: { 18 | default_popup: "src/pages/popup/index.html", 19 | default_icon: "icon-32.png", 20 | }, 21 | icons: { 22 | "16": "icon-16.png", 23 | "32": "icon-32.png", 24 | "48": "icon-48.png", 25 | "128": "icon-128.png", 26 | }, 27 | content_scripts: [ 28 | { 29 | matches: ["http://*/*", "https://*/*", ""], 30 | js: ["src/pages/content/index.js"], 31 | // KEY for cache invalidation 32 | css: ["assets/css/contentStyle.chunk.css"], 33 | }, 34 | ], 35 | web_accessible_resources: [ 36 | { 37 | resources: [ 38 | "assets/js/*.js", 39 | "assets/css/*.css", 40 | "icon-128.png", 41 | "icon-32.png", 42 | "src/pages/options/*", 43 | "src/pages/panel/*", 44 | ], 45 | matches: ["*://*/*"], 46 | }, 47 | ], 48 | permissions: ["tabs", "tabGroups", "storage"], 49 | }; 50 | 51 | export default manifest; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.1.0", 3 | "name": "tab-player", 4 | "title": "Tab Player", 5 | "description": "An astonishing way of managing tabs.", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/sipt/tab-player" 10 | }, 11 | "scripts": { 12 | "build": "tsc --noEmit && vite build", 13 | "build:watch": "cross-env __DEV__=true vite build -w", 14 | "build:hmr": "rollup --config utils/reload/rollup.config.ts", 15 | "wss": "node utils/reload/initReloadServer.js", 16 | "dev": "npm run build:hmr && (run-p wss build:watch)", 17 | "test": "jest" 18 | }, 19 | "type": "module", 20 | "dependencies": { 21 | "@radix-ui/colors": "^1.0.1", 22 | "@radix-ui/react-dialog": "^1.0.4", 23 | "@radix-ui/react-icons": "^1.3.0", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-frame-component": "^5.2.6" 27 | }, 28 | "devDependencies": { 29 | "@rollup/plugin-typescript": "^8.5.0", 30 | "@tailwindcss/typography": "^0.5.9", 31 | "@testing-library/react": "13.4.0", 32 | "@types/chrome": "0.0.224", 33 | "@types/jest": "29.0.3", 34 | "@types/node": "18.15.11", 35 | "@types/react": "18.0.21", 36 | "@types/react-dom": "18.2.4", 37 | "@types/ws": "^8.5.4", 38 | "@typescript-eslint/eslint-plugin": "5.56.0", 39 | "@typescript-eslint/parser": "5.38.1", 40 | "@vitejs/plugin-react": "2.2.0", 41 | "autoprefixer": "^10.4.14", 42 | "chokidar": "^3.5.3", 43 | "cross-env": "^7.0.3", 44 | "daisyui": "^3.5.0", 45 | "eslint": "8.36.0", 46 | "eslint-config-prettier": "^8.5.0", 47 | "eslint-plugin-prettier": "4.2.1", 48 | "eslint-plugin-react": "7.32.2", 49 | "fs-extra": "11.1.0", 50 | "jest": "29.0.3", 51 | "jest-environment-jsdom": "29.5.0", 52 | "npm-run-all": "^4.1.5", 53 | "postcss": "^8.4.24", 54 | "prettier": "2.8.8", 55 | "rollup": "2.79.1", 56 | "sass": "1.62.1", 57 | "tailwindcss": "^3.3.2", 58 | "ts-jest": "29.0.2", 59 | "ts-loader": "9.4.2", 60 | "typescript": "4.8.3", 61 | "vite": "3.1.3", 62 | "ws": "8.13.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/public/icon-128.png -------------------------------------------------------------------------------- /public/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/public/icon-16.png -------------------------------------------------------------------------------- /public/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/public/icon-32.png -------------------------------------------------------------------------------- /public/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/public/icon-48.png -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/fonts/SF-Pro.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/fonts/SF-Pro.ttf -------------------------------------------------------------------------------- /src/assets/img/active-group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/active-group.png -------------------------------------------------------------------------------- /src/assets/img/cup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/cup.png -------------------------------------------------------------------------------- /src/assets/img/default-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/default-image.png -------------------------------------------------------------------------------- /src/assets/img/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/icon-128.png -------------------------------------------------------------------------------- /src/assets/img/image.dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/image.dark.png -------------------------------------------------------------------------------- /src/assets/img/image.light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/image.light.png -------------------------------------------------------------------------------- /src/assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/img/moon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/moon.png -------------------------------------------------------------------------------- /src/assets/img/pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/pin.png -------------------------------------------------------------------------------- /src/assets/img/sun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/sun.png -------------------------------------------------------------------------------- /src/assets/img/thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/thumb.png -------------------------------------------------------------------------------- /src/assets/img/trash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/trash.png -------------------------------------------------------------------------------- /src/assets/img/wand.and.stars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipt/tab-player/dc18690ebf35cfa03fdca7f84aa70a6f758fcd26/src/assets/img/wand.and.stars.png -------------------------------------------------------------------------------- /src/assets/style/theme.scss: -------------------------------------------------------------------------------- 1 | .crx-class { 2 | color: pink; 3 | } 4 | -------------------------------------------------------------------------------- /src/common/keymap.tsx: -------------------------------------------------------------------------------- 1 | interface Hotkey { 2 | altKey: boolean; 3 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/code) */ 4 | code: string; 5 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/ctrlKey) */ 6 | ctrlKey: boolean; 7 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/key) */ 8 | key: string; 9 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/metaKey) */ 10 | metaKey: boolean; 11 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/shiftKey) */ 12 | shiftKey: boolean; 13 | } 14 | 15 | const KeyCode = { 16 | Escape: "􀆧", 17 | Tab: "􀅂", 18 | Backspase: "􁂈", 19 | Capslock: "􀆡", 20 | Command: "􀆔", 21 | Option: "􀆕", 22 | Shift: "􀆝", 23 | Alt: "􀆖", 24 | Control: "􀆍", 25 | Enter: "􀅇", 26 | Fn: "􀥌", 27 | Win: "􀚈", 28 | Space: "􁁺", 29 | ArrowUp: "􀄨", 30 | ArrowDown: "􀄩", 31 | ArrowLeft: "􀄪", 32 | ArrowRight: "􀄫", 33 | }; 34 | 35 | class HotkeyManager { 36 | private locked = false; 37 | constructor( 38 | public hotkey: { 39 | set(Hotkey): void; 40 | get(): Hotkey; 41 | } 42 | ) {} 43 | 44 | handle(e: KeyboardEvent) { 45 | let h: Hotkey = this.hotkey.get(); 46 | switch (e.type) { 47 | case "keydown": 48 | if (!this.locked) { 49 | this.locked = true; 50 | h = {} as Hotkey; 51 | } 52 | h.altKey = h.altKey || e.altKey; 53 | h.code = 54 | e.code && 55 | ![ 56 | "ShiftLeft", 57 | "ShiftRight", 58 | "ControlLeft", 59 | "ControlRight", 60 | "AltLeft", 61 | "AltRight", 62 | "MetaLeft", 63 | "MetaRight", 64 | ].includes(e.code) 65 | ? e.code 66 | : h.code; 67 | h.ctrlKey = h.ctrlKey || e.ctrlKey; 68 | h.key = h.key || e.key; 69 | h.metaKey = h.metaKey || e.metaKey; 70 | h.shiftKey = h.shiftKey || e.shiftKey; 71 | this.hotkey.set(h); 72 | break; 73 | case "keyup": 74 | if (!e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { 75 | this.locked = false; 76 | } 77 | break; 78 | default: 79 | break; 80 | } 81 | } 82 | 83 | Symbols() { 84 | const h = this.hotkey.get(); 85 | const symbols = []; 86 | if (h.ctrlKey) { 87 | symbols.push(KeyCode.Control); 88 | } 89 | if (h.metaKey) { 90 | symbols.push(KeyCode.Command); 91 | } 92 | if (h.altKey) { 93 | symbols.push(KeyCode.Option); 94 | } 95 | if (h.shiftKey) { 96 | symbols.push(KeyCode.Shift); 97 | } 98 | if (h.code) { 99 | switch (h.code) { 100 | case "Escape": 101 | symbols.push(KeyCode.Escape); 102 | break; 103 | case "Space": 104 | symbols.push(KeyCode.Space); 105 | break; 106 | case "Tab": 107 | symbols.push(KeyCode.Tab); 108 | break; 109 | case "Backspace": 110 | symbols.push(KeyCode.Backspase); 111 | break; 112 | case "CapsLock": 113 | symbols.push(KeyCode.Capslock); 114 | break; 115 | case "Enter": 116 | symbols.push(KeyCode.Enter); 117 | break; 118 | case "ArrowUp": 119 | symbols.push(KeyCode.ArrowUp); 120 | break; 121 | case "ArrowDown": 122 | symbols.push(KeyCode.ArrowDown); 123 | break; 124 | case "ArrowLeft": 125 | symbols.push(KeyCode.ArrowLeft); 126 | break; 127 | case "ArrowRight": 128 | symbols.push(KeyCode.ArrowRight); 129 | break; 130 | default: 131 | if (h.code.startsWith("Key")) { 132 | symbols.push(h.code.replace("Key", "")); 133 | } else if (h.code.startsWith("Digit")) { 134 | symbols.push(h.code.replace("Digit", "")); 135 | } else if (h.code.startsWith("Numpad")) { 136 | symbols.push(h.code.replace("Numpad", "")); 137 | } 138 | break; 139 | } 140 | } 141 | return symbols; 142 | } 143 | } 144 | 145 | function MatchHotkey(hotkey: Hotkey, e: KeyboardEvent): boolean { 146 | return ( 147 | hotkey.altKey === e.altKey && 148 | hotkey.code === e.code && 149 | hotkey.ctrlKey === e.ctrlKey && 150 | hotkey.metaKey === e.metaKey && 151 | hotkey.shiftKey === e.shiftKey 152 | ); 153 | } 154 | 155 | export { KeyCode, Hotkey, HotkeyManager, MatchHotkey }; 156 | -------------------------------------------------------------------------------- /src/common/lock.tsx: -------------------------------------------------------------------------------- 1 | async function lockTabs(): Promise { 2 | const { tabsLock } = await chrome.storage.local.get("tabsLock"); 3 | if (tabsLock) { 4 | return false; 5 | } 6 | await chrome.storage.local.set({ tabsLock: true }); 7 | return true; 8 | } 9 | 10 | async function unlockTabs(): Promise { 11 | const { tabsLock } = await chrome.storage.local.get("tabsLock"); 12 | if (!tabsLock) { 13 | return false; 14 | } 15 | await chrome.storage.local.set({ tabsLock: false }); 16 | return true; 17 | } 18 | 19 | export { lockTabs, unlockTabs }; 20 | -------------------------------------------------------------------------------- /src/common/optionsConfig.tsx: -------------------------------------------------------------------------------- 1 | import { Hotkey } from "./keymap"; 2 | 3 | interface OptionsConfig { 4 | // GeneralConfig 5 | theme?: string; 6 | 7 | // TabGroupsConfig 8 | hotkey?: Hotkey; 9 | colorSeparator?: string; 10 | defaultNames?: string[]; 11 | } 12 | 13 | const defaultOptionsConfig: OptionsConfig = { 14 | theme: "dark", 15 | hotkey: { 16 | altKey: false, 17 | code: "KeyO", 18 | ctrlKey: false, 19 | key: "o", 20 | metaKey: true, 21 | shiftKey: true, 22 | }, 23 | colorSeparator: "[[", 24 | defaultNames: [ 25 | "花间一壶酒", 26 | "独坐敬亭山", 27 | "海上生明月", 28 | "天涯共此时", 29 | "春风细雨微霜", 30 | "明月几时有", 31 | "空山不见人", 32 | "但闻人语响", 33 | "返景入深林", 34 | "复照青苔上", 35 | "白日依山尽", 36 | "黄河入海流", 37 | "欲穷千里目", 38 | "更上一层楼", 39 | "千山鸟飞绝", 40 | "万径人踪灭", 41 | "孤舟蓑笠翁", 42 | "独钓寒江雪", 43 | "独坐幽篁里", 44 | "弹琴复长啸", 45 | "深林人不知", 46 | "明月来相照", 47 | "移舟泊烟渚", 48 | "日暮客愁新", 49 | "野旷天低树", 50 | "江清月近人", 51 | "红豆生南国", 52 | "春来发几枝", 53 | "愿君多采撷", 54 | "此物最相思", 55 | "床前明月光", 56 | "疑是地上霜", 57 | "举头望明月", 58 | "低头思故乡", 59 | "松下问童子", 60 | "言师采药去", 61 | "只在此山中", 62 | "云深不知处", 63 | "功盖三分国", 64 | "名成八阵图", 65 | "江流石不转", 66 | "遗恨失吞吴", 67 | "春眠不觉晓", 68 | "处处闻啼鸟", 69 | "夜来风雨声", 70 | "花落知多少", 71 | "绿蚁新醅酒", 72 | "红泥小火炉", 73 | "晚来天欲雪", 74 | "能饮一杯无", 75 | "向晚意不适", 76 | "驱车登古原", 77 | "夕阳无限好", 78 | "只是近黄昏", 79 | "山中相送罢", 80 | "日暮掩柴扉", 81 | "春草明年绿", 82 | "王孙归不归", 83 | ], 84 | }; 85 | 86 | async function loadOptionsConfig(): Promise { 87 | try { 88 | let { optionsConfig } = await chrome.storage.local.get("optionsConfig"); 89 | if (!optionsConfig) { 90 | optionsConfig = defaultOptionsConfig; 91 | } else { 92 | optionsConfig = { ...defaultOptionsConfig, ...optionsConfig }; 93 | } 94 | await chrome.storage.local.set({ optionsConfig: optionsConfig }); 95 | return optionsConfig; 96 | } catch (err) { 97 | console.error(err); 98 | return defaultOptionsConfig; 99 | } 100 | } 101 | 102 | async function saveOptionsConfig(optionsConfig: OptionsConfig) { 103 | try { 104 | await chrome.storage.local.set({ optionsConfig: optionsConfig }); 105 | } catch (err) { 106 | console.error(err); 107 | } 108 | } 109 | 110 | export { OptionsConfig, loadOptionsConfig, saveOptionsConfig }; 111 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import Chrome from "chrome"; 2 | 3 | declare namespace chrome { 4 | export default Chrome; 5 | } 6 | 7 | declare module "virtual:reload-on-update-in-background-script" { 8 | export const reloadOnUpdate: (watchPath: string) => void; 9 | export default reloadOnUpdate; 10 | } 11 | 12 | declare module "virtual:reload-on-update-in-view" { 13 | const refreshOnUpdate: (watchPath: string) => void; 14 | export default refreshOnUpdate; 15 | } 16 | 17 | declare module "*.svg" { 18 | import React = require("react"); 19 | export const ReactComponent: React.SFC>; 20 | const src: string; 21 | export default src; 22 | } 23 | 24 | declare module "*.jpg" { 25 | const content: string; 26 | export default content; 27 | } 28 | 29 | declare module "*.png" { 30 | const content: string; 31 | export default content; 32 | } 33 | 34 | declare module "*.json" { 35 | const content: string; 36 | export default content; 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/background/index.tsx: -------------------------------------------------------------------------------- 1 | import { lockTabs, unlockTabs } from "@src/common/lock"; 2 | import reloadOnUpdate from "virtual:reload-on-update-in-background-script"; 3 | import { colorFix } from "@src/pages/panel/Common"; 4 | 5 | reloadOnUpdate("pages/background"); 6 | 7 | /** 8 | * Extension reloading is necessary because the browser automatically caches the css. 9 | * If you do not use the css of the content script, please delete it. 10 | */ 11 | reloadOnUpdate("pages/content/style.scss"); 12 | 13 | console.log("background loaded"); 14 | 15 | chrome.tabs.onCreated.addListener(async (tab) => { 16 | if (tab.groupId !== -1) { 17 | return; 18 | } 19 | try { 20 | const locked = await lockTabs(); 21 | if (!locked) { 22 | return; 23 | } 24 | const items = await chrome.storage.local.get("focusOnGroupId"); 25 | const focusOnGroupId = items.focusOnGroupId || 0; 26 | const groups = await chrome.tabGroups.query({}); 27 | const group = groups.find((group) => group.id === focusOnGroupId); 28 | if (group && group.windowId === tab.windowId) { 29 | await chrome.tabs.group({ groupId: group.id, tabIds: tab.id }); 30 | } 31 | } catch (err) { 32 | console.error(err); 33 | } finally { 34 | await unlockTabs(); 35 | } 36 | }); 37 | 38 | chrome.omnibox.onInputChanged.addListener(async (text, suggest) => { 39 | let fg = await chrome.tabGroups.query({}); 40 | if (text !== "") { 41 | fg = fg.filter((group) => { 42 | return group.title.toLowerCase().includes(text.toLowerCase()); 43 | }); 44 | } 45 | const { focusOnGroupId } = await chrome.storage.local.get("focusOnGroupId"); 46 | // 有 focusOnGroupId 时,将其放在第一位 47 | if (focusOnGroupId !== 0) { 48 | const index = fg.findIndex((group) => group.id === focusOnGroupId); 49 | if (index !== -1) { 50 | fg = [fg[index], ...fg.slice(0, index), ...fg.slice(index + 1)]; 51 | } 52 | } 53 | const suggestions = fg.map((group) => { 54 | return { 55 | content: group.title, 56 | description: `${group.title} ${ 57 | group.id === focusOnGroupId ? "(Focusing)" : "" 58 | }`, 59 | }; 60 | }); 61 | suggest(suggestions); 62 | }); 63 | 64 | chrome.omnibox.onInputEntered.addListener(async (text) => { 65 | const groups = await chrome.tabGroups.query({}); 66 | const group = groups.find((group) => group.title === text); 67 | const groupId = group?.id || 0; 68 | 69 | try { 70 | await lockTabs(); 71 | if (groupId !== 0) { 72 | const tabs = await chrome.tabs.query({ groupId: groupId }); 73 | const tab = tabs.at(-1); 74 | await chrome.tabs.update(tab.id!, { active: true }); 75 | await chrome.storage.local.set({ focusOnGroupId: groupId }); 76 | } else { 77 | let tabId = 0; 78 | const currentWindow = await chrome.windows.getCurrent(); 79 | const currentTab = await chrome.tabs.query({ active: true }); 80 | currentTab.forEach((tab) => { 81 | if ( 82 | tab.windowId === currentWindow.id && 83 | tab.url.startsWith("chrome://newtab/") 84 | ) { 85 | tabId = tab.id!; 86 | } 87 | }); 88 | 89 | if (tabId === 0) { 90 | const newTab = await chrome.tabs.create({}); 91 | tabId = newTab.id!; 92 | } 93 | const groupId = await chrome.tabs.group({ 94 | tabIds: tabId, 95 | }); 96 | const [title, color] = text.split("[["); 97 | await chrome.tabGroups.update(groupId, { 98 | title: title.trim(), 99 | color: colorFix(color), 100 | }); 101 | await chrome.storage.local.set({ focusOnGroupId: groupId }); 102 | } 103 | } catch (err) { 104 | console.error(err); 105 | } finally { 106 | await unlockTabs(); 107 | } 108 | }); 109 | -------------------------------------------------------------------------------- /src/pages/content/index.tsx: -------------------------------------------------------------------------------- 1 | let iframe: HTMLIFrameElement | null = null; 2 | let channel: MessageChannel | null = new MessageChannel(); 3 | 4 | interface Hotkey { 5 | altKey: boolean; 6 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/code) */ 7 | code: string; 8 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/ctrlKey) */ 9 | ctrlKey: boolean; 10 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/key) */ 11 | key: string; 12 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/metaKey) */ 13 | metaKey: boolean; 14 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/shiftKey) */ 15 | shiftKey: boolean; 16 | } 17 | interface OptionsConfig { 18 | // GeneralConfig 19 | theme?: string; 20 | 21 | // TabGroupsConfig 22 | hotkey?: Hotkey; 23 | colorSeparator?: string; 24 | } 25 | 26 | const defaultOptionsConfig: OptionsConfig = { 27 | theme: "dark", 28 | hotkey: { 29 | altKey: false, 30 | code: "KeyO", 31 | ctrlKey: false, 32 | key: "o", 33 | metaKey: true, 34 | shiftKey: true, 35 | }, 36 | colorSeparator: "[[", 37 | }; 38 | 39 | async function loadOptionsConfig(): Promise { 40 | try { 41 | let { optionsConfig } = await chrome.storage.local.get("optionsConfig"); 42 | if (!optionsConfig) { 43 | optionsConfig = defaultOptionsConfig; 44 | await chrome.storage.local.set({ optionsConfig: optionsConfig }); 45 | } 46 | return optionsConfig; 47 | } catch (err) { 48 | console.error(err); 49 | return defaultOptionsConfig; 50 | } 51 | } 52 | 53 | let optionsConfig = {} as OptionsConfig; 54 | function init() { 55 | loadOptionsConfig().then((opc) => { 56 | optionsConfig = opc; 57 | window.onkeydown = (e) => { 58 | if ( 59 | optionsConfig.hotkey.altKey === e.altKey && 60 | optionsConfig.hotkey.code === e.code && 61 | optionsConfig.hotkey.ctrlKey === e.ctrlKey && 62 | optionsConfig.hotkey.metaKey === e.metaKey && 63 | optionsConfig.hotkey.shiftKey === e.shiftKey 64 | ) { 65 | toggle(); 66 | } else if (e.key === "Escape") { 67 | if (iframe) { 68 | iframe.style.display = "none"; 69 | } 70 | } 71 | }; 72 | }); 73 | } 74 | function mount() { 75 | // 创建一个MutationObserver实例 76 | const observer = new MutationObserver(function (mutationsList) { 77 | for (const mutation of mutationsList) { 78 | if ( 79 | mutation.type === "attributes" && 80 | mutation.attributeName === "style" 81 | ) { 82 | if (iframe.style.display === "block") { 83 | iframe.contentWindow.focus(); 84 | channel.port1.postMessage("redisplay"); 85 | } else { 86 | window.focus(); 87 | } 88 | break; 89 | } 90 | } 91 | }); 92 | const root = document.createElement("div"); 93 | root.id = "tab-player-root"; 94 | iframe = document.createElement("iframe"); 95 | iframe.src = chrome.runtime.getURL("src/pages/panel/index.html"); 96 | iframe.id = "tab-player-iframe"; 97 | iframe.style.width = "100vw"; 98 | iframe.style.height = "100vh"; 99 | iframe.style.colorScheme = "none"; 100 | root.appendChild(iframe); 101 | document.documentElement.appendChild(root); 102 | observer.observe(iframe, { attributes: true }); 103 | window.onclose = () => { 104 | observer.disconnect(); 105 | }; 106 | chrome.storage.local.onChanged.addListener((changes) => { 107 | if (changes.optionsConfig) { 108 | optionsConfig = changes.optionsConfig.newValue; 109 | } 110 | }); 111 | iframe.onload = () => { 112 | setTimeout(() => { 113 | iframe.contentWindow.postMessage("init-tab-player-panel", "*", [ 114 | channel.port2, 115 | ]); 116 | channel.port1.onmessage = (e) => { 117 | if (e.data === "dismiss") { 118 | toggle(); 119 | } 120 | }; 121 | iframe.contentWindow.focus(); 122 | channel.port1.postMessage("redisplay"); 123 | }, 200); 124 | }; 125 | } 126 | 127 | init(); 128 | function toggle() { 129 | if (iframe) { 130 | iframe.style.display = iframe.style.display === "none" ? "block" : "none"; 131 | } else { 132 | mount(); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/pages/content/style.scss: -------------------------------------------------------------------------------- 1 | // @import "@assets/style/theme.scss"; 2 | // @tailwind base; 3 | // @tailwind components; 4 | // @tailwind utilities; 5 | 6 | .content-view { 7 | font-size: 30px; 8 | } 9 | 10 | #tab-player-iframe { 11 | position: fixed; 12 | left: 0; 13 | top: 0; 14 | border: 0; 15 | z-index: 10000; 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/options/Options.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | height: 50vh; 4 | font-size: 2rem; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/options/Options.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import "@pages/options/Options.css"; 3 | import icon from "@assets/img/icon-128.png"; 4 | import { Hotkey, HotkeyManager } from "@src/common/keymap"; 5 | import { 6 | loadOptionsConfig, 7 | saveOptionsConfig, 8 | } from "@src/common/optionsConfig"; 9 | 10 | function Options() { 11 | const [hotkey, setHotkey] = useState({} as Hotkey); 12 | const [colorSeparator, setColorSeparator] = useState(""); 13 | const [defaultNames, setDefaultNames] = useState([]); 14 | const [theme, setTheme] = useState(""); 15 | const hotkeyManager = new HotkeyManager({ 16 | set(Hotkey) { 17 | setHotkey(Hotkey); 18 | }, 19 | get() { 20 | return hotkey; 21 | }, 22 | }); 23 | 24 | useEffect(() => { 25 | loadOptionsConfig().then((optionsConfig) => { 26 | setHotkey(optionsConfig.hotkey); 27 | setColorSeparator(optionsConfig.colorSeparator); 28 | setTheme(optionsConfig.theme); 29 | setDefaultNames(optionsConfig.defaultNames); 30 | }); 31 | }, []); 32 | 33 | useEffect(() => { 34 | saveOptionsConfig({ 35 | hotkey: hotkey, 36 | colorSeparator: colorSeparator, 37 | theme: theme, 38 | defaultNames: defaultNames, 39 | }) 40 | .then(() => {}) 41 | .catch((err) => { 42 | console.error(err); 43 | }); 44 | }, [hotkey, colorSeparator, theme, defaultNames]); 45 | 46 | useEffect(() => { 47 | document.documentElement.setAttribute("data-theme", theme); 48 | document.documentElement.className = theme; 49 | }, [theme]); 50 | 51 | return ( 52 |
53 |
54 |
55 | 56 | Tab Player 57 | Options 58 |
59 |
60 |
61 | 62 | # 63 | {" "} 64 | General 65 |
66 |
67 | 68 |
69 |
70 | 82 |
83 |
84 |
85 |
86 |
87 |
88 | 89 | # 90 | {" "} 91 | Tab Groups 92 |
93 |
94 | 95 |
96 |
97 | {hotkeyManager.Symbols().map((symbol, index) => { 98 | return ( 99 | 103 | {symbol} 104 | 105 | ); 106 | })} 107 |
108 |
{ 113 | hotkeyManager.handle(e.nativeEvent); 114 | e.preventDefault(); 115 | }} 116 | onKeyUp={(e) => { 117 | hotkeyManager.handle(e.nativeEvent); 118 | e.preventDefault(); 119 | }} 120 | >
121 |
122 |
123 |
124 | 125 | { 130 | setColorSeparator(e.target.value); 131 | }} 132 | className="input input-bordered w-64 h-8 max-w-xs dark:placeholder:text-slate-600 placeholder:text-slate-400 focus:outline-indigo-500" 133 | /> 134 |
135 |
136 | 137 | 145 |
146 |
147 |
148 |
149 | ); 150 | } 151 | 152 | export default Options; 153 | -------------------------------------------------------------------------------- /src/pages/options/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: "tab-player-font"; 7 | src: url("../../assets/fonts/SF-Pro.ttf"); 8 | } 9 | 10 | body { 11 | font-family: "tab-player-font"; 12 | } 13 | 14 | /* width */ 15 | ::-webkit-scrollbar { 16 | width: 8px; 17 | cursor: default; 18 | } 19 | 20 | /* Track */ 21 | ::-webkit-scrollbar-track { 22 | background: tranparent; 23 | } 24 | 25 | /* Handle */ 26 | ::-webkit-scrollbar-thumb { 27 | border-radius: 5px; 28 | background: #555; 29 | } 30 | 31 | /* Handle on hover */ 32 | ::-webkit-scrollbar-thumb:hover { 33 | background: #888; 34 | } -------------------------------------------------------------------------------- /src/pages/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Options 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/pages/options/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import Options from "@pages/options/Options"; 4 | import "@pages/options/index.css"; 5 | import refreshOnUpdate from "virtual:reload-on-update-in-view"; 6 | 7 | refreshOnUpdate("pages/options"); 8 | 9 | function init() { 10 | const appContainer = document.querySelector("#app-container"); 11 | if (!appContainer) { 12 | throw new Error("Can not find #app-container"); 13 | } 14 | const root = createRoot(appContainer); 15 | root.render(); 16 | } 17 | 18 | init(); 19 | -------------------------------------------------------------------------------- /src/pages/panel/App.tsx: -------------------------------------------------------------------------------- 1 | import { KeyboardEventHandler, useEffect, useRef, useState } from "react"; 2 | import { Group, GroupEvent } from "./Group"; 3 | import { colorFix, colors } from "./Common"; 4 | import { lockTabs, unlockTabs } from "@src/common/lock"; 5 | import { loadOptionsConfig } from "@src/common/optionsConfig"; 6 | 7 | function App() { 8 | const inputRef = useRef(null); 9 | const [inputValue, setInputValue] = useState(""); 10 | const [port, setPort] = useState(null); 11 | const [groups, setGroups] = useState([]); 12 | const [filteredGroups, setFilteredGroups] = useState< 13 | chrome.tabGroups.TabGroup[] 14 | >([]); 15 | const [selectGroupId, setSelectGroupId] = useState(0); 16 | const [focusOnGroupId, setFocusOnGroupId] = useState(0); 17 | const [refresh, setRefresh] = useState(0); 18 | const [colorSeparator, setColorSeparator] = useState("[["); 19 | const [defaultNames, setDefaultNames] = useState([]); 20 | const [randomGroup, setRandomGroup] = useState<{ 21 | name: string; 22 | color: string; 23 | }>(); 24 | useEffect(() => { 25 | window.addEventListener("message", (event) => { 26 | setPort(event.ports?.[0]); 27 | }); 28 | loadOptionsConfig() 29 | .then((optionsConfig) => { 30 | setColorSeparator(optionsConfig.colorSeparator || "[["); 31 | setDefaultNames(optionsConfig.defaultNames); 32 | document.documentElement.classList.remove("dark", "light"); 33 | document.documentElement.classList.add(optionsConfig.theme || "dark"); 34 | document.documentElement.setAttribute( 35 | "data-theme", 36 | optionsConfig.theme || "dark" 37 | ); 38 | }) 39 | .catch((e) => { 40 | console.error(e); 41 | }); 42 | chrome.storage.local.onChanged.addListener((changes) => { 43 | if (changes.optionsConfig) { 44 | setColorSeparator( 45 | changes.optionsConfig.newValue.colorSeparator || "[[" 46 | ); 47 | setDefaultNames(changes.optionsConfig.newValue.defaultNames); 48 | document.documentElement.classList.remove("dark", "light"); 49 | document.documentElement.classList.add( 50 | changes.optionsConfig.newValue.theme || "dark" 51 | ); 52 | document.documentElement.setAttribute( 53 | "data-theme", 54 | changes.optionsConfig.newValue.theme || "dark" 55 | ); 56 | } 57 | }); 58 | }, []); 59 | 60 | useEffect(() => { 61 | inputRef.current.onkeydown = (e) => { 62 | if (e.key === "Escape") { 63 | setInputValue(""); 64 | port.postMessage("dismiss"); 65 | e.preventDefault(); 66 | e.stopPropagation(); 67 | } 68 | }; 69 | if (port) { 70 | port.onmessage = (e) => { 71 | switch (e.data) { 72 | case "redisplay": 73 | chrome.tabGroups 74 | .query({}) 75 | .then((groups) => { 76 | setGroups(groups); 77 | }) 78 | .catch((e) => { 79 | console.error(e); 80 | }); 81 | chrome.storage.local.get("focusOnGroupId", (items) => { 82 | setFocusOnGroupId(items.focusOnGroupId || 0); 83 | }); 84 | break; 85 | default: 86 | break; 87 | } 88 | }; 89 | window.onkeydown = (e) => { 90 | if (e.key === "Escape") { 91 | port.postMessage("dismiss"); 92 | } 93 | }; 94 | } 95 | }, [port, inputRef]); 96 | 97 | useEffect(() => { 98 | chrome.tabGroups 99 | .query({}) 100 | .then((groups) => { 101 | setGroups(groups); 102 | }) 103 | .catch((e) => { 104 | console.error(e); 105 | }); 106 | chrome.storage.local.get("focusOnGroupId", (items) => { 107 | setFocusOnGroupId(items.focusOnGroupId || 0); 108 | }); 109 | }, [refresh]); 110 | 111 | useEffect(() => { 112 | inputRef.current.value = ""; 113 | inputRef.current.focus(); 114 | }, [groups]); 115 | 116 | useEffect(() => { 117 | let fg = groups; 118 | let found = false; 119 | if (inputValue !== "") { 120 | fg = fg.filter((group) => { 121 | if (group.title === inputValue) { 122 | found = true; 123 | } 124 | return group.title.toLowerCase().includes(inputValue.toLowerCase()); 125 | }); 126 | } 127 | if (inputValue !== "") { 128 | fg = fg.sort((a, b) => { 129 | return a.title.length - b.title.length; 130 | }); 131 | } 132 | // 有 focusOnGroupId 时,将其放在第一位 133 | if (focusOnGroupId !== 0) { 134 | const index = fg.findIndex((group) => group.id === focusOnGroupId); 135 | if (index !== -1) { 136 | fg = [fg[index], ...fg.slice(0, index), ...fg.slice(index + 1)]; 137 | } 138 | } 139 | if (inputValue !== "" && !found) { 140 | const cell = inputValue.split(colorSeparator); 141 | let color = "grey"; 142 | if (cell.length > 1) { 143 | color = colorFix(cell[1]); 144 | } 145 | fg = [ 146 | { id: 0, title: cell[0], color: color } as chrome.tabGroups.TabGroup, 147 | ...fg, 148 | ]; 149 | } else if (inputValue === "") { 150 | const rg = { 151 | name: defaultNames[Math.floor(Math.random() * defaultNames.length)], 152 | color: colors[Math.floor(Math.random() * colors.length)], 153 | }; 154 | setRandomGroup(rg); 155 | fg = [ 156 | { 157 | id: 0, 158 | title: rg.name, 159 | color: rg.color, 160 | } as chrome.tabGroups.TabGroup, 161 | ...fg, 162 | ]; 163 | } 164 | setFilteredGroups(fg); 165 | }, [inputValue, groups, focusOnGroupId]); 166 | 167 | useEffect(() => { 168 | let groupId = 0; 169 | if (inputValue.length > 0) { 170 | filteredGroups.forEach((group) => { 171 | if (group.title === inputValue) { 172 | groupId = group.id; 173 | } 174 | }); 175 | } else if (filteredGroups.length > 0) { 176 | groupId = filteredGroups[0].id; 177 | } 178 | setSelectGroupId(groupId); 179 | }, [filteredGroups]); 180 | 181 | async function groupCallback(event: GroupEvent) { 182 | try { 183 | switch (event.type) { 184 | case "group.select": 185 | const tabs = await chrome.tabs.query({ groupId: event.group.id }); 186 | const tab = tabs.at(-1); 187 | await chrome.tabs.update(tab.id!, { active: true }); 188 | await chrome.storage.local.set({ focusOnGroupId: event.group.id }); 189 | break; 190 | case "group.focus": 191 | setSelectGroupId(event.group.id); 192 | break; 193 | default: 194 | break; 195 | } 196 | } catch (err) { 197 | console.error(err); 198 | } 199 | } 200 | 201 | function groupsView() { 202 | return filteredGroups.map((group) => { 203 | return ( 204 | 211 | ); 212 | }); 213 | } 214 | 215 | const keydownEventHandler: KeyboardEventHandler = async ( 216 | e 217 | ) => { 218 | switch (e.key) { 219 | case "ArrowUp": 220 | const index = filteredGroups.findIndex( 221 | (group) => group.id === selectGroupId 222 | ); 223 | if (index > 0) { 224 | setSelectGroupId(filteredGroups[index - 1].id); 225 | } 226 | e.preventDefault(); 227 | break; 228 | case "ArrowDown": 229 | const index2 = filteredGroups.findIndex( 230 | (group) => group.id === selectGroupId 231 | ); 232 | if (index2 < filteredGroups.length - 1) { 233 | setSelectGroupId(filteredGroups[index2 + 1].id); 234 | } 235 | e.preventDefault(); 236 | break; 237 | case "Enter": 238 | try { 239 | if (e.metaKey) { 240 | let allTabs = await chrome.tabs.query({}); 241 | const tabs = allTabs.filter((tab) => tab.groupId === selectGroupId); 242 | if (tabs.length === 0) { 243 | e.preventDefault(); 244 | return; 245 | } 246 | const windowId = tabs[0].windowId; 247 | allTabs = allTabs.filter((tab) => tab.windowId !== windowId); 248 | try { 249 | await lockTabs(); 250 | if (tabs.length === allTabs.length) { 251 | await chrome.tabs.create({}); 252 | } 253 | const tabids = tabs.map((tab) => tab.id); 254 | await chrome.tabs.remove(tabids); 255 | setRefresh(refresh + 1); 256 | } catch (err) { 257 | console.error(err); 258 | } finally { 259 | await unlockTabs(); 260 | } 261 | e.preventDefault(); 262 | return; 263 | } 264 | if (selectGroupId !== 0 && focusOnGroupId === selectGroupId) { 265 | await chrome.storage.local.set({ focusOnGroupId: 0 }); 266 | setInputValue(""); 267 | port.postMessage("dismiss"); 268 | return; 269 | } 270 | groups.forEach((group) => { 271 | if (group.id !== selectGroupId) { 272 | chrome.tabGroups.update(group.id, { collapsed: true }); 273 | } 274 | }); 275 | } catch (err) { 276 | console.error(err); 277 | } 278 | try { 279 | await lockTabs(); 280 | 281 | if (selectGroupId !== 0) { 282 | const tabs = await chrome.tabs.query({ groupId: selectGroupId }); 283 | const tab = tabs.at(-1); 284 | await chrome.tabs.update(tab.id, { active: true }); 285 | await chrome.storage.local.set({ focusOnGroupId: selectGroupId }); 286 | } else { 287 | const newTab = await chrome.tabs.create({}); 288 | const groupId = await chrome.tabs.group({ 289 | tabIds: newTab.id, 290 | }); 291 | let [title, color] = [randomGroup.name, randomGroup.color]; 292 | if (inputValue !== "") { 293 | [title, color] = inputValue.split(colorSeparator); 294 | } 295 | await chrome.tabGroups.update(groupId, { 296 | title: title.trim(), 297 | color: colorFix(color), 298 | }); 299 | await chrome.storage.local.set({ focusOnGroupId: groupId }); 300 | } 301 | setInputValue(""); 302 | port.postMessage("dismiss"); 303 | } catch (err) { 304 | console.error(err); 305 | } finally { 306 | await unlockTabs(); 307 | } 308 | e.preventDefault(); 309 | break; 310 | default: 311 | break; 312 | } 313 | }; 314 | 315 | return ( 316 |
{ 319 | if (port) { 320 | port.postMessage("dismiss"); 321 | } 322 | }} 323 | > 324 |
{ 327 | e.stopPropagation(); 328 | }} 329 | > 330 |
331 |
332 |
333 | { 349 | setInputValue(e.target.value); 350 | }} 351 | onKeyDown={keydownEventHandler} 352 | aria-activedescendant="docsearch-item-0" 353 | aria-controls="docsearch-list" 354 | /> 355 |
356 |
357 |
358 |
    359 | {groupsView()} 360 |
361 |
362 |
363 |
364 |
365 | ); 366 | } 367 | 368 | export default App; 369 | -------------------------------------------------------------------------------- /src/pages/panel/Common.tsx: -------------------------------------------------------------------------------- 1 | const colorMap = { 2 | grey: "bg-gray-400 dark:bg-gray-500", 3 | blue: "bg-blue-400 dark:bg-blue-500", 4 | red: "bg-red-400 dark:bg-red-500", 5 | yellow: "bg-yellow-400 dark:bg-yellow-500", 6 | green: "bg-green-400 dark:bg-green-500", 7 | pink: "bg-pink-400 dark:bg-pink-500", 8 | purple: "bg-purple-400 dark:bg-purple-500", 9 | cyan: "bg-cyan-400 dark:bg-cyan-500", 10 | orange: "bg-orange-400 dark:bg-orange-500", 11 | }; 12 | 13 | function colorFix( 14 | color: string 15 | ): 16 | | "grey" 17 | | "blue" 18 | | "red" 19 | | "yellow" 20 | | "green" 21 | | "pink" 22 | | "purple" 23 | | "cyan" 24 | | "orange" { 25 | if (colorMap[color.toLowerCase()]) { 26 | return color as any; 27 | } else { 28 | return "grey"; 29 | } 30 | } 31 | 32 | const colors = [ 33 | "grey", 34 | "blue", 35 | "red", 36 | "yellow", 37 | "green", 38 | "pink", 39 | "purple", 40 | "cyan", 41 | "orange", 42 | ]; 43 | 44 | export { colorMap, colorFix, colors }; 45 | -------------------------------------------------------------------------------- /src/pages/panel/Group.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from "react"; 2 | import { colorMap } from "./Common"; 3 | import defaultIcon from "@assets/img/default-image.png"; 4 | import activeGroup from "@assets/img/active-group.png"; 5 | 6 | interface GroupEvent { 7 | type: "group.select" | "group.focus"; 8 | group: chrome.tabGroups.TabGroup; 9 | } 10 | 11 | function Group(props: { 12 | group: chrome.tabGroups.TabGroup; 13 | selectGroupId: number; 14 | focusOnGroupId: number; 15 | callback: (event: GroupEvent) => void; 16 | }) { 17 | const [tabs, setTabs] = useState([]); 18 | const groupItem = useRef(null); 19 | 20 | useEffect(() => { 21 | if (props.group.id === 0) { 22 | return; 23 | } 24 | chrome.tabs 25 | .query({ groupId: props.group.id }) 26 | .then((tabs) => { 27 | setTabs(tabs); 28 | }) 29 | .catch((err) => { 30 | console.error(err); 31 | }); 32 | }, [props.group]); 33 | 34 | useEffect(() => { 35 | if (props.selectGroupId === props.group.id) { 36 | groupItem.current?.scrollIntoView({ 37 | behavior: "smooth", 38 | block: "nearest", 39 | inline: "nearest", 40 | }); 41 | } 42 | }, [props.selectGroupId]); 43 | 44 | function tabsView() { 45 | let els = []; 46 | if (tabs.length > 0) { 47 | els.push( 48 | ...tabs.map((tab, index) => { 49 | return ( 50 |
54 | { 58 | e.currentTarget.src = defaultIcon; 59 | }} 60 | /> 61 |
62 | ); 63 | }) 64 | ); 65 | } else { 66 | els.push(
{`Press "Enter" to create the group.`}
); 67 | } 68 | return els; 69 | } 70 | return ( 71 |
  • { 76 | props.callback({ 77 | type: "group.focus", 78 | group: props.group, 79 | }); 80 | }} 81 | onClick={() => { 82 | props.callback({ 83 | type: "group.select", 84 | group: props.group, 85 | }); 86 | }} 87 | > 88 |
    89 |
    90 |
    91 |
    96 | {props.group.title || "(No Title)"} 97 |
    98 | {props.focusOnGroupId != 0 && 99 | props.focusOnGroupId === props.group.id ? ( 100 |
    101 | 102 |
    103 | ) : null} 104 |
    105 |
    106 | {tabsView()} 107 |
    108 |
    109 |
    110 |
  • 111 | ); 112 | } 113 | 114 | export { Group, GroupEvent }; 115 | -------------------------------------------------------------------------------- /src/pages/panel/NewGroup.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { colorMap } from "./Common"; 3 | 4 | function NewGroup(props: { title: string; selectGroupId: number }) { 5 | const [title, setTitle] = useState(""); 6 | const [color, setColor] = useState("grey"); 7 | 8 | useEffect(() => { 9 | const cell = props.title.split("[["); 10 | setTitle(cell[0]); 11 | if (cell.length > 1) { 12 | setColor(cell[1].split("]]")[0]); 13 | } 14 | }); 15 | 16 | return ( 17 |
  • 21 |
    22 |
    23 |
    24 |
    29 | {title} 30 |
    31 |
    32 |
    33 |
    34 | Please enter the group name and press "Enter" to create the group. 35 |
    36 |
    37 |
    38 |
    39 |
  • 40 | ); 41 | } 42 | 43 | export default NewGroup; 44 | -------------------------------------------------------------------------------- /src/pages/panel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tab Player 8 | 9 | 10 |
    11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/pages/panel/index.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | #root { 6 | @apply w-screen h-screen; 7 | } 8 | 9 | .search-modal { 10 | margin: 0 auto; 11 | width: 100%; 12 | max-width: 47.375rem; 13 | display: flex; 14 | flex-direction: column; 15 | min-height: 0; 16 | border-radius: theme("borderRadius.lg"); 17 | box-shadow: theme("boxShadow.lg"); 18 | background: white; 19 | 20 | .dark & { 21 | background: theme("colors.slate.800"); 22 | box-shadow: inset 0 1px 0 0 rgb(255 255 255 / 0.05); 23 | } 24 | } 25 | 26 | .search-icon { 27 | @apply flex-none w-6 h-6; 28 | background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m19 19-3.5-3.5' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='11' cy='11' r='6' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); 29 | 30 | .dark & { 31 | background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m19 19-3.5-3.5' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='11' cy='11' r='6' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); 32 | } 33 | } 34 | 35 | .search-input { 36 | appearance: none; 37 | background: transparent; 38 | height: 3.5rem; 39 | font-size: 1rem; 40 | color: theme("colors.slate.900"); 41 | margin-left: 0.75rem; 42 | margin-right: 1rem; 43 | flex: auto; 44 | min-width: 0; 45 | 46 | @apply dark:text-slate-200; 47 | } 48 | 49 | @screen sm { 50 | .search-input { 51 | font-size: 0.875rem; 52 | } 53 | } 54 | 55 | .search-input:focus { 56 | outline: 2px dotted transparent; 57 | } 58 | 59 | .search-input::-webkit-search-cancel-button, 60 | .search-input::-webkit-search-decoration, 61 | .search-input::-webkit-search-results-button, 62 | .search-input::-webkit-search-results-decoration { 63 | display: none; 64 | } 65 | 66 | .escape-icon { 67 | appearance: none; 68 | flex: none; 69 | font-size: 0; 70 | border-radius: 0.375rem; 71 | padding: 0.25rem 0.375rem; 72 | @apply ring-1 ring-slate-900/5 shadow-sm hover:ring-slate-900/10 hover:shadow dark:ring-0 dark:bg-slate-600; 73 | width: 1.75rem; 74 | height: 1.5rem; 75 | /* esc */ 76 | background-image: url("data:image/svg+xml,%3Csvg width='16' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M.506 6h3.931V4.986H1.736v-1.39h2.488V2.583H1.736V1.196h2.69V.182H.506V6ZM8.56 1.855h1.18C9.721.818 8.87.102 7.574.102c-1.276 0-2.21.705-2.205 1.762-.003.858.602 1.35 1.585 1.585l.634.159c.633.153.986.335.988.727-.002.426-.406.716-1.03.716-.64 0-1.1-.295-1.14-.878h-1.19c.03 1.259.931 1.91 2.343 1.91 1.42 0 2.256-.68 2.259-1.745-.003-.969-.733-1.483-1.744-1.71l-.523-.125c-.506-.117-.93-.304-.92-.722 0-.375.332-.65.934-.65.588 0 .949.267.994.724ZM15.78 2.219C15.618.875 14.6.102 13.254.102c-1.537 0-2.71 1.086-2.71 2.989 0 1.898 1.153 2.989 2.71 2.989 1.492 0 2.392-.992 2.526-2.063l-1.244-.006c-.117.623-.606.98-1.262.98-.883 0-1.483-.656-1.483-1.9 0-1.21.591-1.9 1.492-1.9.673 0 1.159.389 1.253 1.028h1.244Z' fill='%23334155'/%3E%3C/svg%3E"); 77 | background-position: center; 78 | background-repeat: no-repeat; 79 | background-size: 57.1428571429% auto; 80 | 81 | .dark & { 82 | background-image: url("data:image/svg+xml,%3Csvg width='16' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M.506 6h3.931V4.986H1.736v-1.39h2.488V2.583H1.736V1.196h2.69V.182H.506V6ZM8.56 1.855h1.18C9.721.818 8.87.102 7.574.102c-1.276 0-2.21.705-2.205 1.762-.003.858.602 1.35 1.585 1.585l.634.159c.633.153.986.335.988.727-.002.426-.406.716-1.03.716-.64 0-1.1-.295-1.14-.878h-1.19c.03 1.259.931 1.91 2.343 1.91 1.42 0 2.256-.68 2.259-1.745-.003-.969-.733-1.483-1.744-1.71l-.523-.125c-.506-.117-.93-.304-.92-.722 0-.375.332-.65.934-.65.588 0 .949.267.994.724ZM15.78 2.219C15.618.875 14.6.102 13.254.102c-1.537 0-2.71 1.086-2.71 2.989 0 1.898 1.153 2.989 2.71 2.989 1.492 0 2.392-.992 2.526-2.063l-1.244-.006c-.117.623-.606.98-1.262.98-.883 0-1.483-.656-1.483-1.9 0-1.21.591-1.9 1.492-1.9.673 0 1.159.389 1.253 1.028h1.244Z' fill='%2394a3b8'/%3E%3C/svg%3E"); 83 | } 84 | } 85 | 86 | // group css 87 | .group-title { 88 | align-self: flex-start; 89 | font-size: 0.75rem; 90 | line-height: 1.5rem; 91 | font-weight: 600; 92 | padding: 0 0.375rem; 93 | @apply rounded-md; 94 | } 95 | 96 | .group-list { 97 | .group-item[aria-selected="true"] { 98 | @apply bg-violet-400/30 dark:bg-violet-800/30; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/pages/panel/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "@src/pages/panel/App"; 4 | import "./index.scss"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/pages/popup/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from "react"; 2 | import Window from "@src/pages/popup/Window"; 3 | import * as Dialog from "@radix-ui/react-dialog"; 4 | import { Cross2Icon } from "@radix-ui/react-icons"; 5 | import Toolbar from "@src/pages/popup/Toolbar"; 6 | import trashIcon from "@assets/img/trash.png"; 7 | import pinIcon from "@assets/img/pin.png"; 8 | import wandAndStarsIcon from "@assets/img/wand.and.stars.png"; 9 | 10 | interface AppEvent { 11 | type: string; 12 | window?: chrome.windows.Window; 13 | tab?: chrome.tabs.Tab; 14 | } 15 | 16 | function App() { 17 | const [windows, setWindows] = useState([]); 18 | const [selectWindowId, setSelectWindowId] = useState(0); // windows[0].id 19 | const [keyword, setKeyword] = useState(""); 20 | const [tabs, setTabs] = useState([]); 21 | const [selectedIds, setSelectedIds] = useState([]); 22 | const [refresh, setRefresh] = useState(0); 23 | const [openDialog, setOpenDialog] = useState(false); 24 | const channel = new BroadcastChannel("event_channel"); 25 | const inputRef = useRef(null); 26 | const [statusBarTitle, setStatusBarTitle] = useState(""); 27 | 28 | useEffect(() => { 29 | channel.onmessage = (event) => { 30 | const appEvent = event.data as AppEvent; 31 | switch (appEvent.type) { 32 | case "window.close": 33 | chrome.windows 34 | .remove(appEvent.window!.id!) 35 | .then(() => { 36 | setWindows((prevWindows) => { 37 | return prevWindows.filter((window) => { 38 | return window.id !== appEvent.window!.id; 39 | }); 40 | }); 41 | setSelectWindowId((prevSelectWindowId) => { 42 | if (prevSelectWindowId === appEvent.window!.id) { 43 | return 0; 44 | } else { 45 | return prevSelectWindowId; 46 | } 47 | }); 48 | // setRefresh((prevRefresh) => prevRefresh + 1); 49 | }) 50 | .catch((err) => { 51 | console.error(err); 52 | }); 53 | break; 54 | case "window.select": 55 | setSelectWindowId(appEvent.window!.id!); 56 | break; 57 | case "window.unselect": 58 | setSelectWindowId(0); 59 | break; 60 | case "tab.select": 61 | toggleTabSelected(appEvent.tab!.id!); 62 | break; 63 | case "tab.hover": 64 | setStatusBarTitle(appEvent.tab!.title!); 65 | break; 66 | case "tab.unhover": 67 | setStatusBarTitle((prevStatusBarTitle) => { 68 | if (prevStatusBarTitle === appEvent.tab!.title!) { 69 | return ""; 70 | } else { 71 | return prevStatusBarTitle; 72 | } 73 | }); 74 | break; 75 | default: 76 | break; 77 | } 78 | }; 79 | }, []); 80 | 81 | useEffect(() => { 82 | inputRef.current?.focus(); 83 | chrome.windows 84 | .getAll() 85 | .then((windows) => { 86 | // 以 windowid 为 key 生成一个 map 87 | const windowMap = new Map(); 88 | windows.forEach((window) => { 89 | windowMap.set(window.id!, window); 90 | }); 91 | chrome.tabs 92 | .query({}) 93 | .then((tabs) => { 94 | setTabs(tabs); 95 | const windowCounter = new Map(); 96 | tabs.forEach((tab) => { 97 | const windowId = tab.windowId; 98 | const count = windowCounter.get(windowId) || 0; 99 | windowCounter.set(windowId, count + 1); 100 | }); 101 | const windowCounts: { id: number; count: number }[] = []; 102 | windowCounter.forEach((count, windowId) => { 103 | windowCounts.push({ id: windowId, count: count }); 104 | }); 105 | // sort windowCounts by count 106 | windowCounts.sort((a, b) => { 107 | return b.count - a.count; 108 | }); 109 | const windowsSorted: chrome.windows.Window[] = []; 110 | windowCounts.forEach((windowCount) => { 111 | const window = windowMap.get(windowCount.id); 112 | if (window) { 113 | windowsSorted.push(window); 114 | } 115 | }); 116 | setWindows(windowsSorted); 117 | setSelectWindowId((prevSelectWindowId) => prevSelectWindowId); 118 | }) 119 | .catch((err) => { 120 | console.error(err); 121 | }); 122 | }) 123 | .catch((err) => { 124 | console.error(err); 125 | }); 126 | }, [refresh]); 127 | 128 | useEffect(() => { 129 | if (keyword) { 130 | let searchKeyword = keyword; 131 | let statusKey = ""; 132 | // 判断 keyword 是否以 @ 开头,如果是截取出 @ 后面的内容直到 @ 或者空格, 并替换 keyword 133 | if (keyword.startsWith("@")) { 134 | const index = keyword.indexOf(" "); 135 | if (index === -1) { 136 | statusKey = keyword.slice(1); 137 | searchKeyword = ""; 138 | } else { 139 | statusKey = keyword.slice(1, index); 140 | searchKeyword = keyword.slice(index + 1); 141 | } 142 | } 143 | const selectedIds: number[] = []; 144 | tabs.forEach((tab) => { 145 | if (statusKey) { 146 | switch (statusKey) { 147 | case "pinned": 148 | if (!tab.pinned) { 149 | return; 150 | } 151 | break; 152 | case "unpinned": 153 | if (tab.pinned) { 154 | return; 155 | } 156 | break; 157 | case "audible": 158 | if (!tab.audible) { 159 | return; 160 | } 161 | break; 162 | case "muted": 163 | if (!tab.mutedInfo?.muted) { 164 | return; 165 | } 166 | break; 167 | case "unmuted": 168 | if (tab.mutedInfo?.muted) { 169 | return; 170 | } 171 | break; 172 | case "active": 173 | if (!tab.active) { 174 | return; 175 | } 176 | break; 177 | case "inactive": 178 | if (tab.active) { 179 | return; 180 | } 181 | break; 182 | case "highlighted": 183 | if (!tab.highlighted) { 184 | return; 185 | } 186 | break; 187 | case "unhighlighted": 188 | if (tab.highlighted) { 189 | return; 190 | } 191 | break; 192 | case "current": 193 | if (!tab.active || !tab.highlighted) { 194 | return; 195 | } 196 | break; 197 | case "uncurrent": 198 | if (tab.active && tab.highlighted) { 199 | return; 200 | } 201 | break; 202 | case "loading": 203 | if (!tab.status || tab.status !== "loading") { 204 | return; 205 | } 206 | break; 207 | case "complete": 208 | if (!tab.status || tab.status !== "complete") { 209 | return; 210 | } 211 | break; 212 | case "unloaded": 213 | if (!tab.status || tab.status !== "unloaded") { 214 | return; 215 | } 216 | break; 217 | default: 218 | statusKey = ""; 219 | break; 220 | } 221 | } 222 | if (selectWindowId !== 0 && selectWindowId !== tab.windowId) { 223 | return; 224 | } 225 | if (searchKeyword) { 226 | if ( 227 | tab 228 | .url!.toLocaleLowerCase() 229 | .includes(searchKeyword.toLowerCase()) || 230 | tab.title!.toLocaleLowerCase().includes(searchKeyword.toLowerCase()) 231 | ) { 232 | selectedIds.push(tab.id!); 233 | } 234 | } else if (statusKey) { 235 | selectedIds.push(tab.id!); 236 | } 237 | }); 238 | setSelectedIds(selectedIds); 239 | } else { 240 | setSelectedIds([]); 241 | } 242 | }, [keyword, selectWindowId, tabs]); 243 | 244 | useEffect(() => { 245 | if (!openDialog) { 246 | inputRef.current?.focus(); 247 | } 248 | }, [openDialog]); 249 | 250 | const handleClose = () => { 251 | chrome.tabs 252 | .remove(selectedIds) 253 | .then(() => { 254 | setRefresh((preRefresh) => preRefresh + 1); 255 | }) 256 | .catch((err) => { 257 | console.error(err); 258 | }); 259 | }; 260 | 261 | const handlePin = () => { 262 | selectedIds.forEach((tabId) => { 263 | chrome.tabs 264 | .update(tabId, { pinned: true }) 265 | .then(() => { 266 | setRefresh((preRefresh) => preRefresh + 1); 267 | }) 268 | .catch((err) => { 269 | console.error(err); 270 | }); 271 | }); 272 | }; 273 | 274 | const closeDialog = () => { 275 | setOpenDialog(false); 276 | }; 277 | 278 | const toggleTabSelected = (tabId: number) => { 279 | setSelectedIds((prevSelectedIds) => { 280 | const index = prevSelectedIds.indexOf(tabId); 281 | if (index === -1) { 282 | return [...prevSelectedIds, tabId]; 283 | } else { 284 | return [ 285 | ...prevSelectedIds.slice(0, index), 286 | ...prevSelectedIds.slice(index + 1), 287 | ]; 288 | } 289 | }); 290 | }; 291 | 292 | function dialogContent() { 293 | if (selectedIds.length === 0) { 294 | return ( 295 |
    296 | No tab selected 297 |
    298 | ); 299 | } else { 300 | return ( 301 |
    302 | 312 | 322 |
    323 | ); 324 | } 325 | } 326 | 327 | return ( 328 |
    329 |
    330 | 331 |
    332 | 367 | 377 |
    378 |
    379 | {windows.map((window) => { 380 | return ( 381 | 388 | ); 389 | })} 390 |
    391 |
    392 |
    393 | {statusBarTitle} 394 |
    395 |
    396 | {`selected: ${selectedIds.length}`} 397 |
    398 |
    399 | 400 | 401 | 402 | 403 | {dialogContent()} 404 | 405 | 415 | 416 | 417 | 418 | 419 |
    420 |
    421 | ); 422 | } 423 | 424 | export default App; 425 | -------------------------------------------------------------------------------- /src/pages/popup/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import cupIcon from "@assets/img/cup.png"; 3 | import thumbIcon from "@assets/img/thumb.png"; 4 | import sunIcon from "@assets/img/sun.png"; 5 | import moonIcon from "@assets/img/moon.png"; 6 | 7 | function Toolbar() { 8 | const [theme, setTheme] = useState(""); 9 | useEffect(() => { 10 | if (chrome.storage) { 11 | chrome.storage.sync.get( 12 | ["theme"], 13 | (result: { [key: string]: string }) => { 14 | if (result.theme) { 15 | setTheme(result.theme); 16 | } else { 17 | setTheme("dark"); 18 | } 19 | } 20 | ); 21 | } else { 22 | setTheme((localStorage.theme as string) || "dark"); 23 | } 24 | }, []); 25 | 26 | useEffect(() => { 27 | if (!theme) return; 28 | if (chrome.storage) { 29 | chrome.storage.sync.set({ theme: theme }).catch((err) => { 30 | console.error(err); 31 | }); 32 | } else { 33 | localStorage.theme = theme; 34 | } 35 | document.documentElement.classList.remove("dark"); 36 | document.documentElement.classList.remove("light"); 37 | document.documentElement.classList.add(theme); 38 | }, [theme]); 39 | 40 | return ( 41 | 100 | ); 101 | } 102 | export default Toolbar; 103 | -------------------------------------------------------------------------------- /src/pages/popup/Window.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import defaultIcon from "@assets/img/default-image.png"; 3 | 4 | interface WindowProps { 5 | window: chrome.windows.Window; 6 | selectedIds: number[]; 7 | channel: BroadcastChannel; 8 | selectedWindowId: number; 9 | } 10 | 11 | function Window(props: WindowProps) { 12 | const [width, setWidth] = useState(0); 13 | const [isCurrent, setIsCurrent] = useState(false); // props.window.focused 14 | const [tabs, setTabs] = useState([]); 15 | const [showCloseBtn, setShowCloseBtn] = useState(false); 16 | useEffect(() => { 17 | chrome.windows 18 | .getCurrent() 19 | .then((window) => { 20 | setIsCurrent(window.id === props.window.id); 21 | }) 22 | .catch((err) => { 23 | console.error(err); 24 | }); 25 | chrome.tabs 26 | .query({ windowId: props.window.id }) 27 | .then((tabs) => { 28 | setWidth(getWidth(tabs)); 29 | setTabs(tabs); 30 | }) 31 | .catch((err) => { 32 | console.error(err); 33 | }); 34 | }, [props.window]); 35 | // useEffect(() => {}, [props.selectedIds]); 36 | return ( 37 |
    { 45 | e.stopPropagation(); 46 | props.channel.postMessage({ 47 | type: 48 | props.window.id === props.selectedWindowId 49 | ? "window.unselect" 50 | : "window.select", 51 | window: props.window, 52 | }); 53 | }} 54 | > 55 |
    56 |
    57 |
    58 |
    59 |
    { 64 | setShowCloseBtn(true); 65 | }} 66 | onMouseLeave={() => { 67 | setShowCloseBtn(false); 68 | }} 69 | > 70 | 104 |
    105 |
    110 |
    115 |
    116 |
    117 |
    118 |
    119 |
    120 | {tabs.map((tab, index) => { 121 | return ( 122 |
    { 130 | e.stopPropagation(); 131 | props.channel.postMessage({ 132 | type: "tab.select", 133 | tab, 134 | }); 135 | }} 136 | onMouseEnter={() => { 137 | props.channel.postMessage({ 138 | type: "tab.hover", 139 | tab, 140 | }); 141 | }} 142 | onMouseLeave={() => { 143 | props.channel.postMessage({ 144 | type: "tab.unhover", 145 | tab, 146 | }); 147 | }} 148 | > 149 | { 153 | e.currentTarget.src = defaultIcon; 154 | }} 155 | /> 156 |
    157 | ); 158 | })} 159 |
    160 |
    161 |
    162 |
    163 | ); 164 | } 165 | 166 | function getWidth(tabs: chrome.tabs.Tab[]) { 167 | const len = tabs.length; 168 | if (len === 0) return 100; 169 | var cols = 3; 170 | if (len > 12) { 171 | for (let i = 1; true; i++) { 172 | const r = Math.ceil(len / i); 173 | if (r <= i + 1) { 174 | cols = i; 175 | break; 176 | } 177 | } 178 | } 179 | const width = 16 * 2 + cols * 32 + (cols - 1) * 6 + 2; 180 | return Math.max(width, 100); 181 | } 182 | 183 | export default Window; 184 | -------------------------------------------------------------------------------- /src/pages/popup/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/pages/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tab Player 8 | 9 | 10 |
    11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/pages/popup/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "@src/pages/popup/App"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { violet, blackA, mauve, green } = require("@radix-ui/colors"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | darkMode: "class", 6 | content: ["./src/**/*.{js,jsx,ts,tsx}", "./src/**/index.html"], 7 | theme: { 8 | extend: { 9 | width: { 10 | 800: "800px", 11 | 600: "600px", 12 | }, 13 | height: { 14 | 800: "800px", 15 | 600: "600px", 16 | }, 17 | colors: { 18 | ...mauve, 19 | ...violet, 20 | ...green, 21 | ...blackA, 22 | }, 23 | keyframes: { 24 | overlayShow: { 25 | from: { opacity: 0 }, 26 | to: { opacity: 1 }, 27 | }, 28 | contentShow: { 29 | from: { opacity: 0, transform: "translate(-50%, -48%) scale(0.96)" }, 30 | to: { opacity: 1, transform: "translate(-50%, -50%) scale(1)" }, 31 | }, 32 | }, 33 | animation: { 34 | overlayShow: "overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1)", 35 | contentShow: "contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)", 36 | }, 37 | }, 38 | }, 39 | plugins: [require("@tailwindcss/typography"), require("daisyui")], 40 | }; 41 | -------------------------------------------------------------------------------- /test-utils/jest.setup.js: -------------------------------------------------------------------------------- 1 | // Do what you need to set up your test 2 | console.log("setup test: jest.setup.js"); 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "baseUrl": ".", 5 | "allowJs": false, 6 | "target": "esnext", 7 | "module": "esnext", 8 | "jsx": "react-jsx", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "moduleResolution": "node", 13 | "types": ["vite/client", "node"], 14 | "noFallthroughCasesInSwitch": true, 15 | "allowSyntheticDefaultImports": true, 16 | "lib": ["dom", "dom.iterable", "esnext"], 17 | "forceConsistentCasingInFileNames": true, 18 | "typeRoots": ["./src/global.d.ts"], 19 | "paths": { 20 | "@src/*": ["src/*"], 21 | "@assets/*": ["src/assets/*"], 22 | "@pages/*": ["src/pages/*"], 23 | "virtual:reload-on-update-in-background-script": ["./src/global.d.ts"], 24 | "virtual:reload-on-update-in-view": ["./src/global.d.ts"] 25 | } 26 | }, 27 | "include": ["src", "utils", "vite.config.ts", "node_modules/@types"] 28 | } 29 | -------------------------------------------------------------------------------- /utils/log.ts: -------------------------------------------------------------------------------- 1 | type ColorType = "success" | "info" | "error" | "warning" | keyof typeof COLORS; 2 | 3 | export default function colorLog(message: string, type?: ColorType) { 4 | let color: string = type || COLORS.FgBlack; 5 | 6 | switch (type) { 7 | case "success": 8 | color = COLORS.FgGreen; 9 | break; 10 | case "info": 11 | color = COLORS.FgBlue; 12 | break; 13 | case "error": 14 | color = COLORS.FgRed; 15 | break; 16 | case "warning": 17 | color = COLORS.FgYellow; 18 | break; 19 | default: 20 | color = COLORS[type]; 21 | break; 22 | } 23 | 24 | console.log(color, message); 25 | } 26 | 27 | const COLORS = { 28 | Reset: "\x1b[0m", 29 | Bright: "\x1b[1m", 30 | Dim: "\x1b[2m", 31 | Underscore: "\x1b[4m", 32 | Blink: "\x1b[5m", 33 | Reverse: "\x1b[7m", 34 | Hidden: "\x1b[8m", 35 | FgBlack: "\x1b[30m", 36 | FgRed: "\x1b[31m", 37 | FgGreen: "\x1b[32m", 38 | FgYellow: "\x1b[33m", 39 | FgBlue: "\x1b[34m", 40 | FgMagenta: "\x1b[35m", 41 | FgCyan: "\x1b[36m", 42 | FgWhite: "\x1b[37m", 43 | BgBlack: "\x1b[40m", 44 | BgRed: "\x1b[41m", 45 | BgGreen: "\x1b[42m", 46 | BgYellow: "\x1b[43m", 47 | BgBlue: "\x1b[44m", 48 | BgMagenta: "\x1b[45m", 49 | BgCyan: "\x1b[46m", 50 | BgWhite: "\x1b[47m", 51 | } as const; 52 | -------------------------------------------------------------------------------- /utils/manifest-parser/index.ts: -------------------------------------------------------------------------------- 1 | type Manifest = chrome.runtime.ManifestV3; 2 | 3 | class ManifestParser { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-function 5 | private constructor() {} 6 | 7 | static convertManifestToString(manifest: Manifest): string { 8 | return JSON.stringify(manifest, null, 2); 9 | } 10 | } 11 | 12 | export default ManifestParser; 13 | -------------------------------------------------------------------------------- /utils/plugins/add-hmr.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { readFileSync } from "fs"; 3 | import type { PluginOption } from "vite"; 4 | 5 | const isDev = process.env.__DEV__ === "true"; 6 | 7 | const DUMMY_CODE = `export default function(){};`; 8 | 9 | function getInjectionCode(fileName: string): string { 10 | return readFileSync( 11 | path.resolve(__dirname, "..", "reload", "injections", fileName), 12 | { encoding: "utf8" } 13 | ); 14 | } 15 | 16 | type Config = { 17 | background?: boolean; 18 | view?: boolean; 19 | }; 20 | 21 | export default function addHmr(config?: Config): PluginOption { 22 | const { background = false, view = true } = config || {}; 23 | const idInBackgroundScript = "virtual:reload-on-update-in-background-script"; 24 | const idInView = "virtual:reload-on-update-in-view"; 25 | 26 | const scriptHmrCode = isDev ? getInjectionCode("script.js") : DUMMY_CODE; 27 | const viewHmrCode = isDev ? getInjectionCode("view.js") : DUMMY_CODE; 28 | 29 | return { 30 | name: "add-hmr", 31 | resolveId(id) { 32 | if (id === idInBackgroundScript || id === idInView) { 33 | return getResolvedId(id); 34 | } 35 | }, 36 | load(id) { 37 | if (id === getResolvedId(idInBackgroundScript)) { 38 | return background ? scriptHmrCode : DUMMY_CODE; 39 | } 40 | 41 | if (id === getResolvedId(idInView)) { 42 | return view ? viewHmrCode : DUMMY_CODE; 43 | } 44 | }, 45 | }; 46 | } 47 | 48 | function getResolvedId(id: string) { 49 | return "\0" + id; 50 | } 51 | -------------------------------------------------------------------------------- /utils/plugins/custom-dynamic-import.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | 3 | export default function customDynamicImport(): PluginOption { 4 | return { 5 | name: "custom-dynamic-import", 6 | renderDynamicImport() { 7 | return { 8 | left: ` 9 | { 10 | const dynamicImport = (path) => import(path); 11 | dynamicImport( 12 | `, 13 | right: ")}", 14 | }; 15 | }, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /utils/plugins/make-manifest.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import colorLog from "../log"; 4 | import ManifestParser from "../manifest-parser"; 5 | import type { PluginOption } from "vite"; 6 | 7 | const { resolve } = path; 8 | 9 | const distDir = resolve(__dirname, "..", "..", "dist"); 10 | const publicDir = resolve(__dirname, "..", "..", "public"); 11 | 12 | export default function makeManifest( 13 | manifest: chrome.runtime.ManifestV3, 14 | config: { isDev: boolean; contentScriptCssKey?: string } 15 | ): PluginOption { 16 | function makeManifest(to: string) { 17 | if (!fs.existsSync(to)) { 18 | fs.mkdirSync(to); 19 | } 20 | const manifestPath = resolve(to, "manifest.json"); 21 | 22 | // Naming change for cache invalidation 23 | if (config.contentScriptCssKey) { 24 | manifest.content_scripts.forEach((script) => { 25 | script.css = script.css.map((css) => 26 | css.replace("", config.contentScriptCssKey) 27 | ); 28 | }); 29 | } 30 | 31 | fs.writeFileSync( 32 | manifestPath, 33 | ManifestParser.convertManifestToString(manifest) 34 | ); 35 | 36 | colorLog(`Manifest file copy complete: ${manifestPath}`, "success"); 37 | } 38 | 39 | return { 40 | name: "make-manifest", 41 | buildStart() { 42 | if (config.isDev) { 43 | makeManifest(distDir); 44 | } 45 | }, 46 | buildEnd() { 47 | if (config.isDev) { 48 | return; 49 | } 50 | makeManifest(publicDir); 51 | }, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /utils/plugins/watch-rebuild.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | import { resolve } from "path"; 3 | 4 | const rootDir = resolve(__dirname, "..", ".."); 5 | const manifestFile = resolve(rootDir, "manifest.ts"); 6 | const viteConfigFile = resolve(rootDir, "vite.config.ts"); 7 | 8 | export default function watchRebuild(): PluginOption { 9 | return { 10 | name: "watch-rebuild", 11 | async buildStart() { 12 | this.addWatchFile(manifestFile); 13 | this.addWatchFile(viteConfigFile); 14 | }, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /utils/reload/constant.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_RELOAD_SOCKET_PORT = 8081; 2 | export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`; 3 | export const UPDATE_PENDING_MESSAGE = "wait_update"; 4 | export const UPDATE_REQUEST_MESSAGE = "do_update"; 5 | export const UPDATE_COMPLETE_MESSAGE = "done_update"; 6 | -------------------------------------------------------------------------------- /utils/reload/initReloadClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LOCAL_RELOAD_SOCKET_URL, 3 | UPDATE_COMPLETE_MESSAGE, 4 | UPDATE_PENDING_MESSAGE, 5 | UPDATE_REQUEST_MESSAGE, 6 | } from "./constant"; 7 | import MessageInterpreter from "./interpreter"; 8 | 9 | let needToUpdate = false; 10 | 11 | export default function initReloadClient({ 12 | watchPath, 13 | onUpdate, 14 | }: { 15 | watchPath: string; 16 | onUpdate: () => void; 17 | }): WebSocket { 18 | const socket = new WebSocket(LOCAL_RELOAD_SOCKET_URL); 19 | 20 | function sendUpdateCompleteMessage() { 21 | socket.send(MessageInterpreter.send({ type: UPDATE_COMPLETE_MESSAGE })); 22 | } 23 | 24 | socket.addEventListener("message", (event) => { 25 | const message = MessageInterpreter.receive(String(event.data)); 26 | 27 | switch (message.type) { 28 | case UPDATE_REQUEST_MESSAGE: { 29 | if (needToUpdate) { 30 | sendUpdateCompleteMessage(); 31 | needToUpdate = false; 32 | onUpdate(); 33 | } 34 | return; 35 | } 36 | case UPDATE_PENDING_MESSAGE: { 37 | if (!needToUpdate) { 38 | needToUpdate = message.path.includes(watchPath); 39 | } 40 | return; 41 | } 42 | } 43 | }); 44 | 45 | socket.onclose = () => { 46 | console.warn( 47 | `Reload server disconnected.\nPlease check if the WebSocket server is running properly on ${LOCAL_RELOAD_SOCKET_URL}. This feature detects changes in the code and helps the browser to reload the extension or refresh the current tab.` 48 | ); 49 | }; 50 | 51 | return socket; 52 | } 53 | -------------------------------------------------------------------------------- /utils/reload/initReloadServer.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket, WebSocketServer } from "ws"; 2 | import chokidar from "chokidar"; 3 | import { debounce } from "./utils"; 4 | import { 5 | LOCAL_RELOAD_SOCKET_PORT, 6 | LOCAL_RELOAD_SOCKET_URL, 7 | UPDATE_COMPLETE_MESSAGE, 8 | UPDATE_PENDING_MESSAGE, 9 | UPDATE_REQUEST_MESSAGE, 10 | } from "./constant"; 11 | import MessageInterpreter from "./interpreter"; 12 | 13 | const clientsThatNeedToUpdate: Set = new Set(); 14 | 15 | function initReloadServer() { 16 | const wss = new WebSocketServer({ port: LOCAL_RELOAD_SOCKET_PORT }); 17 | 18 | wss.on("listening", () => 19 | console.log(`[HRS] Server listening at ${LOCAL_RELOAD_SOCKET_URL}`) 20 | ); 21 | 22 | wss.on("connection", (ws) => { 23 | clientsThatNeedToUpdate.add(ws); 24 | 25 | ws.addEventListener("close", () => clientsThatNeedToUpdate.delete(ws)); 26 | ws.addEventListener("message", (event) => { 27 | const message = MessageInterpreter.receive(String(event.data)); 28 | if (message.type === UPDATE_COMPLETE_MESSAGE) { 29 | ws.close(); 30 | } 31 | }); 32 | }); 33 | } 34 | 35 | /** CHECK:: src file was updated **/ 36 | const debounceSrc = debounce(function (path: string) { 37 | // Normalize path on Windows 38 | const pathConverted = path.replace(/\\/g, "/"); 39 | clientsThatNeedToUpdate.forEach((ws: WebSocket) => 40 | ws.send( 41 | MessageInterpreter.send({ 42 | type: UPDATE_PENDING_MESSAGE, 43 | path: pathConverted, 44 | }) 45 | ) 46 | ); 47 | // Delay waiting for public assets to be copied 48 | }, 400); 49 | chokidar.watch("src").on("all", (event, path) => debounceSrc(path)); 50 | 51 | /** CHECK:: build was completed **/ 52 | const debounceDist = debounce(() => { 53 | clientsThatNeedToUpdate.forEach((ws: WebSocket) => { 54 | ws.send(MessageInterpreter.send({ type: UPDATE_REQUEST_MESSAGE })); 55 | }); 56 | }, 100); 57 | chokidar.watch("dist").on("all", (event) => { 58 | // Ignore unlink, unlinkDir and change events 59 | // that happen in beginning of build:watch and 60 | // that will cause ws.send() if it takes more than 400ms 61 | // to build (which it might). This fixes: 62 | // https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/100 63 | if (event !== "add" && event !== "addDir") return; 64 | debounceDist(); 65 | }); 66 | 67 | initReloadServer(); 68 | -------------------------------------------------------------------------------- /utils/reload/injections/script.ts: -------------------------------------------------------------------------------- 1 | import initReloadClient from "../initReloadClient"; 2 | 3 | export default function addHmrIntoScript(watchPath: string) { 4 | initReloadClient({ 5 | watchPath, 6 | onUpdate: () => { 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | // @ts-ignore 9 | chrome.runtime.reload(); 10 | }, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /utils/reload/injections/view.ts: -------------------------------------------------------------------------------- 1 | import initReloadClient from "../initReloadClient"; 2 | 3 | export default function addHmrIntoView(watchPath: string) { 4 | let pendingReload = false; 5 | 6 | initReloadClient({ 7 | watchPath, 8 | onUpdate: () => { 9 | // disable reload when tab is hidden 10 | if (document.hidden) { 11 | pendingReload = true; 12 | return; 13 | } 14 | reload(); 15 | }, 16 | }); 17 | 18 | // reload 19 | function reload(): void { 20 | pendingReload = false; 21 | window.location.reload(); 22 | } 23 | 24 | // reload when tab is visible 25 | function reloadWhenTabIsVisible(): void { 26 | !document.hidden && pendingReload && reload(); 27 | } 28 | document.addEventListener("visibilitychange", reloadWhenTabIsVisible); 29 | } 30 | -------------------------------------------------------------------------------- /utils/reload/interpreter/index.ts: -------------------------------------------------------------------------------- 1 | import type { ReloadMessage, SerializedMessage } from "./types"; 2 | 3 | export default class MessageInterpreter { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-function 5 | private constructor() {} 6 | 7 | static send(message: ReloadMessage): SerializedMessage { 8 | return JSON.stringify(message); 9 | } 10 | static receive(serializedMessage: SerializedMessage): ReloadMessage { 11 | return JSON.parse(serializedMessage); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /utils/reload/interpreter/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UPDATE_COMPLETE_MESSAGE, 3 | UPDATE_PENDING_MESSAGE, 4 | UPDATE_REQUEST_MESSAGE, 5 | } from "../constant"; 6 | 7 | type UpdatePendingMessage = { 8 | type: typeof UPDATE_PENDING_MESSAGE; 9 | path: string; 10 | }; 11 | 12 | type UpdateRequestMessage = { 13 | type: typeof UPDATE_REQUEST_MESSAGE; 14 | }; 15 | 16 | type UpdateCompleteMessage = { type: typeof UPDATE_COMPLETE_MESSAGE }; 17 | 18 | export type SerializedMessage = string; 19 | export type ReloadMessage = 20 | | UpdateCompleteMessage 21 | | UpdateRequestMessage 22 | | UpdatePendingMessage; 23 | -------------------------------------------------------------------------------- /utils/reload/rollup.config.ts: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | 3 | const plugins = [typescript()]; 4 | 5 | export default [ 6 | { 7 | plugins, 8 | input: "utils/reload/initReloadServer.ts", 9 | output: { 10 | file: "utils/reload/initReloadServer.js", 11 | }, 12 | external: ["ws", "chokidar", "timers"], 13 | }, 14 | { 15 | plugins, 16 | input: "utils/reload/injections/script.ts", 17 | output: { 18 | file: "utils/reload/injections/script.js", 19 | }, 20 | }, 21 | { 22 | plugins, 23 | input: "utils/reload/injections/view.ts", 24 | output: { 25 | file: "utils/reload/injections/view.js", 26 | }, 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /utils/reload/utils.ts: -------------------------------------------------------------------------------- 1 | import { clearTimeout } from "timers"; 2 | 3 | export function debounce( 4 | callback: (...args: A) => void, 5 | delay: number 6 | ) { 7 | let timer: NodeJS.Timeout; 8 | return function (...args: A) { 9 | clearTimeout(timer); 10 | timer = setTimeout(() => callback(...args), delay); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path, { resolve } from "path"; 4 | import makeManifest from "./utils/plugins/make-manifest"; 5 | import customDynamicImport from "./utils/plugins/custom-dynamic-import"; 6 | import addHmr from "./utils/plugins/add-hmr"; 7 | import watchRebuild from "./utils/plugins/watch-rebuild"; 8 | import manifest from "./manifest"; 9 | 10 | const rootDir = resolve(__dirname); 11 | const srcDir = resolve(rootDir, "src"); 12 | const pagesDir = resolve(srcDir, "pages"); 13 | const assetsDir = resolve(srcDir, "assets"); 14 | const outDir = resolve(rootDir, "dist"); 15 | const publicDir = resolve(rootDir, "public"); 16 | 17 | const isDev = process.env.__DEV__ === "true"; 18 | const isProduction = !isDev; 19 | 20 | // ENABLE HMR IN BACKGROUND SCRIPT 21 | const enableHmrInBackgroundScript = true; 22 | 23 | export default defineConfig({ 24 | resolve: { 25 | alias: { 26 | "@src": srcDir, 27 | "@assets": assetsDir, 28 | "@pages": pagesDir, 29 | }, 30 | }, 31 | plugins: [ 32 | react(), 33 | makeManifest(manifest, { 34 | isDev, 35 | contentScriptCssKey: regenerateCacheInvalidationKey(), 36 | }), 37 | customDynamicImport(), 38 | addHmr({ background: enableHmrInBackgroundScript, view: true }), 39 | watchRebuild(), 40 | ], 41 | publicDir, 42 | build: { 43 | outDir, 44 | /** Can slowDown build speed. */ 45 | // sourcemap: isDev, 46 | minify: isProduction, 47 | reportCompressedSize: isProduction, 48 | rollupOptions: { 49 | input: { 50 | content: resolve(pagesDir, "content", "index.tsx"), 51 | background: resolve(pagesDir, "background", "index.tsx"), 52 | contentStyle: resolve(pagesDir, "content", "style.scss"), 53 | panel: resolve(pagesDir, "panel", "index.html"), 54 | popup: resolve(pagesDir, "popup", "index.html"), 55 | options: resolve(pagesDir, "options", "index.html"), 56 | }, 57 | output: { 58 | entryFileNames: "src/pages/[name]/index.js", 59 | chunkFileNames: isDev 60 | ? "assets/js/[name].js" 61 | : "assets/js/[name].[hash].js", 62 | assetFileNames: (assetInfo) => { 63 | const { dir, name: _name } = path.parse(assetInfo.name); 64 | const assetFolder = dir.split("/").at(-1); 65 | const name = assetFolder + firstUpperCase(_name); 66 | if (name === "contentStyle") { 67 | return `assets/css/contentStyle${cacheInvalidationKey}.chunk.css`; 68 | } 69 | return `assets/[ext]/${name}.chunk.[ext]`; 70 | }, 71 | }, 72 | }, 73 | }, 74 | }); 75 | 76 | function firstUpperCase(str: string) { 77 | const firstAlphabet = new RegExp(/( |^)[a-z]/, "g"); 78 | return str.toLowerCase().replace(firstAlphabet, (L) => L.toUpperCase()); 79 | } 80 | 81 | let cacheInvalidationKey: string = generateKey(); 82 | function regenerateCacheInvalidationKey() { 83 | cacheInvalidationKey = generateKey(); 84 | return cacheInvalidationKey; 85 | } 86 | 87 | function generateKey(): string { 88 | return `${(Date.now() / 100).toFixed()}`; 89 | } 90 | --------------------------------------------------------------------------------