Greasy Fork

A岛引用查看增强

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

目前为 2021-03-21 提交的版本。查看 最新版本

// ==UserScript==
// @name        A岛引用查看增强
// @namespace   http://tampermonkey.net/
// @version     0.1.12
// @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: 折叠时点击 mask,preventDefault?

// 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;
    max-width: calc(100vw - var(--offset-left) - 35px);
    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;

                console.log(Utils.getCoords(linkElem).left);
                viewDiv.style.setProperty('--offset-left', `${Utils.getCoords(linkElem).left}px`);

                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);
        }

        // https://stackoverflow.com/a/26230989
        static getCoords(elem) { // crossbrowser version
            var box = elem.getBoundingClientRect();

            var body = document.body;
            var docEl = document.documentElement;

            var scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
            var scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;

            var clientTop = docEl.clientTop || body.clientTop || 0;
            var clientLeft = docEl.clientLeft || body.clientLeft || 0;

            var top = box.top + scrollTop - clientTop;
            var left = box.left + scrollLeft - clientLeft;

            return { top: Math.round(top), left: Math.round(left) };
        }

    }

    entry();
})();