Files
TocPlugin/Plugin.php

879 lines
28 KiB
PHP
Raw Permalink Normal View History

2026-02-23 20:06:11 +08:00
<?php
/**
* 文章目录、返回底部、页面进度
*
* @package TocPlugin
* @author 石头厝
* @version 2.0.1
* @link https://www.shitoucuo.com/
*/
class TocPlugin_Plugin implements Typecho_Plugin_Interface
{
/**
* 激活插件
*/
public static function activate()
{
Typecho_Plugin::factory('Widget_Archive')->beforeRender = array('TocPlugin_Plugin', 'injectToc');
Typecho_Plugin::factory('Widget_Archive')->footer = array('TocPlugin_Plugin', 'footer');
return _t('插件已激活');
}
/**
* 禁用插件
*/
public static function deactivate()
{
return _t('插件已禁用');
}
/**
* 插件配置面板
*
* @param Typecho_Widget_Helper_Form $form
*/
public static function config(Typecho_Widget_Helper_Form $form)
{
// 目录功能配置
// echo '<h3 class="typecho-option-title">📖 目录功能配置</h3>';
// 标题设置
$title = new Typecho_Widget_Helper_Form_Element_Text(
'title',
NULL,
'文章目录',
_t('目录标题'),
_t('目录的标题文字')
);
$form->addInput($title);
// 默认状态
$defaultState = new Typecho_Widget_Helper_Form_Element_Select(
'defaultState',
array(
'expanded' => _t('展开'),
'collapsed' => _t('收起')
),
'expanded',
_t('默认状态'),
_t('目录初始状态')
);
$form->addInput($defaultState);
// 是否显示数字序号
$showNumbers = new Typecho_Widget_Helper_Form_Element_Radio(
'showNumbers',
array(
'1' => _t('显示'),
'0' => _t('不显示')
),
'1',
_t('显示数字序号'),
_t('是否在目录项前显示数字序号')
);
$form->addInput($showNumbers);
// 平滑滚动
$smoothScroll = new Typecho_Widget_Helper_Form_Element_Radio(
'smoothScroll',
array(
'1' => _t('启用'),
'0' => _t('禁用')
),
'1',
_t('平滑滚动'),
_t('点击目录时是否使用平滑滚动效果')
);
$form->addInput($smoothScroll);
// 只在文章页面显示
$onlyInPost = new Typecho_Widget_Helper_Form_Element_Radio(
'onlyInPost',
array(
'1' => _t('是'),
'0' => _t('否')
),
'1',
_t('只在文章页面显示'),
_t('是否只在文章页面显示目录,不在首页和列表页显示')
);
$form->addInput($onlyInPost);
// 快速导航按钮配置
// echo '<h3 class="typecho-option-title">⬆️⬇️ 快速导航按钮</h3>';
// 启用返回顶部按钮
$enableTopButton = new Typecho_Widget_Helper_Form_Element_Radio(
'enableTopButton',
array(
'1' => _t('开启'),
'0' => _t('关闭')
),
'1',
_t('返回顶部按钮'),
_t('是否显示返回顶部按钮')
);
$form->addInput($enableTopButton);
// 启用返回底部按钮
$enableBottomButton = new Typecho_Widget_Helper_Form_Element_Radio(
'enableBottomButton',
array(
'1' => _t('开启'),
'0' => _t('关闭')
),
'1',
_t('返回底部按钮'),
_t('是否显示返回底部按钮')
);
$form->addInput($enableBottomButton);
// 进度圆圈配置
//echo '<h3 class="typecho-option-title">⭕️ 页面进度圆圈</h3>';
// 启用进度圆圈
$enableProgressCircle = new Typecho_Widget_Helper_Form_Element_Radio(
'enableProgressCircle',
array(
'1' => _t('开启'),
'0' => _t('关闭')
),
'1',
_t('进度圆圈'),
_t('是否显示页面进度圆圈(点击可返回顶部)')
);
$form->addInput($enableProgressCircle);
}
/**
* 个人用户配置面板
*
* @param Typecho_Widget_Helper_Form $form
*/
public static function personalConfig(Typecho_Widget_Helper_Form $form)
{
}
/**
* 获取目录结构
*
* @param string $content 文章内容
* @return array 目录结构
*/
private static function extractHeadings($content)
{
// 匹配H1-H3标题
$pattern = '/<(h[1-3])(?:\s[^>]*)?>(.*?)<\/\1>/i';
preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
$toc = array();
$lastH1 = null;
$lastH2 = null;
foreach ($matches as $index => $match) {
$tag = strtolower($match[1]);
$text = strip_tags($match[2]);
// 清理HTML实体
$text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
// 生成锚点ID
$anchorId = 'toc-' . self::generateAnchorId($text, $index);
// 创建目录项
$item = array(
'id' => $anchorId,
'tag' => $tag,
'text' => $text,
'level' => (int)substr($tag, 1),
'children' => array()
);
// 构建层级结构
if ($tag == 'h1') {
$lastH1 = count($toc);
$lastH2 = null;
$toc[] = $item;
} elseif ($tag == 'h2') {
$lastH2 = count($toc);
if ($lastH1 !== null) {
$toc[$lastH1]['children'][] = $item;
} else {
$toc[] = $item;
}
} elseif ($tag == 'h3') {
if ($lastH1 !== null && isset($toc[$lastH1])) {
if ($lastH2 !== null && isset($toc[$lastH1]['children'][$lastH2])) {
$toc[$lastH1]['children'][$lastH2]['children'][] = $item;
} else {
$toc[$lastH1]['children'][] = $item;
}
} else {
$toc[] = $item;
}
}
}
return $toc;
}
/**
* 生成锚点ID
*
* @param string $text 标题文本
* @param int $index 索引
* @return string 锚点ID
*/
private static function generateAnchorId($text, $index)
{
// 移除特殊字符,只保留字母、数字、中文、横线和下划线
$text = preg_replace('/[^\p{L}\p{N}_-]/u', '', $text);
// 如果清理后为空,使用索引
if (empty($text)) {
$text = 'item-' . $index;
}
// 转换为小写
$text = strtolower($text);
return $text;
}
/**
* 生成目录HTML
*
* @param array $toc 目录结构
* @return string 目录HTML
*/
private static function generateTocHtml($toc)
{
$options = Helper::options()->plugin('TocPlugin');
$defaultState = $options->defaultState == 'collapsed' ? ' collapsed' : '';
$showNumbers = $options->showNumbers ? ' show-numbers' : '';
$html = '<div class="toc-wrapper">';
// 输出CSS样式内联在HTML中避免FOUC
$html .= '<style>' . self::getTocStyles() . '</style>';
$html .= '<div class="toc-header" role="button" tabindex="0" aria-label="切换目录">';
$html .= '<span class="toc-title">' . htmlspecialchars($options->title) . '</span>';
$html .= '<span class="toc-toggle-icon">' . ($defaultState ? '▶' : '▼') . '</span>';
$html .= '</div>';
$html .= '<div class="toc-content' . $defaultState . $showNumbers . '">';
if (empty($toc)) {
$html .= '<p class="toc-empty">本文无标题结构</p>';
} else {
$html .= self::renderTocItems($toc);
}
$html .= '</div>';
$html .= '</div>';
return $html;
}
/**
* 渲染目录项
*
* @param array $items 目录项数组
* @param int $depth 当前深度
* @return string HTML
*/
private static function renderTocItems($items, $depth = 0)
{
$html = '<ul class="toc-list toc-depth-' . $depth . '">';
foreach ($items as $index => $item) {
$itemClass = 'toc-item toc-level-' . $item['level'];
if (!empty($item['children'])) {
$itemClass .= ' has-children';
}
$html .= '<li class="' . $itemClass . '">';
$html .= '<a href="#' . $item['id'] . '" class="toc-link">';
$html .= '<span class="toc-text">' . htmlspecialchars($item['text']) . '</span>';
$html .= '</a>';
if (!empty($item['children'])) {
$html .= self::renderTocItems($item['children'], $depth + 1);
}
$html .= '</li>';
}
$html .= '</ul>';
return $html;
}
/**
* 插入目录到文章
*
* @param Widget_Archive $archive
*/
public static function injectToc($archive)
{
if (!$archive->is('single')) {
$options = Helper::options()->plugin('TocPlugin');
if ($options->onlyInPost) {
return;
}
}
if ($archive->is('single') || !$archive->is('single')) {
$content = $archive->content;
// 提取标题并生成目录
$toc = self::extractHeadings($content);
if (!empty($toc)) {
$tocHtml = self::generateTocHtml($toc);
// 为标题添加ID
$content = preg_replace_callback(
'/<(h[1-3])(?:\s[^>]*)?>(.*?)<\/\1>/i',
function($matches) {
$tag = $matches[1];
$text = strip_tags($matches[2]);
$anchorId = 'toc-' . self::generateAnchorId($text, 0);
return '<' . $tag . ' id="' . $anchorId . '">' . $matches[2] . '</' . $tag . '>';
},
$content
);
// 将目录插入到文章最前面
$archive->content = $tocHtml . $content;
}
}
}
/**
* 获取CSS样式
*/
private static function getTocStyles()
{
return '
/* 目录样式 - 原有样式完全不变 */
.toc-wrapper {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 0;
margin: 18px 0 20px 0;
margin-top:0px!important;
float: right;
width: 150px;
max-width: 100%;
position: relative;
z-index: 100;
transition: all 0.3s ease;
overflow: hidden;
}
.toc-header {
background: #e9ecef;
padding: 8px 10px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
}
.toc-wrapper .toc-header {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.toc-wrapper:has(.toc-content.collapsed) .toc-header {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.toc-header:hover {
background: #dee2e6;
}
.toc-title {
font-weight: bold;
color: #495057;
font-size: 13px;
}
.toc-toggle-icon {
font-size: 12px;
line-height: 1;
transition: transform 0.3s ease;
color: #6c757d;
}
.toc-content {
padding: 8px 10px;
max-height: 400px;
overflow-y: auto;
transition: all 0.3s ease;
}
.toc-content.collapsed {
max-height: 0;
padding: 0 10px;
overflow: hidden;
border: none;
}
.toc-wrapper:has(.toc-content:not(.collapsed)) .toc-content {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.toc-content.show-numbers {
counter-reset: toc-counter;
}
.toc-list {
list-style: none !important;
padding: 0;
margin: 0;
}
.toc-list,
.toc-list li,
.toc-list ul {
list-style: none !important;
}
.toc-list .toc-item {
list-style-type: none !important;
padding-left: 0;
}
.toc-list.toc-depth-0 > .toc-item > .toc-link:before {
content: none;
}
.toc-content.show-numbers .toc-list.toc-depth-0 > .toc-item > .toc-link:before {
content: counter(toc-counter) ". ";
counter-increment: toc-counter;
font-size: 11px;
margin-right: 4px;
display: inline-block;
min-width: 18px;
}
.toc-content.show-numbers .toc-list.toc-depth-1 > .toc-item > .toc-link:before,
.toc-content.show-numbers .toc-list.toc-depth-2 > .toc-item > .toc-link:before {
content: none;
}
.toc-item {
margin: 3px 0;
position: relative;
}
.toc-link {
display: block;
padding: 4px 0;
color: #495057;
text-decoration: none;
font-size: 12px;
line-height: 1.3;
transition: color 0.2s ease;
padding-left: 0;
}
.toc-link:hover {
color: #495057;
background: none;
}
.toc-link.active {
color: #0056b3;
font-weight: bold;
}
.toc-level-1 .toc-link {
font-weight: bold;
font-size: 12px;
}
.toc-level-2 .toc-link {
font-size: 11px;
padding-left: 0;
margin-left: 0;
}
.toc-level-3 .toc-link {
font-size: 10px;
color: #6c757d;
padding-left: 0;
margin-left: 0;
}
.toc-content.show-numbers .toc-level-1 .toc-link {
padding-left: 22px;
position: relative;
}
.toc-content ul{padding-left:0.8em!important;}
.toc-item ul{padding-left:1.4em!important;}
.toc-content.show-numbers .toc-level-1 .toc-link:before {
position: absolute;
left: 0;
top: 4px;
}
.toc-content.show-numbers .toc-level-2 .toc-link,
.toc-content.show-numbers .toc-level-3 .toc-link {
padding-left: 0px;
}
.toc-empty {
color: #6c757d;
font-style: italic;
margin: 0;
padding: 6px 0;
text-align: center;
font-size: 11px;
}
@media (max-width: 768px) {
.toc-wrapper {
float: none;
width: 100%;
margin: 0 0 15px 0;
}
.toc-title {
font-size: 14px;
}
.toc-link {
font-size: 13px;
}
.toc-level-1 .toc-link {
font-size: 13px;
}
.toc-level-2 .toc-link {
font-size: 12px;
}
.toc-level-3 .toc-link {
font-size: 11px;
}
}
@media (max-width: 480px) {
.toc-wrapper {
width: 100%;
}
.toc-header {
padding: 10px 12px;
}
.toc-content {
padding: 10px 12px;
}
}
.post-content {
overflow: hidden;
position: relative;
}
/* 深色模式适配 */
.dark .toc-wrapper {
background: #1d1d1e;
border: 1px solid #333;
}
.dark .toc-header {
background: #1d1d1e;
}
.dark .toc-header:hover {
background: #2d2d2e;
}
.dark .toc-title {
color: rgb(156 163 175 / var(--tw-text-opacity));
}
.dark .toc-toggle-icon {
color: rgb(156 163 175 / var(--tw-text-opacity));
}
.dark .toc-link {
color: rgb(156 163 175 / var(--tw-text-opacity));
}
.dark .toc-link:hover {
color: rgb(156 163 175 / var(--tw-text-opacity));
background: none;
}
.dark .toc-link.active {
color: #f15a22;
}
.dark .toc-level-3 .toc-link {
color: #a0aec0;
}
.dark .toc-empty {
color: #a0aec0;
}
';
}
/**
* 输出导航按钮HTML
*/
public static function outputNavigationButtons()
{
$options = Helper::options()->plugin('TocPlugin');
$output = '';
if ($options->enableTopButton) {
$output .= '
<li class="relative nav-li">
<button onclick="tocScrollToTop()" title="返回顶部" id="top-button" class="rounded px-2 py-1 text-2xl jasmine-primary-bg-hover btop">
<iconify-icon icon="tabler:arrow-bar-to-up"></iconify-icon>
</button>
</li>';
}
if ($options->enableBottomButton) {
$output .= '
<li class="relative nav-li">
<button onclick="tocScrollToBottom()" title="返回底部" id="bottom-button" class="rounded px-2 py-1 text-2xl jasmine-primary-bg-hover btop">
<iconify-icon icon="tabler:arrow-bar-to-down"></iconify-icon>
</button>
</li>';
}
if ($options->enableProgressCircle) {
$output .= '
<li class="relative nav-li">
<button onclick="tocScrollToTop()" title="点击返回顶部" id="progress-circle-container" class="rounded px-2 py-1 text-2xl jasmine-primary-bg-hover btop">
<div id="progress-circle">
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"
stroke-dasharray="63" stroke-dashoffset="63" id="progress-circle-path"/>
<text x="12" y="12" text-anchor="middle" dy=".3em" font-size="7" font-weight="bold"
fill="currentColor" id="progress-circle-text">0</text>
</svg>
</div>
</button>
</li>';
}
return $output;
}
/**
* 输出JavaScript
*/
public static function footer()
{
$options = Helper::options()->plugin('TocPlugin');
?>
<script>
// 返回顶部函数
function tocScrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
// 返回底部函数
function tocScrollToBottom() {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: 'smooth'
});
}
<?php if ($options->enableProgressCircle): ?>
// 页面进度圆圈功能
document.addEventListener('DOMContentLoaded', function() {
const progressCircle = document.getElementById('progress-circle-path');
const progressText = document.getElementById('progress-circle-text');
if (progressCircle && progressText) {
// 更新进度
function updateProgress() {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
const scrollPercent = scrollHeight > 0 ? Math.round((scrollTop / scrollHeight) * 100) : 0;
// 更新SVG进度
const circumference = 63; // 2 * π * r (r=10)
const offset = circumference - (scrollPercent / 100) * circumference;
progressCircle.style.strokeDashoffset = offset;
// 更新文本 - 去掉%号
progressText.textContent = scrollPercent;
// 更新颜色
if (scrollPercent > 90) {
progressCircle.style.stroke = '#e74c3c';
} else if (scrollPercent > 50) {
progressCircle.style.stroke = '#f39c12';
} else {
progressCircle.style.stroke = 'currentColor';
}
// 当进度为100时数字加粗并稍微放大
if (scrollPercent === 100) {
progressText.style.fontWeight = '900';
progressText.style.fontSize = '7.5px';
} else {
progressText.style.fontWeight = 'bold';
progressText.style.fontSize = '7px';
}
}
// 初始更新
updateProgress();
// 监听滚动
let ticking = false;
window.addEventListener('scroll', function() {
if (!ticking) {
window.requestAnimationFrame(function() {
updateProgress();
ticking = false;
});
ticking = true;
}
});
// 点击进度圆圈回到顶部
const progressContainer = document.getElementById('progress-circle-container');
if (progressContainer) {
progressContainer.addEventListener('click', function(e) {
e.preventDefault();
tocScrollToTop();
});
}
}
});
<?php endif; ?>
// 为按钮添加平滑滚动
document.addEventListener('DOMContentLoaded', function() {
<?php if ($options->enableTopButton): ?>
const topButton = document.getElementById('top-button');
if (topButton) {
topButton.addEventListener('click', function(e) {
e.preventDefault();
tocScrollToTop();
});
}
<?php endif; ?>
<?php if ($options->enableBottomButton): ?>
const bottomButton = document.getElementById('bottom-button');
if (bottomButton) {
bottomButton.addEventListener('click', function(e) {
e.preventDefault();
tocScrollToBottom();
});
}
<?php endif; ?>
});
// 目录功能脚本 - 原有代码完全不变
document.addEventListener('DOMContentLoaded', function() {
var tocWrapper = document.querySelector('.toc-wrapper');
var tocHeader = document.querySelector('.toc-header');
var tocContent = document.querySelector('.toc-content');
var tocToggleIcon = document.querySelector('.toc-toggle-icon');
if (tocHeader && tocContent) {
// 点击标题栏切换目录展开/收起
tocHeader.addEventListener('click', function() {
tocContent.classList.toggle('collapsed');
var isCollapsed = tocContent.classList.contains('collapsed');
tocToggleIcon.textContent = isCollapsed ? '▶' : '▼';
tocHeader.setAttribute('aria-expanded', !isCollapsed);
});
// 设置初始状态
var isCollapsed = tocContent.classList.contains('collapsed');
tocHeader.setAttribute('aria-expanded', !isCollapsed);
}
// 目录项点击高亮
var tocLinks = document.querySelectorAll('.toc-link');
var headings = document.querySelectorAll('h1[id], h2[id], h3[id]');
// 点击目录项滚动到对应位置
tocLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
// 移除所有active类
tocLinks.forEach(function(l) {
l.classList.remove('active');
});
// 添加当前active类
this.classList.add('active');
// 如果目录是收起的,点击后展开
if (tocContent && tocContent.classList.contains('collapsed')) {
tocContent.classList.remove('collapsed');
tocToggleIcon.textContent = '▼';
tocHeader.setAttribute('aria-expanded', 'true');
}
});
});
// 滚动时高亮当前章节
function highlightCurrentSection() {
var scrollPos = window.scrollY + 100;
var currentId = '';
// 找到当前视口中的标题
headings.forEach(function(heading) {
var offsetTop = heading.offsetTop;
if (offsetTop <= scrollPos) {
currentId = heading.id;
}
});
// 高亮对应的目录项
tocLinks.forEach(function(link) {
link.classList.remove('active');
if (link.getAttribute('href') === '#' + currentId) {
link.classList.add('active');
}
});
}
if (tocLinks.length > 0) {
window.addEventListener('scroll', highlightCurrentSection);
// 初始高亮
highlightCurrentSection();
}
// 处理文字环绕
if (tocWrapper) {
var postContent = document.querySelector('.post-content');
if (postContent) {
tocWrapper.style.marginLeft = '12px';
tocWrapper.style.marginBottom = '12px';
}
}
});
</script>
<?php
}
}