SE Preview on hover

Shows preview of the linked questions/answers on hover

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

您需要先安装一个扩展,例如 篡改猴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.3.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/*
// @include        /https?:\/\/www\.?google(\.com?)?(\.\w\w)?\/(webhp|q|.*?[?#]q=|#q|search).*/
// @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(194, 136, 0)',
		foreInv: 'white',
	},
};

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

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 (hasKeyModifier(e))
		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'))
			preview.frame.contentWindow.postMessage('SEpreview-hidden', '*');
	}, PREVIEW_DELAY * 3, this);
	if (xhr)
		xhr.abort();
}

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

function fadeOut(element, transition) {
	return new Promise(resolve => {
		if (transition) {
			element.style.transition = typeof transition == 'number' ? `opacity ${transition}s ease-in-out` : transition;
			setTimeout(doFadeOut);
		} else
			doFadeOut();

		function doFadeOut() {
			element.style.opacity = '0';
			$on('transitionend', element, function done() {
				$off('transitionend', element, done);
				if (element.style.opacity == '0')
					element.style.display = 'none';
				resolve();
			});
		}
	});
}

function fadeIn(element) {
	element.style.opacity = '0';
	element.style.display = 'block';
	setTimeout(() => element.style.opacity = '1');
}

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

function initPreview() {
	preview.frame = document.createElement('iframe');
	preview.frame.id = 'SEpreview';
	document.body.appendChild(preview.frame);

	lockScroll.attach = e => {
		if (lockScroll.pos)
			return;
		lockScroll.pos = {x: scrollX, y: scrollY};
		$on('scroll', document, lockScroll.run);
		$on('mouseover', document, lockScroll.detach);
	};
	lockScroll.run = e => scrollTo(lockScroll.pos.x, lockScroll.pos.y);
	lockScroll.detach = e => {
		if (!lockScroll.pos)
			return;
		lockScroll.pos = null;
		$off('mouseout', document, lockScroll.detach);
		$off('scroll', document, lockScroll.run);
	};
}

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

	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) : null;
	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 = tryCatch(() => new Date($('.lastactivity-link', doc).title).getTime()) || Date.now();

	markPreviewableLinks(doc);
	$$remove('script', doc);

	if (!preview.frame)
		initPreview();

	let pvDoc, pvWin;
	preview.frame.style.display = '';
	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 markPreviewableLinks(container) {
		for (let link of $$('a:not(.SEpreviewable)', container)) {
			if (rxPreviewable.test(link.href)) {
				link.removeAttribute('title');
				link.classList.add('SEpreviewable');
			}
		}
	}

	function addStyles() {
		const SEpreviewStyles = $replaceOrCreate({
    		id: 'SEpreviewStyles',
			tag: 'style', parent: pvDoc.head, className: 'SEpreview-reuse',
			innerHTML: preview.stylesOverride,
		});
		$replaceOrCreate($$('style', doc).map(e => ({
			id: 'SEpreview' + e.innerHTML.replace(/\W+/g, '').length,
			tag: 'style', before: SEpreviewStyles, className: 'SEpreview-reuse',
			innerHTML: e.innerHTML,
		})));
		return onStyleSheetsReady(
			$replaceOrCreate($$('link[rel="stylesheet"]', doc).map(e => ({
				id: e.href.replace(/\W+/g, ''),
				tag: 'link', before: SEpreviewStyles, className: 'SEpreview-reuse',
				href: e.href, rel: 'stylesheet',
			})))
		);
	}

	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,
		}, {
		// close button
			id: 'SEpreview-close',
			parent: pvDoc.body,
			title: 'Or press Esc key while the preview is focused (also when just shown)',
		}, {
		// 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: [status, post.parentElement, comments, commentsShowLink],
		}]);

		// prettify code blocks
		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()';
		}

		// 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 renderShelfAnswer(e) {
		const shortUrl = $('.short-link', e).href.replace(/(\d+)\/\d+/, '$1');
		const extraClasses = (e.matches(postId) ? ' SEpreviewed' : '') +
		      (e.matches('.deleted-answer') ? ' deleted-answer' : '') +
			  ($('.vote-accepted-on', e) ? ' SEpreview-accepted' : '');
		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);
		return (
			`<a href="${shortUrl}" title="${title}" class="SEpreviewable${extraClasses}">` +
				$text('.vote-count-post', e).replace(/^0$/, '&nbsp;') + ' ' +
				(!gravatar ? '' : gravatar.src ? `<img src="${gravatar.src}">` : gravatar.outerHTML) +
			'</a>');
	}

	function show() {
		pvDoc.onmouseover = lockScroll.attach;
		pvDoc.onclick = onClick;
		pvDoc.onkeydown = e => !hasKeyModifier(e) && e.keyCode == 27 && hide();
		pvWin.onmessage = e => e.data == 'SEpreview-hidden' && hide({fade: true});
		$$('.user-info a img', pvDoc).forEach(e => e.onmouseover = loadUserDetails);
		$('#SEpreview-body', pvDoc).scrollTop = 0;
		preview.frame.style.opacity = '1';
		preview.frame.focus();
	}

	function hide({fade = false} = {}) {
		releaseLinkListeners();
		releasePreviewListeners();
		const maybeZap = () => preview.frame.style.opacity == '0' && $removeChildren(pvDoc.body);
		if (fade)
			fadeOut(preview.frame).then(maybeZap);
		else {
			preview.frame.style.opacity = '0';
			preview.frame.style.display = 'none';
			maybeZap();
		}
	}

	function releasePreviewListeners(e) {
		pvWin.onmessage = null;
		pvDoc.onmouseover = null;
		pvDoc.onclick = null;
		pvDoc.onkeydown = null;
	}

	function onClick(e) {
		if (e.target.id == 'SEpreview-close')
			return hide();

		const link = e.target.closest('a');
		if (!link)
			return;

		if (link.matches('.js-show-link.comments-link')) {
			fadeOut(link, 0.5);
			loadComments();
			return e.preventDefault();
		}

		if (e.button || hasKeyModifier(e) || !link.matches('.SEpreviewable'))
			return (link.target = '_blank');

		e.preventDefault();

		if (link.id == 'SEpreview-title')
			showPreview({doc, finalUrl: finalUrlOfQuestion});
		else if (link.matches('#SEpreview-answers a'))
			showPreview({doc, finalUrl: finalUrlOfQuestion + '/' + link.pathname.match(/\/(\d+)/)[1]});
		else
			downloadPreview(link.href);
	}

	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;
				tbody.closest('.comments').style.display = 'block';
				for (let tr of tbody.rows)
					if (!oldIds.has(tr.id))
						tr.classList.add('new-comment-highlight');
				markPreviewableLinks(tbody);
			},
		});
	}

	function loadUserDetails(e, ready) {
		if (ready !== true)
			return setTimeout(loadUserDetails, PREVIEW_DELAY, e, true);
		$$('#user-menu', pvDoc).forEach(e => e.id = '');
		const userId = e.target.closest('a').pathname.match(/\d+/)[0];
		const existing = $(`.SEpreview-user[data-id="${userId}"]`, pvDoc);
		if (existing) {
			existing.id = 'user-menu';
			fadeIn(existing);
			return;
		}
		GM_xmlhttpRequest({
			method: 'GET',
			url: new URL(finalUrl).origin + '/users/user-info/' + userId,
			onload: r => {
				let userMenu = $replaceOrCreate({
					id: 'user-menu',
					dataset: {id: userId},
					parent: e.target.closest('.user-info'),
					className: 'SEpreview-user',
					innerHTML: r.responseText,
				});
				userMenu.onmouseout = e => e.target == userMenu && fadeOut(userMenu);
				userMenu.onmouseover = e => e.target == userMenu && (userMenu.style.opacity = '1');
				fadeIn(userMenu);
			},
		});
	}
}

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) {
	let retryCount = 0;
	return new Promise(function retry(resolve) {
		if (linkElements.every(e => e.sheet && e.sheet.href == e.href))
			resolve();
		else if (retryCount++ > 10)
			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|posts\/comments)/\\d+');
}

function isLinkPreviewable(link) {
	const inPreview = preview.frame && link.ownerDocument == preview.frame.contentDocument;
	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.indexOf(pageUrls.base) &&
		   url.indexOf(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;
	const fragment = doc.createDocumentFragment();
	for (let e of elements)
		if (e)
		   fragment.appendChild(e.ownerDocument == doc ? e : doc.importNode(e, true));
	newParent.appendChild(fragment);
}

function $removeChildren(el) {
	if (!el.children.length)
		return;
	const range = new Range();
	range.selectNodeContents(el);
	range.deleteContents();
}

function $replaceOrCreate(options) {
	if (typeof options.map == 'function')
		return options.map($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)) {
		const value = options[key];
		switch (key) {
			case 'tag':
			case 'parent':
			case 'before':
				break;
			case 'dataset':
				for (let dataAttr of Object.keys(value))
					if (el.dataset[dataAttr] != value[dataAttr])
						el.dataset[dataAttr] = value[dataAttr];
				break;
			case 'children':
				$removeChildren(el);
				$appendChildren(el, options[key]);
				break;
			default:
				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].nodeType ? args[0] : args[1].nodeType ? 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 hasKeyModifier(e) {
	return e.ctrlKey || e.altKey || e.shiftKey || e.metaKey;
}

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: 400px;
			position: fixed;
			opacity: 0;
			transition: opacity .25s 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 {
			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-close {
			position: absolute;
			top: 0;
			right: 0;
			flex: none;
			cursor: pointer;
			padding: .5ex 1ex;
		}
		#SEpreview-close:after {
			content: "x"; }
		#SEpreview-close:active {
			background-color: rgba(0,0,0,.1); }
		#SEpreview-close:hover {
			background-color: rgba(0,0,0,.05); }

		#SEpreview-body {
			padding: 30px!important;
			overflow: auto;
			flex-grow: 2;
		}
		#SEpreview-body .post-menu {
			display: none!important;
		}
		#SEpreview-body > .question-status {
			margin: -30px -30px 30px;
			padding-left: 30px;
		}
		#SEpreview-body .question-originals-of-duplicate {
			margin: -30px -30px 30px;
			padding: 15px 30px;
		}
		#SEpreview-body > .question-status h2 {
			font-weight: normal;
		}

		#SEpreview-answers {
			all: unset;
			display: block;
			padding: 10px 10px 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: 0 1ex 1ex  0;
		}
		#SEpreview-answers img {
			width: 32px;
			height: 32px;
		}
		.SEpreview-accepted {
			position: relative;
		}
		.SEpreview-accepted:after {
			content: "✔";
			position: absolute;
			display: block;
			top: 1.3ex;
			right: -0.7ex;
			font-size: 32px;
			color: #4bff2c;
			text-shadow: 1px 2px 2px rgba(0,0,0,0.5);
		}
		#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};
		}

		.comment-edit,
		.delete-tag,
		.comment-actions td:last-child {
			display: none;
		}
		.comments {
			border-top: none;
		}
		.comments tr:last-child td {
			border-bottom: 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);
		}

		.user-info {
			position: relative;
		}
		#user-menu {
			position: absolute;
		}
		.SEpreview-user {
			position: absolute;
			right: -1em;
			top: -2em;
			transition: opacity .25s ease-in-out;
			opacity: 0;
			display: none;
		}

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