Optional TOC Implementation

This commit is contained in:
2026-04-21 01:42:22 +02:00
parent 56a8b97875
commit 04eeda3580
8 changed files with 726 additions and 7 deletions
+21 -1
View File
@@ -10,7 +10,7 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
if ( ! defined( 'ZEITFRESSER_VERSION' ) ) { if ( ! defined( 'ZEITFRESSER_VERSION' ) ) {
define( 'ZEITFRESSER_VERSION', '2.2.0' ); define( 'ZEITFRESSER_VERSION', '2.3.6' );
} }
if ( ! defined( 'DAISY_BLOG_VERSION' ) ) { if ( ! defined( 'DAISY_BLOG_VERSION' ) ) {
@@ -20,6 +20,7 @@ if ( ! defined( 'DAISY_BLOG_VERSION' ) ) {
require get_template_directory() . '/inc/zeitfresser-helpers.php'; require get_template_directory() . '/inc/zeitfresser-helpers.php';
require get_template_directory() . '/inc/legacy-aliases.php'; require get_template_directory() . '/inc/legacy-aliases.php';
require get_template_directory() . '/inc/performance-tools.php'; require get_template_directory() . '/inc/performance-tools.php';
require get_template_directory() . '/inc/zeitfresser-toc.php';
/** /**
* Theme setup. * Theme setup.
@@ -108,6 +109,25 @@ function zeitfresser_widgets_init() {
} }
add_action( 'widgets_init', 'zeitfresser_widgets_init' ); add_action( 'widgets_init', 'zeitfresser_widgets_init' );
/**
* Enqueue floating TOC assets on single posts.
*
* @return void
*/
function zeitfresser_enqueue_toc_assets() {
if ( is_singular( 'post' ) && zeitfresser_has_floating_toc() ) {
wp_enqueue_script(
'zeitfresser-toc',
get_template_directory_uri() . '/js/toc.js',
array(),
ZEITFRESSER_VERSION,
true
);
}
}
add_action( 'wp_enqueue_scripts', 'zeitfresser_enqueue_toc_assets', 20 );
/** /**
* Return file version using filemtime in production-safe form. * Return file version using filemtime in production-safe form.
* *
+8
View File
@@ -15,3 +15,11 @@ function zeitfresser_get_default_sticky_menu() {
function zeitfresser_get_default_container_width() { function zeitfresser_get_default_container_width() {
return 1400; return 1400;
} }
function zeitfresser_get_default_show_article_toc() {
return true;
}
function zeitfresser_get_default_article_toc_min_headlines() {
return 3;
}
+1
View File
@@ -24,6 +24,7 @@ function zeitfresser_register_general_customization_section( $wp_customize ) {
require dirname( __FILE__ ) . '/default-general.php'; require dirname( __FILE__ ) . '/default-general.php';
require dirname( __FILE__ ) . '/container-width/container-width.php'; require dirname( __FILE__ ) . '/container-width/container-width.php';
require dirname( __FILE__ ) . '/social-links/social-links.php'; require dirname( __FILE__ ) . '/social-links/social-links.php';
require dirname( __FILE__ ) . '/toc-options.php';
require dirname( __FILE__ ) . '/../post-snippet/default-post-snippet.php'; require dirname( __FILE__ ) . '/../post-snippet/default-post-snippet.php';
require dirname( __FILE__ ) . '/../post-snippet/excerpt/excerpt.php'; require dirname( __FILE__ ) . '/../post-snippet/excerpt/excerpt.php';
+99
View File
@@ -0,0 +1,99 @@
<?php
/**
* TOC related general settings.
*/
add_action( 'customize_register', 'zeitfresser_register_article_toc_options' );
/**
* Sanitize the minimum heading threshold for the article TOC.
*
* @param mixed $value Submitted control value.
* @return int
*/
function zeitfresser_sanitize_article_toc_min_headlines( $value ) {
$value = absint( $value );
if ( $value < 1 ) {
$value = zeitfresser_get_default_article_toc_min_headlines();
}
return min( 50, $value );
}
/**
* Register article TOC options in General Options.
*
* @param WP_Customize_Manager $wp_customize Customizer manager.
* @return void
*/
function zeitfresser_register_article_toc_options( $wp_customize ) {
$article_toc_title = '<hr/><h2>' . esc_html__( 'Article TOC:', 'zeitfresser' ) . '</h2>';
$wp_customize->add_setting(
'article_toc_options_heading',
array(
'sanitize_callback' => 'wp_kses_post',
)
);
$wp_customize->add_control(
new Daisy_Blog_Custom_Text(
$wp_customize,
'article_toc_options_heading',
array(
'section' => 'daisy_blog_general_customization_section',
'label' => $article_toc_title,
'priority' => 12,
)
)
);
$wp_customize->add_setting(
'show_article_toc',
array(
'sanitize_callback' => 'zeitfresser_sanitize_checkbox',
'default' => zeitfresser_get_default_show_article_toc(),
)
);
$wp_customize->add_control(
new Graphthemes_Toggle_Control(
$wp_customize,
'show_article_toc',
array(
'settings' => 'show_article_toc',
'section' => 'daisy_blog_general_customization_section',
'label' => esc_html__( 'Show Article TOC', 'zeitfresser' ),
'description' => esc_html__( 'Enable the floating article TOC on single posts. Default: enabled.', 'zeitfresser' ),
'type' => 'toggle',
'priority' => 13,
)
)
);
$wp_customize->add_setting(
'article_toc_min_headlines',
array(
'sanitize_callback' => 'zeitfresser_sanitize_article_toc_min_headlines',
'default' => zeitfresser_get_default_article_toc_min_headlines(),
)
);
$wp_customize->add_control(
'article_toc_min_headlines',
array(
'settings' => 'article_toc_min_headlines',
'type' => 'number',
'section' => 'daisy_blog_general_customization_section',
'label' => esc_html__( 'Number of Headlines to Start TOC', 'zeitfresser' ),
'description' => esc_html__( 'The article TOC is shown only when this number of headings or more is found. Default: 3.', 'zeitfresser' ),
'priority' => 14,
'input_attrs' => array(
'min' => 1,
'max' => 50,
'step' => 1,
),
)
);
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* Floating table of contents helpers.
*
* @package zeitfresser
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Return whether article TOC output is enabled.
*
* @return bool
*/
function zeitfresser_show_article_toc() {
return (bool) get_theme_mod( 'show_article_toc', zeitfresser_get_default_show_article_toc() );
}
/**
* Return the minimum heading count required before showing the TOC.
*
* @return int
*/
function zeitfresser_get_article_toc_min_headlines() {
$threshold = absint( get_theme_mod( 'article_toc_min_headlines', zeitfresser_get_default_article_toc_min_headlines() ) );
return max( 1, $threshold );
}
/**
* Build a processed single post content payload with TOC metadata.
*
* @param int $post_id Post ID.
* @return array{content:string,items:array<int,array<string,mixed>>}
*/
function zeitfresser_build_toc_payload( $post_id ) {
static $cache = array();
$post_id = (int) $post_id;
if ( isset( $cache[ $post_id ] ) ) {
return $cache[ $post_id ];
}
$payload = array(
'content' => apply_filters( 'the_content', get_post_field( 'post_content', $post_id ) ),
'items' => array(),
);
if ( ! $post_id || ! is_singular( 'post' ) || ! zeitfresser_show_article_toc() ) {
$cache[ $post_id ] = $payload;
return $payload;
}
$content = trim( (string) $payload['content'] );
if ( '' === $content ) {
$cache[ $post_id ] = $payload;
return $payload;
}
if ( ! class_exists( 'DOMDocument' ) ) {
$cache[ $post_id ] = $payload;
return $payload;
}
libxml_use_internal_errors( true );
$dom = new DOMDocument();
$loaded = $dom->loadHTML(
'<?xml encoding="utf-8" ?><div id="zeitfresser-toc-root">' . $content . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
if ( ! $loaded ) {
libxml_clear_errors();
$cache[ $post_id ] = $payload;
return $payload;
}
$container = $dom->getElementById( 'zeitfresser-toc-root' );
if ( ! $container ) {
libxml_clear_errors();
$cache[ $post_id ] = $payload;
return $payload;
}
$index = 1;
$toc_items = array();
$xpath = new DOMXPath( $dom );
$headings = $xpath->query( './/h2 | .//h3 | .//h4', $container );
if ( $headings instanceof DOMNodeList ) {
foreach ( $headings as $heading ) {
$text = trim( wp_strip_all_tags( $heading->textContent ) );
if ( '' === $text ) {
continue;
}
$tag_name = strtolower( $heading->nodeName );
$id = $heading->getAttribute( 'id' );
if ( '' === $id ) {
$base_id = sanitize_title( $text );
$id = $base_id ? $base_id : 'section-' . $index;
while ( $dom->getElementById( $id ) ) {
$id = $base_id . '-' . $index;
$index++;
}
$heading->setAttribute( 'id', $id );
}
$toc_items[] = array(
'id' => $id,
'text' => $text,
'level' => (int) substr( $tag_name, 1 ),
);
$index++;
}
}
libxml_clear_errors();
if ( count( $toc_items ) < zeitfresser_get_article_toc_min_headlines() ) {
$cache[ $post_id ] = array(
'content' => zeitfresser_extract_toc_inner_html( $container ),
'items' => array(),
);
return $cache[ $post_id ];
}
$cache[ $post_id ] = array(
'content' => zeitfresser_extract_toc_inner_html( $container ),
'items' => $toc_items,
);
return $cache[ $post_id ];
}
/**
* Extract container inner HTML.
*
* @param DOMNode $node Source node.
* @return string
*/
function zeitfresser_extract_toc_inner_html( $node ) {
$html = '';
if ( ! $node || ! $node->hasChildNodes() ) {
return $html;
}
foreach ( $node->childNodes as $child_node ) {
$html .= $node->ownerDocument->saveHTML( $child_node );
}
return $html;
}
/**
* Return whether the current singular post has a TOC.
*
* @param int|null $post_id Optional post ID.
* @return bool
*/
function zeitfresser_has_floating_toc( $post_id = null ) {
$post_id = $post_id ? (int) $post_id : get_the_ID();
if ( ! $post_id ) {
return false;
}
$payload = zeitfresser_build_toc_payload( $post_id );
return ! empty( $payload['items'] );
}
/**
* Render floating TOC markup.
*
* @param int|null $post_id Optional post ID.
* @return void
*/
function zeitfresser_render_floating_toc( $post_id = null ) {
$post_id = $post_id ? (int) $post_id : get_the_ID();
if ( ! $post_id ) {
return;
}
$payload = zeitfresser_build_toc_payload( $post_id );
if ( empty( $payload['items'] ) ) {
return;
}
?>
<aside class="zeitfresser-floating-toc" id="zeitfresser-floating-toc" aria-label="<?php echo esc_attr__( 'Table of contents', 'zeitfresser' ); ?>">
<div class="zeitfresser-floating-toc__header">
<span class="zeitfresser-floating-toc__title"><?php echo esc_html__( 'Content', 'zeitfresser' ); ?></span>
</div>
<div class="zeitfresser-floating-toc__progress" aria-hidden="true">
<span class="zeitfresser-floating-toc__progress-bar" id="zeitfresser-floating-toc-progress"></span>
</div>
<nav class="zeitfresser-floating-toc__nav">
<ol class="zeitfresser-floating-toc__list">
<?php foreach ( $payload['items'] as $item ) : ?>
<li class="zeitfresser-floating-toc__item level-<?php echo (int) $item['level']; ?>">
<a href="#<?php echo esc_attr( $item['id'] ); ?>" data-target="<?php echo esc_attr( $item['id'] ); ?>">
<?php echo esc_html( $item['text'] ); ?>
</a>
</li>
<?php endforeach; ?>
</ol>
</nav>
</aside>
<?php
}
+178
View File
@@ -0,0 +1,178 @@
document.addEventListener('DOMContentLoaded', function () {
var toc = document.getElementById('zeitfresser-floating-toc');
var title = document.querySelector('.zeitfresser-article-heading .page-title, .zeitfresser-article-heading .entry-title, .entry-header .entry-title');
var progressBar = document.getElementById('zeitfresser-floating-toc-progress');
var nav = toc.querySelector('.zeitfresser-floating-toc__nav');
if (!toc || !title) {
return;
}
var links = Array.prototype.slice.call(toc.querySelectorAll('a[data-target]'));
var desktopQuery = window.matchMedia('(min-width: 1500px)');
var stickyTop = 100;
var headingOffset = 88;
var ticking = false;
function isDesktop() {
return desktopQuery.matches;
}
function getTarget(link) {
var id = link.getAttribute('data-target');
return id ? document.getElementById(id) : null;
}
function getHeadings() {
return links.map(function (link) {
return {
link: link,
target: getTarget(link)
};
}).filter(function (item) {
return !!item.target;
});
}
function syncPosition() {
if (!isDesktop()) {
document.documentElement.style.setProperty('--zeitfresser-toc-top', stickyTop + 'px');
document.documentElement.style.setProperty('--zeitfresser-toc-left', '24px');
document.documentElement.style.setProperty('--zeitfresser-toc-width', '220px');
return;
}
var titleRect = title.getBoundingClientRect();
var scrollTop = window.scrollY || window.pageYOffset || 0;
var contentColumn = document.querySelector('.inside-page .main-wrapper > *:first-child, .inside-page .main-wrapper .primary-content, .inside-page .main-wrapper #primary, .inside-page .main-wrapper main');
var sidebar = document.querySelector('.inside-page .main-wrapper > aside, .inside-page .main-wrapper .widget-area, .inside-page .main-wrapper #secondary, .inside-page .main-wrapper .sidebar');
var contentRect = contentColumn ? contentColumn.getBoundingClientRect() : titleRect;
var sidebarRect = sidebar ? sidebar.getBoundingClientRect() : null;
var mirroredGap = 56;
if (sidebarRect) {
mirroredGap = Math.max(Math.round(sidebarRect.left - contentRect.right), 40);
}
var maxWidth = Math.max(Math.round(contentRect.left - mirroredGap - 24), 180);
var tocWidth = Math.max(190, Math.min(250, maxWidth));
var tocLeft = Math.max(24, Math.round(contentRect.left - mirroredGap - tocWidth));
var tocTop = Math.max(stickyTop, Math.round(titleRect.top + scrollTop + 14));
document.documentElement.style.setProperty('--zeitfresser-toc-top', tocTop + 'px');
document.documentElement.style.setProperty('--zeitfresser-toc-left', tocLeft + 'px');
document.documentElement.style.setProperty('--zeitfresser-toc-width', tocWidth + 'px');
}
function setActiveLink(id) {
links.forEach(function (link) {
var active = link.getAttribute('data-target') === id;
link.classList.toggle('is-active', active);
if (active) {
link.setAttribute('aria-current', 'true');
} else {
link.removeAttribute('aria-current');
}
});
}
function updateProgress() {
if (!progressBar) {
return;
}
var article = document.querySelector('.single-post .post-content article, .single-post .post-content, article.post, article');
if (!article) {
progressBar.style.width = '0%';
return;
}
var rect = article.getBoundingClientRect();
var total = Math.max(article.offsetHeight - window.innerHeight, 1);
var progress = Math.min(Math.max((-rect.top / total) * 100, 0), 100);
progressBar.style.width = progress + '%';
}
function updateActiveHeading() {
var headings = getHeadings();
if (!headings.length) {
return;
}
var currentId = headings[0].target.id;
var triggerY = headingOffset + 24;
headings.forEach(function (item) {
if (item.target.getBoundingClientRect().top <= triggerY) {
currentId = item.target.id;
}
});
setActiveLink(currentId);
}
function onViewportChange() {
if (ticking) {
return;
}
ticking = true;
window.requestAnimationFrame(function () {
syncPosition();
updateProgress();
updateActiveHeading();
ticking = false;
});
}
links.forEach(function (link) {
link.addEventListener('click', function (event) {
var target = getTarget(link);
if (!target) {
return;
}
event.preventDefault();
var top = target.getBoundingClientRect().top + window.scrollY - headingOffset;
window.scrollTo({
top: top,
behavior: 'smooth'
});
setActiveLink(target.id);
});
});
if (nav) {
nav.addEventListener('wheel', function (event) {
var canScroll = nav.scrollHeight > nav.clientHeight;
if (!canScroll) {
return;
}
var atTop = nav.scrollTop <= 0;
var atBottom = Math.ceil(nav.scrollTop + nav.clientHeight) >= nav.scrollHeight;
if ((event.deltaY < 0 && !atTop) || (event.deltaY > 0 && !atBottom)) {
event.preventDefault();
nav.scrollTop += event.deltaY;
}
}, { passive: false });
}
syncPosition();
updateProgress();
updateActiveHeading();
window.addEventListener('scroll', onViewportChange, { passive: true });
window.addEventListener('resize', onViewportChange, { passive: true });
});
+183 -3
View File
File diff suppressed because one or more lines are too long
+11 -2
View File
@@ -17,9 +17,18 @@
$show_hide_author_block = get_theme_mod( 'post_detail_hide_show_author_block', zeitfresser_get_default_post_detail_author_block() ); $show_hide_author_block = get_theme_mod( 'post_detail_hide_show_author_block', zeitfresser_get_default_post_detail_author_block() );
$show_hide_social_share = get_theme_mod( 'post_detail_hide_show_social_share', zeitfresser_get_default_post_detail_social_share() ); $show_hide_social_share = get_theme_mod( 'post_detail_hide_show_social_share', zeitfresser_get_default_post_detail_social_share() );
$social_share = get_theme_mod( 'post_detail_social_share_options', zeitfresser_get_default_post_detail_social_share_options() ); $social_share = get_theme_mod( 'post_detail_social_share_options', zeitfresser_get_default_post_detail_social_share_options() );
$toc_payload = zeitfresser_build_toc_payload( get_the_ID() );
$has_floating_toc = ! empty( $toc_payload['items'] );
?> ?>
<h1 class="page-title"><?php the_title(); ?></h1> <div class="zeitfresser-article-heading">
<h1 class="page-title"><?php the_title(); ?></h1>
</div>
<?php if ( $has_floating_toc ) : ?>
<?php zeitfresser_render_floating_toc( get_the_ID() ); ?>
<?php endif; ?>
<div class="single-post"> <div class="single-post">
@@ -110,7 +119,7 @@
<article> <article>
<div class="inner-article-content"> <div class="inner-article-content">
<?php the_content(); ?> <?php echo $toc_payload['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div> </div>
<?php <?php