SE Preview on hover

Shows preview of the linked questions/answers on hover

目前為 2017-02-14 提交的版本,檢視 最新版本

// ==UserScript==
// @name           SE Preview on hover
// @description    Shows preview of the linked questions/answers on hover
// @version        0.1.5
// @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://gf.qytechs.cn/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(this);
}

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

function restartPreviewTimer(link) {
	clearTimeout(preview.timer);
	preview.timer = setTimeout(() => {
		preview.timer = 0;
		link.removeEventListener('mousemove', onLinkMouseMove);
		if (link.matches(':hover'))
			downloadPreview(link.href);
	}, PREVIEW_DELAY);
}

function abortPreview(e) {
	releaseLinkListeners(this);
	preview.timer = setTimeout(link => {
		if (link == preview.link && preview.frame && !preview.frame.matches(':hover')) {
			releaseLinkListeners(link);
			hideAndRemove(preview.frame);
		}
	}, PREVIEW_DELAY * 3, this);
	if (xhr)
		xhr.abort();
}

function releaseLinkListeners(link) {
	link.removeEventListener('mousemove', onLinkMouseMove);
	link.removeEventListener('mouseout', abortPreview);
	link.removeEventListener('mousedown', abortPreview);
	clearTimeout(preview.timer);
}

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);
		if (+element.style.opacity === 0)
			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 status = isQuestion && $(doc, '.question-status');
	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, status]
						.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>`;
		}

		onFrameReady(preview.frame, function() {
			this.onload = null;
			this.style.opacity = 1;
			this.contentDocument.addEventListener('mouseover', retainMainScrollPos);
			this.contentDocument.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 onFrameReady(frame, callback) {
	if (frame.contentDocument.readyState == 'complete')
		return callback.call(frame);
	else
		frame.onload = callback;
}

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 .question-status {
			margin: -25px -30px -30px;
			padding-left: 30px;
		}
		#SEpreviewBody .question-status h2 {
			font-weight: normal;
		}

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址