From bb4ee6bb2d8523b4264a16bf6ea0066f8f7ea295 Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Wed, 14 Aug 2024 03:55:49 +0800 Subject: [PATCH] feat(plugin-shikiji): add support for `collapsed lines` --- .../src/client/composables/collapsed-lines.ts | 15 ++++ .../src/node/markdown/preWrapperPlugin.ts | 23 ++++-- .../src/node/prepareClientConfigFile.ts | 2 + .../plugin-shikiji/src/node/shikiPlugin.ts | 13 +-- plugins/plugin-shikiji/src/node/types.ts | 11 ++- .../src/node/utils/collapsedLines.ts | 18 ++++ .../plugin-shikiji/src/node/utils/index.ts | 2 + plugins/plugin-shikiji/tsup.config.ts | 1 + theme/src/client/styles/code.css | 82 +++++++++++++++++++ 9 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 plugins/plugin-shikiji/src/client/composables/collapsed-lines.ts create mode 100644 plugins/plugin-shikiji/src/node/utils/collapsedLines.ts diff --git a/plugins/plugin-shikiji/src/client/composables/collapsed-lines.ts b/plugins/plugin-shikiji/src/client/composables/collapsed-lines.ts new file mode 100644 index 00000000..a551423f --- /dev/null +++ b/plugins/plugin-shikiji/src/client/composables/collapsed-lines.ts @@ -0,0 +1,15 @@ +import { useEventListener } from '@vueuse/core' + +export function useCollapsedLines({ + selector = 'div[class*="language-"] > .collapsed-lines', +}: { selector?: string } = {}): void { + useEventListener('click', (e) => { + const el = e.target as HTMLElement + if (el.matches(selector)) { + const parent = el.parentElement + if (parent?.classList.toggle('collapsed')) { + parent.scrollIntoView({ block: 'center', behavior: 'instant' }) + } + } + }) +} diff --git a/plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts b/plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts index c6f6f455..1a5cff15 100644 --- a/plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts +++ b/plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts @@ -2,9 +2,12 @@ // v-pre block logic is in `../highlight.ts` import type { Markdown } from 'vuepress/markdown' import type { PreWrapperOptions } from '../types.js' -import { resolveAttr, resolveLanguage } from '../utils/index.js' +import { resolveAttr, resolveCollapsedLines, resolveLanguage } from '../utils/index.js' -export function preWrapperPlugin(md: Markdown, { preWrapper = true }: PreWrapperOptions = {}): void { +export function preWrapperPlugin( + md: Markdown, + { preWrapper = true, collapsedLines = false }: PreWrapperOptions = {}, +): void { const rawFence = md.renderer.rules.fence! md.renderer.rules.fence = (...args) => { @@ -16,17 +19,27 @@ export function preWrapperPlugin(md: Markdown, { preWrapper = true }: PreWrapper const lang = resolveLanguage(info) const title = resolveAttr(info, 'title') || lang - const languageClass = `${options.langPrefix}${lang}` + const classes: string[] = [`${options.langPrefix}${lang}`] let result = rawFence(...args) if (!preWrapper) { // remove `` attributes result = result.replace(//, '') - result = `
`
+    }
 
-    return `
${result}
` + return `
${result}
` } } diff --git a/plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts b/plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts index 0ffabaa4..63670e9d 100644 --- a/plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts +++ b/plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts @@ -17,6 +17,7 @@ export async function prepareClientConfigFile(app: App, { `\ ${twoslash ? `import { enhanceTwoslash } from '${CLIENT_FOLDER}composables/twoslash.js'` : ''} ${copyCode ? `import { useCopyCode } from '${CLIENT_FOLDER}composables/copy-code.js'` : ''} +import { useCollapsedLines } from '${CLIENT_FOLDER}composables/collapsed-lines.js' export default { ${twoslash @@ -30,6 +31,7 @@ export default { selector: __CC_SELECTOR__, duration: __CC_DURATION__, }) + useCollapsedLines() },` : ''} } diff --git a/plugins/plugin-shikiji/src/node/shikiPlugin.ts b/plugins/plugin-shikiji/src/node/shikiPlugin.ts index 2d961a9d..5316b59a 100644 --- a/plugins/plugin-shikiji/src/node/shikiPlugin.ts +++ b/plugins/plugin-shikiji/src/node/shikiPlugin.ts @@ -1,7 +1,6 @@ import type { Plugin } from 'vuepress/core' -import { getDirname } from 'vuepress/utils' import { isPlainObject } from 'vuepress/shared' -import { highlight } from './highlight.js' +import { highlight } from './highlight/index.js' import type { CopyCodeOptions, HighlighterOptions, @@ -16,7 +15,8 @@ import { import { copyCodeButtonPlugin } from './copy-code-button/index.js' import { prepareClientConfigFile } from './prepareClientConfigFile.js' -export interface ShikiPluginOptions extends HighlighterOptions, LineNumberOptions, PreWrapperOptions { +export interface ShikiPluginOptions + extends HighlighterOptions, LineNumberOptions, PreWrapperOptions { /** * Add copy code button * @@ -25,12 +25,11 @@ export interface ShikiPluginOptions extends HighlighterOptions, LineNumberOption copyCode?: boolean | CopyCodeOptions } -const __dirname = getDirname(import.meta.url) - export function shikiPlugin({ preWrapper = true, lineNumbers = true, copyCode = true, + collapsedLines = false, ...options }: ShikiPluginOptions = {}): Plugin { const copyCodeOptions: CopyCodeOptions = isPlainObject(copyCode) ? copyCode : {} @@ -54,9 +53,11 @@ export function shikiPlugin({ md.options.highlight = await highlight(theme, options) md.use(highlightLinesPlugin) - md.use(preWrapperPlugin, { + md.use(preWrapperPlugin, { preWrapper, + collapsedLines, }) + if (preWrapper) { copyCodeButtonPlugin(md, app, copyCode) md.use(lineNumberPlugin, { lineNumbers }) diff --git a/plugins/plugin-shikiji/src/node/types.ts b/plugins/plugin-shikiji/src/node/types.ts index 3503f1b0..db434228 100644 --- a/plugins/plugin-shikiji/src/node/types.ts +++ b/plugins/plugin-shikiji/src/node/types.ts @@ -79,7 +79,7 @@ export interface HighlighterOptions { * Enable transformerRenderWhitespace * @default false */ - whitespace?: boolean + whitespace?: boolean | 'all' | 'boundary' | 'trailing' } export interface LineNumberOptions { @@ -99,6 +99,15 @@ export interface PreWrapperOptions { * - Required for title display of default theme */ preWrapper?: boolean + + /** + * Hide extra rows when exceeding a specific number of lines. + * + * `true` is equivalent to `15` . + * + * @default false + */ + collapsedLines?: number | boolean } /** diff --git a/plugins/plugin-shikiji/src/node/utils/collapsedLines.ts b/plugins/plugin-shikiji/src/node/utils/collapsedLines.ts new file mode 100644 index 00000000..72d42f37 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/utils/collapsedLines.ts @@ -0,0 +1,18 @@ +export const COLLAPSED_LINES_REGEXP = /:collapsed-lines(?:=(\d+))?\b/ +export const NO_COLLAPSED_LINES_REGEXP = /:no-collapsed-lines\b/ + +const DEFAULT_LINES = 15 + +export function resolveCollapsedLines(info: string, defaultLines: boolean | number): number | false { + if (NO_COLLAPSED_LINES_REGEXP.test(info)) + return false + + const lines = defaultLines === true ? DEFAULT_LINES : defaultLines + + const match = info.match(COLLAPSED_LINES_REGEXP) + + if (match) { + return Number(match[1]) || lines || DEFAULT_LINES + } + return lines ?? false +} diff --git a/plugins/plugin-shikiji/src/node/utils/index.ts b/plugins/plugin-shikiji/src/node/utils/index.ts index 9c345151..e5a146d2 100644 --- a/plugins/plugin-shikiji/src/node/utils/index.ts +++ b/plugins/plugin-shikiji/src/node/utils/index.ts @@ -2,3 +2,5 @@ export * from './attrsToLines.js' export * from './resolveAttr.js' export * from './resolveLanguage.js' export * from './lru.js' +export * from './whitespace.js' +export * from './collapsedLines.js' diff --git a/plugins/plugin-shikiji/tsup.config.ts b/plugins/plugin-shikiji/tsup.config.ts index d045dc8b..1a328544 100644 --- a/plugins/plugin-shikiji/tsup.config.ts +++ b/plugins/plugin-shikiji/tsup.config.ts @@ -21,6 +21,7 @@ export default defineConfig(() => { entry: [ 'copy-code.ts', 'twoslash.ts', + 'collapsed-lines.ts', ].map(file => `./src/client/composables/${file}`), outDir: './lib/client/composables', external: [/.*\.css$/], diff --git a/theme/src/client/styles/code.css b/theme/src/client/styles/code.css index dcdd388a..d0fe1d1f 100644 --- a/theme/src/client/styles/code.css +++ b/theme/src/client/styles/code.css @@ -310,3 +310,85 @@ html:not(.dark) .vp-code span { /* rtl:ignore */ transform: translateX(calc(-100% - 1px)); } + +/* + Collapsed lines + -------------------------------------------------------------------------- + */ + +.vp-doc div[class*="language-"].has-collapsed.collapsed { + height: calc(var(--vp-collapsed-lines) * var(--vp-code-line-height) * var(--vp-code-font-size) + 62px); + overflow-y: hidden; +} + +@property --vp-code-bg-collapsed-lines { + inherits: false; + initial-value: #fff; + syntax: ""; +} + +.vp-doc div[class*="language-"].has-collapsed .collapsed-lines { + --vp-code-bg-collapsed-lines: var(--vp-code-block-bg); + + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 4; + display: flex; + align-items: center; + justify-content: center; + height: 44px; + cursor: pointer; + background: linear-gradient(to bottom, transparent 0%, var(--vp-code-bg-collapsed-lines) 50%, var(--vp-code-bg-collapsed-lines) 100%); + transition: --vp-code-bg-collapsed-lines var(--t-color); +} + +.vp-doc div[class*="language-"].has-collapsed .collapsed-lines:hover { + --vp-code-bg-collapsed-lines: var(--vp-c-default-soft); +} + +.vp-doc div[class*="language-"].has-collapsed .collapsed-lines::before { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-width='2' d='m18 12l-6 6l-6-6m12-6l-6 6l-6-6'/%3E%3C/svg%3E"); + --trans-rotate: 0deg; + + display: inline-block; + width: 24px; + height: 24px; + pointer-events: none; + content: ""; + background-color: var(--vp-code-block-color); + -webkit-mask-image: var(--icon); + mask-image: var(--icon); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: 50%; + mask-position: 50%; + -webkit-mask-size: 20px; + mask-size: 20px; + animation: code-collapsed-lines 1.2s infinite alternate-reverse ease-in-out; +} + +.vp-doc div[class*="language-"].has-collapsed:not(.collapsed) code { + padding-bottom: 20px; +} + +.vp-doc div[class*="language-"].has-collapsed:not(.collapsed) .collapsed-lines:hover { + --vp-code-bg-collapsed-lines: transparent; +} + +.vp-doc div[class*="language-"].has-collapsed:not(.collapsed) .collapsed-lines::before { + --trans-rotate: 180deg; +} + +@keyframes code-collapsed-lines { + 0% { + opacity: 0.3; + transform: translateY(-2px) rotate(var(--trans-rotate)); + } + + 100% { + opacity: 1; + transform: translateY(2px) rotate(var(--trans-rotate)); + } +}