| 1 |
<?php |
| 2 |
/** |
| 3 |
* TOC Renderer |
| 4 |
* |
| 5 |
* Modern, clean HTML and SVG generator for TOC layouts, counters, toggle icons, and sticky drawer. |
| 6 |
* |
| 7 |
* @package WPEXtra\Modules\Common\TOC |
| 8 |
*/ |
| 9 |
|
| 10 |
namespace WPEXtra\Modules\Common\TOC; |
| 11 |
|
| 12 |
use WPEXtra\Helper; |
| 13 |
|
| 14 |
if ( ! defined( 'ABSPATH' ) ) { |
| 15 |
exit; |
| 16 |
} |
| 17 |
|
| 18 |
class Renderer { |
| 19 |
|
| 20 |
/** |
| 21 |
* Render standard Table of Contents box. |
| 22 |
* |
| 23 |
* @param array $headings Nested tree of headings. |
| 24 |
* @param array $options Optional runtime option overrides. |
| 25 |
* @return string |
| 26 |
*/ |
| 27 |
public static function render( array $headings, array $options = array() ) { |
| 28 |
if ( empty( $headings ) ) { |
| 29 |
return ''; |
| 30 |
} |
| 31 |
|
| 32 |
$heading_text = isset( $options['heading_text'] ) ? $options['heading_text'] : Helper::get_option( 'heading_text', esc_html__( 'Table of Contents', 'wp-extra' ) ); |
| 33 |
$counter_style = isset( $options['counter'] ) ? $options['counter'] : Helper::get_option( 'counter', 'none' ); |
| 34 |
$collapse_mode = isset( $options['subheading_collapse_mode'] ) ? $options['subheading_collapse_mode'] : Helper::get_option( 'subheading_collapse_mode', 'open_first' ); |
| 35 |
$is_init_hide = (bool) Helper::get_option( 'visibility_hide_by_default', false ); |
| 36 |
$show_toggle = (bool) Helper::get_option( 'visibility', true ); |
| 37 |
|
| 38 |
$container_classes = array( |
| 39 |
'wptoc-container', |
| 40 |
'wptoc-layout-default', |
| 41 |
'wptoc-counter-' . sanitize_html_class( $counter_style ), |
| 42 |
'wptoc-mode-' . sanitize_html_class( $collapse_mode ), |
| 43 |
); |
| 44 |
|
| 45 |
if ( $is_init_hide ) { |
| 46 |
$container_classes[] = 'wptoc-collapsed'; |
| 47 |
$container_classes[] = 'toc_close'; |
| 48 |
} |
| 49 |
|
| 50 |
$title_icon = self::get_title_prefix_icon(); |
| 51 |
$toggle_icon = $show_toggle ? self::get_toggle_icon() : ''; |
| 52 |
|
| 53 |
ob_start(); |
| 54 |
?> |
| 55 |
<div id="wptoc-container" class="<?php echo esc_attr( implode( ' ', $container_classes ) ); ?>" role="navigation" aria-label="<?php echo esc_attr( $heading_text ); ?>"> |
| 56 |
<div class="wptoc-title-container"> |
| 57 |
<p class="wptoc-title"> |
| 58 |
<?php echo $title_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> |
| 59 |
<span class="wptoc-title-text"><?php echo esc_html( $heading_text ); ?></span> |
| 60 |
</p> |
| 61 |
<?php if ( $show_toggle ) : ?> |
| 62 |
<button type="button" class="wptoc-title-toggle wptoc-toggle" aria-label="<?php echo esc_attr__( 'Toggle table of contents', 'wp-extra' ); ?>" aria-expanded="<?php echo $is_init_hide ? 'false' : 'true'; ?>"> |
| 63 |
<?php echo $toggle_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> |
| 64 |
</button> |
| 65 |
<?php endif; ?> |
| 66 |
</div> |
| 67 |
<nav<?php echo $is_init_hide ? ' style="display: none;"' : ''; ?>> |
| 68 |
<?php echo self::render_list( $headings, 1, $collapse_mode ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> |
| 69 |
</nav> |
| 70 |
</div> |
| 71 |
<?php |
| 72 |
return ob_get_clean(); |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* Recursive nested list renderer. |
| 77 |
* |
| 78 |
* @param array $items |
| 79 |
* @param int $depth |
| 80 |
* @param string $collapse_mode |
| 81 |
* @return string |
| 82 |
*/ |
| 83 |
public static function render_list( array $items, $depth = 1, $collapse_mode = 'open_first' ) { |
| 84 |
if ( empty( $items ) ) { |
| 85 |
return ''; |
| 86 |
} |
| 87 |
|
| 88 |
$html = '<ul class="wptoc-list wptoc-depth-' . intval( $depth ) . '">'; |
| 89 |
|
| 90 |
foreach ( $items as $index => $item ) { |
| 91 |
$has_children = ! empty( $item['children'] ); |
| 92 |
$is_active = ( 0 === $index && 1 === $depth ); |
| 93 |
$li_classes = array( |
| 94 |
'wptoc-item', |
| 95 |
'wptoc-heading-level-' . intval( $item['level'] ), |
| 96 |
); |
| 97 |
|
| 98 |
if ( $is_active ) { |
| 99 |
$li_classes[] = 'active'; |
| 100 |
} |
| 101 |
|
| 102 |
if ( $has_children ) { |
| 103 |
$li_classes[] = 'wptoc-has-children'; |
| 104 |
if ( 'open_all' === $collapse_mode || ( 'open_first' === $collapse_mode && 0 === $index && 1 === $depth ) ) { |
| 105 |
$li_classes[] = 'wptoc-open'; |
| 106 |
} else { |
| 107 |
$li_classes[] = 'wptoc-closed'; |
| 108 |
} |
| 109 |
} |
| 110 |
|
| 111 |
$html .= '<li class="' . esc_attr( implode( ' ', $li_classes ) ) . '">'; |
| 112 |
$html .= '<div class="wptoc-item-row' . ( $is_active ? ' active' : '' ) . '">'; |
| 113 |
|
| 114 |
$html .= '<a href="#' . esc_attr( $item['id'] ) . '" class="wptoc-link">' |
| 115 |
. '<span class="wptoc-text">' . esc_html( $item['title'] ) . '</span>' |
| 116 |
. '</a>'; |
| 117 |
|
| 118 |
if ( $has_children ) { |
| 119 |
$html .= '<button type="button" class="wptoc-toggle-sub" aria-label="' . esc_attr__( 'Toggle subheadings', 'wp-extra' ) . '">' |
| 120 |
. '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>' |
| 121 |
. '</button>'; |
| 122 |
} |
| 123 |
|
| 124 |
$html .= '</div>'; |
| 125 |
|
| 126 |
if ( $has_children ) { |
| 127 |
$html .= self::render_list( $item['children'], $depth + 1, $collapse_mode ); |
| 128 |
} |
| 129 |
|
| 130 |
$html .= '</li>'; |
| 131 |
} |
| 132 |
|
| 133 |
$html .= '</ul>'; |
| 134 |
return $html; |
| 135 |
} |
| 136 |
|
| 137 |
/** |
| 138 |
* Render Sticky TOC Popup & FAB floating button in wp_footer. |
| 139 |
* |
| 140 |
* @param array $headings |
| 141 |
* @return string |
| 142 |
*/ |
| 143 |
public static function render_sticky_footer( array $headings ) { |
| 144 |
if ( empty( $headings ) || ! Helper::get_option( 'sticky-toggle', true ) ) { |
| 145 |
return ''; |
| 146 |
} |
| 147 |
|
| 148 |
$sticky_pos = Helper::get_option( 'sticky-toggle-position', 'bottom-left' ); |
| 149 |
$pos_class = 'wptoc-pos-' . sanitize_html_class( $sticky_pos ); |
| 150 |
$fab_text = trim( (string) Helper::get_option( 'sticky_button_text', '' ) ); |
| 151 |
$has_fab_text = ! empty( $fab_text ); |
| 152 |
$fab_class = $has_fab_text ? 'wptoc-fab-pill' : 'wptoc-fab-icon-only'; |
| 153 |
$heading_text = Helper::get_option( 'heading_text', esc_html__( 'Table of Contents', 'wp-extra' ) ); |
| 154 |
$counter_style = Helper::get_option( 'counter', 'none' ); |
| 155 |
$collapse_mode = Helper::get_option( 'subheading_collapse_mode', 'open_first' ); |
| 156 |
|
| 157 |
ob_start(); |
| 158 |
?> |
| 159 |
<div class="wptoc-sticky-wrapper <?php echo esc_attr( $pos_class ); ?>" role="navigation" aria-label="<?php echo esc_attr__( 'Table of Contents Sticky Navigation', 'wp-extra' ); ?>"> |
| 160 |
<div id="wptoc-sticky-popup" class="wptoc-sticky-popup hide wptoc-counter-<?php echo sanitize_html_class( $counter_style ); ?> <?php echo esc_attr( $pos_class ); ?>"> |
| 161 |
<div class="wptoc-title-container"> |
| 162 |
<p class="wptoc-title"> |
| 163 |
<?php echo self::get_title_prefix_icon(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> |
| 164 |
<span class="wptoc-title-text"><?php echo esc_html( $heading_text ); ?></span> |
| 165 |
</p> |
| 166 |
<button type="button" class="wptoc-sticky-close-btn" onclick="wpTOC_hideBar(event)" aria-label="<?php echo esc_attr__( 'Close table of contents', 'wp-extra' ); ?>"> |
| 167 |
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> |
| 168 |
</button> |
| 169 |
</div> |
| 170 |
<nav class="wptoc-sidebar"> |
| 171 |
<?php echo self::render_list( $headings, 1, $collapse_mode ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> |
| 172 |
</nav> |
| 173 |
</div> |
| 174 |
<a class="wptoc-open-icon wptoc-fab-button <?php echo esc_attr( $pos_class . ' ' . $fab_class ); ?>" href="#" role="button" onclick="wpTOC_showBar(event)" aria-label="<?php echo esc_attr__( 'Open table of contents', 'wp-extra' ); ?>"> |
| 175 |
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> |
| 176 |
<line x1="8" y1="6" x2="21" y2="6"></line> |
| 177 |
<line x1="8" y1="12" x2="21" y2="12"></line> |
| 178 |
<line x1="8" y1="18" x2="21" y2="18"></line> |
| 179 |
<line x1="3" y1="6" x2="3.01" y2="6"></line> |
| 180 |
<line x1="3" y1="12" x2="3.01" y2="12"></line> |
| 181 |
<line x1="3" y1="18" x2="3.01" y2="18"></line> |
| 182 |
</svg> |
| 183 |
<?php if ( $has_fab_text ) : ?> |
| 184 |
<span class="wptoc-fab-text"><?php echo esc_html( $fab_text ); ?></span> |
| 185 |
<?php endif; ?> |
| 186 |
</a> |
| 187 |
</div> |
| 188 |
<?php |
| 189 |
return ob_get_clean(); |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* Render Header Toggle Icon SVG. |
| 194 |
* |
| 195 |
* @return string |
| 196 |
*/ |
| 197 |
public static function get_toggle_icon() { |
| 198 |
$icon_style = Helper::get_option( 'toggle_icon_style', 'chevron' ); |
| 199 |
$svg = ''; |
| 200 |
|
| 201 |
switch ( $icon_style ) { |
| 202 |
case 'chevron': |
| 203 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>'; |
| 204 |
break; |
| 205 |
case 'caret': |
| 206 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>'; |
| 207 |
break; |
| 208 |
case 'plus_minus': |
| 209 |
$svg = '<svg class="wptoc-svg-icon-minus" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>' |
| 210 |
. '<svg class="wptoc-svg-icon-plus" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>'; |
| 211 |
break; |
| 212 |
case 'grid': |
| 213 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/></svg>'; |
| 214 |
break; |
| 215 |
case 'list': |
| 216 |
default: |
| 217 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>'; |
| 218 |
break; |
| 219 |
} |
| 220 |
|
| 221 |
return '<span class="wptoc-icon-toggle-span wptoc-icon-style-' . sanitize_html_class( $icon_style ) . '">' . $svg . '</span>'; |
| 222 |
} |
| 223 |
|
| 224 |
/** |
| 225 |
* Render Title Prefix Icon SVG. |
| 226 |
* |
| 227 |
* @return string |
| 228 |
*/ |
| 229 |
public static function get_title_prefix_icon() { |
| 230 |
$style = Helper::get_option( 'title_icon_style', 'none' ); |
| 231 |
if ( empty( $style ) || 'none' === $style ) { |
| 232 |
return ''; |
| 233 |
} |
| 234 |
|
| 235 |
$svg = ''; |
| 236 |
switch ( $style ) { |
| 237 |
case 'toc_tree': |
| 238 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="currentColor" focusable="false"><path d="M5 5c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 1 1 0-2Zm3 0h11c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 1 1 0-2Zm-3 8c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h11c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm0-4c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 1 1 0-2Zm3 0h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm-3 8c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Z" fill-rule="evenodd"></path></svg>'; |
| 239 |
break; |
| 240 |
case 'list': |
| 241 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>'; |
| 242 |
break; |
| 243 |
case 'bookmark': |
| 244 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>'; |
| 245 |
break; |
| 246 |
case 'book': |
| 247 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>'; |
| 248 |
break; |
| 249 |
case 'layers': |
| 250 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>'; |
| 251 |
break; |
| 252 |
case 'sparkle': |
| 253 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>'; |
| 254 |
break; |
| 255 |
case 'settings': |
| 256 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>'; |
| 257 |
break; |
| 258 |
case 'bullet': |
| 259 |
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg>'; |
| 260 |
break; |
| 261 |
default: |
| 262 |
$svg = ''; |
| 263 |
break; |
| 264 |
} |
| 265 |
|
| 266 |
return ! empty( $svg ) ? '<span class="wptoc-title-icon">' . $svg . '</span>' : ''; |
| 267 |
} |
| 268 |
} |
| 269 |
|