Greasy Fork

A岛引用查看增强

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

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

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

// TODO 0.2:
// TODO: 自定义配置页 https://stackoverflow.com/a/43462416
// TODO: 配置决定:点图钉是悬浮还是关闭,还是额外提供「🚫」按钮?正常载入是否还提供刷新按钮?多少秒算超时?是否自动固定存在缓存的引用内容?
// TODO 0.3:
// TODO: 持久化缓存;配置可选数量;按「最后看到的时间」、「是直接看到还是获取引用看到」决定权重
// TODO: 自动固定其他相同的引用?
// TODO ?:
// 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/36081863
// * 超级嵌套: 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;
    // 如为真,存在缓存的引用内容会自动以折叠的形式固定
    const autoOpenRefViewIfRefContentAlreadyCached = false;
    // 如为真,展开一处引用将展开当前已知所有其他处指向相同内容的引用
    // TODO: 考虑也自动展开之后才遇到的指向相同内容的引用?
    // 尚未实现
    const autoOpenOtherRefViewsWithSameRefIdAfterOpenOne = false;

    const additionalStyleText = `

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

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

        Controller.setupStyle();

        const controller = new Controller(model);
        controller.setupContent(document.body);
    }

    class Controller {

        /**
         * 
         * @param {Model} model 
         */
        constructor(model) {
            this.model = model;
        }

        static setupStyle() {
            const style = document.createElement('style');
            style.id = 'fto-additional-style';
            // TODO: fade out
            style.append(additionalStyleText);
            document.head.append(style);
        }

        /**
         * 
         * @param {HTMLElement} root
         */
        setupContent(root) {

            if (root === document.body) {
                root.querySelectorAll('.h-threads-item').forEach((threadItemElem) => {
                    this.setupThreadContent(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);
                this.setupRefContent(root, threadID, po);
            } else {
                this.setupErrorRefContent(root);
                return;
            }

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

        /**
         * 
         * @param {HTMLElement} threadItemElem 
         */
        setupThreadContent(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.append(itemRefDiv);
                const itemMainDiv = originalItemMainElem.cloneNode(true);
                itemMainDiv.className = '';
                itemMainDiv.classList.add('h-threads-item-reply-main');
                itemRefDiv.append(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);
                }
                this.model.recordRef(threadID, itemDiv, 'global');
            }

            // 将各回应加入缓存
            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.append(itemElem);
                this.model.recordRef(ViewHelper.getPostID(itemElem), div, 'global');
            });
        }

        /**
         * 
         * @param {HTMLElement} elem 
         * @param {number} threadID
         * @param {String} po
         */
        setupRefContent(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);
            }

            this.setupButtons(infoElem);
        }

        /**
         * 
         * @param {Element} elem 
         */
        setupErrorRefContent(elem) {
            this.setupButtons(elem);
        }

        /**
         * 
         * @param {HTMLElement} elem
         */
        setupButtons(elem) {
            const viewDiv = elem.closest('.fto-ref-view');
            const linkElem = ViewHelper.getRefLinkByViewId(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', () => {
                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.append(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', () => {
                    this.startLoadingViewContent(viewDiv, Number(linkElem.dataset.refId), true);
                });
                Utils.insertAfter(pinSpan, refreshSpan);
                buttonListSpan.append(refreshSpan);
            }

            elem.prepend(buttonListSpan);
        }

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

            if (autoOpenRefViewIfRefContentAlreadyCached) {
                (async () => {
                    const refCache = await this.model.getRefCache(refId);
                    if (refCache) {
                        linkElem.dataset.status = 'open';
                        viewDiv.dataset.status = 'collapsed';
                        viewDiv.append(refCache);
                        this.setupContent(refCache);
                    }
                })();
            }

            // 处理悬浮
            linkElem.addEventListener('mouseenter', () => {
                if (viewDiv.dataset.status !== 'closed') {
                    viewDiv.dataset.isHovering = '1';
                    return;
                }
                viewDiv.dataset.status = 'floating';
                viewDiv.dataset.isHovering = '1';
                this.startLoadingViewContent(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 {HTMLElement} viewDiv 
         * @param {number} refId 
         * @param {boolean} force
         */
        startLoadingViewContent(viewDiv, refId, forced = false) {
            if (!forced && viewDiv.hasChildNodes()) {
                return;
            } else if (viewDiv.dataset.isLoading) { // TODO: 也可以强制从头重新加载?
                return;
            }
            this.setupLoading(viewDiv);

            this.model.subscribeForLoadingItemElement(this, refId, viewDiv.dataset.viewId, forced);
        }

        /**
         * 
         * @param {HTMLElement} viewDiv 
         */
        setupLoading(viewDiv) {
            viewDiv.dataset.isLoading = '1';

            const loadingSpan = document.createElement('span');
            loadingSpan.classList.add('fto-ref-view-loading');
            const loadingTextSpan = document.createElement('span');
            loadingTextSpan.classList.add('fto-ref-view-loading-text');
            loadingTextSpan.dataset.waitedMilliseconds = '0';
            loadingTextSpan.textContent = "加载中…";
            loadingSpan.append(loadingTextSpan);
            viewDiv.innerHTML = '';
            viewDiv.append(loadingSpan);
            this.setupButtons(loadingSpan);
        }

        /**
         * 
         * @param {String} viewId 
         */
        isLoading(viewId) {
            return !!ViewHelper.getRefViewByViewId(viewId).dataset.isLoading;
        }

        /**
         * 
         * @param {*} viewId 
         * @param {*} spentMs 
         */
        reportSpentTime(viewId, spentMs) {
            const viewDiv = ViewHelper.getRefViewByViewId(viewId);
            if (!this.isLoading(viewId)) {
                this.setupLoading(viewDiv)
            }
            viewDiv.querySelector('.fto-ref-view-loading-text')
                .textContent = `加载中… ${(spentMs / 1000.0).toFixed(2)}s`
        }

        /**
         * 
         * @param {String} viewId 
         * @param {HTMLElement} itemElement 
         */
        updateViewContent(viewId, itemElement) {
            const viewDiv = ViewHelper.getRefViewByViewId(viewId);
            delete viewDiv.dataset.isLoading;
            viewDiv.innerHTML = '';
            viewDiv.append(itemElement);
            this.setupContent(itemElement);
        }

    }

    class ViewHelper {
        /**
         * 
         * @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 
         */
        static hasFetchingRefSucceeded(elem) {
            return !elem.parentElement.querySelector('.fto-ref-view-error');
        }

        /**
         * 
         * @param {String} viewId 
         */
        static getRefViewByViewId(viewId) {
            return document.querySelector(`.fto-ref-view[data-view-id="${viewId}"]`);
        }
        /**
         * @param {String} viewId 
         */
        static getRefLinkByViewId(viewId) {
            return document.querySelector(`.fto-ref-link[data-view-id="${viewId}"]`);
        }

    }


    class Model {

        constructor() {
            // refId -> HTMLElement
            this.refCache = {};
            /**
             * @type {Set<number>}
            */
            this.refsInFetching = new Set();
            /**
             * @type {Map<number, Set<String>>}
             */
            this.refSubscriptions = new Map();
        }

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

        /**
         * 
         * @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
         * @param {'page' | 'global'} scope
         */
        async recordRef(refId, rawItem, scope = 'page') {
            this.refCache[refId] = rawItem.cloneNode(true);
        }

        /**
         * 
         * @param {Controller} controller
         * @param {number} refId 
         * @param {String} viewId
         * @param {boolean} ignoresCache
         * 
         * FIXME: 刷新没有让所有引用同步
         */
        async subscribeForLoadingItemElement(controller, refId, viewId, ignoresCache = false) {
            if (!this.refSubscriptions.has(refId)) {
                this.refSubscriptions.set(refId, new Set());
            }
            this.refSubscriptions.get(refId).add(viewId);

            const itemCache = ignoresCache ? null : await this.getRefCache(refId);
            if (itemCache) {
                const item = this.processItemElement(itemCache, refId);
                controller.updateViewContent(viewId, item);
                return;
            }

            if (this.refsInFetching.has(refId)) {
                return;
            }
            this.refsInFetching.add(refId);

            let item = await this.fetchItemElement(controller, refId, viewId);
            item = this.processItemElement(item, refId);
            this.refSubscriptions.get(refId).forEach((viewId) => {
                controller.updateViewContent(viewId, item.cloneNode(true));
            });
            this.refsInFetching.delete(refId);

        }

        /**
         * 
         * @param {Controller} controller
         * @param {number} refId 
         * @param {String} viewId
         */
        async fetchItemElement(controller, refId, viewId) {
            const itemContainer = document.createElement('div');
            const abortController = new AbortController();
            try {
                const resp = await Promise.race([
                    fetch(`/Home/Forum/ref?id=${refId}`, { signal: abortController.signal }),
                    new Promise((_, reject) => {
                        let spentMs = 0;
                        const intervalId = setInterval(() => {
                            spentMs += 20;
                            if (!controller.isLoading(viewId)) {
                                clearInterval(intervalId);
                            } else if (refFetchingTimeout && spentMs >= refFetchingTimeout) {
                                reject(new Error('Timeout'));
                                abortController.abort();
                                clearInterval(intervalId);
                            } else {
                                this.refSubscriptions.get(refId).forEach((viewId) => {
                                    controller.reportSpentTime(viewId, spentMs);
                                });
                            }
                        }, 20);
                    }),
                ]);
                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;
            }

            const item = itemContainer.firstChild;
            this.recordRef(refId, item, 'global');
            return item;
        }

        /**
         * 
         * @param {HTMLElement} item 
         * @param {number} refId
         */
        processItemElement(item, refId) {
            if (item.querySelector('.fto-ref-view-error')) {
                return item;
            }
            if (!ViewHelper.getThreadID(item)) {
                const errorSpan = document.createElement('span');
                errorSpan.classList.add('fto-ref-view-error');
                errorSpan.textContent = `引用内容不存在!`;
                this.recordRef(refId, item, 'page');
                return errorSpan;
            }
            return item
        }

    }

    class Utils {

        // https://stackoverflow.com/a/59837035
        static generateViewID() {
            if (!Utils.currentGeneratedViewID) {
                Utils.currentGeneratedViewID = 0;
            }
            Utils.currentGeneratedViewID += 1;
            return String(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) {
        //             controller.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();
})();