- // ==UserScript==
- // @name SE Preview on hover
- // @description Shows preview of the linked questions/answers on hover
- // @version 0.1.1
- // @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://greasyfork.org/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);
- }, PREVIEW_DELAY * 3);
- 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') +
- $text(e, '.vote-count-post').replace(/\d+/, s => !s ? '' : ', votes: ' + s)}"
- 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};
- word-break: break-word;
- }
- #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>`;
- }