Greasy Fork

A岛引用查看增强

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

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

// ==UserScript==
// @name        A岛引用查看增强
// @namespace   http://tampermonkey.net/
// @version     0.1.2
// @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: 悬浮淡入、淡出

(function () {
    'use strict';

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

            .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="collapsed"] {
                display: block;
                max-height: 80px;
                overflow: hidden;
                text-overflow: ellipsis;
            }

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

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

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

        /**
         * 
         * @param {Model} model
         * @param {HTMLElement} root
         */
        static setupContent(model, root) {
            const po = ViewHelper.po;
            if (root !== document.body) {
                // 补标 PO
                root.querySelectorAll('.h-threads-info-uid').forEach((elem) => {
                    if (ViewHelper.getPosterID(elem.parentNode) === po) {
                        const poLabel = document.createElement('span');
                        poLabel.textContent = "(PO主)";
                        poLabel.classList.add('uk-text-primary', 'uk-text-small');
                        Utils.insertAfter(elem, poLabel);
                        Utils.insertAfter(elem, document.createTextNode(' '));
                    }
                });

                // 图钉📌按钮和刷新🔄按钮
                root.querySelectorAll('.h-threads-info').forEach((parentElem) => {
                    const pinSpan = document.createElement('span');
                    pinSpan.classList.add('ref-view-pin');
                    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 = 'closed';
                        }
                    });

                    // const refreshSpan = document.createElement('span');
                    // refreshSpan.classList.add('ref-view-refresh');
                    // 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 {
                        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 = '';
                // console.log(itemElement);
                itemContainer.appendChild(itemElement);
            })(model);
        }

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

        /**
         * 
         * @param {HTMLElement} elem 
         */
        static getPosterID(elem) {
            const uid = elem.querySelector('.h-threads-info-uid').textContent;
            return /^ID:(.*)$/.exec(uid)[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;
            console.log(item);
            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();
})();