SE Preview on hover

Shows preview of the linked questions/answers on hover

目前为 2017-02-13 提交的版本。查看 最新版本

// ==UserScript==
// @name           SE Preview on hover
// @description    Shows preview of the linked questions/answers on hover
// @version        0.0.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://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 = {
	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;
		if (preview.link.matches(':hover'))
			downloadPreview(preview.link.href);
	}, PREVIEW_DELAY);
}

function abortPreview() {
	preview.link.removeEventListener('mousemove', onLinkMouseMove);
	preview.link.removeEventListener('mouseout', abortPreview);
	preview.link.removeEventListener('mousedown', abortPreview);
	clearTimeout(preview.timer);
	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: 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 = [preview.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 preview.CSScache)
				externalsReady.push(preview.CSScache[e.href]);
			else {
				stylesToGet.add(e.href);
				GM_xmlhttpRequest({
					method: 'GET',
					url: e.href,
					onload: data => {
						externalsReady.push(preview.CSScache[e.href] = '<style>' + data.responseText + '</style>');
						stylesToGet.delete(e.href);
						maybeRender();
					},
				});
			}
		});

	}

	function maybeRender() {
		if (stylesToGet.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);

		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.frame.contentDocument;
			pvDoc.open();
			pvDoc.write(allHtml);
			pvDoc.close();
		} catch(e) {
			preview.frame.srcdoc = `<html>${allHtml}</html>`;
		}
		preview.frame.contentWindow.onload = () => {
			preview.frame.style.opacity = 1;
			let pvDoc = preview.frame.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(preview.link.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.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);
	}
	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(rxPreviewable) || [])[0];
	return {
		base,
		short: base ? base.replace('/questions/', '/q/') : undefined,
	};
}

function isLinkPreviewable(link, pageUrls = thisPageUrls) {
	return rxPreviewable.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});
		}
	`);

	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.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>`;
}