feat(plugin-md-power): add Annotation syntax support (#483)

* feat(plugin-md-power): add `Annotation` syntax support

* chore: lint fix
This commit is contained in:
pengzhanbo 2025-02-23 02:10:01 +08:00 committed by GitHub
parent 1f0ec7feaf
commit f8d32835df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 310 additions and 3 deletions

View File

@ -25,5 +25,7 @@
"no-hard-tabs": {
"spaces_per_tab": 2,
"ignore_code_languages": ["xml"]
}
},
"link-image-reference-definitions": false,
"no-bare-urls": false
}

View File

@ -25,6 +25,7 @@ export const theme: Theme = plumeTheme({
flowchart: true,
},
markdownPower: {
annotation: true,
abbr: true,
imageSize: 'all',
pdf: true,

View File

@ -0,0 +1,119 @@
<script setup lang="ts">
import { onClickOutside, useEventListener } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
const props = defineProps<{
label: string
total: number
}>()
const active = ref(false)
const list = computed(() => Array.from({ length: props.total }, (_, i) => i))
const position = ref({ x: 0, y: 0 })
const popover = useTemplateRef<HTMLDivElement>('popover')
const button = useTemplateRef<HTMLButtonElement>('button')
onClickOutside(popover, () => (active.value = false), {
ignore: [button],
})
function updatePosition() {
if (__VUEPRESS_SSR__)
return
if (!active.value || !popover.value || !button.value)
return
const { x: _x, y: _y, width: w, height: h } = button.value.getBoundingClientRect()
const x = _x + w / 2
const y = _y + h
const { width, height } = popover.value.getBoundingClientRect()
const { clientWidth, clientHeight } = document.documentElement
position.value.x = x + width + 16 > clientWidth ? clientWidth - x - width - 16 : 0
position.value.y = y + height + 16 > clientHeight ? clientHeight - y - height - 16 : 0
}
watch(active, () => nextTick(updatePosition))
useEventListener('resize', updatePosition)
</script>
<template>
<span class="vp-annotation" :class="{ active, [label]: true }" :aria-label="label">
<span ref="button" class="vpi-annotation" @click="active = !active" />
<Transition name="fade">
<div
v-show="active" ref="popover"
class="annotations-popover" :class="{ list: list.length > 1 }"
:style="{ '--vp-annotation-x': `${position.x}px`, '--vp-annotation-y': `${position.y}px` }"
>
<div v-for="i in list" :key="label + i" class="annotation">
<slot :name="`item-${i}`" />
</div>
</div>
</Transition>
</span>
</template>
<style scoped>
.vp-annotation {
position: relative;
}
.vpi-annotation {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.262-.951l-4.537.93a1 1 0 0 1-1.18-1.18l.93-4.537A10 10 0 0 1 2 12m10-4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1' clip-rule='evenodd'/%3E%3C/svg%3E");
position: relative;
z-index: 2;
width: 1.2em;
height: 1.2em;
color: currentcolor;
cursor: pointer;
opacity: 0.5;
transition: color var(--vp-t-color), opacity var(--vp-t-color), transform var(--vp-t-color);
transform: rotate(0deg);
}
.vp-annotation.active {
z-index: 10;
}
.vp-annotation:where(:hover, .active) .vpi-annotation {
color: var(--vp-c-brand-2);
opacity: 1;
}
.vp-annotation.active .vpi-annotation {
transform: rotate(-45deg);
}
.annotations-popover {
position: absolute;
top: 50%;
left: 50%;
width: max-content;
max-width: min(calc(100vw - 32px), 360px);
max-height: 360px;
padding: 8px 12px;
overflow-y: auto;
font-size: 14px;
background-color: var(--vp-c-bg);
border: solid 1px var(--vp-c-divider);
border-radius: 4px;
box-shadow: var(--vp-shadow-2);
transform: translateX(var(--vp-annotation-x, 0)) translateY(var(--vp-annotation-y, 0));
}
.annotations-popover.list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
background-color: var(--vp-c-bg-soft);
}
.annotations-popover.list .annotation {
padding: 4px 12px;
background-color: var(--vp-c-bg);
border-radius: 4px;
box-shadow: var(--vp-shadow-1);
}
</style>

View File

@ -0,0 +1,161 @@
import type { PluginSimple } from 'markdown-it'
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs'
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'
import type Token from 'markdown-it/lib/token.mjs'
interface AnnotationToken extends Token {
meta: {
label: string
annotations: string[]
}
}
interface AnnotationEnv extends Record<string, unknown> {
annotations: Record<string, string[]>
}
interface AnnotationStateBlock extends StateBlock {
tokens: AnnotationToken[]
env: AnnotationEnv
}
interface AnnotationStateInline extends StateInline {
tokens: AnnotationToken[]
env: AnnotationEnv
}
const annotationDef: RuleBlock = (
state: AnnotationStateBlock,
startLine: number,
endLine: number,
silent: boolean,
) => {
const start = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine]
if (
// line should be at least 5 chars - "[+x]:"
start + 4 > max
|| state.src.charAt(start) !== '['
|| state.src.charAt(start + 1) !== '+'
) {
return false
}
let pos = start + 2
while (pos < max) {
if (state.src.charAt(pos) === ' ')
return false
if (state.src.charAt(pos) === ']')
break
pos++
}
if (
// empty footnote label
pos === start + 2
|| pos + 1 >= max
|| state.src.charAt(++pos) !== ':'
) {
return false
}
if (silent)
return true
pos++
state.env.annotations ??= {}
const label = state.src.slice(start + 2, pos - 2)
const annotation = state.src.slice(pos, max).trim()
state.env.annotations[`:${label}`] ??= []
state.env.annotations[`:${label}`].push(annotation)
state.line += 1
return true
}
const annotationRef: RuleInline = (
state: AnnotationStateInline,
silent: boolean,
): boolean => {
const start = state.pos
const max = state.posMax
if (
// should be at least 4 chars - "[+x]"
start + 3 > max
|| typeof state.env.annotations === 'undefined'
|| state.src.charAt(start) !== '['
|| state.src.charAt(start + 1) !== '+'
) {
return false
}
let pos = start + 2
while (pos < max) {
if (state.src.charAt(pos) === ' ' || state.src.charAt(pos) === '\n')
return false
if (state.src.charAt(pos) === ']')
break
pos++
}
if (
// empty annotation labels
pos === start + 2
|| pos >= max
) {
return false
}
pos++
const label = state.src.slice(start + 2, pos - 1)
const annotations = state.env.annotations?.[`:${label}`] ?? []
if (annotations.length === 0)
return false
if (!silent) {
const refToken = state.push('annotation_ref', '', 0)
refToken.meta = {
label,
annotations,
} as AnnotationToken['meta']
}
state.pos = pos
state.posMax = max
return true
}
export const annotationPlugin: PluginSimple = (md) => {
md.renderer.rules.annotation_ref = (
tokens: AnnotationToken[],
idx: number,
) => {
const { label = '', annotations = [] } = tokens[idx].meta ?? {}
return `<Annotation label="${label}" :total="${annotations.length}">
${annotations.map((annotation, i) => {
return `<template #item-${i}>${md.renderInline(annotation)}</template>`
}).join('\n')}
</Annotation>`
}
md.inline.ruler.before('image', 'annotation_ref', annotationRef)
md.block.ruler.before('reference', 'annotation', annotationDef, {
alt: ['paragraph', 'reference'],
})
}

View File

@ -8,6 +8,7 @@ import { sup } from '@mdit/plugin-sup'
import { tasklist } from '@mdit/plugin-tasklist'
import { isPlainObject } from '@vuepress/helper'
import { abbrPlugin } from './abbr.js'
import { annotationPlugin } from './annotation.js'
import { iconsPlugin } from './icons.js'
import { plotPlugin } from './plot.js'
@ -22,9 +23,21 @@ export function inlineSyntaxPlugin(
md.use(footnote)
md.use(tasklist)
if (options.annotation) {
/**
* xxx [+foo] xxx
*
* [+foo]: xxx
*/
md.use(annotationPlugin)
}
if (options.abbr) {
// a HTML element
// *[HTML]: A HTML element description
/**
* a HTML element
*
* [HTML]: A HTML element description
*/
md.use(abbrPlugin)
}

View File

@ -82,6 +82,11 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
enhances.add(`app.component('VPDemoNormal', VPDemoNormal)`)
}
if (options.annotation) {
imports.add(`import Annotation from '${CLIENT_FOLDER}components/Annotation.vue'`)
enhances.add(`app.component('Annotation', Annotation)`)
}
if (options.abbr) {
imports.add(`import Abbreviation from '${CLIENT_FOLDER}components/Abbreviation.vue'`)
enhances.add(`app.component('Abbreviation', Abbreviation)`)

View File

@ -9,6 +9,12 @@ import type { ReplOptions } from './repl.js'
export interface MarkdownPowerPluginOptions {
/**
*
* @default false
*/
annotation?: boolean
/*
* abbr
* @default false
*/