// ==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();
})();