getData(); if (!$this->isEnabled($data)) { return ''; } if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) { return ''; } $tocItems = $this->generateTocItems($data); if (empty($tocItems)) { return ''; } $css = $this->generateCSS($data); $html = $this->buildHTML($data, $tocItems); $script = $this->buildScript($data); return sprintf("\n%s\n%s", $css, $html, $script); } public function supports(string $componentType): bool { return $componentType === self::COMPONENT_NAME; } private function isEnabled(array $data): bool { return ($data['visibility']['is_enabled'] ?? false) === true; } private function getVisibilityClasses(bool $desktop, bool $mobile): ?string { if (!$desktop && !$mobile) { return null; } if (!$desktop && $mobile) { return 'd-lg-none'; } if ($desktop && !$mobile) { return 'd-none d-lg-block'; } return ''; } private function generateTocItems(array $data): array { $content = $data['content'] ?? []; $autoGenerate = $content['auto_generate'] ?? true; if (!$autoGenerate) { return []; } $headingLevelsStr = $content['heading_levels'] ?? 'h2,h3'; $headingLevels = array_map('trim', explode(',', $headingLevelsStr)); return $this->generateTocFromContent($headingLevels); } private function generateTocFromContent(array $headingLevels): array { global $post; if (!$post || empty($post->post_content)) { return []; } $content = apply_filters('the_content', $post->post_content); $dom = new DOMDocument(); libxml_use_internal_errors(true); $dom->loadHTML('' . $content); libxml_clear_errors(); $xpath = new DOMXPath($dom); $tocItems = []; $xpathQuery = implode(' | ', array_map(function($level) { return '//' . $level; }, $headingLevels)); $headings = $xpath->query($xpathQuery); if ($headings->length === 0) { return []; } foreach ($headings as $heading) { $tagName = strtolower($heading->tagName); $level = intval(substr($tagName, 1)); $text = trim($heading->textContent); if (empty($text)) { continue; } $existingId = $heading->getAttribute('id'); // Generar anchor ID - la inyección de IDs se hace via JavaScript // para evitar problemas con orden de filtros WordPress y caché if (empty($existingId)) { $anchor = $this->generateAnchorId($text); } else { $anchor = $existingId; } $tocItems[] = [ 'text' => $text, 'anchor' => $anchor, 'level' => $level ]; } return $tocItems; } private function generateAnchorId(string $text): string { $id = strtolower($text); $id = remove_accents($id); $id = preg_replace('/[^a-z0-9]+/', '-', $id); $id = trim($id, '-'); $baseId = $id; $count = 1; while (isset($this->headingCounter[$id])) { $id = $baseId . '-' . $count; $count++; } $this->headingCounter[$id] = true; return $id; } private function addIdToHeading(string $headingText, string $anchorId): void { add_filter('the_content', function($content) use ($headingText, $anchorId) { $pattern = '/<(h[2-6])([^>]*)>(\s*)' . preg_quote($headingText, '/') . '(\s*)<\/\1>/i'; $replacement = '<$1 id="' . esc_attr($anchorId) . '"$2>$3' . $headingText . '$4'; return preg_replace($pattern, $replacement, $content, 1); }, 20); } public function generateCSS(array $data): string { $colors = $data['colors'] ?? []; $spacing = $data['spacing'] ?? []; $typography = $data['typography'] ?? []; $effects = $data['visual_effects'] ?? []; $behavior = $data['behavior'] ?? []; $visibility = $data['visibility'] ?? []; $cssRules = []; // Container styles - Flexbox layout for proper scrolling $cssRules[] = $this->cssGenerator->generate('.toc-container', [ 'background-color' => $colors['background_color'] ?? '#ffffff', 'border' => ($effects['border_width'] ?? '1px') . ' solid ' . ($colors['border_color'] ?? '#E6E9ED'), 'border-radius' => $effects['border_radius'] ?? '8px', 'box-shadow' => $effects['box_shadow'] ?? '0 2px 8px rgba(0, 0, 0, 0.08)', 'padding' => $spacing['container_padding'] ?? '12px 16px', 'margin-bottom' => $spacing['margin_bottom'] ?? '13px', 'max-height' => $behavior['max_height'] ?? 'calc(100vh - 71px - 10px - 250px - 15px - 15px)', 'display' => 'flex', 'flex-direction' => 'column', 'overflow' => 'visible', ]); // Sticky behavior - aplica al wrapper .sidebar-sticky de single.php // NO al .toc-container individual (ver template líneas 817-835) if (($behavior['is_sticky'] ?? true)) { $cssRules[] = $this->cssGenerator->generate('.sidebar-sticky', [ 'position' => 'sticky', 'top' => '85px', 'display' => 'flex', 'flex-direction' => 'column', ]); } // Custom scrollbar $cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar', [ 'width' => $spacing['scrollbar_width'] ?? '6px', ]); $cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar-track', [ 'background' => $colors['scrollbar_track_color'] ?? '#F9FAFB', 'border-radius' => $effects['scrollbar_border_radius'] ?? '3px', ]); $cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar-thumb', [ 'background' => $colors['scrollbar_thumb_color'] ?? '#6B7280', 'border-radius' => $effects['scrollbar_border_radius'] ?? '3px', ]); // Title styles - Color #1e3a5f = navy-primary del Design System $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-title', [ 'font-size' => $typography['title_font_size'] ?? '1rem', 'font-weight' => $typography['title_font_weight'] ?? '600', 'color' => $colors['title_color'] ?? '#1e3a5f', 'padding-bottom' => $spacing['title_padding_bottom'] ?? '8px', 'margin-bottom' => $spacing['title_margin_bottom'] ?? '0.75rem', 'border-bottom' => '2px solid ' . ($colors['title_border_color'] ?? '#E6E9ED'), 'margin-top' => '0', ]); // List styles - Scrollable area with flex $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list', [ 'margin' => '0', 'padding' => '0', 'padding-right' => '0.5rem', 'list-style' => 'none', 'overflow-y' => 'auto', 'flex' => '1', 'min-height' => '0', ]); $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list li', [ 'margin-bottom' => $spacing['item_margin_bottom'] ?? '0.15rem', ]); // Link styles - Color #495057 = neutral-600 del template $transitionDuration = $effects['transition_duration'] ?? '0.3s'; $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link', [ 'display' => 'block', 'font-size' => $typography['link_font_size'] ?? '0.9rem', 'line-height' => $typography['link_line_height'] ?? '1.3', 'color' => $colors['link_color'] ?? '#495057', 'text-decoration' => 'none', 'padding' => $spacing['link_padding'] ?? '0.3rem 0.85rem', 'border-radius' => $effects['link_border_radius'] ?? '4px', 'border-left' => ($effects['active_border_left_width'] ?? '3px') . ' solid transparent', 'transition' => "all {$transitionDuration} ease", ]); // Link hover - Color #1e3a5f = navy-primary del Design System // Template: background, border-left-color, color $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link:hover', [ 'color' => $colors['link_hover_color'] ?? '#1e3a5f', 'background-color' => $colors['link_hover_background'] ?? '#F9FAFB', 'border-left-color' => $colors['active_border_color'] ?? '#1e3a5f', ]); // Active link - Color #1e3a5f = navy-primary del Design System // Template: font-weight: 600 $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link.active', [ 'color' => $colors['active_text_color'] ?? '#1e3a5f', 'background-color' => $colors['active_background_color'] ?? '#F9FAFB', 'border-left-color' => $colors['active_border_color'] ?? '#1e3a5f', 'font-weight' => '600', ]); // Focus - eliminar outline azul del browser en click $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link:focus', [ 'outline' => 'none', ]); // Level indentation $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-level-3 .toc-link', [ 'padding-left' => $spacing['level_three_padding_left'] ?? '1.5rem', 'font-size' => $typography['level_three_font_size'] ?? '0.85rem', ]); $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-level-4 .toc-link', [ 'padding-left' => $spacing['level_four_padding_left'] ?? '2rem', 'font-size' => $typography['level_four_font_size'] ?? '0.8rem', ]); // Scrollbar for toc-list $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar', [ 'width' => $spacing['scrollbar_width'] ?? '6px', ]); $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-track', [ 'background' => $colors['scrollbar_track_color'] ?? '#F9FAFB', 'border-radius' => $effects['scrollbar_border_radius'] ?? '3px', ]); $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-thumb', [ 'background' => $colors['scrollbar_thumb_color'] ?? '#6B7280', 'border-radius' => $effects['scrollbar_border_radius'] ?? '3px', ]); $cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-thumb:hover', [ 'background' => $colors['active_border_color'] ?? '#1e3a5f', ]); // Responsive visibility $showOnDesktop = $visibility['show_on_desktop'] ?? true; $showOnMobile = $visibility['show_on_mobile'] ?? false; if (!$showOnMobile) { $cssRules[] = "@media (max-width: 991.98px) { .toc-container { display: none !important; } }"; } if (!$showOnDesktop) { $cssRules[] = "@media (min-width: 992px) { .toc-container { display: none !important; } }"; } // Responsive layout adjustments $cssRules[] = "@media (max-width: 991px) { .sidebar-sticky { position: relative !important; top: 0 !important; } .toc-container { margin-bottom: 2rem; } .toc-container .toc-list { max-height: 300px; } }"; return implode("\n", $cssRules); } private function buildHTML(array $data, array $tocItems): string { $content = $data['content'] ?? []; $title = $content['title'] ?? 'Tabla de Contenido'; // NOTA: El sticky behavior se maneja en el wrapper .sidebar-sticky de single.php // El TOC no debe tener la clase sidebar-sticky - está dentro del wrapper $html = '
'; $html .= sprintf( '%s', esc_html($title) ); $html .= '
    '; foreach ($tocItems as $item) { $text = $item['text'] ?? ''; $anchor = $item['anchor'] ?? ''; $level = $item['level'] ?? 2; if (empty($text) || empty($anchor)) { continue; } $indentClass = $level > 2 ? 'toc-level-' . $level : ''; $html .= sprintf( '
  1. %s
  2. ', esc_attr($indentClass), esc_attr($anchor), intval($level), esc_html($text) ); } $html .= '
'; $html .= '
'; return $html; } private function buildScript(array $data): string { $content = $data['content'] ?? []; $behavior = $data['behavior'] ?? []; $smoothScroll = $content['smooth_scroll'] ?? true; $scrollOffset = intval($behavior['scroll_offset'] ?? 100); if (!$smoothScroll) { return ''; } // Script con inyección de IDs client-side para mayor fiabilidad // Resuelve problemas de orden de filtros WordPress y caché $script = << document.addEventListener('DOMContentLoaded', function() { var tocLinks = document.querySelectorAll('.toc-link'); var offsetTop = {$scrollOffset}; // =========================================== // PASO 1: Generar IDs para headings sin ID // =========================================== function generateSlug(text) { return text .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') // Remover acentos .replace(/[^a-z0-9]+/g, '-') // Reemplazar caracteres especiales .replace(/^-+|-+$/g, ''); // Trim guiones } // Mapeo de IDs existentes para evitar duplicados var existingIds = {}; document.querySelectorAll('[id]').forEach(function(el) { existingIds[el.id] = true; }); // Para cada link del TOC, buscar el heading correspondiente y asignarle ID tocLinks.forEach(function(link) { var targetId = link.getAttribute('href').substring(1); // Remover # var existingElement = document.getElementById(targetId); // Si ya existe el elemento con ese ID, no hacer nada if (existingElement) return; // Buscar el heading por su texto var linkText = link.textContent.trim(); var headings = document.querySelectorAll('h2, h3, h4, h5, h6'); headings.forEach(function(heading) { // Comparar texto normalizado var headingText = heading.textContent.trim(); if (headingText === linkText && !heading.id) { // Asignar el ID que espera el TOC heading.id = targetId; existingIds[targetId] = true; } }); }); // =========================================== // PASO 2: Smooth scroll on click // =========================================== tocLinks.forEach(function(link) { link.addEventListener('click', function(e) { e.preventDefault(); var targetId = this.getAttribute('href'); var targetElement = document.querySelector(targetId); if (targetElement) { var elementPosition = targetElement.getBoundingClientRect().top; var offsetPosition = elementPosition + window.pageYOffset - offsetTop; window.scrollTo({ top: offsetPosition, behavior: 'smooth' }); } }); }); // =========================================== // PASO 3: ScrollSpy con Intersection Observer // =========================================== var sections = []; var sectionMap = {}; tocLinks.forEach(function(link) { var id = link.getAttribute('href').substring(1); var section = document.getElementById(id); if (section) { sections.push(section); sectionMap[id] = link; } }); if (sections.length === 0) return; var visibleSections = new Set(); var currentActive = null; function updateActiveFromVisible() { if (visibleSections.size === 0) return; var topMostSection = null; var topMostPosition = Infinity; visibleSections.forEach(function(id) { var section = document.getElementById(id); if (section) { var rect = section.getBoundingClientRect(); if (rect.top < topMostPosition) { topMostPosition = rect.top; topMostSection = id; } } }); if (topMostSection && topMostSection !== currentActive) { tocLinks.forEach(function(link) { link.classList.remove('active'); }); if (sectionMap[topMostSection]) { sectionMap[topMostSection].classList.add('active'); currentActive = topMostSection; // Auto-scroll del TOC para mostrar el elemento activo sectionMap[topMostSection].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } } var observerOptions = { root: null, rootMargin: '-' + offsetTop + 'px 0px -50% 0px', threshold: 0 }; var observer = new IntersectionObserver(function(entries) { entries.forEach(function(entry) { var id = entry.target.id; if (entry.isIntersecting) { visibleSections.add(id); } else { visibleSections.delete(id); } }); requestAnimationFrame(updateActiveFromVisible); }, observerOptions); sections.forEach(function(section) { observer.observe(section); }); // Activar primera sección al cargar requestAnimationFrame(function() { if (visibleSections.size === 0 && sections.length > 0) { var firstId = sections[0].id; if (sectionMap[firstId]) { sectionMap[firstId].classList.add('active'); currentActive = firstId; } } }); }); JS; return $script; } }