SE Preview on hover

Shows preview of the linked questions/answers on hover while Ctrl key is held

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

// ==UserScript==
// @name           SE Preview on hover
// @description    Shows preview of the linked questions/answers on hover while Ctrl key is held
// @version        0.0.3
// @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',
	},
};

var xhr;
var preview;
var previewLink;
var previewTimer;
var previewCSScache = {};
var hovering = {stoppedAt: {x:0, y:0}};

const rx = getURLregexForMatchedSites();
const thisPageBaseUrl = (location.href.match(rx) || [])[0];
const thisPageBaseUrlShort = thisPageBaseUrl ? thisPageBaseUrl.replace('/questions/', '/q/') : undefined;

const stylesOverride = `<style>
	body, html {
		min-width: unset!important;
		box-shadow: none!important;
	}
	html, body {
		background: unset!important;;
	}
	body {
		display: flex;
		flex-direction: column;
		height: 100%;
	}
	#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};
	}
</style>`;

GM_addStyle(`
	#SEpreview {
	    all: unset;
	    box-sizing: content-box;
	    width: 720px; /* 660px + 30px + 30px */
	    height: 33%;
	    min-height: 200px;
	    position: fixed;
	    transition: opacity .25s ease-in-out;
	    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});
	}
`);

processExistingAndSetMutationHandler('a', onLinkAdded);

/**************************************************************/

function onLinkAdded(links) {
	for (var i = 0, link; (link = links[i++]); ) {
		if (rx.test(link.href) &&
			!link.matches('.short-link') &&
			!link.href.startsWith(thisPageBaseUrl) &&
			!link.href.startsWith(thisPageBaseUrlShort)
		) {
			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'))
			return;
		downloadPage();
	}, 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'))
			hidePreview();
	}, 500);
	if (xhr)
		xhr.abort();
}

function downloadPage() {
	xhr = GM_xmlhttpRequest({
		method: 'GET',
		url: previewLink.href,
		onload: showPreview,
	});
}

function showPreview(data) {
	var 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}">`);

	var answerIdMatch = data.finalUrl.match(/questions\/.+?\/(\d+)/);
	var postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
	var post = $(doc, postId + ' .post-text');
	if (!post)
		return;
	var title = $(doc, 'meta[property="og:title"]').content;
	var comments = $(doc, postId + ' .comments');
	var answers; // = answerIdMatch ? null : $$(doc, '.answer');

	$$remove(doc, 'script, .post-menu');

	var externalsReady = [stylesOverride];
	var stylesToGet = new Set();
	var afterBodyHtml = '';

	fetchExternals();
	maybeRender();

	function fetchExternals() {
		var 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.sandbox = 'allow-same-origin allow-scripts';
		}
		preview.classList.toggle('SEpreviewIsAnswer', !!answerIdMatch);
		document.body.appendChild(preview);

		var headHtml = externalsReady.join('');
		var bodyHtml = [post.parentElement, comments].map(e => e ? e.outerHTML || e : '').join('');
		var pvDoc = preview.contentDocument;
		pvDoc.open();
		pvDoc.write(`
			<head>${headHtml}</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>`);
		pvDoc.close();

		pvDoc.addEventListener('mouseover', retainMainScrollPos);
		if (answers)
			$(pvDoc, '#SEpreviewAnswers').addEventListener('click', revealAnswers);
	}
}

function retainMainScrollPos(e) {
	var 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 hidePreview() {
	preview.remove();
}

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 $(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());
}