SE Preview on hover

Shows preview of the linked questions/answers on hover

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

您需要先安装一个扩展,例如 篡改猴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.2.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
// @require        https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.4.4/lz-string.min.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 = 200;
const CACHE_DURATION = 1 * 60 * 1000; // 1 minute for the recently active posts, scales up logarithmically
const COLORS = {
	question: {
		backRGB: '80, 133, 195',
		fore: '#265184',
	},
	answer: {
		backRGB: '112, 195, 80',
		fore: '#3f7722',
		foreInv: 'white',
	},
	deleted: {
		backRGB: '181, 103, 103',
		fore: 'rgb(181, 103, 103)',
		foreInv: 'white',
	},
	closed: {
		backRGB: '255, 206, 93',
		fore: 'rgb(204, 143, 0)',
		foreInv: 'white',
	},
};

let xhr;
let preview = {
	frame: null,
	link: null,
	hover: {x:0, y:0},
	timer: 0,
	cacheCSS: {},
	stylesOverride: '',
};

const rxPreviewable = getURLregexForMatchedSites();
const thisPageUrls = getPageBaseUrls(location.href);

initStyles();
initPolyfills();
setMutationHandler('a', onLinkAdded, {processExisting: true});
setTimeout(cleanupCache, 10000);

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

function onLinkAdded(links) {
	for (let i = 0, link; (link = links[i++]); ) {
		if (isLinkPreviewable(link)) {
			link.removeAttribute('title');
			$on('mouseover', link, onLinkHovered);
		}
	}
}

function onLinkHovered(e) {
	if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)
		return;
	preview.link = this;
	$on('mousemove', this, onLinkMouseMove);
	$on('mouseout', this, abortPreview);
	$on('mousedown', this, 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;
		$off('mousemove', link, 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);
			preview.frame.contentWindow.postMessage('SEpreview-hidden', '*');
			fadeOut(preview.frame);
		}
	}, PREVIEW_DELAY * 3, this);
	if (xhr)
		xhr.abort();
}

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

function fadeOut(element, transition) {
	if (transition) {
		element.style.transition = typeof transition == 'number' ? `opacity ${transition}s ease-in-out` : transition;
		return setTimeout(fadeOut, 0, element);
	}
	element.style.opacity = 0;
	$on('transitionend', element, function remove() {
		$off('transitionend', element, remove);
		if (+element.style.opacity === 0)
			element.style.display = 'none';
	});
}

function downloadPreview(url) {
	let cached = readCache(url);
	if (cached)
		showPreview(cached);
	else {
		xhr = GM_xmlhttpRequest({
			method: 'GET',
			url: httpsUrl(url),
			onload: r => {
				let html = r.responseText;
				let lastActivity = showPreview({finalUrl: r.finalUrl, html});
				let inactiveDays = Math.max(0, (Date.now() - lastActivity) / (24 * 3600 * 1000));
				let cacheDuration = CACHE_DURATION * Math.pow(Math.log(inactiveDays + 1) + 1, 2);
				writeCache({url, finalUrl: r.finalUrl, html, cacheDuration});
			},
		});
	}
}

function showPreview({finalUrl, html, doc}) {
	doc = doc || new DOMParser().parseFromString(html, 'text/html');
	if (!doc || !doc.head) {
		error('no HEAD in the document received for', finalUrl);
		return;
	}

	if (!$('base', doc))
		doc.head.insertAdjacentHTML('afterbegin', `<base href="${finalUrl}">`);

	const answerIdMatch = finalUrl.match(/questions\/\d+\/[^\/]+\/(\d+)/);
	const isQuestion = !answerIdMatch;
	const postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
	const post = $(postId + ' .post-text', doc);
	if (!post)
		return error('No parsable post found', doc);
	const isDeleted = post.closest('.deleted-answer');
	const title = $('meta[property="og:title"]', doc).content;
	const status = isQuestion && !$('.question-status', post) && $('.question-status', doc);
	const isClosed = $('.question-originals-of-duplicate, .close-as-off-topic-status-list, .close-status-suffix', doc);
	const comments = $(`${postId} .comments`, doc);
	const commentsHidden = +$('tbody', comments).dataset.remainingCommentsCount;
	const commentsShowLink = commentsHidden && $(`${postId} .js-show-link.comments-link`, doc);
	const finalUrlOfQuestion = getCacheableUrl(finalUrl);

	const lastActivity = +doc.body.getAttribute('SEpreview-lastActivity')
		|| tryCatch(() => new Date($('.lastactivity-link', doc).title).getTime())
		|| Date.now();
	if (lastActivity)
		doc.body.setAttribute('SEpreview-lastActivity', lastActivity);

	$$remove('script', doc);

	// underline previewable links
	for (let link of $$('a:not(.SEpreviewable)', doc)) {
		if (rxPreviewable.test(link.href)) {
			link.removeAttribute('title');
			link.classList.add('SEpreviewable');
		}
	}

	if (!preview.frame) {
		preview.frame = document.createElement('iframe');
		preview.frame.id = 'SEpreview';
    	document.body.appendChild(preview.frame);
	}

	let pvDoc, pvWin;
	preview.frame.setAttribute('SEpreview-type',
		isDeleted ? 'deleted' : isQuestion ? (isClosed ? 'closed' : 'question') : 'answer');
	onFrameReady(preview.frame).then(
		() => {
			pvDoc = preview.frame.contentDocument;
			pvWin = preview.frame.contentWindow;
			initPolyfills(pvWin);
		})
		.then(addStyles)
		.then(render)
		.then(show);
	return lastActivity;

	function addStyles() {
		const SEpreviewStyles = $replaceOrCreate({
    		id: 'SEpreviewStyles',
			tag: 'style', parent: pvDoc.head, className: 'SEpreview-reuse',
			innerHTML: preview.stylesOverride,
		});

		$replaceOrCreate($$('style, link[rel="stylesheet"]', doc).map(e =>
			e.localName == 'style' ? {
				id: 'SEpreview' + e.innerHTML.replace(/\W+/g, '').length,
				tag: 'style', before: SEpreviewStyles, className: 'SEpreview-reuse',
				innerHTML: e.innerHTML,
			} : {
				id: e.href.replace(/\W+/g, ''),
				tag: 'link', before: SEpreviewStyles, className: 'SEpreview-reuse',
				href: e.href, rel: 'stylesheet',
			})
		);

		return onStyleSheetsReady($$('link[rel="stylesheet"]', pvDoc));
	}

	function render() {
		pvDoc.body.setAttribute('SEpreview-type', preview.frame.getAttribute('SEpreview-type'));

		$replaceOrCreate([{
		// title
			id: 'SEpreview-title', tag: 'a',
			parent: pvDoc.body, className: 'SEpreviewable',
            href: finalUrlOfQuestion,
			textContent: title,
		}, {
		// vote count, date, views#
			id: 'SEpreview-meta',
			parent: pvDoc.body,
			innerHTML: [
				$text('.vote-count-post', post.closest('table')).replace(/(-?)(\d+)/,
					(s, sign, v) => s == '0' ? '' : `<b>${s}</b> vote${+v > 1 ? 's' : ''}, `),
				isQuestion
					? $$('#qinfo tr', doc)
						.map(row => $$('.label-key', row).map($text).join(' '))
						.join(', ').replace(/^((.+?) (.+?), .+?), .+? \3$/, '$1')
					: [...$$('.user-action-time', post.closest('.answer'))]
						.reverse().map($text).join(', ')
			].join('')
		}, {
		// content wrapper
			id: 'SEpreview-body',
			parent: pvDoc.body,
			className: isDeleted ? 'deleted-answer' : '',
			children: [post.parentElement, comments, commentsShowLink, status],
		}]);

		renderCode();

		// render bottom shelf
		const answers = $$('.answer', doc);
		if (answers.length > (isQuestion ? 0 : 1)) {
			$replaceOrCreate({
				id: 'SEpreview-answers',
				parent: pvDoc.body,
				innerHTML: answers.map(renderShelfAnswer).join(' '),
			});
		} else
			$$remove('#SEpreview-answers', pvDoc);

		// cleanup leftovers from previously displayed post and foreign elements not injected by us
		$$('style, link, body script, html > *:not(head):not(body)', pvDoc).forEach(e => {
			if (e.classList.contains('SEpreview-reuse'))
				e.classList.remove('SEpreview-reuse');
			else
				e.remove();
		});
	}

	function renderCode() {
		const codeBlocks = $$('pre code', pvDoc);
		if (codeBlocks.length) {
			codeBlocks.forEach(e => e.parentElement.classList.add('prettyprint'));
			if (!pvWin.StackExchange) {
				pvWin.StackExchange = {};
				let script = $scriptIn(pvDoc.head);
				script.text = 'StackExchange = {}';
				script = $scriptIn(pvDoc.head);
				script.src = 'https://cdn.sstatic.net/Js/prettify-full.en.js';
				script.setAttribute('onload', 'prettyPrint()');
			} else
				$scriptIn(pvDoc.body).text = 'prettyPrint()';
		}
	}

	function renderShelfAnswer(e) {
		const shortUrl = $('.short-link', e).href.replace(/(\d+)\/\d+/, '$1');
		const extraClasses = (e.matches(postId) ? ' SEpreviewed' : '') +
		      (e.matches('.deleted-answer') ? ' deleted-answer' : '');
		const author = $('.post-signature:last-child', e);
		const title = $text('.user-details a', author) + ' (rep ' +
			  $text('.reputation-score', author) + ')\n' +
			  $text('.user-action-time', author);
		const gravatar = $('img, .anonymous-gravatar, .community-wiki', author);
		const accepted = !!$('.vote-accepted-on', e);
		return (
			`<a href="${shortUrl}" title="${title}" class="SEpreviewable${extraClasses}">` +
				$text('.vote-count-post', e) + ' ' +
				(!accepted ? '' : '<span class="vote-accepted-on"></span>') +
				(!gravatar ? '' : gravatar.src ? `<img src="${gravatar.src}">` : gravatar.outerHTML) +
			'</a>');
	}

	function show() {
		pvDoc.onmouseover = retainMainScrollPos;
		pvDoc.onclick = interceptLinks;
		pvWin.onmessage = e => {
			if (e.data == 'SEpreview-hidden') {
				pvWin.onmessage = null;
				pvDoc.onmouseover = null;
				pvDoc.onclick = null;
			}
		};

		$('#SEpreview-body', pvDoc).scrollTop = 0;
		preview.frame.style.opacity = 1;
		preview.frame.style.display = '';
	}

	function retainMainScrollPos(e) {
		let scrollPos = {x:scrollX, y:scrollY};
		$on('scroll', preventScroll);
		$on('mouseover', releaseScrollLock);

		function preventScroll(e) {
			scrollTo(scrollPos.x, scrollPos.y);
		}

		function releaseScrollLock(e) {
			$off('mouseout', releaseScrollLock);
			$off('scroll', preventScroll);
		}
	}

	function interceptLinks(e) {
		const link = e.target.closest('a');
		if (!link)
			return;
		if (link.matches('.js-show-link.comments-link')) {
			fadeOut(link, 0.5);
			loadComments();
		}
		else if (e.button || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey || !link.matches('.SEpreviewable'))
			return (link.target = '_blank');
		else if (link.matches('#SEpreview-answers a, a#SEpreview-title'))
			showPreview({
				finalUrl: finalUrlOfQuestion + (link.id == 'SEpreview-title' ? '' : '/' + link.pathname.match(/\/(\d+)/)[1]),
				doc
			});
		else
			downloadPreview(link.getAttribute('SEpreview-fullUrl') || link.href);
		e.preventDefault();
	}

	function loadComments() {
		GM_xmlhttpRequest({
			method: 'GET',
			url: new URL(finalUrl).origin + '/posts/' + comments.id.match(/\d+/)[0] + '/comments',
			onload: r => {
				let tbody = $(`#${comments.id} tbody`, pvDoc);
				let oldIds = new Set([...tbody.rows].map(e => e.id));
				tbody.innerHTML = r.responseText;
				for (let tr of tbody.rows)
					if (!oldIds.has(tr.id))
						tr.classList.add('new-comment-highlight');
			},
		});
	}
}

function getCacheableUrl(url) {
	// strips querys and hashes and anything after the main part https://site/questions/####/title/
	return url
		.replace(/(\/q(?:uestions)?\/\d+\/[^\/]+).*/, '$1')
		.replace(/(\/a(?:nswers)?\/\d+).*/, '$1')
		.replace(/[?#].*$/, '');
}

function readCache(url) {
	keyUrl = getCacheableUrl(url);
	const meta = (localStorage[keyUrl] || '').split('\t');
	const expired = +meta[0] < Date.now();
	const finalUrl = meta[1] || url;
	const keyFinalUrl = meta[1] ? getCacheableUrl(finalUrl) : keyUrl;
	return !expired && {
		finalUrl,
		html: LZString.decompressFromUTF16(localStorage[keyFinalUrl + '\thtml']),
	};
}

function writeCache({url, finalUrl, html, cacheDuration = CACHE_DURATION, cleanupRetry}) {
	// keyUrl=expires
	// redirected keyUrl=expires+finalUrl, and an additional entry keyFinalUrl=expires is created
	// keyFinalUrl\thtml=html
	cacheDuration = Math.max(CACHE_DURATION, Math.min(0xDEADBEEF, Math.floor(cacheDuration)));
	finalUrl = finalUrl.replace(/[?#].*/, '');
	const keyUrl = getCacheableUrl(url);
	const keyFinalUrl = getCacheableUrl(finalUrl);
	const expires = Date.now() + cacheDuration;
	if (!tryCatch(() => localStorage[keyFinalUrl + '\thtml'] = LZString.compressToUTF16(html))) {
		if (cleanupRetry)
			return error('localStorage write error');
		cleanupCache({aggressive: true});
		setIimeout(writeCache, 0, {url, finalUrl, html, cacheDuration, cleanupRetry: true});
	}
	localStorage[keyFinalUrl] = expires;
	if (keyUrl != keyFinalUrl)
		localStorage[keyUrl] = expires + '\t' + finalUrl;
	setTimeout(() => {
		[keyUrl, keyFinalUrl, keyFinalUrl + '\thtml'].forEach(e => localStorage.removeItem(e));
	}, cacheDuration + 1000);
}

function cleanupCache({aggressive = false} = {}) {
	Object.keys(localStorage).forEach(k => {
		if (k.match(/^https?:\/\/[^\t]+$/)) {
			let meta = (localStorage[k] || '').split('\t');
			if (+meta[0] > Date.now() && !aggressive)
				return;
			if (meta[1])
				localStorage.removeItem(meta[1]);
			localStorage.removeItem(`${meta[1] || k}\thtml`);
			localStorage.removeItem(k);
		}
	});
}

function onFrameReady(frame) {
	if (frame.contentDocument.readyState == 'complete')
		return Promise.resolve();
	else
		return new Promise(resolve => {
			$on('load', frame, function onLoad() {
				$off('load', frame, onLoad);
				resolve();
			});
		});
}

function onStyleSheetsReady(linkElements) {
	return new Promise(function retry(resolve) {
		if (linkElements.every(e => e.sheet && e.sheet.href == e.href))
			resolve();
		else
			setTimeout(retry, 0, resolve);
	});
}

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 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 getPageBaseUrls(url) {
	const base = httpsUrl((url.match(rxPreviewable) || [])[0]);
	return base ? {
		base,
		short: base.replace('/questions/', '/q/'),
	} : {};
}

function httpsUrl(url) {
	return (url || '').replace(/^http:/, 'https:');
}

function $(selector, node = document) {
	return node.querySelector(selector);
}

function $$(selector, node = document) {
	return node.querySelectorAll(selector);
}

function $text(selector, node = document) {
	const e = typeof selector == 'string' ? node.querySelector(selector) : selector;
	return e ? e.textContent.trim() : '';
}

function $$remove(selector, node = document) {
	node.querySelectorAll(selector).forEach(e => e.remove());
}

function $appendChildren(newParent, elements) {
	const doc = newParent.ownerDocument;
	for (let e of elements)
		if (e)
		   newParent.appendChild(e.ownerDocument == doc ? e : doc.importNode(e, true));
}

function $replaceOrCreate(options) {
	if (options.length && typeof options[0] == 'object')
		return [].map.call(options, $replaceOrCreate);
    const doc = (options.parent || options.before).ownerDocument;
	const el = doc.getElementById(options.id) || doc.createElement(options.tag || 'div');
	for (let key of Object.keys(options)) {
		switch (key) {
			case 'tag':
			case 'parent':
			case 'before':
				break;
			case 'children':
				if (el.children.length)
					el.innerHTML = '';
				$appendChildren(el, options[key]);
				break;
			default:
				const value = options[key];
				if (key in el && el[key] != value)
					el[key] = value;
		}
	}
	if (!el.parentElement)
    	(options.parent || options.before.parentElement).insertBefore(el, options.before);
	return el;
}

function $scriptIn(element) {
	return element.appendChild(element.ownerDocument.createElement('script'));
}

function $on(eventName, ...args) {
// eventName, selector, node, callback, options
// eventName, selector, callback, options
// eventName, node, callback, options
// eventName, callback, options
	const selector = typeof args[0] == 'string' ? args[0] : null;
	const node = args[0] instanceof Node ? args[0] : args[1] instanceof Node ? args[1] : document;
	const callback = args[typeof args[0] == 'function' ? 0 : typeof args[1] == 'function' ? 1 : 2];
	const options = args[args.length - 1] != callback ? args[args.length - 1] : undefined;
	const method = this == 'removeEventListener' ? this : 'addEventListener';
	(selector ? node.querySelector(selector) : node)[method](eventName, callback, options);
}

function $off(eventName, ...args) {
	$on.apply('removeEventListener', arguments);
}

function log(...args) {
	console.log(GM_info.script.name, ...args);
}

function error(...args) {
	console.error(GM_info.script.name, ...args);
}

function tryCatch(fn) {
	try { return fn() }
	catch(e) {}
}

function initPolyfills(context = window) {
	for (let method of ['forEach', 'filter', 'map', 'every', context.Symbol.iterator])
		if (!context.NodeList.prototype[method])
			context.NodeList.prototype[method] = context.Array.prototype[method];
}

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-width: 8px;
			border-style: solid;
		}
	`
	+ Object.keys(COLORS).map(s => `
		#SEpreview[SEpreview-type="${s}"] {
			border-color: rgb(${COLORS[s].backRGB});
		}
	`).join('')
	);

	preview.stylesOverride = `
		body, html {
			min-width: unset!important;
			box-shadow: none!important;
			padding: 0!important;
			margin: 0!important;
		}
		html, body {
			background: unset!important;;
		}
		body {
			display: flex;
			flex-direction: column;
			height: 100vh;
		}
		#SEpreview-body a.SEpreviewable a.SEpreviewable {
			text-decoration: underline !important;
		}
		#SEpreview-title {
			all: unset;
			display: block;
			padding: 20px 30px;
			font-weight: bold;
			font-size: 18px;
			line-height: 1.2;
			cursor: pointer;
		}
		#SEpreview-title:hover {
			text-decoration: underline;
		}
		#SEpreview-meta {
			position: absolute;
			top: .5ex;
			left: 30px;
			opacity: 0.5;
		}
		#SEpreview-title:hover + #SEpreview-meta {
			opacity: 1.0;
		}

		#SEpreview-body {
			padding: 30px!important;
			overflow: auto;
			flex-grow: 2;
		}
		#SEpreview-body .post-menu {
			display: none!important;
		}
		#SEpreview-body > .question-status {
			margin: -10px -30px -30px;
			padding-left: 30px;
		}
		#SEpreview-body > .question-status h2 {
			font-weight: normal;
		}
		#SEpreview-body > a + .question-status {
			margin-top: 20px;
		}

		#SEpreview-answers {
			all: unset;
			display: block;
			padding: 10px 30px;
			font-weight: bold;
			line-height: 1.0;
			border-top: 4px solid rgba(${COLORS.answer.backRGB}, 0.37);
			background-color: rgba(${COLORS.answer.backRGB}, 0.37);
			color: ${COLORS.answer.fore};
			word-break: break-word;
		}
		#SEpreview-answers:before {
			content: "Answers:";
			margin-right: 1ex;
			font-size: 20px;
			line-height: 48px;
		}
		#SEpreview-answers a {
			color: ${COLORS.answer.fore};
			text-decoration: none;
			font-size: 11px;
			font-family: monospace;
			width: 32px;
			display: inline-block;
			vertical-align: top;
			margin: 1ex 1ex 0 0;
		}
		#SEpreview-answers img {
			width: 32px;
			height: 32px;
		}
		#SEpreview-answers .vote-accepted-on {
			position: absolute;
			margin: -12px 0 0 6px;
			filter: drop-shadow(1px 2px 1px rgba(0,0,0,1));
		}
		#SEpreview-answers a.deleted-answer {
			color: ${COLORS.deleted.fore};
			background: transparent;
			opacity: 0.25;
		}
		#SEpreview-answers a.deleted-answer:hover {
			opacity: 1.0;
		}
		#SEpreview-answers a:hover:not(.SEpreviewed) {
			text-decoration: underline;
		}
		#SEpreview-answers a.SEpreviewed {
			background-color: ${COLORS.answer.fore};
			color: ${COLORS.answer.foreInv};
			position: relative;
		}
		#SEpreview-answers a.SEpreviewed:after {
			display: block;
			content: " ";
			position: absolute;
			left: -4px;
			top: -4px;
			right: -4px;
			bottom: -4px;
			border: 4px solid ${COLORS.answer.fore};
		}

		.delete-tag,
		.comment-actions td:last-child {
			display: none;
		}
		.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}
		}
	`
	+ Object.keys(COLORS).map(s => `
		body[SEpreview-type="${s}"] #SEpreview-title {
			background-color: rgba(${COLORS[s].backRGB}, 0.37);
			color: ${COLORS[s].fore};
		}
		body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar {
			background-color: rgba(${COLORS[s].backRGB}, 0.1); }
		body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb {
			background-color: rgba(${COLORS[s].backRGB}, 0.2); }
		body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb:hover {
			background-color: rgba(${COLORS[s].backRGB}, 0.3); }
		body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb:active {
			background-color: rgba(${COLORS[s].backRGB}, 0.75); }
	`).join('')
	+ ['deleted', 'closed'].map(s => `
		body[SEpreview-type="${s}"] #SEpreview-answers {
			border-top-color: rgba(${COLORS[s].backRGB}, 0.37);
			background-color: rgba(${COLORS[s].backRGB}, 0.37);
			color: ${COLORS[s].fore};
		}
		body[SEpreview-type="${s}"] #SEpreview-answers a.SEpreviewed {
			background-color: ${COLORS[s].fore};
			color: ${COLORS[s].foreInv};
		}
		body[SEpreview-type="${s}"] #SEpreview-answers a.SEpreviewed:after {
			border-color: ${COLORS[s].fore};
		}
	`).join('');
}