Files
TocPlugin/Plugin.php
2026-02-23 20:06:11 +08:00

879 lines
28 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
}
}