File size: 5,613 Bytes
1b44660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
export interface TocItem {
  id: string;
  text: string;
  level: number;
}

export interface UseTableOfContentsOptions {
  contentRef: Ref<HTMLElement | null>;
  headerOffset?: number;
  selectors?: string; // e.g., 'h2, h3, u > strong'
}

const DEFAULT_HEADER_OFFSET = 80;
const DEFAULT_SELECTORS = 'h2, h3, u > strong';

// Simple slugify, might need refinement depending on edge cases
const generateSlug = (text: string): string => {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9\s-]/g, '') // Remove special chars except space/hyphen
    .trim()
    .replace(/\s+/g, '-') // Replace spaces with hyphens
    .slice(0, 50); // Limit length
};

export function useTableOfContents({
  contentRef,
  headerOffset = DEFAULT_HEADER_OFFSET,
  selectors = DEFAULT_SELECTORS,
}: UseTableOfContentsOptions) {
  const tocItems = ref<TocItem[]>([]);
  const activeHeadingId = ref<string | null>(null);
  const mobileMenuOpen = ref(false); // Keep mobile state here if tied to TOC display

  let observer: IntersectionObserver | null = null;

  const generateToc = () => {
    if (!contentRef.value) return;

    const elements = contentRef.value.querySelectorAll(selectors);
    const newTocItems: TocItem[] = [];
    const observedElements: Element[] = []; // Keep track of elements to observe

    elements.forEach((el, index) => {
      let level: number;
      const text = el.textContent?.trim() || '';
      let targetElement: HTMLElement = el as HTMLElement;

      if (el.tagName === 'H2') level = 2;
      else if (el.tagName === 'H3') level = 3;
      else if (el.tagName === 'STRONG' && el.parentElement?.tagName === 'U') {
        level = 5; // Special level for topics
        targetElement = el.parentElement; // Target the <u> tag
      } else {
        return; // Skip unrecognized elements
      }

      // Ensure unique ID even if slug is identical
      const id = `${level === 5 ? 'topic' : 'section'}-${index}-${generateSlug(text)}`;

      if (text && targetElement) {
        targetElement.id = id; // Assign ID
        newTocItems.push({ id, text, level });
        observedElements.push(targetElement); // Add element for intersection observer
      }
    });

    tocItems.value = newTocItems;
    setupIntersectionObserver(observedElements); // Setup observer after generating TOC
  };

  const setupIntersectionObserver = (elements: Element[]) => {
    // Disconnect previous observer if exists
    if (observer) {
      observer.disconnect();
    }

    // Observer options: trigger when heading is near the top of the viewport
    const options = {
      rootMargin: `-${headerOffset - 1}px 0px -${window.innerHeight - headerOffset - 50}px 0px`, // Adjust bottom margin as needed
      threshold: 0, // Trigger as soon as any part enters/leaves the rootMargin
    };

    observer = new IntersectionObserver(entries => {
      // Find the topmost visible entry
      let topmostVisibleEntry: IntersectionObserverEntry | null = null;
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // Prioritize the entry closest to the top boundary defined by rootMargin
          if (!topmostVisibleEntry || entry.boundingClientRect.top < topmostVisibleEntry.boundingClientRect.top) {
            topmostVisibleEntry = entry;
          }
        }
      });

      if (topmostVisibleEntry) {
        activeHeadingId.value = (topmostVisibleEntry as IntersectionObserverEntry).target.id;
      } else {
        // If no entry is intersecting within the top margin, check if we scrolled past the first item
        if (tocItems.value.length > 0 && window.scrollY > document.getElementById(tocItems.value[0].id)!.offsetTop) {
          // Potentially keep the last active ID, or find the last item scrolled past
          // For simplicity, let's just keep the *last* one that *was* active if nothing is currently in the top zone
          // activeHeadingId.value remains unchanged unless explicitly cleared or updated
        } else {
          // Scrolled to the very top above the first item
          activeHeadingId.value = null;
        }
      }
    }, options);

    elements.forEach(el => observer!.observe(el));
  };

  // Computed property for the "current section name" shown in mobile/dropdown
  const currentSectionName = computed(() => {
    if (!activeHeadingId.value) {
      return 'on this page'; // Default text
    }
    const activeItem = tocItems.value.find(item => item.id === activeHeadingId.value);
    // Maybe find the parent H2 if the active item is H3/topic? Depends on desired UX.
    // For now, just use the active item's text.
    return activeItem ? activeItem.text.toLowerCase() : 'on this page';
  });

  const scrollToSection = (id: string) => {
    const el = document.getElementById(id);
    if (el) {
      const elementPosition = el.getBoundingClientRect().top;
      const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
      window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
      mobileMenuOpen.value = false; // Close mobile menu on selection
    }
  };

  onMounted(() => {
    // Ensure DOM is ready before querying elements
    nextTick(() => {
      generateToc();
    });
  });

  onUnmounted(() => {
    if (observer) {
      observer.disconnect();
    }
  });

  // Optional: Watch for content changes if the article content could be dynamic
  // watch(contentRef, () => { nextTick(generateToc); });

  return {
    tocItems,
    activeHeadingId,
    currentSectionName,
    mobileMenuOpen,
    generateToc, // Expose if manual regeneration is needed
    scrollToSection,
  };
}