879 lines
28 KiB
PHP
879 lines
28 KiB
PHP
|
|
<?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
|
|||
|
|
}
|
|||
|
|
}
|