├── .babelrc.js ├── .eslintrc.js ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc.js ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config ├── ChromeExtensionsDevHMRPlugin │ ├── BackgroundLoadScriptRuntimeModule.js │ ├── ContentLoadScriptRuntimeModule.js │ ├── LoadScriptRuntimeModule.js │ └── index.js ├── common.js ├── development.js └── production.js ├── docs ├── assets │ ├── block.gif │ ├── get_it_from_edge.png │ ├── hidden.png │ ├── mark.png │ ├── options.png │ ├── rating-predictor.png │ ├── restart.png │ ├── show-extension.png │ ├── show-file-icon.png │ ├── show.png │ ├── slug.png │ ├── submission-detail-download-progress.png │ ├── submission-detail-download.png │ ├── trynow.png │ ├── 临时解除黑名单.gif │ ├── 删除黑名单.gif │ ├── 实时预测.gif │ ├── 实时预测配置.png │ ├── 打开黑名单列表.gif │ ├── 演示拖拽手柄.gif │ ├── 演示拖拽链接.gif │ ├── 竞赛答题页自定义布局.png │ ├── 自定义题单管理.png │ ├── 题单审核.png │ ├── 题单页侧边栏.png │ └── 题库页题单侧边栏.png ├── 开发.md ├── 竞赛排名页.md ├── 答题页.md ├── 配置选项.md ├── 题单管理.md └── 首页帖子黑名单.md ├── jest.config.js ├── jest └── setupTests.ts ├── package.json ├── packcrx.js ├── pnpm-lock.yaml ├── public ├── _locales │ └── zh_CN │ │ └── messages.json ├── file-icons │ ├── c.svg │ ├── cpp.svg │ ├── csharp.svg │ ├── elixir.svg │ ├── erlang.svg │ ├── golang.svg │ ├── java.svg │ ├── javascript.svg │ ├── kotlin.svg │ ├── php.svg │ ├── python.svg │ ├── python3.svg │ ├── racket.svg │ ├── ruby.svg │ ├── rust.svg │ ├── scala.svg │ ├── swift.svg │ └── typescript.svg ├── icons │ ├── icon-128.png │ ├── icon-16.png │ └── icon-48.png ├── manifest-dev.json ├── manifest.json ├── options.html └── popup.html ├── release.config.js ├── semantic-release-build ├── index.js ├── prepare.js └── verifyConditions.js ├── src ├── background │ ├── hot.ts │ ├── index.ts │ ├── lcrp.ts │ └── utils │ │ ├── cache.ts │ │ ├── fileIconData.ts │ │ ├── getContest.ts │ │ ├── index.ts │ │ ├── lbaoAPI.ts │ │ ├── predictorApi.ts │ │ └── sleep.ts ├── content │ ├── App.tsx │ ├── app │ │ └── store.ts │ ├── components │ │ ├── Button.tsx │ │ ├── Checkbox.tsx │ │ ├── Dialog.tsx │ │ ├── DistortSvg.tsx │ │ ├── ErrorToolTip.tsx │ │ ├── Input.tsx │ │ ├── Modal.tsx │ │ ├── Popper.tsx │ │ ├── PopperUnstyled.tsx │ │ ├── Portal.tsx │ │ ├── SvgIcon.tsx │ │ ├── Switch.tsx │ │ ├── ToolTip.tsx │ │ ├── animation.tsx │ │ ├── icons │ │ │ ├── AddIcon.tsx │ │ │ ├── CheckBoxIcon.tsx │ │ │ ├── CrownIcon.tsx │ │ │ ├── EditIcon.tsx │ │ │ ├── HelpIcon.tsx │ │ │ ├── PrivateIcon.tsx │ │ │ ├── PublicIcon.tsx │ │ │ ├── RemoveIcon.tsx │ │ │ ├── ResetIcon.tsx │ │ │ └── index.ts │ │ └── utils.ts │ ├── hoc │ │ ├── index.ts │ │ ├── withPage.tsx │ │ └── withRoot.tsx │ ├── hooks │ │ ├── index.ts │ │ ├── useEffectMount.ts │ │ ├── useEvent.ts │ │ ├── useHover.ts │ │ ├── useIsMount.ts │ │ ├── useObserverAncestor.ts │ │ └── useThrottle.ts │ ├── index.tsx │ ├── load.ts │ ├── pages │ │ ├── global │ │ │ ├── globalSlice.ts │ │ │ └── optionsSlice.ts │ │ ├── home │ │ │ ├── App.tsx │ │ │ ├── BlockUser.tsx │ │ │ ├── BlockUserList.tsx │ │ │ ├── DragAndDrop.tsx │ │ │ ├── DropContainer.tsx │ │ │ ├── GlobalStyle.tsx │ │ │ ├── Mask.tsx │ │ │ ├── PostItem.tsx │ │ │ ├── blockUsersSlice.ts │ │ │ ├── postsSlice.ts │ │ │ └── useBlock.ts │ │ ├── problem-list │ │ │ ├── AddFavorite.tsx │ │ │ ├── App.tsx │ │ │ ├── Editor.tsx │ │ │ ├── FavoriteItem.tsx │ │ │ ├── FavoriteList.tsx │ │ │ ├── FavoriteWrap.tsx │ │ │ ├── HelpHead.tsx │ │ │ ├── ProblemList.tsx │ │ │ ├── favoriteSlice.ts │ │ │ ├── fixRandom.ts │ │ │ └── useSetProblemListRoot.ts │ │ ├── problems │ │ │ ├── App.tsx │ │ │ ├── Beta.tsx │ │ │ ├── DynamicLayout.tsx │ │ │ ├── Legacy.tsx │ │ │ ├── OptimizedContestProblemsPage.tsx │ │ │ ├── Random.tsx │ │ │ ├── RandomOption.tsx │ │ │ ├── ShortcutKeyOption.tsx │ │ │ ├── Timer.tsx │ │ │ ├── useTimer.ts │ │ │ └── utils.ts │ │ ├── problemset │ │ │ ├── AddQuestion.tsx │ │ │ ├── App.tsx │ │ │ ├── Item.tsx │ │ │ ├── Open.tsx │ │ │ ├── Rank.tsx │ │ │ ├── RankItem.tsx │ │ │ ├── RankRange.tsx │ │ │ ├── Title.tsx │ │ │ ├── TitleBase.tsx │ │ │ ├── intercept.tsx │ │ │ ├── questionsSlice.ts │ │ │ └── utils.ts │ │ └── ranking │ │ │ ├── App.tsx │ │ │ ├── BetaApp.tsx │ │ │ ├── Item.tsx │ │ │ ├── LanguageIcon.tsx │ │ │ ├── Predict.tsx │ │ │ ├── RealTimePredict.tsx │ │ │ ├── Title.tsx │ │ │ ├── rankSlice.ts │ │ │ └── utils.ts │ ├── theme │ │ └── index.ts │ ├── types │ │ ├── customEvent.d.ts │ │ ├── global.d.ts │ │ └── styled.d.ts │ └── utils │ │ ├── conv.ts │ │ ├── index.ts │ │ ├── leetcode-api.ts │ │ ├── predict.ts │ │ └── utils.ts ├── options │ ├── App.tsx │ ├── Option.tsx │ ├── index.tsx │ └── options.ts ├── popup │ ├── App.tsx │ └── index.tsx ├── test └── utils │ ├── index.ts │ ├── logger.ts │ └── utils.ts ├── tsconfig.json └── webpack.config.js /.babelrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {import('@babel/core').ConfigAPI} api 4 | * @returns 5 | */ 6 | module.exports = api => { 7 | const BABEL_ENV = api.env() 8 | 9 | let targets = {} 10 | if (BABEL_ENV === 'test') { 11 | targets = { node: 'current' } 12 | } else if (BABEL_ENV === 'development') { 13 | targets = 14 | 'last 1 chrome version, last 1 firefox version, last 1 safari version' 15 | } else if (BABEL_ENV === 'production') { 16 | targets = 'last 1 chrome version, last 1 firefox version, last 1 safari version' 17 | } 18 | 19 | /** 20 | * @type {import('@babel/core').TransformOptions} 21 | */ 22 | const babelConfig = { 23 | presets: [ 24 | ['@babel/env', { targets }], 25 | ['@babel/react', { runtime: 'automatic' }], 26 | '@babel/preset-typescript', 27 | ], 28 | plugins: [ 29 | [ 30 | 'macros', 31 | { 32 | styledComponents: { 33 | pure: true, 34 | displayName: true, 35 | }, 36 | }, 37 | ], 38 | '@babel/plugin-proposal-class-properties', 39 | BABEL_ENV === 'development' && 'react-refresh/babel', 40 | ].filter(Boolean), 41 | } 42 | 43 | return babelConfig 44 | } 45 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('eslint').Linter.Config} config 3 | */ 4 | const eslintConfig = { 5 | env: { 6 | browser: true, 7 | es2021: true, 8 | node: true, 9 | }, 10 | extends: [ 11 | 'eslint:recommended', 12 | 'plugin:react/recommended', 13 | 'plugin:import/recommended', 14 | 'plugin:import/typescript', 15 | 'plugin:@typescript-eslint/recommended', 16 | 'plugin:prettier/recommended', 17 | 'plugin:react-hooks/recommended', 18 | ], 19 | parser: '@typescript-eslint/parser', 20 | parserOptions: { 21 | ecmaFeatures: { 22 | jsx: true, 23 | }, 24 | ecmaVersion: 12, 25 | sourceType: 'module', 26 | }, 27 | settings: { 28 | react: { 29 | version: 'detect', 30 | }, 31 | 'import/resolver': { 32 | typescript: true, 33 | node: true, 34 | }, 35 | }, 36 | plugins: ['react', '@typescript-eslint'], 37 | rules: { 38 | // ======================================== 39 | // 禁用某些对象的属性 40 | // ======================================== 41 | 'no-restricted-properties': [ 42 | 'error', 43 | { property: 'substring', message: 'Use .slice instead of .substring.' }, 44 | { property: 'substr', message: 'Use .slice instead of .substr.' }, 45 | { 46 | object: 'assert', 47 | property: 'equal', 48 | message: 'Use assert.strictEqual instead of assert.equal.', 49 | }, 50 | { 51 | object: 'assert', 52 | property: 'notEqual', 53 | message: 'Use assert.notStrictEqual instead of assert.notEqual.', 54 | }, 55 | { 56 | object: 'assert', 57 | property: 'deepEqual', 58 | message: 'Use assert.deepStrictEqual instead of assert.deepEqual.', 59 | }, 60 | { 61 | object: 'assert', 62 | property: 'notDeepEqual', 63 | message: 64 | 'Use assert.notDeepStrictEqual instead of assert.notDeepEqual.', 65 | }, 66 | ], 67 | 68 | 'react/jsx-filename-extension': [2, { extensions: ['.tsx'] }], 69 | 70 | // 当启用新的 JSX 转换时,可以关闭下面两条规则 71 | 'react/jsx-uses-react': 'off', 72 | 'react/react-in-jsx-scope': 'off', 73 | 74 | 'react/prop-types': 'off', 75 | 'react/self-closing-comp': [ 76 | 'error', 77 | { 78 | component: true, 79 | html: true, 80 | }, 81 | ], 82 | 83 | '@typescript-eslint/ban-types': [ 84 | 'error', 85 | { 86 | extendDefaults: true, 87 | types: { 88 | '{}': false, 89 | }, 90 | }, 91 | ], 92 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 93 | 94 | // https://styled-components.com/docs/tooling#enforce-macro-imports 95 | 'no-restricted-imports': [ 96 | 'error', 97 | { 98 | paths: [ 99 | { 100 | name: 'styled-components', 101 | message: 'Please import from styled-components/macro.', 102 | }, 103 | ], 104 | patterns: ['!styled-components/macro'], 105 | }, 106 | ], 107 | 108 | '@typescript-eslint/explicit-module-boundary-types': 'off', 109 | '@typescript-eslint/explicit-function-return-type': 'off', 110 | '@typescript-eslint/explicit-member-accessibility': 'off', 111 | 112 | 'prefer-const': 'warn', 113 | }, 114 | overrides: [ 115 | { 116 | files: ['*.ts', '*.tsx'], 117 | rules: { 118 | '@typescript-eslint/explicit-module-boundary-types': ['error'], 119 | '@typescript-eslint/explicit-member-accessibility': ['error'], 120 | }, 121 | }, 122 | { 123 | files: ['*.js'], 124 | rules: { 125 | '@typescript-eslint/no-var-requires': ['off'], 126 | }, 127 | }, 128 | ], 129 | ignorePatterns: ['**/node_modules/**', 'dist', 'lib', '**/__snapshots__/**'], 130 | } 131 | 132 | module.exports = eslintConfig 133 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | environment: release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Setup Environment 20 | uses: pnpm/action-setup@v2.1.0 21 | with: 22 | version: 6.26.1 23 | 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: '14' 28 | cache: 'pnpm' 29 | 30 | - name: Install dependencies 31 | run: pnpm install 32 | 33 | - name: Lint 34 | run: pnpm lint 35 | 36 | - name: Build 37 | run: pnpm build 38 | 39 | - name: Release 40 | env: 41 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | GIT_AUTHOR_NAME: github-actions 44 | GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com 45 | GIT_COMMITTER_NAME: github-actions 46 | GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com 47 | run: npx semantic-release 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Dependency directories 8 | node_modules/ 9 | 10 | # Optional npm cache directory 11 | .npm 12 | 13 | # Optional eslint cache 14 | .eslintcache 15 | 16 | # Output of 'npm pack' 17 | *.tgz 18 | 19 | # Yarn Integrity file 20 | .yarn-integrity 21 | 22 | # dotenv environment variables file 23 | .env 24 | .env.local 25 | .env.development.local 26 | .env.test.local 27 | .env.production.local 28 | 29 | # OS X temporary files 30 | .DS_Store 31 | 32 | dist 33 | *.pem 34 | 35 | # WebStorm 36 | .idea -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | module.exports = { 5 | trailingComma: `es5`, 6 | tabWidth: 2, 7 | semi: false, 8 | singleQuote: true, 9 | endOfLine: `lf`, 10 | printWidth: 80, 11 | arrowParens: `avoid`, 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/node_modules/webpack/bin/webpack.js", 13 | "args": ["serve", "--node-env=development"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.format.enable": true, 3 | "eslint.packageManager": "pnpm", 4 | "[typescript]": { 5 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 6 | }, 7 | "[typescriptreact]": { 8 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 9 | }, 10 | "json.schemas": [ 11 | { 12 | // @see https://github.com/GoogleChrome/chrome-types/issues/7 13 | "fileMatch": ["manifest.json"], 14 | "url": "https://json.schemastore.org/chrome-manifest" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present, XYShaoKang 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. -------------------------------------------------------------------------------- /config/ChromeExtensionsDevHMRPlugin/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ee https://github.com/webpack/webpack/blob/13a3bc13736e3b7066779623939e9fdaec7bc06b/lib/RuntimePlugin.js#L361 3 | */ 4 | const { RuntimeGlobals } = require('webpack') 5 | const ContentLoadScriptRuntimeModule = require('./ContentLoadScriptRuntimeModule') 6 | const BackgroundLoadScriptRuntimeModule = require('./BackgroundLoadScriptRuntimeModule') 7 | const DefaultLoadScriptRuntimeModule = require('webpack/lib/runtime/LoadScriptRuntimeModule') 8 | 9 | class ChromeExtensionsDevHMRPlugin { 10 | constructor(options) { 11 | this.options = options 12 | } 13 | apply(compiler) { 14 | if (compiler.hooks) { 15 | compiler.hooks.thisCompilation.tap( 16 | 'ChromeExtensionsDevHMRPlugin', 17 | compilation => { 18 | compilation.hooks.runtimeRequirementInTree 19 | .for(RuntimeGlobals.loadScript) 20 | .tap('RuntimePlugin', (chunk, set) => { 21 | const withCreateScriptUrl = 22 | !!compilation.outputOptions.trustedTypes 23 | if (withCreateScriptUrl) { 24 | set.add(RuntimeGlobals.createScriptUrl) 25 | } 26 | 27 | switch (chunk.name) { 28 | case 'content-load': 29 | compilation.addRuntimeModule( 30 | chunk, 31 | new ContentLoadScriptRuntimeModule(withCreateScriptUrl) 32 | ) 33 | break 34 | case 'background': 35 | compilation.addRuntimeModule( 36 | chunk, 37 | new BackgroundLoadScriptRuntimeModule(withCreateScriptUrl) 38 | ) 39 | break 40 | 41 | default: 42 | compilation.addRuntimeModule( 43 | chunk, 44 | new DefaultLoadScriptRuntimeModule(withCreateScriptUrl) 45 | ) 46 | break 47 | } 48 | 49 | return true 50 | }) 51 | } 52 | ) 53 | } 54 | } 55 | } 56 | 57 | module.exports = ChromeExtensionsDevHMRPlugin 58 | -------------------------------------------------------------------------------- /config/common.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require('path') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const CopyPlugin = require('copy-webpack-plugin') 5 | 6 | /** 7 | * @type {import('webpack').Configuration} 8 | */ 9 | const common = { 10 | entry: { 11 | popup: path.join(__dirname, '../src/popup/index.tsx'), 12 | options: path.join(__dirname, '../src/options/index.tsx'), 13 | content: path.join(__dirname, '../src/content/index.tsx'), 14 | 'content-load': path.join(__dirname, '../src/content/load.ts'), 15 | background: path.join(__dirname, '../src/background/index.ts'), 16 | }, 17 | output: { 18 | filename: '[name].bundle.js', 19 | path: path.join(__dirname, '../dist'), 20 | publicPath: '/', 21 | clean: true, 22 | }, 23 | resolve: { 24 | alias: { 25 | 'react/jsx-runtime': 'react/jsx-runtime.js', 26 | '@': path.join(__dirname, '../src/content'), 27 | src: path.join(__dirname, '../src'), 28 | }, 29 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 30 | }, 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.[jt]sx?$/, 35 | exclude: /node_modules/, 36 | use: 'babel-loader', 37 | }, 38 | { 39 | test: /\.(png|jp(e*)g|svg|gif)$/, 40 | use: ['file-loader'], 41 | }, 42 | { 43 | test: /\.svg$/, 44 | use: ['@svgr/webpack'], 45 | }, 46 | ], 47 | }, 48 | plugins: [ 49 | new HtmlWebpackPlugin({ 50 | template: path.join(__dirname, '../public', 'options.html'), 51 | chunks: ['options'], 52 | filename: 'options.html', 53 | publicPath: '/', 54 | }), 55 | new HtmlWebpackPlugin({ 56 | template: path.join(__dirname, '../public', 'popup.html'), 57 | chunks: ['popup'], 58 | filename: 'popup.html', 59 | publicPath: '/', 60 | }), 61 | new CopyPlugin({ 62 | patterns: [ 63 | { from: 'icons/*.png', context: path.resolve('public') }, 64 | { from: 'file-icons/*.svg', context: path.resolve('public') }, 65 | { from: '_locales', context: path.resolve('public'), to: '_locales' }, 66 | ], 67 | }), 68 | ], 69 | } 70 | 71 | module.exports = { common } 72 | -------------------------------------------------------------------------------- /config/development.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require('path') 3 | const webpack = require('webpack') 4 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin') 5 | const CopyPlugin = require('copy-webpack-plugin') 6 | 7 | const ChromeExtensionsDevHMRPlugin = require('./ChromeExtensionsDevHMRPlugin') 8 | 9 | /** 10 | * @type {import('webpack').Configuration} 11 | */ 12 | const development = env => ({ 13 | output: { 14 | publicPath: 'http://localhost:9100/', 15 | }, 16 | entry: { 17 | options: [ 18 | 'webpack/hot/dev-server.js', 19 | 'webpack-dev-server/client/index.js?hot=true&protocol=ws&hostname=localhost&port=9100', 20 | path.join(__dirname, '../src/options/index.tsx'), 21 | ], 22 | popup: [ 23 | 'webpack/hot/dev-server.js', 24 | 'webpack-dev-server/client/index.js?hot=true&protocol=ws&hostname=localhost&port=9100', 25 | path.join(__dirname, '../src/popup/index.tsx'), 26 | ], 27 | content: [ 28 | 'webpack/hot/dev-server.js', 29 | 'webpack-dev-server/client/index.js?hot=true&protocol=ws&hostname=localhost&port=9100', 30 | path.join(__dirname, '../src/content/index.tsx'), 31 | ], 32 | 'content-load': [ 33 | 'webpack/hot/dev-server.js', 34 | 'webpack-dev-server/client/index.js?hot=true&protocol=ws&hostname=localhost&port=9100', 35 | path.join(__dirname, '../src/content/load.ts'), 36 | ], 37 | }, 38 | devServer: { 39 | static: { 40 | directory: path.join(__dirname, '../dist'), 41 | watch: false, 42 | }, 43 | host: 'localhost', 44 | port: 9100, 45 | hot: false, 46 | devMiddleware: { 47 | writeToDisk: true, 48 | }, 49 | allowedHosts: 'all', 50 | headers: { 51 | 'Access-Control-Allow-Origin': '*', 52 | }, 53 | client: false, 54 | }, 55 | plugins: [ 56 | new ReactRefreshWebpackPlugin({ overlay: false }), 57 | new webpack.SourceMapDevToolPlugin({ 58 | publicPath: 'http://localhost:9100/', 59 | }), 60 | new ChromeExtensionsDevHMRPlugin(), 61 | new webpack.HotModuleReplacementPlugin(), 62 | new CopyPlugin({ 63 | patterns: [{ from: 'public/manifest-dev.json', to: 'manifest.json' }], 64 | }), 65 | new webpack.DefinePlugin({ 66 | REFINED_LEETCODE_LOG_LEVEL: env.LOG_LEVEL 67 | ? JSON.stringify(env.LOG_LEVEL) 68 | : JSON.stringify('debug'), 69 | }), 70 | new webpack.BannerPlugin({ 71 | banner: file => { 72 | if (file.filename === 'content.bundle.js') { 73 | return ` 74 | const frame = document.createElement('frame') 75 | frame.style.display = 'none' 76 | document.body.append(frame) 77 | const console = frame.contentWindow.console 78 | window.console = console;` 79 | } 80 | return '' 81 | }, 82 | raw: true, 83 | }), 84 | ], 85 | devtool: false, 86 | mode: 'development', 87 | }) 88 | 89 | module.exports = { development } 90 | -------------------------------------------------------------------------------- /config/production.js: -------------------------------------------------------------------------------- 1 | const CopyPlugin = require('copy-webpack-plugin') 2 | const webpack = require('webpack') 3 | 4 | /** 5 | * @type {import('webpack').Configuration} 6 | */ 7 | const production = env => ({ 8 | plugins: [ 9 | new CopyPlugin({ 10 | patterns: [{ from: 'public/manifest.json', to: 'manifest.json' }], 11 | }), 12 | new webpack.DefinePlugin({ 13 | REFINED_LEETCODE_LOG_LEVEL: env.LOG_LEVEL 14 | ? JSON.stringify(env.LOG_LEVEL) 15 | : JSON.stringify('error'), 16 | }), 17 | ], 18 | mode: 'production', 19 | }) 20 | 21 | module.exports = { production } 22 | -------------------------------------------------------------------------------- /docs/assets/block.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/block.gif -------------------------------------------------------------------------------- /docs/assets/get_it_from_edge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/get_it_from_edge.png -------------------------------------------------------------------------------- /docs/assets/hidden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/hidden.png -------------------------------------------------------------------------------- /docs/assets/mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/mark.png -------------------------------------------------------------------------------- /docs/assets/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/options.png -------------------------------------------------------------------------------- /docs/assets/rating-predictor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/rating-predictor.png -------------------------------------------------------------------------------- /docs/assets/restart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/restart.png -------------------------------------------------------------------------------- /docs/assets/show-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/show-extension.png -------------------------------------------------------------------------------- /docs/assets/show-file-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/show-file-icon.png -------------------------------------------------------------------------------- /docs/assets/show.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/show.png -------------------------------------------------------------------------------- /docs/assets/slug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/slug.png -------------------------------------------------------------------------------- /docs/assets/submission-detail-download-progress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/submission-detail-download-progress.png -------------------------------------------------------------------------------- /docs/assets/submission-detail-download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/submission-detail-download.png -------------------------------------------------------------------------------- /docs/assets/trynow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/trynow.png -------------------------------------------------------------------------------- /docs/assets/临时解除黑名单.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/临时解除黑名单.gif -------------------------------------------------------------------------------- /docs/assets/删除黑名单.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/删除黑名单.gif -------------------------------------------------------------------------------- /docs/assets/实时预测.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/实时预测.gif -------------------------------------------------------------------------------- /docs/assets/实时预测配置.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/实时预测配置.png -------------------------------------------------------------------------------- /docs/assets/打开黑名单列表.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/打开黑名单列表.gif -------------------------------------------------------------------------------- /docs/assets/演示拖拽手柄.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/演示拖拽手柄.gif -------------------------------------------------------------------------------- /docs/assets/演示拖拽链接.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/演示拖拽链接.gif -------------------------------------------------------------------------------- /docs/assets/竞赛答题页自定义布局.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/竞赛答题页自定义布局.png -------------------------------------------------------------------------------- /docs/assets/自定义题单管理.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/自定义题单管理.png -------------------------------------------------------------------------------- /docs/assets/题单审核.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/题单审核.png -------------------------------------------------------------------------------- /docs/assets/题单页侧边栏.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/题单页侧边栏.png -------------------------------------------------------------------------------- /docs/assets/题库页题单侧边栏.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/docs/assets/题库页题单侧边栏.png -------------------------------------------------------------------------------- /docs/开发.md: -------------------------------------------------------------------------------- 1 | ## 开发 2 | 3 | 首先需要安装运行环境 [Node.js](https://nodejs.org/en/download/)(>=v14),以及依赖管理工具 [pnpm](https://pnpm.io/) 4 | 5 | > 依赖管理工具可以使用 yarn 或者 npm 替代,下面使用 pnpm 演示 6 | 7 | ### 下载 8 | 9 | ```sh 10 | git clone https://github.com/XYShaoKang/refined-leetcode.git 11 | cd refined-leetcode 12 | ``` 13 | 14 | ### 安装依赖 15 | 16 | ```sh 17 | pnpm install 18 | ``` 19 | 20 | 安装的时候,如果遇到 electron postinstall 安装失败的错误, 一般是因为网络问题,可以通过将 electron 的源设置为国内源解决。 21 | 22 | 通过修改 home 目录下的 `.npmrc` 或者直接在项目根目录下新建一个 `.npmrc` 文件,输入以下内容,保存后,在尝试安装 23 | 24 | ```sh 25 | electron_mirror=https://npmmirror.com/mirrors/electron/ 26 | ``` 27 | 28 | ### 运行 29 | 30 | ```sh 31 | pnpm dev 32 | ``` 33 | 34 | ### 加载扩展 35 | 36 | 运行之后会在项目中生成 dist 文件夹,之后进行如下操作 37 | 38 | 1. 打开 Chrome 浏览器 39 | 2. 转到 chrome://extensions/ 页面 40 | 3. 打开页面右上角的开发者模式 41 | 4. 点击页面左上角的 `加载以解压的扩展程序` 按钮 42 | 5. 选择到 dist 路径,再点击确定即可 43 | 44 | ### 热模块加载 45 | 46 | 对于 content 的页面开发,已经配置好了热模块加载([Hot Module Replacement](https://webpack.js.org/concepts/hot-module-replacement/))功能,React 组件内的大多数修改应该都能实时反应到页面中,可能有些特殊情况或者是修改 React 外部的代码时,页面会重新加载,不过这些都会自动完成. 47 | 48 | 如果是需要修改 background 中的代码,保存之后,等待编译完成,需要到扩展页面,手动按刷新按钮才行. 49 | -------------------------------------------------------------------------------- /docs/竞赛排名页.md: -------------------------------------------------------------------------------- 1 | # 竞赛排名页 2 | 3 | ## 显示预测 4 | 5 | - 2022-02-24 完成初始版本 6 | - 2022-11-27 更新预测数据源 7 | 8 | 此功能源码 [src/content/pages/ranking](../src/content/pages/ranking) 9 | 10 | 目前预测数据 API 已经切换至 https://lccn.lbao.site/,欢迎大家到作者的仓库中 https://github.com/baoliay2008/lccn_predictor 点点 Star 11 | 12 | > ~~效果如下,预测数据来自 https://lcpredictor.herokuapp.com/~~ 13 | > 14 | > https://lcpredictor.herokuapp.com/ 目前在[迁移](https://github.com/SysSn13/leetcode-rating-predictor/issues/48),旧的 API 已经失效,新的 API 未知,无法获取数据。 15 | 16 | ![rating-predictor](assets/rating-predictor.png) 17 | 18 | ## 竞赛排名页面显示代码对应的语言图标 19 | 20 | - 2022-03-09 完成初始版本 21 | 22 | 此功能源码 [src/content/pages/ranking/FileIcon.tsx](../src/content/pages/ranking/FileIcon.tsx) 23 | 24 | 在排名页列表中的提交时间前面,将之前代码图标替换为提交所使用的语言对应的图标,这样可以很直观的看到选手提交所使用的语言 25 | 26 | 这样如果我只想看某种语言的提交,就不需要一个个点进去看,可以节省很多时间 27 | 28 | > 由于世界服 API 返回的数据没有对应提交的语言标记,所以目前只支持国服的排名页 29 | 30 | ![show-file-icon](./assets/show-file-icon.png) 31 | 32 | 演示 33 | 34 | https://user-images.githubusercontent.com/38753204/212783934-2383f4a5-caf5-4997-b166-c13a6996ddd6.mp4 35 | 36 | ## 实时预测 37 | 38 | - 2023-03-18 添加 39 | 40 | 在客户端根据选手的得分和完成时间实时计算预测分数,计算时会消耗一定的性能,所以最好是在有需要时在开启。可以在配置选项中控制功能的开关。 41 | 42 | ![实时预测配置](./assets/实时预测配置.png) 43 | 44 | 另外实时预测会存在一定的误差,计算结果仅供参考。 45 | 46 | 为了跟榜单预测进行区分,实时预测外侧会添加一个虚线框。 47 | 48 | 演示 49 | 50 | https://user-images.githubusercontent.com/38753204/226372446-6861c5e7-4a23-4a57-a1c6-3314229ed29c.mp4 51 | 52 | ### 预测算法说明 53 | 54 | 预测算法源码:[src/content/utils/predict.ts](../src/content/utils/predict.ts) 55 | 56 | - 因为无法区分 0 分选手属于未参赛,还是有提交但 WA 掉的,所以对于这部分选手进行特殊处理。目前采用的策略是直接过滤这部分选手。虽然这会造成一开始 AK 的选手预测结果误差较大,但随着比赛时间慢慢进行,这个误差也会慢慢缩小。 57 | - 对于相同分数并且相同完成时间的选手排名计算,首先会计算有多少选手是比这个分数和时间更快,然后在此基础上加 1,所以相同分数和相同完成时间的选手,会有一样的排名。 58 | - 为了提高性能,会先预处理 `[0,8000]` 范围内的 ERank,预测是只需要在这个范围内二分查找对应的 ERank 即可。 59 | - 但如果按照正常的算法去预处理 ERank 大概需要 5s 以上,虽然预处理后就可以重复使用,但第一次打开页面时的体验很不好。这个问题通过使用 FFT 来加速预处理 ERank 得到解决,使用 FFT 加速之后,预处理 ERank 就只需要 100ms~200ms 左右,速度非常快。 60 | 61 | > FFT 加速的思路来自于 [Carrot](https://github.com/meooow25/carrot),但其实我一开始是看不懂的,好在经过 [@tiger2005](https://leetcode.cn/u/u/tiger2005/) 大佬的讲解并实现了代码,我了解了如何使用,并在项目中实际用上 FFT 加速,从而提升了预测算法的性能。同时非常感谢 [@tiger2005](https://leetcode.cn/u/u/tiger2005/) 大佬,在预测算法的优化过程中,提供的指导和反馈。 62 | > 63 | > 以上测试的时间,均来自于我本地的测试结果。 64 | -------------------------------------------------------------------------------- /docs/答题页.md: -------------------------------------------------------------------------------- 1 | # 答题页 2 | 3 | - 2022-01-02 完成初始版本 4 | - 2022-01-03 自动添加标记的功能 5 | - 2022-10-22 适配力扣新版 UI 6 | - 2022-11-1 添加随机一题按钮 7 | - 2022-11-1 阻止保存页面的弹窗 8 | 9 | 此功能源码 [src/content/pages/problems](../src/content/pages/problems/) 10 | 11 | ## 计时器 12 | 13 | > 此功能的最初创意由 https://leetcode-cn.com/u/s192516/ 提出 14 | 15 | 进入答题页时,自动开始计时,第一次提交成功之后会停止计时,并显示所用时间 16 | 17 | 计时器位于页面右下角,提交和执行代码按钮的左边. 18 | 19 | 目前提供三种操作,分别是在计时阶段: 20 | 21 | 提供隐藏时钟的操作 22 | 23 | ![](assets/hidden.png) 24 | 25 | 重新显示时钟的操作 26 | 27 | ![](assets/show.png) 28 | 29 | 以及在结束之后,重新开始的操作 30 | 31 | ![](assets/restart.png) 32 | 33 | 计时器操作演示 34 | 35 | https://user-images.githubusercontent.com/38753204/212781557-bcfbeb61-d7ff-413f-922a-da65db24bb85.mp4 36 | 37 | ## 自动添加标记的功能 38 | 39 | 当成功提交之后,会将当前所有时间自动添加到对应提交的标记中 40 | 41 | ![](assets/mark.png) 42 | 43 | 新版答题页演示 44 | 45 | https://user-images.githubusercontent.com/38753204/212781261-a6b6a367-8773-49ca-85a5-cef4738a0a73.mp4 46 | 47 | ## 随机一题按钮 48 | 49 | `随机一题`按钮位于上方导航栏处,跟`上一题`、`下一题`等按钮放在一块,在随机刷题的计划中,不用每次都返回题库页去点击`随机一题`。 50 | 51 | ## 比赛答题页提供快捷键禁用选项 52 | 53 | 比赛的时候,直接在网页写题的时候,经常会发生按到某个快捷键的情况,比如像 Cmd+S 会触发页面保存,当然这个只要按下取消就好了,但如果是不小心按到提交的快捷键,则会造成罚时,导致比赛失利。 54 | 55 | 所以我添加了一个禁用快捷键的选项,启用后会禁止触发这些快捷键,防止意外发生。 56 | 57 | ## 比赛答题页自定义布局 58 | 59 | 来自 [https://github.com/acfinity](https://github.com/acfinity) 的贡献 60 | 61 | 将布局切换成横向布局,更加贴近平常刷题的界面,以及更加容易一遍看题目一遍写代码。另外还支持拖动改变题目和编辑框的宽度。 62 | 63 | ![](assets/%E7%AB%9E%E8%B5%9B%E7%AD%94%E9%A2%98%E9%A1%B5%E8%87%AA%E5%AE%9A%E4%B9%89%E5%B8%83%E5%B1%80.png) 64 | -------------------------------------------------------------------------------- /docs/配置选项.md: -------------------------------------------------------------------------------- 1 | # 配置选项 2 | 3 | - 2022-01-31 完成初始版本 4 | 5 | 通过配置选项,去控制扩展各个功能的开关。安装后启用扩展,可以通过左键点击浏览器右上角的扩展图标,在弹出的窗口中进行配置。或者是右键点击扩展图标,在点击选项,会打开一个网页,可以在其中进行配置。 6 | 7 | ![options](assets/options.png) 8 | 9 | 新版本的 Chrome 默认会隐藏扩展图标,可以通过点击拼图图标,在其中找到扩展图标进行操作。另外可以通过点击固定图标,将扩展图标固定显示出来。 10 | 11 | ![show-extension](assets/show-extension.png) 12 | -------------------------------------------------------------------------------- /docs/题单管理.md: -------------------------------------------------------------------------------- 1 | # 题单管理 2 | 3 | - 2023-01-03 在题库页和题单页新增侧边栏,通过鼠标点击展开,提供对题单的管理和浏览 4 | - 2023-01-17 引入题目评分数据 5 | 6 | ## 侧边栏 7 | 8 | 题库页位置 9 | 10 | ![题库页题单侧边栏](assets/%E9%A2%98%E5%BA%93%E9%A1%B5%E9%A2%98%E5%8D%95%E4%BE%A7%E8%BE%B9%E6%A0%8F.png) 11 | 12 | 题单页位置 13 | 14 | ![题单页侧边栏](assets/%E9%A2%98%E5%8D%95%E9%A1%B5%E4%BE%A7%E8%BE%B9%E6%A0%8F.png) 15 | 16 | 自定义题单提供增删改查等功能 17 | 18 | ![自定义题单管理](assets/%E8%87%AA%E5%AE%9A%E4%B9%89%E9%A2%98%E5%8D%95%E7%AE%A1%E7%90%86.png) 19 | 20 | 目前题单的创建和修改名称都是需要经过审核的,为了更方便了解当前的状态,在处于审核状态的题单前面会有一个旋转的「审」字,如果检测到审核通过,则会自动取消。在审核中的题单无法修改名称和可见度,但可以被删除和添加题目 21 | 22 | ![题单审核](assets/%E9%A2%98%E5%8D%95%E5%AE%A1%E6%A0%B8.png) 23 | 24 | 演示 25 | 26 | https://user-images.githubusercontent.com/38753204/212785117-470c750b-30ae-4dc7-bc15-1e548eed46b1.mp4 27 | 28 | ## 题目评分 29 | 30 | 筛选出指定评分范围的题目 31 | 32 | https://user-images.githubusercontent.com/38753204/212785264-85e030ed-844e-44f3-81c3-36da652692cf.mp4 33 | 34 | 按照评分进行排序 35 | 36 | https://user-images.githubusercontent.com/38753204/212785300-37f93904-1550-44fe-a950-2ed375ea2cd4.mp4 37 | 38 | 将筛选出来的题目添加到题单中,注意题单最多只能添加 200 个题目,超出则不让添加 39 | 40 | https://user-images.githubusercontent.com/38753204/212785367-0799f8c7-4465-49e4-b779-6de6c6981136.mp4 41 | 42 | 关闭和开启评分功能 43 | 44 | https://user-images.githubusercontent.com/38753204/212886717-bbe4b613-ebcb-40c5-a515-24ba8053d6fb.mp4 45 | -------------------------------------------------------------------------------- /docs/首页帖子黑名单.md: -------------------------------------------------------------------------------- 1 | # 首页帖子黑名单 2 | 3 | - 2022-10-21 完成初始版本 4 | - 2022-11-14 更新添加黑名单的快捷方式 5 | 6 | 此功能源码 [src/content/pages/home](../src/content/pages/home) 7 | 8 | 通过添加黑名单用户,可以阻止该用户的帖子在首页显示。黑名单数据只存储在本地,如果有多个地方使用的话,则需要在对应的浏览器上添加黑名单。目前只支持首页的帖子中过滤掉黑名单用户发的帖子。 9 | 10 | 安装扩展之后,会在首页右边栏最上方出现一栏黑名单管理的组件,点击可以对当前黑名单列表进行添加和删除等管理。 11 | 12 | ## 打开黑名单列表 13 | 14 | ![打开黑名单列表](assets/打开黑名单列表.gif) 15 | 16 | ## 手动输入 slug 添加黑名单 17 | 18 | ![演示添加黑名单](assets/block.gif) 19 | 20 | 如果要将某个用户添加到黑名单中,需要输入其 slug,可以从其个人主页获取,一般会出现在昵称的下面,或者是个人主页 url 的最后一段。 21 | 22 | ![slug 位置](assets/slug.png) 23 | 24 | ## 通过添加拖拽手柄添加 25 | 26 | ![演示拖拽手柄](assets/演示拖拽手柄.gif) 27 | 28 | ## 通过拖拽链接添加 29 | 30 | ![演示拖拽链接](assets/演示拖拽链接.gif) 31 | 32 | ## 临时解除黑名单 33 | 34 | ![临时解除黑名单](assets/临时解除黑名单.gif) 35 | 36 | ## 删除黑名单 37 | 38 | ![删除黑名单](assets/删除黑名单.gif) 39 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@jest/types').Config.InitialOptions} */ 2 | const config = { 3 | verbose: true, 4 | setupFilesAfterEnv: ['/jest/setupTests.ts'], 5 | transform: { 6 | '^.+\\.[jt]sx?$': 'babel-jest', 7 | }, 8 | testEnvironment: 'jsdom', 9 | } 10 | 11 | module.exports = config 12 | -------------------------------------------------------------------------------- /jest/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | 7 | // https://github.com/jsdom/jsdom/issues/1724 8 | import 'whatwg-fetch' 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "refined-leetcode", 3 | "version": "0.14.4", 4 | "main": "index.js", 5 | "author": "XYShaoKang", 6 | "license": "MIT", 7 | "description": "", 8 | "keywords": [], 9 | "scripts": { 10 | "start": "webpack serve --node-env development", 11 | "dev": "webpack serve --node-env development", 12 | "build": "webpack --node-env production", 13 | "build:debug": "webpack --node-env production --env LOG_LEVEL=debug", 14 | "packcrx": "node packcrx.js", 15 | "lint": "pnpm lint:prettier && pnpm lint:eslint", 16 | "lint:prettier": "prettier --check src", 17 | "lint:eslint": "eslint \"src/**/*.{ts,tsx}\"", 18 | "test": "jest", 19 | "reduxdev": "redux-devtools --open" 20 | }, 21 | "devDependencies": { 22 | "@babel/cli": "^7.16.8", 23 | "@babel/core": "^7.16.7", 24 | "@babel/plugin-proposal-class-properties": "^7.16.7", 25 | "@babel/preset-env": "^7.16.8", 26 | "@babel/preset-react": "^7.16.7", 27 | "@babel/preset-typescript": "^7.16.7", 28 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", 29 | "@redux-devtools/cli": "^1.0.4", 30 | "@semantic-release/changelog": "^6.0.1", 31 | "@semantic-release/error": "^3.0.0", 32 | "@semantic-release/git": "^10.0.1", 33 | "@testing-library/dom": "^8.11.2", 34 | "@testing-library/jest-dom": "^5.16.1", 35 | "@testing-library/react": "^12.1.2", 36 | "@testing-library/user-event": "^13.5.0", 37 | "@types/chrome": "^0.0.176", 38 | "@types/jest": "^27.4.0", 39 | "@types/react": "^17.0.38", 40 | "@types/react-dom": "^17.0.11", 41 | "@types/react-redux": "^7.1.25", 42 | "@types/redux-logger": "^3.0.9", 43 | "@types/redux-state-sync": "^3.1.5", 44 | "@types/remote-redux-devtools": "^0.5.5", 45 | "@types/styled-components": "^5.1.20", 46 | "@types/testing-library__jest-dom": "^5.14.2", 47 | "@types/webpack-env": "^1.16.3", 48 | "@typescript-eslint/eslint-plugin": "^5.9.1", 49 | "@typescript-eslint/parser": "^5.9.1", 50 | "archiver": "^5.3.0", 51 | "babel-jest": "^27.4.6", 52 | "babel-loader": "^8.2.3", 53 | "babel-plugin-macros": "^3.1.0", 54 | "clean-webpack-plugin": "^4.0.0", 55 | "copy-webpack-plugin": "^10.2.0", 56 | "crx": "^5.0.1", 57 | "eslint": "^8.7.0", 58 | "eslint-config-prettier": "^8.3.0", 59 | "eslint-import-resolver-typescript": "^3.5.2", 60 | "eslint-plugin-import": "^2.25.4", 61 | "eslint-plugin-jsx-a11y": "^6.5.1", 62 | "eslint-plugin-prettier": "^4.0.0", 63 | "eslint-plugin-react": "^7.28.0", 64 | "eslint-plugin-react-hooks": "^4.3.0", 65 | "file-loader": "^6.2.0", 66 | "html-webpack-plugin": "^5.5.0", 67 | "jest": "^27.4.7", 68 | "prettier": "^2.5.1", 69 | "react-devtools": "^4.22.1", 70 | "react-is": "^17.0.2", 71 | "react-refresh": "^0.11.0", 72 | "remote-redux-devtools": "^0.5.16", 73 | "semantic-release": "^19.0.2", 74 | "style-loader": "^3.3.1", 75 | "tapable": "^2.2.1", 76 | "typescript": "^4.5.4", 77 | "webpack": "^5.66.0", 78 | "webpack-cli": "^4.9.1", 79 | "webpack-dev-server": "^4.7.3", 80 | "webpack-merge": "^5.8.0", 81 | "whatwg-fetch": "^3.6.2" 82 | }, 83 | "dependencies": { 84 | "@reduxjs/toolkit": "^1.7.1", 85 | "date-fns": "^2.28.0", 86 | "json5": "^2.2.3", 87 | "localforage": "^1.10.0", 88 | "pino": "^7.11.0", 89 | "react": "^17.0.2", 90 | "react-dnd": "^16.0.1", 91 | "react-dnd-html5-backend": "^16.0.1", 92 | "react-dom": "^17.0.2", 93 | "react-redux": "^7.2.6", 94 | "redux": "^4.1.2", 95 | "redux-logger": "^3.0.6", 96 | "redux-persist": "^6.0.0", 97 | "redux-state-sync": "^3.1.4", 98 | "styled-components": "^5.3.3" 99 | } 100 | } -------------------------------------------------------------------------------- /packcrx.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const ChromeExtension = require('crx') 4 | 5 | const getKey = () => { 6 | let { PRIVATE_KEY, PRIVATE_KEY_PATH } = process.env 7 | if (PRIVATE_KEY) return Buffer.from(PRIVATE_KEY, 'utf-8') 8 | 9 | try { 10 | const keyPath = PRIVATE_KEY_PATH || path.join(__dirname, './key.pem') 11 | fs.accessSync(keyPath, fs.constants.W_OK) 12 | return fs.readFileSync(keyPath) 13 | } catch (error) { 14 | throw new Error('请设置 PRIVATE_KEY 或 PRIVATE_KEY_PATH') 15 | } 16 | } 17 | 18 | const crx = new ChromeExtension({ 19 | privateKey: getKey(), 20 | }) 21 | 22 | crx 23 | .load(path.join(__dirname, './dist/')) 24 | .then(crx => crx.pack()) 25 | .then(crxBuffer => { 26 | fs.writeFileSync(path.join(__dirname, './refined-leetcode.crx'), crxBuffer) 27 | }) 28 | -------------------------------------------------------------------------------- /public/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /public/file-icons/c.svg: -------------------------------------------------------------------------------- 1 | file_type_c -------------------------------------------------------------------------------- /public/file-icons/cpp.svg: -------------------------------------------------------------------------------- 1 | file_type_cpp -------------------------------------------------------------------------------- /public/file-icons/csharp.svg: -------------------------------------------------------------------------------- 1 | file_type_csharp -------------------------------------------------------------------------------- /public/file-icons/elixir.svg: -------------------------------------------------------------------------------- 1 | file_type_elixir -------------------------------------------------------------------------------- /public/file-icons/erlang.svg: -------------------------------------------------------------------------------- 1 | file_type_erlang -------------------------------------------------------------------------------- /public/file-icons/golang.svg: -------------------------------------------------------------------------------- 1 | file_type_go_lightblue -------------------------------------------------------------------------------- /public/file-icons/java.svg: -------------------------------------------------------------------------------- 1 | file_type_java -------------------------------------------------------------------------------- /public/file-icons/javascript.svg: -------------------------------------------------------------------------------- 1 | file_type_js -------------------------------------------------------------------------------- /public/file-icons/kotlin.svg: -------------------------------------------------------------------------------- 1 | file_type_kotlin -------------------------------------------------------------------------------- /public/file-icons/php.svg: -------------------------------------------------------------------------------- 1 | file_type_php -------------------------------------------------------------------------------- /public/file-icons/python.svg: -------------------------------------------------------------------------------- 1 | file_type_python -------------------------------------------------------------------------------- /public/file-icons/python3.svg: -------------------------------------------------------------------------------- 1 | file_type_python -------------------------------------------------------------------------------- /public/file-icons/racket.svg: -------------------------------------------------------------------------------- 1 | file_type_racket -------------------------------------------------------------------------------- /public/file-icons/rust.svg: -------------------------------------------------------------------------------- 1 | file_type_rust -------------------------------------------------------------------------------- /public/file-icons/scala.svg: -------------------------------------------------------------------------------- 1 | file_type_scala -------------------------------------------------------------------------------- /public/file-icons/swift.svg: -------------------------------------------------------------------------------- 1 | file_type_swift -------------------------------------------------------------------------------- /public/file-icons/typescript.svg: -------------------------------------------------------------------------------- 1 | file_type_typescript -------------------------------------------------------------------------------- /public/icons/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/public/icons/icon-128.png -------------------------------------------------------------------------------- /public/icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/public/icons/icon-16.png -------------------------------------------------------------------------------- /public/icons/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XYShaoKang/refined-leetcode/ad48e28eb9ad43bade6356cac18d5d332d539452/public/icons/icon-48.png -------------------------------------------------------------------------------- /public/manifest-dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Refined Leetcode dev", 3 | "description": "LeetCode 网页扩展", 4 | "version": "0.2.1", 5 | "manifest_version": 3, 6 | "homepage_url": "https://github.com/XYShaoKang/refined-leetcode", 7 | "default_locale": "zh_CN", 8 | "icons": { 9 | "16": "icons/icon-16.png", 10 | "48": "icons/icon-48.png", 11 | "128": "icons/icon-128.png" 12 | }, 13 | "web_accessible_resources": [ 14 | { 15 | "resources": [ 16 | "/content.bundle.js", 17 | "/*.hot-update.js", 18 | "/*.hot-update.json", 19 | "/*.map", 20 | "/file-icons/*.svg" 21 | ], 22 | "matches": [ 23 | "*://leetcode-cn.com/*", 24 | "*://leetcode.cn/*" 25 | ] 26 | } 27 | ], 28 | "content_scripts": [ 29 | { 30 | "matches": [ 31 | "https://leetcode-cn.com/*", 32 | "https://leetcode.cn/*" 33 | ], 34 | "js": [ 35 | "content-load.bundle.js" 36 | ] 37 | } 38 | ], 39 | "background": { 40 | "service_worker": "background.bundle.js" 41 | }, 42 | "options_page": "options.html", 43 | "action": { 44 | "default_popup": "popup.html" 45 | }, 46 | "content_security_policy": { 47 | "extension_pages": "default-src 'self' http://localhost:9100; script-src 'self' http://localhost:9100; script-src-elem 'self' http://localhost:9100; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:9100 http://localhost:9100 https://leetcode-rating-predictor.herokuapp.com https://leetcode-predictor.herokuapp.com https://leetcode-cn.com https://leetcode.cn https://lccn.lbao.site https://leetcode.com" 48 | }, 49 | "permissions": [ 50 | "scripting", 51 | "storage" 52 | ], 53 | "host_permissions": [ 54 | "https://leetcode-cn.com/*", 55 | "https://leetcode.cn/*", 56 | "http://localhost:9100/*", 57 | "https://leetcode-rating-predictor.herokuapp.com/*", 58 | "https://leetcode-predictor.herokuapp.com/*", 59 | "https://lccn.lbao.site/*", 60 | "https://leetcode.com/*" 61 | ], 62 | "externally_connectable": { 63 | "matches": [ 64 | "https://leetcode-cn.com/*", 65 | "https://leetcode.cn/*" 66 | ] 67 | } 68 | } -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Refined Leetcode", 3 | "description": "LeetCode 网页扩展", 4 | "version": "0.0.0", 5 | "manifest_version": 3, 6 | "homepage_url": "https://github.com/XYShaoKang/refined-leetcode", 7 | "default_locale": "zh_CN", 8 | "icons": { 9 | "16": "icons/icon-16.png", 10 | "48": "icons/icon-48.png", 11 | "128": "icons/icon-128.png" 12 | }, 13 | "web_accessible_resources": [ 14 | { 15 | "resources": [ 16 | "/content.bundle.js", 17 | "/*.map", 18 | "/file-icons/*.svg" 19 | ], 20 | "matches": [ 21 | "*://leetcode-cn.com/*", 22 | "*://leetcode.cn/*" 23 | ] 24 | } 25 | ], 26 | "content_scripts": [ 27 | { 28 | "matches": [ 29 | "https://leetcode-cn.com/*", 30 | "https://leetcode.cn/*" 31 | ], 32 | "js": [ 33 | "content-load.bundle.js" 34 | ] 35 | } 36 | ], 37 | "background": { 38 | "service_worker": "background.bundle.js" 39 | }, 40 | "options_page": "options.html", 41 | "action": { 42 | "default_popup": "popup.html" 43 | }, 44 | "content_security_policy": { 45 | "extension_pages": "default-src 'self'; script-src 'self'; script-src-elem 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://leetcode-rating-predictor.herokuapp.com https://leetcode-predictor.herokuapp.com https://leetcode-cn.com https://leetcode.cn https://lccn.lbao.site https://leetcode.com" 46 | }, 47 | "permissions": [ 48 | "storage" 49 | ], 50 | "host_permissions": [ 51 | "https://leetcode-cn.com/*", 52 | "https://leetcode.cn/*", 53 | "https://leetcode-rating-predictor.herokuapp.com/*", 54 | "https://leetcode-predictor.herokuapp.com/*", 55 | "https://lccn.lbao.site/*", 56 | "https://leetcode.com/*" 57 | ], 58 | "externally_connectable": { 59 | "matches": [ 60 | "https://leetcode-cn.com/*", 61 | "https://leetcode.cn/*" 62 | ] 63 | } 64 | } -------------------------------------------------------------------------------- /public/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Refined LeetCode 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Refined LeetCode 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ['master'], 3 | preset: 'angular', 4 | plugins: [ 5 | '@semantic-release/commit-analyzer', 6 | '@semantic-release/release-notes-generator', 7 | [ 8 | './semantic-release-build', 9 | { 10 | dist: 'dist', 11 | name: 'refined-leetcode', 12 | }, 13 | ], 14 | [ 15 | '@semantic-release/changelog', 16 | { 17 | changelogFile: 'CHANGELOG.md', 18 | }, 19 | ], 20 | [ 21 | '@semantic-release/git', 22 | { 23 | assets: ['CHANGELOG.md', 'package.json'], 24 | message: 'chore(release): ${nextRelease.version} [skip ci]', 25 | }, 26 | ], 27 | [ 28 | '@semantic-release/github', 29 | { 30 | assets: [ 31 | { 32 | path: 'refined-leetcode.crx', 33 | name: 'refined-leetcode-${nextRelease.gitTag}.crx', 34 | }, 35 | { 36 | path: 'refined-leetcode.zip', 37 | name: 'refined-leetcode-${nextRelease.gitTag}.zip', 38 | }, 39 | { 40 | path: 'refined-leetcode.crx.zip', 41 | name: 'refined-leetcode-${nextRelease.gitTag}.crx.zip', 42 | }, 43 | ], 44 | }, 45 | ], 46 | ], 47 | } 48 | -------------------------------------------------------------------------------- /semantic-release-build/index.js: -------------------------------------------------------------------------------- 1 | const verifyConditions = require('./verifyConditions') 2 | const prepare = require('./prepare') 3 | module.exports = { verifyConditions, prepare } 4 | -------------------------------------------------------------------------------- /semantic-release-build/prepare.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const SemanticReleaseError = require('@semantic-release/error') 4 | const archiver = require('archiver') 5 | const ChromeExtension = require('crx') 6 | 7 | const getKey = () => { 8 | let { PRIVATE_KEY, PRIVATE_KEY_PATH } = process.env 9 | if (PRIVATE_KEY) return Buffer.from(PRIVATE_KEY, 'utf-8') 10 | 11 | try { 12 | const keyPath = PRIVATE_KEY_PATH || path.resolve('key.pem') 13 | fs.accessSync(keyPath, fs.constants.W_OK) 14 | return fs.readFileSync(keyPath) 15 | } catch (error) { 16 | throw new SemanticReleaseError('请设置 PRIVATE_KEY 或 PRIVATE_KEY_PATH') 17 | } 18 | } 19 | 20 | const prepare = async (pluginConfig = {}, context) => { 21 | let { dist = 'dist', name = 'extension' } = pluginConfig 22 | 23 | dist = path.resolve(dist) 24 | if (!fs.existsSync(dist)) throw new SemanticReleaseError('dist 目录不存在') 25 | 26 | const manifestPath = path.join(dist, 'manifest.json') 27 | if (!fs.existsSync(manifestPath)) 28 | throw new SemanticReleaseError('manifest.json 文件不存在') 29 | 30 | const { logger, nextRelease } = context 31 | 32 | // 修改版本号 33 | const version = nextRelease.version 34 | try { 35 | logger.log('修改 manifest.json 版本号') 36 | const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) 37 | fs.writeFileSync( 38 | manifestPath, 39 | JSON.stringify({ ...manifestData, version }, null, 2), 40 | 'utf-8' 41 | ) 42 | 43 | logger.log('修改 package.json 版本号') 44 | const packagePath = path.resolve('package.json') 45 | const packageData = JSON.parse(fs.readFileSync(packagePath, 'utf-8')) 46 | fs.writeFileSync( 47 | packagePath, 48 | JSON.stringify({ ...packageData, version }, null, 2), 49 | 'utf-8' 50 | ) 51 | } catch (error) { 52 | throw new SemanticReleaseError('修改版本失败') 53 | } 54 | 55 | // 打包成 zip 56 | logger.log('打包 zip') 57 | const archive = archiver('zip', { zlib: { level: 9 } }) 58 | const output = path.resolve(`${name}.zip`) 59 | archive.pipe(fs.createWriteStream(output)) 60 | archive.directory(dist, false) 61 | archive.finalize() 62 | 63 | // 打包为 crx 64 | logger.log('打包 crx') 65 | await new ChromeExtension({ privateKey: getKey() }) 66 | .load(dist) 67 | .then(crx => crx.pack()) 68 | .then(crxBuffer => { 69 | fs.mkdir 70 | fs.writeFileSync(path.resolve(`${name}.crx`), crxBuffer) 71 | }) 72 | 73 | logger.log('打包 zip') 74 | const crxarchive = archiver('zip', { zlib: { level: 9 } }) 75 | const crxoutput = path.resolve(`${name}.crx.zip`) 76 | crxarchive.pipe(fs.createWriteStream(crxoutput)) 77 | crxarchive.file(path.resolve(`${name}.crx`), { name: `${name}.crx` }) 78 | crxarchive.finalize() 79 | } 80 | 81 | module.exports = prepare 82 | -------------------------------------------------------------------------------- /semantic-release-build/verifyConditions.js: -------------------------------------------------------------------------------- 1 | const SemanticReleaseError = require('@semantic-release/error') 2 | 3 | const verifyConditions = () => { 4 | const { PRIVATE_KEY, PRIVATE_KEY_PATH } = process.env 5 | 6 | if (!PRIVATE_KEY && !PRIVATE_KEY_PATH) { 7 | throw new SemanticReleaseError('请设置 PRIVATE_KEY 或 PRIVATE_KEY_PATH') 8 | } 9 | } 10 | 11 | module.exports = verifyConditions 12 | -------------------------------------------------------------------------------- /src/background/hot.ts: -------------------------------------------------------------------------------- 1 | type HotUpdateMessage = { 2 | type: 'hot-update' 3 | url: string 4 | } 5 | type ReloadMessage = { 6 | type: 'reload' 7 | } 8 | type RestartMessage = { 9 | type: 'restart' 10 | } 11 | 12 | type Message = HotUpdateMessage | ReloadMessage | RestartMessage 13 | async function hotUpdate( 14 | message: HotUpdateMessage, 15 | sender: chrome.runtime.MessageSender, 16 | sendResponse: (response?: any) => void 17 | ) { 18 | const reg = /\/\.\/([\d\D]+\.hot-update\.js)/ 19 | const url = message.url.match(reg)?.[1] 20 | 21 | if (url && sender.tab?.id) { 22 | chrome.scripting.executeScript({ 23 | target: { tabId: sender.tab!.id! }, 24 | files: [url], 25 | }) 26 | 27 | sendResponse({ data: true }) 28 | } else { 29 | sendResponse({ data: false }) 30 | } 31 | } 32 | 33 | async function reload( 34 | _message: ReloadMessage, 35 | _sender: chrome.runtime.MessageSender, 36 | _sendResponse: (response?: any) => void 37 | ) { 38 | console.log('start reload') 39 | chrome.runtime.reload() 40 | } 41 | 42 | async function restart( 43 | message: RestartMessage, 44 | sender: chrome.runtime.MessageSender, 45 | sendResponse: (response?: any) => void 46 | ) { 47 | chrome.tabs.reload(sender.tab!.id!) 48 | sendResponse({ data: true }) 49 | } 50 | 51 | if (module.hot) { 52 | chrome.runtime.onMessage.addListener( 53 | (message: Message, sender, sendResponse) => { 54 | console.log(message) 55 | switch (message.type) { 56 | case 'hot-update': 57 | hotUpdate(message, sender, sendResponse) 58 | break 59 | case 'reload': 60 | reload(message, sender, sendResponse) 61 | break 62 | case 'restart': 63 | restart(message, sender, sendResponse) 64 | break 65 | default: 66 | break 67 | } 68 | return true 69 | } 70 | ) 71 | } 72 | 73 | export {} 74 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import './hot' 2 | import './lcrp' 3 | 4 | import { defaultOptions } from '../options/options' 5 | 6 | chrome.runtime.onInstalled.addListener(e => { 7 | if (e.reason === 'install') { 8 | // 当前是首次安装,使用默认配置 9 | chrome.storage.local.set({ options: defaultOptions }) 10 | } else { 11 | chrome.storage.local.get('options').then(({ options }) => { 12 | // 当前是更新 13 | if (!options) { 14 | // 不存在配置选项,因为不确定用户之前主要使用哪些功能,所以启用所有功能,让用户决定是否关闭某些功能 15 | options = {} 16 | for (const page of Reflect.ownKeys(defaultOptions)) { 17 | options[page] = {} 18 | for (const key of Reflect.ownKeys((defaultOptions as any)[page])) { 19 | options[page][key] = true 20 | } 21 | } 22 | } else { 23 | // 如果存在配置,则将将当前配置与默认配置进行合并,以当前配置为主,如果发现不存在某个配置,则将其设置为默认配置 24 | for (const page of Reflect.ownKeys(defaultOptions)) { 25 | if (!options[page]) options[page] = {} 26 | for (const key of Reflect.ownKeys((defaultOptions as any)[page])) { 27 | if (options[page][key] === undefined) 28 | options[page][key] = (defaultOptions as any)[page][key] 29 | } 30 | } 31 | } 32 | chrome.storage.local.set({ options }) 33 | }) 34 | } 35 | }) 36 | 37 | export {} 38 | -------------------------------------------------------------------------------- /src/background/utils/cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 根据 key 缓存函数执行结果 3 | * @param keyFn 生成缓存的 key 4 | * @param handle 输出函数 5 | * @param timeout 超时设置,单位为毫秒,默认为 10 分钟,如果缓存数据超过 timeout,则重新获取结果 6 | * @returns 7 | */ 8 | export function cache( 9 | keyFn: (...args: T) => string, 10 | handle: (...args: T) => Promise | R, 11 | timeout = 600000 12 | ): (...args: T) => Promise { 13 | const map = new Map | R, number]>() 14 | return async (...args: T) => { 15 | const key = keyFn(...args) 16 | if (map.has(key)) { 17 | const [promise, time] = map.get(key)! 18 | if (new Date().valueOf() - time <= timeout) { 19 | try { 20 | const data = await promise 21 | return data 22 | } catch (error) { 23 | // 如果当前缓存有错误,则舍弃缓存的结果,重新获取 24 | } 25 | } 26 | } 27 | const promise = handle(...args) 28 | 29 | map.set(key, [promise, new Date().valueOf()]) 30 | return promise 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/background/utils/fileIconData.ts: -------------------------------------------------------------------------------- 1 | export const fileIconData = [ 2 | { 3 | slug: 'cpp', 4 | lang: 'C++', 5 | file: '/file-icons/cpp.svg', 6 | }, 7 | { 8 | slug: 'java', 9 | lang: 'Java', 10 | file: '/file-icons/java.svg', 11 | }, 12 | { 13 | slug: 'python', 14 | lang: 'Python', 15 | file: '/file-icons/python.svg', 16 | }, 17 | // { 18 | // slug: 'mysql', 19 | // lang: 'MySQL', 20 | // file: '/file-icons/mysql.svg', 21 | // }, 22 | { 23 | slug: 'c', 24 | lang: 'C', 25 | file: '/file-icons/c.svg', 26 | }, 27 | { 28 | slug: 'csharp', 29 | lang: 'C#', 30 | file: '/file-icons/csharp.svg', 31 | }, 32 | { 33 | slug: 'javascript', 34 | lang: 'JavaScript', 35 | file: '/file-icons/javascript.svg', 36 | }, 37 | { 38 | slug: 'ruby', 39 | lang: 'Ruby', 40 | file: '/file-icons/ruby.svg', 41 | }, 42 | // { 43 | // slug: 'bash', 44 | // lang: 'Bash', 45 | // file: '/file-icons/bash.svg', 46 | // }, 47 | { 48 | slug: 'swift', 49 | lang: 'Swift', 50 | file: '/file-icons/swift.svg', 51 | }, 52 | { 53 | slug: 'golang', 54 | lang: 'Go', 55 | file: '/file-icons/golang.svg', 56 | }, 57 | { 58 | slug: 'python3', 59 | lang: 'Python3', 60 | file: '/file-icons/python3.svg', 61 | }, 62 | { 63 | slug: 'scala', 64 | lang: 'Scala', 65 | file: '/file-icons/scala.svg', 66 | }, 67 | { 68 | slug: 'kotlin', 69 | lang: 'Kotlin', 70 | file: '/file-icons/kotlin.svg', 71 | }, 72 | { 73 | slug: 'rust', 74 | lang: 'Rust', 75 | file: '/file-icons/rust.svg', 76 | }, 77 | { 78 | slug: 'php', 79 | lang: 'PHP', 80 | file: '/file-icons/php.svg', 81 | }, 82 | { 83 | slug: 'typescript', 84 | lang: 'TypeScript', 85 | file: '/file-icons/typescript.svg', 86 | }, 87 | { 88 | slug: 'racket', 89 | lang: 'Racket', 90 | file: '/file-icons/racket.svg', 91 | }, 92 | { 93 | slug: 'erlang', 94 | lang: 'Erlang', 95 | file: '/file-icons/erlang.svg', 96 | }, 97 | { 98 | slug: 'elixir', 99 | lang: 'Elixir', 100 | file: '/file-icons/elixir.svg', 101 | }, 102 | ] 103 | -------------------------------------------------------------------------------- /src/background/utils/getContest.ts: -------------------------------------------------------------------------------- 1 | import { RankingDataType, RankType, SubmissionType } from '@/utils' 2 | import { cache } from '.' 3 | 4 | export const getContest = cache( 5 | (contestId: string, page: number, region: 'local' | 'global' = 'local') => 6 | `${contestId}-${page}-${region}`, 7 | async function getContest( 8 | contestId: string, 9 | page: number, 10 | region: 'local' | 'global' = 'local', 11 | retry = 1 12 | ): Promise { 13 | const url = `https://leetcode.cn/contest/api/ranking/${contestId}/?pagination=${page}®ion=${region}` 14 | const res = await fetch(url) 15 | if (res.status === 200) { 16 | return res.json() 17 | } 18 | if (retry > 5) throw new Error('获取 Contest 数据失败') 19 | return getContest(contestId, page, region, retry + 1) 20 | } 21 | ) 22 | 23 | export type MyRankingType = { 24 | my_solved: number[] 25 | registered: boolean 26 | fallback_local: boolean 27 | my_submission: { [key: number]: SubmissionType } 28 | my_rank: RankType 29 | } 30 | 31 | export const getMyRanking = cache( 32 | (contestId: string) => contestId, 33 | async function getMyRanking( 34 | contestId: string, 35 | retry = 1 36 | ): Promise { 37 | const url = `https://leetcode.cn/contest/api/myranking/${contestId}/` 38 | const res = await fetch(url) 39 | 40 | if (res.status === 200) return res.json() 41 | 42 | if (retry > 5) throw new Error('获取 Rank 数据失败') 43 | return getMyRanking(contestId, retry + 1) 44 | } 45 | ) 46 | -------------------------------------------------------------------------------- /src/background/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sleep' 2 | export * from './cache' 3 | export * from './fileIconData' 4 | export * from './predictorApi' 5 | export * from './getContest' 6 | export * from './lbaoAPI' 7 | -------------------------------------------------------------------------------- /src/background/utils/lbaoAPI.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from '@/utils' 2 | 3 | export type LbaoPredictorType = { 4 | data_region: string 5 | username: string 6 | delta?: number 7 | oldRating?: number 8 | newRating?: number 9 | } 10 | 11 | export const lbaoPredictorApi = async ( 12 | contest_name: string, 13 | users: { data_region: string; username: string }[], 14 | retry = 5 15 | ): Promise => { 16 | const res = await fetch( 17 | 'https://lccn.lbao.site/api/v1/contest-records/predicted-rating', 18 | { 19 | method: 'POST', 20 | body: JSON.stringify({ 21 | contest_name, 22 | users, 23 | }), 24 | headers: { 'content-type': 'application/json' }, 25 | } 26 | ) 27 | if (retry && res.status === 503) { 28 | await sleep(2000) 29 | return lbaoPredictorApi(contest_name, users, retry - 1) 30 | } 31 | const data: { 32 | old_rating: number 33 | new_rating: number 34 | delta_rating: number 35 | }[] = await res.json() 36 | return users.map((user, i) => ({ 37 | ...user, 38 | oldRating: data[i]?.old_rating, 39 | delta: data[i]?.delta_rating, 40 | newRating: data[i]?.new_rating, 41 | })) 42 | } 43 | -------------------------------------------------------------------------------- /src/background/utils/predictorApi.ts: -------------------------------------------------------------------------------- 1 | import { sleep, cache } from '.' 2 | 3 | export type PredictorType = { 4 | status: string 5 | meta: { 6 | contest_id: string 7 | total_count: number 8 | } 9 | items: { 10 | data_region: string 11 | delta: number 12 | _id: string 13 | }[] 14 | } 15 | 16 | // @see: https://github.com/SysSn13/leetcode-rating-predictor/blob/4a7f4057bd5bf94727723b0bc02d781be573a3eb/chrome-extension/background.js#L26 17 | const LCRP_API = [ 18 | 'https://leetcode-predictor.herokuapp.com/api/v1/predictions', 19 | 'https://leetcode-rating-predictor.herokuapp.com/api/v1/predictions', 20 | ] 21 | let API_INDEX = 0 22 | const RETRY_COUNT = 5 23 | 24 | const changeApiIndex = (() => { 25 | let enable = true 26 | return () => { 27 | if (enable) { 28 | API_INDEX = (API_INDEX + 1) % LCRP_API.length 29 | 30 | enable = false 31 | setTimeout(() => { 32 | enable = true 33 | }, 2000) 34 | } 35 | } 36 | })() 37 | 38 | export const predictorApi = cache( 39 | ({ contestId, handles }: { contestId: string; handles: string[] }) => 40 | contestId + ',' + handles.sort().join(','), 41 | async function predictorApi( 42 | { contestId, handles }, 43 | retry = 1 44 | ): Promise { 45 | const baseUrl = LCRP_API[API_INDEX] 46 | const url = new URL(baseUrl) 47 | url.searchParams.set('contestId', contestId) 48 | url.searchParams.set('handles', handles.join(';')) 49 | 50 | try { 51 | const res = await fetch(url.toString()) 52 | 53 | if (res.status === 200) { 54 | return res.json() 55 | } else if (retry < RETRY_COUNT) { 56 | // 换一个 API 试试 57 | changeApiIndex() 58 | await sleep(2 ** retry * 100) 59 | return predictorApi({ contestId, handles }, retry + 1) 60 | } else { 61 | throw new Error('获取预测数据失败') 62 | } 63 | } catch (error) { 64 | // 网络请求失败,换一个 API 试试 65 | changeApiIndex() 66 | await sleep(2 ** retry * 100) 67 | return predictorApi({ contestId, handles }, retry + 1) 68 | } 69 | } 70 | ) 71 | -------------------------------------------------------------------------------- /src/background/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export function sleep(time: number): Promise { 2 | return new Promise(function (resolve) { 3 | setTimeout(resolve, time) 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /src/content/App.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { withRoot } from './hoc' 3 | 4 | import Ranking from './pages/ranking/App' 5 | import Home from './pages/home/App' 6 | import Timer from './pages/problems/App' 7 | import ShortcutKeyOption from './pages/problems/ShortcutKeyOption' 8 | import Problemset from './pages/problemset/App' 9 | import ProblemList from './pages/problem-list/App' 10 | import { customEventDispatch } from './utils' 11 | import OptimizedContestProblemsPage from '@/pages/problems/OptimizedContestProblemsPage' 12 | 13 | const App: FC = () => { 14 | customEventDispatch('refinedLeetcodeGetOptions') 15 | return ( 16 | <> 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | export default withRoot(App) 29 | -------------------------------------------------------------------------------- /src/content/app/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, ConfigureStoreOptions } from '@reduxjs/toolkit' 2 | import { 3 | createStateSyncMiddleware, 4 | initMessageListener, 5 | } from 'redux-state-sync' 6 | import { persistStore, persistReducer } from 'redux-persist' 7 | import storage from 'redux-persist/lib/storage' 8 | import localforage from 'localforage' 9 | 10 | import { apiSlice, contestInfosSlice } from '@/pages/ranking/rankSlice' 11 | import postsReducer from '@/pages/home/postsSlice' 12 | import blockUsersReducer from '@/pages/home/blockUsersSlice' 13 | import globalDataReducer, { fetchGlobalData } from '@/pages/global/globalSlice' 14 | import optionsReducer from '@/pages/global/optionsSlice' 15 | import favoritesReducer from '@/pages/problem-list/favoriteSlice' 16 | import questionsReducer from '@/pages/problemset/questionsSlice' 17 | 18 | // debug 19 | // import logger from 'redux-logger' 20 | // import devToolsEnhancer from 'remote-redux-devtools' 21 | const enhancers: ConfigureStoreOptions['enhancers'] = [ 22 | // 配置 Redux DevTools 23 | // devToolsEnhancer({ 24 | // hostname: 'localhost', 25 | // port: 8000, 26 | // realtime: true, 27 | // }), 28 | ] 29 | 30 | const config = { 31 | whitelist: [ 32 | 'users/setBlockUserBySlug/fulfilled', 33 | 'users/setBlockUserByPostId/fulfilled', 34 | 'users/setBlockUserByCommunityArticleId/fulfilled', 35 | 'users/setBlockUserBySolutionSlug/fulfilled', 36 | 'blockUsers/unSetBlockUser', 37 | 'blockUsers/toggleBlockUser', 38 | 'global/fetchGlobalData/fulfilled', 39 | 'options/toggleContestProblemShortcutKeyOption', 40 | ], 41 | } 42 | 43 | const persistConfig = { 44 | key: 'refined-leetcode', 45 | storage, 46 | migrate: (state: any) => { 47 | const key = 'BlockUserList' 48 | const data = localStorage.getItem(key) 49 | // 迁移老版本的数据,如果存在的话 50 | if (data) { 51 | try { 52 | const blockUserList: { 53 | slug: string 54 | name: string 55 | }[] = JSON.parse(data) 56 | 57 | if (!state) state = {} 58 | if (!state.entities) { 59 | state.entities = {} 60 | state.ids = [] 61 | } 62 | 63 | for (const { slug, name } of blockUserList) { 64 | if (!state.entities[slug]) { 65 | state.entities[slug] = { slug, name, block: true } 66 | state.ids.push(slug) 67 | } 68 | } 69 | } catch (error) { 70 | // 71 | } 72 | } 73 | // console.log('Migration Running!', state) 74 | return Promise.resolve(state) 75 | }, 76 | } 77 | 78 | const persistedUsersReducer = persistReducer(persistConfig, blockUsersReducer) 79 | const persistedQuestionsReducer = persistReducer( 80 | { 81 | key: 'refined-leetcode-questions', 82 | storage: localforage, 83 | }, 84 | questionsReducer 85 | ) 86 | const persistedOptionsReducer = persistReducer( 87 | { 88 | key: 'refined-leetcode-option', 89 | storage: localforage, 90 | }, 91 | optionsReducer 92 | ) 93 | 94 | const store = configureStore({ 95 | reducer: { 96 | [apiSlice.reducerPath]: apiSlice.reducer, 97 | posts: postsReducer, 98 | blockUsers: persistedUsersReducer, 99 | global: globalDataReducer, 100 | options: persistedOptionsReducer, 101 | favorites: favoritesReducer, 102 | questions: persistedQuestionsReducer, 103 | contestInfos: contestInfosSlice.reducer, 104 | }, 105 | devTools: false, 106 | middleware: getDefaultMiddleware => 107 | getDefaultMiddleware({ 108 | serializableCheck: false, 109 | immutableCheck: false, 110 | }) 111 | .concat(apiSlice.middleware) 112 | .concat(createStateSyncMiddleware(config)), //.concat(logger) 113 | enhancers, 114 | }) 115 | 116 | export const persistor = persistStore(store) 117 | 118 | initMessageListener(store) 119 | 120 | export default store 121 | 122 | export type RootState = ReturnType 123 | export type AppDispatch = typeof store.dispatch 124 | 125 | store.dispatch(fetchGlobalData()) 126 | -------------------------------------------------------------------------------- /src/content/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ForwardedRef, forwardRef, ReactNode } from 'react' 2 | import styled from 'styled-components/macro' 3 | 4 | import { SCProps, StyledComponent } from './utils' 5 | import { rotate360Deg } from './animation' 6 | 7 | interface ButtonOwnerProps { 8 | disabled?: boolean 9 | loading?: boolean 10 | children: ReactNode 11 | } 12 | 13 | const Button: StyledComponent = forwardRef( 14 | function Button( 15 | { children, disabled, loading, ...props }: SCProps, 16 | ref: ForwardedRef 17 | ) { 18 | return ( 19 | 20 | {loading ? : children} 21 | 22 | ) 23 | } 24 | ) 25 | 26 | export default Button 27 | 28 | const Loading = styled.div` 29 | border-radius: 50%; 30 | background: linear-gradient(to right, #fff 10%, rgba(128, 0, 255, 0) 42%); 31 | position: relative; 32 | transform: translateZ(0); 33 | height: 16px; 34 | width: 16px; 35 | animation: ${rotate360Deg} 0.1s infinite linear; 36 | &::before { 37 | width: 50%; 38 | height: 50%; 39 | background: #fff; 40 | border-radius: 100% 0 0 0; 41 | position: absolute; 42 | top: 0; 43 | left: 0; 44 | content: ''; 45 | } 46 | &::after { 47 | background-color: ${props => props.theme.palette.button.disable}; 48 | width: 75%; 49 | height: 75%; 50 | border-radius: 50%; 51 | content: ''; 52 | margin: auto; 53 | position: absolute; 54 | top: 0; 55 | left: 0; 56 | bottom: 0; 57 | right: 0; 58 | } 59 | ` 60 | 61 | const ButtonStyled = styled.button.attrs<{ disabled?: boolean }>(props => ({ 62 | disable: props.disabled, 63 | }))` 64 | flex-shrink: 0; 65 | width: 70px; 66 | color-scheme: dark; 67 | font-feature-settings: 'tnum'; 68 | box-sizing: border-box; 69 | margin: 0; 70 | border: none; 71 | line-height: 20px; 72 | outline: none; 73 | user-select: none; 74 | text-decoration: none; 75 | display: inline-flex; 76 | align-items: center; 77 | justify-content: center; 78 | transition-property: color, box-shadow, background-color, opacity; 79 | transition-duration: 0.3s; 80 | overflow: hidden; 81 | cursor: ${props => (props.disabled ? '' : 'pointer')}; 82 | opacity: 1; 83 | font-size: 14px; 84 | padding: 6px 12px; 85 | border-radius: 8px; 86 | color: ${props => props.theme.palette.button.text}; 87 | background-color: ${props => 88 | props.disabled 89 | ? props.theme.palette.button.disable 90 | : props.theme.palette.button.main}; 91 | &:hover { 92 | background-color: ${props => 93 | props.disabled ? '' : `${props.theme.palette.button.hover};`}; 94 | } 95 | &:focus { 96 | background-color: ${props => 97 | props.disabled ? '' : `${props.theme.palette.button.hover};`}; 98 | } 99 | ` 100 | -------------------------------------------------------------------------------- /src/content/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEventHandler, FC } from 'react' 2 | import { css } from 'styled-components/macro' 3 | import { 4 | CheckBoxCheckedIcon, 5 | CheckBoxIndeterminateIcon, 6 | CheckBoxUncheckedIcon, 7 | } from './icons' 8 | 9 | interface CheckboxProps { 10 | checked?: boolean 11 | indeterminate?: boolean 12 | onChange?: ChangeEventHandler 13 | size?: number 14 | color?: string 15 | id?: string 16 | } 17 | 18 | const Checkbox: FC = ({ 19 | checked, 20 | indeterminate, 21 | onChange, 22 | size, 23 | color, 24 | id, 25 | }) => { 26 | const Icon = indeterminate 27 | ? CheckBoxIndeterminateIcon 28 | : checked 29 | ? CheckBoxCheckedIcon 30 | : CheckBoxUncheckedIcon 31 | const chandleChange: ChangeEventHandler = e => { 32 | onChange?.(e) 33 | } 34 | return ( 35 | 43 | 60 | 61 | 62 | ) 63 | } 64 | 65 | export default Checkbox 66 | -------------------------------------------------------------------------------- /src/content/components/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, ForwardedRef, ReactNode, useEffect } from 'react' 2 | import { css } from 'styled-components/macro' 3 | 4 | import { Portal } from './Portal' 5 | import { StyledComponent, SCProps } from './utils' 6 | 7 | export type Placement = 'top' | 'bottom' | 'left' | 'right' 8 | 9 | interface DialogProps { 10 | open: boolean 11 | children: ReactNode 12 | onClose?: (e: any) => void 13 | } 14 | 15 | const Dialog: StyledComponent = forwardRef(function Popper< 16 | AsC extends string | React.ComponentType = 'span' 17 | >( 18 | { open, children, onClose, ...props }: SCProps, 19 | _ref: ForwardedRef 20 | ) { 21 | useEffect(() => { 22 | const handleClose = (e: KeyboardEvent) => { 23 | if (e.code === 'Escape') { 24 | if (onClose) { 25 | onClose(e) 26 | } 27 | } 28 | } 29 | if (open && children) document.body.addEventListener('keydown', handleClose) 30 | return () => { 31 | document.body.removeEventListener('keydown', handleClose) 32 | } 33 | }, [open, children, onClose]) 34 | 35 | if (!children) return null 36 | 37 | return ( 38 | 39 |
52 |
{children}
53 |
54 |
55 | ) 56 | }) 57 | 58 | export default Dialog 59 | -------------------------------------------------------------------------------- /src/content/components/DistortSvg.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import 'styled-components/macro' 3 | 4 | /** 实现扭曲糢糊效果 5 | * 6 | * 通过 CSS 中的 filter 使用 7 | */ 8 | const DistortSvg: FC = () => { 9 | return ( 10 | 15 | 22 | 23 | 30 | 31 | 32 | ) 33 | } 34 | 35 | export default DistortSvg 36 | -------------------------------------------------------------------------------- /src/content/components/ErrorToolTip.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement, ComponentProps } from 'react' 2 | import { ToolTip } from './ToolTip' 3 | 4 | interface ErrorToolTipOwnerProps { 5 | error: { 6 | message: string 7 | show: boolean 8 | } 9 | children: ReactElement 10 | onClose?: (e: any) => void 11 | } 12 | 13 | type ErrorToolTipProps = Omit< 14 | ComponentProps, 15 | keyof ErrorToolTipOwnerProps 16 | > & 17 | ErrorToolTipOwnerProps 18 | 19 | const ErrorToolTip: FC< 20 | Partial> & ErrorToolTipOwnerProps 21 | > = ({ error, children, ...props }: ErrorToolTipProps) => { 22 | return ( 23 | 44 | 48 | 49 | } 50 | > 51 | {children} 52 | 53 | ) 54 | } 55 | 56 | export default ErrorToolTip 57 | -------------------------------------------------------------------------------- /src/content/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/macro' 2 | 3 | export const Input = styled.input` 4 | overflow: visible; 5 | height: 32px; 6 | outline: none; 7 | border-radius: 8px !important; 8 | box-sizing: border-box; 9 | width: 100%; 10 | padding: 4px 11px; 11 | font-size: 14px; 12 | line-height: 1.5715; 13 | border: 1px solid rgba(0, 0, 0, 0); 14 | transition: all 0.3s; 15 | touch-action: manipulation; 16 | text-overflow: ellipsis; 17 | color: ${props => props.theme.palette.text.main}; 18 | background: ${props => props.theme.palette.secondary.main}; 19 | &:focus { 20 | background-color: ${props => props.theme.palette.secondary.hover}; 21 | } 22 | ` 23 | -------------------------------------------------------------------------------- /src/content/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, ForwardedRef, ReactNode, useEffect } from 'react' 2 | import { css } from 'styled-components/macro' 3 | 4 | import { Portal } from './Portal' 5 | import { StyledComponent, SCProps } from './utils' 6 | 7 | export type Placement = 'top' | 'bottom' | 'left' | 'right' 8 | 9 | interface ModalProps { 10 | open: boolean 11 | children: ReactNode 12 | onClose?: (e: any) => void 13 | } 14 | 15 | const Modal: StyledComponent = forwardRef(function Popper< 16 | AsC extends string | React.ComponentType = 'span' 17 | >( 18 | { open, children, onClose, ...props }: SCProps, 19 | _ref: ForwardedRef 20 | ) { 21 | useEffect(() => { 22 | const handleClose = (e: KeyboardEvent) => { 23 | if (e.code === 'Escape' && typeof onClose === 'function') { 24 | onClose(e) 25 | } 26 | } 27 | if (open && children) document.body.addEventListener('keydown', handleClose) 28 | return () => { 29 | document.body.removeEventListener('keydown', handleClose) 30 | } 31 | }, [open, children, onClose]) 32 | 33 | if (!children) return null 34 | 35 | return ( 36 | 37 |
50 |
{children}
51 |
52 |
53 | ) 54 | }) 55 | 56 | export default Modal 57 | -------------------------------------------------------------------------------- /src/content/components/Portal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | 3 | import { createPortal } from 'react-dom' 4 | 5 | export const Portal = function Portal({ 6 | children, 7 | container, 8 | }: { 9 | children: ReactNode 10 | container?: HTMLElement | null 11 | }): React.ReactPortal { 12 | if (!container) container = document.body 13 | return createPortal(children, container) 14 | } 15 | -------------------------------------------------------------------------------- /src/content/components/SvgIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SCProps, StyledComponent } from './utils' 2 | 3 | interface OwnSvgIconProps { 4 | children?: React.ReactNode 5 | height?: number 6 | } 7 | export type SvgIconProps = SCProps 8 | export type SvgIconType = StyledComponent 9 | 10 | const SvgIcon: SvgIconType = ({ children, height = 24, ...props }) => { 11 | return ( 12 | 13 | {children} 14 | 15 | ) 16 | } 17 | 18 | export default SvgIcon 19 | -------------------------------------------------------------------------------- /src/content/components/Switch.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react' 2 | 3 | interface SwitchProps { 4 | enable?: boolean 5 | height?: number 6 | width?: number 7 | onToggle?: () => void 8 | } 9 | 10 | const Switch: React.FC = ({ 11 | enable: enableProp, 12 | height, 13 | width, 14 | onToggle, 15 | }) => { 16 | const { current: isControlled } = useRef(enableProp !== undefined) 17 | const [state, setState] = useState() 18 | const handleToggle = () => { 19 | setState(!state) 20 | if (onToggle) onToggle() 21 | } 22 | if (height === undefined && width === undefined) height = 24 23 | const enable = isControlled ? enableProp : state 24 | return ( 25 | 30 | {enable ? ( 31 | 36 | ) : ( 37 | 41 | )} 42 | 43 | ) 44 | } 45 | 46 | export default Switch 47 | -------------------------------------------------------------------------------- /src/content/components/ToolTip.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | useRef, 4 | useCallback, 5 | ReactElement, 6 | cloneElement, 7 | ForwardedRef, 8 | } from 'react' 9 | import { useHover } from '@/hooks' 10 | import { setRef } from '@/utils' 11 | import { Popper, Placement, PopperProps } from './Popper' 12 | import { StyledComponent, SCProps } from './utils' 13 | 14 | export interface TooltipOwnerProps { 15 | title: ReactElement | string 16 | placement?: Placement 17 | open?: boolean 18 | arrow?: boolean 19 | icon?: ReactElement 20 | delay?: number 21 | children: React.ReactElement 22 | keep?: boolean 23 | } 24 | 25 | export const ToolTip: StyledComponent = 26 | forwardRef(function ToolTip1< 27 | AsC extends string | React.ComponentType = 'span' 28 | >( 29 | { 30 | title, 31 | placement = 'top', 32 | open: openProp, 33 | arrow: arrowProp, 34 | icon, 35 | delay, 36 | keep, 37 | children, 38 | ...props 39 | }: SCProps, 40 | ref: ForwardedRef 41 | ) { 42 | const [setHoverRef, hover] = useHover(delay ?? 100) 43 | const [setPopperHoverRef, popperHover] = useHover(delay ?? 100, [ 44 | ref as any, 45 | ]) 46 | 47 | const childrenRef = useRef() 48 | 49 | const mulRef = useCallback(el => { 50 | setRef(el, childrenRef) 51 | setRef(el, setHoverRef) 52 | setRef(el, children && (children as any).ref) 53 | }, []) 54 | 55 | let open = openProp 56 | if (openProp === undefined) { 57 | open = !!title && (hover || (keep && popperHover)) 58 | } 59 | let arrow = arrowProp 60 | if (arrowProp === undefined) { 61 | arrow = true 62 | } 63 | 64 | return ( 65 | <> 66 | {cloneElement(children, { ...children.props, ref: mulRef })} 67 | {open ? ( 68 | 75 | <> 76 | {icon} 77 | {title} 78 | 79 | 80 | ) : null} 81 | 82 | ) 83 | }) 84 | -------------------------------------------------------------------------------- /src/content/components/animation.tsx: -------------------------------------------------------------------------------- 1 | import { keyframes } from 'styled-components/macro' 2 | 3 | export const rotate360Deg = keyframes` 4 | from { 5 | transform: rotate(0deg); 6 | } 7 | 8 | to { 9 | transform: rotate(360deg); 10 | } 11 | ` 12 | -------------------------------------------------------------------------------- /src/content/components/icons/AddIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '../SvgIcon' 2 | 3 | const AddIcon: React.FC = props => { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default AddIcon 12 | -------------------------------------------------------------------------------- /src/content/components/icons/CheckBoxIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '../SvgIcon' 2 | 3 | export const CheckBoxCheckedIcon: React.FC = props => { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export const CheckBoxUncheckedIcon: React.FC = props => { 12 | return ( 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export const CheckBoxIndeterminateIcon: React.FC = props => { 20 | return ( 21 | 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/content/components/icons/CrownIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '../SvgIcon' 2 | 3 | const CrownIcon: React.FC = props => { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default CrownIcon 12 | -------------------------------------------------------------------------------- /src/content/components/icons/EditIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '../SvgIcon' 2 | 3 | const EditIcon: React.FC = props => { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default EditIcon 12 | -------------------------------------------------------------------------------- /src/content/components/icons/HelpIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '../SvgIcon' 2 | 3 | const HelpIcon: React.FC = props => { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default HelpIcon 12 | -------------------------------------------------------------------------------- /src/content/components/icons/PrivateIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '../SvgIcon' 2 | 3 | const PrivateIcon: React.FC = props => { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default PrivateIcon 12 | -------------------------------------------------------------------------------- /src/content/components/icons/PublicIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '../SvgIcon' 2 | 3 | const PublicIcon: React.FC = props => { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default PublicIcon 12 | -------------------------------------------------------------------------------- /src/content/components/icons/RemoveIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '../SvgIcon' 2 | 3 | const RemoveIcon: React.FC = props => { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default RemoveIcon 12 | -------------------------------------------------------------------------------- /src/content/components/icons/ResetIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '../SvgIcon' 2 | 3 | const ResetIcon: React.FC = props => { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default ResetIcon 12 | -------------------------------------------------------------------------------- /src/content/components/icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as HelpIcon } from './HelpIcon' 2 | export { default as AddIcon } from './AddIcon' 3 | export { default as EditIcon } from './EditIcon' 4 | export { default as RemoveIcon } from './RemoveIcon' 5 | export { default as PublicIcon } from './PublicIcon' 6 | export { default as PrivateIcon } from './PrivateIcon' 7 | export * from './CheckBoxIcon' 8 | -------------------------------------------------------------------------------- /src/content/components/utils.ts: -------------------------------------------------------------------------------- 1 | import { StyledComponentProps } from 'styled-components/macro' 2 | 3 | export type SCProps< 4 | BaseProps extends object, 5 | AsC extends string | React.ComponentType, 6 | FAsC extends string | React.ComponentType = AsC 7 | > = Omit< 8 | StyledComponentProps, 9 | keyof BaseProps 10 | > & 11 | BaseProps & { 12 | as?: AsC | undefined 13 | forwardedAs?: FAsC | undefined 14 | } 15 | 16 | export type StyledComponent< 17 | BaseProps extends object, 18 | DefaultAsC extends string | React.ComponentType 19 | > = < 20 | AsC extends string | React.ComponentType = DefaultAsC, 21 | FAsC extends string | React.ComponentType = AsC 22 | >( 23 | props: SCProps, 24 | ref?: React.Ref 25 | ) => React.ReactElement> | null 26 | -------------------------------------------------------------------------------- /src/content/hoc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './withRoot' 2 | export * from './withPage' 3 | -------------------------------------------------------------------------------- /src/content/hoc/withPage.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, forwardRef } from 'react' 2 | 3 | import { PageName } from 'src/options/options' 4 | import { useAppSelector } from '@/hooks' 5 | import { selectCurrentPage } from '@/pages/global/globalSlice' 6 | 7 | export const withPage = 8 | (pageName: PageName) => 9 | >(Component: T): T => { 10 | const App = forwardRef(function App(props: any, ref: any) { 11 | const currentPage = useAppSelector(selectCurrentPage) 12 | if (currentPage !== pageName) return null 13 | return 14 | }) 15 | 16 | return App as any 17 | } 18 | -------------------------------------------------------------------------------- /src/content/hoc/withRoot.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StrictMode, 3 | ComponentType, 4 | useState, 5 | useEffect, 6 | forwardRef, 7 | } from 'react' 8 | import { Provider } from 'react-redux' 9 | import { PersistGate } from 'redux-persist/integration/react' 10 | import { ThemeProvider } from 'styled-components/macro' 11 | 12 | import store, { persistor } from '@/app/store' 13 | import { darkTheme, lightTheme } from '@/theme' 14 | import { getPageName, getTheme, isBetaUI } from '@/utils' 15 | import { setCurrentPage } from '@/pages/global/globalSlice' 16 | 17 | const Loading = () => <> 18 | 19 | export const withRoot = >(Component: T): T => { 20 | const Root = forwardRef(function Root(props: any, ref: any) { 21 | const [theme, setTheme] = useState(getTheme()) 22 | useEffect(() => { 23 | // 跟随力扣的明暗主题进行切换 24 | let observer: MutationObserver 25 | void (async function () { 26 | const beta = await isBetaUI() 27 | const el = beta ? document.documentElement : document.body 28 | 29 | observer = new MutationObserver(mutationList => { 30 | if (mutationList.some(record => record.attributeName === 'class')) { 31 | if (el.classList.contains('dark')) { 32 | setTheme(darkTheme) 33 | } else { 34 | setTheme(lightTheme) 35 | } 36 | } 37 | }) 38 | 39 | observer.observe(el, { attributes: true }) 40 | })() 41 | return () => { 42 | if (observer) observer.disconnect() 43 | } 44 | }, []) 45 | useEffect(() => { 46 | const handleUrlChange = async () => { 47 | const pageName = getPageName() 48 | store.dispatch(setCurrentPage(pageName)) 49 | } 50 | window.addEventListener('urlchange', handleUrlChange) 51 | return () => { 52 | window.removeEventListener('urlchange', handleUrlChange) 53 | } 54 | }, []) 55 | 56 | return ( 57 | 58 | } persistor={persistor}> 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ) 67 | }) 68 | 69 | return Root as any 70 | } 71 | -------------------------------------------------------------------------------- /src/content/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux' 2 | import type { TypedUseSelectorHook } from 'react-redux' 3 | import type { RootState, AppDispatch } from '@/app/store' 4 | export * from './useEvent' 5 | export * from './useHover' 6 | export * from './useEffectMount' 7 | export * from './useIsMount' 8 | export * from './useObserverAncestor' 9 | 10 | export const useAppDispatch: () => AppDispatch = useDispatch 11 | export const useAppSelector: TypedUseSelectorHook = useSelector 12 | -------------------------------------------------------------------------------- /src/content/hooks/useEffectMount.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useEffect } from 'react' 2 | import { State, useUnMount } from './useIsMount' 3 | 4 | /** 判断当前组件是否被卸载,主要用于需要在 useEffect 中去处理一些异步任务 5 | * 6 | * @param effect 传入一个函数,这个函数接收一个 state 的参数,其中 state.isMount 就是标识当前是否被卸载,另外可以通过设置 state.unmount 来运行一些卸载的时候需要处理的逻辑 7 | * @param deps 是否需要依赖项,默认为空数组,既默认只会在组件加载时执行,在组件卸载时执行卸载操作 8 | * 9 | * @example 10 | * ```ts 11 | * useEffectMount( 12 | * async state => { 13 | * await awaitFn() 14 | * if(!state.isMount) return // 如果当前组件已经被卸载则不去执行后面的语句 15 | * state.unmount.push(()=>{ }) // 执行一些卸载操作 16 | * }, 17 | * [] 18 | * ) 19 | * ``` 20 | */ 21 | export const useEffectMount = ( 22 | effect: 23 | | ((state: State) => void | (() => void)) 24 | | ((state: State) => Promise), 25 | deps: DependencyList = [] 26 | ): void => { 27 | const state = useUnMount() 28 | useEffect(() => { 29 | const fn = effect(state) 30 | return () => { 31 | while (state.unmount.length) { 32 | const fn = state.unmount.pop() 33 | if (fn instanceof Function) fn() 34 | } 35 | if (fn instanceof Function) fn() 36 | } 37 | }, deps) 38 | } 39 | -------------------------------------------------------------------------------- /src/content/hooks/useEvent.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useLayoutEffect, useRef } from 'react' 2 | 3 | /** 4 | * @see https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md 5 | */ 6 | const useEvent = any>(fn: T): T => { 7 | const ref = useRef(fn) 8 | useLayoutEffect(() => { 9 | ref.current = fn 10 | }) 11 | 12 | return useCallback(((...args) => ref.current(...args)) as T, []) 13 | } 14 | 15 | export { useEvent } 16 | -------------------------------------------------------------------------------- /src/content/hooks/useHover.ts: -------------------------------------------------------------------------------- 1 | import { setRef } from '@/utils' 2 | import { useCallback, useRef, useState } from 'react' 3 | import { useEvent } from './useEvent' 4 | 5 | /** 6 | * 处理 hover 逻辑的钩子 7 | * @param delay hover 效果消失的延迟时间 8 | */ 9 | export const useHover = ( 10 | delay = 0, 11 | refs?: React.Ref[] 12 | ): [ 13 | bindRef: (el: T | null) => void, 14 | hover: boolean, 15 | ref: React.MutableRefObject 16 | ] => { 17 | const ref = useRef() 18 | const [hover, setHover] = useState(false) 19 | const timer = useRef>() 20 | 21 | const clearTimer = () => { 22 | if (timer.current !== undefined) { 23 | clearTimeout(timer.current) 24 | timer.current = undefined 25 | } 26 | } 27 | 28 | const handleMouseEnter = useEvent((_e: MouseEvent) => { 29 | setHover(true) 30 | clearTimer() 31 | }) 32 | 33 | const handleMouseLeave = useEvent((_e: MouseEvent) => { 34 | timer.current = setTimeout(() => setHover(false), delay) 35 | }) 36 | 37 | const refcb = useCallback((el: T | null) => { 38 | if (ref.current) { 39 | // ref 会在卸载时被设置为 null 40 | // 所以不用另外在 useEffect 去设置针对组件卸载时删除事件侦听器的逻辑,只要在这里设置即可 41 | 42 | clearTimer() 43 | setHover(false) 44 | 45 | ref.current.removeEventListener('mouseenter', handleMouseEnter) 46 | ref.current.removeEventListener('mouseleave', handleMouseLeave) 47 | } 48 | 49 | refs?.forEach(ref => setRef(el, ref)) 50 | ref.current = el 51 | if (ref.current) { 52 | ref.current.addEventListener('mouseenter', handleMouseEnter) 53 | ref.current.addEventListener('mouseleave', handleMouseLeave) 54 | } 55 | }, []) 56 | 57 | return [refcb, hover, ref] 58 | } 59 | -------------------------------------------------------------------------------- /src/content/hooks/useIsMount.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export type State = { 4 | isMount: boolean 5 | unmount: Array<() => void> 6 | } 7 | 8 | /** 9 | * 处理组件卸载逻辑 10 | */ 11 | export const useUnMount = (): State => { 12 | const { current: state } = useRef({ isMount: true, unmount: [] }) 13 | useEffect(() => { 14 | state.isMount = true 15 | return () => { 16 | state.isMount = false 17 | while (state.unmount.length) { 18 | const fn = state.unmount.pop() 19 | if (fn instanceof Function) fn() 20 | } 21 | } 22 | }, []) 23 | return state 24 | } 25 | -------------------------------------------------------------------------------- /src/content/hooks/useObserverAncestor.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useEffect, useRef } from 'react' 2 | import { useEvent } from './useEvent' 3 | import { State, useUnMount } from './useIsMount' 4 | 5 | /** 用于当祖先元素发生变化时,执行特定的操作 6 | * 7 | * 原理是通过 MutationObserver 监听对应祖先元素是否被删除,如果被删除, 8 | * 则需要重新挂载元素或绑定事件,并将新元素的祖先结点重新绑定 MutationObserver 事件 9 | * 10 | * 主要针对某个结点其祖先元素有可能会被删除的场景 11 | * 12 | * @param onChange 当祖先元素发生变化时,需要执行的操作 13 | * @param getAncestor 获取监听的祖先元素 14 | * @param check 检查是否满足变化的条件 15 | * 16 | * @todo 当同时有多个地方使用 useObserverAncestor 时,尝试合并监听的事件,以提升性能 17 | */ 18 | export const useObserverAncestor = ( 19 | onChange: ( 20 | state: State 21 | ) => HTMLElement | undefined | null | Promise, 22 | deps: DependencyList = [] 23 | ): void => { 24 | const state = useUnMount() 25 | const ancestorRef = useRef({ 26 | nodes: [] as HTMLElement[], 27 | nodeSet: new Set(), 28 | observers: [] as MutationObserver[], 29 | }) 30 | 31 | const mount = useEvent(async () => { 32 | const root = await onChange(state) 33 | if (!root) return 34 | let el = root 35 | const { nodes, nodeSet, observers } = ancestorRef.current 36 | const els: HTMLElement[] = [] 37 | 38 | //#region 找到新元素和旧元素的公共祖先结点,然后将旧元素到公共祖先结点之间的元素删除,并删除对应的 MutationObserver 39 | while (el && !nodeSet.has(el) && el !== document.body) { 40 | els.push(el) 41 | el = el.parentElement! 42 | } 43 | const pop = () => { 44 | const node = nodes.pop()! 45 | nodeSet.delete(node) 46 | observers.pop()!.disconnect() 47 | return node 48 | } 49 | 50 | while (nodes.length) { 51 | const node = pop() 52 | if (el === node) break 53 | } 54 | 55 | //#endregion 56 | 57 | // 添加新元素变化的部分祖先结点,并添加对应的 MutationObserver 58 | // 要判断当前结点是否被删除,必须要将 MutationObserver 挂载到父元素上 59 | 60 | while (els.length) { 61 | const el = els.pop()! 62 | const observer = new MutationObserver(mutations => { 63 | const checked = mutations.some(({ removedNodes }) => 64 | Array.prototype.some.call(removedNodes, node => node === el) 65 | ) 66 | if (checked) { 67 | while (nodes.length) { 68 | const ancestor = pop() 69 | if (ancestor === el) break 70 | } 71 | mount() 72 | } 73 | }) 74 | nodes.push(el) 75 | nodeSet.add(el) 76 | observers.push(observer) 77 | observer.observe(el.parentElement!, { childList: true }) 78 | } 79 | 80 | state.unmount.push(() => { 81 | while (observers.length) pop() 82 | }) 83 | }) 84 | 85 | useEffect(() => { 86 | mount() 87 | return () => { 88 | while (state.unmount.length) { 89 | const fn = state.unmount.pop() 90 | if (fn instanceof Function) fn() 91 | } 92 | } 93 | }, deps) 94 | } 95 | -------------------------------------------------------------------------------- /src/content/hooks/useThrottle.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { throttle } from 'src/utils' 3 | 4 | export function useThrottle< 5 | T extends (...args: any) => Promise | boolean | void, 6 | R = (...args: Parameters) => Promise 7 | >(fn: T, interval = 0): R { 8 | const handle = useMemo(() => throttle(fn, interval), []) as R 9 | return handle 10 | } 11 | -------------------------------------------------------------------------------- /src/content/index.tsx: -------------------------------------------------------------------------------- 1 | import { initUrlChangeEvent } from '@/utils' 2 | import { render } from 'react-dom' 3 | 4 | import App from './App' 5 | 6 | initUrlChangeEvent() 7 | 8 | const rootId = 'Refined-LeetCode-Root' 9 | let root = document.getElementById(rootId) 10 | if (!root) { 11 | root = document.createElement('div') 12 | root.id = rootId 13 | document.body.append(root) 14 | } 15 | render(, root) 16 | -------------------------------------------------------------------------------- /src/content/load.ts: -------------------------------------------------------------------------------- 1 | import { customEventDispatch } from './utils' 2 | 3 | __webpack_public_path__ = chrome.runtime.getURL('.') + '/' 4 | 5 | if (module.hot) { 6 | module.hot.addStatusHandler(async status => { 7 | if (status === 'abort') { 8 | return awaitReload() 9 | } 10 | }) 11 | } 12 | 13 | function awaitReload() { 14 | return new Promise(function (resolve) { 15 | console.log('sendMessage reload') 16 | chrome.runtime.sendMessage({ type: 'reload' }, () => { 17 | resolve() 18 | }) 19 | }) 20 | } 21 | 22 | function loadScript(url: string) { 23 | const script = document.createElement('script') 24 | 25 | // 将扩展 id 添加到脚本标签中,方便网页脚本通过 id 跟扩展通讯 26 | script.id = 'refined-leetcode' 27 | script.dataset.extensionid = chrome.runtime.id 28 | script.src = url 29 | document.body.append(script) 30 | } 31 | 32 | let isLoad = false 33 | if (!isLoad) { 34 | isLoad = true 35 | loadScript(chrome.runtime.getURL('content.bundle.js')) 36 | } 37 | 38 | window.addEventListener('refinedLeetcodeGetOptions', () => { 39 | chrome.storage.local.get('options', ({ options }) => { 40 | customEventDispatch('refinedLeetcodeOptionsChange', { options }) 41 | }) 42 | }) 43 | 44 | chrome.storage.onChanged.addListener(e => { 45 | if ('options' in e) { 46 | const options = e.options.newValue 47 | customEventDispatch('refinedLeetcodeOptionsChange', { options }) 48 | } 49 | }) 50 | 51 | window.addEventListener( 52 | 'refinedLeetcodeSaveOptions', 53 | ({ detail: { options } }) => { 54 | chrome.storage.local.set({ options }) 55 | } 56 | ) 57 | 58 | export {} 59 | -------------------------------------------------------------------------------- /src/content/pages/global/globalSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' 2 | 3 | import { RootState } from '@/app/store' 4 | import { 5 | LeetCodeApi, 6 | GlobalData, 7 | ProblemsetPageProps, 8 | ProblemRankData, 9 | getProblemRankData, 10 | getPageName, 11 | } from '@/utils' 12 | import { PageName } from 'src/options/options' 13 | 14 | const api = new LeetCodeApi(location.origin) 15 | 16 | export const fetchGlobalData = createAsyncThunk< 17 | GlobalData, 18 | undefined, 19 | { state: RootState } 20 | >('global/fetchGlobalData', async () => { 21 | const res = await api.queryGlobalData() 22 | return res 23 | }) 24 | 25 | export const fetchProblemsetPageProps = createAsyncThunk< 26 | ProblemsetPageProps, 27 | undefined, 28 | { state: RootState } 29 | >('global/fetchProblemsetPageProps', async () => { 30 | const res = await api.getProblemsetPageProps() 31 | return res 32 | }) 33 | 34 | export const fetchProblemRankData = createAsyncThunk< 35 | ProblemRankData[], 36 | undefined, 37 | { state: RootState } 38 | >('global/fetchProblemRankData', async () => { 39 | const res = await getProblemRankData() 40 | return res 41 | }) 42 | 43 | const initialState: { 44 | globalData?: GlobalData 45 | problemsetPageProps?: ProblemsetPageProps 46 | ProblemRankData: { [key: string]: ProblemRankData } 47 | currentPage?: PageName 48 | } = { ProblemRankData: {}, currentPage: getPageName() } 49 | 50 | export const globalDataSlice = createSlice({ 51 | name: 'global', 52 | initialState, 53 | reducers: { 54 | setCurrentPage: (state, action) => { 55 | state.currentPage = action.payload 56 | }, 57 | }, 58 | extraReducers(builder) { 59 | builder 60 | .addCase(fetchGlobalData.fulfilled, (state, action) => { 61 | state.globalData = action.payload 62 | }) 63 | .addCase(fetchProblemsetPageProps.fulfilled, (state, action) => { 64 | state.problemsetPageProps = action.payload 65 | }) 66 | .addCase(fetchProblemRankData.fulfilled, (state, action) => { 67 | for (const rank of action.payload) { 68 | state.ProblemRankData[rank.TitleSlug] = rank 69 | } 70 | }) 71 | }, 72 | }) 73 | 74 | export const { setCurrentPage } = globalDataSlice.actions 75 | 76 | export const selectIsPremium = (state: RootState): boolean | undefined => 77 | state.global.globalData?.userStatus?.isPremium 78 | export const selectIsSignedIn = (state: RootState): boolean | undefined => 79 | state.global.globalData?.userStatus?.isSignedIn 80 | 81 | export const selectFeaturedLists = ( 82 | state: RootState 83 | ): ProblemsetPageProps['featuredLists'] | undefined => 84 | state.global.problemsetPageProps?.featuredLists 85 | 86 | export const selectProblemRankDataByTitleSlug = ( 87 | state: RootState, 88 | titleSlug: string 89 | ): ProblemRankData | undefined => state.global.ProblemRankData[titleSlug] 90 | 91 | export const selectCurrentPage = (state: RootState): PageName | undefined => 92 | state.global.currentPage 93 | 94 | export default globalDataSlice.reducer 95 | -------------------------------------------------------------------------------- /src/content/pages/global/optionsSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, current, PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import store, { RootState } from '@/app/store' 4 | import { OptionsType, PageName } from 'src/options/options' 5 | import { customEventDispatch } from '@/utils' 6 | 7 | export interface RandomOptionType { 8 | skipAC: boolean 9 | } 10 | 11 | export const labelOfKey = { 12 | skipAC: '跳过已解答题目', 13 | } 14 | 15 | const defaultRandomOption = (): RandomOptionType => ({ skipAC: false }) 16 | 17 | interface OptionsState { 18 | random: { 19 | [key: string]: RandomOptionType 20 | } 21 | options?: OptionsType 22 | } 23 | 24 | const initialState: OptionsState = { 25 | random: { 26 | all: defaultRandomOption(), 27 | }, 28 | } 29 | 30 | function saveOption(options: OptionsType) { 31 | customEventDispatch('refinedLeetcodeSaveOptions', { options }) 32 | } 33 | 34 | export const optionsSlice = createSlice({ 35 | name: 'options', 36 | initialState, 37 | reducers: { 38 | setRandomOption: (state, action) => { 39 | const { favorite, option } = action.payload 40 | 41 | state.random[favorite] = { 42 | ...(state.random[favorite] ?? defaultRandomOption()), 43 | ...option, 44 | } 45 | }, 46 | disableProblemRating({ options }, { payload }: PayloadAction) { 47 | if (options) { 48 | if (payload === 'problemsetPage') { 49 | options.problemsetPage.problemRating = false 50 | } else if (payload === 'problemListPage') { 51 | options.problemListPage.problemRating = false 52 | } 53 | saveOption(current(options)) 54 | } 55 | }, 56 | enableProblemRating({ options }, { payload }: PayloadAction) { 57 | if (options) { 58 | if (payload === 'problemsetPage') { 59 | options.problemsetPage.problemRating = true 60 | } else if (payload === 'problemListPage') { 61 | options.problemListPage.problemRating = true 62 | } 63 | saveOption(current(options)) 64 | } 65 | }, 66 | toggleContestProblemShortcutKeyOption({ options }) { 67 | if (options) { 68 | options.contestProblemsPage.disableShortcutkey = 69 | !options.contestProblemsPage.disableShortcutkey 70 | saveOption(current(options)) 71 | } 72 | }, 73 | setOptions: (state, action) => { 74 | state.options = action.payload 75 | }, 76 | setContestProblemViewWidth: ( 77 | { options }, 78 | { payload }: PayloadAction 79 | ) => { 80 | if (options) { 81 | options.contestProblemsPage.problemViewWidth = payload 82 | saveOption(current(options)) 83 | } 84 | }, 85 | }, 86 | }) 87 | 88 | export const { 89 | setRandomOption, 90 | disableProblemRating, 91 | enableProblemRating, 92 | toggleContestProblemShortcutKeyOption, 93 | setOptions, 94 | setContestProblemViewWidth, 95 | } = optionsSlice.actions 96 | 97 | export const selectRandomOption = ( 98 | state: RootState, 99 | key: string 100 | ): RandomOptionType => state.options.random[key] ?? defaultRandomOption() 101 | 102 | export const selectOptions = (state: RootState): OptionsType | undefined => 103 | state.options.options 104 | 105 | window.addEventListener('refinedLeetcodeOptionsChange', e => { 106 | store.dispatch(setOptions(e.detail.options)) 107 | }) 108 | 109 | export default optionsSlice.reducer 110 | -------------------------------------------------------------------------------- /src/content/pages/home/App.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react' 2 | import { DndProvider } from 'react-dnd' 3 | import { HTML5Backend } from 'react-dnd-html5-backend' 4 | 5 | import DistortSvg from '@/components/DistortSvg' 6 | import { withPage } from '@/hoc' 7 | import { useAppSelector, useEffectMount } from '@/hooks' 8 | 9 | import GlobalStyle from './GlobalStyle' 10 | import BlockUser from './BlockUser' 11 | import { selectOptions } from '../global/optionsSlice' 12 | import { css } from 'styled-components/macro' 13 | import { useBlock } from './useBlock' 14 | import { findElement } from '@/utils' 15 | import { Portal } from '@/components/Portal' 16 | 17 | const App: FC = () => { 18 | useBlock() 19 | const options = useAppSelector(selectOptions) 20 | const [root, setRoot] = useState() 21 | useEffectMount(async state => { 22 | const parent = await findElement('.css-kktm6n-RightContainer') 23 | const root = document.createElement('div') 24 | if (state.isMount) { 25 | parent.prepend(root) 26 | setRoot(root) 27 | state.unmount.push(() => root.remove()) 28 | } 29 | }, []) 30 | 31 | if (!root || !options?.homePage.block) return null 32 | return ( 33 | 34 |
42 | 43 | 44 | 45 | 46 | 47 |
48 |
49 | ) 50 | } 51 | 52 | export default withPage('homePage')(App) 53 | -------------------------------------------------------------------------------- /src/content/pages/home/BlockUser.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useRef, useState } from 'react' 2 | import styled from 'styled-components/macro' 3 | 4 | import BlockUserList from './BlockUserList' 5 | import DragAndDrop from './DragAndDrop' 6 | 7 | const Container = styled.div` 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | font-size: 14px; 12 | width: 100%; 13 | padding: 5px 16px; 14 | border-radius: 8px; 15 | box-sizing: border-box; 16 | cursor: pointer; 17 | line-height: 20px; 18 | background: ${props => props.theme.palette.primary.main}; 19 | color: ${props => props.theme.palette.text.main}; 20 | box-shadow: ${props => props.theme.shadows[1]}; 21 | ` 22 | 23 | const BlockUser: FC = () => { 24 | const ref = useRef(null) 25 | const listRef = useRef(null) 26 | const [showEdit, setShowEdit] = useState(false) 27 | const handleClick = () => { 28 | setShowEdit(state => !state) 29 | } 30 | useEffect(() => { 31 | // 滚动页面时,隐藏黑名单列表窗口 32 | const handleScroll = () => setShowEdit(false), 33 | option: AddEventListenerOptions = { passive: true } 34 | document.body.addEventListener('scroll', handleScroll, option) 35 | return () => { 36 | document.body.removeEventListener('scroll', handleScroll, option) 37 | } 38 | }, []) 39 | 40 | useEffect(() => { 41 | if (!showEdit) return 42 | const handleClick = (e: MouseEvent) => { 43 | const el = e.target as Node 44 | if (!el) return 45 | if (ref.current?.contains(el) || listRef.current?.contains(el)) return 46 | setShowEdit(false) 47 | } 48 | document.addEventListener('click', handleClick) 49 | return () => { 50 | document.removeEventListener('click', handleClick) 51 | } 52 | }, [showEdit]) 53 | 54 | return ( 55 | <> 56 | 57 |
黑名单管理
58 | 66 | 67 | 68 |
69 | {showEdit && ( 70 | 75 | )} 76 | 77 | 78 | 79 | ) 80 | } 81 | 82 | export default BlockUser 83 | -------------------------------------------------------------------------------- /src/content/pages/home/DragAndDrop.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react' 2 | import { DropTargetMonitor, useDrop } from 'react-dnd' 3 | import { NativeTypes } from 'react-dnd-html5-backend' 4 | 5 | import { useAppDispatch } from '@/hooks' 6 | 7 | import PostItem, { ItemTypes, PostItemType } from './PostItem' 8 | import DropContainer from './DropContainer' 9 | import { 10 | setBlockUserByCommunityArticleId, 11 | setBlockUserByPostId, 12 | setBlockUserBySolutionSlug, 13 | } from './blockUsersSlice' 14 | import Mask from './Mask' 15 | 16 | type NativeTypeHTML = { 17 | html: string 18 | dataTransfer: { 19 | dropEffect: string 20 | effectAllowed: string 21 | files: FileList 22 | items: DataTransferItemList 23 | types: [] 24 | } 25 | } 26 | 27 | function geturl(monitor: DropTargetMonitor) { 28 | const itemType = monitor.getItemType(), 29 | item = monitor.getItem() 30 | 31 | if (itemType === ItemTypes.POST) { 32 | return (item as PostItemType).url ?? '' 33 | } else if (itemType === NativeTypes.HTML) { 34 | const div = document.createElement('div') 35 | div.innerHTML = (item as NativeTypeHTML).html 36 | const a = div.querySelector(':scope>a') 37 | if (!(a instanceof HTMLAnchorElement)) return '' 38 | return a.href 39 | } else { 40 | return '' 41 | } 42 | } 43 | 44 | export function isValid(url: string): boolean { 45 | try { 46 | const URI = new URL(url) 47 | if (URI.host !== 'leetcode.cn') return false 48 | const strs = URI.pathname.split('/').filter(Boolean) 49 | 50 | if (strs[0] === 'circle') { 51 | // 帖子 52 | if (strs[1] !== 'discuss' && strs[1] !== 'article') return false 53 | return !!strs[2] 54 | } else if (strs[0] === 'problems' && strs[2] === 'solution') { 55 | // 题解 56 | return !!strs[3] 57 | } 58 | return false 59 | } catch (error) { 60 | return false 61 | } 62 | } 63 | 64 | const DragAndDrop: FC = () => { 65 | const dispatch = useAppDispatch() 66 | const [open, setOpen] = useState(false) 67 | 68 | const [{ canDrop, isOver }, drop] = useDrop( 69 | () => ({ 70 | accept: [ItemTypes.POST, NativeTypes.HTML], 71 | async drop(item, monitor) { 72 | const url = geturl(monitor) 73 | if (!url) { 74 | console.error('无效源') 75 | return 76 | } 77 | 78 | if (!isValid(url)) { 79 | console.error('无效链接') 80 | return 81 | } 82 | 83 | const strs = new URL(url).pathname.split('/').filter(Boolean) 84 | try { 85 | if (strs[1] === 'discuss') { 86 | // 讨论帖 87 | const postId = strs[2] 88 | await dispatch(setBlockUserByPostId(postId)).unwrap() 89 | } else if (strs[1] === 'article') { 90 | // 文章 91 | const id = strs[2] 92 | await dispatch(setBlockUserByCommunityArticleId(id)).unwrap() 93 | } else { 94 | // 题解 95 | const solutionSlug = strs[3] 96 | await dispatch(setBlockUserBySolutionSlug(solutionSlug)).unwrap() 97 | } 98 | } catch (error) { 99 | console.error((error as { message: string }).message) 100 | } 101 | }, 102 | collect: monitor => { 103 | return { 104 | isOver: monitor.isOver(), 105 | canDrop: monitor.canDrop(), 106 | } 107 | }, 108 | }), 109 | [] 110 | ) 111 | 112 | return ( 113 | <> 114 | 115 | {} 116 | {open && } 117 | 118 | ) 119 | } 120 | 121 | export default DragAndDrop 122 | -------------------------------------------------------------------------------- /src/content/pages/home/DropContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import styled from 'styled-components/macro' 3 | import { ConnectDropTarget } from 'react-dnd' 4 | import { Portal } from '@/components/Portal' 5 | 6 | const Container = styled.div<{ active?: boolean }>` 7 | position: fixed; 8 | right: 50px; 9 | top: 50%; 10 | width: 100px; 11 | height: 150px; 12 | background: ${props => (props.active ? '#3cb97f' : '#49ab7e')}; 13 | transform: translate(0, -50%); 14 | color: white; 15 | border-radius: 5px; 16 | padding: 4px; 17 | z-index: 9999; 18 | ` 19 | 20 | const SVG = styled.svg` 21 | height: 1em; 22 | fill: currentcolor; 23 | font-size: 1.5rem; 24 | position: absolute; 25 | top: 0; 26 | left: 0; 27 | right: 0; 28 | bottom: 0; 29 | margin: auto; 30 | ` 31 | 32 | type BlockDropContainerProps = { 33 | drop: ConnectDropTarget 34 | active?: boolean 35 | } 36 | 37 | const DropContainer: FC = ({ drop, active }) => { 38 | return ( 39 | 40 | 41 | 拖动到此 42 |
43 | 加入黑名单 44 | 45 | 46 | 47 |
48 |
49 | ) 50 | } 51 | 52 | export default DropContainer 53 | -------------------------------------------------------------------------------- /src/content/pages/home/GlobalStyle.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components/macro' 2 | 3 | const styled = { createGlobalStyle } 4 | const GlobalStyle = styled.createGlobalStyle` 5 | /* 隐藏黑名单用户发的帖子 */ 6 | .refined-leetcode-block { 7 | display: none; 8 | } 9 | /* 隐藏帖子相邻的分割元素 */ 10 | .refined-leetcode-block + div.css-1vwizfm-Divider { 11 | display: none; 12 | } 13 | 14 | /* 临时显示帖子,但对内容进行糢糊处理,以区别 */ 15 | .refined-leetcode-temp { 16 | display: flex; 17 | filter: url(#refined-leetcode-noise); 18 | } 19 | .refined-leetcode-block.refined-leetcode-temp + div.css-1vwizfm-Divider { 20 | display: block; 21 | } 22 | /* 当悬浮鼠标时,取消糢糊效果 */ 23 | .refined-leetcode-block.refined-leetcode-temp:hover { 24 | filter: none; 25 | } 26 | ` 27 | export default GlobalStyle 28 | -------------------------------------------------------------------------------- /src/content/pages/home/Mask.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, FC } from 'react' 2 | import 'styled-components/macro' 3 | 4 | import { Portal } from '@/components/Portal' 5 | 6 | import { isValid } from './DragAndDrop' 7 | 8 | type MaskProps = { 9 | open: boolean 10 | setOpen: React.Dispatch> 11 | } 12 | 13 | /** 遮罩层 14 | * 15 | * 拖拽时显示,用以遮挡其余无关元素,突出显示拖拽源和目标容器 16 | */ 17 | const Mask: FC = ({ open, setOpen }) => { 18 | useEffect(() => { 19 | let el: HTMLElement | null = null 20 | const handleDragstart = (e: DragEvent) => { 21 | el = e.target as HTMLElement 22 | if (!(el instanceof HTMLAnchorElement)) return 23 | if (!isValid(el.href)) return 24 | setTimeout(() => setOpen(true), 100) 25 | } 26 | const handleDragend = () => { 27 | setOpen(false) 28 | } 29 | document.body.addEventListener('dragstart', handleDragstart) 30 | document.body.addEventListener('dragend', handleDragend) 31 | return () => { 32 | document.body.removeEventListener('dragstart', handleDragstart) 33 | document.body.removeEventListener('dragend', handleDragend) 34 | } 35 | }, []) 36 | if (!open) return null 37 | 38 | return ( 39 | 40 |
51 | 52 | ) 53 | } 54 | 55 | export default Mask 56 | -------------------------------------------------------------------------------- /src/content/pages/home/postsSlice.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSlice, 3 | createAsyncThunk, 4 | createEntityAdapter, 5 | } from '@reduxjs/toolkit' 6 | 7 | import { RootState } from '@/app/store' 8 | import { LeetCodeApi, NotyArticleType, NotyItem } from '@/utils' 9 | 10 | const api = new LeetCodeApi(location.origin) 11 | 12 | type Post = { 13 | feedContent: { 14 | uuid: string 15 | author: { userSlug: string } 16 | } 17 | meta: { link: string } 18 | } 19 | 20 | const postsAdapter = createEntityAdapter({ 21 | selectId: post => post.feedContent.uuid, 22 | }) 23 | 24 | export const fetchPosts = createAsyncThunk< 25 | NotyItem, 26 | number, 27 | { state: RootState } 28 | >('posts/fetchPosts', async (limit, { getState }) => { 29 | const nextToken = getState().posts.nextToken 30 | const res = await api.getNoty(nextToken, limit) 31 | return res 32 | }) 33 | 34 | const initialState = postsAdapter.getInitialState({ 35 | nextToken: '', 36 | }) 37 | 38 | const postsSlice = createSlice({ 39 | name: 'posts', 40 | initialState, 41 | reducers: {}, 42 | extraReducers(builder) { 43 | builder.addCase(fetchPosts.fulfilled, (state, action) => { 44 | state.nextToken = action.payload.nextToken 45 | 46 | const data = action.payload.rows.filter( 47 | a => a.feedContent.__typename === 'Article' 48 | ) as { feedContent: NotyArticleType; meta: { link: string } }[] 49 | 50 | postsAdapter.upsertMany(state, data) 51 | }) 52 | }, 53 | }) 54 | 55 | export const { 56 | selectAll: selectAllPosts, 57 | selectById: selectPostById, 58 | selectIds: selectPostIds, 59 | } = postsAdapter.getSelectors(state => state.posts) 60 | 61 | export default postsSlice.reducer 62 | -------------------------------------------------------------------------------- /src/content/pages/home/useBlock.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { useAppDispatch, useAppSelector, useEvent } from '@/hooks' 4 | 5 | import { selectAllPosts, fetchPosts } from './postsSlice' 6 | import { selectAllBlockUsers } from './blockUsersSlice' 7 | 8 | // TODO: 分离获取列表和处理隐藏 9 | export const useBlock = (): void => { 10 | const posts = useAppSelector(selectAllPosts) 11 | const users = useAppSelector(selectAllBlockUsers) 12 | const dispatch = useAppDispatch() 13 | 14 | const handleBlock = useEvent(() => { 15 | const blockUsers = new Set( 16 | users.filter(user => user.block).map(user => user.slug) 17 | ), 18 | tempUnBlockUsers = new Set( 19 | users.filter(user => !user.block).map(user => user.slug) 20 | ) 21 | 22 | const blockPosts = new Set(), 23 | tmpUnBlockPosts = new Set() 24 | 25 | for (const post of posts) { 26 | const userSlug = post.feedContent.author.userSlug 27 | if (blockUsers.has(userSlug)) { 28 | blockPosts.add(post.meta.link) 29 | } else if (tempUnBlockUsers.has(userSlug)) { 30 | tmpUnBlockPosts.add(post.meta.link) 31 | } 32 | } 33 | 34 | const postEls = document.querySelectorAll( 35 | '.css-1tc14ag-card-layer1-card-MainContentConainer>.css-1pej3s6-FeedContainer>div:nth-of-type(1)>a:nth-of-type(1)' 36 | ) 37 | 38 | Array.prototype.forEach.call(postEls, (el: HTMLAnchorElement) => { 39 | const p = el.parentElement?.parentElement 40 | if (p) { 41 | if (blockPosts.has(el.pathname)) { 42 | // 处于黑名单中的元素 43 | p.classList.add('refined-leetcode-block') 44 | p.classList.remove('refined-leetcode-temp') 45 | } else if (tmpUnBlockPosts.has(el.pathname)) { 46 | // 处于临时解锁中的元素 47 | p.classList.add('refined-leetcode-block') 48 | p.classList.add('refined-leetcode-temp') 49 | } else if (p.classList.contains('refined-leetcode-block')) { 50 | // 不处于黑名单中的元素 51 | p.classList.remove('refined-leetcode-block', 'refined-leetcode-temp') 52 | } 53 | } 54 | }) 55 | }) 56 | useEffect(() => { 57 | handleBlock() 58 | }, [posts, users]) 59 | 60 | useEffect(() => { 61 | dispatch(fetchPosts(30)) 62 | 63 | const handleFetchPost = async () => { 64 | await dispatch(fetchPosts(10)).unwrap() 65 | // 防止某些元素加载比较慢的情况,延迟再触发一次 66 | setTimeout(handleBlock, 500) 67 | } 68 | 69 | const handleClick = (e: Event) => { 70 | const el = e.target 71 | const cls = 'css-1csfdb4-BaseButtonComponent-LoadMoreButton' 72 | if ( 73 | el instanceof HTMLElement && 74 | (el.classList.contains(cls) || 75 | el.parentElement?.classList.contains(cls)) 76 | ) { 77 | handleFetchPost() 78 | } 79 | } 80 | const root = document.querySelector('.css-185cq5e-LeftContainer') 81 | if (root) { 82 | root.addEventListener('click', handleClick) 83 | } 84 | return () => { 85 | if (root) { 86 | root.removeEventListener('click', handleClick) 87 | } 88 | } 89 | }, []) 90 | } 91 | -------------------------------------------------------------------------------- /src/content/pages/problem-list/AddFavorite.tsx: -------------------------------------------------------------------------------- 1 | import { AddIcon } from '@/components/icons' 2 | import { css } from 'styled-components/macro' 3 | import Editor from './Editor' 4 | 5 | interface AddFavoriteProps { 6 | onSave?: (text: string) => void | Promise 7 | onCancel?: (...arg: any) => void | Promise 8 | toggleEnableEdit: (...arg: any) => void | Promise 9 | enableEdit: boolean 10 | } 11 | 12 | const AddFavorite: React.FC = ({ 13 | enableEdit, 14 | toggleEnableEdit, 15 | ...props 16 | }) => { 17 | return ( 18 |
27 | {enableEdit ? ( 28 | 29 | ) : ( 30 |
props.theme.palette.secondary.main}; 42 | &:hover { 43 | background-color: ${props => props.theme.palette.secondary.hover}; 44 | } 45 | `} 46 | onClick={toggleEnableEdit} 47 | > 48 | 49 | 创建新题单 50 |
51 | )} 52 |
53 | ) 54 | } 55 | 56 | export default AddFavorite 57 | -------------------------------------------------------------------------------- /src/content/pages/problem-list/App.tsx: -------------------------------------------------------------------------------- 1 | import Rank from '../problemset/Rank' 2 | import { FC, useEffect, useState } from 'react' 3 | import { useAppSelector, useEffectMount } from '@/hooks' 4 | import { selectOptions } from '../global/optionsSlice' 5 | import { awaitFn, problemsetPageIsLoad } from '@/utils' 6 | import { Portal } from '@/components/Portal' 7 | import ProblemList from './ProblemList' 8 | import { withPage } from '@/hoc' 9 | // import { fixRandom } from './fixRandom' 10 | import { useSetProblemListRoot } from './useSetProblemListRoot' 11 | 12 | const App: FC = () => { 13 | const options = useAppSelector(selectOptions) 14 | const [problemListRoot, setProblemListRoot] = useState() 15 | const [isLoad, setIsLoad] = useState(false) 16 | useEffectMount(async state => { 17 | await awaitFn(async () => { 18 | const res = await problemsetPageIsLoad() 19 | return !res 20 | }) 21 | if (state.isMount) setIsLoad(true) 22 | }) 23 | useSetProblemListRoot( 24 | '//*[@id="__next"]/div/div[2]/div/div[2]/div/*//span[text()="精选题单"]/../..', 25 | isLoad, 26 | setProblemListRoot 27 | ) 28 | 29 | useEffect(() => { 30 | if (!isLoad) return 31 | // fixRandom() 32 | }, [isLoad]) 33 | 34 | if (!isLoad) return null 35 | const showProblemList = !!options?.problemListPage.problemList 36 | const showRank = !!options?.problemListPage.problemRating 37 | return ( 38 | <> 39 | 40 | {showProblemList && problemListRoot && ( 41 | 42 | 43 | 44 | )} 45 | 46 | ) 47 | } 48 | 49 | export default withPage('problemListPage')(App) 50 | -------------------------------------------------------------------------------- /src/content/pages/problem-list/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEventHandler, FC, useEffect, useState } from 'react' 2 | 3 | import { Input } from '@/components/Input' 4 | import Button from '@/components/Button' 5 | import ErrorToolTip from '@/components/ErrorToolTip' 6 | import { css } from 'styled-components/macro' 7 | import { useUnMount } from '@/hooks' 8 | 9 | interface EditorProps { 10 | text?: string 11 | onSave?: (text: string) => void | Promise 12 | onCancel?: (...arg: any) => void 13 | } 14 | 15 | const Editor: FC = ({ text: initText = '', onSave, onCancel }) => { 16 | const [text, setText] = useState(initText) 17 | const [error, setError] = useState({ message: '', show: false }) 18 | const [loading, setLoading] = useState(false) 19 | // 判断当前组件是否挂载,还是已被卸载 20 | const mountState = useUnMount() 21 | useEffect(() => { 22 | if (error.show) { 23 | setTimeout(() => { 24 | setError({ ...error, show: false }) 25 | }, 1000) 26 | } 27 | }, [error]) 28 | 29 | const handleChange: ChangeEventHandler = e => { 30 | setText(e.target.value) 31 | } 32 | 33 | const handleSave = async () => { 34 | setLoading(true) 35 | if (typeof onSave === 'function') { 36 | try { 37 | await onSave(text) 38 | if (mountState.isMount) setText('') 39 | } catch (error: any) { 40 | setError({ message: error.message, show: true }) 41 | } 42 | } 43 | if (mountState.isMount) setLoading(false) 44 | } 45 | const handleCancel = () => { 46 | if (typeof onCancel === 'function') { 47 | onCancel() 48 | } 49 | } 50 | 51 | return ( 52 |
57 | 58 | 69 | 70 |
77 | 91 | 99 |
100 |
101 | ) 102 | } 103 | 104 | export default Editor 105 | -------------------------------------------------------------------------------- /src/content/pages/problem-list/FavoriteList.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from 'react' 2 | import { useAppSelector } from '@/hooks' 3 | import { selectFavoriteIdsByCategory, FavoriteCategory } from './favoriteSlice' 4 | import { selectFeaturedLists } from '../global/globalSlice' 5 | import FavoriteItem from './FavoriteItem' 6 | import FavoriteWrap from './FavoriteWrap' 7 | import { css } from 'styled-components/macro' 8 | 9 | const getCurrentId = () => { 10 | const strs = location.pathname.split('/').filter(Boolean) 11 | if (strs[0] === 'problem-list') return strs[1] 12 | return '' 13 | } 14 | 15 | const nameByCategory = { 16 | official: '官方题单', 17 | custom: '自定义题单', 18 | third: '第三方题单', 19 | } 20 | 21 | interface FavoriteListProps { 22 | category: FavoriteCategory 23 | } 24 | 25 | const FavoriteList: FC = ({ category }) => { 26 | const [open, setOpen] = useState(false) 27 | const toggle = () => setOpen(open => !open) 28 | const ids = useAppSelector(selectFavoriteIdsByCategory(category)) 29 | 30 | const featuredLists = useAppSelector(selectFeaturedLists) 31 | const name = nameByCategory[category] 32 | const [currentId, setCurrentId] = useState(getCurrentId()) 33 | 34 | const isCustom = category === 'custom' 35 | 36 | useEffect(() => { 37 | if (currentId && ids.includes(currentId)) { 38 | if ( 39 | featuredLists && 40 | featuredLists.every(favorite => favorite.idHash !== currentId) 41 | ) { 42 | setOpen(true) 43 | } 44 | } else { 45 | setOpen(false) 46 | } 47 | }, [currentId]) 48 | 49 | useEffect(() => { 50 | const handleUrlChange = () => { 51 | setCurrentId(getCurrentId()) 52 | } 53 | window.addEventListener('urlchange', handleUrlChange) 54 | return () => { 55 | window.removeEventListener('urlchange', handleUrlChange) 56 | } 57 | }, []) 58 | 59 | return ( 60 | 67 |
    74 | {ids.map(idHash => ( 75 | 81 | ))} 82 |
83 |
84 | ) 85 | } 86 | 87 | export default FavoriteList 88 | -------------------------------------------------------------------------------- /src/content/pages/problem-list/HelpHead.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components/macro' 2 | 3 | const HelpHead: React.FC = () => { 4 | return ( 5 |
14 |
props.theme.palette.text.light}; 22 | `} 23 | > 24 | 图标 25 | 题单名称 26 | 编辑 27 | 删除 28 | 公开 29 |
30 |
31 | ) 32 | } 33 | 34 | export default HelpHead 35 | -------------------------------------------------------------------------------- /src/content/pages/problem-list/ProblemList.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from 'react' 2 | 3 | import { useAppDispatch, useAppSelector } from '@/hooks' 4 | 5 | import FavoriteList from './FavoriteList' 6 | import { 7 | fetchFavoriteDetails, 8 | fetchFavoriteMyFavorites, 9 | fetchFavorites, 10 | } from './favoriteSlice' 11 | import { 12 | fetchProblemsetPageProps, 13 | selectIsSignedIn, 14 | } from '../global/globalSlice' 15 | 16 | const App: FC = () => { 17 | const dispatch = useAppDispatch() 18 | const isSignedIn = useAppSelector(selectIsSignedIn) 19 | useEffect(() => { 20 | void (async function () { 21 | dispatch(fetchProblemsetPageProps()) 22 | const res = await dispatch(fetchFavorites()).unwrap() 23 | const data = isSignedIn 24 | ? await dispatch(fetchFavoriteMyFavorites()).unwrap() 25 | : [] 26 | const ids = [ 27 | ...new Set( 28 | res.allFavorites 29 | .concat(res.officialFavorites) 30 | .map(({ idHash }) => idHash) 31 | .concat(data.map(a => a.idHash)) 32 | ), 33 | ] 34 | dispatch(fetchFavoriteDetails(ids)) 35 | })() 36 | }, [isSignedIn]) 37 | 38 | return ( 39 | <> 40 | {isSignedIn && } 41 | {isSignedIn && } 42 | 43 | 44 | ) 45 | } 46 | 47 | export default App 48 | -------------------------------------------------------------------------------- /src/content/pages/problem-list/fixRandom.ts: -------------------------------------------------------------------------------- 1 | import store from '@/app/store' 2 | import { findElementByXPath, LeetCodeApi, routerTo } from '@/utils' 3 | 4 | import { selectIsPremium } from '../global/globalSlice' 5 | import { selectOptions } from '../global/optionsSlice' 6 | 7 | const getCurrentId = () => { 8 | const strs = location.pathname.split('/').filter(Boolean) 9 | if (strs[0] === 'problem-list') return strs[1] 10 | return '' 11 | } 12 | const api = new LeetCodeApi(location.origin) 13 | const handleRandom = async (e: MouseEvent) => { 14 | const options = selectOptions(store.getState()) 15 | if (options?.problemListPage.fixRandomQuestion) { 16 | e.preventDefault() 17 | e.stopPropagation() 18 | const id = getCurrentId() 19 | let questions = await api.getProblemsetQuestionListAll({ 20 | filters: { listId: id }, 21 | }) 22 | const isPremium = selectIsPremium(store.getState()) 23 | if (!isPremium) questions = questions.filter(q => !q.paidOnly) 24 | 25 | const i = Math.floor(Math.random() * (questions.length - 1)) 26 | const url = `/problems/${questions[i].titleSlug}/?favorite=${id}` 27 | routerTo(url) 28 | } 29 | } 30 | const randomXpath = 31 | '//*[@id="__next"]/div/div[2]/div/div[2]/div/*//span[text()="随机开始"]' 32 | 33 | export const fixRandom = async (): Promise => { 34 | const spans = await findElementByXPath({ 35 | xpath: randomXpath, 36 | nodeType: 'UNORDERED_NODE_ITERATOR_TYPE', 37 | }) 38 | for (const el of spans) { 39 | el.parentElement?.removeEventListener('click', handleRandom) // 如果存在旧的监听器,先删除掉 40 | el.parentElement?.addEventListener('click', handleRandom) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/content/pages/problem-list/useSetProblemListRoot.ts: -------------------------------------------------------------------------------- 1 | import { useEffectMount } from '@/hooks' 2 | import { findElementByXPath } from '@/utils' 3 | import { debounce } from 'src/utils' 4 | 5 | export const useSetProblemListRoot = ( 6 | problemListXPath: string, 7 | isLoad: boolean, 8 | setProblemListRoot: (root: HTMLElement) => void 9 | ): void => { 10 | useEffectMount( 11 | async state => { 12 | if (!isLoad) return 13 | const handleMount = async () => { 14 | const el = await findElementByXPath(problemListXPath) 15 | 16 | if (state.isMount && el.parentNode) { 17 | const root = document.createElement('div') 18 | el.parentNode.insertBefore(root, el) 19 | setProblemListRoot(root) 20 | state.unmount.push(() => root.remove()) 21 | const handleChange = debounce(async () => { 22 | const el = await findElementByXPath(problemListXPath) 23 | 24 | if (el.previousSibling === root) return 25 | if (!state.isMount) return 26 | observer.disconnect() 27 | root.remove() 28 | state.unmount = [] 29 | handleMount() 30 | }, 100) 31 | const observer = new MutationObserver(handleChange) 32 | observer.observe(el.parentNode, { childList: true }) 33 | } 34 | } 35 | handleMount() 36 | }, 37 | [isLoad] 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/content/pages/problems/App.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react' 2 | 3 | import { withPage } from '@/hoc' 4 | import { isBetaUI } from '@/utils' 5 | import Beta from './Beta' 6 | import Legacy from './Legacy' 7 | import { useEffectMount } from '@/hooks' 8 | import DynamicLayout from './DynamicLayout' 9 | 10 | const App: FC<{ beta?: boolean }> = () => { 11 | const [beta, setBeta] = useState() 12 | 13 | useEffectMount(async state => { 14 | const beta = await isBetaUI() 15 | if (!state.isMount) return 16 | setBeta(beta) 17 | }, []) 18 | if (localStorage.getItem('used-dynamic-layout') === 'true') { 19 | return 20 | } 21 | 22 | if (beta === undefined) return null 23 | 24 | if (beta) { 25 | return 26 | } else { 27 | return 28 | } 29 | } 30 | 31 | export default withPage('problemsPage')(App) 32 | -------------------------------------------------------------------------------- /src/content/pages/problems/Beta.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react' 2 | 3 | import { useAppSelector, useObserverAncestor } from '@/hooks' 4 | 5 | import { selectOptions } from '../global/optionsSlice' 6 | import Timer from './Timer' 7 | import { findElement } from '@/utils' 8 | import { getRoot } from './utils' 9 | import { Portal } from '@/components/Portal' 10 | import Random from './Random' 11 | 12 | const Beta: FC<{ beta?: boolean }> = () => { 13 | const options = useAppSelector(selectOptions) 14 | const [timerRoot, setTimerRoot] = useState() 15 | const [randomRoot, setRandomRoot] = useState() 16 | 17 | const showTimer = !!options?.problemsPage.timer 18 | const showRandomQuestion = false 19 | useObserverAncestor( 20 | async state => { 21 | if (!showRandomQuestion) return 22 | // 创建「随机一题」按钮根元素 23 | const nav = await findElement( 24 | '#__next > div > div > div > nav > div > div > div:nth-child(2)' 25 | ) 26 | if (!state.isMount) return 27 | const randomRoot = document.createElement('div') 28 | randomRoot.style.lineHeight = '0' 29 | setRandomRoot(randomRoot) 30 | nav.append(randomRoot) 31 | state.unmount.push(() => randomRoot && randomRoot.remove()) 32 | return randomRoot 33 | }, 34 | [showRandomQuestion] 35 | ) 36 | 37 | useObserverAncestor( 38 | async state => { 39 | if (!showTimer) return 40 | // 创建「计时器」按钮根元素 41 | const parent = await getRoot() 42 | if (!state.isMount) return 43 | 44 | const root = document.createElement('div') 45 | parent.prepend(root) 46 | setTimerRoot(root) 47 | state.unmount.push(() => root && root.remove()) 48 | return root! 49 | }, 50 | [showTimer] 51 | ) 52 | 53 | return ( 54 | <> 55 | {showTimer && timerRoot && } 56 | {randomRoot && showRandomQuestion && ( 57 | 58 | 59 | 60 | )} 61 | 62 | ) 63 | } 64 | 65 | export default Beta 66 | -------------------------------------------------------------------------------- /src/content/pages/problems/DynamicLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react' 2 | 3 | import { useAppSelector, useObserverAncestor } from '@/hooks' 4 | 5 | import { selectOptions } from '../global/optionsSlice' 6 | import Timer from './Timer' 7 | import { getRoot } from './utils' 8 | 9 | const DynamicLayout: FC<{ beta?: boolean }> = () => { 10 | const options = useAppSelector(selectOptions) 11 | const [timerRoot, setTimerRoot] = useState() 12 | 13 | const showTimer = !!options?.problemsPage.timer 14 | 15 | useObserverAncestor( 16 | async state => { 17 | if (!showTimer) return 18 | const parent = await getRoot(true) 19 | 20 | // 创建「计时器」按钮根元素 21 | if (!parent || !state.isMount) return 22 | 23 | const root = document.createElement('div') 24 | parent.style.display = 'flex' 25 | parent.append(root) 26 | setTimerRoot(root) 27 | state.unmount.push(() => root && root.remove()) 28 | return root! 29 | }, 30 | [showTimer] 31 | ) 32 | 33 | return ( 34 | <> 35 | {showTimer && timerRoot && ( 36 | 37 | )} 38 | 39 | ) 40 | } 41 | 42 | export default DynamicLayout 43 | -------------------------------------------------------------------------------- /src/content/pages/problems/Legacy.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react' 2 | 3 | import { useAppSelector, useObserverAncestor } from '@/hooks' 4 | 5 | import { selectOptions } from '../global/optionsSlice' 6 | import Timer from './Timer' 7 | import { getRoot } from './utils' 8 | 9 | const Legacy: FC<{ beta?: boolean }> = () => { 10 | const options = useAppSelector(selectOptions) 11 | const [timerRoot, setTimerRoot] = useState() 12 | const showTimer = !!options?.problemsPage.timer 13 | 14 | useObserverAncestor( 15 | async state => { 16 | if (!showTimer) return 17 | const parent = await getRoot() 18 | if (!state.isMount) return 19 | 20 | const root = document.createElement('div') 21 | root.style.marginRight = '15px' 22 | parent.prepend(root) 23 | setTimerRoot(root) 24 | state.unmount.push(() => root && root.remove()) 25 | return root 26 | }, 27 | [showTimer] 28 | ) 29 | 30 | return ( 31 | <>{showTimer && timerRoot && } 32 | ) 33 | } 34 | 35 | export default Legacy 36 | -------------------------------------------------------------------------------- /src/content/pages/problems/RandomOption.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, ForwardedRef } from 'react' 2 | 3 | import { useAppSelector, useAppDispatch } from '@/hooks' 4 | import PopperUnstyled, { Placement } from '@/components/PopperUnstyled' 5 | 6 | import { 7 | selectRandomOption, 8 | RandomOptionType, 9 | setRandomOption, 10 | labelOfKey, 11 | } from '../global/optionsSlice' 12 | import { css } from 'styled-components/macro' 13 | 14 | interface RandomOptionProps { 15 | anchorEl?: HTMLElement | null 16 | placement?: Placement 17 | favorite: string 18 | } 19 | 20 | type RandomOptionKey = keyof RandomOptionType 21 | 22 | const RandomOption = forwardRef(function RandomOption( 23 | { favorite, ...props }: RandomOptionProps, 24 | ref: ForwardedRef 25 | ) { 26 | const option = useAppSelector(state => selectRandomOption(state, favorite)) 27 | const dispatch = useAppDispatch() 28 | const toggle = (key: RandomOptionKey) => () => { 29 | dispatch( 30 | setRandomOption({ 31 | favorite, 32 | option: { ...option, [key]: !option[key] }, 33 | }) 34 | ) 35 | } 36 | const keys: RandomOptionKey[] = Object.keys(option) as any 37 | 38 | return ( 39 | 47 |
props.theme.palette.primary.light}; 50 | border-radius: 0.5rem; 51 | padding: 0.625rem; 52 | box-shadow: ${props => props.theme.shadows[1]}; 53 | margin-left: 10px; 54 | `} 55 | > 56 | {keys.map(key => ( 57 |
  • theme.palette.text.light}; 71 | `} 72 | > 73 | 88 | props.theme.palette.checkbox.backgroundColor}; 89 | &:checked { 90 | background-color: ${props => 91 | props.theme.palette.checkbox.checkedBackgroundColor}; 92 | } 93 | &:checked::before { 94 | content: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px" fill="rgb(255 255 255)" aria-hidden="true"%3E%3Cpath fill-rule="evenodd" d="M9.688 15.898l-3.98-3.98a1 1 0 00-1.415 1.414L8.98 18.02a1 1 0 001.415 0L20.707 7.707a1 1 0 00-1.414-1.414l-9.605 9.605z" clip-rule="evenodd"%3E%3C/path%3E%3C/svg%3E'); 95 | } 96 | `} 97 | /> 98 | 106 |
  • 107 | ))} 108 |
    109 |
    110 | ) 111 | }) 112 | 113 | export default RandomOption 114 | -------------------------------------------------------------------------------- /src/content/pages/problems/ShortcutKeyOption.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, FC } from 'react' 2 | import { Portal } from '@/components/Portal' 3 | import { findElement } from '@/utils' 4 | import { css } from 'styled-components/macro' 5 | import { useAppDispatch, useAppSelector, useEffectMount } from '@/hooks' 6 | import { 7 | selectOptions, 8 | toggleContestProblemShortcutKeyOption, 9 | } from '../global/optionsSlice' 10 | import { withPage } from '@/hoc' 11 | import { useThrottle } from '@/hooks/useThrottle' 12 | 13 | const ShortcutKeyOption: FC = () => { 14 | const options = useAppSelector(selectOptions) 15 | const [optionEl, setOptionEl] = useState() 16 | 17 | const disableShortcutKey = 18 | options && options.contestProblemsPage.disableShortcutkey 19 | const dispatch = useAppDispatch() 20 | 21 | const toggle = useThrottle(() => { 22 | dispatch(toggleContestProblemShortcutKeyOption()) 23 | }, 500) 24 | 25 | useEffectMount(async state => { 26 | const handleClick = async () => { 27 | try { 28 | const content = await findElement( 29 | '.rc-dialog-body>.modal-body.description__21Ft' 30 | ) 31 | if (!state.isMount) return 32 | setOptionEl(content) 33 | } catch (error) { 34 | // 35 | } 36 | } 37 | handleClick() 38 | const settingBtn = await findElement('.setting-btn') 39 | if (!state.isMount) return 40 | settingBtn.addEventListener('click', handleClick) 41 | state.unmount.push( 42 | () => settingBtn && settingBtn.removeEventListener('click', handleClick) 43 | ) 44 | }) 45 | 46 | useEffect(() => { 47 | if (!disableShortcutKey) return 48 | let editor: HTMLElement | null = null 49 | const handleClick = (e: KeyboardEvent) => { 50 | e.stopPropagation() 51 | } 52 | void (async function () { 53 | editor = await findElement('.editor-base') 54 | editor?.addEventListener('keydown', handleClick) 55 | })() 56 | return () => { 57 | editor && editor.removeEventListener('keydown', handleClick) 58 | } 59 | }, [disableShortcutKey]) 60 | 61 | if (!optionEl) return <> 62 | return ( 63 | 64 |
    65 |
    74 |
    禁用快捷键
    75 | 76 | 81 | {disableShortcutKey ? ( 82 | 87 | ) : ( 88 | 92 | )} 93 | 94 |
    95 |
    96 | ) 97 | } 98 | 99 | export default withPage('contestProblemsPage')(ShortcutKeyOption) 100 | -------------------------------------------------------------------------------- /src/content/pages/problems/useTimer.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from 'react' 2 | import { logger } from '../../../utils' 3 | 4 | const log = logger.child({ prefix: 'useTimer' }) 5 | 6 | type Time = [house: number, minute: number, second: number] 7 | 8 | function formatTime(time: number): Time { 9 | const house = Math.floor(time / 3600) 10 | const minute = Math.floor((time / 60) % 60) 11 | const second = Math.floor(time % 60) 12 | return [house, minute, second] 13 | } 14 | 15 | type UseTimeReturn = { 16 | time: Time 17 | isDone: boolean 18 | done: (time: Time) => void 19 | restart: () => void 20 | } 21 | 22 | /** 23 | * 计时器钩子 24 | * @returns 返回当前累计的时间 `time`;是否已结束 `isDone`;结束当前计时函数 `done`;重新开始函数 `restart` 25 | */ 26 | const useTimer = (): UseTimeReturn => { 27 | const start = useRef(new Date()) 28 | const [isDone, setIsDone] = useState(false) 29 | const [value, setValue] = useState(0) 30 | const time = useMemo(() => { 31 | return formatTime(value) 32 | }, [value]) 33 | 34 | useEffect(() => { 35 | let timer: ReturnType 36 | if (!isDone) { 37 | timer = setInterval(async () => { 38 | setValue(Math.floor((Date.now() - start.current.valueOf()) / 1000)) 39 | }, 100) 40 | } 41 | 42 | return () => { 43 | if (timer) { 44 | clearInterval(timer) 45 | } 46 | } 47 | }, [isDone]) 48 | 49 | const restart = () => { 50 | log.debug('重新开始计时') 51 | setIsDone(false) 52 | start.current = new Date() 53 | setValue(0) 54 | } 55 | 56 | const done = () => { 57 | log.debug('结束计时') 58 | setIsDone(true) 59 | } 60 | 61 | return { time, isDone, done, restart } 62 | } 63 | 64 | export { useTimer } 65 | -------------------------------------------------------------------------------- /src/content/pages/problemset/App.tsx: -------------------------------------------------------------------------------- 1 | import Rank from './Rank' 2 | import './intercept' 3 | import { FC, useState } from 'react' 4 | import { useAppSelector, useEffectMount } from '@/hooks' 5 | import { selectOptions } from '../global/optionsSlice' 6 | import { awaitFn, problemsetPageIsLoad } from '@/utils' 7 | import { Portal } from '@/components/Portal' 8 | import ProblemList from '../problem-list/ProblemList' 9 | import { withPage } from '@/hoc' 10 | import { useSetProblemListRoot } from '../problem-list/useSetProblemListRoot' 11 | 12 | const App: FC = () => { 13 | const options = useAppSelector(selectOptions) 14 | const [problemListRoot, setProblemListRoot] = useState() 15 | const [isLoad, setIsLoad] = useState(false) 16 | 17 | useEffectMount(async state => { 18 | await awaitFn(() => problemsetPageIsLoad()) 19 | if (state.isMount) setIsLoad(true) 20 | }) 21 | useSetProblemListRoot( 22 | '//*[@id="__next"]/*//div[text()="热门企业题库"]/../../..', 23 | isLoad, 24 | setProblemListRoot 25 | ) 26 | 27 | if (!isLoad) return null 28 | 29 | const showProblemList = !!options?.problemsetPage.problemList 30 | const showRank = !!options?.problemsetPage.problemRating 31 | return ( 32 | <> 33 | 34 | {showProblemList && problemListRoot && ( 35 | 36 | 37 | 38 | )} 39 | 40 | ) 41 | } 42 | 43 | export default withPage('problemsetPage')(App) 44 | -------------------------------------------------------------------------------- /src/content/pages/problemset/Open.tsx: -------------------------------------------------------------------------------- 1 | import { useHover } from '@/hooks' 2 | import { FC } from 'react' 3 | import { css, keyframes } from 'styled-components/macro' 4 | 5 | import SvgIcon from '@/components/SvgIcon' 6 | 7 | export type Pos = { right: number; bottom: number } 8 | interface OpenProps { 9 | onEnable?: () => void 10 | pos?: Pos 11 | } 12 | 13 | const defaultPos = { right: 30, bottom: 151 } 14 | 15 | const makeMove = ({ right, bottom }: Pos) => keyframes` 16 | from { 17 | right: ${right}px; 18 | bottom: ${bottom}px; 19 | } 20 | 21 | to { 22 | right: 30px; 23 | bottom: 151px; 24 | } 25 | ` 26 | 27 | const Open: FC = ({ onEnable, pos }) => { 28 | const [ref, hover] = useHover() 29 | 30 | const handleClick = () => { 31 | if (onEnable) onEnable() 32 | } 33 | if (!pos) { 34 | pos = defaultPos 35 | } 36 | const move = makeMove(pos) 37 | 38 | return ( 39 |
    52 | props.theme.mode === 'dark' ? '#282828' : '#fff'}; 53 | color: ${props => 54 | props.theme.mode === 'dark' ? `#eff1f6bf` : `#262626`}; 55 | box-shadow: ${props => 56 | props.theme.mode === 'dark' 57 | ? css`rgba(0, 0, 0, 0) 0px 0px 0px 0px, 58 | rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.24) 0px 1px 3px 0px, 59 | rgba(0, 0, 0, 0.16) 0px 2px 8px 0px` 60 | : css`rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.04) 0px 1px 3px 0px, rgba(0, 0, 0, 0.08) 0px 2px 8px 0px`}; 61 | `} 62 | onClick={handleClick} 63 | > 64 | {hover ? ( 65 |
    71 | 打开评分 72 |
    73 | ) : ( 74 |
    79 | 80 | 81 | 82 |
    83 | )} 84 |
    85 | ) 86 | } 87 | 88 | export default Open 89 | -------------------------------------------------------------------------------- /src/content/pages/problemset/RankItem.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from 'react' 2 | 3 | import { useAppSelector } from '@/hooks' 4 | 5 | import { selectProblemRankDataByTitleSlug } from '../global/globalSlice' 6 | import { css } from 'styled-components/macro' 7 | 8 | interface RankProps { 9 | row: HTMLElement 10 | } 11 | 12 | const color = (rating: number, themeMode: 'dark' | 'light') => { 13 | let res: { dark: string; light: string } 14 | if (rating < 1200) { 15 | // Newbie 16 | res = { dark: '#808080', light: '#808080' } 17 | } else if (rating < 1400) { 18 | // Pupil 19 | res = { dark: '#008000', light: '#008000' } 20 | } else if (rating < 1600) { 21 | // Specialist 22 | res = { dark: '#03a89e', light: '#03a89e' } 23 | } else if (rating < 1900) { 24 | // Expert 25 | res = { dark: '#008ed3', light: '#00f' } 26 | } else if (rating < 2100) { 27 | // Candidate Master 28 | res = { dark: '#a0a', light: '#a0a' } 29 | } else if (rating < 2300) { 30 | // Master 31 | res = { dark: '#dbaf75', light: '#dbaf75' } 32 | } else if (rating < 2400) { 33 | // International Master 34 | res = { dark: '#dba049', light: '#dba049' } 35 | } else if (rating < 2600) { 36 | // Grandmaster 37 | res = { dark: '#db6666', light: '#db6666' } 38 | // } else if (rating < 3000) { 39 | // // International Grandmaster 40 | // return '#FF0000' 41 | } else { 42 | // Legendary Grandmaster 43 | res = { dark: '#c70000', light: '#FF0000' } 44 | } 45 | return res[themeMode] 46 | } 47 | 48 | const RankItem: FC = ({ row }) => { 49 | const getTitleSlug = () => 50 | row.children[1] 51 | ?.querySelector('a') 52 | ?.pathname.split('/') 53 | .filter(Boolean)[1] ?? '' 54 | const [titleSlug, setTitleSlug] = useState(getTitleSlug()) 55 | const rank = useAppSelector(state => 56 | selectProblemRankDataByTitleSlug(state, titleSlug) 57 | ) 58 | 59 | useEffect(() => { 60 | const observer = new MutationObserver(() => { 61 | const titleSlug = getTitleSlug() 62 | if (titleSlug) { 63 | setTitleSlug(titleSlug) 64 | } 65 | }) 66 | observer.observe(row.children[1], { childList: true }) 67 | return () => { 68 | observer.disconnect() 69 | } 70 | }, []) 71 | 72 | if (!rank) return null 73 | 74 | return ( 75 |
    color(rank.Rating, props.theme.mode)}; 78 | font-weight: bold; 79 | text-align: center; 80 | ${rank.Rating >= 3000 81 | ? css` 82 | &::first-letter { 83 | color: ${props => 84 | props.theme.mode === 'dark' ? '#fff' : '#000'}; 85 | } 86 | ` 87 | : ''} 88 | `} 89 | > 90 | {rank?.Rating.toFixed(0) ?? ''} 91 |
    92 | ) 93 | } 94 | 95 | export default RankItem 96 | -------------------------------------------------------------------------------- /src/content/pages/problemset/RankRange.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeEventHandler, 3 | Dispatch, 4 | FC, 5 | KeyboardEventHandler, 6 | SetStateAction, 7 | useState, 8 | } from 'react' 9 | 10 | import { css } from 'styled-components/macro' 11 | import { Input } from '@/components/Input' 12 | import Button from '@/components/Button' 13 | 14 | import { 15 | getId, 16 | parseParams, 17 | serializationPrams, 18 | ParamType, 19 | once, 20 | } from './utils' 21 | import { routerTo } from '@/utils' 22 | import { ToolTip } from '@/components/ToolTip' 23 | import CrownIcon from '@/components/icons/CrownIcon' 24 | import { Box } from './AddQuestion' 25 | 26 | const StyledInput: FC<{ 27 | value: string 28 | setValue: Dispatch> 29 | onKeyDown?: KeyboardEventHandler 30 | }> = ({ value, setValue, onKeyDown }) => { 31 | const handleChange: ChangeEventHandler = e => { 32 | setValue(e.target.value) 33 | } 34 | return ( 35 | 48 | ) 49 | } 50 | 51 | const RankRange: FC = () => { 52 | const params: ParamType = parseParams() 53 | const [min, setMin] = useState(params.custom?.min ?? '') 54 | const [max, setMax] = useState(params.custom?.max ?? '') 55 | const [includePremium, setIncludePremium] = useState( 56 | params.custom?.includePremium ?? true 57 | ) 58 | 59 | const handleApply = async (includePremium: boolean) => { 60 | const params: ParamType = parseParams() 61 | 62 | let id = getId(true) 63 | if ( 64 | params.sorting?.[0].orderBy !== 'FRONTEND_ID' || 65 | params.sorting?.[0].sortOrder === 'DESCENDING' 66 | ) { 67 | routerTo( 68 | location.pathname + 69 | '?' + 70 | serializationPrams({ 71 | ...params, 72 | sorting: [{ orderBy: 'FRONTEND_ID', sortOrder: id }], 73 | custom: undefined, 74 | }) 75 | ) 76 | 77 | // 第一次是等待上面的跳转 78 | await once('routeChangeComplete') 79 | // 第二次是因为上面 sortOrder 使用 id,力扣会重新跳转到 sortOrder 为 ‘ASCENDING’ 的页面 80 | await once('routeChangeComplete') 81 | } 82 | id = getId(true) 83 | params.sorting = [{ orderBy: 'FRONTEND_ID', sortOrder: id }] 84 | params.custom = { ...params.custom, min, max, includePremium } 85 | const url = location.pathname + '?' + serializationPrams(params) 86 | routerTo(url) 87 | } 88 | const handleEnter: KeyboardEventHandler = e => { 89 | if (e.code === 'Enter') handleApply(includePremium) 90 | } 91 | const handleChangeIncludePremium = () => { 92 | setIncludePremium(!includePremium) 93 | handleApply(!includePremium) 94 | } 95 | return ( 96 | <> 97 | 98 | 99 | 102 | 103 | 104 | 难度范围: 105 | - 106 | 107 | 118 | 119 | ) 120 | } 121 | 122 | export default RankRange 123 | -------------------------------------------------------------------------------- /src/content/pages/ranking/Predict.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo, useEffect } from 'react' 2 | 3 | import { fetchPrediction } from './rankSlice' 4 | import { Portal } from '@/components/Portal' 5 | import { Item, useUrlChange } from './Item' 6 | import { User, useUser } from './utils' 7 | import { useAppDispatch, useAppSelector } from '@/hooks' 8 | import { selectOptions } from '../global/optionsSlice' 9 | 10 | interface PredictItemProps { 11 | hasMyRank: boolean 12 | showOldRating: boolean 13 | showPredictordelta: boolean 14 | showNewRating: boolean 15 | showExpectingRanking: boolean 16 | index: number 17 | row: HTMLElement 18 | beta?: boolean 19 | } 20 | const PredictItem = memo(function PredictItem({ 21 | hasMyRank, 22 | row, 23 | index, 24 | beta, 25 | ...props 26 | }: PredictItemProps) { 27 | const { username, region } = useUser(hasMyRank, index, row, beta) 28 | const [{ contestId: contestSlug }] = useUrlChange(beta) 29 | 30 | return ( 31 | 36 | ) 37 | }) 38 | 39 | interface PredictProps { 40 | rows: HTMLElement[] 41 | hasMyRank: boolean 42 | showOldRating: boolean 43 | showPredictordelta: boolean 44 | showNewRating: boolean 45 | showExpectingRanking: boolean 46 | userInfos: User[] 47 | beta?: boolean 48 | } 49 | const Predict = memo(function Predict({ 50 | hasMyRank, 51 | rows, 52 | userInfos, 53 | ...props 54 | }: PredictProps) { 55 | const [{ contestId }] = useUrlChange(props.beta) 56 | const options = useAppSelector(selectOptions) 57 | 58 | const dispatch = useAppDispatch() 59 | useEffect(() => { 60 | dispatch( 61 | fetchPrediction({ 62 | contestSlug: contestId, 63 | users: userInfos.map(a => ({ 64 | region: a.region, 65 | username: a.oldUsername!, 66 | })), 67 | }) 68 | ) 69 | }, [dispatch, contestId, JSON.stringify(userInfos)]) 70 | 71 | const showPredict = !!options?.contestRankingPage.showPredict 72 | return ( 73 | <> 74 | {rows.map((row, i) => ( 75 | 76 | {showPredict ? ( 77 | 78 | 79 | 80 | ) : ( 81 | 82 | )} 83 | 84 | ))} 85 | 86 | ) 87 | }) 88 | 89 | type TDWrapProps = React.HTMLAttributes & { 90 | beta?: boolean 91 | children?: React.ReactNode 92 | } 93 | 94 | export const TDWrap: FC = ({ beta, children, ...props }) => { 95 | if (beta) { 96 | return
    {children}
    97 | } 98 | return {children} 99 | } 100 | 101 | export default Predict 102 | -------------------------------------------------------------------------------- /src/content/pages/ranking/RealTimePredict.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { Portal } from '@/components/Portal' 3 | import { Item, useUrlChange } from './Item' 4 | 5 | import { useFetchPreviousRatingData, usePredict, useUser } from './utils' 6 | import { TDWrap } from './Predict' 7 | 8 | interface RealTimePredictItemProps { 9 | isVirtual?: boolean 10 | hasMyRank: boolean 11 | showOldRating: boolean 12 | showPredictordelta: boolean 13 | showNewRating: boolean 14 | showExpectingRanking: boolean 15 | index: number 16 | row: HTMLElement 17 | beta?: boolean 18 | } 19 | export const RealTimePredictItem: FC = ({ 20 | hasMyRank, 21 | index, 22 | row, 23 | beta, 24 | ...props 25 | }) => { 26 | const { username, region } = useUser(hasMyRank, index, row, beta) 27 | const [{ contestId: contestSlug }] = useUrlChange(beta) 28 | 29 | usePredict({ 30 | username, 31 | region, 32 | contestSlug, 33 | }) 34 | 35 | return ( 36 | 41 | ) 42 | } 43 | interface RealTimePredictProps { 44 | rows: HTMLElement[] 45 | hasMyRank: boolean 46 | showOldRating: boolean 47 | showPredictordelta: boolean 48 | showNewRating: boolean 49 | showExpectingRanking: boolean 50 | beta?: boolean 51 | } 52 | 53 | export const RealTimePredict: FC = ({ 54 | rows, 55 | hasMyRank, 56 | ...props 57 | }) => { 58 | const [{ contestId: contestSlug }] = useUrlChange(props.beta) 59 | useFetchPreviousRatingData(contestSlug) 60 | 61 | const borderColor = props.beta ? '#888' : '#ddd' 62 | 63 | return ( 64 | <> 65 | {rows.map((row, i) => ( 66 | 67 | 79 | 83 | 84 | 85 | ))} 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/content/pages/ranking/Title.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react' 2 | import styled from 'styled-components/macro' 3 | 4 | import { ToolTip } from '@/components/ToolTip' 5 | 6 | const HelpIcon = styled.span` 7 | position: relative; 8 | cursor: pointer; 9 | font-family: 'Glyphicons Halflings'; 10 | font-style: normal; 11 | font-weight: 400; 12 | color: rgba(0, 0, 0, 0.65); 13 | line-height: 1; 14 | top: 1px; 15 | -webkit-font-smoothing: antialiased; 16 | -moz-osx-font-smoothing: grayscale; 17 | 18 | &::before { 19 | content: '\\e085'; 20 | } 21 | ` 22 | 23 | const Content = styled.div` 24 | color: #fff; 25 | text-align: center; 26 | white-space: nowrap; 27 | ` 28 | 29 | const Help = ({ content }: { content: string | ReactNode }) => { 30 | return ( 31 | {content}} 36 | offset={{ left: 70, top: 40 }} 37 | > 38 | 39 | 40 | ) 41 | } 42 | 43 | interface TitleProps { 44 | showOldRating: boolean 45 | showPredictordelta: boolean 46 | showNewRating: boolean 47 | showExpectingRanking: boolean 48 | realTime: boolean 49 | help?: string | ReactNode 50 | } 51 | 52 | const Title: FC = ({ 53 | showNewRating, 54 | showPredictordelta, 55 | showOldRating, 56 | showExpectingRanking, 57 | realTime, 58 | help, 59 | }) => { 60 | return ( 61 | <> 62 |
    67 | {showOldRating && ( 68 |
    69 | 旧分数 70 | {!showPredictordelta && !showNewRating && help && ( 71 | 72 | )} 73 |
    74 | )} 75 | {showPredictordelta && ( 76 |
    82 | {showNewRating ? 'Δ' : '预测'} 83 | {!showNewRating && help && } 84 |
    85 | )} 86 | 87 | {showNewRating && ( 88 |
    89 | 新分数 90 | {help && } 91 |
    92 | )} 93 | 94 | {showExpectingRanking && realTime && ( 95 |
    96 | Rk/Exp 97 | 100 |
    当前全球排名(Rk)/期望全球排名(Exp)
    101 |
    102 | 榜单数据更新有延迟,对于还不确定的「当前全球排名」标记为 103 | 灰色 104 | ,如果已经确定,则标记为 105 | 黑色 106 |
    107 |
    108 | } 109 | /> 110 |
    111 | )} 112 |
    113 | 114 | ) 115 | } 116 | 117 | export default Title 118 | -------------------------------------------------------------------------------- /src/content/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { DefaultTheme } from 'styled-components/macro' 2 | 3 | export const darkTheme: DefaultTheme = { 4 | mode: 'dark', 5 | palette: { 6 | primary: { 7 | main: `rgba(40, 40, 40, 1)`, 8 | dark: `rgba(26, 26, 26, 1)`, 9 | light: `rgba(48, 48, 48, 1)`, 10 | hover: `rgba(52, 52, 52, 1)`, 11 | }, 12 | secondary: { 13 | main: `rgba(80, 80, 80, 1)`, 14 | light: `rgba(85, 85, 85, 1)`, 15 | dark: `rgba(75, 75, 75, 1)`, 16 | hover: `rgba(90, 90, 90, 1)`, 17 | }, 18 | text: { 19 | main: `rgba(239, 242, 246, 0.6)`, 20 | light: `rgba(239, 242, 246, 1)`, 21 | dark: `rgba(239, 242, 246, 0.3)`, 22 | }, 23 | button: { 24 | main: `rgba(10, 102, 204, 1)`, 25 | dark: `rgba(8, 89, 180, 1)`, 26 | light: `rgba(11, 113, 229, 1)`, 27 | hover: `rgba(7, 72, 146, 1)`, 28 | text: `rgba(255, 255, 255, 1)`, 29 | disable: `rgb(110 110 110)`, 30 | }, 31 | checkbox: { 32 | backgroundColor: 'hsla(0,0%,100%,.14)', 33 | checkedBackgroundColor: 'rgb(10, 132, 255)', 34 | }, 35 | }, 36 | shadows: { 37 | 1: `0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 4px rgba(0, 0, 0, 0.25)`, 38 | 2: `rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.24) 0px 1px 3px 0px, rgba(0, 0, 0, 0.16) 0px 2px 8px 0px`, 39 | }, 40 | } 41 | 42 | export const lightTheme: DefaultTheme = { 43 | mode: 'light', 44 | palette: { 45 | primary: { 46 | main: `rgba(255, 255, 255, 1)`, 47 | light: `rgba(255, 255, 255, 1)`, 48 | dark: `rgba(235, 235, 235, 1)`, 49 | hover: `rgba(220, 220, 220, 1)`, 50 | }, 51 | secondary: { 52 | main: `rgba(235, 235, 235, 1)`, 53 | light: `rgba(245, 245, 245, 1)`, 54 | dark: `rgba(225, 225, 225, 1)`, 55 | hover: `rgba(220, 220, 220, 1)`, 56 | }, 57 | text: { 58 | main: `hsl(0deg 0% 15%)`, 59 | light: `hsl(0deg 0% 3%)`, 60 | dark: `hsl(0deg 0% 10%)`, 61 | }, 62 | button: { 63 | main: `rgba(0, 110, 230, 1)`, 64 | dark: `rgba(0, 100, 208, 1)`, 65 | light: `rgba(0, 122, 255, 1)`, 66 | hover: `rgba(0, 90, 216)`, 67 | text: `rgba(255, 255, 255, 1)`, 68 | disable: `rgb(110 110 110)`, 69 | }, 70 | checkbox: { 71 | backgroundColor: 'rgba(0,10,32,.05)', 72 | checkedBackgroundColor: 'rgb(0, 122, 255)', 73 | }, 74 | }, 75 | shadows: { 76 | 1: `0 2px 8px rgba(0, 0, 0, 0.08),0 1px 2px rgba(0, 0, 0, 0.1)`, 77 | 2: `rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.04) 0px 1px 3px 0px, rgba(0, 0, 0, 0.08) 0px 2px 8px 0px`, 78 | }, 79 | } 80 | -------------------------------------------------------------------------------- /src/content/types/customEvent.d.ts: -------------------------------------------------------------------------------- 1 | type CustomMap = { 2 | refinedLeetcodeOptionsChange: { 3 | options: import('src/options/options').OptionsType 4 | } 5 | refinedLeetcodeSaveOptions: { 6 | options: import('src/options/options').OptionsType 7 | } 8 | refinedLeetcodeGetOptions: void 9 | } 10 | 11 | type CustomEventMap = { [K in keyof T]: CustomEvent } 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 14 | interface WindowEventMap extends CustomEventMap { 15 | urlchange: {} 16 | } 17 | 18 | type Args = CustomMap[T] extends undefined 19 | ? [name: T] 20 | : [name: T, data: CustomMap[T]] 21 | 22 | type CustomEventDispatch = (...args: Args) => void 23 | -------------------------------------------------------------------------------- /src/content/types/global.d.ts: -------------------------------------------------------------------------------- 1 | interface FiberRoot { 2 | current: { [key: string]: unknown } // Fiber 3 | } 4 | interface HTMLElement { 5 | _reactRootContainer?: { 6 | _internalRoot?: FiberRoot 7 | } 8 | } 9 | 10 | declare let REFINED_LEETCODE_LOG_LEVEL: string 11 | -------------------------------------------------------------------------------- /src/content/types/styled.d.ts: -------------------------------------------------------------------------------- 1 | // import original module declarations 2 | import 'styled-components/macro' 3 | import type {} from 'styled-components/cssprop' 4 | 5 | // and extend them! 6 | declare module 'styled-components' { 7 | export interface DefaultTheme { 8 | mode: 'dark' | 'light' 9 | palette: { 10 | primary: { 11 | main: string 12 | light: string 13 | dark: string 14 | hover: string 15 | } 16 | secondary: { 17 | main: string 18 | light: string 19 | dark: string 20 | hover: string 21 | } 22 | text: { 23 | main: string 24 | light: string 25 | dark: string 26 | } 27 | button: { 28 | main: string 29 | light: string 30 | dark: string 31 | hover: string 32 | text: string 33 | disable: string 34 | } 35 | checkbox: { 36 | backgroundColor: string 37 | checkedBackgroundColor: string 38 | } 39 | } 40 | shadows: { 41 | [key: number]: string 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/content/utils/conv.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://github.com/meooow25/carrot/blob/20045fba44ebba404b62a2f9d0ac20fe4514a084/carrot/src/util/conv.js 3 | */ 4 | 5 | export class FFTConv { 6 | public n: number 7 | public wr: number[] 8 | public wi: number[] 9 | public rev: number[] 10 | public constructor(n: number) { 11 | let k = 1 12 | while (1 << k < n) { 13 | k++ 14 | } 15 | this.n = 1 << k 16 | const n2 = this.n >> 1 17 | this.wr = [] 18 | this.wi = [] 19 | const ang = (2 * Math.PI) / this.n 20 | for (let i = 0; i < n2; i++) { 21 | this.wr[i] = Math.cos(i * ang) 22 | this.wi[i] = Math.sin(i * ang) 23 | } 24 | this.rev = [0] 25 | for (let i = 1; i < this.n; i++) { 26 | this.rev[i] = (this.rev[i >> 1] >> 1) | ((i & 1) << (k - 1)) 27 | } 28 | } 29 | 30 | public reverse(a: number[]): void { 31 | for (let i = 1; i < this.n; i++) { 32 | if (i < this.rev[i]) { 33 | const tmp = a[i] 34 | a[i] = a[this.rev[i]] 35 | a[this.rev[i]] = tmp 36 | } 37 | } 38 | } 39 | 40 | public transform(ar: number[], ai: number[]): void { 41 | this.reverse(ar) 42 | this.reverse(ai) 43 | const wr = this.wr 44 | const wi = this.wi 45 | for (let len = 2; len <= this.n; len <<= 1) { 46 | const half = len >> 1 47 | const diff = this.n / len 48 | for (let i = 0; i < this.n; i += len) { 49 | let pw = 0 50 | for (let j = i; j < i + half; j++) { 51 | const k = j + half 52 | const vr = ar[k] * wr[pw] - ai[k] * wi[pw] 53 | const vi = ar[k] * wi[pw] + ai[k] * wr[pw] 54 | ar[k] = ar[j] - vr 55 | ai[k] = ai[j] - vi 56 | ar[j] += vr 57 | ai[j] += vi 58 | pw += diff 59 | } 60 | } 61 | } 62 | } 63 | 64 | public convolve(a: number[], b: number[]): number[] { 65 | if (a.length === 0 || b.length === 0) { 66 | return [] 67 | } 68 | const n = this.n 69 | const resLen = a.length + b.length - 1 70 | if (resLen > n) { 71 | throw new Error( 72 | `a.length + b.length - 1 is ${a.length} + ${b.length} - 1 = ${resLen}, ` + 73 | `expected <= ${n}` 74 | ) 75 | } 76 | const cr = new Array(n).fill(0) 77 | const ci = new Array(n).fill(0) 78 | // cr.splice(0, a.length, ...a) 79 | for (let i = 0; i < a.length; i++) { 80 | cr[i] = a[i] 81 | } 82 | // ci.splice(0, b.length, ...b) 83 | for (let i = 0; i < b.length; i++) { 84 | ci[i] = b[i] 85 | } 86 | this.transform(cr, ci) 87 | 88 | cr[0] = 4 * cr[0] * ci[0] 89 | ci[0] = 0 90 | for (let i = 1, j = n - 1; i <= j; i++, j--) { 91 | const ar = cr[i] + cr[j] 92 | const ai = ci[i] - ci[j] 93 | const br = ci[j] + ci[i] 94 | const bi = cr[j] - cr[i] 95 | cr[i] = ar * br - ai * bi 96 | ci[i] = ar * bi + ai * br 97 | cr[j] = cr[i] 98 | ci[j] = -ci[i] 99 | } 100 | 101 | this.transform(cr, ci) 102 | const res = [] 103 | res[0] = cr[0] / (4 * n) 104 | for (let i = 1, j = n - 1; i <= j; i++, j--) { 105 | res[i] = cr[j] / (4 * n) 106 | res[j] = cr[i] / (4 * n) 107 | } 108 | res.splice(resLen) 109 | return res 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/content/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils' 2 | export * from './leetcode-api' 3 | export * from './predict' 4 | -------------------------------------------------------------------------------- /src/content/utils/predict.ts: -------------------------------------------------------------------------------- 1 | import { FFTConv } from './conv' 2 | 3 | /** 4 | * @param k 参赛次数 5 | */ 6 | const f = (() => { 7 | let cache: number[] 8 | return (k: number) => { 9 | if (k > 100) return 3.5 10 | if (!cache) { 11 | cache = [] 12 | for (let i = 0; i <= 100; i++) { 13 | cache[i] = (cache[i - 1] ?? 0) + Math.pow(5 / 7, i) 14 | } 15 | } 16 | return cache[k] ?? 3.5 17 | } 18 | })() 19 | 20 | /** 预测算法 21 | * 22 | * @param seeds 种子数据 23 | * @param rating 当前选手的分数 24 | * @param rank 当前算寿的排名 25 | * @param attendedContestsCount 当前选手参赛次数 26 | * @returns 27 | */ 28 | export const predict = ( 29 | seeds: number[], 30 | rating: number, 31 | rank: number, 32 | attendedContestsCount: number 33 | ): number => { 34 | const erank = getERank(seeds, rating) 35 | 36 | const m = Math.sqrt(erank * rank) 37 | 38 | let l = 0, 39 | r = MAX_RATING 40 | while (l < r) { 41 | const mid = Math.floor((l + r) / 2) 42 | if (getSeed(seeds, mid) < m) { 43 | r = mid 44 | } else { 45 | l = mid + 1 46 | } 47 | } 48 | 49 | const d = (l - rating * ACCURACY) / (1 + f(attendedContestsCount)) 50 | return d / ACCURACY 51 | } 52 | 53 | //#region 由 [@tiger2005](https://leetcode.cn/u/tiger2005/) 编写 54 | const ACCURACY = 10 55 | const MAX_RATING = 8000 * ACCURACY 56 | 57 | /** 预处理 seeds 58 | * 59 | * @param previousRatings 当前比赛所有选手的 rating 数据 60 | * @returns 61 | */ 62 | export const calcSeed = (previousRatings: number[]): number[] => { 63 | const FFT = new FFTConv(3 * MAX_RATING + 1) 64 | 65 | const ELO_PROB_REV = new Array(2 * MAX_RATING + 1) 66 | for (let i = -MAX_RATING; i <= MAX_RATING; i++) { 67 | ELO_PROB_REV[i + MAX_RATING] = 1 / (1 + Math.pow(10, i / (400 * ACCURACY))) 68 | } 69 | const freq: number[] = new Array(MAX_RATING + 1).fill(0) 70 | for (let rating of previousRatings) { 71 | rating = Math.round(rating * ACCURACY) 72 | freq[rating]++ 73 | } 74 | 75 | const seeds = FFT.convolve(ELO_PROB_REV, freq) 76 | return seeds 77 | } 78 | 79 | /** 获取当前选手的期望胜率 80 | * 81 | * @param seeds 种子数据 82 | * @param x 当前选手的 rating 83 | * @returns 84 | */ 85 | export const getERank = (seeds: number[], x: number): number => { 86 | x = Math.round(x * ACCURACY) 87 | return seeds[x + MAX_RATING] + 0.5 88 | } 89 | 90 | /** 获取某个分数 x 的期望胜率 91 | * 92 | * @param seeds 种子数据 93 | * @param x 真实分数*ACCURACY 94 | * @returns 95 | */ 96 | export const getSeed = (seeds: number[], x: number): number => { 97 | return seeds[x + MAX_RATING] + 1 98 | } 99 | //#endregion 100 | -------------------------------------------------------------------------------- /src/options/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { css } from 'styled-components/macro' 3 | import Option from './Option' 4 | 5 | const App: FC = () => { 6 | return ( 7 |
    16 |
    18 | ) 19 | } 20 | 21 | export default App 22 | -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { render } from 'react-dom' 3 | import { createGlobalStyle } from 'styled-components/macro' 4 | 5 | import App from './App' 6 | 7 | __webpack_public_path__ = chrome.runtime.getURL('.') + '/' 8 | 9 | const GlobalStyle = createGlobalStyle` 10 | body { 11 | margin: 0; 12 | } 13 | ` 14 | 15 | render( 16 | 17 | 18 | 19 | , 20 | document.getElementById('root') 21 | ) 22 | -------------------------------------------------------------------------------- /src/options/options.ts: -------------------------------------------------------------------------------- 1 | export const defaultOptions = { 2 | homePage: { 3 | block: true, 4 | }, 5 | problemsetPage: { 6 | problemList: true, 7 | problemRating: false, 8 | }, 9 | problemListPage: { 10 | problemList: true, 11 | problemRating: false, 12 | }, 13 | problemsPage: { 14 | timer: true, 15 | }, 16 | contestProblemsPage: { 17 | disableShortcutkey: false, 18 | modifyPageLayout: false, 19 | reverseLayout: false, 20 | problemViewWidth: '40%', 21 | }, 22 | contestRankingPage: { 23 | languageIcon: false, 24 | showPredict: true, 25 | realTimePredict: false, 26 | showOldRating: false, 27 | ratingPredictor: true, 28 | showNewRating: true, 29 | expectingRanking: false, 30 | }, 31 | } 32 | 33 | type Child = { [K in keyof T]: T[K] }[keyof T] 34 | 35 | export type PageName = keyof typeof defaultOptions | 'solutionsPage' 36 | 37 | export type OptionsType = typeof defaultOptions 38 | 39 | export type ItemType = Child 40 | 41 | export const labelMap: { [key: string]: string } = { 42 | homePage: '首页', 43 | problemsetPage: '题库页', 44 | problemListPage: '题单页', 45 | problemsPage: '答题页', 46 | contestProblemsPage: '竞赛答题页', 47 | contestRankingPage: '竞赛排名页', 48 | block: '黑名单', 49 | problemList: '题单列表', 50 | problemRating: '题目评分', 51 | timer: '计时器', 52 | disableShortcutkey: '禁用快捷键', 53 | modifyPageLayout: '修改页面布局', 54 | reverseLayout: '题面居于右侧', 55 | languageIcon: '语言图标', 56 | showOldRating: '显示旧分数', 57 | ratingPredictor: '显示预测分数', 58 | showNewRating: '显示新分数', 59 | showPredict: '显示预测', 60 | realTimePredict: '显示实时预测', 61 | expectingRanking: '期望全球排名', 62 | } 63 | -------------------------------------------------------------------------------- /src/popup/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import Option from '../options/Option' 3 | 4 | const App: FC = () => { 5 | return