diff --git a/.vscode/settings.json b/.vscode/settings.json index 2a7e290..20a180f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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, diff --git a/src/pages/docs/[version]/[[...slug]].tsx b/src/pages/docs/[version]/[[...slug]].tsx index 4279728..c80cbf2 100644 --- a/src/pages/docs/[version]/[[...slug]].tsx +++ b/src/pages/docs/[version]/[[...slug]].tsx @@ -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 @@ -226,6 +227,7 @@ function findInTreeBFS(tree: TreeNode[], check: (node: TreeNode) => boolean): Tr const Page: FC> = ({ doc, tree, breadcrumbs, childrenTree }) => { const router = useRouter() useLiveReload() + useCopyButtonEnhancer() const MDXContent = useMDXComponent(doc.body.code || '') let content = MDXContent && const checkGrammar = useCheckGrammar() diff --git a/src/utils/useCopyButtonEnhancer.ts b/src/utils/useCopyButtonEnhancer.ts new file mode 100644 index 0000000..19aba7b --- /dev/null +++ b/src/utils/useCopyButtonEnhancer.ts @@ -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
 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 => {
+      // 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 
 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])
+}