// ==UserScript== // @name A岛引用查看增强 // @namespace http://tampermonkey.net/ // @version 0.1.6 // @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: 刷新按钮 // TODO: 自定义配置页 https://stackoverflow.com/a/43462416 // TODO: 20秒超时/异常处理 // TODO: 更好的「加载中…」?;计时器? // TODO: 悬浮淡入、淡出 // TODO?: 减少一下引用里的内容的空白?;右边不需要留空白 // TODO: 保留折叠状态 // TODO: 高度过低拒绝折叠? // TODO: 折叠时图钉的图标应该也有变化(渐变?) // TODO: cache 先占个位,减小重复请求可能性 // 人的手不可能在添加 dict 项这么短的时间内触发两次事件 // 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; margin-left: -5px; margin-right: -40px; } .ref-view .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; } .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="open"] + br { display: none; } .ref-view[data-status="collapsed"] { display: block; max-height: 80px; overflow: hidden; text-overflow: ellipsis; } .ref-view[data-status="collapsed"] + br { display: none; } /* https://stackoverflow.com/a/22809380 */ .ref-view[data-status="collapsed"]:before { content: ''; position: absolute; top: 60px; height: 20px; width: 100%; background: linear-gradient(#f0e0d600, #ffeeddcc); z-index: 999; } .ref-view-button { cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } .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', 'ref-view-button'); 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 = 'floating'; } }); // const refreshSpan = document.createElement('span'); // refreshSpan.classList.add('ref-view-refresh', 'ref-view-button'); // 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 = ''; 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 = "获取引用内容失败"; return itemContainer.firstChild; } } const item = itemContainer.firstChild; 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(); })();