j-cli 12.9.18

A fast CLI tool for alias management, daily reports, and productivity
import { useMemo, useEffect, useState, useRef, useCallback } from 'react'
import type { Language } from '../../types'

interface TOCItem {
  id: string
  text: string
  level: number // 2 for h2, 3 for h3, 4 for h4
}

interface TOCProps {
  content: string
  lang: Language
}

// i18n for TOC title
const tocTitleI18n: Record<Language, string> = {
  en: 'On This Page',
  zh: '本文目录'
}

// Top navigation bar height
const NAV_HEIGHT = 70

// Generate slug from text
function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\u4e00-\u9fa5]+/g, '-') // Keep alphanumeric and Chinese
    .replace(/^-+|-+$/g, '')
    .slice(0, 50) // Limit length
}

// Extract headings from markdown content
function extractHeadings(content: string): TOCItem[] {
  const lines = content.split('\n')
  const headings: TOCItem[] = []
  const usedIds = new Set<string>()
  let inCodeBlock = false
  
  lines.forEach(line => {
    // Track code block boundaries
    if (line.startsWith('```')) {
      inCodeBlock = !inCodeBlock
      return
    }
    
    // Skip content inside code blocks
    if (inCodeBlock) return
    
    let text: string
    let level: number
    
    if (line.startsWith('## ')) {
      text = line.slice(3).trim()
      level = 2
    } else if (line.startsWith('### ')) {
      text = line.slice(4).trim()
      level = 3
    } else if (line.startsWith('#### ')) {
      text = line.slice(5).trim()
      level = 4
    } else {
      return
    }
    
    // Remove markdown formatting from text
    text = text.replace(/\*\*([^*]+)\*\*/g, '$1')
    text = text.replace(/\*([^*]+)\*/g, '$1')
    text = text.replace(/`([^`]+)`/g, '$1')
    
    // Generate unique id
    let id = slugify(text)
    let counter = 1
    while (usedIds.has(id)) {
      id = `${slugify(text)}-${counter}`
      counter++
    }
    usedIds.add(id)
    
    headings.push({ id, text, level })
  })
  
  return headings
}

export function TOC({ content, lang }: TOCProps) {
  const headings = useMemo(() => extractHeadings(content), [content])
  const [activeId, setActiveId] = useState<string | null>(null)
  const isScrollingRef = useRef(false)
  
  // Scroll to heading with accurate position
  const scrollToHeading = useCallback((id: string) => {
    const element = document.getElementById(id)
    if (!element) return
    
    // Immediately update active state
    setActiveId(id)
    isScrollingRef.current = true
    
    // Calculate scroll position considering nav height
    const elementTop = element.offsetTop - NAV_HEIGHT
    window.scrollTo({
      top: elementTop,
      behavior: 'smooth'
    })
    
    // Reset scrolling flag after animation
    setTimeout(() => {
      isScrollingRef.current = false
    }, 500)
  }, [])
  
  // Track scroll position to update active heading
  useEffect(() => {
    if (headings.length === 0) return
    
    const handleScroll = () => {
      // Skip during programmatic scroll
      if (isScrollingRef.current) return
      
      const scrollY = window.scrollY + NAV_HEIGHT + 20
      
      // Find the heading closest to current scroll position
      let currentId: string | null = null
      for (const heading of headings) {
        const el = document.getElementById(heading.id)
        if (el && el.offsetTop <= scrollY) {
          currentId = heading.id
        }
      }
      
      if (currentId && currentId !== activeId) {
        setActiveId(currentId)
      }
    }
    
    // Initialize on mount
    handleScroll()
    
    window.addEventListener('scroll', handleScroll, { passive: true })
    return () => window.removeEventListener('scroll', handleScroll)
  }, [headings, activeId])
  
  // Don't render if no headings
  if (headings.length === 0) {
    return null
  }
  
  return (
    <nav className="hidden xl:block fixed right-0 top-[65px] w-52 h-[calc(100vh-65px)] border-l border-stone-200/70 bg-[#faf9f6]/95 backdrop-blur-sm">
      {/* Header */}
      <div className="sticky top-0 px-4 py-3 border-b border-stone-200/50 bg-[#faf9f6]">
        <span className="text-xs font-semibold text-stone-400 uppercase tracking-wider">
          {tocTitleI18n[lang]}
        </span>
      </div>
      
      {/* Heading list */}
      <ul className="py-2 px-1 overflow-y-auto max-h-[calc(100vh-120px)]">
        {headings.map(({ id, text, level }) => {
          const isActive = activeId === id
          const isH3 = level === 3
          const isH4 = level === 4
          
          return (
            <li key={id}>
              <button
                onClick={() => scrollToHeading(id)}
                className={`
                  relative w-full text-left py-1.5 px-3 rounded-lg transition-all duration-200
                  ${isH4 
                    ? 'pl-8 text-xs' 
                    : isH3 
                      ? 'pl-6 text-xs' 
                      : 'text-sm mt-1'
                  }
                  ${isActive 
                    ? isH4
                      ? 'text-stone-700 font-medium bg-stone-50 before:absolute before:left-5 before:top-1/2 before:-translate-y-1/2 before:w-1 before:h-1 before:bg-stone-400 before:rounded-full'
                      : isH3
                        ? 'text-stone-800 font-medium bg-stone-50 before:absolute before:left-3 before:top-1/2 before:-translate-y-1/2 before:w-1 before:h-1 before:bg-stone-500 before:rounded-full'
                        : 'text-stone-900 font-medium bg-stone-100 before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.5 before:h-4 before:bg-stone-900 before:rounded-full'
                    : isH4
                      ? 'text-stone-400 hover:text-stone-500 hover:bg-stone-50'
                      : isH3
                        ? 'text-stone-400 hover:text-stone-600 hover:bg-stone-50'
                        : 'text-stone-500 hover:text-stone-700 hover:bg-stone-50'
                  }
                `}
              >
                {text}
              </button>
            </li>
          )
        })}
      </ul>
    </nav>
  )
}