├── .gitignore ├── README.md ├── README_zh.md ├── demo.webp ├── docs ├── .vitepress │ ├── config.ts │ └── theme │ │ └── index.ts ├── eee.md ├── index.md └── test.md ├── lib ├── index.d.ts ├── index.js └── index.ts ├── package.json ├── style ├── index.css ├── index.css.map └── index.scss └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | package-lock.json 4 | yarn.lock 5 | **/cache 6 | **/dist 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vitepress-plugin-codeblocks-fold 2 | 3 | [![npm](https://img.shields.io/npm/v/vitepress-plugin-codeblocks-fold?color=green)](https://www.npmjs.com/package/vitepress-plugin-codeblocks-fold) 4 | 5 | English | [简体中文](README_zh.md) 6 | 7 | ![](./demo.webp) 8 | 9 | > Add collapse to vitepress codeblocks 10 | 11 |
12 | update log 13 | 31 |
32 | 33 | ## Install 34 | 35 | ```shell 36 | // npm 37 | npm i vitepress-plugin-codeblocks-fold 38 | // yarn 39 | yarn add vitepress-plugin-codeblocks-fold 40 | ``` 41 | 42 | ## Use 43 | 44 | Use in `.vitepress/theme/index.js` 45 | 46 | ```js 47 | import DefaultTheme from 'vitepress/theme'; 48 | import { useData, useRoute } from 'vitepress'; 49 | import codeblocksFold from 'vitepress-plugin-codeblocks-fold'; // import method 50 | import 'vitepress-plugin-codeblocks-fold/style/index.css'; // import style 51 | 52 | export default { 53 | ...DefaultTheme, 54 | enhanceApp(ctx) { 55 | DefaultTheme.enhanceApp(ctx); 56 | // ... 57 | }, 58 | setup() { 59 | // get frontmatter and route 60 | const { frontmatter } = useData(); 61 | const route = useRoute(); 62 | // basic use 63 | codeblocksFold({route, frontmatter}); 64 | // configurable parameters 65 | // codeblocksFold({route, frontmatter}, true, 400); 66 | } 67 | }; 68 | ``` 69 | 70 | `codeblocksFold()` takes three parameters: 71 | 72 | - vitepressObj 73 | 74 | This is an object, there must be two values in the object: `route` and `frontmatter`。 75 | 76 | - defaultAllFold 77 | 78 | Whether the codeblocks of all pages are set to the collapsed state by default,default `true`; Set to 'false' to not fold by default. can be ignored. 79 | 80 | - height 81 | 82 | The height of the codeblocks after being folded, default `400`(unit`px`). can be ignored. 83 | 84 | ## Extended use 85 | 86 | You can set frontmatter to a single .md file 87 | 88 | ```md 89 | --- 90 | cbf: [1,2,3] 91 | --- 92 | ``` 93 | 94 | The meaning of this array is: 95 | 96 | - When 'defaultAllFold' is set to 'true' (that is all pages are folded by default), 97 | the first、second and third code blocks of the current page are forcibly not folded 98 | - When 'defaultAllFold' is set to 'false' (that is all pages are not folded by default), 99 | the first、second and third code blocks of the current page are forcibly folded 100 | 101 | `cbf` also has two parameters: `true` and `false` 102 | 103 | - `true` means that all code blocks on the current page are folded 104 | - `false` means that all code blocks on the current page are not folded 105 | 106 | ## more vitepress plugins 107 | 108 | You may be interested in these plugins: 109 | [Click me to view more vitepress plugins](https://github.com/T-miracle/vitepress-plugins) 110 | 111 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # vitepress-plugin-codeblocks-fold 2 | 3 | [![npm](https://img.shields.io/npm/v/vitepress-plugin-codeblocks-fold?color=green)](https://www.npmjs.com/package/vitepress-plugin-codeblocks-fold) 4 | 5 | ![](./demo.webp) 6 | 7 | > 给 vitepress 代码块添加折叠功能 8 | 9 |
10 | 更新日志 11 | 29 |
30 | 31 | ## 安装 32 | 33 | ```shell 34 | // npm 35 | npm i vitepress-plugin-codeblocks-fold 36 | // yarn 37 | yarn add vitepress-plugin-codeblocks-fold 38 | ``` 39 | 40 | ## 使用 41 | 42 | 在 `.vitepress/theme/index.js` 中使用 43 | 44 | ```js 45 | import DefaultTheme from 'vitepress/theme'; 46 | import { useData, useRoute } from 'vitepress'; 47 | import codeblocksFold from 'vitepress-plugin-codeblocks-fold'; // 导入方法 48 | import 'vitepress-plugin-codeblocks-fold/style/index.css'; // 导入样式 49 | 50 | export default { 51 | ...DefaultTheme, 52 | enhanceApp(ctx) { 53 | DefaultTheme.enhanceApp(ctx); 54 | // ... 55 | }, 56 | setup() { 57 | // 获取前言和路由 58 | const { frontmatter } = useData(); 59 | const route = useRoute(); 60 | // 基础使用 61 | codeblocksFold({ route, frontmatter }); 62 | // 可配置参数 63 | // codeblocksFold({ route, frontmatter }, true, 400); 64 | } 65 | }; 66 | ``` 67 | 68 | `codeblocksFold()` 接收三个参数: 69 | 70 | - vitepressObj 71 | 72 | 这是一个对象,对象里面必须有两个值:路由和前言。 73 | 74 | - defaultAllFold 75 | 76 | 是否默认所有页面的代码块都设置成折叠状态,默认为 `true`;设置成 `false` 则默认不折叠。可以忽略不填。 77 | 78 | - height 79 | 80 | 代码块被折叠后的高度,默认为 `400`(单位`px`)。可以忽略不填。 81 | 82 | ## 扩展使用 83 | 84 | 单个.md文件可以设置前言 85 | 86 | ```md 87 | --- 88 | cbf: [1,2,3] 89 | --- 90 | ``` 91 | 92 | 该数组含义为: 93 | 94 | - 当 `defaultAllFold` 设置为 `true` (即默认全部页面开启折叠)时,当前页面第 1、2、3 个代码块强制不开启折叠 95 | - 当 `defaultAllFold` 设置为 `false` (即默认全部页面不开启折叠)时,当前页面第 1、2、3 个代码块强制开启折叠 96 | 97 | `cbf` 还有两个参数:`true` 和 `false` 98 | 99 | - `true` 表示当前页面所有代码块开启折叠 100 | - `false` 表示当前页面所有代码块不开启折叠 101 | 102 | ## 更多vitepress插件 103 | 104 | 这些插件你可能会感兴趣:[点我查看更多vitepress插件](https://github.com/T-miracle/vitepress-plugins) 105 | 106 | -------------------------------------------------------------------------------- /demo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/T-miracle/vitepress-plugin-codeblocks-fold/3e784c7fc15f07af5b31597eb7de38287efbbe85/demo.webp -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | 3 | export default defineConfig({ 4 | // lang: "zh-CN", 5 | title: 'test', 6 | markdown: { 7 | lineNumbers: true, 8 | theme: { 9 | light: 'vitesse-light', 10 | dark: 'vitesse-dark', 11 | } 12 | }, 13 | // 主题配置 14 | themeConfig: { 15 | sidebar: [ 16 | {text: 'index', link : '/index.md'}, 17 | {text: 'test', link : '/test.md'}, 18 | {text: 'eee', link : '/eee.md'}, 19 | ] 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { EnhanceAppContext, useData, useRoute } from 'vitepress'; 2 | import DefaultTheme from 'vitepress/theme'; 3 | import codeblocksFold from '../../../lib/index.ts'; 4 | import '../../../style/index.scss' 5 | 6 | export default { 7 | ...DefaultTheme, 8 | enhanceApp(ctx: EnhanceAppContext) { 9 | DefaultTheme.enhanceApp(ctx); 10 | }, 11 | setup() { 12 | // 获取前言和路由 13 | const { frontmatter } = useData(); 14 | const route = useRoute(); 15 | codeblocksFold({route, frontmatter}) 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /docs/eee.md: -------------------------------------------------------------------------------- 1 | # eee 2 | 3 | ## test1 4 | 5 | ```shell 6 | 12131313 7 | 1312312312 8 | 1 9 | 31 10 | 3131 11 | 3 12 | 123 13 | 12 14 | 321 15 | 3 16 | 21 17 | 3 18 | 123 19 | 1 20 | 3 21 | 1 22 | 31 23 | 3 24 | 25 | 1 26 | 12131313 27 | 1312312312 28 | 1 29 | 31 30 | 3131 31 | 3 32 | 123 33 | 12 34 | 321 35 | 3 36 | 21 37 | 3 38 | 123 39 | 1 40 | 3 41 | 1 42 | 31 43 | 3 44 | 45 | 1 46 | 12131313 47 | 1312312312 48 | 1 49 | 31 50 | 3131 51 | 3 52 | 123 53 | 12 54 | 321 55 | 3 56 | 21 57 | 3 58 | 123 59 | 1 60 | 3 61 | 1 62 | 31 63 | 3 64 | 65 | 1 66 | ``` 67 | 68 | ## test2 69 | 70 | ```shell 71 | 12131313 72 | 1312312312 73 | 1 74 | 31 75 | 3131 76 | 3 77 | 123 78 | 12 79 | 321 80 | 3 81 | 21 82 | 3 83 | 123 84 | 1 85 | 3 86 | 1 87 | 31 88 | 3 89 | 90 | 1 91 | 12131313 92 | 1312312312 93 | 1 94 | 31 95 | 3131 96 | 3 97 | 123 98 | 12 99 | 321 100 | 3 101 | 21 102 | 3 103 | 123 104 | 1 105 | 3 106 | 1 107 | 31 108 | 3 109 | 110 | 1 111 | 12131313 112 | 1312312312 113 | 1 114 | 31 115 | 3131 116 | 3 117 | 123 118 | 12 119 | 321 120 | 3 121 | 21 122 | 3 123 | 123 124 | 1 125 | 3 126 | 1 127 | 31 128 | 3 129 | 130 | 1 131 | ``` 132 | 133 | ## test3 134 | 135 | ```shell 136 | 12131313 137 | 1312312312 138 | 1 139 | 31 140 | 3131 141 | 3 142 | 123 143 | 12 144 | 321 145 | 3 146 | 21 147 | 3 148 | 123 149 | 1 150 | 3 151 | 1 152 | 31 153 | 3 154 | 155 | 1 156 | 12131313 157 | 1312312312 158 | 1 159 | 31 160 | 3131 161 | 3 162 | 123 163 | 12 164 | 321 165 | 3 166 | 21 167 | 3 168 | 123 169 | 1 170 | 3 171 | 1 172 | 31 173 | 3 174 | 175 | 1 176 | 12131313 177 | 1312312312 178 | 1 179 | 31 180 | 3131 181 | 3 182 | 123 183 | 12 184 | 321 185 | 3 186 | 21 187 | 3 188 | 123 189 | 1 190 | 3 191 | 1 192 | 31 193 | 3 194 | 195 | 1 196 | ``` 197 | 198 | ## test4 199 | 200 | ```shell 201 | 12131313 202 | 1312312312 203 | 1 204 | 31 205 | 3131 206 | 3 207 | 123 208 | 12 209 | 321 210 | 3 211 | 21 212 | 3 213 | 123 214 | 1 215 | 3 216 | 1 217 | 31 218 | 3 219 | 220 | 1 221 | 12131313 222 | 1312312312 223 | 1 224 | 31 225 | 3131 226 | 3 227 | 123 228 | 12 229 | 321 230 | 3 231 | 21 232 | 3 233 | 123 234 | 1 235 | 3 236 | 1 237 | 31 238 | 3 239 | 240 | 1 241 | 12131313 242 | 1312312312 243 | 1 244 | 31 245 | 3131 246 | 3 247 | 123 248 | 12 249 | 321 250 | 3 251 | 21 252 | 3 253 | 123 254 | 1 255 | 3 256 | 1 257 | 31 258 | 3 259 | 260 | 1 261 | ``` 262 | 263 | 1313131 264 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | cbf: true 3 | --- 4 | 5 | # Index 6 | 7 | [jump eee.md test3 title](./eee.md#test3) 8 | 9 | :::code-group 10 | 11 | ```vue [Vue2] 12 | 15 | 16 | 27 | 28 | 39 | 40 | 51 | ``` 52 | 53 | ```vue [Vue3] 54 | 55 | 61 | ``` 62 | 63 | ```vue [Vue3] 64 | 65 | 71 | 72 | 78 | 79 | 85 | 86 | 92 | ``` 93 | 94 | ::: 95 | 96 | 1823781738131 97 | 98 | sadiadkjaida 99 | 100 | 1233131 101 | -------------------------------------------------------------------------------- /docs/test.md: -------------------------------------------------------------------------------- 1 | # test 2 | 3 | :::code-group 4 | 5 | ```vue [Vue2] 6 | 7 | 18 | ``` 19 | 20 | ```vue [Vue3] 21 | 22 | 28 | ``` 29 | 30 | ```vue [Vue3] 31 | 32 | 38 | 39 | 45 | 46 | 52 | 53 | 59 | ``` 60 | 61 | ::: 62 | 63 | 123131231 64 | 65 | 123133131 66 | 67 | 1232313231 68 | -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from 'vue'; 2 | import { PageData, Route } from 'vitepress'; 3 | type vitepressAPI = { 4 | frontmatter: Ref; 5 | route: Route; 6 | }; 7 | /** 8 | * Set codeblocks folding. 设置代码块折叠 9 | * @param {vitepressAPI} vitepressObj route and frontmatter. 路由与前言 10 | * @param [defaultAllFold] Collapse all by default? 默认全部折叠? 11 | * @param [height] The height of the folded codeblocks(default 400px). 折叠后的代码块高度(默认 400px) 12 | */ 13 | declare const codeblocksFold: (vitepressObj: vitepressAPI, defaultAllFold?: boolean, height?: number) => void; 14 | export default codeblocksFold; 15 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import { nextTick, onMounted, watch } from 'vue'; 2 | let themeChangeObserve = null; 3 | /** 4 | * 设置代码块折叠功能 5 | * @param frontmatter 前言 6 | * @param defaultAllFold 默认全部折叠 7 | * @param height 高度 8 | */ 9 | const cbf = (frontmatter, defaultAllFold, height) => { 10 | // 获取前言值 11 | let fm = true; 12 | if (frontmatter.value && frontmatter.value.cbf !== undefined) { 13 | fm = frontmatter.value.cbf; 14 | } 15 | // 获取文章里的所有代码块 16 | const codeblocks = document.querySelectorAll('.vp-doc [class*="language-"]'); 17 | // 遍历给代码块添加折叠 18 | codeblocks.forEach((el, index) => { 19 | const element = el; 20 | if (element.offsetHeight !== 0 && element.offsetHeight <= height) { 21 | return; 22 | } 23 | if (Array.isArray(fm)) { // 如果是数组 24 | if (defaultAllFold) { 25 | if (fm.indexOf(index + 1) === -1) { 26 | judge(element, height); 27 | } 28 | } 29 | else { 30 | if (fm.indexOf(index + 1) !== -1) { 31 | judge(element, height); 32 | } 33 | } 34 | } 35 | else { // 如果是布尔值 36 | if (defaultAllFold && fm) { 37 | judge(element, height); 38 | } 39 | } 40 | }); 41 | // 使用高刷新率动画定位到锚点 42 | let time = codeblocks.length; 43 | function step() { 44 | if (time !== 0) { 45 | window.requestAnimationFrame(() => { 46 | jumpHashLink(); 47 | step(); 48 | }); 49 | time--; 50 | } 51 | } 52 | window.requestAnimationFrame(step); 53 | !themeChangeObserve && themeChangeObserver(); 54 | }; 55 | /** 56 | * 兼容代码块组 57 | * @param el 元素 58 | * @param height 限制高度 59 | */ 60 | const observer = (el, height) => { 61 | new MutationObserver((mutations) => { 62 | mutations.forEach((mutation) => { 63 | const _el = mutation.target; 64 | if (mutation.attributeName === 'class' && _el.classList.contains('active') && _el.offsetHeight > height) { 65 | fold(el, height); 66 | } 67 | }); 68 | }).observe(el, { 69 | attributeFilter: ['class'] 70 | }); 71 | }; 72 | /** 73 | * 判断是否是代码块组中未显示的代码块 74 | * @param el 元素 75 | * @param height 高度 76 | */ 77 | const judge = (el, height) => { 78 | const displayStatus = window.getComputedStyle(el, null).getPropertyValue('display'); 79 | const isDetailBlock = el.parentElement.classList.contains('details'); 80 | if (displayStatus === 'none' || isDetailBlock) { 81 | observer(el, height); 82 | } 83 | else { 84 | fold(el, height); 85 | } 86 | }; 87 | /** 88 | * 折叠与展开 89 | * @param el 代码块元素 90 | * @param height 限制高度 91 | */ 92 | const fold = (el, height) => { 93 | if (el.classList.contains('fold')) { 94 | return; 95 | } 96 | el.classList.add('fold'); 97 | const pres = el.querySelectorAll('pre'); 98 | pres.forEach(pre => { 99 | pre.style.height = height + 'px'; 100 | pre.style.overflow = 'hidden'; 101 | }); 102 | el.style.marginBottom = '48px'; 103 | el.style.borderRadius = '8px 8px 0 0'; 104 | const foldBtn = document.createElement('div'); 105 | const mask = document.createElement('div'); 106 | mask.style.backgroundImage = 'linear-gradient(-180deg, rgba(0, 0, 0, 0) 0%, var(--vp-code-block-bg) 100%)'; 107 | mask.className = 'codeblocks-mask'; 108 | foldBtn.style.backgroundColor = 'var(--vp-code-block-bg)'; 109 | foldBtn.className = 'fold-btn'; 110 | foldBtn.insertAdjacentHTML('afterbegin', ``); 111 | el.appendChild(mask); 112 | el.appendChild(foldBtn); 113 | // 添加折叠事件 114 | foldBtn.onclick = () => { 115 | const maskElement = el.querySelector('.codeblocks-mask'); 116 | const iconElement = el.querySelector('.fold-btn-icon'); 117 | pres.forEach(pre => { 118 | foldBtnEvent({ pre, foldBtn, iconElement, maskElement }, height); 119 | }); 120 | }; 121 | }; 122 | /** 123 | * 折叠事件 124 | * @param els 元素对象 125 | * @param height 高度 126 | */ 127 | const foldBtnEvent = (els, height) => { 128 | const { pre, foldBtn, iconElement, maskElement } = els; 129 | if (pre.classList.contains('expand')) { // 折叠 130 | const oldPos = foldBtn.getBoundingClientRect().top; 131 | pre.style.height = height + 'px'; 132 | pre.style.overflow = 'hidden'; 133 | pre.scrollTo(0, 0); 134 | pre.classList.remove('expand'); 135 | maskElement.style.height = '48px'; 136 | iconElement.classList.remove('turn'); 137 | // 保持按钮位置并滚动页面 138 | window.scrollTo(0, foldBtn.getBoundingClientRect().top + window.scrollY - oldPos); 139 | } 140 | else { // 展开 141 | pre.style.height = 'auto'; 142 | pre.style.overflow = 'auto'; 143 | pre.classList.add('expand'); 144 | maskElement.style.height = '0'; 145 | iconElement.classList.add('turn'); 146 | } 147 | }; 148 | const rebindListener = (height) => { 149 | // console.log('重新绑定监听...') 150 | const codeblocks = document.querySelectorAll('.vp-doc [class*="language-"]'); 151 | codeblocks.forEach(el => { 152 | const foldBtn = el.querySelector('.fold-btn'); 153 | // console.log(`--->`, foldBtn?.onclick) 154 | if (foldBtn && !foldBtn.onclick) { 155 | foldBtn.onclick = () => { 156 | const pre = el.querySelector('pre'); 157 | const maskElement = el.querySelector('.codeblocks-mask'); 158 | const iconElement = el.querySelector('.fold-btn-icon'); 159 | foldBtnEvent({ pre, foldBtn, iconElement, maskElement }, height); 160 | }; 161 | } 162 | }); 163 | }; 164 | function isRGBA(value) { 165 | // 使用正则表达式匹配 RGBA 值的模式 166 | const rgbaPattern = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\)$/i; 167 | // 使用 test 方法检查值是否符合模式 168 | return rgbaPattern.test(value); 169 | } 170 | const themeChangeObserver = () => { 171 | hideMask(); 172 | themeChangeObserve = new MutationObserver((mutations) => { 173 | mutations.forEach((mutation) => { 174 | if (mutation.attributeName === 'class') { 175 | // console.log(`hideMask---${new Date()}`) 176 | hideMask(); 177 | } 178 | }); 179 | }); 180 | themeChangeObserve.observe(document.querySelector('html'), { 181 | attributeFilter: ['class'] 182 | }); 183 | }; 184 | const hideMask = () => { 185 | if (document.querySelector('.vp-doc [class*="language-"]')) { 186 | let _isRGBA = isRGBA(window.getComputedStyle(document.querySelector('.vp-doc [class*="language-"]'), null).getPropertyValue('background-color')); 187 | // console.log(`isRGBA`, _isRGBA) 188 | if (_isRGBA) { 189 | nextTick(() => { 190 | document.querySelectorAll('.codeblocks-mask').forEach(item => { 191 | // console.log(`display`); 192 | item.style.display = 'none'; 193 | }); 194 | }).then(); 195 | } 196 | else { 197 | nextTick(() => { 198 | document.querySelectorAll('.codeblocks-mask').forEach(item => { 199 | item.style.display = ''; 200 | }); 201 | }).then(); 202 | } 203 | } 204 | }; 205 | const jumpHashLink = () => { 206 | // 获取url中的锚点 207 | const hash = location.hash; 208 | // 如果有锚点,滚动到锚点位置 209 | if (hash) { 210 | // hash解码 211 | const _hash = decodeURIComponent(hash); 212 | const target = document.querySelector(_hash); 213 | const headerHeight = document.querySelector('.VPNav')?.clientHeight ?? 0; 214 | if (target) { 215 | // 不带动画滚动 216 | window.scrollTo(0, target.getBoundingClientRect().top + window.scrollY - headerHeight); 217 | } 218 | } 219 | }; 220 | /** 221 | * Set codeblocks folding. 设置代码块折叠 222 | * @param {vitepressAPI} vitepressObj route and frontmatter. 路由与前言 223 | * @param [defaultAllFold] Collapse all by default? 默认全部折叠? 224 | * @param [height] The height of the folded codeblocks(default 400px). 折叠后的代码块高度(默认 400px) 225 | */ 226 | const codeblocksFold = (vitepressObj, defaultAllFold = true, height = 400) => { 227 | const { frontmatter, route } = vitepressObj; 228 | onMounted(() => { 229 | nextTick(() => { 230 | cbf(frontmatter, defaultAllFold, height); 231 | rebindListener(height); 232 | }).catch(); 233 | }); 234 | watch(() => route.path, () => { 235 | nextTick(() => { 236 | cbf(frontmatter, defaultAllFold, height); 237 | rebindListener(height); 238 | }).catch(); 239 | }); 240 | }; 241 | export default codeblocksFold; 242 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import { nextTick, onMounted, Ref, watch } from 'vue'; 2 | import { PageData, Route } from 'vitepress'; 3 | 4 | type vitepressAPI = { 5 | frontmatter: Ref, 6 | route: Route 7 | } 8 | 9 | let themeChangeObserve: any = null; 10 | 11 | /** 12 | * 设置代码块折叠功能 13 | * @param frontmatter 前言 14 | * @param defaultAllFold 默认全部折叠 15 | * @param height 高度 16 | */ 17 | const cbf = (frontmatter: Ref, defaultAllFold: boolean, height: number) => { 18 | // 获取前言值 19 | let fm: number[] | boolean = true; 20 | if (frontmatter.value && frontmatter.value.cbf !== undefined) { 21 | fm = frontmatter.value.cbf; 22 | } 23 | // 获取文章里的所有代码块 24 | const codeblocks = document.querySelectorAll('.vp-doc [class*="language-"]'); 25 | // 遍历给代码块添加折叠 26 | codeblocks.forEach((el: Element, index: number) => { 27 | const element = el as HTMLElement; 28 | if (element.offsetHeight !== 0 && element.offsetHeight <= height) { 29 | return; 30 | } 31 | if (Array.isArray(fm)) { // 如果是数组 32 | if (defaultAllFold) { 33 | if (fm.indexOf(index + 1) === -1) { 34 | judge(element, height); 35 | } 36 | } else { 37 | if (fm.indexOf(index + 1) !== -1) { 38 | judge(element, height); 39 | } 40 | } 41 | } else { // 如果是布尔值 42 | if (defaultAllFold && fm) { 43 | judge(element, height); 44 | } 45 | } 46 | }); 47 | 48 | // 使用高刷新率动画定位到锚点 49 | let time: number = codeblocks.length; 50 | function step() { 51 | if (time !== 0) { 52 | window.requestAnimationFrame(() => { 53 | jumpHashLink(); 54 | step(); 55 | }); 56 | time--; 57 | } 58 | } 59 | window.requestAnimationFrame(step); 60 | 61 | !themeChangeObserve && themeChangeObserver(); 62 | }; 63 | 64 | /** 65 | * 兼容代码块组 66 | * @param el 元素 67 | * @param height 限制高度 68 | */ 69 | const observer = (el: HTMLElement, height: number) => { 70 | new MutationObserver((mutations) => { 71 | mutations.forEach((mutation) => { 72 | const _el = mutation.target as HTMLElement; 73 | if (mutation.attributeName === 'class' && _el.classList.contains('active') && _el.offsetHeight > height) { 74 | fold(el, height); 75 | } 76 | }); 77 | }).observe(el, { 78 | attributeFilter: [ 'class' ] 79 | }); 80 | }; 81 | 82 | /** 83 | * 判断是否是代码块组中未显示的代码块 84 | * @param el 元素 85 | * @param height 高度 86 | */ 87 | const judge = (el: HTMLElement, height: number) => { 88 | const displayStatus: string = window.getComputedStyle(el, null).getPropertyValue('display'); 89 | const isDetailBlock: boolean = el.parentElement!.classList.contains('details'); 90 | if (displayStatus === 'none' || isDetailBlock) { 91 | observer(el, height); 92 | } else { 93 | fold(el, height); 94 | } 95 | }; 96 | 97 | /** 98 | * 折叠与展开 99 | * @param el 代码块元素 100 | * @param height 限制高度 101 | */ 102 | const fold = (el: HTMLElement, height: number) => { 103 | if (el.classList.contains('fold')) { 104 | return; 105 | } 106 | el.classList.add('fold'); 107 | const pres = el.querySelectorAll('pre')!; 108 | pres.forEach(pre => { 109 | pre.style.height = height + 'px'; 110 | pre.style.overflow = 'hidden'; 111 | }); 112 | el.style.marginBottom = '48px'; 113 | el.style.borderRadius = '8px 8px 0 0'; 114 | const foldBtn = document.createElement('div'); 115 | const mask = document.createElement('div'); 116 | mask.style.backgroundImage = 'linear-gradient(-180deg, rgba(0, 0, 0, 0) 0%, var(--vp-code-block-bg) 100%)'; 117 | mask.className = 'codeblocks-mask'; 118 | foldBtn.style.backgroundColor = 'var(--vp-code-block-bg)'; 119 | foldBtn.className = 'fold-btn'; 120 | foldBtn.insertAdjacentHTML('afterbegin', ``); 121 | el.appendChild(mask); 122 | el.appendChild(foldBtn); 123 | 124 | // 添加折叠事件 125 | foldBtn.onclick = () => { 126 | const maskElement = el.querySelector('.codeblocks-mask') as HTMLElement; 127 | const iconElement = el.querySelector('.fold-btn-icon') as HTMLElement; 128 | pres.forEach(pre => { 129 | foldBtnEvent({ pre, foldBtn, iconElement, maskElement }, height); 130 | }); 131 | }; 132 | }; 133 | 134 | /** 135 | * 折叠事件 136 | * @param els 元素对象 137 | * @param height 高度 138 | */ 139 | const foldBtnEvent = (els: { 140 | pre: HTMLElement, 141 | foldBtn: HTMLElement, 142 | iconElement: HTMLElement, 143 | maskElement: HTMLElement 144 | }, height: number) => { 145 | const { pre, foldBtn, iconElement, maskElement } = els; 146 | if (pre!.classList.contains('expand')) { // 折叠 147 | const oldPos = foldBtn.getBoundingClientRect().top; 148 | pre!.style.height = height + 'px'; 149 | pre!.style.overflow = 'hidden'; 150 | pre!.scrollTo(0, 0); 151 | pre!.classList.remove('expand'); 152 | maskElement.style.height = '48px'; 153 | iconElement.classList.remove('turn'); 154 | // 保持按钮位置并滚动页面 155 | window.scrollTo(0, foldBtn.getBoundingClientRect().top + window.scrollY - oldPos); 156 | } else { // 展开 157 | pre!.style.height = 'auto'; 158 | pre!.style.overflow = 'auto'; 159 | pre!.classList.add('expand'); 160 | maskElement.style.height = '0'; 161 | iconElement.classList.add('turn'); 162 | } 163 | }; 164 | 165 | const rebindListener = (height: number) => { 166 | // console.log('重新绑定监听...') 167 | const codeblocks = document.querySelectorAll('.vp-doc [class*="language-"]'); 168 | codeblocks.forEach(el => { 169 | const foldBtn = el.querySelector('.fold-btn') as HTMLElement; 170 | // console.log(`--->`, foldBtn?.onclick) 171 | if (foldBtn && !foldBtn.onclick) { 172 | foldBtn.onclick = () => { 173 | const pre = el.querySelector('pre') as HTMLElement; 174 | const maskElement = el.querySelector('.codeblocks-mask') as HTMLElement; 175 | const iconElement = el.querySelector('.fold-btn-icon') as HTMLElement; 176 | foldBtnEvent({ pre, foldBtn, iconElement, maskElement }, height); 177 | }; 178 | } 179 | }); 180 | }; 181 | 182 | function isRGBA(value: string) { 183 | // 使用正则表达式匹配 RGBA 值的模式 184 | const rgbaPattern = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\)$/i; 185 | 186 | // 使用 test 方法检查值是否符合模式 187 | return rgbaPattern.test(value); 188 | } 189 | 190 | const themeChangeObserver = () => { 191 | hideMask(); 192 | themeChangeObserve = new MutationObserver((mutations) => { 193 | mutations.forEach((mutation) => { 194 | if (mutation.attributeName === 'class') { 195 | // console.log(`hideMask---${new Date()}`) 196 | hideMask(); 197 | } 198 | }); 199 | }); 200 | themeChangeObserve.observe(document.querySelector('html')!, { 201 | attributeFilter: [ 'class' ] 202 | }); 203 | }; 204 | 205 | const hideMask = () => { 206 | if (document.querySelector('.vp-doc [class*="language-"]')) { 207 | let _isRGBA: boolean = isRGBA(window.getComputedStyle(document.querySelector('.vp-doc [class*="language-"]')!, null).getPropertyValue('background-color')); 208 | // console.log(`isRGBA`, _isRGBA) 209 | if (_isRGBA) { 210 | nextTick(() => { 211 | document.querySelectorAll('.codeblocks-mask').forEach(item => { 212 | // console.log(`display`); 213 | (item as HTMLElement).style.display = 'none'; 214 | }); 215 | }).then(); 216 | } else { 217 | nextTick(() => { 218 | document.querySelectorAll('.codeblocks-mask').forEach(item => { 219 | (item as HTMLElement).style.display = ''; 220 | }); 221 | }).then(); 222 | } 223 | } 224 | }; 225 | 226 | const jumpHashLink = () => { 227 | // 获取url中的锚点 228 | const hash = location.hash; 229 | // 如果有锚点,滚动到锚点位置 230 | if (hash) { 231 | // hash解码 232 | const _hash = decodeURIComponent(hash); 233 | const target = document.querySelector(_hash); 234 | const headerHeight = document.querySelector('.VPNav')?.clientHeight ?? 0; 235 | if (target) { 236 | // 不带动画滚动 237 | window.scrollTo(0, target.getBoundingClientRect().top + window.scrollY - headerHeight); 238 | } 239 | } 240 | }; 241 | 242 | /** 243 | * Set codeblocks folding. 设置代码块折叠 244 | * @param {vitepressAPI} vitepressObj route and frontmatter. 路由与前言 245 | * @param [defaultAllFold] Collapse all by default? 默认全部折叠? 246 | * @param [height] The height of the folded codeblocks(default 400px). 折叠后的代码块高度(默认 400px) 247 | */ 248 | const codeblocksFold = (vitepressObj: vitepressAPI, defaultAllFold: boolean = true, height: number = 400) => { 249 | const { frontmatter, route } = vitepressObj; 250 | 251 | onMounted(() => { 252 | nextTick(() => { 253 | cbf(frontmatter, defaultAllFold, height); 254 | rebindListener(height); 255 | }).catch(); 256 | }) 257 | 258 | watch(() => route.path, () => { 259 | nextTick(() => { 260 | cbf(frontmatter, defaultAllFold, height); 261 | rebindListener(height); 262 | }).catch(); 263 | }); 264 | }; 265 | 266 | export default codeblocksFold; 267 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vitepress-plugin-codeblocks-fold", 3 | "version": "1.2.34", 4 | "description": "Add collapse to vitepress codeblocks", 5 | "type": "module", 6 | "main": "./lib/index.js", 7 | "types": "./lib/index.d.ts", 8 | "scripts": { 9 | "run": "vitepress dev docs", 10 | "dev": "vitepress dev", 11 | "build": "vitepress build" 12 | }, 13 | "files": [ 14 | "lib", 15 | "style" 16 | ], 17 | "keywords": [ 18 | "vitepress", 19 | "vitepress-plugin", 20 | "code-blocks", 21 | "fold" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/T-miracle/vitepress-plugin-codeblocks-fold.git" 26 | }, 27 | "author": "T-miracle", 28 | "license": "MIT", 29 | "devDependencies": { 30 | "sass": "^1.62.0", 31 | "vitepress": "^1.0.0-alpha.65", 32 | "vue": "^3.2.47" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | .vp-doc div.fold[class*=language-]{overflow:unset;margin-bottom:var(--codeblocks-margin-bottom)}.vp-doc div.fold[class*=language-]>.line-numbers-wrapper{overflow:hidden}.vp-doc div.fold[class*=language-]>.codeblocks-mask{display:block;position:absolute;left:0;bottom:0;height:48px;width:100%;z-index:9;pointer-events:none}.vp-doc div.fold[class*=language-]>.fold-btn{display:flex;position:absolute;left:0;bottom:-36px;height:36px;width:100%;z-index:9;border-radius:0 0 8px 8px;cursor:pointer;user-select:none;justify-content:center;align-items:center}.vp-doc div.fold[class*=language-]>.fold-btn>svg.fold-btn-icon{animation:float1 infinite .8s}.vp-doc div.fold[class*=language-]>.fold-btn>svg.fold-btn-icon.turn{animation:float2 infinite .8s}@keyframes float1{0%{transform:translateY(0px)}50%{transform:translateY(-5px)}100%{transform:translateY(0px)}}@keyframes float2{0%{transform:translateY(0px) rotate(180deg)}50%{transform:translateY(-5px) rotate(180deg)}100%{transform:translateY(0px) rotate(180deg)}}.vp-doc pre.shiki{scrollbar-width:auto !important;scrollbar-color:rgba(0,0,0,0) rgba(0,0,0,0) !important}.vp-doc pre.shiki::-webkit-scrollbar{width:4px !important;height:4px !important;background-color:rgba(0,0,0,0)}.vp-doc pre.shiki::-webkit-scrollbar-thumb{height:10px !important;outline-offset:0 !important;outline:unset !important;border-radius:2px !important;border:1px rgba(0,0,0,0) solid !important;background-color:rgba(0,0,0,0) !important}.vp-doc pre.shiki::-webkit-scrollbar-thumb:hover{height:10px !important;border-radius:2px !important;background-color:rgba(0,0,0,0) !important}.vp-doc pre.shiki::-webkit-scrollbar-track-piece{-webkit-border-radius:0 !important;background-color:rgba(0,0,0,0) !important}.vp-doc pre.shiki::-webkit-scrollbar-track{right:0 !important;background-color:rgba(0,0,0,0) !important}.vp-doc pre.shiki::-webkit-scrollbar-button{height:4px !important;width:4px !important;background-color:rgba(0,0,0,0) !important}.vp-doc pre.shiki::-webkit-scrollbar-corner{background-color:rgba(0,0,0,0) !important}/*# sourceMappingURL=index.css.map */ 2 | -------------------------------------------------------------------------------- /style/index.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["index.scss"],"names":[],"mappings":"AACI,mCACI,eACA,8CAEA,yDACI,gBAGJ,oDACI,cACA,kBACA,OACA,SACA,YACA,WACA,UACA,oBAGJ,6CACI,aACA,kBACA,OACA,aACA,YACA,WACA,UACA,0BACA,eACA,iBACA,uBACA,mBAEA,+DACI,8BAEA,oEACI,8BAKJ,kBACI,GACI,0BAEJ,IACI,2BAEJ,KACI,2BARR,kBACI,GACI,yCAEJ,IACI,0CAEJ,KACI,0CAQpB,kBAEI,gCACA,uDAGA,qCACI,qBACA,sBACA,+BAIJ,2CACI,uBACA,4BACA,yBACA,6BACA,0CACA,0CAIJ,iDACI,uBACA,6BACA,0CAIJ,iDACI,mCACA,0CAIJ,2CACI,mBACA,0CAIJ,4CAEI,sBACA,qBACA,0CAIJ,4CACI","file":"index.css"} -------------------------------------------------------------------------------- /style/index.scss: -------------------------------------------------------------------------------- 1 | .vp-doc { 2 | div.fold[class*="language-"] { 3 | overflow: unset; 4 | margin-bottom: var(--codeblocks-margin-bottom); 5 | 6 | > .line-numbers-wrapper { 7 | overflow: hidden; 8 | } 9 | 10 | > .codeblocks-mask { 11 | display: block; 12 | position: absolute; 13 | left: 0; 14 | bottom: 0; 15 | height: 48px; 16 | width: 100%; 17 | z-index: 9; 18 | pointer-events: none; 19 | } 20 | 21 | > .fold-btn { 22 | display: flex; 23 | position: absolute; 24 | left: 0; 25 | bottom: -36px; 26 | height: 36px; 27 | width: 100%; 28 | z-index: 9; 29 | border-radius: 0 0 8px 8px; 30 | cursor: pointer; 31 | user-select: none; 32 | justify-content: center; 33 | align-items: center; 34 | 35 | > svg.fold-btn-icon { 36 | animation: float1 infinite .8s; 37 | 38 | &.turn { 39 | animation: float2 infinite .8s; 40 | } 41 | } 42 | 43 | @for $i from 1 through 2 { 44 | @keyframes float#{$i} { 45 | 0% { 46 | transform: translateY(0px) if($i == 2, rotate(180deg), null); 47 | } 48 | 50% { 49 | transform: translateY(-5px) if($i == 2, rotate(180deg), null); 50 | } 51 | 100% { 52 | transform: translateY(0px) if($i == 2, rotate(180deg), null); 53 | } 54 | } 55 | } 56 | } 57 | } 58 | 59 | /* 设置全局滚动条样式 */ 60 | pre.shiki { 61 | /*--ms-滚动条--*/ 62 | scrollbar-width: auto !important; 63 | scrollbar-color: transparent transparent !important; 64 | 65 | /*-webkit-滚动条*/ 66 | &::-webkit-scrollbar { 67 | width: 4px !important; 68 | height: 4px !important; 69 | background-color: transparent; 70 | } 71 | 72 | /*-webkit-滑块*/ 73 | &::-webkit-scrollbar-thumb { 74 | height: 10px !important; 75 | outline-offset: 0 !important; 76 | outline: unset !important; 77 | border-radius: 2px !important; 78 | border: 1px transparent solid !important; 79 | background-color: transparent !important; 80 | } 81 | 82 | /*-webkit-滑块hover效果*/ 83 | &::-webkit-scrollbar-thumb:hover { 84 | height: 10px !important; 85 | border-radius: 2px !important; 86 | background-color: transparent !important; 87 | } 88 | 89 | /*-webkit-滚动框*/ 90 | &::-webkit-scrollbar-track-piece { 91 | -webkit-border-radius: 0 !important; 92 | background-color: transparent !important; 93 | } 94 | 95 | /* -webkit-滑轨 */ 96 | &::-webkit-scrollbar-track { 97 | right: 0 !important; 98 | background-color: transparent !important; 99 | } 100 | 101 | /* -webkit-滑轨两头的按钮 */ 102 | &::-webkit-scrollbar-button { 103 | // display: none; 104 | height: 4px !important; 105 | width: 4px !important; 106 | background-color: transparent !important; 107 | } 108 | 109 | /* -webkit-边角 */ 110 | &::-webkit-scrollbar-corner { 111 | background-color: transparent !important; 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "jsx": "preserve", 6 | "module": "ESNext", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "moduleResolution": "node", 10 | "lib": [ 11 | "ESNext", 12 | "DOM" 13 | ], 14 | "noImplicitAny": true, 15 | "skipLibCheck": true, 16 | "declaration": true, 17 | "incremental": false, 18 | "outDir": "./lib", 19 | "declarationDir": "./lib" 20 | }, 21 | "include": [ 22 | "lib/*.ts" 23 | ], 24 | "exclude": [ 25 | "node_modules" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------