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.10
// @description 让A岛网页端的引用支持嵌套查看、固定、折叠等功能
// @author      FToovvr
// @license     MIT; https://opensource.org/licenses/MIT
// @include     /^https?://(adnmb\d*.com|tnmb.org)/.*$/
// @grant       none
// ==/UserScript==

// TODO: 持久化缓存;配置可选数量;按「最后看到的时间」、「是直接看到还是获取引用看到」决定权重
// TODO: 刷新按钮
// TODO: 自定义配置页 https://stackoverflow.com/a/43462416
// TODO: 20秒超时/异常处理
// TODO: 更好的「加载中…」?;计时器?
// TODO: 悬浮淡出
// TODO: cache 先占个位,减小重复请求可能性
// 人的手不可能在添加 dict 项这么短的时间内触发两次事件
// TODO: 随时有图钉按钮解除固定?
// TODO: 自动展开;配置可选,默认关闭?
// TODO: 配置决定点图钉是悬浮还是关闭
// TODO: 不存在的引用在本页面缓存,但不在全局缓存(考虑到日后被恢复但可能性)
// TODO: 🚫 来直接关闭

// TODO?: 优化引用内容的空白?

(function () {
    'use strict';

    // TODO: 配置决定
    const collapsedHeight = 80;
    const floatingOpacity = '100%'; // '90%';
    const fadingDuration = 0; // '80ms';
    const clickPinToCloseView = false;
    const refFetchingTimeout = 20000; // 20 秒

    function entry() {

        if (window.disableAdnmbReferenceViewerEnhancementUserScript) {
            console.log("「A岛引用查看增强」用户脚本被禁用(设有变量 `window.disableAdnmbReferenceViewerEnhancementUserScript`),将终止。")
            return;
        }

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

        // 销掉原先的预览方法
        document.querySelectorAll('font[color="#789922"]').forEach((elem) => {
            if (elem.textContent.startsWith('>>')) {
                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');
            style.id = 'fto-additional-style';
            // TODO: fade out
            style.appendChild(document.createTextNode(`
.fto-ref-view {
    /* 照搬自 h.desktop.css '#h-ref-view .h-threads-item-ref' */
    background: #f0e0d6;
    border: 1px solid #000;

    position: relative;

    width: fit-content;

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

.h-threads-item-ref .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;
}

.fto-ref-view[data-status="closed"] {
    /* display: none; */
    opacity: 0; display: inline-block;
    width: 0; height: 0; overflow: hidden;
    padding: 0; border: 0; margin: 0;

    /* transition: opacity ${fadingDuration} ease-out; */
}

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

    opacity: ${floatingOpacity};

    transition: opacity ${fadingDuration} ease-in;
}

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

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

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

.fto-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;
}

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

.fto-ref-view-error {
    color: red;
}

`));
            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-item').forEach((threadItemElem) => {
                    {
                        const originalItemMainElem = threadItemElem.querySelector('.h-threads-item-main');
                        const itemDiv = document.createElement('div');
                        itemDiv.classList.add('h-threads-item');
                        const itemRefDiv = document.createElement('div');
                        itemRefDiv.classList.add('h-threads-item-reply', 'h-threads-item-ref');
                        itemDiv.appendChild(itemRefDiv);
                        const itemMainDiv = originalItemMainElem.cloneNode(true);
                        itemMainDiv.className = '';
                        itemMainDiv.classList.add('h-threads-item-reply-main');
                        itemRefDiv.appendChild(itemMainDiv);
                        const infoDiv = itemMainDiv.querySelector('.h-threads-info');
                        try { // 尝试修正几个按钮的位置。以后如果A岛自己修正了这里就会抛异常
                            const messedUpDiv = infoDiv.querySelector('.h-admin-tool').closest('.h-threads-info-report-btn');
                            if (!messedUpDiv) { // 版块页面里的各个按钮没搞砸
                                infoDiv.querySelectorAll('.h-threads-info-report-btn a').forEach((aElem) => {
                                    if (aElem.textContent !== "举报") {
                                        aElem.closest('.h-threads-info-report-btn').remove();
                                    }
                                })
                                infoDiv.querySelector('.h-threads-info-reply-btn').remove();
                            } else { // 串内容页面的各个按钮搞砸了
                                infoDiv.append(
                                    '', messedUpDiv.querySelector('.h-threads-info-id'),
                                    '', messedUpDiv.querySelector('.h-admin-tool'));
                                messedUpDiv.remove();
                            }
                        } catch (e) {
                            console.log(e);
                        }
                        model.recordRef(threadID, itemDiv);
                    }

                    threadItemElem.querySelectorAll('.h-threads-item-replys .h-threads-item-reply').forEach((originalItemElem) => {
                        const div = document.createElement('div');
                        div.classList.add('h-threads-item');
                        const itemElem = originalItemElem.cloneNode(true);
                        itemElem.classList.add('h-threads-item-ref');
                        itemElem.querySelector('.h-threads-item-reply-icon').remove();
                        for (const child of itemElem.querySelector('.h-threads-item-reply-main').children) {
                            if (!child.classList.contains('h-threads-info')
                                && !child.classList.contains('h-threads-content')) {
                                child.remove();
                            }
                        }
                        itemElem.querySelectorAll('.uk-text-primary').forEach((labelElem) => {
                            if (labelElem.textContent === "(PO主)") {
                                labelElem.remove();
                            }
                        })
                        div.appendChild(itemElem);
                        model.recordRef(ViewHelper.getPostID(itemElem), div);
                    });
                })
            } else {
                const parentElem = root.querySelector('.h-threads-info');

                // 补标 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);
                }

                // 图钉📌按钮
                const pinSpan = document.createElement('span');
                pinSpan.classList.add('fto-ref-view-pin', 'fto-ref-view-button');
                pinSpan.textContent = "📌";
                pinSpan.addEventListener('click', (el) => {
                    const viewDiv = pinSpan.closest('.fto-ref-view');
                    const linkElem = viewDiv.parentNode.querySelector('.fto-ref-link');
                    if (viewDiv.dataset.status === 'floating') {
                        linkElem.dataset.status = 'open';
                        viewDiv.dataset.status = 'open';
                    } else {
                        linkElem.dataset.status = 'closed';
                        viewDiv.dataset.status = clickPinToCloseView ? 'closed' : 'floating';
                    }
                });
                parentElem.prepend(pinSpan);

                // 刷新🔄按钮
                // const refreshSpan = document.createElement('span');
                // refreshSpan.classList.add('fto-ref-view-refresh', 'fto-ref-view-button');
                // refreshSpan.textContent = "🔄";
                // parentElem.prepend(refreshSpan);
            }

            root.querySelectorAll('font[color="#789922"]').forEach(linkElem => {
                if (!linkElem.textContent.startsWith('>>')) { return; }

                linkElem.classList.add('fto-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.generateViewID();
                linkElem.dataset.viewId = viewId;

                const viewDiv = document.createElement('div');
                viewDiv.classList.add('fto-ref-view');
                // closed: 不显示; floating: 悬浮显示; open: 完整固定显示; collapsed: 折叠固定显示
                viewDiv.dataset.status = 'closed';
                viewDiv.dataset.viewId = viewId;
                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'
                        || ['collapsed', 'floating'].includes(viewDiv.dataset.status)) {
                        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: 更好的「加载中」
            if (viewDiv.classList.contains('fto-ref-view-loading')) {
                return;
            }
            viewDiv.classList.add('fto-ref-view-loading');
            viewDiv.dataset.waitedMilliseconds = '0';
            viewDiv.textContent = "加载中… 0s";
            const intervalId = setInterval(() => {
                if (viewDiv.classList.contains('fto-ref-view-loading')) {
                    const milliseconds = Number(viewDiv.dataset.waitedMilliseconds) + 20;
                    viewDiv.textContent = `加载中… ${(milliseconds / 1000.0).toFixed(2)}s`;
                    viewDiv.dataset.waitedMilliseconds = String(milliseconds);
                } else {
                    clearInterval(intervalId);
                }
            }, 20);
            (async (model) => {
                const itemElement = await model.loadItemElement(refId, viewId);
                viewDiv.classList.remove('fto-ref-view-loading');
                viewDiv.innerHTML = '';
                viewDiv.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');
            const id = /^.*\/t\/(\d*).*$/.exec(link)[1];
            if (!id.length) {
                return null;
            }
            return Number(id);
        }

        /**
         * 
         * @param {HTMLElement} elem 
         */
        static getPostID(elem) {
            if (!elem.classList.contains('.h-threads-info-id')) {
                elem = elem.querySelector('.h-threads-info-id');
            }
            return Number(/^No.(\d+)$/.exec(elem.textContent)[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: 异常处理
                    if (e instanceof Error) {
                        message = e.toString();
                    } else {
                        message = String(e);
                    }
                    const errorSpan = document.createElement('span');
                    errorSpan.classList.add('fto-ref-view-error');
                    errorSpan.textContent = `获取引用内容失败:${message}`;
                    return errorSpan;
                }
            }

            const item = itemContainer.firstChild;

            if (!ViewHelper.getThreadID(item)) {
                const errorSpan = document.createElement('span');
                errorSpan.classList.add('fto-ref-view-error');
                errorSpan.textContent = `引用内容不存在`;
                return errorSpan;
            }

            this.recordRef(refId, item);
            ViewHelper.setupContent(this, item);
            this.recordView(viewId, item);
            return item;

        }
    }

    class Utils {

        // https://stackoverflow.com/a/59837035
        static generateViewID() {
            if (!Utils.currentGeneratedViewID) {
                Utils.currentGeneratedViewID = 0;
            }
            Utils.currentGeneratedViewID += 1;
            return Utils.currentGeneratedViewID;
        }

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

    }

    entry();
})();