SE Preview on hover

Shows preview of the linked questions/answers on hover

Fra 05.03.2017. Se den seneste versjonen.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name           SE Preview on hover
// @description    Shows preview of the linked questions/answers on hover
// @version        0.5.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/*
// @include        /https?:\/\/www\.?google(\.com?)?(\.\w\w)?\/(webhp|q|.*?[?#]q=|search).*/
// @match          *://*.bing.com/*
// @match          *://*.yahoo.com/*
// @match          *://*.yahoo.co.jp/*
// @match          *://*.yahoo.cn/*
// @include        /https?:\/\/(\w+\.)*yahoo.(com|\w\w(\.\w\w)?)\/.*/
// @require        https://greatest.deepsurf.us/scripts/12228/code/setMutationHandler.js
// @require        https://greatest.deepsurf.us/scripts/27531/code/LZString-2xspeedup.js
// @grant          GM_addStyle
// @grant          GM_xmlhttpRequest
// @grant          GM_getValue
// @grant          GM_setValue
// @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 MIN_HEIGHT = 400; // px
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;
const xhrNoSSL = new Set();
const preview = {
	frame: null,
	link: null,
	hover: {x:0, y:0},
	timer: 0,
	stylesOverride: '',
};
const lockScroll = {};

const {full: rxPreviewable, siteOnly: rxPreviewableSite} = 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 (hasKeyModifiers(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;
		if (!link.matches(':hover'))
			return releaseLinkListeners(link);
		$off('mousemove', link, onLinkMouseMove);
		downloadPreview(link);
	}, 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);
	if (preview.timer)
		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, done);
			$on('visibilitychange', done);
			function done(e) {
				$off('transitionend', element, done);
				$off('visibilitychange', 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(link) {
	const cached = readCache(link.href);
	if (cached)
		return showPreview(cached);
	doXHR({url: httpsUrl(link.href)}).then(r => {
		const html = r.responseText;
		const lastActivity = link.matches(':hover') ? +showPreview({finalUrl: r.finalUrl, html}) : Date.now();
		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);
		setTimeout(writeCache, 1000, {url: link.href, finalUrl: r.finalUrl, html, cacheDuration});
	});
}

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

	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('mouseover', document, lockScroll.detach);
		$off('scroll', document, lockScroll.run);
	};

	const killer = mutations => mutations.forEach(m => [...m.addedNodes].forEach(n => n.remove()));
	const killerMO = {
		head: new MutationObserver(killer),
		documentElement: new MutationObserver(killer),
	};
	preview.killInvaders = {
		start: () => Object.keys(killerMO).forEach(k => killerMO[k].observe(preview.frame.contentDocument[k], {childList: true})),
		stop: () => Object.keys(killerMO).forEach(k => killerMO[k].disconnect()),
	};
}

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();
	const answers = $$('.answer', doc);
	const hasAnswers = answers.length > (isQuestion ? 0 : 1);

	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');
	preview.frame.classList.toggle('SEpreview-hasAnswers', hasAnswers);

	onFrameReady(preview.frame).then(
		() => {
			pvDoc = preview.frame.contentDocument;
			pvWin = preview.frame.contentWindow;
			initPolyfills(pvWin);
			preview.killInvaders.stop();
		})
		.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 markHoverableUsers(container) {
		for (let link of $$('a[href*="/users/"]', container)) {
			if (rxPreviewableSite.test(link.href) && link.pathname.match(/^\/users\/\d+/)) {
				link.onmouseover = loadUserCard;
				link.classList.add('SEpreview-userLink');
			}
		}
	}

	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({
			doc: pvDoc,
			urls: $$('link[rel="stylesheet"]', doc).map(e => e.href),
			onBeforeRequest: preview.frame.style.opacity != '1' ? null : () => {
				preview.frame.style.transition = 'border-color .5s ease-in-out';
				$on('transitionend', preview.frame, () => preview.frame.style.transition = '', {once: true});
			},
		}).then(els => {
			els.forEach(e => e.className = 'SEpreview-reuse');
		});
	}

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

		$replaceOrCreate([{
		// base
			id: 'SEpreview-base', tag: 'base',
			parent: pvDoc.head,
            href: $('base', doc).href,
		}, {
		// 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],
		}]);

		// delinkify/remove non-functional items in post-menu
		$$remove('.short-link, .flag-post-link', pvDoc);
		$$('.post-menu a:not(.edit-post)', pvDoc).forEach(a => {
			if (a.children.length)
				a.outerHTML = `<span>${a.innerHTML}</span>`;
			else
				a.remove();
		});

		// add a timeline link
		if (isQuestion)
			$('.post-menu', pvDoc).insertAdjacentHTML('beforeend',
				'<span class="lsep">|</span>' +
				`<a href="/posts/${new URL(finalUrl).pathname.match(/\d+/)[0]}/timeline">timeline</a>`);

		// 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
		if (hasAnswers) {
			$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), .post-menu .lsep + .lsep', 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 => { if (!hasKeyModifiers(e) && e.keyCode == 27) hide() };
		pvWin.onmessage = e => { if (e.data == 'SEpreview-hidden') hide({fade: true}) };

		markHoverableUsers(pvDoc);
		preview.killInvaders.start();

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

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

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

	function loadComments() {
		const url = new URL(finalUrl).origin + '/posts/' + comments.id.match(/\d+/)[0] + '/comments';
		doXHR({url}).then(r => {
			const tbody = $(`#${comments.id} tbody`, pvDoc);
			const 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);
			markHoverableUsers(tbody);
		});
	}

	function loadUserCard(e, ready) {
		if (ready !== true)
			return setTimeout(loadUserCard, PREVIEW_DELAY * 2, e, true);
		const link = e.target.closest('a');
		if (!link.matches(':hover'))
			return;
		let timer;
		let userCard = link.nextElementSibling;
		if (userCard && userCard.matches('.SEpreview-userCard'))
			return fadeInUserCard();
		const url = link.origin + '/users/user-info/' + link.pathname.match(/\d+/)[0];

		Promise.resolve(
			readCache(url) ||
			doXHR({url}).then(r => {
				writeCache({url, html: r.responseText, cacheDuration: CACHE_DURATION * 100});
				return {html: r.responseText};
			})
		).then(renderUserCard);

		function renderUserCard({html}) {
			const linkBounds = link.getBoundingClientRect();
			const wrapperBounds = $('#SEpreview-body', pvDoc).getBoundingClientRect();
			userCard = $replaceOrCreate({id: 'user-menu-tmp', className: 'SEpreview-userCard', innerHTML: html, after: link});
			userCard.style.left = Math.min(linkBounds.left - 20, pvWin.innerWidth - 350) + 'px';
			if (linkBounds.bottom + 100 > wrapperBounds.bottom)
				userCard.style.marginTop = '-5rem';
			userCard.onmouseout = e => {
				if (e.target != userCard || userCard.contains(e.relatedTarget))
					if (e.relatedTarget) // null if mouse is outside the preview
						return;
				fadeOut(userCard);
				clearTimeout(timer);
				timer = 0;
			};
			fadeInUserCard();
		}

		function fadeInUserCard() {
			if (userCard.id != 'user-menu') {
				$$('#user-menu', pvDoc).forEach(e => e.id = e.style.display = '' );
				userCard.id = 'user-menu';
			}
			userCard.style.opacity = '0';
			userCard.style.display = 'block';
			timer = setTimeout(() => timer && (userCard.style.opacity = '1'));
		}
	}
}

function getCacheableUrl(url) {
	// strips queries 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 || url).replace(/[?#].*/, '');
	const keyUrl = getCacheableUrl(url);
	const keyFinalUrl = getCacheableUrl(finalUrl);
	const expires = Date.now() + cacheDuration;
	const lz = LZString.compressToUTF16(html);
	if (!tryCatch(() => localStorage[keyFinalUrl + '\thtml'] = lz)) {
		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({urls, doc = document, onBeforeRequest = null}) {
	return Promise.all(
		urls.map(url => $(`link[href="${url}"]`, doc) || new Promise(resolve => {
			if (typeof onBeforeRequest == 'function')
				onBeforeRequest(url);
			doXHR({url}).then(() => {
				const sheetElement = $replaceOrCreate({tag: 'link', href: url, rel: 'stylesheet', parent: doc.head});
				const timeout = setTimeout(doResolve, 100);
				sheetElement.onload = doResolve;
				function doResolve() {
					sheetElement.onload = null;
					clearTimeout(timeout);
					resolve(sheetElement);
				}
			});
		}))
	);
}

function getURLregexForMatchedSites() {
	const sites = 'https?://(\\w*\\.)*(' + GM_info.script.matches.map(
		m => m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.')).join('|') + ')/';
	return {
		full: new RegExp(sites + '(questions|q|a|posts\/comments)/\\d+'),
		siteOnly: new RegExp(sites),
	};
}

function isLinkPreviewable(link) {
	if (!rxPreviewable.test(link.href) || link.matches('.short-link'))
		return false;
	const inPreview = preview.frame && link.ownerDocument == preview.frame.contentDocument;
	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 doXHR(options) {
	options = Object.assign({method: 'GET'}, options);
	const useHttpUrl = () => options.url = options.url.replace(/^https/, 'http');
	const hostname = new URL(options.url).hostname;
	if (xhrNoSSL.has(hostname))
		useHttpUrl();
	else if (options.url.startsWith('https')) {
		options.onerror = e => {
			useHttpUrl();
			xhrNoSSL.add(hostname);
			xhr = GM_xmlhttpRequest(options);
		};
	}
	if (options.onload)
		return (xhr = GM_xmlhttpRequest(options));
	else
		return new Promise(resolve => {
			xhr = GM_xmlhttpRequest(Object.assign(options, {onload: resolve}));
		});
}

function makeResizable() {
	let heightOnClick;
	const pvDoc = preview.frame.contentDocument;
	const topBorderHeight = (preview.frame.offsetHeight - preview.frame.clientHeight) / 2;
	setHeight(GM_getValue('height', innerHeight / 3) |0);

	// mouseover in the main page is fired only on the border of the iframe
	$on('mouseover', preview.frame, onOverAttach);
	$on('message', preview.frame.contentWindow, e => {
		if (e.data != 'SEpreview-hidden')
			return;
		if (heightOnClick) {
			releaseResizeListeners();
			setHeight(heightOnClick);
		}
		if (preview.frame.style.cursor)
			onOutDetach();
	});

	function setCursorStyle(e) {
		return (preview.frame.style.cursor = e.offsetY <= 0 ? 's-resize' : '');
	}

	function onOverAttach(e) {
		setCursorStyle(e);
		$on('mouseout', preview.frame, onOutDetach);
		$on('mousemove', preview.frame, setCursorStyle);
		$on('mousedown', onDownStartResize);
	}

	function onOutDetach(e) {
		if (!e || !e.relatedTarget || !pvDoc.contains(e.relatedTarget)) {
			$off('mouseout', preview.frame, onOutDetach);
			$off('mousemove', preview.frame, setCursorStyle);
			$off('mousedown', onDownStartResize);
			preview.frame.style.cursor = '';
		}
	}

	function onDownStartResize(e) {
		if (!preview.frame.style.cursor)
			return;
		heightOnClick = preview.frame.clientHeight;

		$off('mouseover', preview.frame, onOverAttach);
		$off('mousemove', preview.frame, setCursorStyle);
		$off('mouseout', preview.frame, onOutDetach);

		document.documentElement.style.cursor = 's-resize';
		document.body.style.cssText += ';pointer-events: none!important';
		$on('mousemove', onMoveResize);
		$on('mouseup',  onUpConfirm);
	}

	function onMoveResize(e) {
		setHeight(innerHeight - topBorderHeight - e.clientY);
		getSelection().removeAllRanges();
		preview.frame.contentWindow.getSelection().removeAllRanges();
	}

	function onUpConfirm(e) {
		GM_setValue('height', pvDoc.body.clientHeight);
		releaseResizeListeners(e);
	}

	function releaseResizeListeners() {
		$off('mouseup', releaseResizeListeners);
		$off('mousemove', onMoveResize);

		$on('mouseover', preview.frame, onOverAttach);
		onOverAttach({});

		document.body.style.pointerEvents = '';
		document.documentElement.style.cursor = '';
		heightOnClick = 0;
	}
}

function setHeight(height) {
	const currentHeight = preview.frame.clientHeight;
	const borderHeight = preview.frame.offsetHeight - currentHeight;
	const newHeight = Math.max(MIN_HEIGHT, Math.min(innerHeight - borderHeight, height));
	if (newHeight != currentHeight)
		preview.frame.style.height = newHeight + 'px';
}

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)
		el.innerHTML = ''; // the fastest as per https://jsperf.com/innerhtml-vs-removechild/256
}

function $replaceOrCreate(options) {
	if (typeof options.map == 'function')
		return options.map($replaceOrCreate);
    const doc = (options.parent || options.before || options.after).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':
			case 'after':
				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 || options.after).parentElement)
			.insertBefore(el, options.before || (options.after && options.after.nextElementSibling));
	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
	let i = 0;
	const selector = typeof args[i] == 'string' ? args[i++] : null;
	const node = args[i].nodeType ? args[i++] : document;
	const callback = args[i++];
	const options = args[i];

	const actualNode = selector ? node.querySelector(selector) : node;
	const method = this == 'removeEventListener' ? this : 'addEventListener';
	actualNode[method](eventName, callback, options);
}

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

function hasKeyModifiers(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', 'some', 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: ${MIN_HEIGHT}px;
			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;
			border-color: transparent;
		}
		#SEpreview[SEpreview-type="question"].SEpreview-hasAnswers {
			border-image: linear-gradient(rgb(${COLORS.question.backRGB}) 66%, rgb(${COLORS.answer.backRGB})) 1 1;
		}
	`
	+ Object.keys(COLORS).map(s => `
		#SEpreview[SEpreview-type="${s}"] {
			border-color: rgb(${COLORS[s].backRGB});
		}
	`).join('')
	);

	preview.stylesOverride = `
		html, body {
			min-width: unset!important;
			box-shadow: none!important;
			padding: 0!important;
			margin: 0!important;
			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 {
			position: relative;
			padding: 30px!important;
			overflow: auto;
			flex-grow: 2;
		}
		#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};
		}

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

		#SEpreview-body .post-menu > span {
			opacity: .35;
		}
		#SEpreview-body #user-menu {
			position: absolute;
		}
		.SEpreview-userCard {
			position: absolute;
			display: none;
			transition: opacity .25s cubic-bezier(.88,.02,.92,.66) .5s;
			margin-top: -3rem;
		}

		#SEpreview-body .wmd-preview a:not(.post-tag),
		#SEpreview-body .post-text a:not(.post-tag),
		#SEpreview-body .comment-copy a:not(.post-tag) {
			border-bottom: 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('');
}