Greasy Fork

A岛引用查看增强

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

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

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

// TODO T1:
// TODO: cache 先占个位,减小重复请求可能性
// TODO: 自动展开;配置可选,默认关闭?
// TODO: 持久化缓存;配置可选数量;按「最后看到的时间」、「是直接看到还是获取引用看到」决定权重
// TODO: 不存在的引用在本页面缓存,但不在全局缓存(考虑到日后被恢复但可能性)
// TODO T2:
// TODO: 自定义配置页 https://stackoverflow.com/a/43462416
// TODO: 配置决定:点图钉是悬浮还是关闭?正常载入是否还提供刷新按钮?多少秒算超时?
// TODO T3:
// TODO: 随时有图钉按钮解除固定?
// TODO: 🚫 来直接关闭,放在最右侧?
// TODO: 悬浮淡出
// TODO: 折叠时点击 mask,preventDefault?
// TODO?:
// TODO?: 优化引用内容的空白?

// 测试场地:
// * 长内容: https://adnmb3.com/t/36053697
// * 长内容2: https://adnmb3.com/t/36048637?page=2
// * 不存在内容: https://adnmb3.com/f/值班室
// * 超级嵌套: https://adnmb3.com/t/20311039?page=1641
// * 各种内容: https://adnmb3.com/t/26165309?page=3(页数是随便选的)

(function () {
    'use strict';

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

    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.parentElement.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(`

.h-threads-content {
    word-break: break-word;
}

.fto-ref-view {
    /* 照搬自 h.desktop.css '#h-ref-view .h-threads-item-ref' */
    background: #f0e0d6;
    border: 1px solid #000;
    clear: left;

    position: relative;

    width: max-content;
    max-width: calc(100vw - var(--offset-left) - 35px);

    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-button-list > .fto-ref-view-pin,
.fto-ref-view[data-status="floating"] >.fto-ref-view-error
>.fto-ref-view-button-list >.fto-ref-view-pin,
.fto-ref-view[data-status="floating"] >.fto-ref-view-loading
>.fto-ref-view-button-list >.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-button-list >.fto-ref-view-pin:before,
.fto-ref-view[data-status="collapsed"] >.fto-ref-view-error
>.fto-ref-view-button-list >.fto-ref-view-pin:before,
.fto-ref-view[data-status="collapsed"] >.fto-ref-view-loading
>.fto-ref-view-button-list >.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) {

            if (root === document.body) {
                root.querySelectorAll('.h-threads-item').forEach((threadItemElem) => {
                    ViewHelper.setupThreadContent(model, threadItemElem);
                })
            } else if (ViewHelper.hasFetchingRefSucceeded(root)) {
                const repliesElem = root.closest('.h-threads-item-replys');
                let threadElem;
                if (repliesElem) { // 在串的回应中
                    threadElem = repliesElem.closest('.h-threads-item');
                } else { // 在串首中
                    threadElem = root.closest('.h-threads-item-main');
                }
                const threadID = ViewHelper.getThreadID(threadElem);
                const po = ViewHelper.getPosterID(threadElem);
                ViewHelper.setupRefContent(model, root, threadID, po);
            } else {
                ViewHelper.setupErrorRefContent(model, root);
                return;
            }

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

        /**
         * 
         * @param {Model} model
         * @param {HTMLElement} threadItemElem 
         */
        static setupThreadContent(model, threadItemElem) {
            const threadID = ViewHelper.getThreadID(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);
            });
        }

        /**
         * 
         * @param {Model} model
         * @param {HTMLElement} elem 
         * @param {number} threadID
         * @param {String} po
         */
        static setupRefContent(model, elem, threadID, po) {
            const infoElem = elem.querySelector('.h-threads-info')

            // 补标 PO
            if (ViewHelper.getPosterID(infoElem) === po) {
                const poLabel = document.createElement('span');
                poLabel.textContent = "(PO主)";
                poLabel.classList.add('uk-text-primary', 'uk-text-small', 'fto-po-label');
                const elem = infoElem.querySelector('.h-threads-info-uid');
                Utils.insertAfter(elem, poLabel);
                Utils.insertAfter(elem, document.createTextNode(' '));
            }

            // 标「外串」
            if (ViewHelper.getThreadID(infoElem) !== threadID) {
                const outerThreadLabel = document.createElement('span');
                outerThreadLabel.textContent = "(外串)";
                outerThreadLabel.classList.add('uk-text-secondary', 'uk-text-small', 'fto-outer-thread-label');
                const elem = infoElem.querySelector('.h-threads-info-id');
                elem.append(' ', outerThreadLabel);
            }

            ViewHelper.setupButtons(model, infoElem);
        }

        /**
         * 
         * @param {Model} model
         * @param {Element} elem 
         */
        static setupErrorRefContent(model, elem) {
            this.setupButtons(model, elem);
        }

        /**
         * 
         * @param {Model} model
         * @param {HTMLElement} elem
         */
        static setupButtons(model, elem) {
            const viewDiv = elem.closest('.fto-ref-view');
            const linkElem = viewDiv.parentElement.querySelector(`.fto-ref-link[data-view-id="${viewDiv.dataset.viewId}"]`);

            const buttonListSpan = document.createElement('span');
            buttonListSpan.classList.add('fto-ref-view-button-list');

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

            if (!viewDiv.isLoading &&
                (!ViewHelper.hasFetchingRefSucceeded(elem) || showRefreshButtonEvenIfRefContentLoaded)) {
                // 刷新🔄按钮
                const refreshSpan = document.createElement('span');
                refreshSpan.classList.add('fto-ref-view-refresh', 'fto-ref-view-button');
                refreshSpan.textContent = "🔄";
                refreshSpan.addEventListener('click', (el) => {
                    ViewHelper.doLoadViewContent(model, viewDiv, linkElem.dataset.refId, true);
                });
                Utils.insertAfter(pinSpan, refreshSpan);
                buttonListSpan.appendChild(refreshSpan);
            }

            elem.prepend(buttonListSpan);
        }

        /**
         * 
         * @param {Model} model 
         * @param {HTMLElement} linkElem 
         * @returns 
         */
        static setupRefLink(model, linkElem) {
            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;

            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 
         * @param {boolean} force
         * 
         * TODO: 如果强制重新加载引用,是不是该同步刷新其他加载了相同引用内容的 view?reactivity?
         * TODO: 要不要考虑尝试保存展开/折叠的状态?
         */
        static doLoadViewContent(model, viewDiv, refId, forced = false) {
            const viewId = viewDiv.dataset.viewId;
            // TODO: 更好的「加载中」
            if (viewDiv.dataset.isLoading) {
                return;
            }
            viewDiv.dataset.isLoading = '1';

            const loadingSpan = document.createElement('span');
            loadingSpan.classList.add('fto-ref-view-loading');
            const loadingTextSpan = document.createElement('span');
            loadingTextSpan.dataset.waitedMilliseconds = '0';
            loadingTextSpan.textContent = "加载中… 0.00s";
            loadingSpan.appendChild(loadingTextSpan);
            viewDiv.innerHTML = '';
            viewDiv.appendChild(loadingSpan);
            const intervalId = setInterval(() => {
                if (viewDiv.dataset.isLoading) {
                    const milliseconds = Number(loadingTextSpan.dataset.waitedMilliseconds) + 20;
                    loadingTextSpan.textContent = `加载中… ${(milliseconds / 1000.0).toFixed(2)}s`;
                    loadingTextSpan.dataset.waitedMilliseconds = String(milliseconds);
                } else {
                    clearInterval(intervalId);
                }
            }, 20);
            ViewHelper.setupButtons(model, loadingSpan);

            (async (model) => {
                const [itemElement, isViewCached] = await model.loadItemElement(refId, viewId, forced);
                delete viewDiv.dataset.isLoading;
                viewDiv.innerHTML = '';
                viewDiv.appendChild(itemElement);
                if (!isViewCached) {
                    ViewHelper.setupContent(model, itemElement);
                    //if (ViewHelper.hasFetchingRefSucceeded(itemElement)) {
                    model.recordView(viewId, itemElement);
                    //}
                }
            })(model);
        }

        /**
         * 
         * @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];
        }

        /**
         * 
         * @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]);
        }

        /**
         * 
         * @param {HTMLElement} elem 
         * @returns 
         */
        static hasFetchingRefSucceeded(elem) {
            return !elem.parentElement.querySelector('.fto-ref-view-error');
        }

    }

    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
         * @param {boolean} ignoresCache
         * 
         * @returns {[HTMLElement, boolean]} 元素、是否是 view 的缓存
         */
        async loadItemElement(refId, viewId, ignoresCache = false) {
            if (!ignoresCache) {
                const viewItemCache = await this.getViewCache(viewId);
                if (viewItemCache) {
                    return [viewItemCache, true];
                }
            }
            const itemContainer = document.createElement('div');

            const itemCache = ignoresCache ? null : await this.getRefCache(refId);
            if (itemCache) {
                itemContainer.appendChild(itemCache);
            } else {
                try {
                    const resp = await Utils.fetchWithTimeout(`/Home/Forum/ref?id=${refId}`, null, refFetchingTimeout);
                    itemContainer.innerHTML = await resp.text();
                } catch (e) {
                    let message;
                    if (e instanceof Error) {
                        if (e.message === 'Timeout') {
                            message = `获取引用内容超时!`
                        } else {
                            message = `获取引用内容失败:${e.toString()}`;
                        }
                    } else {
                        message = `获取引用内容失败:${String(e)}`;
                    }
                    const errorSpan = document.createElement('span');
                    errorSpan.classList.add('fto-ref-view-error');
                    errorSpan.textContent = message;
                    return [errorSpan, false];
                }
            }

            const item = itemContainer.firstChild;

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

            this.recordRef(refId, item);
            return [item, false];

        }
    }

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

        // https://stackoverflow.com/a/49857905
        // https://stackoverflow.com/a/50101022
        static fetchWithTimeout(url, options, timeout = 10000) {
            options = { ...(options || {}) };
            const controller = new AbortController();
            if (options.signal instanceof AbortSignal) {
                options.signal.addEventListener(function (ev) {
                    signal.dispatchEvent.call(this, ev);
                })
            }
            options.signal = controller.signal;
            return Promise.race([
                fetch(url, options),
                new Promise((_, reject) => {
                    setTimeout(() => {
                        reject(new Error('Timeout'));
                        controller.abort();
                    }, timeout);
                })
            ]);
        }

    }

    entry();
})();