SE Preview on hover

Shows preview of the linked questions/answers on hover

当前为 2017-02-14 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           SE Preview on hover
// @description    Shows preview of the linked questions/answers on hover
// @version        0.1.2
// @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) {
	log('hiding preview'); console.trace();
	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 = data.SEpreviewDoc || 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:&nbsp;' +
				answers.map((e, index) =>
					`<a href="${$(e, '.short-link').href.replace(/(\d+)\/\d+/, '$1')}"
						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');

		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)
			return (link.target = '_blank');
		else if (link.matches('#SEpreviewAnswers a, a#SEpreviewTitle'))
			showPreview({
				finalUrl: link.href.includes('/questions/')
					? link.href
					: data.finalUrl.replace(/(\/\d+[^\/]*|\?.*)?$/g, '') + '/' + link.pathname.match(/\d+/)[0],
				SEpreviewDoc: doc,
			});
		else if (!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 (!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 .post-menu {
			display: none!important;
		}
		#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>`;
}