-
Notifications
You must be signed in to change notification settings - Fork 8
add/sharecard #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
add/sharecard #56
Changes from all commits
9d35e5c
0d0b4b0
a4c131b
cfffc4f
b1cf2f9
8f0441d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| </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> | ||
| 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
|
||
|
|
||
| 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
|
||
| } | ||
|
|
||
| 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
|
||
| } | ||
|
|
||
| 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> | ||
There was a problem hiding this comment.
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.