import { Component, inject, Input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TocService, TocItem } from '../../services/toc.service'; @Component({ selector: 'app-toc-panel', standalone: true, imports: [CommonModule], template: ` `, styles: [` .toc-panel { background: var(--toc-bg, #111827); color: var(--toc-fg, #e5e7eb); border-left: 1px solid var(--toc-border, rgba(148, 163, 184, 0.18)); } .toc-header { border-bottom: 1px solid var(--toc-border, rgba(148, 163, 184, 0.18)); color: inherit; } .toc-close-btn { color: inherit; } .toc-close-btn:hover { background: color-mix(in srgb, rgba(148, 163, 184, 0.15) 60%, transparent); } .toc-empty { color: var(--toc-muted, rgba(148, 163, 184, 0.75)); } .toc-footer { border-top: 1px solid var(--toc-border, rgba(148, 163, 184, 0.18)); color: var(--toc-muted, rgba(148, 163, 184, 0.75)); } .toc-item { border: 1px solid transparent; background: color-mix(in srgb, var(--toc-bg, #111827) 70%, rgba(148, 163, 184, 0.12) 30%); color: inherit; box-shadow: inset 0 0 0 0 transparent; } .toc-item:hover { border-color: color-mix(in srgb, var(--toc-active, #6366f1) 35%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--toc-active, #6366f1) 25%, transparent); background: color-mix(in srgb, rgba(99, 102, 241, 0.18) 40%, var(--toc-bg, #111827)); } .toc-item:focus-visible { outline: 2px solid var(--toc-active, #6366f1); outline-offset: 2px; } .toc-item-h1 { padding-left: 0.25rem; font-weight: 600; } .toc-item-h2 { padding-left: 1.25rem; font-weight: 500; } .toc-item-h3 { padding-left: 2.25rem; font-weight: 500; font-size: 0.8125rem; color: var(--toc-muted, rgba(148, 163, 184, 0.75)); } .toc-item-active { border-color: color-mix(in srgb, var(--toc-active, #6366f1) 60%, transparent); background: color-mix(in srgb, var(--toc-active, #6366f1) 18%, transparent); color: var(--toc-active, #6366f1); } .toc-text { display: flex; justify-content: space-between; gap: 0.5rem; color: inherit; } .toc-level { font-size: 0.75rem; color: var(--toc-muted, rgba(148, 163, 184, 0.75)); } `] }) export class TocPanelComponent { readonly tocService = inject(TocService); @Input() mode: 'fixed' | 'container' = 'fixed'; private collapsed = new Set(); getTocItemClass(item: TocItem): string { switch (item.level) { case 1: return 'toc-item-h1'; case 2: return 'toc-item-h2'; case 3: return 'toc-item-h3'; default: return 'toc-item-h3'; } } get panelClass(): string { const base = 'toc-panel shadow-xl z-40'; if (this.mode === 'container') { return `${base} h-full overflow-hidden`; } return `${base} fixed right-0 top-0 bottom-0 overflow-y-auto`; } onItemClick(item: TocItem, ev?: MouseEvent): void { // Shift+Click on H1/H2 toggles collapse/expand if ((ev?.shiftKey) && this.isCollapsible(item)) { this.toggleCollapse(item); return; } this.ensureExpandedFor(item); this.tocService.scrollToHeading(item.blockId); } ngOnChanges(): void { this.maybeFocusFirst(); } ngAfterViewChecked(): void { this.maybeFocusFirst(); } private lastFocused = false; private maybeFocusFirst() { // When panel opens, focus first item once if (this.tocService.isOpen() && !this.lastFocused) { const root = (document.getElementById('toc-panel')) as HTMLElement | null; const btn = root?.querySelector('button'); (btn as HTMLElement | null)?.focus?.(); this.lastFocused = true; } if (!this.tocService.isOpen() && this.lastFocused) this.lastFocused = false; // Auto-expand ancestors for active item this.ensureExpandedForActive(); } onKeydown(ev: KeyboardEvent) { const root = document.getElementById('toc-panel') as HTMLElement | null; if (!root) return; const items = Array.from(root.querySelectorAll('button')) as HTMLElement[]; if (!items.length) return; const active = document.activeElement as HTMLElement | null; let idx = Math.max(0, items.findIndex(b => b === active)); const move = (delta: number) => { idx = (idx + delta + items.length) % items.length; items[idx]?.focus?.(); }; switch (ev.key) { case 'ArrowDown': move(1); ev.preventDefault(); break; case 'ArrowUp': move(-1); ev.preventDefault(); break; case 'Home': idx = 0; items[idx]?.focus?.(); ev.preventDefault(); break; case 'End': idx = items.length - 1; items[idx]?.focus?.(); ev.preventDefault(); break; case 'Enter': case ' ': (active as HTMLButtonElement | null)?.click?.(); ev.preventDefault(); break; case 'Tab': { // Focus trap inside panel const shift = ev.shiftKey; if (shift && idx === 0) { items[items.length - 1]?.focus?.(); ev.preventDefault(); } else if (!shift && idx === items.length - 1) { items[0]?.focus?.(); ev.preventDefault(); } break; } } } isCollapsible(item: TocItem): boolean { return item.level === 1 || item.level === 2; } isCollapsed(item: TocItem): boolean { return this.collapsed.has(item.blockId); } toggleCollapse(item: TocItem): void { if (!this.isCollapsible(item)) return; if (this.isCollapsed(item)) this.collapsed.delete(item.blockId); else this.collapsed.add(item.blockId); } visibleTocItems(): TocItem[] { const items = this.tocService.tocItems(); const out: TocItem[] = []; let hideLevel1: string | null = null; let hideLevel2: string | null = null; for (let i = 0; i < items.length; i++) { const it = items[i]; if (it.level === 1) { hideLevel2 = null; hideLevel1 = this.isCollapsed(it) ? it.blockId : null; out.push(it); continue; } if (it.level === 2) { if (hideLevel1) continue; // hidden under collapsed H1 hideLevel2 = this.isCollapsed(it) ? it.blockId : null; out.push(it); continue; } // level 3 if (hideLevel1 || hideLevel2) continue; out.push(it); } return out; } private ensureExpandedForActive() { const active = this.tocService.activeId(); if (!active) return; const items = this.tocService.tocItems(); const idx = items.findIndex(it => it.blockId === active); if (idx < 0) return; // Expand nearest ancestors (H2 then H1 above) for (let i = idx - 1; i >= 0; i--) { const it = items[i]; if (it.level === 3) continue; if (it.level === 2) { this.collapsed.delete(it.blockId); } if (it.level === 1) { this.collapsed.delete(it.blockId); break; } } } private ensureExpandedFor(item: TocItem) { // When navigating to item, expand its ancestors if (item.level === 3) { const items = this.tocService.tocItems(); const idx = items.findIndex(it => it.blockId === item.blockId); for (let i = idx - 1; i >= 0; i--) { const it = items[i]; if (it.level === 2) this.collapsed.delete(it.blockId); if (it.level === 1) { this.collapsed.delete(it.blockId); break; } } } else if (item.level === 2) { const items = this.tocService.tocItems(); const idx = items.findIndex(it => it.blockId === item.blockId); for (let i = idx - 1; i >= 0; i--) { const it = items[i]; if (it.level === 1) { this.collapsed.delete(it.blockId); break; } } } } }