feat(plugin-md-power): add lazy animation mode for mark highlights (#718)

This commit is contained in:
HAO CHEN 2025-10-12 21:56:15 +08:00 committed by GitHub
parent aa9c64f00f
commit 5c0d211d82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 233 additions and 3 deletions

View File

@ -119,6 +119,12 @@ export default defineUserConfig({
- **默认值**: `false`
- **详情**: 是否启用缩写语法
### mark
- **类型**: `MarkOptions`
- **默认值**: `'eager'`
- **详情**: 设置 `==马克笔==` 的动画播放模式
### codeTabs
- **类型**: `boolean | CodeTabsOptions`

View File

@ -129,6 +129,12 @@ The `include` configuration is implemented by the
- **Default:** `false`
- **Details:** Whether to enable abbreviation syntax.
### mark
- **Type:** `MarkOptions`
- **Default:** `'eager'`
- **Details:** Sets when the `==mark==` animation runs.
### codeTabs
- **Type:** `boolean | CodeTabsOptions`

View File

@ -81,6 +81,15 @@ mark {
--vp-mark-bg-shift: 0.55lh;
--vp-mark-linear-color: var(--vp-c-brand-3);
--vp-mark-bg-image: linear-gradient(to right, var(--vp-mark-linear-color) 50%, transparent 50%);
animation: var(--vp-mark-animation, mark-highlight 1.5s 0.5s forwards);
}
[data-mark-mode="lazy"] mark {
--vp-mark-animation: none;
}
[data-mark-mode="lazy"] mark.vp-mark-visible {
animation: mark-highlight 1.5s 0.2s forwards;
}
mark.note {
@ -145,3 +154,19 @@ mark.classname {
Use `==Mark=={.classname}` in Markdown.
You can name `classname` freely and add other CSS properties besides modifying CSS variables.
## Animation Modes
By default, the highlight animation plays as soon as the page renders.
If you prefer to animate only when the marker enters the viewport, set `markdown.mark` to `'lazy'` in your theme config:
```ts title=".vuepress/config.ts" {5}
export default defineUserConfig({
theme: plumeTheme({
markdown: {
mark: 'lazy',
},
}),
})
```

View File

@ -82,6 +82,15 @@ mark {
--vp-mark-bg-shift: 0.55lh;
--vp-mark-linear-color: var(--vp-c-brand-3);
--vp-mark-bg-image: linear-gradient(to right, var(--vp-mark-linear-color) 50%, transparent 50%);
animation: var(--vp-mark-animation, mark-highlight 1.5s 0.5s forwards);
}
[data-mark-mode="lazy"] mark {
--vp-mark-animation: none;
}
[data-mark-mode="lazy"] mark.vp-mark-visible {
animation: mark-highlight 1.5s 0.2s forwards;
}
mark.note {
@ -146,3 +155,19 @@ mark.classname {
然后在 Markdown 中使用 `==Mark=={.classname}` 进行标记。
你可以随意命名 `classname`,除了修改 CSS 变量,也可以添加其他的 CSS 样式属性。
## 动画模式
默认情况下,马克笔会在页面渲染时立即播放描线动画。
如果希望在滚动到可视区域后再播放动画,可以在主题配置中将 `markdown.mark` 设置为 `'lazy'`
```ts title=".vuepress/config.ts" {5}
export default defineUserConfig({
theme: plumeTheme({
markdown: {
mark: 'lazy',
},
}),
})
```

View File

@ -0,0 +1,140 @@
import { onBeforeUnmount, onMounted } from 'vue'
import { onContentUpdated, useRouter } from 'vuepress/client'
const MARK_MODE_ATTR = 'data-mark-mode'
const MARK_MODE_LAZY = 'lazy'
const MARK_VISIBLE_CLASS = 'vp-mark-visible'
const MARK_BOUND_ATTR = 'data-vp-mark-bound'
const MARK_SELECTOR = '.vp-doc mark'
const DOC_SELECTOR = '.vp-doc'
const BOUND_SELECTOR = `${MARK_SELECTOR}[${MARK_BOUND_ATTR}="1"]`
export function setupMarkHighlight(mode: 'lazy' | 'eager'): void {
if (typeof window === 'undefined' || __VUEPRESS_SSR__)
return
const root = document.documentElement
if (mode !== MARK_MODE_LAZY) {
root.removeAttribute(MARK_MODE_ATTR)
return
}
root.setAttribute(MARK_MODE_ATTR, MARK_MODE_LAZY)
let intersectionObserver: IntersectionObserver | null = null
let mutationObserver: MutationObserver | null = null
let rafId: number | null = null
let removeAfterEach: (() => void) | null = null
const ensureObserver = () => {
if (!intersectionObserver) {
intersectionObserver = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting && entry.intersectionRatio <= 0)
continue
const target = entry.target as HTMLElement
target.classList.add(MARK_VISIBLE_CLASS)
target.removeAttribute(MARK_BOUND_ATTR)
obs.unobserve(target)
}
}, {
threshold: [0, 0.1, 0.25, 0.5],
rootMargin: '8% 0px -8% 0px',
})
}
return intersectionObserver
}
const bindMarks = () => {
const marks = Array.from(document.querySelectorAll<HTMLElement>(MARK_SELECTOR))
.filter(mark =>
mark instanceof HTMLElement
&& !mark.classList.contains(MARK_VISIBLE_CLASS)
&& mark.getAttribute(MARK_BOUND_ATTR) !== '1',
)
if (marks.length === 0)
return
const observer = ensureObserver()
for (const mark of marks) {
mark.setAttribute(MARK_BOUND_ATTR, '1')
observer.observe(mark)
}
}
const scheduleBind = () => {
if (rafId !== null)
cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(() => {
rafId = null
bindMarks()
})
}
const observeDocMutations = () => {
const doc = document.querySelector(DOC_SELECTOR)
if (!doc)
return
if (mutationObserver)
mutationObserver.disconnect()
mutationObserver = new MutationObserver((mutations) => {
if (mutations.some(mutation => mutation.addedNodes.length > 0))
scheduleBind()
})
mutationObserver.observe(doc, { childList: true, subtree: true })
}
const resetObserver = () => {
document.querySelectorAll<HTMLElement>(BOUND_SELECTOR).forEach((mark) => {
if (!mark.classList.contains(MARK_VISIBLE_CLASS))
mark.removeAttribute(MARK_BOUND_ATTR)
})
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = null
}
}
const router = useRouter()
onMounted(() => {
observeDocMutations()
scheduleBind()
})
onContentUpdated(() => {
resetObserver()
observeDocMutations()
scheduleBind()
})
if (router?.afterEach) {
removeAfterEach = router.afterEach(() => {
resetObserver()
observeDocMutations()
scheduleBind()
})
}
if (router?.isReady) {
router.isReady().then(() => scheduleBind()).catch(() => {})
}
onBeforeUnmount(() => {
if (rafId !== null)
cancelAnimationFrame(rafId)
resetObserver()
mutationObserver?.disconnect()
mutationObserver = null
removeAfterEach?.()
removeAfterEach = null
})
}

View File

@ -132,6 +132,18 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
}
const setupIcon = prepareIcon(imports, options.icon)
const setupStmts: string[] = []
const iconSetup = setupIcon.trim()
if (iconSetup)
setupStmts.push(iconSetup)
const markMode = options.mark === 'lazy' ? 'lazy' : 'eager'
imports.add(`import { setupMarkHighlight } from '${CLIENT_FOLDER}composables/mark.js'`)
setupStmts.push(`setupMarkHighlight(${JSON.stringify(markMode)})`)
const setupContent = setupStmts.length
? ` ${setupStmts.join('\n ')}\n`
: ''
return app.writeTemp(
'md-power/config.js',
@ -148,7 +160,7 @@ ${Array.from(enhances.values())
.join('\n')}
},
setup() {
${setupIcon}
${setupContent}
}
})
`,

View File

@ -0,0 +1 @@
export type MarkOptions = 'lazy' | 'eager'

View File

@ -3,6 +3,7 @@ import type { CodeTabsOptions } from './codeTabs.js'
import type { CodeTreeOptions } from './codeTree.js'
import type { FileTreeOptions } from './fileTree.js'
import type { IconOptions } from './icon.js'
import type { MarkOptions } from './mark.js'
import type { NpmToOptions } from './npmTo.js'
import type { PDFOptions } from './pdf.js'
import type { PlotOptions } from './plot.js'
@ -21,6 +22,11 @@ export interface MarkdownPowerPluginOptions {
* @default false
*/
abbr?: boolean
/**
*
* @default 'eager'
*/
mark?: MarkOptions
/**
*
*/

View File

@ -4,7 +4,7 @@ import { argv } from '../../scripts/tsdown-args.mjs'
/** @import {Options} from 'tsdown' */
const config = [
{ dir: 'composables', files: ['codeRepl.ts', 'pdf.ts', 'rustRepl.ts', 'size.ts', 'audio.ts', 'demo.ts'] },
{ dir: 'composables', files: ['codeRepl.ts', 'pdf.ts', 'rustRepl.ts', 'size.ts', 'audio.ts', 'demo.ts', 'mark.ts'] },
{ dir: 'utils', files: ['http.ts', 'link.ts', 'sleep.ts'] },
{ dir: '', files: ['index.ts', 'options.ts'] },
]

View File

@ -15,7 +15,7 @@ mark {
background-repeat: no-repeat;
background-position: 100% var(--vp-mark-bg-shift);
background-size: 200%;
animation: mark-highlight 1.5s 0.5s forwards;
animation: var(--vp-mark-animation, mark-highlight 1.5s 0.5s forwards);
}
mark:where(.note) {
@ -66,6 +66,14 @@ mark:where(.important) {
--vp-mark-linear-color: #66c;
}
[data-mark-mode="lazy"] mark {
--vp-mark-animation: none;
}
[data-mark-mode="lazy"] mark.vp-mark-visible {
animation: mark-highlight 1.5s 0.2s forwards;
}
@keyframes mark-highlight {
0% {
color: inherit;

View File

@ -52,6 +52,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [
'icon',
'imageSize',
'jsfiddle',
'mark',
'npmTo',
'pdf',
'plot',