My知乎目录

知乎长文目录,方便阅读。

// ==UserScript==
// @name         My知乎目录
// @namespace    https://github.com/kongkongye/zh-category
// @version      1.1.0
// @description  知乎长文目录,方便阅读。
// @author       空空叶
// @match        https://www.zhihu.com/*
// @license      Apache
// @require http://cdn.staticfile.org/jquery/3.4.1/jquery.min.js
// ==/UserScript==

(function() {
    'use strict';

    /**
     * 文章列表
     */
    let articles = []
    let articlesMap = {}
    /**
     * 当前文章,可以没有
     */
    let currentArticle
    /**
     * 标题条
     */
    let titleBar
    /**
     * 最顶端那条的高度
     */
    let appHeaderHeight = 0;

    (() => {
        Array.prototype.removeIf = function(callback) {
            let i = 0;
            while (i < this.length) {
                if (callback(this[i], i)) {
                    this.splice(i, 1);
                }
                else {
                    ++i;
                }
            }
        };
    })()

    /**
     * 常量
     */
    let Consts = {
        textColorSel: '#76839b',//文字选中颜色
        textColorUnSel: '#8590a6',//文字未选中颜色
    }

    /**
     * 实用类
     */
    let Utils = {
        // Generate four random hex digits.
        S4: () => (((1+Math.random())*0x10000)|0).toString(16).substring(1),
        // Generate a pseudo-GUID by concatenating random hexadecimal.
        guid: () => (Utils.S4()+Utils.S4()+"-"+Utils.S4()+"-"+Utils.S4()+"-"+Utils.S4()+"-"+Utils.S4()+Utils.S4()+Utils.S4()),

        //获取属性
        getAttr: (ele, key) => $(ele).attr(key),
        //设置属性
        setAttr: (ele, key, value) => $(ele).attr(key, value),

        //滚动
        goToByScroll: ele => {
            $('html,body').animate({
                scrollTop: $(ele).offset().top - appHeaderHeight
            }, 'slow');
        },
        goToEndByScroll: ele => {
            $('html,body').animate({
                scrollTop: $(ele).offset().top + $(ele).height() - $(window).height()
            }, 'slow');
        },
    };

    /**
     * 不同页面适配器
     */
    let Wrappers = {
        /**
         * 得到打开的问题列表
         */
        getArticleRoots: () => {
            let result

            let path = window.location.pathname
            if (path === '/' || path === '/follow') {//首页&关注
                result = $('.TopstoryItem')
            }else if (path === '/search') {//搜索页
                result = $('.SearchResult-Card')
            }else if (path.startsWith("/question")) {
                if (path.indexOf("answer") !== -1) {//问题的指定回答
                    //指定回答&更多回答
                    result = $('.AnswerCard,.List-item')
                }else {//问题的所有回答
                    result = $('.List-item')
                }
            }else if (path === '/topic' || path === '/explore') {//话题
                result = $('.feed-item').filter((index, ele) => {
                    let RichText = $('.zm-item-rich-text', ele)
                    if (RichText && RichText.length > 0) {
                        let children = RichText.children()
                        if (children
                            && children.length > 0
                            && children[0].nodeName.toLowerCase() === 'div'
                            && $(children[0]).css('display') !== 'none') {
                            return true
                        }
                    }
                    return false
                })
                return result
            }else {
                console.debug('[此页面无法解析]', path)
            }

            if (result) {
                result = result.filter((index, ele) => {
                    let RichContent = $('.RichContent', ele)
                    return RichContent && RichContent.length > 0 && !RichContent.hasClass('is-collapsed')
                })
            }

            return result
        },
        /**
         * 尝试解析文章数据
         * @param $root 文章根元素
         * @return Article,解析不出返回null
         */
        parseArticle: $root => {
            let id = Utils.guid()
            let title //文章标题

            //解析
            let path = window.location.pathname
            if (path === '/' || path === '/follow') {//首页&关注
                let $ContentItem = $('.ContentItem', $root)
                let data = JSON.parse($ContentItem.attr('data-zop'))
                title = data.title
            }else if (path === '/search') {//搜索页
                let $ContentItemTitle = $('.ContentItem-title', $root)
                let $nameMeta = $('meta[itemprop="name"]', $ContentItemTitle)
                title = $nameMeta.attr('content')
            }else if (path.startsWith("/question")) {
                //不需要显示标题
            }else if (path === '/topic' || path === '/explore') {//话题&发现(旧)
                let $questionLink = $('.question_link', $root)
                title = $questionLink.text()
            }else {
                console.debug('[此页面无法解析]', path)
                return null
            }

            return new Article({
                id,
                title,
                $root,
            })
        },
        /**
         * 获取头部条
         */
        getHeader: () => {
            let path = window.location.pathname
            if (path === '/topic' || path === '/explore') {//话题&发现(旧)
                return $('.zu-top')
            }else {//其他(新)
                return $('.AppHeader')
            }
        },
        /**
         * 获取侧边栏
         */
        getSideBar: () => {
            let path = window.location.pathname
            if (path === '/' || path === '/follow') {//首页&关注
                return $('.GlobalSideBar')
            }else if (path === '/search') {//搜索页
                return $('.SearchSideBar')
            }else if (path.startsWith("/question")) {
                return $('.Question-sideColumn')
            }else if (path === '/topic' || path === '/explore') {//话题&发现(旧)
                return $('.zu-main-sidebar')
            }else {
                console.debug('[此页面无法解析]', path)
            }
        },
        /**
         * 获取文本容器
         */
        getTextWrapper: $root => {
            let path = window.location.pathname
            if (path === '/topic' || path === '/explore') {//话题&发现(旧)
                return $('.zm-item-rich-text', $root)
            }else {//其他(新)
                return $('.RichText', $root)
            }
        }
    }

    /**
     * 文章类
     */
    let Article = function({id, title, $root}) {
        this.id = id
        this.title = title //可以为null
        this.$root = $root

        this.titles = []
        this.titlesMap = {}

        $root.on('mousemove', () => toggleCurrentArticle(this))

        let $TextWrapper = Wrappers.getTextWrapper($root)
        $('h1,h2,h3,h4,h5,h6', $TextWrapper).each((index, $title) => {
            $title = $($title)
            let id = Utils.guid()
            let titleEle = {
                id: id,
                ele: $title,
                content: $title.text()
            }
            this.titles.push(titleEle)
            this.titlesMap[id] = titleEle
        })
        console.debug(this.titles)
    }

    /**
     * 切换当前的文章
     * @param article 可为null
     */
    let toggleCurrentArticle = article => {
        if (currentArticle === article) {
            return
        }
        console.debug('[切换当前文章]', article)
        currentArticle = article
        refreshTitleBar()
    }

    /**
     * 刷新标题条(根据当前文章)
     */
    let refreshTitleBar = () => {
        //当前没有文章
        if (!currentArticle) {
            titleBar.hide()
            return
        }

        //有标题
        let titleBarContent = $('<div style="padding: 5px 10px;display: flex;flex-direction: column;"></div>')
        //标题
        if (currentArticle.title) {
            let titleLine = $('<div style="align-self: center;text-align: center;">'+currentArticle.title+'</div>')
            titleLine.css('color', Consts.textColorSel)
            titleBarContent.append(titleLine)
        }
        //到顶部
        let toTop = $('<a href="javascript: void(0)" style="align-self: center;margin-top: 5px;color: '+Consts.textColorUnSel+';">↑到顶部↑</a>')
        toTop.on('click', () => Utils.goToByScroll(currentArticle.$root))
        toTop.hover(() => toTop.css('color', Consts.textColorSel), () => toTop.css('color', Consts.textColorUnSel))
        titleBarContent.append(toTop)
        //中间标题
        currentArticle.titles.forEach(item => {
            let titleEle = $('<a href="javascript: void(0)" ' +
                'style="border-radius: 5px;border: solid 1px lightgrey;padding: 5px 10px;margin-top: 5px;color: '+Consts.textColorUnSel+';"' +
                '>'+item.content+'</a>')
            titleEle.hover(() => {
                titleEle.css('border-style', 'inset')
                titleEle.css('color', Consts.textColorSel)
            }, () => {
                titleEle.css('border-style', 'solid')
                titleEle.css('color', Consts.textColorUnSel)
            })
            titleEle.on('click', () => Utils.goToByScroll(item.ele))
            titleBarContent.append(titleEle)
        })
        //到底部
        let toBottom = $('<a href="javascript: void(0)" style="align-self: center;margin-top: 5px;margin-bottom: 5px;color: '+Consts.textColorUnSel+';">↓到底部↓</a>')
        toBottom.on('click', () => Utils.goToEndByScroll(currentArticle.$root))
        toBottom.hover(() => toBottom.css('color', Consts.textColorSel), () => toBottom.css('color', Consts.textColorUnSel))
        titleBarContent.append(toBottom)

        //更新标题内容
        titleBar.children().remove()
        titleBar.append(titleBarContent)
        titleBar.show()
    }

    /**
     * 刷新
     */
    let refresh = () => {
        console.debug('[刷新]')
        //得到打开的问题列表
        let openedArticleRoots = Wrappers.getArticleRoots()

        //全部初始化
        let activeIds = {}
        if (openedArticleRoots) {
            openedArticleRoots.each((index, ele) => {
                let id = init($(ele))
                if (id) {
                    activeIds[id] = true
                }
            })
        }
        articles.removeIf(e => {
            if (!activeIds[e.id]) {
                //注销事件
                e.$root.off()
                return true
            }
        })
        articlesMap = {}
        articles.forEach(article => articlesMap[article.id] = article)
        //检测刷新当前文章
        if (currentArticle && !articlesMap[currentArticle.id]) {
            toggleCurrentArticle(null)
        }
        //日志
        console.debug('[打开的文章]', articles)
    }

    /**
     * 初始化
     * @param $root 打开的项
     * @return id
     */
    let init = $root => {
        let id = $root.attr('id')
        //已经有id了
        if (id) {
            if (articlesMap[id]) {//缓存有效
                console.debug('[已经有缓存了]', id)
                return id
            }else {
                //缓存失效了
            }
        }

        let article = Wrappers.parseArticle($root)
        if (article) {
            let id = article.id
            //设置id
            $root.attr('id', id)
            //添加缓存
            articles.push(article)
            articlesMap[id] = article
            //日志
            console.info('[初始化]', id, article, $root)
            return id
        }
    }

    /**
     * 获取有指定类名的父类
     * @param element 当前元素
     * @param className 类名
     * @return 未找到返回null(如果本身就有此类名,则返回本身)
     */
    let _getParent = (element, className) => {
        //元素为null
        if (!element || $(element).length === 0) {
            return null
        }

        //本身
        if ($(element).hasClass(className)) {
            return $(element)
        }

        //父
        return _getParent($(element).parent(), className)
    }

    $(document).click(({target}) => {
        console.debug('[点击]', target)
        setTimeout(refresh)
    })

    //ready时运行
    $(() => {
        //计算头部高度
        appHeaderHeight = Wrappers.getHeader().height()

        //初始化标题条
        let $SideBar = Wrappers.getSideBar()
        if ($SideBar) {
            let topBottomMargin = appHeaderHeight+30;
            titleBar = $('<div id="tagsTitleBar" style="position: fixed;top: '+topBottomMargin+'px;bottom: '+topBottomMargin+'px;background-color: rgba(255, 255, 255, 0.95);z-index: 180;opacity: 0.5;border-radius: 5px;overflow-y: auto;box-shadow: rgba(152, 152, 152, 0.5) 0px 1px 3px;word-break: break-word;"></div>')
            titleBar.css('color', Consts.textColorUnSel)
            titleBar.width($SideBar.width())
            titleBar.hover(() => {
                titleBar.css('opacity', '1')
            }, () => {
                titleBar.css('opacity', '0.5')

            })
            titleBar.hide()
            $SideBar.append(titleBar)
        }

        //初始刷新
        refresh()
        //定时刷新
        setInterval(refresh, 3000)
    })
})();