- // ==UserScript==
- // @name SE Preview on hover
- // @description Shows preview of the linked questions/answers on hover
- // @version 0.0.7
- // @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: {
- back: '80, 133, 195',
- fore: '#265184',
- },
- answer: {
- back: '112, 195, 80',
- fore: '#3f7722',
- },
- };
-
- let xhr;
- let preview;
- let previewLink;
- let previewTimer;
- let previewCSScache = {};
- let hovering = {stoppedAt: {x:0, y:0}};
-
- const rx = getURLregexForMatchedSites();
- const thisPageUrls = getPageBaseUrls(location.href);
- const stylesOverride = 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;
- previewLink = this;
- previewLink.addEventListener('mousemove', onLinkMouseMove);
- previewLink.addEventListener('mouseout', abortPreview);
- previewLink.addEventListener('mousedown', abortPreview);
- restartPreviewTimer();
- }
-
- function onLinkMouseMove(e) {
- if (Math.abs(hovering.stoppedAt.x - e.clientX) < 2 && Math.abs(hovering.stoppedAt.y - e.clientY) < 2)
- return;
- hovering.stoppedAt.x = e.clientX;
- hovering.stoppedAt.y = e.clientY;
- restartPreviewTimer();
- }
-
- function restartPreviewTimer() {
- clearTimeout(previewTimer);
- previewTimer = setTimeout(() => {
- previewTimer = 0;
- if (previewLink.matches(':hover'))
- downloadPreview(previewLink.href);
- }, PREVIEW_DELAY);
- }
-
- function abortPreview() {
- previewLink.removeEventListener('mousemove', onLinkMouseMove);
- previewLink.removeEventListener('mouseout', abortPreview);
- previewLink.removeEventListener('mousedown', abortPreview);
- clearTimeout(previewTimer);
- previewTimer = setTimeout(() => {
- previewTimer = 0;
- if (preview && !preview.matches(':hover'))
- hideAndRemove(preview);
- }, 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: url,
- onload: showPreview,
- });
- }
-
- function showPreview(data) {
- let doc = new DOMParser().parseFromString(data.responseText, 'text/html');
- if (!doc || !doc.head) {
- console.error(GM_info.script.name, 'empty document received:', data);
- return;
- }
-
- if (!$(doc, 'base'))
- doc.head.insertAdjacentHTML('afterbegin', `<base href="${data.finalUrl}">`);
-
- let answerIdMatch = data.finalUrl.match(/questions\/.+?\/(\d+)/);
- let postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
- let post = $(doc, postId + ' .post-text');
- if (!post)
- return;
- let title = $(doc, 'meta[property="og:title"]').content;
- let comments = $(doc, `${postId} .comments`);
- let commentsMore = +$(comments, 'tbody').dataset.remainingCommentsCount && $(doc, `${postId} .js-show-link.comments-link`);
- let answers; // = answerIdMatch ? null : $$(doc, '.answer');
-
- $$remove(doc, 'script, .post-menu');
-
- let externalsReady = [stylesOverride];
- let stylesToGet = 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 previewCSScache)
- externalsReady.push(previewCSScache[e.href]);
- else {
- stylesToGet.add(e.href);
- GM_xmlhttpRequest({
- method: 'GET',
- url: e.href,
- onload: data => {
- externalsReady.push(previewCSScache[e.href] = '<style>' + data.responseText + '</style>');
- stylesToGet.delete(e.href);
- maybeRender();
- },
- });
- }
- });
-
- }
-
- function maybeRender() {
- if (stylesToGet.size)
- return;
- if (!preview) {
- preview = document.createElement('iframe');
- preview.id = 'SEpreview';
- }
- preview.classList.toggle('SEpreviewIsAnswer', !!answerIdMatch);
- document.body.appendChild(preview);
-
- let bodyHtml = [post.parentElement, comments, commentsMore].map(e => e ? e.outerHTML : '').join('');
- let allHtml = `<head>${externalsReady.join('')}</head>
- <body${answerIdMatch ? ' class="SEpreviewIsAnswer"' : ''}>
- <a id="SEpreviewTitle" href="${data.finalUrl}">${title}</a>
- <div id="SEpreviewBody">${bodyHtml}</div>
- ${!answers ? '' : '<div id="SEpreviewAnswers">' + answers.length + ' answer' + (answers.length > 1 ? 's' : '') + '</div>'}
- ${afterBodyHtml}
- </body>`;
- try {
- let pvDoc = preview.contentDocument;
- pvDoc.open();
- pvDoc.write(allHtml);
- pvDoc.close();
- } catch(e) {
- preview.srcdoc = `<html>${allHtml}</html>`;
- }
- preview.contentWindow.onload = () => {
- preview.style.opacity = 1;
- let pvDoc = preview.contentDocument;
- pvDoc.addEventListener('mouseover', retainMainScrollPos);
- pvDoc.addEventListener('click', interceptLinks);
- if (answers)
- $(pvDoc, '#SEpreviewAnswers').addEventListener('click', revealAnswers);
- };
- }
-
- function interceptLinks(e) {
- if (e.target.matches('.js-show-link.comments-link')) {
- hideAndRemove(e.target, 0.5);
- downloadComments();
- }
- else if (e.button || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)
- return;
- else if (isLinkPreviewable(e.target, getPageBaseUrls(previewLink.href)))
- downloadPreview(e.target.href);
- else
- return;
- 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.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);
- }
- 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 = (url.match(rx) || [])[0];
- return {
- base,
- short: base ? base.replace('/questions/', '/q/') : undefined,
- };
- }
-
- function isLinkPreviewable(link, pageUrls = thisPageUrls) {
- return rx.test(link.href) &&
- !link.matches('.short-link') &&
- !link.href.startsWith(pageUrls.base) &&
- !link.href.startsWith(pageUrls.short);
- }
-
- function $(node__optional, selector) {
- // or $(selector) {
- return (node__optional || document).querySelector(selector || node__optional);
- }
-
- function $$(node__optional, selector) {
- // or $$(selector) {
- return (node__optional || document).querySelectorAll(selector || node__optional);
- }
-
- function $$remove(node__optional, selector) {
- // or $$remove(selector) {
- (node__optional || document).querySelectorAll(selector || node__optional)
- .forEach(e => e.remove());
- }
-
- function initPolyfills() {
- NodeList.prototype.forEach = NodeList.prototype.forEach || Array.prototype.forEach;
- }
-
- 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.back});
- }
- #SEpreview.SEpreviewIsAnswer {
- border-color: rgb(${COLORS.answer.back});
- }
- `);
-
- return `<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.back}, 0.37);
- color: ${COLORS.question.fore};
- }
- #SEpreviewTitle:hover {
- text-decoration: underline;
- }
- #SEpreviewBody {
- padding: 30px!important;
- overflow: auto;
- }
- #SEpreviewBody::-webkit-scrollbar {
- background-color: rgba(${COLORS.question.back}, 0.1);
- }
- #SEpreviewBody::-webkit-scrollbar-thumb {
- background-color: rgba(${COLORS.question.back}, 0.2);
- }
- #SEpreviewBody::-webkit-scrollbar-thumb:hover {
- background-color: rgba(${COLORS.question.back}, 0.3);
- }
- #SEpreviewBody::-webkit-scrollbar-thumb:active {
- background-color: rgba(${COLORS.question.back}, 0.75);
- }
-
- body.SEpreviewIsAnswer #SEpreviewTitle {
- background-color: rgba(${COLORS.answer.back}, 0.37);
- color: ${COLORS.answer.fore};
- }
- body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar {
- background-color: rgba(${COLORS.answer.back}, 0.1);
- }
- body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb {
- background-color: rgba(${COLORS.answer.back}, 0.2);
- }
- body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb:hover {
- background-color: rgba(${COLORS.answer.back}, 0.3);
- }
- body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb:active {
- background-color: rgba(${COLORS.answer.back}, 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.back}, 0.37);
- background-color: rgba(${COLORS.answer.back}, 0.37);
- color: ${COLORS.answer.fore};
- }
-
- .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>`;
- }