功能说明
1. 提取树形结构
从文章的内容中提取h1-h6
标签生成一个树形结构列表
2. 替换原有H1-H6标签
正则替换原有h1-h6
标签增加一些前端使用到的class
和属性等
3. 前端增加特效
目录结构跟屏效果使用scrollspy.js
实现特效
已实现无需引入scrollspy.js
的方式, 具体效果可以看本文的目录树哦😝
4. 2021-04-28更新/单独设置了目录滚动事件
现在目录树滚动到底部或者顶部时, 不再会滚动整个页面
PHP的代码
正则提取H标签
下面代码在提取结构的时候
首先对H标签进行了判断是否包含h1
or h2
标签
因为目录树结构通常是要以h1
or h2
开始的
因此为了没必要的正则检测, 所以提前判断一下
/**
* 匹配H标签
*
* @param string $content 需要解析的内容
* @return string
*/
public static function parse($content, $lastResult = '')
{
$content = empty($lastResult) ? $content : $lastResult;
if(stripos($content, '<h1') !== false || stripos($content, '<h2') !== false) {
// 提取深度自行更改
$content = preg_replace_callback( '/<h([1-6])[^>]*>.*?<\/h\1>/s', 'self::parseCallback', $content);
}
return $content;
}
对匹配到的H标签进行处理以及生成树形结构
/**
* 解析
*
* @param array $matches 解析值
* @return string
*/
public static function parseCallback($matchs)
{
$parent = &self::$tree;
$content = $matchs[0];
$n = $matchs[1];
$menu = [
'num' => $n,
'title' => trim(strip_tags($content)),
'unique' => self::substrs(md5(self::$id . $n . $content), 6, 6, false),
'sub' => []
];
// 替换原有H标签, 增加前端使用的class, 以及前端使用的一些标签
$content = str_replace($content, '<h' . $matchs[1] . ' id="' . $menu['unique'] . '" name="' . $menu['unique'] . '" class="doc-heading">'. $menu['title'] . '</h' . $matchs[1] . '>', $content);
$current = [];
if($parent) {
$current = &$parent[count($parent) - 1];
}
// 根
if(!$parent || (isset($current['num']) && $n <= $current['num'])) {
$parent[] = $menu;
} else {
while(is_array($current[ 'sub' ])) {
// 父子关系
if($current['num'] == $n - 1) {
$current[ 'sub' ][] = $menu;
break;
}
// 后代关系,并存在子菜单
elseif($current['num'] < $n && $current[ 'sub' ]) {
$current = &$current['sub'][count($current['sub']) - 1];
}
// 后代关系,不存在子菜单
else {
for($i = 0; $i < $n - $current['num']; $i++) {
$current['sub'][] = [
'num' => $current['num'] + 1,
'sub' => []
];
$current = &$current['sub'][0];
}
$current['sub'][] = $menu;
break;
}
}
}
self::$id++;
return $content;
}
截取标题字符
/**
* Limit the number of characters in a string.
*
* @param string $value
* @param int $limit
* @return string
*/
public static function limit($value, $limit = 100)
{
if (mb_strwidth($value, 'UTF-8') <= $limit) {
return $value;
}
return rtrim(mb_strimwidth($value, 0, $limit, '', 'UTF-8'));
}
标题内特殊符号转为html实体, 以及特殊字符, 修正
/**
* 标题内特殊符号转为html实体, 以及特殊字符, 修正
*
* @param string $title 待处理字符串
* @return string
*/
public static function clearTitle($title = '')
{
return str_replace([
'–',
'—'
], [
'–',
'—'
], htmlspecialchars($title, ENT_QUOTES));
}
构建目录树,生成索引
/**
* 构建目录树,生成索引
*
* @param array $tree 结构数组
* @param bool $isWrap 是否需要套ul
* @return string
*/
public static function buildTreeHtml($tree, $num = 1, $isWrap = false)
{
$html = '';
foreach($tree as $key => $value) {
if(!isset($value['unique'] ) && $value['sub']) {
$html .= self::buildTreeHtml($value['sub'], $value['num']);
} else {
$title = self::clearTitle($value['title']);
$li = '<li class="d'. $num .'"><a href="#'. $value['unique'] .'" title="'. $title .'">'. $title .'</a>';
if ($value['sub']) {
$li .= self::buildTreeHtml($value['sub'], $value['num'], true);
}
$li .= '</li>';
$html .= $li;
}
}
if($isWrap) {
$html = '<ul class="sub-list">' . $html . '</ul>';
}
return $html;
}
在最后再获取目录
/**
* 获取目录
*
* @param bool $isReturnHtml 是否生成html
* @return mixed
*/
public static function get($isReturnHtml = true)
{
$tree = self::$tree ?? [];
if($isReturnHtml === true) {
$tree = self::buildTreeHtml($tree);
}
self::$id = 1;
self::$tree = [];
return $tree;
}
使用方法
提取目录树开关$data['is_toc']
通常在发布人确定使用时才开启这个功能
// 解析文章目录
if(!empty($data['is_toc'])) {
// 经过处理后的文章正文
$data['content'] = app\libs\Toc::parse($data['content']);
// 获取文章目录树HTML结构
$toc = app\libs\Toc::get();
}
前端的代码
文章目录HTML变量输出
将变量$toc
输出在合适的位置, 例如文章的右侧
输出变量的时候需要原样输出, 不能被模版引擎过滤html
实体
下面在blade
模版引擎中是这样输出的
<div id="J_toc"></div>
<div id="toc" class="cc mt15">
<div class="toc-title">
<div>目录<span class="fr f14">你已阅读了<em>0</em>%</span></div>
<div class="toc-progress-bar"></div>
</div>
<div class="toc-main">
<!--div class="title">目录</div-->
<div class="toc-body">
<div class="toc-list">
<ul>{!! $toc or '' !!}</ul>
</div>
</div>
</div>
关于blade
模版引擎
有一篇TP6
中使用blade
模版引擎的文章可以看看在thinkphp6中使用laravel的blade模版引擎
引入一个基于jquery
的组件scrollspy.js
/* ========================================================================
* Bootstrap: scrollspy.js v3.3.7
* http://getbootstrap.com/javascript/#scrollspy
* ========================================================================
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
define(function (require, exports, module) {
+function ($) {
'use strict';
// SCROLLSPY CLASS DEFINITION
// ==========================
function ScrollSpy(element, options) {
this.$body = $(document.body)
this.$scrollElement = $(element).is(document.body) ? $(window) : $(element)
this.options = $.extend({}, ScrollSpy.DEFAULTS, options)
this.selector = (this.options.target || '') + (' ' + this.options.selector || ' .nav li > a')
this.scrollWrap = this.options.scrollWrap || ''
this.offsets = []
this.targets = []
this.activeTarget = null
this.scrollHeight = 0
this.$scrollElement.on('scroll.bs.scrollspy', $.proxy(this.process, this))
this.refresh()
this.process()
}
ScrollSpy.VERSION = '3.3.7'
ScrollSpy.DEFAULTS = {
offset: 10,
activeClass: 'current',
isParentactiveClass: !0,
}
ScrollSpy.prototype.getScrollHeight = function () {
return this.$scrollElement[0].scrollHeight || Math.max(this.$body[0].scrollHeight, document.documentElement.scrollHeight)
}
ScrollSpy.prototype.refresh = function () {
var that = this
var offsetMethod = 'offset'
var offsetBase = 0
this.offsets = []
this.targets = []
this.scrollHeight = this.getScrollHeight()
if (!$.isWindow(this.$scrollElement[0])) {
offsetMethod = 'position'
offsetBase = this.$scrollElement.scrollTop()
}
this.$body
.find(this.selector)
.map(function () {
var $el = $(this)
var href = $el.data('target') || $el.attr('href')
var $href = /^#./.test(href) && $(href)
return ($href
&& $href.length
&& $href.is(':visible')
&& [[$href[offsetMethod]().top + offsetBase, href]]) || null
})
.sort(function (a, b) { return a[0] - b[0] })
.each(function () {
that.offsets.push(this[0])
that.targets.push(this[1])
})
}
ScrollSpy.prototype.process = function () {
var scrollTop = this.$scrollElement.scrollTop() + this.options.offset
var scrollHeight = this.getScrollHeight()
var maxScroll = this.options.offset + scrollHeight - this.$scrollElement.height()
var offsets = this.offsets
var targets = this.targets
var activeTarget = this.activeTarget
var i
if (this.scrollHeight != scrollHeight) {
this.refresh()
}
if (scrollTop >= maxScroll) {
return activeTarget != (i = targets[targets.length - 1]) && this.activate(i)
}
if (activeTarget && scrollTop < offsets[0]) {
this.activeTarget = null
return this.clear()
}
for (i = offsets.length; i--;) {
activeTarget != targets[i]
&& scrollTop >= offsets[i]
&& (offsets[i + 1] === undefined || scrollTop < offsets[i + 1])
&& this.activate(targets[i])
}
}
ScrollSpy.prototype.activate = function (target) {
this.activeTarget = target
this.clear()
var selector = this.selector +
'[data-target="' + target + '"],' +
this.selector + '[href="' + target + '"]'
var active = $(selector);
// 新增/父节点是否选中效果开关
if(this.options.isParentactiveClass) {
active.parents('li').addClass(this.options.activeClass);
} else {
active.parent('li').addClass(this.options.activeClass);
}
/*
var active = $(selector)
.parents('li')
.addClass(this.options.activeClass)
*/
// 新增/让导航菜单区域, 跟随显示区域滚动
let current = $(target);
if(this.scrollWrap && current.length) {
let scrollWrap = $(this.scrollWrap);
if(scrollWrap.length) {
scrollWrap.stop().animate({scrollTop: active[0].offsetTop}, 300);
}
}
if (active.parent('.dropdown-menu').length) {
active = active
.closest('li.dropdown')
.addClass(this.options.activeClass)
}
active.trigger('activate.bs.scrollspy')
}
ScrollSpy.prototype.clear = function () {
$(this.selector)
.parentsUntil(this.options.target, '.' + this.options.activeClass)
.removeClass(this.options.activeClass)
}
// SCROLLSPY PLUGIN DEFINITION
// ===========================
function Plugin(option) {
return this.each(function () {
var $this = $(this)
var data = $this.data('bs.scrollspy')
var options = typeof option == 'object' && option
if (!data) $this.data('bs.scrollspy', (data = new ScrollSpy(this, options)))
if (typeof option == 'string') data[option]()
})
}
var old = $.fn.scrollspy
$.fn.scrollspy = Plugin
$.fn.scrollspy.Constructor = ScrollSpy
// SCROLLSPY NO CONFLICT
// =====================
$.fn.scrollspy.noConflict = function () {
$.fn.scrollspy = old
return this
}
// SCROLLSPY DATA-API
// ==================
$('load.bs.scrollspy.data-api', function () {
$('[data-spy="scroll"]').each(function () {
var $spy = $(this)
Plugin.call($spy, $spy.data())
})
})
}(jQuery);
});
修改scrollspy.js
中的改动
如果未使用seajs
可以删除这部分
define(function (require, exports, module) {
// ...
})
新增/让导航菜单区域, 跟随显示区域滚动
let current = $(target);
if(this.scrollWrap && current.length) {
let scrollWrap = $(this.scrollWrap);
if(scrollWrap.length) {
scrollWrap.stop().animate({scrollTop: active[0].offsetTop}, 300);
}
}
更改/nav选中时父节点是否选中效果开关
因原来会将父节点也设为选中, 所以不能达到预期效果
所以更改了原有代码增加一个开关
if(this.options.isParentactiveClass) {
active.parents('li').addClass(this.options.activeClass);
} else {
active.parent('li').addClass(this.options.activeClass);
}
写一个目录菜单具体的特效js类
let Toc = () => {
let _this = this;
// 滚动监听
$(window).scroll( function() { _this.scroll(); });
/**
* 滚动主体滚动条到指定位置
*/
this.actScroll = function(endScroll, time) {
$('html,body').stop().animate({scrollTop: endScroll}, time);
};
/**
* 获取页面滚动百分比
*/
this.getScrollPercent = () => {
var scrollTo = $(window).scrollTop(),
docHeight = $(document).height(),
windowHeight = $(window).height(),
scrollPercent = (scrollTo / (docHeight-windowHeight)) * 100;
return scrollPercent.toFixed(0);
};
/**
* 滚动事件相关
*/
this.scroll = () => {
let docScroll = $(document).scrollTop(),
tocbar = $('#toc'),
tocLastTop = $('#J_toc').offset().top,
scrollPercent = this.getScrollPercent();
// 设置文章目录位置
if (tocLastTop <= docScroll) {
if (!tocbar.hasClass('toc-fix')) {
tocbar.addClass('toc-fix');
}
} else {
tocbar.removeClass('toc-fix');
}
};
this.scroll();
(function(win) {
// URL包含锚点
if(win.location.hash && win.location.hash.length == 7) {
let title = $(win.location.hash);
title.length && _this.actScroll(title.offset().top - 5, 500);
}
})(window);
// 滚动到指定锚点
$('#toc .toc-body li a, .content .doc-anchor').click(function (e) {
e.preventDefault();
let id = $(this).attr('href'), title = $(id);
let url = location.href;
if(url.indexOf('#') != -1) {
url = url.replace.hash, '');
}
history.pushState({}, $(this).text(), url + id);
title.length && _this.actScroll(title.offset().top - 5, 500);
});
// 目录滚动事件
$('body').scrollspy({
isParentactiveClass: !1,
target: '.toc-main',
selector: '.toc-list ul li > a',
scrollWrap: '#toc .toc-main',
});
}
if($('#toc').length) {
Toc();
}
相关css
下面的css需要把rem转px请按1rem=100px转换
:root {
--theme: #07f;;
--colorC: #ebeef5;
--colorB: #e4e7ed;
}
#toc {
margin: .06rem 0;
overflow: hidden;
}
#toc.toc-fix {
position: fixed;
top: 0.55rem;
height: 75.5%;
height: calc(100% - 1.47rem);
width: 3.1rem;
}
#toc .toc-title {
position: relative;
margin-bottom: .05rem;
color: var(--main);
font-size: .16rem;
font-weight: 500;
}
#toc .toc-title .toc-progress-bar {
width: 0%;
height: .01rem;
margin-top: .03rem;
background: var(--theme)
}
#toc .toc-title .toc-progress-bar::before {
content: "";
height: .01rem;
background-color: var(--colorB);
width: 100%;
display: block;
position: absolute;
z-index: -1;
}
#toc .toc-main {
position: relative;
font-size: .14rem;
width: 3.1rem;
max-height: 100%;
padding-bottom: .25rem;
overflow-y: auto;
}
#toc .toc-main::-webkit-scrollbar {
width: 4px
}
#toc .toc-main::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 4px;
}
#toc .toc-main:hover::-webkit-scrollbar-thumb {
background: hsla(0,0%,53.3%,.4)
}
#toc .toc-main:hover::-webkit-scrollbar-track {
background: hsla(0,0%,53.3%,.1)
}
#toc .toc-main::-webkit-scrollbar-track {
background: transparent;
}
#toc .toc-body {
position: relative;
margin-top: .08rem;
}
#toc .toc-list:before {
content: "";
position: absolute;
top: 0;
left: .07rem;
bottom: 0;
width: .02rem;
background-color: #ebedef;
opacity: .5;
}
#toc .toc-list a {
color: inherit;
}
#toc .toc-list .sub-list {
margin: 0;
padding: 0;
}
#toc .toc-list li {
margin: 0;
padding: 0;
font-size: .16rem;
font-weight: 400;
line-height: 1.3;
color: #333;
list-style: none;
}
#toc .toc-list li a {
display: block;
position: relative;
padding: .04rem 0 .04rem .12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#toc .toc-list li a:before {
content: "";
position: absolute;
top: 50%;
left: 0;
margin-top: -.02rem;
width: .04rem;
height: .04rem;
background-color: currentColor;
border-radius: 50%;
}
#toc .toc-list li a:hover, #toc .toc-list li.current > a {
background-color: var(--colorC);
}
#toc .toc-list li.d1 {
font-weight: 600;
color: #000;
}
#toc .toc-list li.d1 > a {
margin: .06rem 0;
padding: .04rem 0 .04rem .21rem;
}
#toc .toc-list li.d1 > a:before {
left: .05rem;
margin-top: -.03rem;
width: .06rem;
height: .06rem;
}
#toc .toc-list li.d1:first-child > a {
margin-top: 0;
}
#toc .toc-list li.d2 > a {
padding-left: .35rem;
}
#toc .toc-list li.d2 > a:before {
left: 24px;
}
#toc .toc-list li.d3 > a {
padding-left: .50rem;
}
#toc .toc-list li.d3 > a:before {
left: .39rem;
}
#toc .toc-list li.d4 > a {
padding-left: .65rem;
}
#toc .toc-list li.d4 > a:before {
left: .54rem;
}
#toc .toc-list li.d5 > a {
padding-left: .80rem;
}
#toc .toc-list li.d5 > a:before {
left: .69rem;
}
#toc .toc-list li.current > a {
color: var(--theme);
}
兼容手机端尺寸JS
将下面代码加入head之间, 上面css里面的rem就可以不用改了, 在小屏设备上面会更改html标签上的font-size尺寸达到显示效果和pc一样,前提是px换成rem
比例: 1rem=100px
!function(n,e){function t(){var n=o.clientWidth;n&&(n=Math.min(n,375),n=Math.max(n,300),a!==n&&(clearInterval(i),a=n,o.style.fontSize=n/375*100+"px"))}var i,a=0,o=e.documentElement;e.addEventListener&&(n.addEventListener("onorientationchange"in n?"orientationchange":"resize",function(){i=setInterval(t,10)}),t())}(window,document);
重写目录JS类,弃用scrollspy.js
(function () {
let defaults = {
debug: !1, // console提示
offsetTop: 0, // 有跟屏导航请设置导航高度
isAutoHash: !0, // 是否自动添加url的hash
isCloseFold: !1, // 默认折叠目录父层
isPushState: !0, // 点击目录是否使用push方式增加hash, 会增加浏览器回退记录
};
class Toc {
constructor(element, options) {
let _this = this;
this.options = $.extend({}, defaults, options);
this.scrollElement = $(element).is(document.body) ? $(window) : $(element)
this.wrap = (typeof options.wrap == 'string' ? $(options.wrap) : options.wrap) || $('#toc');
this.container = (typeof options.container == 'string' ? $(options.container) : options.container) || $('body');
if(!this.wrap.length) {
$.errorToast('未取得Toc容器dom');
return !1;
}
this.isFirst = !0;
this.isClick = !1;
this.timer = null;
this.scrollTop = 0;
// 默认折叠
if(this.options.isCloseFold) {
this.wrap.find('.sub-list').hide();
}
(function(win, doc) {
// URL包含锚点
if(win.location.hash && win.location.hash.length == 7) {
let title = $(win.location.hash);
title.length && _this.actScroll(title.offset().top, 500, _this.options.offsetTop + 5);
}
_this.scrollspy($(doc).scrollTop(), _this.options.offsetTop + 15);
})(window, document);
// 滚动到指定锚点
$('#toc .toc-body li a, .content .doc-anchor').off('click').on('click', function (e) {
e.preventDefault();
_this.isClick = !0;
let id = $(this).attr('href'), title = $(id);
if(!title.length) {
return !1;
}
if(_this.options.isAutoHash && id !== .hash) {
if(_this.options.isPushState) {
history.pushState(null, null, id);
_this.options.debug && console.log('Click push - ' + id);
} else {
history.replaceState(null, null, id);
_this.options.debug && console.log('Click replace - ' + id);
}
}
// 滚动到锚点
_this.actScroll(title.offset().top, 500, _this.options.offsetTop + 5);
});
// 滚动监听
this.scrollElement.on('scroll.toc', $.util.throttle(function() {
_this.scroll();
_this.isFirst = !1;
}, 100));
// toc滚动
this.wrap.find('.toc-main').off('mousewheel.toc DOMMouseScroll.toc').on('mousewheel.toc DOMMouseScroll.toc', function(e) {
e.preventDefault();
var elem = $(this),
oe = e.originalEvent,
wheelRange = oe.wheelDelta ? -oe.wheelDelta / 120 : (oe.detail || 0) / 3,
positonVal = elem[0].scrollTop + wheelRange * 80;
elem.scrollTop(positonVal);
});
this.scroll();
};
// 获取未携带hash的URL
getUrl() {
var url = location.href;
if(url.indexOf('#') != -1) {
url = url.replace.hash, '');
}
return url;
};
// 滚动主体滚动条到指定位置
actScroll(endScroll, time, offset) {
$('html').stop().animate({scrollTop: endScroll - offset}, time);
};
// 获取页面滚动百分比
getScrollPercent() {
let scrollTo = $(window).scrollTop(),
docHeight = this.container.height(),
windowHeight = $(window).height(),
scrollPercent = (scrollTo / (docHeight - windowHeight)) * 100,
percentage = scrollPercent.toFixed(0);
return percentage > 100 ? 100 : percentage;
};
updateAnchor(current) {
if(current && current.length) {
let anchor = $(current).find('a').attr('href');
if(!$(anchor).length || this.isClick === !0 || anchor === .hash) {
return !1;
}
this.options.debug && console.log('Scroll replace - ' + anchor);
history.replaceState(null, null, anchor);
return !1;
}
if(this.isFirst == !0) {
return !1;
}
this.options.debug && console.log('Start area replace - null');
history.replaceState(null, null, this.getUrl());
}
scrollspy(top, fix) {
if (this.wrap.find('li a').length === 0) {
return !1;
}
let currentId = '';
const list = this.container.find('h1,h2,h3,h4,h5,h6');
list.each(function () {
const head = $(this);
if (top > head.offset().top - fix) {
currentId = $(this).attr('id') || '';
}
});
if (currentId === '') {
this.wrap.find('li').removeClass('current');
} else {
currentId = '#' + currentId;
}
const _this = this;
const expandToc = function ($item) {
if ($item.is(':visible')) {
return
}
$item.fadeIn(400);
}
const current = this.wrap.find('li.current');
if (this.options.isAutoHash) {
clearTimeout(this.timer);
this.timer = setTimeout(function() {
_this.scrollEnd(() => {
_this.updateAnchor(current);
})
}, 100);
}
if (currentId && current.attr('href') !== currentId) {
this.wrap.find('li.current').removeClass('current');
const __this = this.wrap.find('li a[href="' + currentId + '"]');
__this.parent().addClass('current');
// 自动折叠
if(this.options.isCloseFold) {
const parents = __this.parents('.sub-list');
const parentBox = (parents.length > 0) ? parents.last() : __this.parent();
expandToc(parentBox.closest('li').find('.sub-list'));
parentBox.closest('li').siblings('li').find('.sub-list').hide();
}
}
// 导航菜单区域, 跟随显示区域滚动
if(current.length) {
let scrollWrap = this.wrap.find('.toc-main');
if(scrollWrap.length) {
scrollWrap.stop().animate({scrollTop: current[0].offsetTop - 80}, 300);
}
}
};
scrollEnd(callback) {
if(this.scrollTop == $(document).scrollTop()) {
callback();
this.isClick = !1;
}
};
scroll() {
let tocLastTop = $('#J_toc').offset().top - this.options.offsetTop,
percentage = this.getScrollPercent();
this.scrollTop = $(document).scrollTop();
this.scrollspy(this.scrollTop, this.options.offsetTop + 15);
let title = this.wrap.find('.toc-title');
title.find('em').text(percentage);
title.find('.toc-progress-bar').animate({
width: percentage + '%'
}, 100);
// 设置文章目录位置
if (tocLastTop <= this.scrollTop) {
if (!this.wrap.hasClass('toc-fix')) {
this.wrap.addClass('toc-fix');
}
} else {
this.wrap.removeClass('toc-fix');
}
};
};
window.Toc = Toc;
$.fn.toc = function(options) {
return this.each(function() {
if (!$.data(this, 'toc')) {
var instance = new Toc(this, options);
$.data(this, 'toc', instance);
}
});
}
})();
以jquery
插件方式使用
let options = {};
$('body').toc(options);
普通方式
let options = {};
new Toc(window, options);
如果不想用jquery
把代码内相关语法替换成原生js
的方法, 插件写的并不复杂, 替换应该也不麻烦😂
本文结语
具体的代码已经分享出来, 剩下的就由
u
自己补充喽
基于其他的前端框架可以尝试用类似于scrollspy
, 滚动scroll
使nav
能current
的组件
或自己根据scrollspy
用原生JS
写一个相似的组件, 功能很单一, 实现也不难的啊~我已经写了一个不依赖
scrollspy.js
的TOC类, 可以参考以下😁