Greasy Fork is available in English.

SE Preview on hover

Shows preview of the linked questions/answers on hover

Version vom 27.02.2018. Aktuellste Version

// ==UserScript==
// @name           SE Preview on hover
// @description    Shows preview of the linked questions/answers on hover
// @version        0.5.9
// @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://greasyfork.org/scripts/12228/code/setMutationHandler.js
// @require        https://greasyfork.org/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 BUSY_CURSOR_DELAY = 1000;
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,
	timerCursor: 0,
	stylesOverride: '',
};
const lockScroll = {};

const {full: rxPreviewable, siteOnly: rxPreviewableSite} = getURLregexForMatchedSites();
const thisPageUrls = getPageBaseUrls(location.href);

initStyles();
initPolyfills();
setMutationHandler('a, .question-summary .answered, .question-summary .answered-accepted', onLinkAdded, {processExisting: true});
setTimeout(cleanupCache, 10000);

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

function onLinkAdded(links) {
	for (let i = 0, link; (link = links[i++]); ) {
		if (link.localName != 'a' || 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);
		if (link.localName != 'a')
			link.href = $('a', link.closest('.question-summary')).href;
		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();
	if (this.style.cursor == 'wait')
		this.style.cursor = '';
}

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

function stopTimers(names) {
	for (let k in preview) {
		if (k.startsWith('timer') && preview[k]) {
			clearTimeout(preview[k]);
			preview[k] = 0;
		}
	}
}

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 showAnswers = link.localName != 'a';
	const cached = readCache(link.href);
	if (cached)
		return showPreview(Object.assign(cached, {showAnswers}));

	preview.timerCursor = setTimeout(() => {
		preview.timerCursor = 0;
		link.style.cursor = 'wait';
	}, BUSY_CURSOR_DELAY);

	doXHR(link.href).then(r => {
		const html = r.responseText;
		const finalUrl = r.finalUrl;
		if (link.matches(':hover') || preview.frame && preview.frame.matches(':hover'))
			return {
				html,
				finalUrl,
				lastActivity: showPreview({finalUrl, html, showAnswers}),
			};
	}).then(({html, finalUrl, lastActivity} = {}) => {
		if (preview.timerCursor)
			clearTimeout(preview.timerCursor), preview.timerCursor = 0;
		if (link.style.cursor == 'wait')
			link.style.cursor = '';
		if (lastActivity) {
			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, 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, showAnswers}) {
	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 = !showAnswers
		? finalUrl.match(/questions\/\d+\/[^\/]+\/(\d+)/)
		: [0, ($('[id^="answer-"]', doc) || {id:''}).id.replace(/^answer-/, '')];
	const isQuestion = !answerIdMatch;
	const postNumber = isQuestion ? finalUrl.match(/\d+/)[0] : answerIdMatch[1];
	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 = +$('[data-remaining-comments-count]', 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);

	return onFrameReady(preview.frame).then(
		() => {
			pvDoc = preview.frame.contentDocument;
			pvWin = preview.frame.contentWindow;
			initPolyfills(pvWin);
			preview.killInvaders.stop();
		})
		.then(addStyles)
		.then(render)
		.then(show)
		.then(() => 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('.post-layout')).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
		$('.post-menu', pvDoc).insertAdjacentHTML('beforeend',
			'<span class="lsep">|</span>' +
			`<a href="/posts/${postNumber}/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 list = $(`#${comments.id} .comments-list`, pvDoc);
			const oldIds = new Set([...list.children].map(e => e.id));
			list.innerHTML = r.responseText;
			list.closest('.comments').style.display = 'block';
			for (const cmt of list.children)
				if (!oldIds.has(cmt.id))
					cmt.classList.add('new-comment-highlight');
			markPreviewableLinks(list);
			markHoverableUsers(list);
		});
	}

	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) {
	const 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 = typeof options == 'string' ? {url: options} : 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 {
		options.url = options.url.replace(/^http:/, '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);
	console.trace();
}

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;
			text-decoration-skip: ink;
		}
		#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;
			text-decoration-skip: ink;
		}
		#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;
			text-decoration-skip: ink;
		}
		#SEpreview-answers a.SEpreviewed {
			background-color: ${COLORS.answer.fore};
			color: ${COLORS.answer.foreInv};
			position: relative;
		}
		#SEpreview-answers a.SEpreviewed:before {
			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 .comment-text {
			-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('');
}