Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/.vitepress/theme/components/InjectedShareButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'
import { shareState } from './shareState'

const props = defineProps<{
onClick: () => void
}>()

const isMounted = ref(false)
const isClicked = ref(false)

onMounted(() => {
isMounted.value = true
})

watch(() => shareState.isOpen, (isOpen) => {
if (isOpen) {
isClicked.value = false
}
})

function handleClick() {
props.onClick()
isClicked.value = true
setTimeout(() => {
isClicked.value = false
}, 2000)
}
</script>

<template>
<div class="unocss-scope" style="display: inline-flex; align-items: center; justify-content: center; vertical-align: text-bottom; margin-left: -8px;">
<button class="share-button" :class="[
isClicked ? '!text-green-400' : '',
isMounted ? '' : '!cursor-wait',
]" :disabled="!isMounted" @click.stop.prevent="handleClick()" title="保存图片">
<span flex items-center justify-center>
<svg width="14" height="14" viewBox="0 0 18 18" fill="none" xmlns="www.w3.org/2000/svg">
<path d="M10.5 2.8125H14.625C14.9357 2.8125 15.1875 3.06434 15.1875 3.375V14.625C15.1875 14.9357 14.9357 15.1875 14.625 15.1875H3.375C3.06434 15.1875 2.8125 14.9357 2.8125 14.625V8.5M3.98602 15.1875L11.6958 7.47703C11.748 7.42473 11.8101 7.38324 11.8783 7.35494C11.9466 7.32663 12.0198 7.31206 12.0938 7.31206C12.1677 7.31206 12.2409 7.32663 12.3092 7.35494C12.3774 7.38324 12.4395 7.42473 12.4917 7.47703L15.1875 10.1735M6 2L8 4L6 6M2 2L4 4L2 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</span>
</button>
Comment on lines +33 to +42
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing accessibility: The injected share button lacks proper ARIA labels. The button has a title attribute but should also include aria-label for screen readers. Additionally, the button should be keyboard accessible and announce its state changes when clicked.

Copilot uses AI. Check for mistakes.
</div>
</template>

<style scoped>
.share-button {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
cursor: pointer;
padding: 0;
border-radius: 4px;
line-height: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}

.share-button:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
</style>
195 changes: 195 additions & 0 deletions docs/.vitepress/theme/components/ShareButtonInjector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch, createApp, type App } from 'vue'
import { useRoute, useData } from 'vitepress'
import { openShare, type ShareTag } from './shareState'
import InjectedShareButton from './InjectedShareButton.vue'

const route = useRoute()
const { frontmatter } = useData()
let observer: MutationObserver | null = null
let bodyObserver: MutationObserver | null = null
// Track mounted app instances to properly unmount them later
const mountedApps: App[] = []

const getDocContainer = () => {
return document.querySelector('.vp-doc') ||
document.querySelector('.VPDoc') ||
document.querySelector('main')
}

const injectButtonToH2 = (h2: HTMLHeadingElement) => {
if (h2.classList.contains('share-btn-processed')) return
h2.classList.add('share-btn-processed')

const container = document.createElement('span')
container.className = 'share-btn-container'

const btn = document.createElement('span')
btn.className = 'share-btn-injected'

// Define the share action
const handleShare = () => {
// Clone and remove children to get text only
const clone = h2.cloneNode(true) as HTMLElement

// Extract badges
const badges: ShareTag[] = Array.from(clone.querySelectorAll('.VPBadge')).map(el => {
const text = el.textContent?.trim() || ''
let type = 'info'
if (el.classList.contains('tip')) type = 'tip'
else if (el.classList.contains('warning')) type = 'warning'
else if (el.classList.contains('danger')) type = 'danger'
return { text, type }
}).filter(b => b.text)

// Remove injected buttons, anchors, and badges from title
const childBtns = clone.querySelectorAll('.share-btn-injected, .header-anchor, .VPBadge')
childBtns.forEach(el => el.remove())
const title = clone.innerText.trim()

// Get content: try to find the next paragraph(s)
let content = ''
let next = h2.nextElementSibling
let count = 0
// Grab up to 2 blocks or until next header
while (next && !['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(next.tagName) && count < 2) {
// Capture content from valid content elements
if (!['SCRIPT', 'STYLE', 'LINK', 'TEMPLATE'].includes(next.tagName) &&
!next.classList.contains('share-btn-container') &&
!next.classList.contains('custom-block')) {
let innerHTML = (next as HTMLElement).innerHTML

// Remove footnote references
const tempDiv = document.createElement('div')
tempDiv.innerHTML = innerHTML
tempDiv.querySelectorAll('.footnote-ref').forEach(el => el.remove())
innerHTML = tempDiv.innerHTML

content += (content ? '<br><br>' : '') + innerHTML
count++
}
next = next.nextElementSibling
}

const url = window.location.origin + window.location.pathname + '#' + h2.id

// Combine frontmatter tags and inline badges
const frontmatterTags = (frontmatter.value.tags || []).map((t: string) => ({ text: t, type: 'info' }))
const tags = [...frontmatterTags, ...badges]
openShare(title, content, url, '', tags)
}

// Mount the Share Button Component
const app = createApp(InjectedShareButton, { onClick: handleShare })
app.mount(btn)
mountedApps.push(app)

container.appendChild(btn)

// Insert container at the end of h2
h2.appendChild(container)
}
Comment on lines +82 to +91
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component creates a new Vue app instance for each share button using createApp and mounts it. This is an anti-pattern and inefficient. Consider using a single component instance or Vue's teleport feature instead of creating multiple app instances, which can lead to memory overhead and slower performance.

Copilot uses AI. Check for mistakes.

const scanAndInject = () => {
if (!route.path.startsWith('/docs/')) return

const doc = getDocContainer()
if (!doc) return

const h2s = doc.querySelectorAll('h2')
h2s.forEach(h2 => injectButtonToH2(h2))
}

const observeDocContainer = (doc: Element) => {
// Cleanup old doc observer
if (observer) observer.disconnect()

// Initial scan
scanAndInject()

// Watch for changes inside the doc container
observer = new MutationObserver(() => {
scanAndInject()
})

observer.observe(doc, { childList: true, subtree: true })
Comment on lines +103 to +115
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scanAndInject function is called on every mutation, which could lead to performance issues on pages with frequent DOM changes. Consider debouncing this function call to reduce unnecessary processing.

Copilot uses AI. Check for mistakes.
}

const initObserver = () => {
if (typeof window === 'undefined') return

// Cleanup old observers
if (observer) observer.disconnect()
if (bodyObserver) bodyObserver.disconnect()

const doc = getDocContainer()

if (doc) {
// Doc container exists, observe it directly
observeDocContainer(doc)
} else {
// Doc container not ready, watch body for its appearance
bodyObserver = new MutationObserver(() => {
const doc = getDocContainer()
if (doc) {
bodyObserver?.disconnect()
bodyObserver = null
observeDocContainer(doc)
}
})

bodyObserver.observe(document.body, { childList: true, subtree: true })
}
Comment on lines +131 to +142
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential memory leak: The MutationObserver is watching for changes with childList: true, subtree: true on the entire document.body when the doc container isn't found. This can lead to performance issues on pages with frequent DOM mutations. Consider adding a timeout or maximum attempt limit to stop observing after a reasonable period, or use a more targeted selector.

Copilot uses AI. Check for mistakes.
}

onMounted(() => {
initObserver()
})

onUnmounted(() => {
if (observer) observer.disconnect()
if (bodyObserver) bodyObserver.disconnect()
// Clean up all mounted app instances to prevent memory leaks
mountedApps.forEach(app => app.unmount())
mountedApps.length = 0
})

watch(() => route.path, () => {
// Re-init on route change, MutationObserver will handle async content
initObserver()
})
</script>

<template>
<div style="display: none;"></div>
</template>

<style>
.share-btn-injected {
cursor: pointer;
margin-left: 8px;
opacity: 1;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
color: var(--vp-c-brand-1);
}

.share-btn-container {
display: inline-flex;
align-items: center;
margin-left: auto;
}

.share-btn-injected:hover {
opacity: 1;
}

/* Make h2 a flex container for natural alignment */
.vp-doc h2.share-btn-processed {
display: flex;
align-items: center;
}
</style>
Loading