Skip to content
Open
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
6 changes: 3 additions & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": false,
"source.fixAll": false,
"source.addMissingImports": true
"source.organizeImports": "never",
"source.fixAll": "never",
"source.addMissingImports": "explicit"
},
"files.exclude": {
"**/.git": true,
Expand Down
2 changes: 2 additions & 0 deletions src/pages/docs/[version]/[[...slug]].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { ApiTag } from 'src/components/common/ApiTag'
import { useCheckGrammar } from 'src/utils/useCheckGrammar'
import { LATEST_VERSION, LATEST_VERSION_PATH } from 'src/LATEST_VERSION'
import { getURLPathAliases } from 'src/utils/getURLPathAliases'
import { useCopyButtonEnhancer } from 'src/utils/useCopyButtonEnhancer'

export const getStaticPaths = async () => {
const paths = allDocs
Expand Down Expand Up @@ -226,6 +227,7 @@ function findInTreeBFS(tree: TreeNode[], check: (node: TreeNode) => boolean): Tr
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ doc, tree, breadcrumbs, childrenTree }) => {
const router = useRouter()
useLiveReload()
useCopyButtonEnhancer()
const MDXContent = useMDXComponent(doc.body.code || '')
let content = MDXContent && <MDXContent components={{ ...mdxComponents, ...injectedComponents(tree, router) }} />
const checkGrammar = useCheckGrammar()
Expand Down
284 changes: 284 additions & 0 deletions src/utils/useCopyButtonEnhancer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
import { useEffect } from 'react'
import { useRouter } from 'next/router'

/**
* Minimal hook to add copy buttons to code blocks in /docs routes.
* - Only activates on routes starting with '/docs'
* - Targets elements with classes starting with 'ch-code' or 'ch-codeblock'
* - Falls back to <pre><code> containers
* - Adds a compact chip button to the top-right corner inside the code block
* - Preserves line breaks when copying (extracts innerText)
* - Uses MutationObserver to handle dynamically loaded content
* - Includes keyboard support (Enter/Space), aria-label, and visual feedback
* - Does NOT modify existing logic or styles
*/
export function useCopyButtonEnhancer() {
const router = useRouter()

useEffect(() => {
// Only run on /docs routes
if (!router.pathname.startsWith('/docs')) {
return
}

const extractCodeText = (codeBlock: HTMLElement): string => {
// Use innerText to preserve line breaks, fallback to textContent
return (codeBlock.innerText || codeBlock.textContent || '').trim()
}

const copyToClipboard = async (text: string): Promise<boolean> => {
// Try modern Clipboard API first
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch (err) {
console.error('Clipboard API failed:', err)
}
}

// Fallback: Range + Selection API
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const success = document.execCommand('copy')
document.body.removeChild(textarea)
return success
} catch (err) {
console.error('Fallback copy failed:', err)
return false
}
}

const handleCodeBlocks = () => {
// Query all elements with classes starting with ch-code or ch-codeblock
let codeElements: HTMLElement[] = Array.from(
document.querySelectorAll('[class*="ch-code"], [class*="ch-codeblock"]'),
).filter((el) => {
const classList = (el as HTMLElement).className
return classList.includes('ch-code') || classList.includes('ch-codeblock')
}) as HTMLElement[]

// Fallback: include <pre><code> if no ch-code blocks found
if (codeElements.length === 0) {
codeElements = Array.from(
document.querySelectorAll('pre > code'),
) as HTMLElement[]
}

codeElements.forEach((codeBlock) => {
// Skip if button already attached to this element
if ((codeBlock as any).dataset.copyButtonAttached === 'true') {
return
}

// Mark as processed
;(codeBlock as any).dataset.copyButtonAttached = 'true'

// Find the proper container (parent with position or the code block itself)
let container = codeBlock.parentElement || codeBlock
const computedStyle = window.getComputedStyle(container)

// Only set position:relative if currently static
if (computedStyle.position === 'static') {
container.style.position = 'relative'
}

// Create standardized button with cssText for maximum specificity
const button = document.createElement('button')
button.setAttribute('aria-label', 'Copy code')
button.setAttribute('type', 'button')
button.textContent = 'Copy'

// Apply inline styles with !important to override any inherited styles
button.style.cssText = `
position: absolute !important;
top: 6px !important;
right: 6px !important;
padding: 4px 10px !important;
height: auto !important;
width: auto !important;
background-color: rgba(0, 0, 0, 0.8) !important;
color: #fff !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 3px !important;
cursor: pointer !important;
font-size: 12px !important;
font-weight: 500 !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
line-height: 1 !important;
z-index: 9999 !important;
opacity: 0.8 !important;
transition: all 0.2s ease !important;
outline: none !important;
user-select: none !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4) !important;
display: inline-block !important;
vertical-align: top !important;
white-space: nowrap !important;
margin: 0 !important;
text-decoration: none !important;
`

// Hover effects
button.addEventListener('mouseenter', () => {
button.style.cssText += `
opacity: 1 !important;
background-color: rgba(0, 0, 0, 0.95) !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.6) !important;
`
})
button.addEventListener('mouseleave', () => {
button.style.cssText += `
opacity: 0.8 !important;
background-color: rgba(0, 0, 0, 0.8) !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4) !important;
`
})

// Focus effects for keyboard accessibility
button.addEventListener('focus', () => {
button.style.cssText += `
outline: 2px solid rgba(100, 150, 255, 0.8) !important;
outline-offset: 2px !important;
`
})
button.addEventListener('blur', () => {
button.style.outline = 'none !important'
})

// Copy handler
const handleCopy = async (e: Event) => {
e.preventDefault()
e.stopPropagation()

const text = extractCodeText(codeBlock)
if (!text) {
console.warn('No code text found in code block')
return
}

const success = await copyToClipboard(text)

if (success) {
// Visual feedback - change to Copied! state
const originalText = button.textContent
const originalStyles = button.style.cssText

button.textContent = 'Copied!'
button.style.cssText = `
position: absolute !important;
top: 6px !important;
right: 6px !important;
padding: 4px 10px !important;
height: auto !important;
width: auto !important;
background-color: rgba(34, 197, 94, 0.9) !important;
color: #fff !important;
border: 1px solid rgba(76, 224, 128, 0.5) !important;
border-radius: 3px !important;
cursor: pointer !important;
font-size: 12px !important;
font-weight: 500 !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
line-height: 1 !important;
z-index: 9999 !important;
opacity: 1 !important;
transition: all 0.2s ease !important;
outline: none !important;
user-select: none !important;
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.5) !important;
display: inline-block !important;
vertical-align: top !important;
white-space: nowrap !important;
margin: 0 !important;
text-decoration: none !important;
`

setTimeout(() => {
button.textContent = originalText
button.style.cssText = originalStyles
}, 1500)
} else {
// Error feedback
const originalStyles = button.style.cssText
button.textContent = 'Failed'
button.style.cssText = `
position: absolute !important;
top: 6px !important;
right: 6px !important;
padding: 4px 10px !important;
height: auto !important;
width: auto !important;
background-color: rgba(200, 50, 50, 0.9) !important;
color: #fff !important;
border: 1px solid rgba(240, 100, 100, 0.5) !important;
border-radius: 3px !important;
cursor: pointer !important;
font-size: 12px !important;
font-weight: 500 !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
line-height: 1 !important;
z-index: 9999 !important;
opacity: 1 !important;
transition: all 0.2s ease !important;
outline: none !important;
user-select: none !important;
box-shadow: 0 2px 6px rgba(200, 50, 50, 0.5) !important;
display: inline-block !important;
vertical-align: top !important;
white-space: nowrap !important;
margin: 0 !important;
text-decoration: none !important;
`
setTimeout(() => {
button.textContent = 'Copy'
button.style.cssText = originalStyles
}, 1500)
}
}

// Click handler
button.addEventListener('click', handleCopy)

// Keyboard support: Enter or Space
button.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleCopy(e)
}
})

// Insert button at the end of container (top-right position)
container.appendChild(button)
})
}

// Initial pass
handleCodeBlocks()

// Watch for dynamically added code blocks
const observer = new MutationObserver((mutations) => {
// Debounce to avoid excessive processing
clearTimeout((observer as any).__timeout)
;(observer as any).__timeout = setTimeout(() => {
handleCodeBlocks()
}, 100)
})

observer.observe(document.body, {
childList: true,
subtree: true,
attributes: false,
characterData: false,
})

return () => {
observer.disconnect()
}
}, [router.pathname])
}