commit 5c5e436bace1f1b0ca3f1c89ef6fc20e7218a780 Author: XIGE <710062962@qq.com> Date: Mon Feb 23 20:06:11 2026 +0800 1.0 diff --git a/Plugin.php b/Plugin.php new file mode 100644 index 0000000..b10dd6a --- /dev/null +++ b/Plugin.php @@ -0,0 +1,879 @@ +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 '

📖 目录功能配置

'; + + // 标题设置 + $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 '

⬆️⬇️ 快速导航按钮

'; + + // 启用返回顶部按钮 + $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 '

⭕️ 页面进度圆圈

'; + + // 启用进度圆圈 + $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 = '
'; + + // 输出CSS样式(内联在HTML中,避免FOUC) + $html .= ''; + + $html .= '
'; + $html .= '' . htmlspecialchars($options->title) . ''; + $html .= '' . ($defaultState ? '▶' : '▼') . ''; + $html .= '
'; + + $html .= '
'; + + if (empty($toc)) { + $html .= '

本文无标题结构

'; + } else { + $html .= self::renderTocItems($toc); + } + + $html .= '
'; + $html .= '
'; + + return $html; + } + + /** + * 渲染目录项 + * + * @param array $items 目录项数组 + * @param int $depth 当前深度 + * @return string HTML + */ + private static function renderTocItems($items, $depth = 0) + { + $html = ''; + + 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] . ''; + }, + $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 .= ' + '; + } + + if ($options->enableBottomButton) { + $output .= ' + '; + } + + if ($options->enableProgressCircle) { + $output .= ' + '; + } + + return $output; + } + + /** + * 输出JavaScript + */ + public static function footer() + { + $options = Helper::options()->plugin('TocPlugin'); + ?> + +