├── .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 |
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 | 
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 | 
87 |
88 | ### Tab 操作
89 |
90 | 可以在输入框中直接输入 `Enter` 或点击输入框右侧的 Magic 按钮,弹出可操作选项。支持全键盘操作,切换焦点使用 `Tab` 键。目前支持 `Close` 和 `Pin`。
91 |
92 | 
93 |
94 | (back to top)
95 |
96 | ### 暗黑/明亮模式切换
97 |
98 | 点击右上角的 月亮 或 太阳 按钮进行切换。
99 |
100 | ## Tab Group
101 |
102 | 
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 | 
121 |
122 | 2. 可以通过 `cmd + shift + o` 呼出 Tab Group 弹窗进行操作。默认使用 `[[` 切分颜色,不设置默认是 `grey`。(可选颜色:"grey","blue","red","yellow","green","pink","purple","cyan","orange";这个弹窗只能在可以运行 content script 的页面呼出)
123 | 
124 | 不输入任何内容,会随机一个名称和颜色,输入 `Enter` 就会创建。
125 | 
126 |
127 | ### Tab Group 查看、聚焦、关闭
128 |
129 | Tab Group 可以通过关键词搜索:
130 | 
131 |
132 | 在列表中右侧的小时钟图标表示当前聚焦,选中其它 item,按下 `Enter`,可以切换聚焦。**!!!当聚焦在一个 Tab Group 时,创建同一个 Window 下新的 Tab 会自动归入聚焦的 Group 中。**
133 | 
134 |
135 | 在选中一个 Tab 且 焦点在输入框中,按下 `Cmd + Enter` 可以关闭一个 Group。
136 |
137 | ## Options
138 |
139 | 
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 | |||
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 |
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 |
66 |
67 |
68 |
69 |
70 |
82 |
83 |
84 |
85 |
86 |
87 |
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 |
42 |
43 | {theme === "dark" ? (
44 |
52 | ) : (
53 |
61 | )}
62 |
63 |
81 |
99 |
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 |
--------------------------------------------------------------------------------