Merge pull request #67 from GeoDaoyu/feat/copy-btn-llms

feat: add copy markdown buttons
This commit is contained in:
Sanbu 散步
2026-03-20 15:09:55 +08:00
committed by GitHub
12 changed files with 392 additions and 0 deletions
+3
View File
@@ -320,6 +320,9 @@ watch(sidebarCollapsed, (collapsed) => {
</svg>
</button>
</template>
<template #doc-before>
<CopyOrDownloadAsMarkdownButtons />
</template>
<template #nav-bar-content-after>
<GitHubStars />
<ClientOnly>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><path fill="currentColor" d="M101.228 164.247q-7.425 0-14.108-2.821a38.3 38.3 0 0 1-11.88-7.871q-5.643 1.93-11.731 1.931-9.95 0-18.414-4.901T31.433 137.22q-5.05-8.464-5.05-18.859 0-4.307 1.189-9.356-5.94-5.494-9.207-12.622-3.267-7.276-3.267-15.147 0-8.019 3.415-15.444t9.504-12.771q6.237-5.495 14.405-7.574 1.634-8.465 6.83-15.147 5.348-6.83 13.07-10.692t16.483-3.86q7.425 0 14.108 2.82a38.3 38.3 0 0 1 11.88 7.871q5.643-1.93 11.731-1.93 9.95 0 18.414 4.9t13.514 13.365q5.197 8.465 5.197 18.86 0 4.306-1.188 9.355 5.94 5.495 9.207 12.771a35.6 35.6 0 0 1 3.267 14.999q0 8.019-3.415 15.444t-9.653 12.919q-6.088 5.346-14.256 7.425-1.633 8.465-6.979 15.147-5.198 6.831-12.92 10.692t-16.483 3.861m-36.68-18.562q7.425 0 12.92-3.119l27.918-16.038q1.485-1.04 1.485-2.821v-12.771l-35.937 20.641q-3.267 1.93-6.534 0l-28.067-16.186q0 .445-.148 1.039v1.782q0 7.574 3.564 13.959 3.712 6.237 10.246 9.801 6.534 3.713 14.553 3.713m1.485-24.206q.891.446 1.634.446t1.485-.446l11.137-6.385-35.788-20.79q-3.267-1.93-3.267-5.792V56.288q-7.425 3.267-11.88 10.098-4.455 6.683-4.455 14.85 0 7.276 3.712 13.959t9.653 10.098zm35.195 32.967q7.87 0 14.256-3.564t10.098-9.801 3.712-13.959V95.046q0-1.782-1.485-2.673l-11.286-6.534v41.432q0 3.86-3.267 5.791L85.19 149.249q7.276 5.197 16.038 5.197m5.643-54.351V79.899L90.09 70.395 73.161 79.9v20.196L90.09 109.6zM63.509 52.724q0-3.861 3.267-5.792l28.066-16.186q-7.276-5.198-16.038-5.198-7.87 0-14.256 3.564-6.385 3.564-10.098 9.801-3.564 6.237-3.564 13.96V84.8q0 1.782 1.485 2.821l11.138 6.534zm75.438 70.983q7.425-3.267 11.731-10.098 4.455-6.831 4.455-14.85 0-7.276-3.712-13.96-3.713-6.681-9.653-10.097l-27.769-16.038q-.891-.594-1.634-.446-.743 0-1.485.446L99.743 64.9l35.937 20.938q1.633.891 2.376 2.376.891 1.337.891 3.267zm-29.849-75.438q3.267-2.079 6.534 0l28.215 16.483V62.08q0-7.128-3.564-13.513-3.415-6.534-9.949-10.395-6.386-3.861-14.85-3.861-7.425 0-12.92 3.118L74.646 53.466q-1.485 1.04-1.485 2.822v12.77z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>

After

Width:  |  Height:  |  Size: 191 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-chevron-down-icon lucide-chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 248 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" viewBox="0 0 100 101"><path fill="currentColor" d="m96.138 40.515 3.5 2v1.5l-1 3.5-42.5 10-3.996-9.93zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="m80.626 11.495 4.894 1.027 1.299 1.6 1.239 3.837-.514 2.447-28.521 39-9.5-9.5 26.3-34.514zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="m56.537 5.537 3-2 2.5 1 2.5 3.5-6.849 41.162-4.65-3.162-2-5.5 3.5-31zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="m25.058 6.102 3.082-3.937 2.01-.46 3.99.584 1.968 1.54 14.345 31.804 5.19 15.11-6.071 3.376-23.139-41.987zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="m10.766 27.61-1-4.003 3-3.5 3.5.5h1l21 15.5 6.5 5 9 7-5 8.5-4.5-3.5-3-3-29-20.5zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="m4.856 53-2.263-2.5v-2.224l2.263-.776 25.5 1.5 25 2-.812 4.978L6.856 53.5zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="M19.428 78.51h-5l-1.988-2.29v-2.737l8.488-6 34.508-21.966 3.492 5.966zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="m28.59 92.082-2 .5-3-1.5.5-2.5 29.5-39 4 5.5-22 29zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="m53.09 96.91-1.5 2-3 1-2.5-2-1.5-3 7.5-40.5 4.5.5zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="M77.985 86.16v4l-.5 1.5-2 1-3.5-.466-24.033-35.77 9.533-7.264 8 14.5.75 5.25zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="m89.132 80.508.5 2.5-1.5 2-1.5-.5-8.5-6-13-11.5-10-7 3-9.5 5 3 3 5.5zm0 0" transform-origin="50px 50px"/><path fill="currentColor" d="m82.5 55.5 12.5 1 3 2 2 3v2.159L94.5 66l-28-7-11.5-.5L58 48l8 6zm0 0" transform-origin="50px 50px"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>

After

Width:  |  Height:  |  Size: 287 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-file-down-icon lucide-file-down" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5m-8 10v-6m-3 3 3 3 3-3"/></svg>

After

Width:  |  Height:  |  Size: 397 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right" viewBox="0 0 24 24"><path d="M7 7h10v10M7 17 17 7"/></svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M0 8a4 4 0 0 1 4-4h16a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4zm4-2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2zm1.684 2.051A1 1 0 0 1 6.8 8.4L9 11.333 11.2 8.4A1 1 0 0 1 13 9v6a1 1 0 1 1-2 0v-3l-1.2 1.6a1 1 0 0 1-1.6 0L7 12v3a1 1 0 1 1-2 0V9a1 1 0 0 1 .684-.949M18 9a1 1 0 1 0-2 0v3.586l-.293-.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l2-2a1 1 0 0 0-1.414-1.414l-.293.293z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 541 B

@@ -0,0 +1,340 @@
<template>
<div class="markdown-copy-buttons">
<div class="markdown-copy-buttons-inner">
<div class="dropdown-container" ref="dropdownContainer">
<!-- Main button -->
<div class="dropdown-trigger">
<!-- Copy area -->
<button class="copy-page" @click="copyAsMarkdown">
<span v-html="copied ? iconCheck : iconCopy" class="icon"></span>
<span class="label">
{{ copied ? 'Copied' : 'Copy page' }}
</span>
</button>
<span class="divider"></span>
<!-- Chevron area -->
<button class="chevron-wrapper" @click.stop="toggleDropdown">
<span v-html="iconChevron" class="icon chevron" :class="{ open: isOpen }"></span>
</button>
</div>
<!-- Dropdown -->
<div v-if="isRendered" ref="dropdownMenu" class="dropdown-menu" :class="{ open: isOpen }">
<button class="dropdown-item" @click="viewAsMarkdown">
<span v-html="iconMarkdown" class="icon"></span>
View as Markdown
<span v-html="iconExternal" class="icon external"></span>
</button>
<button
v-for="provider in aiProviders"
:key="provider.name"
class="dropdown-item"
@click="openInAI(provider)"
>
<span v-html="provider.icon" class="icon"></span>
Open in {{ provider.name }}
<span v-html="iconExternal" class="icon external"></span>
</button>
</div>
</div>
<!-- Download button -->
<button class="download-btn" @click="downloadMarkdown">
<span v-html="downloaded ? iconCheck : iconDownload" class="icon"></span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import iconChatGPT from './icons/chatgpt.svg?raw'
import iconCheck from './icons/check.svg?raw'
import iconChevron from './icons/chevron.svg?raw'
import iconClaude from './icons/claude.svg?raw'
import iconCopy from './icons/copy.svg?raw'
import iconDownload from './icons/download.svg?raw'
import iconExternal from './icons/external.svg?raw'
import iconMarkdown from './icons/markdown.svg?raw'
import { downloadFile, resolveMarkdownPageURL } from './utils'
const aiProviders = [
{ name: 'ChatGPT', icon: iconChatGPT, url: 'https://chatgpt.com/?hints=search&prompt=' },
{ name: 'Claude', icon: iconClaude, url: 'https://claude.ai/new?q=' },
]
const isOpen = ref(false)
const copied = ref(false)
const downloaded = ref(false)
const dropdownContainer = ref<HTMLElement | null>(null)
const isRendered = ref(false)
const dropdownMenu = ref<HTMLElement | null>(null)
function toggleDropdown() {
if (isOpen.value) {
// close
isOpen.value = false
const el = dropdownMenu.value
if (!el) return
const onEnd = () => {
isRendered.value = false
el.removeEventListener('transitionend', onEnd)
}
el.addEventListener('transitionend', onEnd)
} else {
// open
isRendered.value = true
requestAnimationFrame(() => {
isOpen.value = true
})
}
}
const currentURL = window.location.origin + window.location.pathname
function copyAsMarkdown() {
fetch(resolveMarkdownPageURL(currentURL))
.then((r) => r.text())
.then((text) => navigator.clipboard.writeText(text))
.then(() => {
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
})
.catch((e) => console.error('❌ Error:', e))
isOpen.value = false
}
function viewAsMarkdown() {
window.open(resolveMarkdownPageURL(currentURL), '_blank')
isOpen.value = false
}
function openInAI(provider: (typeof aiProviders)[0]) {
const markdownUrl = resolveMarkdownPageURL(currentURL)
const prompt = `Read from ${markdownUrl} so I can ask questions about it.`
window.open(provider.url + encodeURIComponent(prompt), '_blank')
isOpen.value = false
}
function downloadMarkdown() {
fetch(resolveMarkdownPageURL(currentURL))
.then((r) => r.text())
.then((text) => {
const filename = resolveMarkdownPageURL(currentURL).split('/').pop() || 'page.md'
downloadFile(filename, text, 'text/markdown')
downloaded.value = true
setTimeout(() => {
downloaded.value = false
}, 2000)
})
.catch((e) => console.error('❌ Error:', e))
}
function handleClickOutside(event: MouseEvent) {
if (dropdownContainer.value && !dropdownContainer.value.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
</script>
<style scoped>
.markdown-copy-buttons {
width: 100%;
display: flex;
margin-bottom: 16px;
}
.markdown-copy-buttons-inner {
margin: 16px 0;
display: flex;
gap: 8px;
position: relative;
}
.dropdown-container {
position: relative;
}
.dropdown-trigger {
display: flex;
align-items: stretch;
background: transparent;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
color: var(--vp-c-text-1);
font-size: 14px;
padding: 0;
overflow: hidden;
}
.copy-page {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
white-space: nowrap;
background: transparent;
border: none;
}
.label {
white-space: nowrap;
}
.divider {
width: 1px;
height: 25px;
align-self: center;
background: var(--vp-c-divider);
opacity: 0.6;
}
.chevron-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
cursor: pointer;
background: transparent;
border: none;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 240px;
background: var(--vp-c-bg-elv);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
z-index: 100;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
opacity: 0;
transform: translateY(-6px) scale(0.96);
pointer-events: none;
}
.dropdown-menu.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.dropdown-item {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: transparent;
border: none;
color: var(--vp-c-text-1);
font-size: 14px;
cursor: pointer;
text-align: left;
}
.dropdown-item .icon.external {
margin-left: auto;
opacity: 0.6;
}
.download-btn {
display: flex;
align-items: center;
padding: 8px 12px;
background: transparent;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
color: var(--vp-c-text-1);
cursor: pointer;
}
.icon {
width: 18px;
height: 18px;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown-item:hover .icon.external {
opacity: 1;
transform: translateX(2px);
}
@media (prefers-reduced-motion: no-preference) {
.dropdown-menu {
transition:
opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.18s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: top;
}
/* Hover zones */
.copy-page:hover,
.chevron-wrapper:hover,
.download-btn:hover {
background: var(--vp-c-bg-soft);
}
.dropdown-trigger,
.copy-page,
.chevron-wrapper,
.dropdown-item,
.dropdown-item .icon.external,
.download-btn {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.dropdown-trigger:hover,
.download-btn:hover {
border-color: var(--vp-c-brand-1);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dropdown-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 0;
height: 100%;
background: var(--vp-c-brand-1);
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.dropdown-item:hover {
padding-left: 20px;
}
.dropdown-item:hover::before {
width: 3px;
}
.chevron {
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
}
</style>
@@ -0,0 +1,35 @@
var removeHtmlExtension = (pathSegment) => {
const lastSlashIndex = pathSegment.lastIndexOf('/')
const lastDotIndex = pathSegment.lastIndexOf('.')
if (
lastDotIndex > lastSlashIndex &&
lastDotIndex !== -1 &&
pathSegment.endsWith('.html')
) {
return pathSegment.slice(0, lastDotIndex)
}
return pathSegment
}
function cleanUrl(url) {
const { origin, pathname } = new URL(url)
const pathnameWithoutTrailingSlash = pathname.replace(/\/+$/, '')
if (pathname.length > 0) {
return origin + removeHtmlExtension(pathnameWithoutTrailingSlash)
}
return origin
}
function resolveMarkdownPageURL(url) {
const cleanedURL = cleanUrl(url)
return `${cleanedURL}/index.md`
}
function downloadFile(filename, content, blobType = 'text/plain') {
const blob =
content instanceof Blob ? content : new Blob([content], { type: blobType })
const url = URL.createObjectURL(blob)
Object.assign(document.createElement('a'), {
href: url,
download: filename
}).click()
URL.revokeObjectURL(url)
}
export { resolveMarkdownPageURL, downloadFile, cleanUrl }
+6
View File
@@ -841,11 +841,17 @@ import ProjectArchitectureComparisonDemo from './components/appendix/project-arc
// Appendix Navigation Component
import AppendixFlowMap from './components/AppendixFlowMap.vue'
import CopyOrDownloadAsMarkdownButtons from './components/CopyOrDownloadAsMarkdownButtons/index.vue'
export default {
extends: DefaultTheme,
Layout,
enhanceApp({ app }) {
app.use(ElementPlus)
app.component(
'CopyOrDownloadAsMarkdownButtons',
CopyOrDownloadAsMarkdownButtons
)
app.component('HomeFeatures', HomeFeatures)
app.component('WelcomeScreen', WelcomeScreen)
app.component('NavGrid', NavGrid)