// ==UserScript==
// @name SE Preview on hover
// @description Shows preview of the linked questions/answers on hover
// @version 0.1.0
// @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
// @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 COLORS = {
question: {
backRGB: '80, 133, 195',
foreRGB: '#265184',
},
answer: {
backRGB: '112, 195, 80',
foreRGB: '#3f7722',
foreInv: 'white',
},
};
let xhr;
let preview = {
frame: null,
link: null,
hover: {x:0, y:0},
timer: 0,
CSScache: {},
stylesOverride: '',
};
const rxPreviewable = getURLregexForMatchedSites();
const thisPageUrls = getPageBaseUrls(location.href);
initStyles();
initPolyfills();
setMutationHandler('a', onLinkAdded, {processExisting: true});
/**************************************************************/
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();
}
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();
}
function restartPreviewTimer() {
clearTimeout(preview.timer);
preview.timer = setTimeout(() => {
preview.timer = 0;
releaseLinkListeners();
if (preview.link.matches(':hover'))
downloadPreview(preview.link.href);
}, PREVIEW_DELAY);
}
function releaseLinkListeners() {
preview.link.removeEventListener('mousemove', onLinkMouseMove);
preview.link.removeEventListener('mouseout', abortPreview);
preview.link.removeEventListener('mousedown', abortPreview);
clearTimeout(preview.timer);
}
function abortPreview() {
releaseLinkListeners();
preview.timer = setTimeout(() => {
preview.timer = 0;
if (preview.frame && !preview.frame.matches(':hover'))
hideAndRemove(preview.frame);
}, 1000);
if (xhr)
xhr.abort();
}
function hideAndRemove(element, transition) {
if (transition) {
element.style.transition = typeof transition == 'number' ? `opacity ${transition}s ease-in-out` : transition;
return setTimeout(hideAndRemove, 0, element);
}
element.style.opacity = 0;
element.addEventListener('transitionend', function remove() {
element.removeEventListener('transitionend', remove);
element.remove();
});
}
function downloadPreview(url) {
xhr = GM_xmlhttpRequest({
method: 'GET',
url: httpsUrl(url),
onload: showPreview,
});
}
function showPreview(data) {
let doc = new DOMParser().parseFromString(data.responseText, 'text/html');
if (!doc || !doc.head) {
error('empty document received:', data);
return;
}
if (!$(doc, 'base'))
doc.head.insertAdjacentHTML('afterbegin', `<base href="${data.finalUrl}">`);
const answerIdMatch = data.finalUrl.match(/questions\/.+?\/(\d+)/);
const isQuestion = !answerIdMatch;
let postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
let post = $(doc, postId + ' .post-text');
if (!post)
return error('No parsable post found', doc);
const title = $(doc, 'meta[property="og:title"]').content;
let comments = $(doc, `${postId} .comments`);
let commentsHidden = +$(comments, 'tbody').dataset.remainingCommentsCount;
let commentsShowLink = commentsHidden && $(doc, `${postId} .js-show-link.comments-link`);
let externalsReady = [preview.stylesOverride];
let externalsToGet = new Set();
let afterBodyHtml = '';
fetchExternals();
maybeRender();
function fetchExternals() {
let codeBlocks = $$(post, 'pre code');
if (codeBlocks.length) {
codeBlocks.forEach(e => e.parentElement.classList.add('prettyprint'));
externalsReady.push(
'<script> StackExchange = {}; </script>',
'<script src="https://cdn.sstatic.net/Js/prettify-full.en.js"></script>'
);
afterBodyHtml += '<script> prettyPrint(); </script>';
}
$$(doc, 'style, link[rel="stylesheet"]').forEach(e => {
if (e.localName == 'style')
externalsReady.push(e.outerHTML);
else if (e.href in preview.CSScache)
externalsReady.push(preview.CSScache[e.href]);
else {
externalsToGet.add(e.href);
GM_xmlhttpRequest({
method: 'GET',
url: e.href,
onload: data => {
externalsReady.push(preview.CSScache[e.href] = '<style>' + data.responseText + '</style>');
externalsToGet.delete(e.href);
maybeRender();
},
});
}
});
}
function maybeRender() {
if (externalsToGet.size)
return;
if (!preview.frame) {
preview.frame = document.createElement('iframe');
preview.frame.id = 'SEpreview';
}
preview.frame.classList.toggle('SEpreviewIsAnswer', !!answerIdMatch);
document.body.appendChild(preview.frame);
const answers = $$(doc, '.answer');
const answersShown = answers.length > (isQuestion ? 0 : 1);
if (answersShown) {
afterBodyHtml += '<div id="SEpreviewAnswers">Answers: ' +
answers.map((e, index) =>
`<a href="${$(e, '.short-link').href}"
title="${$text(e, '.user-details a') + ' (' +
$text(e, '.reputation-score') + ') ' +
$text(e, '.user-action-time')}"
class="${e.matches(postId) ? 'SEpreviewed' : ''}"
>${index + 1}</a>`
).join('') + '</div>';
}
$$remove(doc, 'script, .post-menu');
let html = `<head>${externalsReady.join('')}</head>
<body${answerIdMatch ? ' class="SEpreviewIsAnswer"' : ''}>
<a id="SEpreviewTitle" href="${
isQuestion ? data.finalUrl : data.finalUrl.replace(/\/\d+[^\/]*$/, '')
}">${title}</a>
<div id="SEpreviewBody">${
[post.parentElement, comments, commentsShowLink]
.map(e => e ? e.outerHTML : '').join('')
}</div>
${afterBodyHtml}
</body>`;
try {
let pvDoc = preview.frame.contentDocument;
pvDoc.open();
pvDoc.write(html);
pvDoc.close();
} catch(e) {
preview.frame.srcdoc = `<html>${html}</html>`;
}
preview.frame.onload = () => {
preview.frame.onload = null;
preview.frame.style.opacity = 1;
let pvDoc = preview.frame.contentDocument;
pvDoc.addEventListener('mouseover', retainMainScrollPos);
pvDoc.addEventListener('click', interceptLinks);
};
}
function interceptLinks(e) {
const link = e.target;
if (link.localName != 'a')
return;
if (link.matches('.js-show-link.comments-link')) {
hideAndRemove(link, 0.5);
downloadComments();
}
else if (e.button || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey ||
!isLinkPreviewable(link))
return link.target = '_blank';
else if (!link.matches('.SEpreviewed'))
downloadPreview(link.href);
e.preventDefault();
}
function downloadComments() {
GM_xmlhttpRequest({
method: 'GET',
url: new URL(data.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 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 getPageBaseUrls(url) {
let base = httpsUrl((url.match(rxPreviewable) || [])[0]);
return base ? {
base,
short: base.replace('/questions/', '/q/'),
} : {};
}
function isLinkPreviewable(link) {
const inPreview = link.ownerDocument != document;
if (inPreview && link.matches('#SEpreviewAnswers a, a#SEpreviewTitle'))
return true;
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 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) {
let 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 log(...args) {
console.log(GM_info.script.name, ...args);
}
function error(...args) {
console.error(GM_info.script.name, ...args);
}
function initPolyfills() {
NodeList.prototype.forEach = NodeList.prototype.forEach || Array.prototype.forEach;
NodeList.prototype.map = NodeList.prototype.map || Array.prototype.map;
}
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.SEpreviewIsAnswer {
border-color: rgb(${COLORS.answer.backRGB});
}
`);
preview.stylesOverride = `<style>
body, html {
min-width: unset!important;
box-shadow: none!important;
}
html, body {
background: unset!important;;
}
body {
display: flex;
flex-direction: column;
height: 100vh;
}
#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.foreRGB};
cursor: pointer;
}
#SEpreviewTitle:hover {
text-decoration: underline;
}
#SEpreviewBody {
padding: 30px!important;
overflow: auto;
flex-grow: 2;
}
#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);
}
body.SEpreviewIsAnswer #SEpreviewTitle {
background-color: rgba(${COLORS.answer.backRGB}, 0.37);
color: ${COLORS.answer.foreRGB};
}
body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar {
background-color: rgba(${COLORS.answer.backRGB}, 0.1);
}
body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb {
background-color: rgba(${COLORS.answer.backRGB}, 0.2);
}
body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb:hover {
background-color: rgba(${COLORS.answer.backRGB}, 0.3);
}
body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb:active {
background-color: rgba(${COLORS.answer.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.foreRGB};
}
#SEpreviewAnswers a {
color: ${COLORS.answer.foreRGB};
padding: .25ex .75ex;
text-decoration: none;
}
#SEpreviewAnswers a:hover:not(.SEpreviewed) {
text-decoration: underline;
}
#SEpreviewAnswers a.SEpreviewed {
background-color: ${COLORS.answer.foreRGB};
color: ${COLORS.answer.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}
}
</style>`;
}