// ==UserScript== // @name A岛引用查看增强 // @namespace http://tampermonkey.net/ // @version 0.1.10 // @description 让A岛网页端的引用支持嵌套查看、固定、折叠等功能 // @author FToovvr // @license MIT; https://opensource.org/licenses/MIT // @include /^https?://(adnmb\d*.com|tnmb.org)/.*$/ // @grant none // @downloadURL none // ==/UserScript== // TODO: 持久化缓存;配置可选数量;按「最后看到的时间」、「是直接看到还是获取引用看到」决定权重 // TODO: 刷新按钮 // TODO: 自定义配置页 https://stackoverflow.com/a/43462416 // TODO: 20秒超时/异常处理 // TODO: 更好的「加载中…」?;计时器? // TODO: 悬浮淡出 // TODO: cache 先占个位,减小重复请求可能性 // 人的手不可能在添加 dict 项这么短的时间内触发两次事件 // TODO: 随时有图钉按钮解除固定? // TODO: 自动展开;配置可选,默认关闭? // TODO: 配置决定点图钉是悬浮还是关闭 // TODO: 不存在的引用在本页面缓存,但不在全局缓存(考虑到日后被恢复但可能性) // TODO: 🚫 来直接关闭 // 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; 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; 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); } } entry(); })();