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