├── .prettierignore ├── public ├── icon300.png ├── options.html ├── icon300.svg └── manifest.json ├── screenshots ├── 1.png ├── 2.png ├── content.png ├── options-page.png └── open-options-page.png ├── .commitlintrc.json ├── .env.production ├── .husky └── commit-msg ├── .prettierrc.json ├── jest.config.js ├── src ├── vite-env.d.ts ├── content-utils │ ├── text-util.ts │ ├── url-util.ts │ ├── layout-util.ts │ ├── heading-std-util.ts │ ├── header-util.ts │ ├── scroll-util.ts │ ├── dom-util.ts │ ├── heading-util.ts │ ├── article-util.ts │ ├── heading-infer-util.ts │ └── heading-all-util.ts ├── shared │ ├── error-boundary.less │ ├── resolve-rules.ts │ ├── skeleton.less │ ├── skeleton.tsx │ ├── resolve-rules.json │ ├── resolve-rules-util.ts │ ├── error-boundary.tsx │ └── constants.ts ├── content-view │ ├── toc-body.less │ ├── use-settings.ts │ ├── hooks.ts │ ├── content.tsx │ ├── toc-body.tsx │ ├── use-headings.ts │ ├── toc.less │ ├── use-drag-resize.ts │ └── toc.tsx ├── assets │ ├── close.svg │ ├── icon.svg │ └── variables.less ├── options-view │ ├── options.less │ └── options.tsx ├── extension-utils │ ├── settings.ts │ └── api.ts └── background │ └── background.ts ├── test ├── teardown.js ├── setup.js ├── toc-content.test.js └── links.js ├── .npmrc ├── .env ├── scripts ├── constant.js ├── cli.js ├── zip.js ├── vite.config.js ├── dev │ ├── dev.js │ ├── browser.js │ └── nodemon.js └── build.js ├── .editorconfig ├── tsconfig.json ├── .gitignore ├── .eslintrc.json ├── LICENSE ├── .release-it.json ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.svg 3 | node_modules/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /public/icon300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Whilconn/one-toc/HEAD/public/icon300.png -------------------------------------------------------------------------------- /screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Whilconn/one-toc/HEAD/screenshots/1.png -------------------------------------------------------------------------------- /screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Whilconn/one-toc/HEAD/screenshots/2.png -------------------------------------------------------------------------------- /screenshots/content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Whilconn/one-toc/HEAD/screenshots/content.png -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /screenshots/options-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Whilconn/one-toc/HEAD/screenshots/options-page.png -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VITE_RESOLVE_URL=https://cdn.jsdelivr.net/gh/Whilconn/one-toc/src/shared/resolve-rules.json 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "${1}" 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /screenshots/open-options-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Whilconn/one-toc/HEAD/screenshots/open-options-page.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globalSetup: '/test/setup.js', 3 | globalTeardown: '/test/teardown.js', 4 | }; 5 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare const chrome: any; 3 | interface Window { 4 | navigation: EventTarget; 5 | } 6 | -------------------------------------------------------------------------------- /src/content-utils/text-util.ts: -------------------------------------------------------------------------------- 1 | export function splitTextByLine(text: string) { 2 | return text 3 | .split(/\n+/) 4 | .map((s) => s.trim()) 5 | .filter(Boolean); 6 | } 7 | -------------------------------------------------------------------------------- /test/teardown.js: -------------------------------------------------------------------------------- 1 | module.exports = async function () { 2 | if (!global.browser.isConnected()) return; 3 | 4 | console.log(`🔔 Jest teardown, browser will be closed!`); 5 | await global.browser.close(); 6 | }; 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # npm镜像 2 | registry=https://registry.npmmirror.com 3 | 4 | # config for npm version, see: https://docs.npmjs.com/cli/v7/using-npm/config#commit-hooks 5 | # commit-hooks=false 6 | # git-tag-version=false 7 | 8 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # .env 用于 vite 开发测试 2 | # .env.production 用于 vite 生产构建 3 | # .env.local 仅用于 release-it 发布,提供 GITHUB_TOKEN 等 4 | 5 | VITE_RESOLVE_URL=https://cdn.jsdelivr.net/gh/Whilconn/one-toc@feat/resolve-rules/src/shared/resolve-rules.json 6 | -------------------------------------------------------------------------------- /scripts/constant.js: -------------------------------------------------------------------------------- 1 | const COMMANDS = { 2 | DEV: 'dev', 3 | DEV_CONTENT: 'dev:content', 4 | DEV_OPTIONS: 'dev:options', 5 | DEV_POPUP: 'dev:popup', 6 | BUILD: 'build', 7 | BUILD_DEV: 'build:dev', 8 | ZIP: 'zip', 9 | }; 10 | 11 | module.exports = { COMMANDS }; 12 | -------------------------------------------------------------------------------- /src/shared/error-boundary.less: -------------------------------------------------------------------------------- 1 | .error-boundary { 2 | padding: 20px; 3 | 4 | p { 5 | margin-bottom: 20px; 6 | } 7 | 8 | a { 9 | color: #0681d0; 10 | text-decoration: none; 11 | outline: 0; 12 | cursor: pointer; 13 | 14 | &:hover { 15 | color: #056bad; 16 | } 17 | } 18 | 19 | .grey { 20 | color: var(--onetoc-color); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | # https://github.com/facebook/react/blob/master/.editorconfig 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | max_line_length = 120 14 | 15 | [*.md] 16 | max_line_length = 0 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /src/shared/resolve-rules.ts: -------------------------------------------------------------------------------- 1 | import resolveRuleJson from './resolve-rules.json'; 2 | 3 | export const RESOLVE_RULES_VERSION: string = resolveRuleJson.version; 4 | export const RESOLVE_RULES: ResolveRule[] = resolveRuleJson.rules; 5 | 6 | export type ResolveRule = { 7 | // 网页匹配规则 8 | pages: string[]; 9 | // 正文DOM选择器 10 | article: string; 11 | // 段落标题DOM选择器 12 | headings: string[]; 13 | }; 14 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const { openBrowser } = require('../scripts/dev/browser'); 2 | 3 | module.exports = async function () { 4 | global.browser = await openBrowser(); 5 | 6 | ['exit', 'SIGINT'].forEach((e) => { 7 | process.on(e, async () => { 8 | if (!global.browser.isConnected()) return; 9 | 10 | console.log(`🔔 Jest received ${e} signal, browser will be closed!`); 11 | await global.browser.close(); 12 | }); 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /public/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OneToc 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/content-view/toc-body.less: -------------------------------------------------------------------------------- 1 | .onetoc-container .onetoc-body { 2 | flex: 1; 3 | padding: 15px; 4 | // 为底部 resize cursor 预留区域 5 | margin-bottom: 5px; 6 | overflow: auto; 7 | 8 | a { 9 | display: block; 10 | padding: 8px 0; 11 | transition: all .2s ease-in; 12 | 13 | each(range(0, 10), { 14 | &.onetoc-level@{value} { 15 | padding-left: @value * 16px; 16 | } 17 | }); 18 | } 19 | 20 | .no-content { 21 | text-align: center; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/close.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/content-utils/url-util.ts: -------------------------------------------------------------------------------- 1 | import { SYMBOL } from '../shared/constants'; 2 | import { getText } from './dom-util'; 3 | 4 | // TODO: 用于拷贝锚点链接 5 | export function genAnchorUrl(node: HTMLElement) { 6 | const id = encodeURIComponent(node.id || node.getAttribute('name') || ''); 7 | const text = getText(node); 8 | if (!id && !text) return ''; 9 | 10 | const textFragment = `:~:text=${encodeURIComponent(text)}`; 11 | 12 | const hash = SYMBOL.HASH + (id || textFragment); 13 | return location.hash ? location.href.replace(location.hash, hash) : `${location.href}${hash}`; 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/skeleton.less: -------------------------------------------------------------------------------- 1 | @import '../assets/variables'; 2 | 3 | .onetoc-skeleton { 4 | padding: 10px; 5 | transform: translate(0); 6 | 7 | @keyframes skeleton-animation { 8 | from { 9 | background-position: right; 10 | } 11 | 12 | to { 13 | background-position: left; 14 | } 15 | } 16 | 17 | .onetoc-skeleton-item { 18 | padding: 10px; 19 | margin: 10px 0; 20 | border-radius: 4px; 21 | background: var(--onetoc-skeleton-bg); 22 | animation: skeleton-animation 1.2s ease infinite; 23 | background-size: 400%; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/toc-content.test.js: -------------------------------------------------------------------------------- 1 | const { links } = require('./links'); 2 | const { openPage, getNodes } = require('../scripts/dev/browser'); 3 | 4 | const TIMEOUT = 20e3; 5 | 6 | jest.setTimeout(2 * TIMEOUT); 7 | 8 | describe('counts toc heading', () => { 9 | test.concurrent.each(links)('%s %s %i', async (name, link, num) => { 10 | const page = await openPage(global.browser, link, TIMEOUT); 11 | const selector = '.onetoc-body a'; 12 | const nodes = await getNodes(page, selector); 13 | 14 | expect(nodes.length).toBe(+num); 15 | 16 | if (nodes.length === +num) page.close(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Layer 1 4 | 5 | 6 | One 7 | Toc 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/shared/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import './skeleton.less'; 3 | 4 | export function Skeleton() { 5 | const [nums, setNums] = useState([]); 6 | 7 | useEffect(() => { 8 | const length = Math.max(3, Math.random() * 10); 9 | const list = Array.from({ length }, () => Math.max(40, Math.random() * 100)); 10 | setNums(list); 11 | }, []); 12 | 13 | return ( 14 |
15 | {nums.map((s, i) => { 16 | return

; 17 | })} 18 |

19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /public/icon300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Layer 1 4 | 5 | Toc 6 | OneToc 7 | 8 | 9 | -------------------------------------------------------------------------------- /scripts/cli.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { dev } = require('./dev/dev'); 3 | const { buildProd, buildDev } = require('./build'); 4 | const { zip } = require('./zip'); 5 | const { COMMANDS } = require('./constant'); 6 | 7 | async function start() { 8 | const [command, ...options] = process.argv.slice(2); 9 | 10 | const fileName = path.relative('.', __filename); 11 | console.log(`[${fileName}]:${JSON.stringify({ command, options })}`); 12 | 13 | if (command === COMMANDS.BUILD) { 14 | await buildProd(); 15 | } else if (command === COMMANDS.BUILD_DEV) { 16 | await buildDev(true); 17 | } else if (command.startsWith(COMMANDS.DEV)) { 18 | dev(command); 19 | } else if (command === COMMANDS.ZIP) { 20 | zip(); 21 | } 22 | } 23 | 24 | start().then(); 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ESNext" 9 | ], 10 | "allowJs": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": false, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "types": [ 23 | "node" 24 | ] 25 | }, 26 | "include": [ 27 | "**/*.ts", 28 | "**/*.tsx" 29 | ], 30 | "exclude": [ 31 | "node_modules" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/assets/variables.less: -------------------------------------------------------------------------------- 1 | .var-aero() { 2 | --onetoc-color: #333; 3 | --onetoc-bg-color: #ffffff80; 4 | --onetoc-bg-filter: blur(50px); 5 | --onetoc-skeleton-bg: linear-gradient(90deg, rgba(0, 0, 0, 0.1) 30%, rgba(0, 0, 0, 0.3) 70%, rgba(0, 0, 0, 0.1)); 6 | } 7 | 8 | .var-light() { 9 | --onetoc-color: #333; 10 | --onetoc-bg-color: #fff; 11 | --onetoc-bg-filter: unset; 12 | --onetoc-skeleton-bg: linear-gradient(90deg, rgba(0, 0, 0, 0.1) 30%, rgba(0, 0, 0, 0.3) 70%, rgba(0, 0, 0, 0.1)); 13 | } 14 | 15 | .var-dark() { 16 | --onetoc-color: #eee; 17 | --onetoc-bg-color: #333; 18 | --onetoc-bg-filter: unset; 19 | --onetoc-skeleton-bg: linear-gradient( 20 | 90deg, 21 | rgba(255, 255, 255, 0.6) 30%, 22 | rgba(255, 255, 255, 0.1) 70%, 23 | rgba(255, 255, 255, 0.6) 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.angular/cache 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | 45 | # System Files 46 | .DS_Store 47 | Thumbs.db 48 | 49 | # env local 50 | .env.local 51 | -------------------------------------------------------------------------------- /src/content-view/use-settings.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { loadSettings, Settings } from '../extension-utils/settings'; 3 | import { useEventListener } from './hooks'; 4 | 5 | // 浅比较 TODO: 遍历key value对比 6 | function equals(a: object | undefined | null, b: object | undefined | null) { 7 | if (a === b) return true; 8 | return a && b && Object.entries(a).flat().join() === Object.entries(b).flat().join(); 9 | } 10 | 11 | export function useSettings() { 12 | const [settings, setSettings] = useState(null); 13 | 14 | async function updateSettings() { 15 | const st = await loadSettings(); 16 | setSettings((prevSt) => { 17 | return equals(prevSt, st) ? prevSt : st; 18 | }); 19 | } 20 | 21 | // 初始化时,获取配置 22 | useEffect(() => void updateSettings(), []); 23 | 24 | // 页面切换时,更新配置 25 | useEventListener(document, 'visibilitychange', updateSettings); 26 | 27 | return settings; 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:react-hooks/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "project": [ 16 | "./tsconfig.json" 17 | ], 18 | "ecmaFeatures": { 19 | "jsx": true 20 | }, 21 | "ecmaVersion": "latest", 22 | "sourceType": "module" 23 | }, 24 | "plugins": [ 25 | "react", 26 | "react-hooks", 27 | "@typescript-eslint" 28 | ], 29 | "rules": { 30 | "react-hooks/rules-of-hooks": "error", 31 | "react-hooks/exhaustive-deps": "warn" 32 | }, 33 | "settings": { 34 | "react": { 35 | "version": "detect" 36 | } 37 | }, 38 | "ignorePatterns": ["scripts/**"] 39 | } 40 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OneToc", 3 | "description": "from package.json", 4 | "version": "from package.json", 5 | "manifest_version": 3, 6 | "icons": { 7 | "16": "icon300.png", 8 | "48": "icon300.png", 9 | "128": "icon300.png" 10 | }, 11 | "permissions": [ 12 | "activeTab", 13 | "storage" 14 | ], 15 | "action": {}, 16 | "content_scripts": [ 17 | { 18 | "matches": [ 19 | "" 20 | ], 21 | "js": [ 22 | "react.js", 23 | "react-dom.js", 24 | "content.js" 25 | ] 26 | } 27 | ], 28 | "options_ui": { 29 | "page": "options.html", 30 | "open_in_tab": true 31 | }, 32 | "background": { 33 | "service_worker": "background.js" 34 | }, 35 | "commands": { 36 | "toggle-toc": { 37 | "suggested_key": { 38 | "default": "Ctrl+B", 39 | "mac": "Command+B" 40 | }, 41 | "description": "Toggle toc on page" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/shared/resolve-rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "rules": [ 4 | { 5 | "pages": ["https://www.huxiu.com/article/**"], 6 | "article": "#article-content", 7 | "headings": ["#article-content [label*=标题][class*=title]"] 8 | }, 9 | { 10 | "pages": ["https://my.oschina.net/u/**"], 11 | "article": ".article-detail .content", 12 | "headings": [] 13 | }, 14 | { 15 | "pages": ["https://mp.weixin.qq.com/s/**"], 16 | "article": "#js_content", 17 | "headings": [] 18 | }, 19 | { 20 | "pages": ["https://www.iteye.com/blog/**"], 21 | "article": ".iteye-blog-content-contain", 22 | "headings": [] 23 | }, 24 | { 25 | "pages": ["https://www.iteye.com/news/**"], 26 | "article": "#news_content", 27 | "headings": [] 28 | }, 29 | { 30 | "pages": ["https://zhuanlan.zhihu.com/p/**"], 31 | "article": ".Post-RichText", 32 | "headings": [] 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/resolve-rules-util.ts: -------------------------------------------------------------------------------- 1 | import * as micromatch from 'micromatch'; 2 | import { RESOLVE_RULES, ResolveRule } from './resolve-rules'; 3 | import { saveSettings } from '../extension-utils/settings'; 4 | 5 | export function matchResolveRule(rules: ResolveRule[]) { 6 | const pathInUrl = location.host + location.pathname; 7 | if (!rules?.length) rules = RESOLVE_RULES; 8 | 9 | return rules.find((c) => micromatch.some([location.href, pathInUrl], c.pages)); 10 | } 11 | 12 | /** 更新解析规则 **/ 13 | export async function updateResolveRules() { 14 | const resolveConfigUrl = import.meta.env.VITE_RESOLVE_URL as string; 15 | const resolveConfigJson = (await fetch(resolveConfigUrl).then((r) => { 16 | return r.ok ? r.json() : new Error(`${r.status} ${r.statusText}`); 17 | })) as { version: string; rules: ResolveRule[] }; 18 | 19 | if (!resolveConfigJson?.version || !resolveConfigJson?.rules) return Promise.reject(resolveConfigJson); 20 | 21 | await saveSettings({ 22 | resolveRules: resolveConfigJson.rules, 23 | resolveRulesVersion: resolveConfigJson.version, 24 | }); 25 | 26 | return resolveConfigJson; 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present, Whilconn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/shared/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import pkg from '../../package.json'; 3 | import './error-boundary.less'; 4 | 5 | interface Props { 6 | children: ReactElement; 7 | className?: string; 8 | } 9 | 10 | interface State { 11 | error: Error | undefined; 12 | } 13 | 14 | export class ErrorBoundary extends React.Component { 15 | constructor(props: Props) { 16 | super(props); 17 | this.state = { error: undefined }; 18 | } 19 | 20 | static getDerivedStateFromError(error: Error) { 21 | return { error: error }; 22 | } 23 | 24 | render() { 25 | const className = this.props.className || ''; 26 | if (this.state.error) { 27 | return ( 28 |
29 |

{pkg.extName}插件出现错误

30 |

错误信息:{this.state.error.message}

31 |

32 | location.reload()}>刷新页面试试,或者联系 33 | 34 | 作者 35 | 36 |

37 |
38 | ); 39 | } 40 | 41 | return this.props.children; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /scripts/zip.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const glob = require('glob'); 4 | const JSZip = require('jszip'); 5 | const { ROOT_ABS, DEST_ABS } = require('./vite.config'); 6 | const pkg = require('../package.json'); 7 | 8 | // chrome 安全性提高,无法离线安装crx,因此只打zip包 9 | function zip() { 10 | const fileName = path.relative(ROOT_ABS, __filename); 11 | console.log(`[${fileName}]:🍵 构建zip包...`); 12 | 13 | const inputPattern = path.resolve(DEST_ABS, '**/*.*'); 14 | const files = glob.sync(inputPattern) || []; 15 | const zipTask = JSZip(); 16 | const zipName = `${pkg.extName}-v${pkg.version}`; 17 | 18 | for (const file of files) { 19 | const content = fs.readFileSync(file); 20 | const relPath = path.relative(DEST_ABS, file); 21 | const filePath = path.join(zipName, relPath); 22 | zipTask.file(filePath, content); 23 | } 24 | 25 | const outPath = path.resolve(DEST_ABS, `${zipName}.zip`); 26 | 27 | zipTask 28 | .generateNodeStream({ type: 'nodebuffer', streamFiles: true }) 29 | .pipe(fs.createWriteStream(outPath)) 30 | .on('finish', () => { 31 | console.log(`[${fileName}]:🚀 zip包构建完成,路径是 ${outPath}`); 32 | }); 33 | } 34 | 35 | module.exports = { zip }; 36 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "after:bump": "npm run build && npm run zip" 4 | }, 5 | "git": { 6 | "changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}", 7 | "requireCleanWorkingDir": true, 8 | "requireBranch": false, 9 | "requireUpstream": true, 10 | "requireCommits": false, 11 | "addUntrackedFiles": false, 12 | "commit": true, 13 | "commitMessage": "chore: release v${version}", 14 | "commitArgs": [], 15 | "tag": true, 16 | "tagExclude": null, 17 | "tagName": "v${version}", 18 | "tagMatch": null, 19 | "tagAnnotation": "release ${version}", 20 | "tagArgs": [], 21 | "push": true, 22 | "pushArgs": ["--follow-tags"], 23 | "pushRepo": "" 24 | }, 25 | "github": { 26 | "release": true, 27 | "releaseName": "v${version}", 28 | "releaseNotes": null, 29 | "autoGenerate": false, 30 | "preRelease": false, 31 | "draft": false, 32 | "tokenRef": "GITHUB_TOKEN", 33 | "assets": ["dist/*.zip"], 34 | "host": null, 35 | "timeout": 0, 36 | "proxy": null, 37 | "skipChecks": false, 38 | "web": false 39 | }, 40 | "npm": { 41 | "publish": false 42 | }, 43 | "gitlab": { 44 | "release": false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /scripts/vite.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { defineConfig } = require('vite'); 3 | const react = require('@vitejs/plugin-react'); 4 | 5 | const root = path.resolve(__dirname, '..'); 6 | 7 | const configFn = defineConfig(({ mode }) => { 8 | const isDev = mode === 'development'; 9 | const buildOpts = isDev ? { minify: false, sourcemap: 'inline' } : {}; 10 | const globals = { 11 | antd: 'antd', 12 | react: 'React', 13 | 'react-dom': 'ReactDOM', 14 | }; 15 | 16 | return { 17 | root, 18 | mode, 19 | build: { 20 | ...buildOpts, 21 | emptyOutDir: false, 22 | rollupOptions: { 23 | external: Object.keys(globals), 24 | input: '', 25 | output: { 26 | globals, 27 | format: 'iife', 28 | entryFileNames: '[name].js', 29 | }, 30 | }, 31 | }, 32 | // 解决 micromatch 不兼容浏览器,出现 process is not defined 报错 33 | define: { 34 | process: {}, 35 | }, 36 | plugins: [react()], 37 | }; 38 | }); 39 | 40 | const [DEST, PUBLIC] = ['dist', 'public']; 41 | const ROOT_ABS = root; 42 | const DEST_ABS = path.resolve(ROOT_ABS, DEST); 43 | const PUBLIC_ABS = path.resolve(ROOT_ABS, PUBLIC); 44 | 45 | module.exports = { configFn, ROOT_ABS, DEST_ABS, PUBLIC_ABS }; 46 | -------------------------------------------------------------------------------- /scripts/dev/dev.js: -------------------------------------------------------------------------------- 1 | const { startNodemon } = require('./nodemon'); 2 | const { openPage, reloadPage } = require('./browser'); 3 | const { COMMANDS } = require('../constant'); 4 | const { links } = require('../../test/links'); 5 | const manifest = require('../../public/manifest.json'); 6 | 7 | function dev(command) { 8 | let start = async (browser, pages) => { 9 | pages.contentPage = await openPage(browser, links[0][1]); 10 | }; 11 | 12 | let reload = (browser, pages) => reloadPage(pages.contentPage); 13 | let reloadExtPage = true; 14 | 15 | // default command is COMMANDS.DEV_CONTENT 16 | if (command === COMMANDS.DEV_OPTIONS) { 17 | start = async (browser, pages, extInfo) => { 18 | pages.optionsPage = await openPage(browser, extInfo.optionsPage.url); 19 | }; 20 | 21 | reload = (browser, pages) => reloadPage(pages.optionsPage); 22 | reloadExtPage = false; 23 | } else if (command === COMMANDS.DEV_POPUP) { 24 | start = async (browser, pages, extInfo) => { 25 | const url = `chrome-extension://${extInfo.id}/${manifest.action.default_popup}`; 26 | pages.popupPage = await openPage(browser, url); 27 | }; 28 | 29 | reload = (browser, pages) => reloadPage(pages.popupPage); 30 | reloadExtPage = false; 31 | } 32 | 33 | startNodemon(start, reload, reloadExtPage); 34 | } 35 | 36 | module.exports = { dev }; 37 | -------------------------------------------------------------------------------- /scripts/dev/browser.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer-core'); 2 | const chromePath = require('chrome-location'); 3 | const { DEST_ABS: extensionPath } = require('../vite.config'); 4 | 5 | const SW = 1920; 6 | const SH = 1080; 7 | const TIMEOUT = 20e3; 8 | const WAIT_UNTIL = 'domcontentloaded'; 9 | 10 | async function openBrowser() { 11 | return await puppeteer.launch({ 12 | executablePath: chromePath, 13 | headless: false, 14 | handleSIGTERM: true, 15 | handleSIGINT: true, 16 | handleSIGHUP: true, 17 | waitForInitialPage: false, 18 | defaultViewport: null, 19 | args: [ 20 | '--no-startup-window', 21 | '--start-maximized', 22 | `--window-size=${SW},${SH}`, 23 | `--disable-extensions-except=${extensionPath}`, 24 | `--load-extension=${extensionPath}`, 25 | ], 26 | }); 27 | } 28 | 29 | async function openPage(browser, url, timeout = TIMEOUT) { 30 | const page = await browser.newPage(); 31 | page.setDefaultTimeout(timeout); 32 | await page.goto(url, { waitUntil: WAIT_UNTIL }); 33 | return page; 34 | } 35 | 36 | function reloadPage(page) { 37 | return page.reload({ waitUntil: WAIT_UNTIL }); 38 | } 39 | 40 | async function getNodes(page, selector) { 41 | await page.waitForSelector(selector); 42 | return await page.$$(selector); 43 | } 44 | 45 | module.exports = { openBrowser, openPage, reloadPage, getNodes }; 46 | -------------------------------------------------------------------------------- /src/content-view/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useTitle() { 4 | const [title, setTitle] = useState(document.title); 5 | 6 | // 监听 title 变化 7 | useEffect(() => { 8 | const target = document.querySelector('title'); 9 | if (!target) return; 10 | 11 | const observer = new MutationObserver(() => setTitle(document.title)); 12 | observer.observe(target as Node, { childList: true }); 13 | return () => observer.disconnect(); 14 | }, []); 15 | 16 | // 兼容特殊情况,使用 window.navigation api 需要浏览器版本 > 102 17 | // 参考:https://developer.chrome.com/docs/web-platform/navigation-api/ 18 | useEffect(() => { 19 | const navigation = window.navigation; 20 | if (!navigation) return; 21 | 22 | const evtName = 'navigatesuccess'; 23 | const handler = () => { 24 | setTimeout(() => setTitle(document.title), 500); 25 | }; 26 | 27 | navigation.addEventListener(evtName, handler); 28 | return () => navigation.removeEventListener(evtName, handler); 29 | }, []); 30 | 31 | return title; 32 | } 33 | 34 | export function useEventListener( 35 | target: EventTarget, 36 | eventName: string, 37 | handler: (evt: Event) => unknown | Promise, 38 | ) { 39 | useEffect(() => { 40 | target.addEventListener(eventName, handler); 41 | return () => target.removeEventListener(eventName, handler); 42 | }, [target, eventName, handler]); 43 | } 44 | -------------------------------------------------------------------------------- /src/options-view/options.less: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-size: 14px; 6 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, 7 | Helvetica Neue, sans-serif; 8 | font-style: normal; 9 | } 10 | 11 | #root { 12 | padding: 10px; 13 | } 14 | 15 | .settings-container { 16 | margin: 20px auto; 17 | padding: 15px; 18 | width: 500px; 19 | color: #333; 20 | border-radius: 8px; 21 | box-shadow: rgba(60, 64, 67, 0.3) 0 1px 2px 0, rgba(60, 64, 67, 0.15) 0 2px 6px 2px; 22 | 23 | .ant-form-item-control { 24 | text-align: right; 25 | } 26 | 27 | .space-between { 28 | display: flex; 29 | align-items: center; 30 | justify-content: space-between; 31 | } 32 | 33 | .settings-title { 34 | margin-bottom: 20px; 35 | padding-bottom: 10px; 36 | font-weight: bold; 37 | border-bottom: 1px solid #eee; 38 | } 39 | 40 | .shortcut-desc { 41 | padding: 0 10px; 42 | color: #aaa; 43 | } 44 | 45 | .settings-footer { 46 | margin: 40px 0 0 0; 47 | color: #bbb; 48 | border-top: 1px solid #eee; 49 | 50 | p { 51 | justify-content: space-around; 52 | margin: 0; 53 | padding-top: 10px; 54 | } 55 | 56 | a { 57 | color: #1890ff; 58 | text-decoration: none; 59 | outline: 0; 60 | cursor: pointer; 61 | 62 | &:hover { 63 | color: #40a9ff; 64 | } 65 | 66 | &:active { 67 | color: #096dd9; 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const CID = 'onetoc-1652755589611'; 2 | 3 | export const HEADER_HEIGHT = 100; 4 | 5 | // 标签名 nodeName,tagName 6 | const nodeNames = [ 7 | 'h1', 8 | 'h2', 9 | 'h3', 10 | 'h4', 11 | 'h5', 12 | 'h6', 13 | 'b', 14 | 'strong', 15 | 'a', 16 | 'p', 17 | 'article', 18 | 'header', 19 | 'aside', 20 | 'footer', 21 | 'nav', 22 | 'svg', 23 | 'figure', 24 | ] as const; 25 | export const NODE_NAME = Object.fromEntries(nodeNames.map((k) => [k, k])) as Record<(typeof nodeNames)[number], string>; 26 | 27 | // 定位 position 28 | const positions = ['relative', 'static', 'sticky', 'fixed', 'absolute'] as const; 29 | export const POSITION = Object.fromEntries(positions.map((p) => [p, p])) as Record<(typeof positions)[number], string>; 30 | export const FIXED_POSITIONS = [POSITION.sticky, POSITION.fixed, POSITION.absolute]; 31 | 32 | // 布局 display 33 | const displays = ['block', 'inline'] as const; 34 | export const DISPLAY = Object.fromEntries(displays.map((p) => [p, p])) as Record<(typeof displays)[number], string>; 35 | 36 | export const HEADING_SELECTORS = [NODE_NAME.h1, NODE_NAME.h2, NODE_NAME.h3, NODE_NAME.h4, NODE_NAME.h5, NODE_NAME.h6]; 37 | 38 | export const BOLD_SELECTORS = [NODE_NAME.b, NODE_NAME.strong]; 39 | 40 | export const NOISE_SELECTORS = [NODE_NAME.header, NODE_NAME.aside, NODE_NAME.footer, NODE_NAME.nav]; 41 | 42 | export const NOISE_WORDS = ['head', 'foot', 'side', 'left', 'right', 'comment', 'recommend']; 43 | 44 | export const SYMBOL = { HASH: '#', COMMA: ',' }; 45 | 46 | export const MSG_NAMES = { TOGGLE_TOC: 'toggle-toc' }; 47 | 48 | export const TOC_LEVEL = 'onetoc-level'; 49 | -------------------------------------------------------------------------------- /src/content-utils/layout-util.ts: -------------------------------------------------------------------------------- 1 | import { FIXED_POSITIONS } from '../shared/constants'; 2 | import { queryAll } from './dom-util'; 3 | 4 | const EMBED_MOD = 'onetoc-embed-mod'; 5 | const TOC_WIDTH = 300; 6 | 7 | function getFixedNodes(left: number) { 8 | const nodes = queryAll('*'); 9 | const fixedNodes: Array<[HTMLElement, Array<{ name: string; origin: string; target: string }>]> = []; 10 | 11 | for (const n of nodes) { 12 | const rect = n.getBoundingClientRect(); 13 | const s = getComputedStyle(n); 14 | 15 | // 根据 clientRect、computedStyle 判定 fixed 16 | if (rect.width * rect.height && rect.left < left && FIXED_POSITIONS.includes(s.position)) { 17 | if (fixedNodes.some(([fn]) => fn.contains(n))) continue; 18 | const attrs = [ 19 | { 20 | name: 'left', 21 | origin: n.style.left, 22 | target: `calc(${rect.left}px + var(--onetoc-width))`, 23 | }, 24 | { 25 | name: 'maxWidth', 26 | origin: n.style.maxWidth, 27 | target: 'calc(100vw - var(--onetoc-width))', 28 | }, 29 | ]; 30 | fixedNodes.push([n, attrs]); 31 | } 32 | } 33 | 34 | return fixedNodes; 35 | } 36 | 37 | export function changeLayout() { 38 | document.body.toggleAttribute(EMBED_MOD, true); 39 | 40 | const fixedNodes = getFixedNodes(TOC_WIDTH); 41 | fixedNodes.forEach(([n, attrs]) => { 42 | attrs.forEach((a) => n.style.setProperty(a.name, a.target)); 43 | }); 44 | 45 | return function restoreLayout() { 46 | document.body.toggleAttribute(EMBED_MOD, false); 47 | 48 | fixedNodes.forEach(([n, attrs]) => { 49 | attrs.forEach((a) => n.style.setProperty(a.name, a.origin)); 50 | }); 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/extension-utils/settings.ts: -------------------------------------------------------------------------------- 1 | import { loadStorage, saveStorage } from './api'; 2 | import { RESOLVE_RULES, RESOLVE_RULES_VERSION, ResolveRule } from '../shared/resolve-rules'; 3 | 4 | export interface Settings { 5 | position: string; 6 | theme: string; 7 | strategy: string; 8 | autoOpenRules: string; 9 | // 已读版本号,用于判断是否已经查看releaseNote 10 | knownVersion: string; 11 | resolveRules: ResolveRule[]; 12 | resolveRulesVersion: string; 13 | } 14 | 15 | export const THEME_OPTIONS = [ 16 | { label: '默认', value: 'aero' }, 17 | { label: '浅色', value: 'light' }, 18 | { label: '深色', value: 'dark' }, 19 | ]; 20 | 21 | export const POS_EMBED = 'embed'; 22 | 23 | export const POSITION_OPTIONS = [ 24 | { label: '右浮动', value: 'fixed-right' }, 25 | { label: '左浮动', value: 'fixed-left' }, 26 | { label: '内嵌', value: POS_EMBED }, 27 | ]; 28 | 29 | export const STRATEGY_OPTIONS = [ 30 | { label: '自带', value: 'official' }, 31 | { label: '精选', value: 'inferred' }, 32 | ]; 33 | 34 | export const DEFAULT_SETTINGS: Settings = { 35 | position: POSITION_OPTIONS[0].value, 36 | theme: THEME_OPTIONS[0].value, 37 | strategy: STRATEGY_OPTIONS[0].value, 38 | autoOpenRules: import.meta.env.DEV ? '**' : '', 39 | knownVersion: '', 40 | resolveRules: RESOLVE_RULES, 41 | resolveRulesVersion: RESOLVE_RULES_VERSION, 42 | }; 43 | 44 | export const SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS); 45 | export const SETTINGS_KEYMAP = { 46 | ...Object.fromEntries(SETTINGS_KEYS.map((s) => [s, s])), 47 | } as Record; 48 | 49 | export function loadSettings() { 50 | return loadStorage(SETTINGS_KEYS) as Promise; 51 | } 52 | 53 | export function saveSettings(settings: Partial) { 54 | return saveStorage(settings) as Promise; 55 | } 56 | -------------------------------------------------------------------------------- /src/background/background.ts: -------------------------------------------------------------------------------- 1 | import { MSG_NAMES } from '../shared/constants'; 2 | import { 3 | DEFAULT_SETTINGS, 4 | loadSettings, 5 | POSITION_OPTIONS, 6 | saveSettings, 7 | Settings, 8 | STRATEGY_OPTIONS, 9 | THEME_OPTIONS, 10 | } from '../extension-utils/settings'; 11 | import { 12 | addClickActionListener, 13 | addCommandListener, 14 | addInstalledListener, 15 | INSTALL_REASON, 16 | sendTabMessage, 17 | Tab, 18 | UPDATE_REASON, 19 | } from '../extension-utils/api'; 20 | 21 | // 将变更的配置项重置为默认值,仅用于插件更新的场景 22 | // 当配置项的选项值发生变化,原来的值在现有备选项中不存在,则将该配置项重置为默认的选项值 23 | function fixSettings(localSettings: Settings): Settings { 24 | if (!localSettings) return localSettings; 25 | 26 | const keyAndOptions: [Key1, Option[]][] = [ 27 | ['position', POSITION_OPTIONS], 28 | ['theme', THEME_OPTIONS], 29 | ['strategy', STRATEGY_OPTIONS], 30 | ]; 31 | 32 | const settings = { ...DEFAULT_SETTINGS, ...localSettings }; 33 | 34 | for (const [key, opts] of keyAndOptions) { 35 | if (opts.some((o) => o.value === localSettings[key])) continue; 36 | settings[key] = DEFAULT_SETTINGS[key]; 37 | } 38 | 39 | return settings; 40 | } 41 | 42 | // 监听安装、更新等事件 43 | addInstalledListener((details: { reason: string }) => { 44 | if (details.reason === INSTALL_REASON) { 45 | // 安装时保存默认配置 46 | void saveSettings(DEFAULT_SETTINGS).then(); 47 | } else if (details.reason === UPDATE_REASON) { 48 | // 更新时将变更的配置项重置为默认值 49 | void loadSettings().then((st) => { 50 | const fst = fixSettings(st); 51 | void saveSettings(fst).then(); 52 | }); 53 | } 54 | }); 55 | 56 | // 监听快捷键指令事件 57 | addCommandListener((name: string, tab: Tab) => { 58 | if (name === MSG_NAMES.TOGGLE_TOC) sendTabMessage(tab, MSG_NAMES.TOGGLE_TOC); 59 | }); 60 | 61 | // 监听插件按钮点击事件 62 | addClickActionListener((tab: Tab) => { 63 | sendTabMessage(tab, MSG_NAMES.TOGGLE_TOC); 64 | }); 65 | 66 | type Option = { label: string; value: string }; 67 | 68 | type Key1 = keyof Omit; 69 | -------------------------------------------------------------------------------- /src/content-utils/heading-std-util.ts: -------------------------------------------------------------------------------- 1 | import { queryAll } from './dom-util'; 2 | import { HEADING_SELECTORS, SYMBOL } from '../shared/constants'; 3 | 4 | // 根据标准与网页自带锚点获取heading 5 | export function filterOfficialHeadings(headings: HTMLElement[]) { 6 | const selectorCountMap = inferHeadingSelectorMap(); 7 | const selectors = [...selectorCountMap.keys()]; 8 | headings = filterStandardHeadings(headings); 9 | 10 | // 过滤出既满足标准,又存在页内超链接的 heading 11 | let c1 = 0; 12 | const counts: number[] = []; 13 | headings = headings.filter((node) => { 14 | const selector = selectors.find((s) => node.matches(s)); 15 | if (!selector) return false; 16 | 17 | const count = selectorCountMap.get(selector) || 0; 18 | counts.push(count); 19 | if (count === 1) c1 += 1; 20 | 21 | return true; 22 | }); 23 | 24 | const rate = c1 / counts.length; 25 | if (rate < 0.2 && c1 < 10) { 26 | headings = headings.filter((_, i) => counts[i] > 1); 27 | } 28 | 29 | return headings.length > 1 ? headings : []; 30 | } 31 | 32 | // 根据标准获取heading 33 | function filterStandardHeadings(headings: HTMLElement[]) { 34 | const headingSelector = HEADING_SELECTORS.join(SYMBOL.COMMA); 35 | // 标准:提取自身或子孙节点带有 id,name 属性的所有heading 36 | const exactSelector = `:is(${headingSelector}):is([id],[name],:has([id],[name]))`; 37 | 38 | return headings.filter((n) => n.matches(exactSelector)); 39 | } 40 | 41 | // 根据所有 页内超链接 推断出所有可能的 heading 选择器 42 | function inferHeadingSelectorMap() { 43 | const anchors = queryAll('a[href*="#"]') as HTMLAnchorElement[]; 44 | const selectorMap = new Map(); 45 | 46 | for (const node of anchors) { 47 | const href = (node.getAttribute('href') || '').trim(); 48 | const id = hrefToId(href); 49 | const s = `#${id},[name='${id}']`; 50 | const selector = `:is(${s},:has(${s}))`; 51 | 52 | selectorMap.set(selector, (selectorMap.get(selector) || 0) + 1); 53 | } 54 | 55 | return selectorMap; 56 | } 57 | 58 | function hrefToId(href: string) { 59 | const str = href.replace(/^[^#]*#/, ''); 60 | return CSS.escape(str); 61 | } 62 | -------------------------------------------------------------------------------- /scripts/dev/nodemon.js: -------------------------------------------------------------------------------- 1 | const nodemon = require('nodemon'); 2 | const { buildDev } = require('../build'); 3 | const { openBrowser, openPage } = require('./browser'); 4 | const pkg = require('../../package.json'); 5 | 6 | const EXT_URL = 'chrome://extensions'; 7 | const EXT_TAG = 'extensions-manager'; 8 | 9 | async function startExtension() { 10 | const browser = await openBrowser(); 11 | const extPage = await openPage(browser, EXT_URL); 12 | const extInfo = await extPage.$eval( 13 | EXT_TAG, 14 | (node, name) => { 15 | if (node) return node.extensions_.filter((e) => e.name === name)[0]; 16 | }, 17 | pkg.extName, 18 | ); 19 | 20 | return [browser, { extPage }, extInfo]; 21 | } 22 | 23 | async function reloadExtension(browser, pages) { 24 | await pages.extPage.$eval(EXT_TAG, (node) => { 25 | if (node) return node.delegate.updateAllExtensions(node.extensions_); 26 | }); 27 | } 28 | 29 | let browser, pages, extInfo; 30 | 31 | function startNodemon(startHandler, reloadHandler, reloadExtPage) { 32 | nodemon({ 33 | exec: 'echo', 34 | ext: '*', 35 | delay: 2000, 36 | watch: ['src', 'scripts', 'public', 'test'], 37 | ignore: ['.git/', '**/node_modules/', 'dist/'], 38 | }); 39 | 40 | nodemon 41 | .on('start', async () => { 42 | if (browser) return; 43 | 44 | await buildDev(true); 45 | 46 | [browser, pages, extInfo] = await startExtension(); 47 | if (startHandler) await startHandler(browser, pages, extInfo); 48 | 49 | console.log(`🔔 Pages and extensions loaded at ${new Date().toTimeString()}`); 50 | }) 51 | .on('restart', async (files) => { 52 | console.log('🔔 nodemon restarted due to: ', files); 53 | 54 | await buildDev(); 55 | 56 | if (reloadExtPage) await reloadExtension(browser, pages); 57 | if (reloadHandler) await reloadHandler(browser, pages, extInfo); 58 | 59 | console.log(`🔔 Pages and extensions reloaded at ${new Date().toTimeString()}`); 60 | }) 61 | .on('quit', () => { 62 | console.log('🔔 nodemon has quit'); 63 | process.exit(); 64 | }); 65 | } 66 | 67 | module.exports = { startNodemon }; 68 | -------------------------------------------------------------------------------- /src/content-utils/header-util.ts: -------------------------------------------------------------------------------- 1 | import { FIXED_POSITIONS, HEADER_HEIGHT } from '../shared/constants'; 2 | 3 | export function getFixedHeaderHeight() { 4 | const bottom = locateHeaderBottom(); 5 | return 20 + Math.min(bottom > 0 ? bottom : HEADER_HEIGHT, 2 * HEADER_HEIGHT); 6 | } 7 | 8 | function locateHeaderBottom() { 9 | let startY = 0; 10 | let headerBottom = -1000; 11 | const step = 10; 12 | const endY = Math.min(window.innerHeight / 2, 200); 13 | 14 | while (startY < endY) { 15 | startY += step; 16 | const newHeader = detectHeader(startY); 17 | 18 | if (!newHeader) continue; 19 | 20 | const newBottom = newHeader.getBoundingClientRect().bottom; 21 | if (headerBottom < newBottom) headerBottom = newBottom; 22 | } 23 | 24 | return headerBottom; 25 | } 26 | 27 | function detectHeader(y: number) { 28 | const nodes = document.elementsFromPoint(window.innerWidth / 2, y) as HTMLElement[]; 29 | 30 | for (let node, i = nodes.length - 1; i > -1; i--) { 31 | node = nodes[i]; 32 | 33 | // 退出:样式名包含mask、modal 34 | const isMask = /mask|modal|dialog/i.test(node.className); 35 | if (isMask) break; 36 | 37 | // 退出:全屏浮窗 38 | const isFullScreen = node.scrollWidth >= window.innerWidth && node.scrollHeight >= window.innerHeight; 39 | const style = getComputedStyle(node); 40 | const isFixed = FIXED_POSITIONS.includes(style.position); 41 | if (isFullScreen && isFixed) break; 42 | 43 | if (isFixedHeader(node)) return node; 44 | } 45 | 46 | return null; 47 | } 48 | 49 | function isFixedHeader(node: HTMLElement) { 50 | if (!node) return false; 51 | 52 | // header选择器容易误判,因此不作为判定条件 53 | // const hasHeaderSelector = /head(er)?/i.test(`${node.tagName} ${node.className}`); 54 | 55 | const style = getComputedStyle(node); 56 | const isFixed = FIXED_POSITIONS.includes(style.position); 57 | const isUpper = +style.zIndex > 10; 58 | 59 | const ltHalfHeight = node.scrollHeight < Math.max(window.innerHeight / 2, 200); 60 | const gtQuarterWidth = node.scrollWidth > Math.min(window.innerWidth / 4, 2000); 61 | 62 | return (isFixed || isUpper) && ltHalfHeight && gtQuarterWidth; 63 | } 64 | -------------------------------------------------------------------------------- /src/content-utils/scroll-util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 提取滚动节点 3 | */ 4 | export function getScrollingNode(): HTMLElement { 5 | const se = document.scrollingElement; 6 | if (se?.scrollTop) return se as HTMLElement; 7 | 8 | const SCROLL_ATTRS = ['auto', 'scroll', 'overlay']; 9 | 10 | let node = document.activeElement; 11 | while (node) { 12 | if (node.scrollTop) break; 13 | 14 | const style = window.getComputedStyle(node); 15 | if (SCROLL_ATTRS.includes(style.overflowY) && node.scrollHeight > node.clientHeight) break; 16 | 17 | node = node.parentElement; 18 | } 19 | 20 | if (node === document.body) node = null; 21 | 22 | return (node || se || document.documentElement || document.body) as HTMLElement; 23 | } 24 | 25 | /** 26 | * 使用 scroll api 进行滚动 27 | * @param headingNode 28 | * @param fixedHeaderHeight 29 | */ 30 | export function scrollByApi(headingNode: HTMLElement, fixedHeaderHeight: number) { 31 | const scrollingNode = getScrollingNode(); 32 | scrollingNode.style.scrollBehavior = 'auto'; 33 | 34 | headingNode.style.scrollMarginTop = '0'; 35 | headingNode.style.scrollPaddingTop = '0'; 36 | 37 | headingNode.scrollIntoView({ block: 'start' }); 38 | scrollingNode.scrollBy(0, -fixedHeaderHeight); 39 | markCurrentHeading(headingNode); 40 | } 41 | 42 | /** 43 | * 借助 hashchange 进行滚动 44 | * @param headingNode 45 | * @param fixedHeaderHeight 46 | */ 47 | export function scrollByHash(headingNode: HTMLElement, fixedHeaderHeight: number) { 48 | const scrollingNode = getScrollingNode(); 49 | scrollingNode.style.scrollBehavior = 'auto'; 50 | 51 | // 解决 fixed header 遮挡锚点的问题 52 | headingNode.style.scrollMarginTop = `${fixedHeaderHeight}px`; 53 | headingNode.style.scrollPaddingTop = `${fixedHeaderHeight}px`; 54 | 55 | window.location.hash = headingNode.id; 56 | // scrollingNode.scrollBy(0, -fixedHeaderHeight); 57 | markCurrentHeading(headingNode); 58 | } 59 | 60 | function markCurrentHeading(headingNode: HTMLElement) { 61 | const styleName = 'onetoc-current-heading'; 62 | if (headingNode.classList.contains(styleName)) return; 63 | 64 | headingNode.classList.add(styleName); 65 | setTimeout(() => headingNode.classList.remove(styleName), 500); 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "one-toc", 3 | "extName": "OneToc", 4 | "version": "1.5.3", 5 | "description": "为技术文档、技术博客等网站添加导航目录的浏览器插件,提供更好的阅读体验。", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "node scripts/cli.js dev", 9 | "dev:content": "node scripts/cli.js dev:content", 10 | "dev:options": "node scripts/cli.js dev:options", 11 | "dev:popup": "node scripts/cli.js dev:popup", 12 | "build": "node scripts/cli.js build", 13 | "build:dev": "node scripts/cli.js build:dev", 14 | "zip": "node scripts/cli.js zip", 15 | "release": "dotenv -e .env.local release-it --", 16 | "test": "npm run build:dev && jest", 17 | "lint": "eslint src/" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/Whilconn/one-toc.git" 22 | }, 23 | "keywords": [], 24 | "author": "whilconn", 25 | "license": "ISC", 26 | "bugs": { 27 | "url": "https://github.com/Whilconn/one-toc/issues" 28 | }, 29 | "homepage": "https://github.com/Whilconn/one-toc", 30 | "dependencies": { 31 | "antd": "^4.22.8", 32 | "interactjs": "^1.10.18", 33 | "micromatch": "^4.0.5", 34 | "react": "^18.0.0", 35 | "react-dom": "^18.0.0" 36 | }, 37 | "devDependencies": { 38 | "@commitlint/cli": "^17.0.1", 39 | "@commitlint/config-conventional": "^17.0.0", 40 | "@interactjs/types": "^1.10.18", 41 | "@types/jest": "^28.1.7", 42 | "@types/micromatch": "^4.0.6", 43 | "@types/node": "^18.7.4", 44 | "@types/react": "^18.0.0", 45 | "@types/react-dom": "^18.0.0", 46 | "@typescript-eslint/eslint-plugin": "^5.59.1", 47 | "@typescript-eslint/parser": "^5.59.1", 48 | "@vitejs/plugin-react": "^1.3.0", 49 | "chrome-location": "^1.2.1", 50 | "dotenv-cli": "^7.0.0", 51 | "eslint": "^8.39.0", 52 | "eslint-plugin-react": "^7.32.2", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "glob": "^8.0.3", 55 | "husky": "^8.0.1", 56 | "jest": "^28.1.3", 57 | "jszip": "^3.10.1", 58 | "less": "^4.1.2", 59 | "nodemon": "^2.0.19", 60 | "prettier": "^2.6.2", 61 | "puppeteer-core": "^16.1.1", 62 | "release-it": "^15.6.0", 63 | "typescript": "^5.0.4", 64 | "vite": "^4.3.9" 65 | }, 66 | "engines": { 67 | "node": ">=19.2", 68 | "npm": ">=9.5" 69 | }, 70 | "volta": { 71 | "node": "19.2.0", 72 | "npm": "9.5.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/content-view/content.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM, { Root } from 'react-dom/client'; 3 | import * as micromatch from 'micromatch'; 4 | import { Toc } from './toc'; 5 | import { CID, MSG_NAMES } from '../shared/constants'; 6 | import { ErrorBoundary } from '../shared/error-boundary'; 7 | import { addMessageListener, Message } from '../extension-utils/api'; 8 | import { loadSettings, Settings } from '../extension-utils/settings'; 9 | import { splitTextByLine } from '../content-utils/text-util'; 10 | import { updateResolveRules } from '../shared/resolve-rules-util'; 11 | 12 | let visible = false; 13 | let reactRoot: Root | null = null; 14 | 15 | function getRoot() { 16 | const container = document.getElementById(CID) || document.createElement('div'); 17 | if (!container.isConnected) { 18 | container.id = CID; 19 | container.classList.add('onetoc-root'); 20 | document.documentElement.append(container); 21 | } 22 | 23 | if (!reactRoot) reactRoot = ReactDOM.createRoot(container); 24 | 25 | return reactRoot; 26 | } 27 | 28 | function hideToc() { 29 | const root = getRoot(); 30 | visible = false; 31 | 32 | root.render(<>); 33 | } 34 | 35 | function showToc() { 36 | const root = getRoot(); 37 | visible = true; 38 | 39 | root.render( 40 | 41 | 42 | 43 | 44 | , 45 | ); 46 | } 47 | 48 | function toggleToc() { 49 | visible ? hideToc() : showToc(); 50 | } 51 | 52 | // 监听后台消息,用于监听开启、关闭快捷键 Command+B 53 | addMessageListener((msg: Message) => { 54 | if (msg.name === MSG_NAMES.TOGGLE_TOC) toggleToc(); 55 | }); 56 | 57 | /** 自动打开相关逻辑 **/ 58 | function findAutoOpenRule(settings: Settings): [string, number] | void { 59 | if (!settings.autoOpenRules) return; 60 | 61 | const pathInUrl = location.host + location.pathname; 62 | 63 | return splitTextByLine(settings.autoOpenRules || '') 64 | .map((s) => { 65 | const [glob, t] = s.split(/\s+/); 66 | return [glob, +t] as [string, number]; 67 | }) 68 | .find(([glob]) => { 69 | return micromatch.some([location.href, pathInUrl], glob); 70 | }); 71 | } 72 | 73 | void loadSettings().then((s) => { 74 | void updateResolveRules(); 75 | 76 | const rule = findAutoOpenRule(s); 77 | if (!rule) return; 78 | 79 | if (rule[1]) return setTimeout(showToc, rule[1]); 80 | 81 | showToc(); 82 | }); 83 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const vite = require('vite'); 4 | const { configFn, ROOT_ABS, DEST_ABS, PUBLIC_ABS } = require('./vite.config'); 5 | const pkg = require('../package.json'); 6 | const manifest = require('../public/manifest.json'); 7 | 8 | const MODE = { DEV: 'development', PROD: 'production' }; 9 | 10 | function clearDest() { 11 | if (fs.existsSync(DEST_ABS)) fs.rmSync(DEST_ABS, { recursive: true }); 12 | } 13 | 14 | function copyLibs(mode) { 15 | let sourceFiles = { 16 | 'react.js': 'node_modules/react/umd/react.production.min.js', 17 | 'react-dom.js': 'node_modules/react-dom/umd/react-dom.production.min.js', 18 | 'antd.js': 'node_modules/antd/dist/antd.min.js', 19 | 'antd.css': 'node_modules/antd/dist/antd.min.css', 20 | }; 21 | 22 | if (mode === MODE.DEV) { 23 | sourceFiles['react.js'] = 'node_modules/react/umd/react.development.js'; 24 | sourceFiles['react-dom.js'] = 'node_modules/react-dom/umd/react-dom.development.js'; 25 | } 26 | 27 | Object.entries(sourceFiles).forEach(([dest, src]) => { 28 | src = path.resolve(ROOT_ABS, src); 29 | dest = path.resolve(DEST_ABS, dest); 30 | fs.copyFileSync(src, dest); 31 | }); 32 | } 33 | 34 | function genManifest() { 35 | const dest = path.resolve(DEST_ABS, 'manifest.json'); 36 | const keys = ['version', 'description']; 37 | keys.forEach((k) => (manifest[k] = pkg[k])); 38 | 39 | fs.writeFileSync(dest, JSON.stringify(manifest, null, 2)); 40 | } 41 | 42 | function build(mode) { 43 | const entries = ['src/background/background.ts', 'src/content-view/content.tsx', 'src/options-view/options.tsx']; 44 | 45 | const tasks = entries.map((entry) => { 46 | const config = configFn({ mode }); 47 | // vite build({mode:'development'}) 时 process.env.NODE_ENV 仍然会被强制设置为 production,导致import.meta.env.MODE='development' 而 import.meta.env.DEV=false) 48 | // hack:强制改变 vite build 时的环境变量 process.env.NODE_ENV 49 | process.env.NODE_ENV = mode; 50 | config.build.rollupOptions.input = path.resolve(config.root, entry); 51 | return vite.build({ ...config }); 52 | }); 53 | 54 | return Promise.all(tasks); 55 | } 56 | 57 | module.exports.buildProd = async () => { 58 | clearDest(); 59 | await build(MODE.PROD); 60 | copyLibs(MODE.PROD); 61 | genManifest(); 62 | }; 63 | 64 | module.exports.buildDev = async (clear) => { 65 | if (clear) clearDest(); 66 | await build(MODE.DEV); 67 | copyLibs(MODE.DEV); 68 | genManifest(); 69 | }; 70 | -------------------------------------------------------------------------------- /src/content-view/toc-body.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import { useEventListener } from './hooks'; 3 | import { getFixedHeaderHeight } from '../content-utils/header-util'; 4 | import { scrollByApi } from '../content-utils/scroll-util'; 5 | import { Heading } from '../content-utils/heading-util'; 6 | import { TOC_LEVEL } from '../shared/constants'; 7 | import './toc-body.less'; 8 | 9 | export function TocBody({ headings }: Props) { 10 | const [current, setCurrent] = useState(0); 11 | const [activeTime, setActiveTime] = useState(0); 12 | const [headerHeight, setHeaderHeight] = useState(0); 13 | 14 | function activeLink() { 15 | const now = Date.now(); 16 | if (now - activeTime < 100) return; 17 | 18 | setActiveTime(now); 19 | 20 | let height = getFixedHeaderHeight(); 21 | height = Math.max(height, headerHeight); 22 | if (height > headerHeight) setHeaderHeight(height); 23 | 24 | const offset = 5; 25 | const idx = headings.findIndex((n, i) => { 26 | if (i === headings.length - 1) return i; 27 | 28 | const top = headings[i + 1].node.getBoundingClientRect().top; 29 | return top > height + offset; 30 | }); 31 | 32 | setCurrent(idx); 33 | } 34 | 35 | // 高亮链接:首次进入页面 36 | useEffect(activeLink, [headings]); 37 | 38 | // 高亮链接:滚动页面 39 | const memoActiveLink = useCallback(activeLink, [headings, activeTime, headerHeight]); 40 | useEventListener(window, 'scroll', memoActiveLink); 41 | 42 | function clickAnchor(i: number, anchorNode: HTMLElement) { 43 | setActiveTime(Date.now()); 44 | setCurrent(i); 45 | 46 | let height = getFixedHeaderHeight(); 47 | height = Math.max(height, headerHeight); 48 | if (height > headerHeight) setHeaderHeight(height); 49 | 50 | // scrollByHash(anchorNode, height); 51 | scrollByApi(anchorNode, height); 52 | } 53 | 54 | return ( 55 |
56 | {headings.map(({ node, level }, i) => { 57 | // 修复标题存在隐藏内容的问题 58 | // https://baike.baidu.com/item/Java%20%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80/10263609 59 | const text = (node.innerText || node.textContent || '').trim(); 60 | const cls = [`${TOC_LEVEL}${level}`, i === current ? 'active' : ''].join(' '); 61 | 62 | return ( 63 | clickAnchor(i, node)} className={cls} title={text}> 64 | {text} 65 | 66 | ); 67 | })} 68 | {!headings.length &&

暂无数据

} 69 |
70 | ); 71 | } 72 | 73 | type Props = { headings: Heading[] }; 74 | -------------------------------------------------------------------------------- /src/content-view/use-headings.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { matchResolveRule } from '../shared/resolve-rules-util'; 3 | import { Heading, resolveHeadings } from '../content-utils/heading-util'; 4 | import { DEFAULT_SETTINGS, Settings } from '../extension-utils/settings'; 5 | 6 | function groupHeadings( 7 | settings: Settings, 8 | inferredHeadings: Heading[], 9 | allHeadings: Heading[], 10 | officialHeadings: Heading[], 11 | ): Group[] { 12 | const groups: Group[] = [ 13 | { name: '自带', headings: officialHeadings }, 14 | { 15 | name: '精选', 16 | headings: inferredHeadings, 17 | }, 18 | { name: '所有', headings: allHeadings }, 19 | ]; 20 | 21 | // 默认策略不是official时,交换前两个分组位置 22 | if (settings.strategy !== DEFAULT_SETTINGS.strategy) { 23 | [groups[0], groups[1]] = [groups[1], groups[0]]; 24 | } 25 | 26 | // 两组heading相同时,仅保留下标小的分组 27 | for (let i = groups.length - 1; i > 0; i--) { 28 | for (let j = 0; j < i; j++) { 29 | const gr = groups[i]; 30 | const gl = groups[j]; 31 | if (!gr.headings.length || gr.headings.length !== gl.headings.length) continue; 32 | 33 | if (gr.headings.every((h, k) => h.node === gl.headings[k].node)) { 34 | gr.headings = []; 35 | } 36 | } 37 | } 38 | 39 | return groups; 40 | } 41 | 42 | export function useHeadings(title: string, settings: Settings | null) { 43 | const [loading, setLoading] = useState(true); 44 | const [titleChanged, setTitleChanged] = useState(true); 45 | 46 | const [group, setGroup] = useState(0); 47 | const [headingGroups, setHeadingGroups] = useState([]); 48 | 49 | useEffect(() => { 50 | setTitleChanged(true); 51 | }, [title]); 52 | 53 | useEffect(() => { 54 | // settings加载成功 或 title变化 才会触发文档解析动作 55 | const changed = settings && titleChanged; 56 | if (!changed) return; 57 | 58 | // requestIdleCallback 优化JS长任务(resolveHeadings)阻塞 React 渲染问题 59 | requestIdleCallback(() => { 60 | setLoading(true); 61 | setTitleChanged(false); 62 | 63 | const resolveRule = matchResolveRule(settings.resolveRules); 64 | const { inferredHeadings, allHeadings, officialHeadings } = resolveHeadings(resolveRule); 65 | 66 | const groups = groupHeadings(settings, inferredHeadings, allHeadings, officialHeadings); 67 | 68 | // 优先选中下标小的分组 69 | if (groups[0].headings.length) setGroup(0); 70 | else if (groups[1].headings.length) setGroup(1); 71 | else setGroup(2); 72 | 73 | setHeadingGroups(groups); 74 | setLoading(false); 75 | }); 76 | }, [title, titleChanged, settings]); 77 | 78 | return { loading, headingGroups, group, setGroup }; 79 | } 80 | 81 | type Group = { 82 | name: string; 83 | headings: Heading[]; 84 | }; 85 | -------------------------------------------------------------------------------- /src/content-view/toc.less: -------------------------------------------------------------------------------- 1 | @import "../assets/variables"; 2 | 3 | .onetoc-root { 4 | position: fixed; 5 | z-index: 999999; 6 | user-select: none; 7 | } 8 | 9 | .onetoc-container { 10 | &, 11 | &[data-theme='aero'] { 12 | .var-aero(); 13 | } 14 | 15 | &[data-theme='light'] { 16 | .var-light(); 17 | } 18 | 19 | &[data-theme='dark'] { 20 | .var-dark(); 21 | } 22 | 23 | &, 24 | * { 25 | margin: 0; 26 | padding: 0; 27 | box-sizing: border-box; 28 | line-height: 1.15; 29 | color: var(--onetoc-color); 30 | font-size: 14px; 31 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, 32 | Helvetica Neue, sans-serif; 33 | font-style: normal; 34 | border: none; 35 | } 36 | 37 | position: fixed; 38 | display: flex; 39 | flex-direction: column; 40 | height: auto; 41 | width: var(--onetoc-width); 42 | top: 100px; 43 | left: calc(100vw - 20px - var(--onetoc-width)); 44 | text-align: left; 45 | border-radius: 5px; 46 | background: var(--onetoc-bg-color); 47 | backdrop-filter: var(--onetoc-bg-filter); 48 | box-shadow: 0 0 2px 0 #ccc; 49 | overflow: hidden; 50 | z-index: 999999; 51 | 52 | &.onetoc-fixed-left { 53 | left: 20px; 54 | } 55 | 56 | &.onetoc-embed { 57 | top: 0; 58 | left: 0; 59 | bottom: 0; 60 | height: 100vh; 61 | max-height: 100vh; 62 | border-radius: 0; 63 | } 64 | 65 | a { 66 | color: var(--onetoc-color); 67 | text-decoration: none; 68 | outline: 0; 69 | text-overflow: ellipsis; 70 | overflow: hidden; 71 | white-space: nowrap; 72 | border: none; 73 | background: none; 74 | cursor: pointer; 75 | 76 | &:hover { 77 | color: #007fff; 78 | } 79 | 80 | &.active { 81 | color: #007fff; 82 | font-weight: bold; 83 | } 84 | } 85 | } 86 | 87 | // toc head styles 88 | .onetoc-container { 89 | .onetoc-head { 90 | display: flex; 91 | align-items: center; 92 | border-bottom: .5px solid #e4e6eb; 93 | overflow: visible; 94 | 95 | .onetoc-title { 96 | flex: 1; 97 | padding-left: 15px; 98 | font-weight: bold; 99 | text-overflow: ellipsis; 100 | overflow: hidden; 101 | white-space: nowrap; 102 | } 103 | 104 | .onetoc-new { 105 | margin-right: 8px; 106 | padding: 2px 4px; 107 | color: #fff; 108 | font-size: 12px; 109 | background: #e22; 110 | border-radius: 4px; 111 | } 112 | 113 | .onetoc-close-icon { 114 | padding: 12px; 115 | line-height: 0; 116 | fill: var(--onetoc-color); 117 | cursor: pointer; 118 | 119 | &:hover { 120 | fill: #007fff; 121 | } 122 | } 123 | } 124 | } 125 | 126 | // global styles 127 | html { 128 | --onetoc-width: 300px; 129 | 130 | .onetoc-current-heading { 131 | box-shadow: 0 2px 0 #007fff !important; 132 | } 133 | 134 | body[onetoc-embed-mod] { 135 | padding-left: var(--onetoc-width); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/extension-utils/api.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | export function getAllCommands(): Promise { 4 | return chrome.commands 5 | .getAll() 6 | .then((commands: any) => { 7 | if (import.meta.env.DEV) console.log('[getAllCommands Success]:', commands); 8 | return commands; 9 | }) 10 | .catch((error: any) => { 11 | if (import.meta.env.DEV) console.error('[getAllCommands Error]:', error); 12 | }); 13 | } 14 | 15 | export function loadStorage(keys: string | string[]) { 16 | return chrome.storage.local 17 | .get(keys) 18 | .then((settings: any) => { 19 | if (import.meta.env.DEV) console.log('[loadStorage Success]:', settings); 20 | return settings; 21 | }) 22 | .catch((error: any) => { 23 | if (import.meta.env.DEV) console.error('[loadStorage Error]:', error); 24 | }); 25 | } 26 | 27 | export function saveStorage(settings: any) { 28 | return chrome.storage.local 29 | .set(settings) 30 | .then((settings: any) => { 31 | if (import.meta.env.DEV) console.log('[saveStorage Success]:', settings); 32 | }) 33 | .catch((error: any) => { 34 | if (import.meta.env.DEV) console.error('[saveStorage Error]:', error); 35 | }); 36 | } 37 | 38 | export function createTab(url: string) { 39 | chrome.tabs 40 | .create({ url }) 41 | .then((tab: Tab) => { 42 | if (import.meta.env.DEV) console.log('[createTab Success]:', tab.id); 43 | }) 44 | .catch((error: any) => { 45 | if (import.meta.env.DEV) console.error('[createTab Error]:', error); 46 | }); 47 | } 48 | 49 | export function sendTabMessage(tab: Tab, name: string) { 50 | chrome.tabs 51 | .sendMessage(tab.id, { name }) 52 | .then((response: any) => { 53 | if (import.meta.env.DEV) console.log('[sendTabMessage Success]:', response); 54 | }) 55 | .catch((error: any) => { 56 | if (import.meta.env.DEV) console.error('[sendTabMessage Error]:', error); 57 | }); 58 | } 59 | 60 | export function addInstalledListener(callback: (details: { reason: string }) => void) { 61 | chrome.runtime.onInstalled.addListener(callback); 62 | } 63 | 64 | export function addMessageListener(callback: (msg: Message) => void) { 65 | chrome.runtime.onMessage.addListener(callback); 66 | } 67 | 68 | async function getCurrentTab() { 69 | const opt = { active: true, currentWindow: true }; 70 | const [tab] = await chrome.tabs.query(opt); 71 | return tab; 72 | } 73 | 74 | export function addCommandListener(callback: (name: string, tab: Tab) => void) { 75 | chrome.commands.onCommand.addListener(async (name: string, tab: Tab) => { 76 | if (!tab?.id) tab = await getCurrentTab(); 77 | await callback(name, tab); 78 | }); 79 | } 80 | 81 | export function addClickActionListener(callback: (tab: Tab) => void) { 82 | chrome.action.onClicked.addListener(async (tab: Tab) => { 83 | if (!tab?.id) tab = await getCurrentTab(); 84 | await callback(tab); 85 | }); 86 | } 87 | 88 | export const INSTALL_REASON = chrome.runtime.OnInstalledReason.INSTALL; 89 | export const UPDATE_REASON = chrome.runtime.OnInstalledReason.UPDATE; 90 | 91 | /** types */ 92 | export interface Command { 93 | name: string; 94 | shortcut: string; 95 | description: string; 96 | } 97 | 98 | export interface Message { 99 | name: string; 100 | payload?: any; 101 | } 102 | 103 | export interface Tab { 104 | id: number; 105 | url: string; 106 | title: string; 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OneToc 2 | 3 | ![react](https://badges.aleen42.com/src/react.svg) 4 | ![typescript](https://badges.aleen42.com/src/typescript.svg) 5 | ![vite](https://badges.aleen42.com/src/vitejs.svg) 6 | ![jest](https://badges.aleen42.com/src/jest_1.svg) 7 | ![eslint](https://badges.aleen42.com/src/eslint.svg) 8 | 9 | 生成网页大纲,提供清晰的结构和便捷的导航,提升阅读体验和效率。适用于新闻、博客、教程、文档、论文等各种网页。 10 | 11 |
12 | 13 | Generating a table of contents on web page. Whether it’s news, blogs, tutorials, documents or essays. Providing a clear structure and convenient navigation to help you improve reading experience and efficiency. 14 | 15 | ## 快速开始 16 | 17 | ### Edge 浏览器安装 18 | 19 | 打开[Edge插件商店](https://microsoftedge.microsoft.com/addons/detail/onetoc/jkgapfniamkoblbmbhdjlnfklihlpjmc)页面,点击获取进行安装 20 | 21 | ### Chrome 浏览器安装 22 | 23 | - 下载[zip包](https://github.com/Whilconn/one-toc/releases)(注意下载的是包名形式为 OneToc-vx.x.x.zip 的包,不要下载Source Code包) 24 | - 解压到任意目录,如 `~/one-toc/` 25 | - 在浏览器打开 chrome://extensions/ 页面 26 | - 点击页面右上角开启 `开发者模式` 27 | - 点击页面左上角 `加载已解压的扩展程序`,最后选择上述解压目录 28 | 29 | ### 使用步骤 30 | 31 | - 打开任意网页 32 | - 生成目录:只需要按下快捷键 `Ctrl+B` 或 `Command+B` (Mac),也可以单击地址栏右侧的 `OneToc` 插件图标 33 | - 页内跳转:点击目录中的任意一个标题,就可以跳转到对应的内容位置 34 | 35 | ### 效果 36 | 37 | ![screenshots](screenshots/content.png) 38 | 39 | ## 特性 40 | 41 | - 可以反映文档的逻辑层次和内容重点,方便快速浏览全文结构和定位章节、理解文档的主旨和目的 42 | - 可以作为文档的导航工具,提高阅读文档的效率 43 | - 可以提取网页中的标签化标题:包括 `H1 ~ H6`、`b`、`strong` 等 `HTML` 标签 44 | - 可以提取网页中的加粗标题、序号标题 45 | - 提供自带、精选、所有等3种策略的解析结果以供选择 46 | - 点击目录标题可快速跳转到对应内容 47 | - 页面滚动时自动高亮当前目录标题 48 | - 支持多层级目录 49 | - 支持浅色、深色主题 50 | - 支持浮动、内嵌的定位方式 51 | - 支持自由拖拽 52 | - 支持快捷键开关 53 | - 在本地分析网页内容并生成目录,不会收集或上传任何个人信息和浏览数据 54 | 55 | ## 配置说明 56 | 57 | - 1、右键点击地址栏右侧的 `OneToc` 图标 58 | - 2、点击弹出菜单中的选项按钮打开配置页面 59 | - 3、修改配置后点击保存按钮会立即生效 60 | 61 | ![options](screenshots/open-options-page.png) 62 | 63 | ![options](screenshots/options-page.png) 64 | 65 | ### 主题 66 | 67 | 插件提供默认、浅色、深色3种主题 68 | 69 | ### 定位 70 | 71 | 插件提供浮动、嵌入2种定位方式 72 | 73 | - 浮动定位:默认选项,浮动于内容上方,可能会遮挡网页内容,可自由拖拽 74 | - 嵌入定位:嵌入网页左侧,将网页内容整体右移,不会遮挡网页内容,不可拖拽 75 | 76 | > 嵌入效果与 `vscode` 左侧目录边栏类似,同样使用 `Command+B` 快捷键开启或关闭 77 | 78 | ### 优先显示 79 | 80 | 插件提供自带、精选和所有3种解析策略,可选择优先显示自带或精选。 81 | 82 | 自带是指网页已标识且符合规范的目录节点,精选是指程序筛选出更优的目录节点,所有则包含了自带、精选以及其他可能的目录节点。 83 | 84 | - 自带:默认选项,优先显示自带目录 85 | - 精选:优先显示精选目录 86 | 87 | ### 快捷键 88 | 89 | - 显示目录的快捷键默认为 `Command+B` (Mac) 或 `Ctrl+B` (windows/linux) 90 | - 可点击 `去设置` 跳转到快捷键设置页面自行修改 91 | 92 | ### 自动打开规则 93 | 94 | 该配置项可以指定在哪些网页自动显示目录。该配置项是多行文本,每一行是一个匹配规则(必选)和一个毫秒数(可选),二者使用空格隔开。符合匹配规则的页面打开后会自动显示目录,如果配置了毫秒数,则会在页面加载完成后等待若干毫秒再自动显示目录。如果存在多条匹配规则与当前页面链接匹配,只会应用最靠前的那条规则。 95 | 96 | 匹配规则请使用 [glob](https://en.wikipedia.org/wiki/Glob_(programming)) 编写,源代码中用于匹配的第三方库是 [micromatch](https://github.com/micromatch/micromatch)。 97 | 98 | 以下是一些匹配规则示例: 99 | 100 | - 示例规则1:`**` 匹配所有页面,即打开任意页面都会自动显示目录。 101 | - 示例规则2:`https://zhuanlan.zhihu.com/p/**` 表示匹配所有以 `https://zhuanlan.zhihu.com/p/` 开头的页面,即打开任意以 `https://zhuanlan.zhihu.com/p/` 开头的页面都会自动显示目录。 102 | - 示例规则3:`** 1000` 匹配所有页面并且延迟 `1000ms` 自动显示目录,即打开任意页面 `1000ms` 后都会自动显示目录。延迟配置常用于解决 `Ajax` 加载数据导致解析目录为空的问题,大部分场景不需要。 103 | 104 | ## 适用范围 105 | 106 | 目前只支持 `Edge` 和 `Chrome` 浏览器,在绝大部分文字为主的网页上都能使用。 107 | 108 | ## 感谢 109 | 110 | 感谢 JetBrains 提供的开源开发许可证支持!
111 | 112 | JetBrains Logo (Main) logo.  WebStorm logo. 113 | 114 | ## License 115 | 116 | [MIT](./LICENSE) 117 | 118 | Copyright (c) 2022-present, Whilconn 119 | -------------------------------------------------------------------------------- /src/content-view/use-drag-resize.ts: -------------------------------------------------------------------------------- 1 | import interact from 'interactjs'; 2 | import { useEffect } from 'react'; 3 | import { Element } from '@interactjs/core/types'; 4 | import { DragEvent } from '@interactjs/actions/drag/plugin'; 5 | import { ResizeEvent } from '@interactjs/actions/resize/plugin'; 6 | 7 | export function useDragResize(opts: Options) { 8 | useEffect(() => { 9 | const { containerSelector, dragSelector, dragDisabled, resizeDisabled } = opts; 10 | 11 | const MW = window.innerWidth; 12 | const MH = window.innerHeight; 13 | 14 | /* eslint-disable */ 15 | 16 | function getTranslate(node: Element) { 17 | const transform: any = (node as any).computedStyleMap().get('transform'); 18 | if (!transform?.length) return null; 19 | 20 | const translate: any = transform.toMatrix(); 21 | return { x: translate.m41 as number, y: translate.m42 as number }; 22 | } 23 | 24 | /* eslint-enable */ 25 | 26 | function setTranslate(node: Element, x: number, y: number) { 27 | node.style.transform = `translate(${x}px, ${y}px)`; 28 | } 29 | 30 | function startHandler(node: Element) { 31 | const translate = getTranslate(node); 32 | if (!translate) setTranslate(node, 0, 0); 33 | } 34 | 35 | function calcViewportRect() { 36 | const { pageLeft, pageTop } = window.visualViewport ?? {}; 37 | return new DOMRect(pageLeft, pageTop, MW, MH); 38 | } 39 | 40 | const interactable = interact(containerSelector); 41 | 42 | interactable.draggable({ 43 | allowFrom: dragSelector, 44 | enabled: !dragDisabled, 45 | modifiers: [ 46 | interact.modifiers.restrictRect({ 47 | restriction: calcViewportRect, 48 | }), 49 | ], 50 | listeners: { 51 | start(event: DragEvent) { 52 | startHandler(event.target); 53 | }, 54 | move(event: DragEvent) { 55 | const translate = getTranslate(event.target); 56 | if (!translate) return; 57 | 58 | const { x, y } = translate; 59 | setTranslate(event.target, x + event.dx, y + event.dy); 60 | }, 61 | }, 62 | }); 63 | 64 | // Warning: box-sizing 需要设置为 border-box!否则会出现宽高跳跃性变化! 65 | interactable.resizable({ 66 | enabled: !resizeDisabled, 67 | edges: { left: true, right: true, top: true, bottom: true }, 68 | modifiers: [ 69 | interact.modifiers.restrictSize({ 70 | min: { width: 300, height: 100 }, 71 | max: { width: 0.9 * MW, height: 0.9 * MH }, 72 | }), 73 | interact.modifiers.restrictEdges({ 74 | outer: calcViewportRect, 75 | }), 76 | ], 77 | listeners: { 78 | start(event: ResizeEvent) { 79 | startHandler(event.target); 80 | }, 81 | move: function (event: ResizeEvent) { 82 | const translate = getTranslate(event.target); 83 | if (!translate) return; 84 | 85 | // update width and height 86 | Object.assign(event.target.style, { 87 | width: `${event.rect.width}px`, 88 | height: `${event.rect.height}px`, 89 | }); 90 | 91 | // update translate 92 | let { x, y } = translate; 93 | x += event.deltaRect?.left || 0; 94 | y += event.deltaRect?.top || 0; 95 | 96 | setTranslate(event.target, x, y); 97 | }, 98 | }, 99 | }); 100 | 101 | return () => interactable.unset(); 102 | }, [opts]); 103 | } 104 | 105 | type Options = { 106 | containerSelector: string; 107 | dragSelector: string; 108 | dragDisabled: boolean; 109 | resizeDisabled: boolean; 110 | }; 111 | -------------------------------------------------------------------------------- /src/content-view/toc.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, useEffect, useMemo, useRef } from 'react'; 2 | import { TocBody } from './toc-body'; 3 | import { useTitle } from './hooks'; 4 | import { useHeadings } from './use-headings'; 5 | import { useSettings } from './use-settings'; 6 | import { useDragResize } from './use-drag-resize'; 7 | import { getFixedHeaderHeight } from '../content-utils/header-util'; 8 | import { changeLayout } from '../content-utils/layout-util'; 9 | import { DEFAULT_SETTINGS, POS_EMBED, saveSettings } from '../extension-utils/settings'; 10 | 11 | import pkg from '../../package.json'; 12 | import closeSvg from '../assets/close.svg?raw'; 13 | import './toc.less'; 14 | import { Skeleton } from '../shared/skeleton'; 15 | 16 | export function Toc({ hideToc }: Props) { 17 | const tocRef = useRef(null); 18 | const title = useTitle(); 19 | const settings = useSettings(); 20 | const top = useMemo(getFixedHeaderHeight, [title]); 21 | const { loading, headingGroups, group, setGroup } = useHeadings(title, settings); 22 | 23 | const isEmbed = settings?.position === POS_EMBED; 24 | 25 | // onetoc-embed | onetoc-fixed-right | onetoc-fixed-left 26 | const positionClass = `onetoc-${settings?.position ?? DEFAULT_SETTINGS.position}`; 27 | const positionStyle: CSSProperties = isEmbed ? {} : { top: `${top}px`, maxHeight: `calc(100vh - ${top}px - 20px)` }; 28 | 29 | // 内嵌模式下修改源网页的布局 30 | useEffect(() => { 31 | if (!isEmbed) return; 32 | 33 | const restoreLayout = changeLayout(); 34 | return () => restoreLayout(); 35 | }, [isEmbed]); 36 | 37 | // 定位选项变化时,重置因拖拽而改变的样式 38 | useEffect(() => { 39 | if (tocRef.current) { 40 | Object.assign(tocRef.current.style, { transform: null, width: null, height: null }); 41 | } 42 | }, [settings?.position]); 43 | 44 | function updateKnownVersion() { 45 | void saveSettings({ ...DEFAULT_SETTINGS, ...settings, knownVersion: pkg.version }).then(); 46 | } 47 | 48 | useDragResize({ 49 | containerSelector: '.onetoc-container', 50 | dragSelector: '.onetoc-head', 51 | dragDisabled: isEmbed, 52 | resizeDisabled: isEmbed, 53 | }); 54 | 55 | if (!settings) return null; 56 | 57 | return ( 58 | 97 | ); 98 | } 99 | 100 | type Props = { 101 | hideToc: () => void; 102 | }; 103 | -------------------------------------------------------------------------------- /src/content-utils/dom-util.ts: -------------------------------------------------------------------------------- 1 | import { FIXED_POSITIONS, HEADING_SELECTORS, SYMBOL } from '../shared/constants'; 2 | 3 | export function getText(node: Pick | null) { 4 | return (node?.textContent || '').trim(); 5 | } 6 | 7 | export function getRect(node: HTMLElement | Node) { 8 | if (!node) return new DOMRect(); 9 | if ((node as HTMLElement).getBoundingClientRect) return (node as HTMLElement).getBoundingClientRect(); 10 | 11 | const range = new Range(); 12 | range.selectNode(node); 13 | return range.getBoundingClientRect(); 14 | } 15 | 16 | export function getLevel(node: HTMLElement) { 17 | return +(node.tagName.replace(/[a-z]/gi, '') || HEADING_SELECTORS.length + 1) - 1; 18 | } 19 | 20 | const headSelector = HEADING_SELECTORS.join(SYMBOL.COMMA); 21 | 22 | export function isHeading(node: HTMLElement) { 23 | return node.matches(headSelector); 24 | } 25 | 26 | export function pxToNumber(val: string) { 27 | // 若单位不是px,可使用 CSS API 转换单位为px 28 | return +val.replace(/px|\s/gi, '') || 0; 29 | } 30 | 31 | // 获取视觉上的前一个节点,且文本内容不为空 32 | export function getPrevTextNode(node: HTMLElement) { 33 | while (node && node !== document.body) { 34 | if (node.previousSibling?.textContent) return node.previousSibling; 35 | node = (node.previousSibling || node.parentElement) as HTMLElement; 36 | } 37 | 38 | return null; 39 | } 40 | 41 | // 获取视觉上的下一个节点,且文本内容不为空 42 | export function getNextTextNode(node: HTMLElement) { 43 | while (node && node !== document.body) { 44 | if (node.nextSibling?.textContent) return node.nextSibling; 45 | node = (node.nextSibling || node.parentElement) as HTMLElement; 46 | } 47 | 48 | return null; 49 | } 50 | 51 | // 查找符合条件的祖先节点(包括自身) 52 | export function findAncestor(node: HTMLElement, judge: (n: HTMLElement) => boolean) { 53 | while (node && node !== document.body) { 54 | if (judge(node)) return node; 55 | node = node.parentElement as HTMLElement; 56 | } 57 | 58 | return null; 59 | } 60 | 61 | // 查找符合条件的祖先节点集合(包括自身) 62 | export function findAncestors(node: HTMLElement, judge: (n: HTMLElement) => boolean) { 63 | const nodes: HTMLElement[] = []; 64 | while (node && node !== document.body) { 65 | if (judge(node)) nodes.push(node); 66 | node = node.parentElement as HTMLElement; 67 | } 68 | 69 | return nodes; 70 | } 71 | 72 | // 查找公共祖先节点 73 | export function findCommonAncestor(nodes: HTMLElement[]) { 74 | if (nodes.length <= 1) return nodes[0]; 75 | 76 | let node = nodes[0]; 77 | while (node) { 78 | if (nodes.every((c) => node.contains(c))) return node; 79 | node = node.parentElement as HTMLElement; 80 | } 81 | 82 | return undefined; 83 | } 84 | 85 | export function isHidden(node: HTMLElement, style: CSSStyleDeclaration, rect: DOMRect, bodyRect: DOMRect) { 86 | style = style || getComputedStyle(node); 87 | rect = rect || node.getBoundingClientRect(); 88 | bodyRect = bodyRect || document.body.getBoundingClientRect(); 89 | 90 | if (style.display === 'none') return true; 91 | 92 | if (style.visibility === 'hidden' || style.visibility === 'collapse') return true; 93 | 94 | const T = 10; 95 | if (rect.width < T || rect.height < T) return true; 96 | 97 | // 只能使用左右边界进行判断,目前已知 bodyRect.top 可能一直为0、bodyRect.bottom 可能一直为可视区域高度 98 | return rect.left >= bodyRect.right || rect.right <= bodyRect.left; 99 | } 100 | 101 | export function isFixed(node: HTMLElement, style?: CSSStyleDeclaration) { 102 | style = style || getComputedStyle(node); 103 | 104 | return FIXED_POSITIONS.includes(style.position); 105 | } 106 | 107 | export function genNodePath(node: HTMLElement) { 108 | const selectors: string[] = []; 109 | 110 | while (node && node !== document.body) { 111 | node = node.parentElement as HTMLElement; 112 | const cls = [...node.classList].sort().map((c) => '.' + c); 113 | selectors.push([node.tagName, ...cls].join('')); 114 | } 115 | 116 | return selectors.reverse().join('>'); 117 | } 118 | 119 | export function queryAll(selector: string, parent: HTMLElement = document.body) { 120 | return [...parent.querySelectorAll(selector)] as HTMLElement[]; 121 | } 122 | 123 | export function genIdClsSelector(words: string[]) { 124 | return words.map((v) => `[id*=${v}]${SYMBOL.COMMA}[class*=${v}]`).join(SYMBOL.COMMA); 125 | } 126 | -------------------------------------------------------------------------------- /src/content-utils/heading-util.ts: -------------------------------------------------------------------------------- 1 | import { filterOfficialHeadings } from './heading-std-util'; 2 | import { resolveArticle } from './article-util'; 3 | import { inferHeadings } from './heading-infer-util'; 4 | import { genStyleInfo, getAllHeadings } from './heading-all-util'; 5 | import { getLevel, isHeading, pxToNumber } from './dom-util'; 6 | import { HEADING_SELECTORS } from '../shared/constants'; 7 | import { ResolveRule } from '../shared/resolve-rules'; 8 | 9 | export function resolveHeadings(resolveRule?: ResolveRule) { 10 | const articleNode = resolveArticle(resolveRule); 11 | const { hTagHeadings, bTagHeadings, styleHeadings, semanticHeadings, oneLineHeadings, ruleHeadings } = getAllHeadings( 12 | articleNode, 13 | resolveRule, 14 | ); 15 | 16 | const { styleMap, rectMap } = genStyleInfo([ 17 | ...hTagHeadings, 18 | ...bTagHeadings, 19 | ...styleHeadings, 20 | ...semanticHeadings, 21 | ...oneLineHeadings, 22 | ...ruleHeadings, 23 | ]); 24 | 25 | // 自带标题 26 | const officialHeadings = filterOfficialHeadings(hTagHeadings); 27 | 28 | // 所有标题 29 | let allHeadings: HTMLElement[] = mergeAllHeadings([...hTagHeadings, ...bTagHeadings, ...ruleHeadings]); 30 | 31 | // 精选标题:默认使用h1~h6、b、strong作为精选标题 32 | const tagHeadings = [...allHeadings]; 33 | let inferredHeadings: HTMLElement[] = inferHeadings(articleNode, tagHeadings, styleMap, rectMap); 34 | 35 | const MIN = 1; 36 | // 使用加粗、大号字作为精选标题 37 | if (inferredHeadings.length <= MIN) { 38 | const tempHeadings = dropChildren(styleHeadings); 39 | allHeadings = mergeAllHeadings([...allHeadings, ...tempHeadings]); 40 | inferredHeadings = inferHeadings(articleNode, tempHeadings, styleMap, rectMap); 41 | } 42 | 43 | // 使用带序号文字作为精选标题 44 | if (inferredHeadings.length <= MIN) { 45 | const tempHeadings = dropChildren(semanticHeadings); 46 | allHeadings = mergeAllHeadings([...allHeadings, ...tempHeadings]); 47 | inferredHeadings = inferHeadings(articleNode, tempHeadings, styleMap, rectMap); 48 | } 49 | 50 | // 使用单行文本作为精选标题 51 | if (inferredHeadings.length <= MIN) { 52 | const tempHeadings = dropSiblings(dropChildren(oneLineHeadings)); 53 | allHeadings = mergeAllHeadings([...allHeadings, ...tempHeadings]); 54 | inferredHeadings = inferHeadings(articleNode, tempHeadings, styleMap, rectMap); 55 | } 56 | 57 | return { 58 | allHeadings: attachLevel(allHeadings, styleMap), 59 | officialHeadings: attachLevel(officialHeadings, styleMap), 60 | inferredHeadings: attachLevel(inferredHeadings, styleMap), 61 | }; 62 | } 63 | 64 | function mergeAllHeadings(nodes: HTMLElement[]) { 65 | nodes = nodes.sort((a, b) => { 66 | if (a === b) return 0; 67 | // Node.DOCUMENT_POSITION_FOLLOWING, Node.DOCUMENT_POSITION_CONTAINED_BY 68 | return [4, 16].includes(a.compareDocumentPosition(b)) ? -1 : 1; 69 | }); 70 | 71 | return dropChildren(nodes); 72 | } 73 | 74 | // 过滤子孙节点 75 | function dropChildren(nodes: HTMLElement[]) { 76 | const stack: HTMLElement[] = []; 77 | nodes.forEach((node) => { 78 | if (!stack.at(-1)?.contains(node)) stack.push(node); 79 | }); 80 | return stack; 81 | } 82 | 83 | // 过滤兄弟节点 84 | function dropSiblings(nodes: HTMLElement[]) { 85 | const stack: HTMLElement[] = []; 86 | nodes.forEach((node) => { 87 | if (stack.at(-1)?.nextElementSibling !== node) stack.push(node); 88 | }); 89 | return stack; 90 | } 91 | 92 | function attachLevel(nodes: HTMLElement[], styleMap: WeakMap): Heading[] { 93 | let minLevel = Infinity; 94 | let maxFontSize = -Infinity; 95 | const nodeLevels: NodeLevel[] = nodes.map((node): NodeLevel => { 96 | const style = styleMap.get(node); 97 | const fontsize = style ? pxToNumber(style.fontSize) : -1; 98 | const h = isHeading(node); 99 | const level = h ? getLevel(node) : Infinity; 100 | 101 | minLevel = Math.min(level, minLevel); 102 | maxFontSize = Math.max(fontsize, maxFontSize); 103 | 104 | return { node, fontsize, level, isHeading: h }; 105 | }); 106 | 107 | const hasHeading = minLevel < HEADING_SELECTORS.length; 108 | if (!hasHeading) minLevel = 0; 109 | 110 | const stack: Omit[] = [{ fontsize: maxFontSize, level: minLevel, isHeading: hasHeading }]; 111 | return nodeLevels.map(({ node, fontsize, level, isHeading }): Heading => { 112 | let computedLevel = HEADING_SELECTORS.length; 113 | 114 | while (stack.length) { 115 | const st = stack[stack.length - 1]; 116 | 117 | if (isHeading) { 118 | computedLevel = level - minLevel; 119 | if (st.level <= level) break; 120 | } else { 121 | const isParent = st.isHeading || st.fontsize > fontsize; 122 | computedLevel = st.level + (isParent ? 1 : 0); 123 | if (isParent || st.fontsize === fontsize) break; 124 | } 125 | 126 | stack.pop(); 127 | } 128 | 129 | stack.push({ fontsize, isHeading, level: computedLevel }); 130 | 131 | return { node, level: computedLevel }; 132 | }); 133 | } 134 | 135 | type NodeLevel = { node: HTMLElement; fontsize: number; isHeading: boolean; level: number }; 136 | 137 | export type Heading = Pick; 138 | -------------------------------------------------------------------------------- /src/content-utils/article-util.ts: -------------------------------------------------------------------------------- 1 | import { genIdClsSelector, getRect, isHidden, queryAll } from './dom-util'; 2 | import { ResolveRule } from '../shared/resolve-rules'; 3 | import { DISPLAY, NODE_NAME, NOISE_SELECTORS, NOISE_WORDS, POSITION, SYMBOL } from '../shared/constants'; 4 | 5 | export function resolveArticle(resolveRule?: ResolveRule): HTMLElement { 6 | const bodyRect = getRect(document.body); 7 | const mainNode: HTMLElement = searchMainNode(bodyRect) || document.body; 8 | 9 | // 根据配置获取文章节点 10 | let articleNode = resolveRule?.article ? queryAll(resolveRule?.article)[0] : null; 11 | 12 | // 找唯一的 article 标签节点 13 | if (!articleNode) { 14 | articleNode = searchArticleByTag(mainNode); 15 | } 16 | 17 | // 根据算法获取文章节点 18 | if (!articleNode) { 19 | articleNode = searchArticle(mainNode, bodyRect); 20 | articleNode = fixArticleNode(mainNode, articleNode); 21 | } 22 | 23 | if (import.meta.env.DEV) { 24 | mainNode.style.boxShadow = 'inset blue 0 0 0 10px'; 25 | articleNode.style.boxShadow = 'inset red 0 0 0 10px'; 26 | } 27 | 28 | mainNode.toggleAttribute('onetoc-main', true); 29 | articleNode.toggleAttribute('onetoc-article', true); 30 | 31 | return articleNode; 32 | } 33 | 34 | function searchArticleByTag(mainNode: HTMLElement) { 35 | const nodes = queryAll(NODE_NAME.article); 36 | if (nodes.length !== 1) return null; 37 | 38 | const RATE = 0.8; 39 | const articleNode = nodes[0]; 40 | 41 | const rect = getRect(articleNode); 42 | const mRect = getRect(mainNode); 43 | if (((rect.width * rect.height) / (mRect.width * mRect.height) || 0) < RATE) return null; 44 | 45 | const text = getVisibleText(articleNode); 46 | const mText = getVisibleText(mainNode); 47 | if ((text.length / mText.length || 0) < RATE) return null; 48 | 49 | return articleNode; 50 | } 51 | 52 | const articleSelector = ['', '.', '#'].map((s) => s + NODE_NAME.article).join(SYMBOL.COMMA); 53 | const negativeSelector = NOISE_SELECTORS.join(SYMBOL.COMMA); 54 | const positiveSelector = genIdClsSelector(['content', 'blog', 'markdown', 'main']); 55 | const noiseSelector = genIdClsSelector(NOISE_WORDS); 56 | 57 | function searchArticle(parent: HTMLElement, bodyRect: DOMRect): HTMLElement { 58 | const pRect = getRect(parent); 59 | const pText = getVisibleText(parent); 60 | const pArea = pRect.width * pRect.height; 61 | 62 | const SCORE = 100; 63 | 64 | const children = ([...parent.children] as HTMLElement[]) 65 | .map((child) => { 66 | const rect = getRect(child); 67 | const style = getComputedStyle(child); 68 | const text = getVisibleText(child); 69 | 70 | const topRate = rect.top / pRect.height || 0; 71 | const areaRate = (rect.width * rect.height) / pArea || 0; 72 | const textRate = text.length / pText.length || 0; 73 | 74 | const isTooFar = rect.top - pRect.top > 0.8 * window.outerHeight; 75 | const isTooSmall = Math.max(areaRate, textRate) < 0.5; 76 | const isFixedPos = [POSITION.fixed, POSITION.sticky].includes(style.position); 77 | const isInline = style.display.includes(DISPLAY.inline); 78 | 79 | if (isTooFar || isTooSmall || isFixedPos || isInline || isHidden(child, style, rect, bodyRect)) { 80 | return { child, score: -Infinity }; 81 | } 82 | 83 | let score = SCORE * (textRate + areaRate - topRate); 84 | 85 | if (child.matches(articleSelector)) score += SCORE; 86 | if (child.matches(positiveSelector)) score += SCORE / 4; 87 | if (child.matches(negativeSelector)) score -= SCORE; 88 | if (child.matches(noiseSelector)) score -= SCORE / 2; 89 | 90 | return { child, score }; 91 | }) 92 | .filter((n) => n.score > -Infinity) 93 | .sort((a, b) => b.score - a.score); 94 | 95 | return children[0] ? searchArticle(children[0].child, bodyRect) : parent; 96 | } 97 | 98 | // 用于修复特殊文章,如:https://mp.weixin.qq.com/s/7hMdPkNcFihKXypKY2DMHg 99 | function fixArticleNode(mainNode: HTMLElement, articleNode: HTMLElement) { 100 | const rect = getRect(articleNode); 101 | const text = getVisibleText(articleNode); 102 | 103 | const pRect = getRect(mainNode); 104 | const pText = getVisibleText(mainNode); 105 | 106 | const RATE = 0.8; 107 | const textRate = text.length / pText.length || 0; 108 | const areaRate = (rect.width * rect.height) / (pRect.width * pRect.height) || 0; 109 | 110 | const needFix = 111 | rect.top === pRect.top && 112 | rect.left === pRect.left && 113 | rect.right === pRect.right && 114 | textRate < RATE && 115 | areaRate < RATE; 116 | 117 | return needFix ? mainNode : articleNode; 118 | } 119 | 120 | function searchMainNode(bodyRect: DOMRect) { 121 | const nodes = queryAll('body :not(:is(script,style,svg))'); 122 | 123 | const bodyTextCount = getVisibleText(document.body).length; 124 | 125 | return nodes.findLast((node: HTMLElement) => { 126 | const textCount = getVisibleText(node).length; 127 | const rect = node.getBoundingClientRect(); 128 | const style = getComputedStyle(node); 129 | 130 | return rect.top - bodyRect.top <= 400 && textCount / bodyTextCount >= 0.8 && !isHidden(node, style, rect, bodyRect); 131 | }); 132 | } 133 | 134 | function getVisibleText(node: HTMLElement) { 135 | return (node.innerText || '').replace(/\s/g, ''); 136 | } 137 | -------------------------------------------------------------------------------- /src/options-view/options.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { Button, Col, Form, Input, message, Radio, Row } from 'antd'; 4 | import { splitTextByLine } from '../content-utils/text-util'; 5 | import { Command, createTab, getAllCommands } from '../extension-utils/api'; 6 | import { useEventListener } from '../content-view/hooks'; 7 | import { 8 | THEME_OPTIONS, 9 | POSITION_OPTIONS, 10 | SETTINGS_KEYMAP, 11 | Settings, 12 | DEFAULT_SETTINGS, 13 | loadSettings, 14 | saveSettings, 15 | STRATEGY_OPTIONS, 16 | } from '../extension-utils/settings'; 17 | import { updateResolveRules } from '../shared/resolve-rules-util'; 18 | import pkg from '../../package.json'; 19 | import './options.less'; 20 | 21 | const resolveUrl = import.meta.env.VITE_RESOLVE_URL as string; 22 | 23 | function openShortcutsPage() { 24 | createTab('chrome://extensions/shortcuts'); 25 | } 26 | 27 | function onUpdateResolveRules() { 28 | updateResolveRules() 29 | .then((rules) => { 30 | return message.success(`更新成功!(新版本号: ${rules.version})`); 31 | }) 32 | .catch((err: Error) => { 33 | return message.error(`更新失败!(${err?.message || '未知错误'})`); 34 | }); 35 | } 36 | 37 | function Options() { 38 | const ua = window.navigator.userAgent; 39 | 40 | const [form] = Form.useForm(); 41 | const [commands, setCommands] = useState(); 42 | 43 | useEffect(() => { 44 | void loadSettings().then(form.setFieldsValue); 45 | void getAllCommands().then(setCommands); 46 | }, [form]); 47 | 48 | // 获取最新快捷键 49 | useEventListener(document, 'visibilitychange', () => { 50 | if (document.visibilityState === 'hidden') return; 51 | void getAllCommands().then(setCommands); 52 | }); 53 | 54 | function save(st: Settings) { 55 | st.autoOpenRules = splitTextByLine(st.autoOpenRules).join('\n'); 56 | form.setFieldValue(SETTINGS_KEYMAP.autoOpenRules, st.autoOpenRules); 57 | 58 | void saveSettings(st).then(() => { 59 | return message.success('保存成功'); 60 | }); 61 | } 62 | 63 | function reset() { 64 | const st: Settings = { 65 | ...DEFAULT_SETTINGS, 66 | // knownVersion 不需要重置 67 | knownVersion: form.getFieldValue(SETTINGS_KEYMAP.knownVersion) as string, 68 | }; 69 | form.setFieldsValue(st); 70 | void saveSettings(st).then(); 71 | } 72 | 73 | return ( 74 |
82 |
设置
83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | {commands?.map((c) => { 96 | if (!c.description) return null; 97 | 98 | return ( 99 | 100 | {c.shortcut} 101 | {c.description} 102 | 105 | 106 | ); 107 | })} 108 | 109 | 110 | 113 | 116 | 117 | 118 |

🚁 自动打开规则

119 | 120 | 121 | 125 | 126 | 127 | 128 | 129 | 132 | 133 | 134 | 137 | 138 | 139 | 140 |
141 |

142 | 143 | ❗ 配置说明 144 | 145 | 150 | 🙋 反馈建议 151 | 152 | 153 | 🐞 技术交流 154 | 155 |

156 |

157 | {pkg.extName} V{pkg.version} 158 |

159 |
160 |
161 | ); 162 | } 163 | 164 | ReactDOM.createRoot(document.getElementById('root') as Element).render( 165 | 166 | 167 | , 168 | ); 169 | -------------------------------------------------------------------------------- /test/links.js: -------------------------------------------------------------------------------- 1 | module.exports.links = [ 2 | ['虎嗅-解析异常', 'https://www.huxiu.com/article/2679234.html', '8'], 3 | ['知乎-单行', 'https://zhuanlan.zhihu.com/p/26008219', '13'], 4 | ['知乎-单行', 'https://zhuanlan.zhihu.com/p/23411438', '10'], 5 | ['豆瓣-单行', 'https://book.douban.com/review/15125637/', '3'], 6 | ['豆瓣-单行', 'https://book.douban.com/review/15239792/', '5'], 7 | ['MDPI', 'https://www.mdpi.com/2075-1729/13/7/1425', '15'], 8 | ['虎嗅-样式', 'https://www.huxiu.com/article/1421112.html', '2'], 9 | ['虎嗅', 'https://www.huxiu.com/article/1621190.html', '2'], 10 | ['搜狐', 'https://www.sohu.com/a/112088292_481816', '18'], 11 | ['CSDN', 'https://blog.csdn.net/OKCRoss/article/details/127341037', '11'], 12 | ['CSDN', 'https://blog.csdn.net/AABBbaby/article/details/130193656', '6'], 13 | ['少数派', 'https://sspai.com/post/76060', '21'], 14 | ['36氪', 'https://36kr.com/p/2231774512672388', '9'], 15 | ['品玩', 'https://www.pingwest.com/a/279127', '11'], 16 | ['雷锋网', 'https://www.leiphone.com/category/zaobao/Jp1XvGFjehpedqja.html', '20'], 17 | ['wikipedia', 'https://zh.wikipedia.org/wiki/%E4%B8%AD%E5%9B%BD', '60'], 18 | ['wikipedia', 'https://en.wikipedia.org/wiki/China', '58'], 19 | ['51cto', 'https://blog.51cto.com/harmonyos/5318953', '13'], 20 | ['微信-艾小仙', 'https://mp.weixin.qq.com/s/au2vtN4b_xSVZ1x_ADNO8w', '12'], 21 | ['微信-微观技术', 'https://mp.weixin.qq.com/s/hERgwl3TKM2DzDqAe5Fi-A', '7'], 22 | ['微信-微观技术', 'https://mp.weixin.qq.com/s/7hMdPkNcFihKXypKY2DMHg', '21'], 23 | ['微信-微观技术', 'https://mp.weixin.qq.com/s/YyUXF2_nlUky8yoPuhwGVA', '7'], 24 | ['微信-粤商通', 'https://mp.weixin.qq.com/s/17QqRyhJA4niYmjxbdXTjA', '46'], 25 | ['微信-每日人物', 'https://mp.weixin.qq.com/s/11szrv6J18I9lJbs1RzDqA', '4'], 26 | ['掘金', 'https://juejin.cn/post/7135756687134162980', '5'], 27 | ['知乎', 'https://zhuanlan.zhihu.com/p/24650288', '62'], 28 | ['知乎-粗体过多', 'https://www.zhihu.com/tardis/zm/art/109463499', '14'], 29 | ['知乎-序号', 'https://zhuanlan.zhihu.com/p/434013564', '10'], 30 | ['知乎-序号', 'https://zhuanlan.zhihu.com/p/639361091', '20'], 31 | ['豆瓣-序号', 'https://book.douban.com/review/15193973/', '3'], 32 | ['钛媒体', 'https://www.tmtpost.com/6555757.html', '6'], 33 | ['雪球-中文序号', 'https://xueqiu.com/9292606979/252055225', '4'], 34 | ['今日头条', 'https://www.toutiao.com/article/7239640963889496635', '13'], 35 | ['南方网', 'https://pc.nfapp.southcn.com/38/7736488.html', '3'], 36 | ['人民网', 'http://ent.people.com.cn/n1/2021/0427/c1012-32089968.html', '11'], 37 | ['人民日报', 'http://paper.people.com.cn/rmrb/html/2023-05/21/nw.D110000renmrb_20230521_1-01.htm', '5'], 38 | ['新华网', 'http://www.news.cn/politics/leaders/2023-06/01/c_1129663406.htm', '3'], 39 | ['中国日报', 'https://caijing.chinadaily.com.cn/a/202306/02/WS6479757fa31064684b0543b7.html', '3'], 40 | ['中国经济网', 'http://www.ce.cn/xwzx/gnsz/szyw/202306/02/t20230602_38572819.shtml', '41'], 41 | ['什么值得买', 'https://post.smzdm.com/p/am8zonmk/', '6'], 42 | ['开源中国', 'https://my.oschina.net/u/4843764/blog/5528481', '18'], 43 | ['博客园', 'https://www.cnblogs.com/teach/p/16295605.html', '11'], 44 | ['CSDN', 'https://blog.csdn.net/csdnnews/article/details/124880259', '2'], 45 | ['思否', 'https://segmentfault.com/a/1190000041806654', '5'], 46 | ['MDN', 'https://developer.mozilla.org/zh-CN/docs/Web/API/Window/location', '15'], 47 | ['简书', 'https://www.jianshu.com/p/a2cb1e3a79be', '17'], 48 | ['iteye', 'https://www.iteye.com/blog/kaizi1992-2538691', '5'], 49 | ['iteye', 'https://www.iteye.com/news/32983', '29'], 50 | ['VS Code Doc', 'https://code.visualstudio.com/docs/terminal/profiles', '11'], 51 | ['React Doc(EN)', 'https://react.dev/learn/tutorial-tic-tac-toe', '21'], 52 | ['React Doc(legacy)', 'https://legacy.reactjs.org/docs/getting-started.html', '19'], 53 | ['React Native', 'https://reactnative.dev/docs/getting-started', '5'], 54 | ['Create React App', 'https://create-react-app.dev/docs/getting-started/', '13'], 55 | ['Vue3', 'https://vuejs.org/guide/typescript/overview.html', '13'], 56 | ['Angular', 'https://angular.io/start/start-data', '14'], 57 | ['svelte', 'https://svelte.dev/docs/svelte', '18'], 58 | ['Preact', 'https://preactjs.com/guide/v10/getting-started/', '15'], 59 | ['Vite', 'https://vitejs.dev/config/server-options.html', '17'], 60 | ['Webpack', 'https://webpack.js.org/concepts/module-federation/', '21'], 61 | ['Puppeteer', 'https://pptr.dev/guides/debugging', '10'], 62 | ['tsconfig', 'https://www.typescriptlang.org/tsconfig', '160'], 63 | ['Github(EN)', 'https://github.com/facebook/react', '9'], 64 | ['Github', 'https://github.com/Whilconn/one-toc/issues/7', '4'], 65 | ['Eslint(EN)', 'https://eslint.org/docs/user-guide/getting-started', '6'], 66 | ['Echarts', 'https://echarts.apache.org/handbook/zh/concepts/dataset/', '13'], 67 | ['docusaurus', 'https://docusaurus.io/docs', '15'], 68 | ['NCBI(EN)', 'https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6742634/', '11'], 69 | ['阮一峰博客', 'https://www.ruanyifeng.com/blog/2021/08/weekly-issue-170.html', '12'], 70 | ['张鑫旭博客', 'https://www.zhangxinxu.com/wordpress/2023/04/ai-code-tool-codeium-github-copilot/', '23'], 71 | ['vitest-性能', 'https://cn.vitest.dev/api/', '166'], 72 | // TODO: 识别优化 73 | // ['知乎-层级异常', 'https://zhuanlan.zhihu.com/p/38813482', '6'], 74 | // ['github-自带toc过滤','https://github.com/vpncn/vpncn.github.io',79] 75 | // ['wiki', 'https://en.wikipedia.org/wiki/International_Olympic_Committee', '44'], 76 | // ['微信-前端之巅', 'https://mp.weixin.qq.com/s/utGGaAErfbJSA-yw3G7AHA', '16'], 77 | // ['微信-前端之巅', 'https://mp.weixin.qq.com/s/lA3ApjELCiUHudn9oGxR9Q', '2'], 78 | // ['钛媒体', 'https://www.tmtpost.com/6555123.html', '8'], 79 | // TODO: 内容在 iframe 中;开启content_scripts.all_frames,识别内容区唯一iframe 80 | // ['threejs', 'https://threejs.org/docs/index.html#manual/en/introduction/Installation', '15'], 81 | ]; 82 | -------------------------------------------------------------------------------- /src/content-utils/heading-infer-util.ts: -------------------------------------------------------------------------------- 1 | import { BOLD_SELECTORS, HEADING_SELECTORS, NODE_NAME, NOISE_WORDS } from '../shared/constants'; 2 | import { genIdClsSelector, genNodePath, getLevel, getText, isHeading, pxToNumber } from './dom-util'; 3 | import { RectMap, StyleMap } from './heading-all-util'; 4 | 5 | export function inferHeadings(articleNode: HTMLElement, nodes: HTMLElement[], styleMap: StyleMap, rectMap: RectMap) { 6 | nodes = filterByStyleRuleP1(nodes, styleMap, rectMap); 7 | nodes = filterByScoreRuleP2(articleNode, nodes, styleMap, rectMap, 0.9); 8 | nodes = filterByStyleRuleP3(nodes); 9 | 10 | return nodes; 11 | } 12 | 13 | function filterByStyleRuleP1(nodes: HTMLElement[], styleMap: StyleMap, rectMap: RectMap) { 14 | return nodes.filter((node, i) => { 15 | const rect = rectMap.get(node); 16 | const style = styleMap.get(node); 17 | if (!style || !rect) return true; 18 | 19 | // 剔除与相邻标题的 top 或 bottom 相等的节点 20 | const hasParallelNode = [nodes[i - 1], nodes[i + 1]].some((n) => { 21 | const r = rectMap.get(n); 22 | return r?.top === rect.top || r?.bottom === rect.bottom; 23 | }); 24 | 25 | return !hasParallelNode; 26 | }); 27 | } 28 | 29 | function filterByScoreRuleP2( 30 | articleNode: HTMLElement, 31 | nodes: HTMLElement[], 32 | styleMap: StyleMap, 33 | rectMap: RectMap, 34 | factor: number, 35 | ) { 36 | const SCORES = { 37 | NODE_PATH: 1, 38 | LEFT: 1, 39 | WIDTH: 1, 40 | ID: 3, 41 | TAG_H: 3, 42 | TAG_B: 1, 43 | MX_WIDTH: 3, 44 | FONT_SIZE: 3, 45 | FONT_BOLD: 3, 46 | ARTICLE_PARENT: 3, 47 | RECOMMEND_LINK: -10, 48 | NOISE_PARENT: -10, 49 | DOC_TITLE: -20, 50 | }; 51 | 52 | const FEATS = Object.fromEntries(Object.keys(SCORES).map((k) => [k, k])) as Record; 53 | 54 | const commonStyleFeats: Array = [ 55 | 'display', 56 | 'margin', 57 | 'padding', 58 | 'color', 59 | 'backgroundColor', 60 | 'fontFamily', 61 | ]; 62 | 63 | const noiseSelector = genIdClsSelector(NOISE_WORDS); 64 | 65 | const maxWidth = Math.min(articleNode.scrollWidth / 3, 600); 66 | 67 | const featGroup = new Map(); 68 | const groupByFeat = (feat: string, idx: number, val?: string | number) => { 69 | val = (val ?? '').toString(); 70 | const key = val ? `${feat}-${val}` : feat; 71 | if (!featGroup.has(key)) featGroup.set(key, []); 72 | featGroup.get(key)?.push(idx); 73 | }; 74 | 75 | nodes.forEach((node, i) => { 76 | const valId = node.id || node.getAttribute('name'); 77 | if (valId) groupByFeat(FEATS.ID, i); 78 | 79 | const lowerName = node.tagName.toLowerCase(); 80 | if (HEADING_SELECTORS.includes(lowerName)) groupByFeat(FEATS.TAG_H, i); 81 | if (BOLD_SELECTORS.includes(lowerName)) groupByFeat(FEATS.TAG_B, i); 82 | 83 | const valPath = genNodePath(node); 84 | groupByFeat(FEATS.NODE_PATH, i, valPath); 85 | 86 | // document.title 包含该节点完整的文本内容,且该节点是 H1,则可以剔除 87 | const text = getText(node); 88 | const H1 = NODE_NAME.h1.toUpperCase(); 89 | if (text && document.title.includes(text) && node.tagName === H1) { 90 | groupByFeat(FEATS.DOC_TITLE, i); 91 | } 92 | 93 | // 不能是推荐链接 94 | if (hasRecommendLink(node)) groupByFeat(FEATS.RECOMMEND_LINK, i); 95 | 96 | // 节点在文章主体中 97 | if (node.closest(NODE_NAME.article)) groupByFeat(FEATS.ARTICLE_PARENT, i); 98 | 99 | // 在footer、sidebar等节点下 100 | if (node.closest(noiseSelector)) groupByFeat(FEATS.NOISE_PARENT, i); 101 | 102 | const rect = rectMap.get(node) || new DOMRect(-1, -1, -1, -1); 103 | const style = styleMap.get(node) || new CSSStyleDeclaration(); 104 | 105 | groupByFeat(FEATS.LEFT, i, rect.left >> 0); 106 | groupByFeat(FEATS.WIDTH, i, rect.width >> 0); 107 | 108 | // width 有最小限制 109 | if (rect.width >= maxWidth) groupByFeat(FEATS.MX_WIDTH, i); 110 | 111 | const fontSize = style ? pxToNumber(style.fontSize) : -1; 112 | // 辅助排除字号异常节点,比如文章标题 113 | groupByFeat(FEATS.FONT_SIZE, i, fontSize); 114 | 115 | const fontWeight = +(style ? style.fontWeight : -1); 116 | if (fontWeight >= 500) groupByFeat(FEATS.FONT_BOLD, i); 117 | 118 | for (const p of commonStyleFeats) { 119 | groupByFeat(p as string, i, style[p] as string); 120 | } 121 | }); 122 | 123 | let totalScore = 0; 124 | const scoreMap = new WeakMap(); 125 | 126 | for (const [key, idxList] of featGroup) { 127 | const baseScore = SCORES[key as keyof typeof SCORES] || 1; 128 | 129 | for (const i of idxList) { 130 | const n = nodes[i]; 131 | const score = baseScore > 0 ? baseScore * Math.min(idxList.length, 5) : baseScore; 132 | totalScore += score; 133 | scoreMap.set(n, (scoreMap.get(n) || 0) + score); 134 | } 135 | } 136 | 137 | const avgScore = (totalScore / nodes.length) * factor; 138 | 139 | return nodes.filter((node) => { 140 | return (scoreMap.get(node) ?? -Infinity) >= avgScore; 141 | }); 142 | } 143 | 144 | const noiseSymbolReg = /[,.;!?,。;!?]$/; 145 | 146 | // 同时包含 h1~h6、b、strong 时,剔除部分b、strong节点 147 | function filterByStyleRuleP3(nodes: HTMLElement[]) { 148 | const hTagNodes = nodes.filter((n) => isHeading(n)); 149 | if (hTagNodes.length < 4) return nodes; 150 | 151 | const maxHeadingLevel = Math.max(...hTagNodes.map((n) => getLevel(n))); 152 | 153 | return nodes.filter((node) => { 154 | if (isHeading(node)) return true; 155 | 156 | const level = getLevel(node); 157 | if (level - maxHeadingLevel > 3) return false; 158 | 159 | const text = getText(node); 160 | return !noiseSymbolReg.test(text); 161 | }); 162 | } 163 | 164 | // 是否推荐链接 165 | function hasRecommendLink(node: HTMLElement) { 166 | // TODO: https://en.wikipedia.org/wiki/International_Olympic_Committee 167 | const linkNode = (node.closest(NODE_NAME.a) || node.querySelector(NODE_NAME.a)) as HTMLAnchorElement; 168 | 169 | // a标签与页面 origin 相同,才可能是推荐链接 170 | if (linkNode?.origin !== location.origin) return false; 171 | 172 | // 剔除出自文章内部的链接 173 | return !linkNode?.pathname.startsWith(location.pathname); 174 | } 175 | -------------------------------------------------------------------------------- /src/content-utils/heading-all-util.ts: -------------------------------------------------------------------------------- 1 | import { BOLD_SELECTORS, DISPLAY, HEADING_SELECTORS, NODE_NAME, SYMBOL } from '../shared/constants'; 2 | import { 3 | findAncestor, 4 | getNextTextNode, 5 | getPrevTextNode, 6 | getRect, 7 | getText, 8 | isFixed, 9 | isHeading, 10 | isHidden, 11 | pxToNumber, 12 | queryAll, 13 | } from './dom-util'; 14 | import { ResolveRule } from '../shared/resolve-rules'; 15 | 16 | // b/strong 17 | const boldTagSelector = BOLD_SELECTORS.join(SYMBOL.COMMA); 18 | 19 | function getHeadingsByResolveRules(resolveRule?: ResolveRule) { 20 | if (!resolveRule?.headings?.length) return []; 21 | const selector = resolveRule?.headings.join(','); 22 | 23 | return queryAll(selector) || []; 24 | } 25 | 26 | // TODO: 优先级 h1#id > article h1 > h1 > article b,strong > b,strong > style: bold、fs>=20 > 语义 27 | export function getAllHeadings(articleNode: HTMLElement, resolveRule?: ResolveRule) { 28 | const hTagHeadings: HTMLElement[] = []; 29 | const bTagHeadings: HTMLElement[] = []; 30 | const styleHeadings: HTMLElement[] = []; 31 | const semanticHeadings: HTMLElement[] = []; 32 | const oneLineHeadings: HTMLElement[] = []; 33 | const ruleHeadings: HTMLElement[] = getHeadingsByResolveRules(resolveRule) || []; 34 | 35 | if (ruleHeadings.length) { 36 | return { hTagHeadings, bTagHeadings, styleHeadings, semanticHeadings, oneLineHeadings, ruleHeadings }; 37 | } 38 | 39 | const bodyRect = document.body.getBoundingClientRect(); 40 | const reg = /^([一二三四五六七八九十百千万零]{1,4}|\d{1,3}|[a-z])[、.·,,].+/i; 41 | const oneLineReg = /[^、.。·,,!!??]$/i; 42 | 43 | const walker = document.createTreeWalker(articleNode, NodeFilter.SHOW_ELEMENT); 44 | let node = walker.root as HTMLElement; 45 | 46 | while ((node = walker.nextNode() as HTMLElement)) { 47 | const rect = getRect(node); 48 | const style = getComputedStyle(node); 49 | if (!isFitRuleP0(node, style, rect, bodyRect)) continue; 50 | 51 | if (isHeading(node)) { 52 | hTagHeadings.push(node); 53 | } else { 54 | // 非h1~h6的节点必须保证独占一行 (isFitRuleP0函数已包含该逻辑,此处不再单独判断) 55 | if (node.matches(boldTagSelector)) bTagHeadings.push(node); 56 | 57 | // 样式匹配,样式是加粗或居中对齐 58 | const styleFit = +style.fontWeight >= 500 || pxToNumber(style.fontSize) >= 20; 59 | if (styleFit) styleHeadings.push(node); 60 | 61 | // 语义匹配,文本开头是序号 62 | const text = getText(node); 63 | if (reg.test(text)) semanticHeadings.push(node); 64 | 65 | // 单行文本 66 | if (oneLineReg.test(text)) oneLineHeadings.push(node); 67 | } 68 | } 69 | 70 | return { hTagHeadings, bTagHeadings, styleHeadings, semanticHeadings, oneLineHeadings, ruleHeadings }; 71 | } 72 | 73 | export function genStyleInfo(headings: HTMLElement[]) { 74 | // 注意:rectMap与styleMap是全局状态,且存在改变该状态的逻辑,容易产生bug 75 | const rectMap = new WeakMap(); 76 | const styleMap = new WeakMap(); 77 | 78 | for (const node of headings) { 79 | if (!rectMap.has(node)) rectMap.set(node, resolveHeadingRect(node)); 80 | if (!styleMap.has(node)) styleMap.set(node, resolveHeadingStyle(node)); 81 | } 82 | 83 | return { styleMap, rectMap }; 84 | } 85 | 86 | const invalidTags = [NODE_NAME.svg, NODE_NAME.figure]; 87 | const headingSelector = [...HEADING_SELECTORS, ...BOLD_SELECTORS].join(SYMBOL.COMMA); 88 | 89 | function isFitRuleP0(node: HTMLElement, style: CSSStyleDeclaration, rect: DOMRect, bodyRect: DOMRect) { 90 | // 必须有文字内容 91 | if (!getText(node)) return false; 92 | 93 | // warning: 不再限制字号(存在字号较小的标题 https://www.mdpi.com/2075-1729/13/7/1425) 94 | 95 | // 不能是隐藏节点 96 | if (isHidden(node, style, rect, bodyRect)) return false; 97 | 98 | // 不能是 fixed 节点 99 | if (isFixed(node, style)) return false; 100 | 101 | // 不能是评论内容 102 | if (isNoiseNode(node)) return false; 103 | 104 | // 不能是嵌套的heading节点,如h1 h2,h1 b,h2 strong 等 105 | if (node.parentElement?.closest(headingSelector)) return false; 106 | 107 | // 不能是特殊标签节点 108 | const tagName = node.tagName.toLowerCase(); 109 | if (invalidTags.includes(tagName)) return false; 110 | 111 | // 必须独占一行 112 | return isUniqueInOneLine(node, style, rect); 113 | } 114 | 115 | function isNoiseNode(node: HTMLElement) { 116 | const selector = 'header,aside,footer,nav,.recommend,#recommend'; 117 | return !!node.closest(selector); 118 | } 119 | 120 | // 修正 heading 的字体、字号、颜色等样式(不直接修改节点样式,会导致页面布局改变甚至破坏) 121 | function resolveHeadingStyle(node: HTMLElement) { 122 | const text = getText(node); 123 | const style = getComputedStyle(node); 124 | if (!text) return style; 125 | 126 | const childNode = [...node.children].find((c) => { 127 | const childText = getText(c); 128 | if (text.startsWith(childText)) return c; 129 | }); 130 | 131 | if (!childNode) return style; 132 | 133 | const childStyle = getComputedStyle(childNode); 134 | const { color, fontSize, fontFamily, fontWeight } = childStyle; 135 | 136 | return { ...style, color, fontSize, fontFamily, fontWeight }; 137 | } 138 | 139 | // 修正布局信息(不直接修改节点样式,会导致页面布局改变甚至破坏) 140 | function resolveHeadingRect(node: HTMLElement) { 141 | const rect = getRect(node); 142 | 143 | const ancestor = findAncestor(node, (n) => { 144 | return !getComputedStyle(n).display.includes(DISPLAY.inline); 145 | }); 146 | 147 | if (ancestor) { 148 | const ancestorRect = ancestor.getBoundingClientRect(); 149 | rect.x = ancestorRect.x; 150 | rect.width = ancestorRect.width; 151 | } 152 | 153 | return rect; 154 | } 155 | 156 | // 非 h1~h6 的节点,必须独占一行 157 | function isUniqueInOneLine(node: HTMLElement, style: CSSStyleDeclaration, rect: DOMRect) { 158 | if (isHeading(node)) return true; 159 | 160 | // 文字换行则判定为不是标题 161 | if (hasMultiline(node, style)) return false; 162 | 163 | const prevNode = getPrevTextNode(node); 164 | const nextNode = getNextTextNode(node); 165 | 166 | const y1 = prevNode ? getRect(prevNode).bottom : -Infinity; 167 | const y2 = nextNode ? getRect(nextNode).top : Infinity; 168 | 169 | const prevText = prevNode?.textContent || ''; 170 | const nextText = nextNode?.textContent || ''; 171 | 172 | // 前后节点与当前节点不在同一行 173 | return (y1 <= rect.top || /[\r\n]\s*$/.test(prevText)) && (rect.bottom <= y2 || /^\s*[\r\n]/.test(nextText)); 174 | } 175 | 176 | // 借助 Range.getClientRects 判断是否多行 177 | // 使用高度除以行高、字号判断是否多行存在bug,会出现误判 178 | function hasMultiline(node: HTMLElement, style: CSSStyleDeclaration) { 179 | const range = new Range(); 180 | // 如下设置range起止位置,可以最大程度保证首尾rect的y值与实际首尾节点一致 181 | // 若仍存在问题,可考虑使用最大最小y值求差值进行判断 182 | range.setStart(node, 0); 183 | range.setEndAfter(node); 184 | 185 | const rects = range.getClientRects(); 186 | if (rects.length < 2) return false; 187 | 188 | const fontsize = pxToNumber(style.fontSize); 189 | return Math.abs(rects[rects.length - 1].y - rects[0].y) >= fontsize; 190 | } 191 | 192 | export type StyleMap = WeakMap; 193 | 194 | export type RectMap = WeakMap; 195 | --------------------------------------------------------------------------------