// ==UserScript== // @name A岛引用查看增强 // @namespace http://tampermonkey.net/ // @version 0.1.16 // @description 让A岛网页端的引用支持嵌套查看、固定、折叠等功能 // @author FToovvr // @license MIT; https://opensource.org/licenses/MIT // @include /^https?://(adnmb\d*.com|tnmb.org)/.*$/ // @grant none // @downloadURL none // ==/UserScript== // TODO T1: // TODO: cache 先占个位,减小重复请求可能性 // TODO: 持久化缓存;配置可选数量;按「最后看到的时间」、「是直接看到还是获取引用看到」决定权重 // TODO: 如果强制重新加载引用,是不是该同步刷新其他加载了相同引用内容的 view?reactivity? // TODO: 自动固定其他相同的引用? // TODO T2: // TODO: 自定义配置页 https://stackoverflow.com/a/43462416 // TODO: 配置决定:点图钉是悬浮还是关闭?正常载入是否还提供刷新按钮?多少秒算超时?是否自动固定存在缓存的引用内容? // TODO T3: // TODO: 随时有图钉按钮解除固定? // TODO: 🚫 来直接关闭,放在最右侧? // TODO: 要不要考虑尝试在重新加载后还原先前展开/折叠的状态? // TODO: 悬浮淡出 // TODO: 折叠时点击 mask,preventDefault? // TODO T?: // 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; const autoOpenRefWhenCached = 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 = 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', () => { 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, linkElem.dataset.refId, true); }); Utils.insertAfter(pinSpan, refreshSpan); buttonListSpan.append(refreshSpan); } elem.prepend(buttonListSpan); } /** * * @param {HTMLElement} linkElem * @returns */ 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 (autoOpenRefWhenCached) { (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; } 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.append(loadingTextSpan); viewDiv.innerHTML = ''; viewDiv.append(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); this.setupButtons(loadingSpan); (async () => { const itemElement = await this.model.loadItemElement(refId, forced); delete viewDiv.dataset.isLoading; viewDiv.innerHTML = ''; viewDiv.append(itemElement); this.setupContent(itemElement); })(); } // /** // * // * @param {String} viewId // * @param {HTMLElement} itemElement // */ // updateViewContent(viewId, 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 * @returns */ static hasFetchingRefSucceeded(elem) { return !elem.parentElement.querySelector('.fto-ref-view-error'); } } class Model { constructor() { this.refCache = {}; } 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 {number} refId * @param {String} viewId * @param {boolean} ignoresCache */ async loadItemElement(refId, ignoresCache = false) { const itemContainer = document.createElement('div'); const itemCache = ignoresCache ? null : await this.getRefCache(refId); if (itemCache) { itemContainer.append(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; } } const item = itemContainer.firstChild; if (!ViewHelper.getThreadID(item)) { const errorSpan = document.createElement('span'); errorSpan.classList.add('fto-ref-view-error'); errorSpan.textContent = `引用内容不存在!`; this.recordRef(refId, errorSpan, 'page') return errorSpan; } this.recordRef(refId, item, 'global'); 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(); })();