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.6
// @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 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: 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>`;

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

initPolyfills();
setMutationHandler('a', onLinkAdded, {processExisting: true});

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

function onLinkAdded(links) {
	for (let 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) {
	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 = $(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.sandbox = 'allow-same-origin allow-scripts';
		}
		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>`;
		let pvDoc = preview.contentDocument;
		try {
			pvDoc.open();
			pvDoc.write(allHtml);
			pvDoc.close();
		} catch(e) {
			preview.srcdoc = `<html>${allHtml}</html>`;
		}

		pvDoc.addEventListener('mouseover', retainMainScrollPos);
		if (commentsMore)
			$(pvDoc, '.js-show-link.comments-link').addEventListener('click', downloadComments);
		if (answers)
			$(pvDoc, '#SEpreviewAnswers').addEventListener('click', revealAnswers);
	}

	function downloadComments() {
		this.remove();
		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 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());
}

function initPolyfills() {
	NodeList.prototype.forEach = NodeList.prototype.forEach || Array.prototype.forEach;
}