// ==UserScript==
// @name SE Preview on hover
// @description Shows preview of the linked questions/answers on hover
// @version 0.1.8
// @author wOxxOm
// @namespace wOxxOm.scripts
// @license MIT License
// @match *://*.stackoverflow.com/*
// @match *://*.superuser.com/*
// @match *://*.serverfault.com/*
// @match *://*.askubuntu.com/*
// @match *://*.stackapps.com/*
// @match *://*.mathoverflow.net/*
// @match *://*.stackexchange.com/*
// @require https://gf.qytechs.cn/scripts/12228/code/setMutationHandler.js
// @require https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.4.4/lz-string.min.js
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect stackoverflow.com
// @connect superuser.com
// @connect serverfault.com
// @connect askubuntu.com
// @connect stackapps.com
// @connect mathoverflow.net
// @connect stackexchange.com
// @connect cdn.sstatic.net
// @run-at document-end
// @noframes
// ==/UserScript==
/* jshint lastsemic:true, multistr:true, laxbreak:true, -W030, -W041, -W084 */
const PREVIEW_DELAY = 100;
const CACHE_DURATION = 1 * 60 * 1000; // 1 minute for the recently active posts, scales up logarithmically
const COLORS = {
question: {
backRGB: '80, 133, 195',
fore: '#265184',
},
answer: {
backRGB: '112, 195, 80',
fore: '#3f7722',
foreInv: 'white',
},
deleted: {
backRGB: '181, 103, 103',
fore: 'rgb(181, 103, 103)',
foreInv: 'white',
},
};
let xhr;
let preview = {
frame: null,
link: null,
hover: {x:0, y:0},
timer: 0,
cacheCSS: {},
stylesOverride: '',
};
const rxPreviewable = getURLregexForMatchedSites();
const thisPageUrls = getPageBaseUrls(location.href);
initStyles();
initPolyfills();
setMutationHandler('a', onLinkAdded, {processExisting: true});
setTimeout(cleanupCache, 10000);
/**************************************************************/
function onLinkAdded(links) {
for (let i = 0, link; (link = links[i++]); ) {
if (isLinkPreviewable(link)) {
link.removeAttribute('title');
link.addEventListener('mouseover', onLinkHovered);
}
}
}
function onLinkHovered(e) {
if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)
return;
preview.link = this;
preview.link.addEventListener('mousemove', onLinkMouseMove);
preview.link.addEventListener('mouseout', abortPreview);
preview.link.addEventListener('mousedown', abortPreview);
restartPreviewTimer(this);
}
function onLinkMouseMove(e) {
let stoppedMoving = Math.abs(preview.hover.x - e.clientX) < 2 &&
Math.abs(preview.hover.y - e.clientY) < 2;
if (!stoppedMoving)
return;
preview.hover.x = e.clientX;
preview.hover.y = e.clientY;
restartPreviewTimer(this);
}
function restartPreviewTimer(link) {
clearTimeout(preview.timer);
preview.timer = setTimeout(() => {
preview.timer = 0;
link.removeEventListener('mousemove', onLinkMouseMove);
if (link.matches(':hover'))
downloadPreview(link.href);
}, PREVIEW_DELAY);
}
function abortPreview(e) {
releaseLinkListeners(this);
preview.timer = setTimeout(link => {
if (link == preview.link && preview.frame && !preview.frame.matches(':hover')) {
releaseLinkListeners(link);
preview.frame.contentWindow.postMessage('SEpreviewHidden', '*');
fadeOut(preview.frame);
}
}, PREVIEW_DELAY * 3, this);
if (xhr)
xhr.abort();
}
function releaseLinkListeners(link) {
link.removeEventListener('mousemove', onLinkMouseMove);
link.removeEventListener('mouseout', abortPreview);
link.removeEventListener('mousedown', abortPreview);
clearTimeout(preview.timer);
}
function fadeOut(element, transition) {
if (transition) {
element.style.transition = typeof transition == 'number' ? `opacity ${transition}s ease-in-out` : transition;
return setTimeout(fadeOut, 0, element);
}
element.style.opacity = 0;
element.addEventListener('transitionend', function remove() {
element.removeEventListener('transitionend', remove);
if (+element.style.opacity === 0)
element.style.display = 'none';
});
}
function downloadPreview(url) {
let cached = readCache(url);
if (cached)
showPreview(cached);
else {
xhr = GM_xmlhttpRequest({
method: 'GET',
url: httpsUrl(url),
onload: r => {
let html = r.responseText;
let lastActivity = showPreview({finalUrl: r.finalUrl, html});
let inactiveDays = Math.max(0, (Date.now() - lastActivity) / (24 * 3600 * 1000));
let cacheDuration = CACHE_DURATION * Math.pow(Math.log(inactiveDays + 1) + 1, 2);
writeCache({url, finalUrl: r.finalUrl, html, cacheDuration});
},
});
}
}
function showPreview({finalUrl, html, doc}) {
doc = doc || new DOMParser().parseFromString(html, 'text/html');
if (!doc || !doc.head) {
error('no HEAD in the document received for', finalUrl);
return;
}
if (!$(doc, 'base'))
doc.head.insertAdjacentHTML('afterbegin', `<base href="${finalUrl}">`);
const answerIdMatch = finalUrl.match(/questions\/.+?\/(\d+)/);
const isQuestion = !answerIdMatch;
const postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
const post = $(doc, postId + ' .post-text');
if (!post)
return error('No parsable post found', doc);
const isDeleted = post.closest('.deleted-answer');
const title = $(doc, 'meta[property="og:title"]').content;
const status = isQuestion && !$(post, '.question-status') && $(doc, '.question-status');
const comments = $(doc, `${postId} .comments`);
const commentsHidden = +$(comments, 'tbody').dataset.remainingCommentsCount;
const commentsShowLink = commentsHidden && $(doc, `${postId} .js-show-link.comments-link`);
const lastActivity = +doc.body.getAttribute('SEpreview-lastActivity')
|| tryCatch(() => new Date($(doc, '.lastactivity-link').title).getTime())
|| Date.now();
if (lastActivity)
doc.body.setAttribute('SEpreview-lastActivity', lastActivity);
$$remove(doc, 'script');
// underline previewable links
for (let link of $$(doc, 'a:not(.SEpreviewable)')) {
if (rxPreviewable.test(link.href)) {
link.removeAttribute('title');
link.classList.add('SEpreviewable');
}
}
if (!preview.frame) {
preview.frame = document.createElement('iframe');
preview.frame.id = 'SEpreview';
document.body.appendChild(preview.frame);
}
preview.frame.setAttribute('SEpreviewType', isDeleted ? 'deleted' : isQuestion ? 'question' : 'answer');
onFrameReady(preview.frame, addStyles);
return lastActivity;
function addStyles() {
const pvDoc = preview.frame.contentDocument;
const SEpreviewStyles = $replaceOrCreate({
id: 'SEpreviewStyles',
tag: 'style', parent: pvDoc.head, className: 'SEpreviewReuse',
innerHTML: preview.stylesOverride,
});
$replaceOrCreate($$(doc, 'style, link[rel="stylesheet"]').map(e =>
e.localName == 'style' ? {
id: 'SEpreview' + e.innerHTML.replace(/\W+/g, '').length,
tag: 'style', before: SEpreviewStyles, className: 'SEpreviewReuse',
innerHTML: e.innerHTML,
} : {
id: e.href.replace(/\W+/g, ''),
tag: 'link', before: SEpreviewStyles, className: 'SEpreviewReuse',
href: e.href, rel: 'stylesheet',
})
);
onStyleSheetsReady([...$$(pvDoc, 'link[rel="stylesheet"]')], render);
}
function render() {
const finalUrlOfQuestion = getCacheableUrl(finalUrl);
const pvDoc = preview.frame.contentDocument;
pvDoc.body.setAttribute('SEpreviewType', preview.frame.getAttribute('SEpreviewType'));
$replaceOrCreate([{
id: 'SEpreviewTitle',
tag: 'a', parent: pvDoc.body, className: 'SEpreviewable',
href: isQuestion ? finalUrl : finalUrlOfQuestion,
textContent: title,
}, {
id: 'SEpreviewBody',
tag: 'div', parent: pvDoc.body, className: isDeleted ? 'deleted-answer' : '',
children: [post.parentElement, comments, commentsShowLink, status],
}]);
const codeBlocks = $$(pvDoc, 'pre code');
if (codeBlocks.length) {
codeBlocks.forEach(e => e.parentElement.classList.add('prettyprint'));
if (!preview.frame.contentWindow.StackExchange) {
preview.frame.contentWindow.StackExchange = {};
let script = $scriptIn(pvDoc.head);
script.text = 'StackExchange = {}';
script = $scriptIn(pvDoc.head);
script.src = 'https://cdn.sstatic.net/Js/prettify-full.en.js';
script.setAttribute('onload', 'prettyPrint()');
} else
$scriptIn(pvDoc.body).text = 'prettyPrint()';
}
const answers = $$(doc, '.answer');
if (answers.length > (isQuestion ? 0 : 1)) {
$replaceOrCreate({
id: 'SEpreviewAnswers',
tag: 'div', parent: pvDoc.body,
innerHTML: 'Answers: ' + answers.map((e, index) => {
const shortUrl = $(e, '.short-link').href.replace(/(\d+)\/\d+/, '$1');
const extraClasses = (e.matches(postId) ? ' SEpreviewed' : '') +
(e.matches('.deleted-answer') ? ' deleted-answer' : '');
const author = $(e, '.post-signature:last-child');
return `<a href="${shortUrl}"
SEpreviewFullUrl="${finalUrlOfQuestion + '/' + shortUrl.match(/\/(\d+)/)[1]}"
title="${$text(author, '.user-details a') +
' (rep '+$text(author, '.reputation-score') + ')\n' +
$text(author, '.user-action-time') +
$text(author, '.vote-count-post').replace(/-?\d+/, s =>
s == '0' ? '' : '\n' + s + ' vote' + (+s > 1 ? 's' : ''))}"
class="SEpreviewable${extraClasses}"
>${index + 1}</a>`;
}).join(''),
});
} else
$$remove(pvDoc, '#SEpreviewAnswers');
[...$$(pvDoc.head, 'style, link'), ...$$(pvDoc.body, 'script')].forEach(e => {
if (e.classList.contains('SEpreviewReuse'))
e.classList.remove('SEpreviewReuse');
else
e.remove();
});
pvDoc.onmouseover = retainMainScrollPos;
pvDoc.onclick = interceptLinks;
preview.frame.contentWindow.onmessage = e => {
if (e.data == 'SEpreviewHidden') {
preview.frame.contentWindow.onmessage = null;
pvDoc.onmouseover = null;
pvDoc.onclick = null;
}
};
$(pvDoc, '#SEpreviewBody').scrollTop = 0;
preview.frame.style.opacity = 1;
preview.frame.style.display = '';
}
function interceptLinks(e) {
const link = e.target.closest('a');
if (!link)
return;
if (link.matches('.js-show-link.comments-link')) {
fadeOut(link, 0.5);
downloadComments();
}
else if (e.button || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey || !link.matches('.SEpreviewable'))
return (link.target = '_blank');
else if (link.matches('#SEpreviewAnswers a, a#SEpreviewTitle'))
showPreview({
finalUrl: link.getAttribute('SEpreviewFullUrl') || link.href,
doc
});
else
downloadPreview(link.getAttribute('SEpreviewFullUrl') || link.href);
e.preventDefault();
}
function downloadComments() {
GM_xmlhttpRequest({
method: 'GET',
url: new URL(finalUrl).origin + '/posts/' + comments.id.match(/\d+/)[0] + '/comments',
onload: r => showComments(r.responseText),
});
}
function showComments(html) {
let tbody = $(preview.frame.contentDocument, `#${comments.id} tbody`);
let oldIds = new Set([...tbody.rows].map(e => e.id));
tbody.innerHTML = html;
for (let tr of tbody.rows)
if (!oldIds.has(tr.id))
tr.classList.add('new-comment-highlight');
}
}
function retainMainScrollPos(e) {
let scrollPos = {x:scrollX, y:scrollY};
document.addEventListener('scroll', preventScroll);
document.addEventListener('mouseover', releaseScrollLock);
function preventScroll(e) {
scrollTo(scrollPos.x, scrollPos.y);
log('prevented main page scroll');
}
function releaseScrollLock(e) {
document.removeEventListener('mouseout', releaseScrollLock);
document.removeEventListener('scroll', preventScroll);
}
}
function getCacheableUrl(url) {
// strips querys and hashes and anything after the main part https://site/questions/####/title/
return url
.replace(/(\/q(?:uestions)?\/\d+\/[^\/]+).*/, '$1')
.replace(/(\/a(?:nswers)?\/\d+).*/, '$1')
.replace(/[?#].*$/, '');
}
function readCache(url) {
keyUrl = getCacheableUrl(url);
const meta = (localStorage[keyUrl] || '').split('\t');
const expired = +meta[0] < Date.now();
const finalUrl = meta[1] || url;
const keyFinalUrl = meta[1] ? getCacheableUrl(finalUrl) : keyUrl;
return !expired && {
finalUrl,
html: LZString.decompressFromUTF16(localStorage[keyFinalUrl + '\thtml']),
};
}
function writeCache({url, finalUrl, html, cacheDuration = CACHE_DURATION, cleanupRetry}) {
// keyUrl=expires
// redirected keyUrl=expires+finalUrl, and an additional entry keyFinalUrl=expires is created
// keyFinalUrl\thtml=html
cacheDuration = Math.max(CACHE_DURATION, Math.min(0xDEADBEEF, Math.floor(cacheDuration)));
finalUrl = finalUrl.replace(/[?#].*/, '');
const keyUrl = getCacheableUrl(url);
const keyFinalUrl = getCacheableUrl(finalUrl);
const expires = Date.now() + cacheDuration;
if (!tryCatch(() => localStorage[keyFinalUrl + '\thtml'] = LZString.compressToUTF16(html))) {
if (cleanupRetry)
return error('localStorage write error');
cleanupCache({aggressive: true});
setIimeout(writeCache, 0, {url, finalUrl, html, cacheDuration, cleanupRetry: true});
}
localStorage[keyFinalUrl] = expires;
if (keyUrl != keyFinalUrl)
localStorage[keyUrl] = expires + '\t' + finalUrl;
setTimeout(() => {
[keyUrl, keyFinalUrl, keyFinalUrl + '\thtml'].forEach(e => localStorage.removeItem(e));
}, cacheDuration + 1000);
}
function cleanupCache({aggressive = false} = {}) {
Object.keys(localStorage).forEach(k => {
if (k.match(/^https?:\/\/[^\t]+$/)) {
let meta = (localStorage[k] || '').split('\t');
if (+meta[0] > Date.now() && !aggressive)
return;
if (meta[1])
localStorage.removeItem(meta[1]);
localStorage.removeItem(`${meta[1] || k}\thtml`);
localStorage.removeItem(k);
}
});
}
function onFrameReady(frame, callback, ...args) {
if (frame.contentDocument.readyState == 'complete')
return callback.call(frame, ...args);
else
frame.addEventListener('load', function onLoad() {
frame.removeEventListener('load', onLoad);
callback.call(frame, ...args);
});
}
function onStyleSheetsReady(linkElements, callback, ...args) {
if (linkElements.every(e => e.sheet && e.sheet.href == e.href))
return callback(...args);
else
setTimeout(onStyleSheetsReady, 0, linkElements, callback, ...args);
}
function getURLregexForMatchedSites() {
return new RegExp('https?://(\\w*\\.)*(' + GM_info.script.matches.map(m =>
m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.')
).join('|') + ')/(questions|q|a)/\\d+');
}
function isLinkPreviewable(link) {
const inPreview = link.ownerDocument != document;
if (!rxPreviewable.test(link.href) || link.matches('.short-link'))
return false;
const pageUrls = inPreview ? getPageBaseUrls(preview.link.href) : thisPageUrls;
const url = httpsUrl(link.href);
return !url.startsWith(pageUrls.base) &&
!url.startsWith(pageUrls.short);
}
function getPageBaseUrls(url) {
const base = httpsUrl((url.match(rxPreviewable) || [])[0]);
return base ? {
base,
short: base.replace('/questions/', '/q/'),
} : {};
}
function httpsUrl(url) {
return (url || '').replace(/^http:/, 'https:');
}
function $(node__optional, selector) {
return (node__optional || document).querySelector(selector || node__optional);
}
function $$(node__optional, selector) {
return (node__optional || document).querySelectorAll(selector || node__optional);
}
function $text(node__optional, selector) {
const e = $(node__optional, selector);
return e ? e.textContent.trim() : '';
}
function $$remove(node__optional, selector) {
(node__optional || document).querySelectorAll(selector || node__optional)
.forEach(e => e.remove());
}
function $appendTo(newParent, elements) {
const doc = newParent.ownerDocument;
for (let e of elements)
if (e)
newParent.appendChild(e.ownerDocument == doc ? e : doc.importNode(e, true));
}
function $replaceOrCreate(options) {
if (options.length && typeof options[0] == 'object')
return [].map.call(options, $replaceOrCreate);
const doc = (options.parent || options.before).ownerDocument;
const el = doc.getElementById(options.id) || doc.createElement(options.tag);
for (let key of Object.keys(options)) {
switch (key) {
case 'tag':
case 'parent':
case 'before':
break;
case 'children':
if (el.children.length)
el.innerHTML = '';
$appendTo(el, options[key]);
break;
default:
const value = options[key];
if (key in el && el[key] != value)
el[key] = value;
}
}
if (!el.parentElement)
(options.parent || options.before.parentElement).insertBefore(el, options.before);
return el;
}
function $scriptIn(element) {
return element.appendChild(element.ownerDocument.createElement('script'));
}
function log(...args) {
console.log(GM_info.script.name, ...args);
}
function error(...args) {
console.error(GM_info.script.name, ...args);
}
function tryCatch(fn) {
try { return fn() }
catch(e) {}
}
function initPolyfills() {
for (let method of ['forEach', 'filter', 'map', Symbol.iterator])
if (!NodeList.prototype[method])
NodeList.prototype[method] = Array.prototype[method];
}
function initStyles() {
GM_addStyle(`
#SEpreview {
all: unset;
box-sizing: content-box;
width: 720px; /* 660px + 30px + 30px */
height: 33%;
min-height: 200px;
position: fixed;
opacity: 0;
transition: opacity .5s cubic-bezier(.88,.02,.92,.66);
right: 0;
bottom: 0;
padding: 0;
margin: 0;
background: white;
box-shadow: 0 0 100px rgba(0,0,0,0.5);
z-index: 999999;
border: 8px solid rgb(${COLORS.question.backRGB});
}
#SEpreview[SEpreviewType="answer"] {
border-color: rgb(${COLORS.answer.backRGB});
}
#SEpreview[SEpreviewType="deleted"] {
border-color: rgba(${COLORS.deleted.backRGB}, 0.65);
}
`);
preview.stylesOverride = `
body, html {
min-width: unset!important;
box-shadow: none!important;
padding: 0!important;
margin: 0!important;
}
html, body {
background: unset!important;;
}
body {
display: flex;
flex-direction: column;
height: 100vh;
}
a.SEpreviewable {
text-decoration: underline !important;
}
#SEpreviewTitle {
all: unset;
display: block;
padding: 20px 30px;
font-weight: bold;
font-size: 20px;
line-height: 1.3;
background-color: rgba(${COLORS.question.backRGB}, 0.37);
color: ${COLORS.question.fore};
cursor: pointer;
}
#SEpreviewTitle:hover {
text-decoration: underline;
}
#SEpreviewBody {
padding: 30px!important;
overflow: auto;
flex-grow: 2;
}
#SEpreviewBody .post-menu {
display: none!important;
}
#SEpreviewBody > .question-status {
margin: -10px -30px -30px;
padding-left: 30px;
}
#SEpreviewBody > .question-status h2 {
font-weight: normal;
}
#SEpreviewBody::-webkit-scrollbar {
background-color: rgba(${COLORS.question.backRGB}, 0.1);
}
#SEpreviewBody::-webkit-scrollbar-thumb {
background-color: rgba(${COLORS.question.backRGB}, 0.2);
}
#SEpreviewBody::-webkit-scrollbar-thumb:hover {
background-color: rgba(${COLORS.question.backRGB}, 0.3);
}
#SEpreviewBody::-webkit-scrollbar-thumb:active {
background-color: rgba(${COLORS.question.backRGB}, 0.75);
}
/* answer */
body[SEpreviewType="answer"] #SEpreviewTitle {
background-color: rgba(${COLORS.answer.backRGB}, 0.37);
color: ${COLORS.answer.fore};
}
body[SEpreviewType="answer"] #SEpreviewBody::-webkit-scrollbar {
background-color: rgba(${COLORS.answer.backRGB}, 0.1);
}
body[SEpreviewType="answer"] #SEpreviewBody::-webkit-scrollbar-thumb {
background-color: rgba(${COLORS.answer.backRGB}, 0.2);
}
body[SEpreviewType="answer"] #SEpreviewBody::-webkit-scrollbar-thumb:hover {
background-color: rgba(${COLORS.answer.backRGB}, 0.3);
}
body[SEpreviewType="answer"] #SEpreviewBody::-webkit-scrollbar-thumb:active {
background-color: rgba(${COLORS.answer.backRGB}, 0.75);
}
/* deleted */
body[SEpreviewType="deleted"] #SEpreviewTitle {
background-color: rgba(${COLORS.deleted.backRGB}, 0.37);
color: ${COLORS.deleted.fore};
}
body[SEpreviewType="deleted"] #SEpreviewBody::-webkit-scrollbar {
background-color: rgba(${COLORS.deleted.backRGB}, 0.1);
}
body[SEpreviewType="deleted"] #SEpreviewBody::-webkit-scrollbar-thumb {
background-color: rgba(${COLORS.deleted.backRGB}, 0.2);
}
body[SEpreviewType="deleted"] #SEpreviewBody::-webkit-scrollbar-thumb:hover {
background-color: rgba(${COLORS.deleted.backRGB}, 0.3);
}
body[SEpreviewType="deleted"] #SEpreviewBody::-webkit-scrollbar-thumb:active {
background-color: rgba(${COLORS.deleted.backRGB}, 0.75);
}
/********/
#SEpreviewAnswers {
all: unset;
display: block;
padding: 10px 30px;
font-weight: bold;
font-size: 20px;
line-height: 1.3;
border-top: 4px solid rgba(${COLORS.answer.backRGB}, 0.37);
background-color: rgba(${COLORS.answer.backRGB}, 0.37);
color: ${COLORS.answer.fore};
word-break: break-word;
}
#SEpreviewAnswers a {
color: ${COLORS.answer.fore};
padding: .25ex .75ex;
text-decoration: none;
}
#SEpreviewAnswers a.deleted-answer {
color: ${COLORS.deleted.fore};
background: transparent;
}
#SEpreviewAnswers a:hover:not(.SEpreviewed) {
text-decoration: underline;
}
#SEpreviewAnswers a.SEpreviewed {
background-color: ${COLORS.answer.fore};
color: ${COLORS.answer.foreInv};
}
/* deleted */
body[SEpreviewType="deleted"] #SEpreviewAnswers {
border-top-color: rgba(${COLORS.deleted.backRGB}, 0.37);
background-color: rgba(${COLORS.deleted.backRGB}, 0.37);
color: ${COLORS.deleted.fore};
}
body[SEpreviewType="deleted"] #SEpreviewAnswers a.SEpreviewed {
background-color: ${COLORS.deleted.fore};
color: ${COLORS.deleted.foreInv};
}
/********/
.comments .new-comment-highlight {
-webkit-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
-moz-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
animation: highlight 9s cubic-bezier(0,.8,.37,.88);
}
@-webkit-keyframes highlight {
from {background-color: #ffcf78}
to {background-color: none}
}
`;
}