File size: 3,375 Bytes
35a414b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
/**
 * T037-T047: Table of Contents hook
 * Extracts headings from rendered markdown and provides scroll navigation
 */
import { useEffect, useState, useCallback } from 'react';

export interface Heading {
  id: string;
  text: string;
  level: number;
}

interface UseTableOfContentsReturn {
  headings: Heading[];
  isOpen: boolean;
  setIsOpen: (isOpen: boolean) => void;
  scrollToHeading: (id: string) => void;
}

/**
 * T040: Slugify text to create valid HTML IDs
 * Handles duplicates by appending -2, -3, etc.
 */
export function slugify(text: string, existingSlugs: Set<string> = new Set()): string {
  // T040: Basic slugification
  const baseSlug = text
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^\w-]/g, '');

  // T050: Handle duplicates
  if (!existingSlugs.has(baseSlug)) {
    return baseSlug;
  }

  let counter = 2;
  let uniqueSlug = `${baseSlug}-${counter}`;
  while (existingSlugs.has(uniqueSlug)) {
    counter++;
    uniqueSlug = `${baseSlug}-${counter}`;
  }

  return uniqueSlug;
}

/**
 * T037-T047: Hook for managing TOC state and heading extraction
 */
export function useTableOfContents(): UseTableOfContentsReturn {
  // T042: TOC panel open state
  const [isOpen, setIsOpenState] = useState<boolean>(() => {
    // T043: Restore from localStorage
    const saved = localStorage.getItem('toc-panel-open');
    return saved ? JSON.parse(saved) : false;
  });

  // T041: Store extracted headings
  const [headings, setHeadings] = useState<Heading[]>([]);

  // T043: Persist panel state to localStorage
  const setIsOpen = useCallback((open: boolean) => {
    setIsOpenState(open);
    localStorage.setItem('toc-panel-open', JSON.stringify(open));
  }, []);

  // T041: Extract headings from DOM (called after render)
  const extractHeadings = useCallback(() => {
    const headingElements = document.querySelectorAll('h1, h2, h3');
    const extracted: Heading[] = [];

    headingElements.forEach((element) => {
      const id = element.id;
      const text = element.textContent || '';
      const level = parseInt(element.tagName.charAt(1));

      if (id && text) {
        extracted.push({ id, text, level });
      }
    });

    setHeadings(extracted);
  }, []);

  // T046-T047: Scroll to heading with smooth behavior and accessibility
  const scrollToHeading = useCallback((id: string) => {
    const element = document.getElementById(id);
    if (element) {
      // T047: Respect prefers-reduced-motion
      const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      element.scrollIntoView({
        behavior: prefersReducedMotion ? 'auto' : 'smooth',
        block: 'start'
      });
    }
  }, []);

  // Re-extract headings when content changes
  useEffect(() => {
    // Use MutationObserver to detect when markdown is rendered
    const observer = new MutationObserver(() => {
      extractHeadings();
    });

    // Observe the markdown content container
    const contentContainer = document.querySelector('.prose');
    if (contentContainer) {
      observer.observe(contentContainer, {
        childList: true,
        subtree: true,
      });

      // Initial extraction
      extractHeadings();
    }

    return () => {
      observer.disconnect();
    };
  }, [extractHeadings]);

  return {
    headings,
    isOpen,
    setIsOpen,
    scrollToHeading,
  };
}