A岛引用查看增强

让A岛网页端的引用支持嵌套查看、固定、折叠等功能

当前为 2021-03-21 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        A岛引用查看增强
// @namespace   http://tampermonkey.net/
// @version     0.1.7
// @description 让A岛网页端的引用支持嵌套查看、固定、折叠等功能
// @author      FToovvr
// @license     MIT; https://opensource.org/licenses/MIT
// @include     /^https?://(adnmb\d*.com|tnmb.org)/.*$/
// @grant       none
// ==/UserScript==

// TODO: 把一看到的纳入缓存
// TODO: 持久化缓存;配置可选数量
// TODO: 刷新按钮
// TODO: 自定义配置页 https://stackoverflow.com/a/43462416
// TODO: 20秒超时/异常处理
// TODO: 更好的「加载中…」?;计时器?
// TODO: 悬浮淡入、淡出
// TODO?: 减少一下引用里的内容的空白?;右边不需要留空白
// TODO: 保留折叠状态
// TODO: cache 先占个位,减小重复请求可能性
// 人的手不可能在添加 dict 项这么短的时间内触发两次事件
// TODO: 随时有图钉按钮解除固定?
// TODO: 自动展开;配置可选,默认关闭?

(function () {
    'use strict';

    const collapsedHeight = 80;

    function entry() {
        const model = new Model();
        if (!model.isSupported) {
            console.log("浏览器功能不支持「A岛引用查看增强」脚本。");
            return;
        }

        // 销掉原先的预览方法
        document.querySelectorAll('font[color="#789922"]').forEach((elem) => {
            const newElem = elem.cloneNode(true);
            elem.parentNode.replaceChild(newElem, elem);
        });

        ViewHelper.setupStyle();

        ViewHelper.setupContent(model, document.body);
    }

    class ViewHelper {

        static setupStyle() {
            const style = document.createElement('style');
            // TODO: fade out
            style.appendChild(document.createTextNode(`
            .ref-view {
                /* 照搬自 h.desktop.css */
                background: #f0e0d6;
                border: 1px solid #000;

                position: relative;

                width: fit-content;

                margin-left: -5px;
                margin-right: -40px;
            }

            .ref-view .h-threads-content {
                margin: 5px 20px;
            }

            /* 修复 h.desktop.css 里 .h-threads-item .h-threads-content 这条选择器导致的问题 */
            .h-threads-info {
                font-size: 14px;
                line-height: 20px;
                margin: 0px;
            }

            .ref-view[data-status="closed"] {
                display: none;
            }

            .ref-view[data-status="floating"] {
                position: absolute;
                z-index: 999;

                transition: opacity 100ms ease-in;
            }

            .ref-view[data-status="open"] {
                display: block;
            }
            .ref-view[data-status="open"] + br {
                display: none;
            }

            .ref-view[data-status="collapsed"] {
                display: block;
                max-height: ${collapsedHeight}px;
                overflow: hidden;
                text-overflow: ellipsis;
            }
            .ref-view[data-status="collapsed"] + br {
                display: none;
            }

            /* https://stackoverflow.com/a/22809380 */
            .ref-view[data-status="collapsed"]:before {
                content: '';
                position: absolute;
                top: 60px;
                height: 20px;
                width: 100%;
                background: linear-gradient(#f0e0d600, #ffeeddcc);
                z-index: 999;
            }

            .ref-view-button {
                position: relative;

                font-size: smaller;

                cursor: pointer;
                -webkit-user-select: none;
                -moz-user-select: none;
                -ms-user-select: none;
                user-select: none;
            }

            .ref-view-pin {
                display: inline-block;
                transform: rotate(-45deg);
            }
            /* https://codemyui.com/grayscale-emoji-using-css/ */
            .ref-view[data-status="floating"] >.ref-view-item-container
            >.h-threads-item >.h-threads-item-ref
            >.h-threads-item-reply-main >.h-threads-info
            >.ref-view-pin {
                transform: none;
                filter: grayscale(100%);
            }
            .ref-view[data-status="collapsed"] >.ref-view-item-container
            >.h-threads-item >.h-threads-item-ref
            >.h-threads-item-reply-main >.h-threads-info
            >.ref-view-pin:before {
                content: '';
                position: absolute;
                height: 110%;
                width: 100%;
                background: linear-gradient(#f0e0d600, #f0e0d6ff);
                z-index: 999;
                transform: rotate(45deg);
            }

            `));
            document.getElementsByTagName('head')[0].appendChild(style);
        }

        /**
         * 
         * @param {Model} model
         * @param {HTMLElement} root
         */
        static setupContent(model, root) {
            const po = ViewHelper.po;
            const threadID = ViewHelper.threadID;
            if (root !== document.body) {
                root.querySelectorAll('.h-threads-info').forEach((parentElem) => {
                    // 补标 PO
                    if (ViewHelper.getPosterID(parentElem) === po) {
                        const poLabel = document.createElement('span');
                        poLabel.textContent = "(PO主)";
                        poLabel.classList.add('uk-text-primary', 'uk-text-small', 'fto-po-label');
                        const elem = parentElem.querySelector('.h-threads-info-uid');
                        Utils.insertAfter(elem, poLabel);
                        Utils.insertAfter(elem, document.createTextNode(' '));
                    }
                    // 标「外串」
                    if (ViewHelper.getThreadID(parentElem) !== threadID) {
                        const outerThreadLabel = document.createElement('span');
                        outerThreadLabel.textContent = "(外串)";
                        outerThreadLabel.classList.add('uk-text-secondary', 'uk-text-small', 'fto-outer-thread-label');
                        const elem = parentElem.querySelector('.h-threads-info-id');
                        elem.append(' ', outerThreadLabel);
                    }
                });

                // 图钉📌按钮和刷新🔄按钮
                root.querySelectorAll('.h-threads-info').forEach((parentElem) => {
                    const pinSpan = document.createElement('span');
                    pinSpan.classList.add('ref-view-pin', 'ref-view-button');
                    pinSpan.textContent = "📌";
                    pinSpan.addEventListener('click', (el) => {
                        const viewDiv = pinSpan.closest('.ref-view');
                        const linkElem = viewDiv.parentNode.querySelector('.ref-link');
                        if (viewDiv.dataset.status === 'floating') {
                            linkElem.dataset.status = 'open';
                            viewDiv.dataset.status = 'open';
                        } else {
                            linkElem.dataset.status = 'closed';
                            viewDiv.dataset.status = 'floating';
                        }
                    });

                    // const refreshSpan = document.createElement('span');
                    // refreshSpan.classList.add('ref-view-refresh', 'ref-view-button');
                    // refreshSpan.textContent = "🔄";


                    parentElem.prepend(pinSpan, /*refreshSpan*/);
                });
            }

            root.querySelectorAll('font[color="#789922"]').forEach(linkElem => {
                linkElem.classList.add('ref-link');
                // closed: 无固定显示 view; open: 有固定显示 view
                linkElem.dataset.status = 'closed';

                const r = /^>>No.(\d+)$/.exec(linkElem.textContent);
                if (!r) { return; }
                const refId = Number(r[1]);
                linkElem.dataset.refId = String(refId);

                const viewId = Utils.generateRandomID();
                linkElem.dataset.viewId = viewId;

                const viewDiv = document.createElement('div');
                viewDiv.classList.add('ref-view');
                // closed: 不显示; floating: 悬浮显示; open: 完整固定显示; collapsed: 折叠固定显示
                viewDiv.dataset.status = 'closed';
                viewDiv.dataset.viewId = viewId;
                const itemContainer = document.createElement('div');
                itemContainer.classList.add('ref-view-item-container');
                viewDiv.appendChild(itemContainer);
                Utils.insertAfter(linkElem, viewDiv);

                // 处理悬浮
                linkElem.addEventListener('mouseenter', (ev) => {
                    if (viewDiv.dataset.status !== 'closed') {
                        viewDiv.dataset.isHovering = '1';
                        return;
                    }
                    viewDiv.dataset.status = 'floating';
                    viewDiv.dataset.isHovering = '1';
                    this.doLoadViewContent(model, viewDiv, refId);
                });
                viewDiv.addEventListener('mouseenter', () => {
                    viewDiv.dataset.isHovering = '1';
                })
                for (const elem of [linkElem, viewDiv]) {
                    elem.addEventListener('mouseleave', () => {
                        if (viewDiv.dataset.status != 'floating') {
                            return;
                        }
                        delete viewDiv.dataset.isHovering;
                        (async () => {
                            setTimeout(() => {
                                if (!viewDiv.dataset.isHovering) {
                                    viewDiv.dataset.status = 'closed';
                                }
                            }, 200);
                        })();
                    });
                }

                // 处理折叠
                linkElem.addEventListener('click', () => {
                    if (linkElem.dataset.status === 'closed'
                        || viewDiv.dataset.status === 'collapsed') {
                        linkElem.dataset.status = 'open';
                        viewDiv.dataset.status = 'open';
                    } else if (viewDiv.clientHeight > collapsedHeight) {
                        viewDiv.dataset.status = 'collapsed';
                    }
                });
                viewDiv.addEventListener('click', () => {
                    if (viewDiv.dataset.status === 'collapsed') {
                        viewDiv.dataset.status = 'open';
                    }
                });
            });
        }

        /**
         * 
         * @param {Model} model
         * @param {HTMLElement} viewDiv 
         * @param {number} refId 
         */
        static doLoadViewContent(model, viewDiv, refId) {
            const viewId = viewDiv.dataset.viewId;
            // TODO: 更好的「加载中」
            viewDiv.classList.add('ref-view-loading');
            const itemContainer = viewDiv.getElementsByClassName('ref-view-item-container')[0];
            itemContainer.textContent = "加载中…";
            (async (model) => {
                const itemElement = await model.loadItemElement(refId, viewId);
                viewDiv.classList.remove('ref-view-loading');
                itemContainer.innerHTML = '';
                itemContainer.appendChild(itemElement);
            })(model);
        }

        static get po() {
            return ViewHelper.getPosterID(document.querySelector('.h-threads-item-main'));
        }

        /**
         * 
         * @param {HTMLElement} elem 
         */
        static getPosterID(elem) {
            if (!elem.classList.contains('.h-threads-info-uid')) {
                elem = elem.querySelector('.h-threads-info-uid');
            }
            const uid = elem.textContent;
            return /^ID:(.*)$/.exec(uid)[1];
        }

        static get threadID() {
            return ViewHelper.getThreadID(document.querySelector('.h-threads-item-main'));
        }

        /**
         * 
         * @param {HTMLElement} elem 
         */
        static getThreadID(elem) {
            if (!elem.classList.contains('.h-threads-info-id')) {
                elem = elem.querySelector('.h-threads-info-id');
            }
            const link = elem.getAttribute('href');
            return Number(/^.*\/t\/(\d+).*$/.exec(link)[1]);
        }

    }

    class Model {

        constructor() {
            this.viewCache = {};
            this.refCache = {};
        }

        get isSupported() {
            if (!window.indexedDB || !window.fetch) {
                return false;
            }
            return true;
        }

        // TODO: indexedDB 持久化数据
        /**
         * 
         * @param {String} viewId 
         * @returns {HTMLElement?}
         */
        async getViewCache(viewId) {
            return this.viewCache[viewId];
        }
        /**
         * 
         * @param {String} viewId 
         * @param {HTMLElement} item 
         */
        async recordView(viewId, item) {
            this.viewCache[viewId] = item;
        }

        /**
         * 
         * @param {number} refId 
         * @returns {HTMLElement?}
         */
        async getRefCache(refId) {
            const elem = this.refCache[refId];
            if (!elem) { return null; }
            return elem.cloneNode(true);
        }
        /**
         * 
         * @param {number} refId 
         * @param {HTMLElement} rawItem 
         */
        async recordRef(refId, rawItem) {
            this.refCache[refId] = rawItem.cloneNode(true);
        }

        /**
         * 
         * @param {number} refId 
         * @param {String} viewId
         */
        async loadItemElement(refId, viewId) {
            {
                const viewItemCache = await this.getViewCache(viewId);
                if (viewItemCache) {
                    return viewItemCache;
                }
            }
            const itemContainer = document.createElement('div');

            const itemCache = await this.getRefCache(refId);
            if (itemCache) {
                itemContainer.appendChild(itemCache);
            } else {
                // TODO: timeout 20s
                try {
                    const resp = await fetch(`/Home/Forum/ref?id=${refId}`);
                    itemContainer.innerHTML = await resp.text();
                } catch (e) {
                    // TODO: 异常处理
                    console.log(e);
                    itemContainer.innerHTML = "<span>获取引用内容失败</span>";
                    return itemContainer.firstChild;
                }
            }

            const item = itemContainer.firstChild;
            this.recordRef(refId, item);
            ViewHelper.setupContent(this, item);
            this.recordView(item);
            return item;

        }
    }

    class Utils {

        // https://stackoverflow.com/a/59837035
        static generateRandomID() {
            return Math.random().toString(36).replace('0.', '');
        }

        /**
         * 
         * @param {Node} node 
         * @param {Node} newNode 
         */
        static insertAfter(node, newNode) {
            node.parentNode.insertBefore(newNode, node.nextSibling);
        }

    }

    entry();
})();