├── .gitignore ├── public └── icon.png ├── images ├── screenshot-0.png └── screenshot-1.png ├── src ├── background.ts ├── content_style.scss └── content_script.ts ├── manifest.json ├── .import-sorter.json ├── pack.sh ├── tsconfig.json ├── package.json ├── readme.md └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reorx/github-toc-sidebar/HEAD/public/icon.png -------------------------------------------------------------------------------- /images/screenshot-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reorx/github-toc-sidebar/HEAD/images/screenshot-0.png -------------------------------------------------------------------------------- /images/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reorx/github-toc-sidebar/HEAD/images/screenshot-1.png -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | console.log('background') 2 | 3 | chrome.runtime.onStartup.addListener( () => { 4 | console.log(`onStartup()`); 5 | }); 6 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "GitHub TOC Sidebar", 4 | "description": "Put the GitHub README TOC in the sidebar", 5 | "version": "2.3.0", 6 | "icons": { 7 | "16": "icon.png", 8 | "48": "icon.png", 9 | "128": "icon.png" 10 | }, 11 | "content_scripts": [ 12 | { 13 | "matches": ["https://github.com/*/*"], 14 | "css": ["css/content_style.css"], 15 | "js": ["js/content_script.js"] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.import-sorter.json: -------------------------------------------------------------------------------- 1 | { 2 | "autoFormat": "onSave", 3 | "maxLineLength": 100, 4 | "emptyLinesAfterAllImports": 2, 5 | "wrappingStyle": { 6 | "maxBindingNamesPerLine": 5, 7 | "maxDefaultAndBindingNamesPerLine": 5, 8 | "maxExportNamesPerLine": 5, 9 | "maxNamesPerWrappedLine": 7, 10 | "ignoreComments": false 11 | }, 12 | "groupRules": [ 13 | "^react(-dom)?", 14 | {}, 15 | "^@/(.*)$", 16 | "^[./]" 17 | ], 18 | "sortRules": { 19 | "paths": [ "_", "aA" ], 20 | "names": "none" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | build_dir="$PWD/build" 4 | dist_dir="$PWD/dist" 5 | package_name="github-toc-sidebar" 6 | manifest_path="$build_dir/manifest.json" 7 | 8 | # build 9 | rm -rf "$build_dir" 10 | npm run build 11 | 12 | # version 13 | version=$(grep '"version' "$manifest_path" | grep -Eo '\d.\d.\d') 14 | if [ -z "$version" ]; then 15 | echo "cannot get version" 16 | exit 1 17 | fi 18 | filename="${package_name}-$version.zip" 19 | 20 | pushd "$build_dir" 21 | zip $filename * -vr -x 'types*' 22 | mkdir -p "$dist_dir" 23 | mv $filename "$dist_dir" 24 | popd 25 | 26 | echo "Result:" 27 | ls -l "$dist_dir/$filename" 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es6", 5 | "module": "commonjs", 6 | "esModuleInterop": true, 7 | "rootDir": "src", 8 | "allowJs": true, 9 | "sourceMap": false, 10 | "noEmitOnError": true, 11 | "experimentalDecorators": true, 12 | "useDefineForClassFields": true, 13 | 14 | // Required for tsconfig-paths-webpack-plugin 15 | // "baseUrl": ".", 16 | // Enable constraints that allow a TypeScript project to be used with project references. 17 | // "composite": true, 18 | }, 19 | "include": [ 20 | "./src" 21 | ], 22 | "exclude": [ 23 | "**/__tests__", 24 | "node_modules", 25 | "lib", 26 | "jest-config.js", 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-toc-sidebar", 3 | "version": "2.0.0", 4 | "private": true, 5 | "description": "A boilerplate for building Chrome extensions with Webpack and Vanilla JS", 6 | "scripts": { 7 | "dev": "NODE_ENV=development webpack --watch", 8 | "build": "NODE_ENV=production webpack", 9 | "dist": "npm run build && bash pack.sh", 10 | "clean": "rm -rf dist", 11 | "test": "npx jest", 12 | "format-style": "prettier --write \"src/**/*.{ts,tsx}\"" 13 | }, 14 | "author": "Reorx ", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@babel/core": "^7.17.12", 18 | "@babel/preset-env": "^7.17.12", 19 | "@babel/preset-typescript": "^7.17.12", 20 | "@reorx/webpack-ext-reloader": "^1.4.1", 21 | "@types/chrome": "0.0.217", 22 | "@types/lodash.throttle": "^4.1.9", 23 | "@types/node": "^18.15.11", 24 | "@typescript-eslint/eslint-plugin": "^5.24.0", 25 | "@typescript-eslint/parser": "^5.24.0", 26 | "babel-loader": "^9.1.2", 27 | "copy-webpack-plugin": "^10.2.4", 28 | "core-js": "^3.22.5", 29 | "css-loader": "^6.7.1", 30 | "eslint": "^8.15.0", 31 | "eslint-plugin-react": "^7.29.4", 32 | "generate-json-webpack-plugin": "^2.0.0", 33 | "html-webpack-plugin": "^5.5.0", 34 | "sass": "^1.58.3", 35 | "sass-loader": "^13.2.0", 36 | "style-loader": "^3.3.1", 37 | "ts-loader": "^9.3.0", 38 | "typescript": "^4.6.3", 39 | "webpack": "^5.72.1", 40 | "webpack-bundle-analyzer": "^4.5.0", 41 | "webpack-cli": "^5.0.1", 42 | "webpack-dev-server": "^4.9.0", 43 | "webpack-merge": "^5.0.0" 44 | }, 45 | "dependencies": { 46 | "lodash.throttle": "^4.1.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # GitHub TOC Sidebar 2 | 3 | Show GitHub README TOC as a sidebar 4 | 5 | ![](images/screenshot-0.png) 6 | 7 | 8 | This is how it works: 9 | 10 | ![](images/screenshot-1.png) 11 | 12 | Install it on [Chrome Web Store](https://chrome.google.com/webstore/detail/github-toc-sidebar/cdiiikhamhampcninkmmpgejjbgdgdnn) 13 | 14 | 15 | ## Features 16 | 17 | - Automatically display the native README TOC on the right side while scrolling down. 18 | - Hide the README TOC after the sidebar shows up when you scroll up 19 | 20 | > **Note** 21 | > 1. This extension is designed solely to enhance the browsing experience for GitHub README. 22 | > If you need a universal TOC extension, please refer to other options such as Smart TOC or Simple Outliner. 23 | > 2. This extension utilizes the native README TOC provided by GitHub, 24 | > which makes it look beautiful and easy to implement. 25 | 26 | 27 | ## Thing you should know 28 | 29 | Please note that this extension only works when all the circumstances are met: 30 | 1. You are viewing the home page of a project on GitHub, meaning that the URL must match the format https://github.com//, not subsidiary pages like https://github.com///blob/master/readme.md 31 | 2. The project has a readme file written in a markup language such as Markdown or RST 32 | 3. The readme file has headings that split content into multiple sections with titles 33 | 4. You scroll down the page further than the bottom of the right sidebar 34 | 35 | To test if this extension works, you can open this page as an example: https://github.com/reorx/awesome-chatgpt-api 36 | 37 | 38 | ## Configuration 39 | 40 | I haven't added any configurations since it totally fulfills my need. 41 | However, I have considered options such as being able to customize the width and height of the TOC, 42 | or even making it movable. 43 | 44 | If you would like to suggest and contribute these features, 45 | please feel free to send a pull request or create a detailed issue explaining your requirements and design suggestions. 46 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const CopyPlugin = require('copy-webpack-plugin') 5 | const ExtReloader = require('@reorx/webpack-ext-reloader') 6 | const GenerateJsonPlugin = require('generate-json-webpack-plugin'); 7 | const { merge } = require('webpack-merge') 8 | 9 | const rootDir = path.resolve(__dirname) 10 | const srcDir = path.join(rootDir, 'src') 11 | const destDir = path.join(rootDir, 'build') 12 | 13 | const isDev = process.env.NODE_ENV === 'development' 14 | 15 | const manifestPath = path.join(rootDir, 'manifest.json') 16 | const defaultManifest = JSON.parse(fs.readFileSync(manifestPath).toString()) 17 | 18 | 19 | function getManifest() { 20 | if (isDev) { 21 | // add background for dev, so that the webpack-ext-reloader can work 22 | return Object.assign({}, defaultManifest, { 23 | "background": { 24 | "service_worker": "js/background.js" 25 | }, 26 | }) 27 | } else { 28 | return defaultManifest 29 | } 30 | } 31 | 32 | 33 | const common = { 34 | entry: { 35 | content_script: path.join(srcDir, 'content_script.ts'), 36 | content_style: path.join(srcDir, 'content_style.scss'), 37 | }, 38 | output: { 39 | path: destDir, 40 | filename: 'js/[name].js', 41 | }, 42 | module: { 43 | rules: [ 44 | { 45 | test: /\.tsx?$/, 46 | loader: 'ts-loader', 47 | exclude: /node_modules/, 48 | // options: { 49 | // projectReferences: true, 50 | // } 51 | }, 52 | { 53 | test: /\.css$/i, 54 | use: ['style-loader', 'css-loader'], 55 | }, 56 | { 57 | oneOf: [ 58 | // For "css" in "content_scripts", generate separate files 59 | // https://stackoverflow.com/a/67307684/596206 60 | { 61 | test: /content_.+\.scss$/i, 62 | use: [ 63 | 'sass-loader', 64 | ], 65 | type: 'asset/resource', 66 | generator: { 67 | filename: 'css/[name].css' 68 | } 69 | }, 70 | { 71 | test: /\.scss$/i, 72 | use: [ 73 | 'style-loader', 74 | 'css-loader', 75 | 'sass-loader', 76 | ], 77 | }, 78 | ] 79 | } 80 | ], 81 | }, 82 | resolve: { 83 | extensions: ['.ts', '.tsx', '.js'], 84 | }, 85 | plugins: [ 86 | new CopyPlugin({ 87 | patterns: [{ from: path.join(rootDir, 'public'), to: destDir }], 88 | }), 89 | new GenerateJsonPlugin('manifest.json', getManifest(), null, 2), 90 | new webpack.DefinePlugin({ 91 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 92 | }), 93 | ], 94 | } 95 | 96 | 97 | function developmentConfig() { 98 | console.log('development config') 99 | 100 | const config = merge(common, { 101 | // add background entry 102 | entry: { 103 | background: path.join(srcDir, 'background.ts'), 104 | }, 105 | // `eval` could not be used, see https://stackoverflow.com/questions/48047150/chrome-extension-compiled-by-webpack-throws-unsafe-eval-error 106 | devtool: 'cheap-module-source-map', 107 | mode: 'development', 108 | plugins: [ 109 | new ExtReloader({ 110 | port: 9310, 111 | entries: { 112 | background: 'background', 113 | contentScript: ['content_script', 'content_style'], 114 | }, 115 | }), 116 | ], 117 | }) 118 | 119 | if (process.env.MEASURE_SPEED) { 120 | const SpeedMeasurePlugin = require("speed-measure-webpack-plugin") 121 | const smp = new SpeedMeasurePlugin() 122 | config = smp.wrap(config) 123 | } 124 | return config 125 | } 126 | 127 | 128 | function productionConfig() { 129 | console.log('production config') 130 | const config = merge(common, { 131 | mode: 'production', 132 | }) 133 | return config 134 | } 135 | 136 | 137 | module.exports = isDev ? developmentConfig() : productionConfig() 138 | -------------------------------------------------------------------------------- /src/content_style.scss: -------------------------------------------------------------------------------- 1 | $indent-unit: 16px; 2 | $gradient-height: 48px; 3 | $transition-duration-normal: 0.3s; 4 | $transition-duration-fast: 0.2s; 5 | $transition-easing-standard: cubic-bezier(0.4, 0, 0.2, 1); 6 | 7 | .toc-sidebar { 8 | padding: 16px 0; 9 | color: var(--fgColor-default, var(--color-fg-default)); 10 | font-size: 14px; 11 | h2 { 12 | margin-bottom: 8px; 13 | } 14 | 15 | .toc-sidebar-content { 16 | position: relative; 17 | &.sticky-top { 18 | position: fixed; 19 | top: 0; 20 | } 21 | 22 | .scroll-wrapper { 23 | padding-top: 8px; 24 | // left padding is to reserve space for the active highlight, otherwise it will be covered by overflow 25 | padding-left: 8px; 26 | max-height: calc(100vh - 10px); 27 | overflow-y: auto; 28 | position: relative; 29 | } 30 | &::after { 31 | content: ""; 32 | position: absolute; 33 | bottom: 0; left: 0; right: 0; 34 | height: $gradient-height; 35 | background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, var(--bgColor-default, var(--color-canvas-default)) 100%); 36 | } 37 | } 38 | 39 | a { 40 | color: inherit; 41 | font-size: inherit; 42 | line-height: 20px; 43 | &:hover { 44 | text-decoration: none; 45 | } 46 | } 47 | // Only apply to top-level ul, not nested ones 48 | > .toc-sidebar-content > .scroll-wrapper > ul { 49 | list-style: none; 50 | margin-bottom: $gradient-height; 51 | } 52 | 53 | ul { 54 | li { 55 | border-radius: 6px; 56 | cursor: pointer; 57 | appearance: none; 58 | position: relative; 59 | } 60 | li > a { 61 | padding: 6px 8px; 62 | display: flex; 63 | align-items: center; 64 | gap: 6px; 65 | } 66 | // Only show hover/active background on direct hover, not on parent items 67 | li > a:hover { 68 | background-color: rgba(177, 186, 196, 0.12); 69 | box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px max(1px, 0.0625rem) inset; 70 | border-radius: 6px; 71 | } 72 | li.active > a { 73 | background-color: rgba(177, 186, 196, 0.12); 74 | box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px max(1px, 0.0625rem) inset; 75 | border-radius: 6px; 76 | position: relative; 77 | } 78 | 79 | li.active > a::after { 80 | position: absolute; 81 | top: calc(50% - 12px); 82 | left: -8px; 83 | width: 4px; 84 | height: 24px; 85 | content: ""; 86 | background-color: rgb(9, 105, 218); 87 | border-radius: 6px; 88 | } 89 | } 90 | 91 | // Collapse icon styles 92 | .collapse-icon { 93 | display: inline-flex; 94 | align-items: center; 95 | justify-content: center; 96 | width: 16px; 97 | height: 16px; 98 | flex-shrink: 0; 99 | font-size: 24px; 100 | opacity: 0; 101 | transition: transform $transition-duration-fast $transition-easing-standard, opacity $transition-duration-fast ease; 102 | user-select: none; 103 | transform: rotate(90deg); 104 | 105 | &.has-children { 106 | opacity: 0.6; 107 | cursor: pointer; 108 | 109 | &:hover { 110 | opacity: 1; 111 | } 112 | 113 | &.collapsed { 114 | transform: rotate(0deg); 115 | } 116 | } 117 | } 118 | 119 | // Nested children styles (wrapper div) 120 | .toc-children { 121 | display: grid; 122 | grid-template-rows: 1fr; 123 | transition: grid-template-rows $transition-duration-normal $transition-easing-standard, 124 | margin-top $transition-duration-normal $transition-easing-standard, 125 | opacity $transition-duration-normal $transition-easing-standard; 126 | margin-top: 4px; 127 | opacity: 1; 128 | 129 | &.collapsed { 130 | grid-template-rows: 0fr; 131 | margin-top: 0; 132 | opacity: 0; 133 | } 134 | 135 | // This wrapper is needed for grid animation to work properly 136 | > * { 137 | overflow: hidden; 138 | min-height: 0; 139 | } 140 | 141 | // The actual UL inside the wrapper 142 | ul { 143 | list-style: none; 144 | margin-bottom: 0; 145 | padding-left: $indent-unit; 146 | 147 | li { 148 | margin-left: 0; 149 | } 150 | } 151 | } 152 | 153 | .toc-label-h1 { 154 | font-weight: 600; 155 | } 156 | } 157 | 158 | 159 | @media (max-width: 760px) { 160 | .toc-sidebar { 161 | display: none; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/content_script.ts: -------------------------------------------------------------------------------- 1 | import throttle from 'lodash/throttle'; 2 | 3 | 4 | const tocClassName = 'toc-sidebar' 5 | const tocContentClassName = 'toc-sidebar-content' 6 | const stickyClassName = 'sticky-top' 7 | const DEBUG = process.env.NODE_ENV === 'development' 8 | 9 | function debugLog(...args: any[]) { 10 | if (!DEBUG) return 11 | console.log.apply(null, args) 12 | } 13 | 14 | function getHeadingHref(h: Element) { 15 | let a: HTMLAnchorElement|null = null 16 | if (h.parentElement) { 17 | a = h.parentElement.querySelector('a.anchor') 18 | } else { 19 | a = h.querySelector('a.anchor') 20 | } 21 | debugLog('getHeadingHref', a) 22 | if (!a) { 23 | return 24 | } 25 | return a.getAttribute('href') 26 | } 27 | 28 | function createToC(headings: NodeListOf) { 29 | 30 | const toc = document.createElement('div') 31 | const scrollWrapper = document.createElement('div') 32 | scrollWrapper.classList.add('scroll-wrapper') 33 | const ul = document.createElement('ul') 34 | toc.appendChild(scrollWrapper) 35 | scrollWrapper.appendChild(ul) 36 | 37 | const MAX_HEADING_LEVEL = 6; 38 | 39 | // Track heading hierarchy 40 | interface HeadingNode { 41 | element: HTMLLIElement; 42 | level: number; 43 | children: HTMLUListElement | null; 44 | childrenWrapper: HTMLDivElement | null; 45 | isCollapsed: boolean; 46 | icon: HTMLSpanElement; 47 | href: string; 48 | } 49 | 50 | // Map to track heading nodes by their href for auto-expansion 51 | const nodesByHref = new Map(); 52 | 53 | const getHeadingLevel = (tagName: string): number => { 54 | return parseInt(tagName.charAt(1)); // h1 -> 1, h2 -> 2, etc. 55 | } 56 | 57 | const createCollapseIcon = (hasChildren: boolean): HTMLSpanElement => { 58 | const icon = document.createElement('span') 59 | icon.classList.add('collapse-icon') 60 | if (hasChildren) { 61 | icon.innerHTML = '▸' // chevron that will rotate 62 | icon.classList.add('has-children') 63 | } 64 | return icon 65 | } 66 | 67 | const toggleCollapse = (node: HeadingNode, icon: HTMLSpanElement) => { 68 | if (!node.childrenWrapper) return 69 | 70 | node.isCollapsed = !node.isCollapsed 71 | if (node.isCollapsed) { 72 | node.childrenWrapper.classList.add('collapsed') 73 | icon.classList.add('collapsed') 74 | } else { 75 | node.childrenWrapper.classList.remove('collapsed') 76 | icon.classList.remove('collapsed') 77 | } 78 | } 79 | 80 | const createLi = (text: string, href: string, headingTag: string, level: number) => { 81 | debugLog('createLi', text, href, headingTag) 82 | const li = document.createElement('li') 83 | const a = document.createElement('a') 84 | a.setAttribute('href', href) 85 | 86 | const icon = createCollapseIcon(false) 87 | const label = document.createElement('div') 88 | label.classList.add(`toc-label-${headingTag}`) 89 | label.innerText = text 90 | 91 | a.appendChild(icon) 92 | a.appendChild(label) 93 | li.appendChild(a) 94 | 95 | const node: HeadingNode = { 96 | element: li, 97 | level: level, 98 | children: null, 99 | childrenWrapper: null, 100 | isCollapsed: false, 101 | icon: icon, 102 | href: href 103 | } 104 | 105 | // Add click handler for collapse icon 106 | icon.addEventListener('click', (e) => { 107 | e.preventDefault() 108 | e.stopPropagation() 109 | toggleCollapse(node, icon) 110 | }) 111 | 112 | return { li, node, icon } 113 | } 114 | 115 | let lastParentAtLevel: Map = new Map() 116 | 117 | for (const h of headings) { 118 | const href = getHeadingHref(h) 119 | debugLog('heading and href', h, href) 120 | if (!href) { 121 | continue 122 | } 123 | 124 | const level = getHeadingLevel(h.tagName.toLowerCase()) 125 | const { li, node, icon } = createLi((h.textContent || '').trim(), href, h.tagName.toLowerCase(), level) 126 | 127 | // Store node by href for later lookup 128 | nodesByHref.set(href, node); 129 | 130 | // Find parent (closest heading with lower level) 131 | let parent: HeadingNode | null = null 132 | for (let parentLevel = level - 1; parentLevel >= 1; parentLevel--) { 133 | if (lastParentAtLevel.has(parentLevel)) { 134 | parent = lastParentAtLevel.get(parentLevel)! 135 | break 136 | } 137 | } 138 | 139 | if (parent) { 140 | // This is a child of a parent heading 141 | if (!parent.children) { 142 | // Create children container for parent 143 | parent.childrenWrapper = document.createElement('div') 144 | parent.childrenWrapper.classList.add('toc-children') 145 | 146 | parent.children = document.createElement('ul') 147 | parent.childrenWrapper.appendChild(parent.children) 148 | parent.element.appendChild(parent.childrenWrapper) 149 | 150 | // Update parent's icon to show it has children 151 | parent.icon.classList.add('has-children') 152 | parent.icon.innerHTML = '▸' 153 | } 154 | parent.children.appendChild(li) 155 | } else { 156 | // This is a top-level heading 157 | ul.appendChild(li) 158 | } 159 | 160 | // Update the last parent at this level 161 | lastParentAtLevel.set(level, node) 162 | // Clear all deeper levels 163 | for (let clearLevel = level + 1; clearLevel <= MAX_HEADING_LEVEL; clearLevel++) { 164 | lastParentAtLevel.delete(clearLevel) 165 | } 166 | } 167 | 168 | // Store nodesByHref on toc element for access in scroll handler 169 | (toc as any).__nodesByHref = nodesByHref; 170 | 171 | return toc 172 | } 173 | 174 | function activeTocLinkOnScroll(toc: HTMLDivElement, headings: NodeListOf) { 175 | const activeClass = 'active'; 176 | const nodesByHref = (toc as any).__nodesByHref as Map; 177 | 178 | function getLinkByHeading(heading: Element) { 179 | const href = getHeadingHref(heading); 180 | if (!href) return 181 | return toc.querySelector(`a[href="${href}"]`); 182 | } 183 | 184 | function getOffsetTop(heading: Element) { 185 | if (!heading.getClientRects().length) { 186 | return 0; 187 | } 188 | let rect = heading.getBoundingClientRect(); 189 | return rect.top 190 | } 191 | 192 | function expandParents(href: string) { 193 | const node = nodesByHref.get(href); 194 | if (!node) return; 195 | 196 | // Find all parent nodes and expand them 197 | let currentElement = node.element.parentElement; 198 | while (currentElement) { 199 | // Check if this is a collapsed children wrapper 200 | if (currentElement.classList.contains('toc-children') && currentElement.classList.contains('collapsed')) { 201 | // Find the parent node that owns this wrapper 202 | const parentLi = currentElement.parentElement as HTMLLIElement; 203 | if (parentLi) { 204 | for (const [, parentNode] of nodesByHref) { 205 | if (parentNode.element === parentLi && parentNode.childrenWrapper === currentElement) { 206 | // Expand this parent 207 | parentNode.isCollapsed = false; 208 | parentNode.childrenWrapper.classList.remove('collapsed'); 209 | parentNode.icon.classList.remove('collapsed'); 210 | break; 211 | } 212 | } 213 | } 214 | } 215 | currentElement = currentElement.parentElement; 216 | } 217 | } 218 | 219 | function activate(heading: Element, lastActiveHeading?: Element) { 220 | if (lastActiveHeading) { 221 | getLinkByHeading(lastActiveHeading)?.parentElement!.classList.remove(activeClass); 222 | } 223 | const href = getHeadingHref(heading); 224 | if (href) { 225 | expandParents(href); 226 | } 227 | getLinkByHeading(heading)?.parentElement!.classList.add(activeClass); 228 | } 229 | 230 | // active the first heading at the beginning 231 | let activeHeading: Element = headings[0]; 232 | activate(activeHeading) 233 | 234 | // makes the heading active before it reaches the top of the screen 235 | const offsetTopBuffer = 60; 236 | 237 | const onScroll = () => { 238 | const passedHeadings: Array = []; 239 | for (const h of headings) { 240 | if (getOffsetTop(h) < offsetTopBuffer) { 241 | passedHeadings.push(h) 242 | } else { 243 | break; 244 | } 245 | } 246 | let nextActiveHeading = passedHeadings.length > 0 ? passedHeadings[passedHeadings.length - 1] : headings[0] 247 | if (nextActiveHeading && nextActiveHeading != activeHeading) { 248 | activate(nextActiveHeading, activeHeading) 249 | activeHeading = nextActiveHeading 250 | } 251 | } 252 | 253 | document.addEventListener('scroll', throttle(onScroll, 100)); 254 | } 255 | 256 | function main() { 257 | // check if the url matches \/\w+/\w+$\ 258 | const projectPathRegex = /^\/[\w-]+\/[\w-]+\/?$/gm; 259 | if (!projectPathRegex.exec(window.location.pathname)) { 260 | console.log('not a project home path') 261 | return 262 | } 263 | 264 | // create section that will be added to sidebar later 265 | const section = document.createElement('section') 266 | section.classList.add(tocClassName) 267 | debugLog('create section', section) 268 | 269 | // create title for section 270 | const title = document.createElement('h2'); 271 | title.className = 'h4'; 272 | title.textContent = 'Outline'; 273 | section.appendChild(title) 274 | debugLog('create title', title) 275 | 276 | // get article and headings 277 | const article = document.querySelector('article') as HTMLElement 278 | const headings = article.querySelectorAll('h1, h2, h3, h4, h5') 279 | debugLog('article', article) 280 | debugLog('headings', headings) 281 | 282 | // create toc 283 | const toc = createToC(headings) 284 | toc.classList.add(tocContentClassName) 285 | section.appendChild(toc) 286 | debugLog('toc', toc) 287 | 288 | // add section to sidebar 289 | const elSidebarInner = document.querySelector('.Layout-sidebar > div')! 290 | elSidebarInner.appendChild(section) 291 | debugLog('sidebar inner', elSidebarInner) 292 | 293 | let isSticky = false 294 | const toggleSticky = (flag: boolean) => { 295 | if (flag) { 296 | toc.style.width = `${toc.offsetWidth}px` 297 | toc.classList.add(stickyClassName) 298 | } else { 299 | toc.classList.remove(stickyClassName) 300 | toc.style.width = 'auto' 301 | } 302 | isSticky = flag 303 | } 304 | 305 | // handle scroll 306 | const onScroll = () => { 307 | /* make toc sticky to top when scrolled by */ 308 | const rect = title.getBoundingClientRect(); 309 | // console.log('scroll', rect.top, rect.bottom) 310 | if (rect.bottom < 0 && !isSticky) { 311 | // sticky toc 312 | toggleSticky(true) 313 | } else if (rect.bottom > 0 && isSticky) { 314 | // unsticky toc 315 | toggleSticky(false) 316 | } 317 | } 318 | 319 | document.addEventListener('scroll', onScroll) 320 | 321 | // handle 322 | activeTocLinkOnScroll(toc, headings) 323 | } 324 | 325 | main() 326 | --------------------------------------------------------------------------------