// ==UserScript==
// @name SE Preview on hover
// @description Shows preview of the linked questions/answers on hover
// @version 1.1.7
// @author wOxxOm
// @namespace wOxxOm.scripts
// @license MIT License
//
// please use only matches for the previewable targets and make sure the domain
// is extractable via [-.\w] so that it starts with . like .stackoverflow.com
// @match *://*.stackoverflow.com/*
// @match *://*.superuser.com/*
// @match *://*.serverfault.com/*
// @match *://*.askubuntu.com/*
// @match *://*.stackapps.com/*
// @match *://*.mathoverflow.net/*
// @match *://*.stackexchange.com/*
// stackexchange.com must be the last main site
//
// @include /https?:\/\/(www\.)?google(\.com?)?(\.\w\w)?\/(webhp|q|.*?[?#]q=|search).*/
// @match *://www.google.com/search*
// @match *://*.bing.com/*
// @match *://*.yahoo.com/*
// @include /https?:\/\/(\w+\.)*yahoo.(com|\w\w(\.\w\w)?)\/.*/
//
// @require https://cdn.jsdelivr.net/gh/openstyles/lz-string-unsafe@22af192175b5e1707f49c57de7ce942d4d4ad480/lz-string-unsafe.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/highlight.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/autohotkey.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/autoit.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/dart.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/delphi.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/haskell.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/moonscript.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/nsis.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/powershell.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/r.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/vbnet.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/vbscript-html.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/vbscript.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/x86asm.min.js
// @resource HL-style https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/styles/default.min.css
// @resource HL-style-dark https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/styles/atom-one-dark-reasonable.min.css
//
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_getResourceText
//
// @connect stackoverflow.com
// @connect superuser.com
// @connect serverfault.com
// @connect askubuntu.com
// @connect stackapps.com
// @connect mathoverflow.net
// @connect stackexchange.com
// @connect sstatic.net
// @connect gravatar.com
// @connect imgur.com
// @connect self
//
// @noframes
// @run-at document-idle
// ==/UserScript==
/* global hljs LZStringUnsafe */
'use strict';
Promise.resolve().then(() => {
Detector.init();
Security.init();
Urler.init();
Cache.init();
});
const PREVIEW_DELAY = 200;
const AUTOHIDE_DELAY = 1000;
const BUSY_CURSOR_DELAY = 300;
// 1 minute for the recently active posts, scales up logarithmically
const CACHE_DURATION = 60e3;
const PADDING = 24;
const PROSE_WIDTH = 660; // .s-prose selector
const PROSE_MARGIN = 16; // .s-prose margin-right
const WIDTH = PROSE_WIDTH + PADDING * 2;
const BORDER = 8;
const TOP_BORDER = 24;
const MIN_HEIGHT = 200;
let colors;
const COLORS_LIGHT = {
body: {
back: '#ffffff',
fore: '#000000',
},
question: {
back: '#5894d8',
fore: '#265184',
foreInv: '#fff',
},
answer: {
back: '#70c350',
fore: '#3f7722',
foreInv: '#fff',
},
deleted: {
back: '#cd9898',
fore: '#b56767',
foreInv: '#fff',
},
closed: {
back: '#ffce5d',
fore: '#c28800',
foreInv: '#fff',
},
};
const COLORS_DARK = {
body: {
back: '#222222',
fore: '#cccccc',
},
question: {
back: '#004696',
fore: '#6abaff',
foreInv: '#004696',
},
answer: {
back: '#004c1b',
fore: '#39c466',
foreInv: '#004c1b',
},
deleted: {
back: '#4d0a0b',
fore: '#b56767',
foreInv: '#fff',
},
closed: {
back: '#4b360a',
fore: '#c28800',
foreInv: '#fff',
},
};
const ID = 'SEpreview';
const EXPANDO = Symbol(ID);
const pv = {
/** @type {Target} */
target: null,
/** @type {Element} */
_frame: null,
/** @type {Element} */
get frame() {
if (!this._frame)
Preview.init();
if (!document.contains(this._frame))
document.body.appendChild(this._frame);
return this._frame;
},
set frame(element) {
this._frame = element;
return element;
},
/** @type {Post} */
post: {},
hover: {x: 0, y: 0},
stylesOverride: '',
};
class Detector {
static init() {
const {matches} = GM_info.script;
const sites = matches
.slice(0, matches.findIndex(m => m.includes('stackexchange.com')) + 1)
.map(m => m.match(/[-.\w]+/)[0]);
const rxsSites = 'https?://(\\w*\\.)*(' +
matches
.map(m => m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.'))
.join('|') +
')/';
Detector.rxPreviewableSite = new RegExp(rxsSites);
Detector.rxPreviewablePost = new RegExp(rxsSites + '(questions|q|a|posts/comments)/\\d+');
Detector.pageUrls = getBaseUrls(location, Detector.rxPreviewablePost);
Detector.isStackExchangePage = Detector.rxPreviewableSite.test(location);
const {
rxPreviewablePost,
isStackExchangePage: isSE,
pageUrls: {base, baseShort},
} = Detector;
// array of target elements accumulated in mutation observer
// cleared in attachHoverListener
const moQueue = [];
onMutation([{
addedNodes: [document.body],
}]);
new MutationObserver(onMutation)
.observe(document.body, {
childList: true,
subtree: true,
});
Detector.init = true;
function onMutation(mutations) {
const alreadyScheduled = moQueue.length > 0;
for (const {addedNodes} of mutations) {
for (const n of addedNodes) {
if (!n.localName)
continue;
if (n.localName === 'a') {
moQueue.push(n);
continue;
}
// not using ..spreading since there could be 100k links for all we know
// and that might exceed JS engine stack limit which can be pretty low
const targets = n.getElementsByTagName('a');
for (let k = 0, len = targets.length; k < len; k++)
moQueue.push(targets[k]);
if (!isSE)
continue;
if (n.classList.contains('question-summary')) {
moQueue.push(...n.getElementsByClassName('answered'));
moQueue.push(...n.getElementsByClassName('answered-accepted'));
continue;
}
for (const el of n.getElementsByClassName('question-summary')) {
moQueue.push(...el.getElementsByClassName('answered'));
moQueue.push(...el.getElementsByClassName('answered-accepted'));
}
}
}
if (!alreadyScheduled && moQueue.length)
setTimeout(hoverize);
}
function hoverize() {
for (const el of moQueue) {
if (el[EXPANDO] instanceof Target)
continue;
if (el.localName === 'a') {
if (isSE && el.classList.contains('js-share-link'))
continue;
const previewable = isPreviewable(el) || !isSE && isEmbeddedUrlPreviewable(el);
if (!previewable)
continue;
const url = Urler.makeHttps(el.href);
if (url.startsWith(base) || url.startsWith(baseShort))
continue;
}
Target.createHoverable(el);
}
moQueue.length = 0;
}
function isPreviewable(a) {
let href = false;
const host = '.' + a.hostname;
const hostLen = host.length;
for (const stackSite of sites) {
if (host[hostLen - stackSite.length] === '.' &&
host.endsWith(stackSite) &&
rxPreviewablePost.test(href || (href = a.href)))
return true;
}
}
function isEmbeddedUrlPreviewable(a) {
const url = a.href;
let i = url.indexOf('http', 1);
if (i < 0)
return false;
i = (
url.indexOf('http://', i) + 1 ||
url.indexOf('https://', i) + 1 ||
url.indexOf('http%3A%2F%2F', i) + 1 ||
url.indexOf('https%3A%2F%2F', i) + 1
) - 1;
if (i < 0)
return false;
const j = url.indexOf('&', i);
const embeddedUrl = url.slice(i, j > 0 ? j : undefined);
return rxPreviewablePost.test(embeddedUrl);
}
function getBaseUrls(url, rx) {
if (!rx.test(url))
return {};
const base = Urler.makeHttps(RegExp.lastMatch);
return {
base,
baseShort: base.replace('/questions/', '/q/'),
};
}
}
}
/**
* @property {Element} element
* @property {Boolean} isLink
* @property {String} url
* @property {Number} timer
* @property {Number} timerCursor
* @property {String} savedCursor
*/
class Target {
/** @param {Element} el */
static createHoverable(el) {
const target = new Target(el);
Object.defineProperty(el, EXPANDO, {value: target});
el.removeAttribute('title');
el.addEventListener('mouseover', Target._onMouseOver);
return target;
}
/** @param {Element} el */
constructor(el) {
this.element = el;
this.isLink = el.localName === 'a';
}
release() {
$.off('mousemove', this.element, Target._onMove);
$.off('mouseout', this.element, Target._onHoverEnd);
$.off('mousedown', this.element, Target._onHoverEnd);
for (const k in this) {
if (k.startsWith('timer') && this[k] >= 1) {
clearTimeout(this[k]);
this[k] = 0;
}
}
BusyCursor.hide(this);
pv.target = null;
}
get url() {
const el = this.element;
if (this.isLink)
return el.href;
const a = $('a', el.closest('.question-summary'));
if (a)
return a.href;
}
/** @param {MouseEvent} e */
static _onMouseOver(e) {
if (Util.hasKeyModifiers(e))
return;
const self = /** @type {Target} */ this[EXPANDO];
if (self === Preview.target && Preview.shown() ||
self === pv.target)
return;
if (pv.target)
pv.target.release();
pv.target = self;
pv.hover.x = e.pageX;
pv.hover.y = e.pageY;
$.on('mousemove', this, Target._onMove);
$.on('mouseout', this, Target._onHoverEnd);
$.on('mousedown', this, Target._onHoverEnd);
Target._restartTimer(self);
}
/** @param {MouseEvent} e */
static _onHoverEnd(e) {
if (e.type === 'mouseout' && e.target !== this)
return;
const self = /** @type {Target} */ this[EXPANDO];
if (pv.xhr && pv.target === self) {
pv.xhr.abort();
pv.xhr = null;
}
self.release();
self.timer = setTimeout(Target._onAbortTimer, AUTOHIDE_DELAY, self);
}
/** @param {MouseEvent} e */
static _onMove(e) {
const stoppedMoving =
Math.abs(pv.hover.x - e.pageX) < 2 &&
Math.abs(pv.hover.y - e.pageY) < 2;
if (stoppedMoving) {
pv.hover.x = e.pageX;
pv.hover.y = e.pageY;
Target._restartTimer(this[EXPANDO]);
}
}
/** @param {Target} self */
static _restartTimer(self) {
if (self.timer)
clearTimeout(self.timer);
self.timer = setTimeout(Target._onTimer, PREVIEW_DELAY, self);
}
/** @param {Target} self */
static _onTimer(self) {
self.timer = 0;
const el = self.element;
if (!el.matches(':hover')) {
self.release();
return;
}
$.off('mousemove', el, Target._onMove);
if (self.url)
Preview.start(self);
}
/** @param {Target} self */
static _onAbortTimer(self) {
if ((self === pv.target || self === Preview.target) &&
pv.frame && !pv.frame.matches(':hover')) {
pv.target = null;
Preview.hide({fade: true});
}
}
}
class BusyCursor {
/** @param {Target} target */
static schedule(target) {
target.timerCursor = setTimeout(BusyCursor._onTimer, BUSY_CURSOR_DELAY, target);
}
/** @param {Target} target */
static hide(target) {
if (target.timerCursor) {
clearTimeout(target.timerCursor);
target.timerCursor = 0;
}
const style = target.element.style;
if (style.cursor === 'wait')
style.cursor = target.savedCursor;
}
/** @param {Target} target */
static _onTimer(target) {
target.timerCursor = 0;
target.savedCursor = target.element.style.cursor;
$.setStyle(target.element, ['cursor', 'wait']);
}
}
class Preview {
static init() {
pv.frame = $.create(`#${ID}`, {parent: document.body});
pv.shadow = pv.frame.attachShadow({mode: 'open'});
pv.body = $.create(`body#${ID}-body`, {parent: pv.shadow});
const WRAP_AROUND = '(or wrap around to the question)';
const TITLE_PREV = 'Previous answer\n' + WRAP_AROUND;
const TITLE_NEXT = 'Next answer\n' + WRAP_AROUND;
const TITLE_ENTER = 'Return to the question\n(Enter was Return initially)';
pv.answersTitle =
$.create(`#${ID}-answers-title`, [
'Answers:',
$.create('p', [
'Use ',
$.create('b', {title: TITLE_PREV}),
$.create('b', {title: TITLE_NEXT, attributes: {mirrored: ''}}),
$.create('label', {title: TITLE_ENTER}, 'Enter'),
' to switch entries',
]),
]);
$.on('keydown', pv.frame, Preview.onKey);
$.on('keyup', pv.frame, Util.consumeEsc);
$.on('mouseover', pv.body, ScrollLock.enable);
$.on('click', pv.body, Preview.onClick);
Sizer.init();
Styles.init();
Preview.init = true;
}
/** @param {Target} target */
static async start(target) {
Preview.target = target;
if (!Security.checked)
Security.check();
const {url} = target;
let data = Cache.read(url);
if (data) {
const r = await Urler.get(url, {method: 'HEAD'});
const postTime = Util.getResponseDate(r.responseHeaders);
if (postTime >= data.time)
data = null;
}
if (!data) {
BusyCursor.schedule(target);
const {finalUrl, responseText: html} = await Urler.get(target.url);
data = {finalUrl, html, unsaved: true};
BusyCursor.hide(target);
}
data.url = url;
data.showAnswer = !target.isLink;
if (!Preview.prepare(data))
Preview.target = null;
else if (data.unsaved && data.lastActivity >= 1)
Preview.save(data);
}
static save({url, finalUrl, html, lastActivity}) {
const inactiveDays = Math.max(0, (Date.now() - lastActivity) / (24 * 3600e3));
const cacheDuration = CACHE_DURATION * Math.pow(Math.log(inactiveDays + 1) + 1, 2);
setTimeout(Cache.write, 1000, {url, finalUrl, html, cacheDuration});
}
// data is mutated: its lastActivity property is assigned!
static prepare(data) {
const {finalUrl, html, showAnswer, doc = Util.parseHtml(html)} = data;
if (!doc || !doc.head)
return Util.error('no HEAD in the document received for', finalUrl);
let answerId;
if (showAnswer) {
const el = $('[id^="answer-"]', doc);
answerId = el && el.id.match(/\d+/)[0];
} else {
answerId = finalUrl.match(/questions\/\d+\/[^/]+\/(\d+)|$/)[1];
}
const selector = answerId ? '#answer-' + answerId : '#question';
const core = $(`${selector} .${answerId ? 'answer' : 'post'}cell`, doc);
if (!core)
return Util.error('No parsable post found', doc);
const isQuestion = !answerId;
const status = isQuestion && $('[role="status"]', core);
const isClosed = status && $('[href*="closed"]', status);
const isDeleted = Boolean(core.closest('.deleted-answer'));
const type = [
isQuestion && 'question' || 'answer',
isDeleted && 'deleted',
isClosed && 'closed',
].filter(Boolean).join(' ');
const answers = $.all('.answer', doc);
const comments = $(`${selector} .comments`, doc);
const commentsParent = comments.parentElement;
const showMoreComments = $(`${selector} .js-show-link.comments-link`, doc);
const lastActivity = Util.tryCatch(Util.extractTime, $('a[href*="?lastactivity"]', core)) ||
Date.now();
Object.assign(pv, {
finalUrl,
finalUrlOfQuestion: Urler.makeCacheable(finalUrl),
});
/** @typedef Post
* @property {Document} doc
* @property {String} html
* @property {String} selector
* @property {String} type
* @property {String} id
* @property {String} title
* @property {Boolean} isQuestion
* @property {Boolean} isDeleted
* @property {Number} lastActivity
* @property {Number} numAnswers
* @property {Element} core
* @property {Element} comments
* @property {Element[]} answers
* @property {Element[]} renderParts
*/
Object.assign(pv.post, {
doc,
html,
core,
selector,
answers,
comments,
type,
isQuestion,
isDeleted,
lastActivity,
id: isQuestion ? Urler.getFirstNumber(finalUrl) : answerId,
title: $('meta[property="og:title"]', doc).content,
numAnswers: answers.length,
renderParts: [
// including the parent so the right CSS kicks in
core,
commentsParent,
],
});
$.remove('script', doc);
// remove the comment actions block
$.remove('.comment-form, [id^="comments-link-"], .hover-only-label', commentsParent);
if (!commentsParent.contains(showMoreComments))
commentsParent.appendChild(showMoreComments);
// Expanding relative URLs manually since <base> may be restricted via CSP
for (const a of $.all('a[href]:not([href*=":"])', doc))
a.href = new URL(a.getAttribute('href'), finalUrl);
Promise.all([
pv.frame,
Preview.addStyles(),
Security.ready(),
]).then(Preview.show);
data.lastActivity = lastActivity;
return true;
}
static show() {
Render.all();
const style = getComputedStyle(pv.frame);
if (style.opacity !== '1' || style.display !== 'block') {
$.setStyle(pv.frame, ['display', 'block']);
setTimeout($.setStyle, 0, pv.frame, ['opacity', '1']);
}
pv.parts.focus();
}
static hide({fade = false} = {}) {
if (Preview.target) {
Preview.target.release();
Preview.target = null;
}
pv.body.onmouseover = null;
pv.body.onclick = null;
pv.body.onkeydown = null;
if (fade) {
Util.fadeOut(pv.frame)
.then(Preview.eraseBoxIfHidden);
} else {
$.setStyle(pv.frame,
['opacity', '0'],
['display', 'none']);
Preview.eraseBoxIfHidden();
}
}
static shown() {
return pv.frame.style.opacity === '1';
}
/** @param {KeyboardEvent} e */
static onKey(e) {
switch (e.key) {
case 'Escape':
Preview.hide({fade: true});
break;
case 'ArrowUp':
case 'PageUp':
if (pv.parts.scrollTop)
return;
break;
case 'ArrowDown':
case 'PageDown': {
const {scrollTop: t, clientHeight: h, scrollHeight} = pv.parts;
if (t + h < scrollHeight)
return;
break;
}
case 'ArrowLeft':
case 'ArrowRight': {
if (!pv.post.numAnswers)
return;
// current is 0 if isQuestion, 1 is the first answer
const answers = $.all(`#${ID}-answers a`);
const current = pv.post.numAnswers ?
answers.indexOf($('.SEpreviewed')) + 1 :
pv.post.isQuestion ? 0 : 1;
const num = pv.post.numAnswers + 1;
const dir = e.key === 'ArrowLeft' ? -1 : 1;
const toShow = (current + dir + num) % num;
const a = toShow ? answers[toShow - 1] : $(`#${ID}-title`);
a.click();
break;
}
case 'Enter':
if (pv.post.isQuestion)
return;
$(`#${ID}-title`).click();
break;
default:
return;
}
e.preventDefault();
}
/** @param {MouseEvent} e */
static onClick(e) {
if (e.target.id === `${ID}-close`) {
Preview.hide();
return;
}
const link = e.target.closest('a');
if (!link)
return;
if (link.matches('.js-show-link.comments-link')) {
Util.fadeOut(link, 0.5);
Preview.loadComments();
e.preventDefault();
return;
}
if (e.button ||
Util.hasKeyModifiers(e) ||
!link.matches('.SEpreviewable')) {
link.target = '_blank';
return;
}
e.preventDefault();
const {doc} = pv.post;
if (link.id === `${ID}-title`)
Preview.prepare({doc, finalUrl: pv.finalUrlOfQuestion});
else if (link.matches(`#${ID}-answers a`))
Preview.prepare({doc, finalUrl: pv.finalUrlOfQuestion + '/' + Urler.getFirstNumber(link)});
else
Preview.start(new Target(link));
}
static eraseBoxIfHidden() {
if (!Preview.shown())
pv.body.textContent = '';
}
static setHeight(height) {
const currentHeight = pv.frame.clientHeight;
const borderHeight = pv.frame.offsetHeight - currentHeight;
const newHeight = Math.max(MIN_HEIGHT, Math.min(innerHeight - borderHeight, height));
if (newHeight !== currentHeight)
$.setStyle(pv.frame, ['height', newHeight + 'px']);
}
static async addStyles() {
const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
colors = isDark ? COLORS_DARK : COLORS_LIGHT;
pv.body.className = isDark ? 'theme-dark' : '';
Styles.init(isDark);
let last = $.create(`style#${ID}-styles.${Styles.REUSABLE}`, {
textContent: pv.stylesOverride,
before: pv.shadow.firstChild,
});
if (!pv.styles) {
pv.styles = new Map();
pv.stylesScaled = new Set();
}
const toDownload = [];
const sourceElements = $.all('link[rel="stylesheet"], style', pv.post.doc);
for (const {href, textContent, localName} of sourceElements) {
const isLink = localName === 'link';
const id = ID + '-style-' + (isLink ? href : await Util.sha256(textContent));
const el = pv.styles.get(id);
if (!el && isLink)
toDownload.push(Urler.get({url: href, context: id}));
last = $.create('style', {
id,
className: Styles.REUSABLE,
textContent: isLink ? $.text(el) : textContent,
after: last,
});
pv.styles.set(id, last);
}
const downloaded = await Promise.all(toDownload);
for (const {responseText, context: id} of downloaded)
Styles.applyRemScale(id, responseText);
if (!pv.remScale) {
pv.remScale = parseFloat(getComputedStyle(pv.body).fontSize) /
parseFloat(getComputedStyle(document.documentElement).fontSize);
if (pv.remScale !== 1)
for (const id of pv.styles.keys())
Styles.applyRemScale(id);
}
}
static async loadComments() {
const list = $(`#${pv.post.comments.id} .comments-list`);
const url = new URL(pv.finalUrl).origin +
'/posts/' + pv.post.comments.id.match(/\d+/)[0] + '/comments';
list.innerHTML = (await Urler.get(url)).responseText;
$.remove('.hover-only-label', list);
const oldIds = new Set([...list.children].map(e => e.id));
for (const cmt of list.children) {
if (!oldIds.has(cmt.id))
cmt.classList.add('new-comment-highlight');
}
$.setStyle(list.closest('.comments'), ['display', 'block']);
Render.previewableLinks(list);
Render.hoverableUsers(list);
}
}
class Render {
static all() {
pv.frame.classList.toggle(`${ID}-hasAnswerShelf`, pv.post.numAnswers > 0);
pv.frame.setAttribute(`${ID}-type`, pv.post.type);
pv.body.setAttribute(`${ID}-type`, pv.post.type);
$.create(`a#${ID}-title.SEpreviewable`, {
href: pv.finalUrlOfQuestion,
textContent: pv.post.title,
parent: pv.body,
});
$.create(`#${ID}-close`, {
title: 'Or press Esc key while the preview is focused (also when just shown)',
parent: pv.body,
});
$.create(`#${ID}-meta`, {
parent: pv.body,
onmousedown: Sizer.onMouseDown,
children: [
Render._votes(),
pv.post.isQuestion
? Render._questionMeta()
: Render._answerMeta(),
],
});
Render.previewableLinks(pv.post.doc);
pv.post.answerShelf = pv.post.answers.map(Render._answer);
if (Security.noImages)
Security.embedImages(...pv.post.renderParts);
pv.parts = $.create(`#${ID}-parts`, {
className: pv.post.isDeleted ? 'deleted-answer' : '',
tabIndex: 0,
scrollTop: 0,
parent: pv.body,
children: pv.post.renderParts,
});
Render.hoverableUsers(pv.parts);
if (pv.post.numAnswers) {
$.create(`#${ID}-answers`, {parent: pv.body}, [
pv.answersTitle,
pv.post.answerShelf,
]);
} else {
$.remove(`#${ID}-answers`, pv.body);
}
const ACTIONS_SEL = '.js-post-menu > div';
const elActions = $(ACTIONS_SEL);
// delinkify/remove non-functional items in post-menu
$.remove('.js-share-link, .flag-post-link', pv.body);
for (const el of $.all(`${ACTIONS_SEL} button`)) {
const elWrapper = el.closest(`${ACTIONS_SEL} > div`);
if (elWrapper) elWrapper.remove();
}
// add a timeline link
$.appendChildren(elActions, [
$.create('div.' + elActions.firstElementChild.className, [
$.create('a', {href: `/posts/${pv.post.id}/timeline`}, 'Timeline'),
]),
]);
// prettify code blocks
hljs.configure({
languages: [
...$.all('.post-taglist .post-tag', pv.post.doc).map($.text),
'javascript',
'html',
],
});
$.all('pre > code').forEach(el => {
el = el.parentElement;
el.className = el.className.replace(/((?:^|\s)lang-)bsh(?=\s|$)/, '$1powershell');
hljs.highlightBlock(el);
});
const leftovers = $.all('style, link, script');
for (const el of leftovers) {
if (el.classList.contains(Styles.REUSABLE))
el.classList.remove(Styles.REUSABLE);
else
el.remove();
}
pv.post.html = null;
pv.post.core = null;
pv.post.renderParts = null;
pv.post.answers = null;
pv.post.answerShelf = null;
}
/** @param {Element} container */
static previewableLinks(container) {
for (const a of $.all('a:not(.SEpreviewable)', container)) {
let href = a.getAttribute('href');
if (!href)
continue;
if (!href.includes('://')) {
href = a.href;
a.setAttribute('href', href);
}
if (Detector.rxPreviewablePost.test(href)) {
a.removeAttribute('title');
a.classList.add('SEpreviewable');
}
}
}
/** @param {Element} container */
static hoverableUsers(container) {
for (const a of $.all('a[href*="/users/"]', container)) {
if (Detector.rxPreviewableSite.test(a.href) &&
a.pathname.match(/^\/users\/\d+/)) {
a.onmouseover = UserCard.onUserLinkHovered;
a.classList.add(`${ID}-userLink`);
}
}
}
/** @param {Element} el */
static _answer(el) {
const shortUrl = $('.js-share-link', el).href.replace(/(\d+)\/\d+/, '$1');
const extraClasses =
(el.matches(pv.post.selector) ? ' SEpreviewed' : '') +
(el.matches('.deleted-answer') ? ' deleted-answer' : '') +
(el.matches('.accepted-answer') ? ` ${ID}-accepted` : '');
const author = $('.post-signature:last-child', el);
const title =
$.text('.user-details a', author) +
' (rep ' +
$.text('.reputation-score', author) +
')\n' +
$.text('.user-action-time', author);
let gravatar = $('img, .anonymous-gravatar, .community-wiki', author);
if (gravatar && Security.noImages)
Security.embedImages(gravatar);
if (gravatar && gravatar.src)
gravatar = $.create('img', {src: gravatar.src});
const a = $.create('a', {
href: shortUrl,
title: title,
className: 'SEpreviewable' + extraClasses,
textContent: $.text('.js-vote-count', el).replace(/^0$/, '\xA0') + ' ',
children: gravatar,
});
return [a, ' '];
}
static _votes() {
const votes = $.text('.js-vote-count', pv.post.core.closest('.post-layout'));
if (Number(votes))
return $.create('b', `${votes} vote${Math.abs(votes) >= 2 ? 's' : ''}`);
}
static _questionMeta() {
try {
return [...$('time', pv.post.doc).closest('.grid').children]
.map(el => el.textContent.trim())
.map((s, i) => (i ? s.toLowerCase() : s))
.join(', ');
} catch (e) {
return '';
}
}
static _answerMeta() {
return $.all('.user-action-time', pv.post.core.closest('.answer'))
.reverse()
.map($.text)
.join(', ');
}
}
class UserCard {
_fadeIn() {
this._retakeId(this);
$.setStyle(this.element,
['opacity', '0'],
['display', 'block']);
this.timer = setTimeout(() => {
if (this.timer)
$.setStyle(this.element, ['opacity', '1']);
});
}
_retakeId() {
if (this.element.id !== 'user-menu') {
const oldCard = $('#user-menu');
if (oldCard)
oldCard.id = oldCard.style.display = '';
this.element.id = 'user-menu';
}
}
// 'this' is the hoverable link enclosing the user's name/avatar
static onUserLinkHovered() {
clearTimeout(this[EXPANDO]);
this[EXPANDO] = setTimeout(UserCard._show, PREVIEW_DELAY * 2, this);
}
/** @param {HTMLAnchorElement} a */
static async _show(a) {
if (!a.matches(':hover'))
return;
const el = a.nextElementSibling;
const card = el && el.matches(`.${ID}-userCard`) && el[EXPANDO] ||
await UserCard._create(a);
card._fadeIn();
}
/** @param {HTMLAnchorElement} a */
static async _create(a) {
const url = a.origin + '/users/user-info/' + Urler.getFirstNumber(a);
let {html} = Cache.read(url) || {};
if (!html) {
html = (await Urler.get(url)).responseText;
Cache.write({url, html, cacheDuration: CACHE_DURATION * 100});
}
const dom = Util.parseHtml(html);
if (Security.noImages)
Security.embedImages(dom);
const b = a.getBoundingClientRect();
const pb = pv.parts.getBoundingClientRect();
const left = Math.min(b.left - 20, pb.right - 350) - pb.left + 'px';
const isClipped = b.bottom + 100 > pb.bottom;
const el = $.create(`#user-menu-tmp.${ID}-userCard`, {
attributes: {
style: `left: ${left} !important;` +
(isClipped ? 'margin-top: -5rem !important;' : ''),
},
onmouseout: UserCard._onMouseOut,
children: dom.body.children,
after: a,
});
const card = new UserCard(el);
Object.defineProperty(el, EXPANDO, {value: card});
card.element = el;
return card;
}
/** @param {MouseEvent} e */
static _onMouseOut(e) {
if (this.matches(':hover') ||
this.style.opacity === '0' /* fading out already */)
return;
const self = /** @type {UserCard} */ this[EXPANDO];
clearTimeout(self.timer);
self.timer = 0;
Util.fadeOut(this);
}
}
class Sizer {
static init() {
Preview.setHeight(GM_getValue('height', innerHeight / 3) >> 0);
}
/** @param {MouseEvent} e */
static onMouseDown(e) {
if (e.button !== 0 || Util.hasKeyModifiers(e))
return;
Sizer._heightDelta = innerHeight - e.clientY - pv.frame.clientHeight;
$.on('mousemove', document, Sizer._onMouseMove);
$.on('mouseup', document, Sizer._onMouseUp);
}
/** @param {MouseEvent} e */
static _onMouseMove(e) {
Preview.setHeight(innerHeight - e.clientY - Sizer._heightDelta);
getSelection().removeAllRanges();
}
/** @param {MouseEvent} e */
static _onMouseUp(e) {
GM_setValue('height', pv.frame.clientHeight);
$.off('mouseup', document, Sizer._onMouseUp);
$.off('mousemove', document, Sizer._onMouseMove);
}
}
class ScrollLock {
static enable() {
if (ScrollLock.active)
return;
ScrollLock.active = true;
ScrollLock.x = scrollX;
ScrollLock.y = scrollY;
$.on('mouseover', document.body, ScrollLock._onMouseOver);
$.on('scroll', document, ScrollLock._onScroll);
}
static disable() {
ScrollLock.active = false;
$.off('mouseover', document.body, ScrollLock._onMouseOver);
$.off('scroll', document, ScrollLock._onScroll);
}
static _onMouseOver() {
if (ScrollLock.active)
ScrollLock.disable();
}
static _onScroll() {
scrollTo(ScrollLock.x, ScrollLock.y);
}
}
class Security {
static init() {
if (Detector.isStackExchangePage) {
Security.checked = true;
Security.check = null;
}
Security.init = true;
}
static async check() {
Security.noImages = false;
Security._resolveOnReady = [];
Security._imageCache = new Map();
const {headers} = await fetch(location.href, {
method: 'HEAD',
cache: 'force-cache',
mode: 'same-origin',
credentials: 'same-origin',
});
const csp = headers.get('Content-Security-Policy');
const imgSrc = /(?:^|[\s;])img-src\s+([^;]+)/i.test(csp) && RegExp.$1.trim();
if (imgSrc)
Security.noImages = !/(^\s)(\*|https?:)(\s|$)/.test(imgSrc);
Security._resolveOnReady.forEach(fn => fn());
Security._resolveOnReady = null;
Security.checked = true;
Security.check = null;
}
/** @return Promise<void> */
static ready() {
return Security.checked ?
Promise.resolve() :
new Promise(done => Security._resolveOnReady.push(done));
}
static embedImages(...containers) {
for (const container of containers) {
if (!container)
continue;
if (Util.isIterable(container)) {
Security.embedImages(...container);
continue;
}
if (container.localName === 'img') {
Security._embedImage(container);
continue;
}
for (const img of container.getElementsByTagName('img'))
Security._embedImage(img);
}
}
static _embedImage(img) {
const src = img.src;
if (!src || src.startsWith('data:'))
return;
const data = Security._imageCache.get(src);
const alreadyFetching = Array.isArray(data);
if (alreadyFetching) {
data.push(img);
} else if (data) {
img.src = data;
return;
} else {
Security._imageCache.set(src, [img]);
Security._fetchImage(src);
}
$.setStyle(img, ['visibility', 'hidden']);
img.dataset.src = src;
img.removeAttribute('src');
}
static async _fetchImage(src) {
const r = await Urler.get({url: src, responseType: 'blob'});
const type = Util.getResponseMimeType(r.responseHeaders);
const blob = r.response;
const blobType = blob.type;
let dataUri = await Util.blobToBase64(blob);
if (blobType !== type)
dataUri = 'data:' + type + dataUri.slice(dataUri.indexOf(';'));
const images = Security._imageCache.get(src);
Security._imageCache.set(src, dataUri);
let detached = false;
for (const el of images) {
el.src = dataUri;
el.style.removeProperty('visibility');
if (!detached && el.ownerDocument !== document)
detached = true;
}
if (detached) {
for (const el of $.all(`img[data-src="${src}"]`)) {
el.src = dataUri;
el.style.removeProperty('visibility');
}
}
}
}
// eslint-disable-next-line no-redeclare
class Cache {
static init() {
Cache.timers = new Map();
setTimeout(Cache._cleanup, 10e3);
}
static read(url) {
const keyUrl = Urler.makeCacheable(url);
const [time, expires, finalUrl = url] = (localStorage[keyUrl] || '').split('\t');
const keyFinalUrl = Urler.makeCacheable(finalUrl);
return expires > Date.now() && {
time,
finalUrl,
html: LZStringUnsafe.decompressFromUTF16(localStorage[keyFinalUrl + '\thtml']),
};
}
// standard keyUrl = time,expiry
// keyUrl\thtml = html
// redirected keyUrl = time,expiry,finalUrl
// keyFinalUrl = time,expiry
// keyFinalUrl\thtml = html
static write({url, finalUrl, html, cacheDuration = CACHE_DURATION}) {
cacheDuration = Math.max(CACHE_DURATION, Math.min(0x7FFF0000, cacheDuration >> 0));
finalUrl = (finalUrl || url).replace(/[?#].*/, '');
const keyUrl = Urler.makeCacheable(url);
const keyFinalUrl = Urler.makeCacheable(finalUrl);
const lz = LZStringUnsafe.compressToUTF16(html);
if (!Util.tryCatch(Cache._writeRaw, keyFinalUrl + '\thtml', lz)) {
Cache._cleanup({aggressive: true});
if (!Util.tryCatch(Cache._writeRaw, keyFinalUrl + '\thtml', lz))
return Util.error('localStorage write error');
}
const time = Date.now();
const expiry = time + cacheDuration;
localStorage[keyFinalUrl] = time + '\t' + expiry;
if (keyUrl !== keyFinalUrl)
localStorage[keyUrl] = time + '\t' + expiry + '\t' + finalUrl;
const t = setTimeout(Cache._delete, cacheDuration + 1000,
keyUrl,
keyFinalUrl,
keyFinalUrl + '\thtml');
for (const url of [keyUrl, keyFinalUrl]) {
clearTimeout(Cache.timers.get(url));
Cache.timers.set(url, t);
}
}
static _writeRaw(k, v) {
localStorage[k] = v;
return true;
}
static _delete(...keys) {
for (const k of keys) {
delete localStorage[k];
Cache.timers.delete(k);
}
}
static _cleanup({aggressive = false} = {}) {
for (const k in localStorage) {
if ((k.startsWith('http://') || k.startsWith('https://')) &&
!k.includes('\t')) {
const [, expires, url] = (localStorage[k] || '').split('\t');
if (Number(expires) > Date.now() && !aggressive)
break;
if (url) {
delete localStorage[url];
Cache.timers.delete(url);
}
delete localStorage[(url || k) + '\thtml'];
delete localStorage[k];
Cache.timers.delete(k);
}
}
}
}
class Urler {
static init() {
Urler.xhr = null;
Urler.xhrNoSSL = new Set();
Urler.init = true;
}
static getFirstNumber(url) {
if (typeof url === 'string')
url = new URL(url);
return url.pathname.match(/\/(\d+)/)[1];
}
static makeHttps(url) {
if (!url)
return '';
if (url.startsWith('http:'))
return 'https:' + url.slice(5);
return url;
}
// strips queries and hashes and anything after the main part
// https://site/questions/NNNNNN/title/
static makeCacheable(url) {
return url
.replace(/(\/q(?:uestions)?\/\d+\/[^/]+).*/, '$1')
.replace(/(\/a(?:nswers)?\/\d+).*/, '$1')
.replace(/[?#].*$/, '');
}
static get(options) {
if (!options.url)
options = {url: options, method: 'GET'};
if (!options.method)
options = Object.assign({method: 'GET'}, options);
let url = options.url;
const hostname = new URL(url).hostname;
if (Urler.xhrNoSSL.has(hostname)) {
url = url.replace(/^https/, 'http');
} else {
url = Urler.makeHttps(url);
const _onerror = options.onerror;
options.onerror = () => {
options.onerror = _onerror;
options.url = url.replace(/^https/, 'http');
Urler.xhrNoSSL.add(hostname);
return Urler.get(options);
};
}
return new Promise(resolve => {
let xhr;
options.onload = r => {
if (pv.xhr === xhr)
pv.xhr = null;
resolve(r);
};
options.url = url;
xhr = pv.xhr = GM_xmlhttpRequest(options);
});
}
}
class Util {
static tryCatch(fn, ...args) {
try {
return fn(...args);
} catch (e) {}
}
static isIterable(o) {
return typeof o === 'object' && Symbol.iterator in o;
}
static parseHtml(html) {
if (!Util.parser)
Util.parser = new DOMParser();
return Util.parser.parseFromString(html, 'text/html');
}
static extractTime(element) {
return new Date(element.title).getTime();
}
static getResponseMimeType(headers) {
return headers.match(/^\s*content-type:\s*(.*)|$/mi)[1] ||
'image/png';
}
static getResponseDate(headers) {
try {
return new Date(headers.match(/^\s*date:\s*(.*)/mi)[1]);
} catch (e) {}
}
static blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = e => resolve(e.target.result);
reader.readAsDataURL(blob);
});
}
static async sha256(str) {
if (!pv.utf8encoder)
pv.utf8encoder = new TextEncoder('utf-8');
const buf = await crypto.subtle.digest('SHA-256', pv.utf8encoder.encode(str));
const blob = new Blob([buf]);
const url = await Util.blobToBase64(blob);
return url.slice(url.indexOf(',') + 1);
}
/** @param {KeyboardEvent} e */
static hasKeyModifiers(e) {
return e.ctrlKey || e.altKey || e.shiftKey || e.metaKey;
}
static fadeOut(el, transition) {
return new Promise(resolve => {
if (transition) {
if (typeof transition === 'number')
transition = `opacity ${transition}s ease-in-out`;
$.setStyle(el, ['transition', transition]);
setTimeout(doFadeOut);
} else {
doFadeOut();
}
function doFadeOut() {
$.setStyle(el, ['opacity', '0']);
$.on('transitionend', el, done);
$.on('visibilitychange', el, done);
}
function done() {
$.off('transitionend', el, done);
$.off('visibilitychange', el, done);
if (el.style.opacity === '0')
$.setStyle(el, ['display', 'none']);
resolve();
}
});
}
/** @param {KeyboardEvent} e */
static consumeEsc(e) {
if (e.key === 'Escape')
e.preventDefault();
}
static error(...args) {
console.error(GM_info.script.name, ...args);
}
}
class Styles {
static init(isDark) {
if (Styles.isDark === isDark)
return;
Styles.isDark = isDark;
Styles.REUSABLE = `${ID}-reusable`;
const KBD_COLOR = '#0008';
// language=HTML
const SVG_ARROW = btoa(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path stroke="${KBD_COLOR}" stroke-width="3" fill="none"
d="M2.5,8.5H15 M9,2L2.5,8.5L9,15"/>
</svg>`
.replace(/>\s+</g, '><')
.replace(/[\r\n]/g, ' ')
.replace(/\s\s+/g, ' ')
.trim()
);
const IMPORTANT = '!important;';
// language=CSS
pv.stylesOverride = [
`
:host {
all: initial;
border-color: transparent;
display: none;
opacity: 0;
height: 33%;
transition: opacity .25s cubic-bezier(.88,.02,.92,.66),
border-color .25s ease-in-out;
}
`,
`
:host {
box-sizing: content-box;
width: ${WIDTH}px;
min-height: ${MIN_HEIGHT}px;
position: fixed;
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: ${TOP_BORDER}px ${BORDER}px ${BORDER}px;
border-style: solid;
}
:host(:not([style*="opacity: 1"])) {
pointer-events: none;
}
:host([\\type$="question"].\\hasAnswerShelf) {
border-image: linear-gradient(
${colors.question.back} 66%,
${colors.answer.back}) 1 1;
}
`.replace(/;/g, IMPORTANT),
...Object.entries(colors).map(([type, colors]) => `
:host([\\type$="${type}"]) {
border-color: ${colors.back} !important;
}
`),
`
#\\body {
min-width: unset!important;
box-shadow: none!important;
padding: 0!important;
margin: 0!important;
background: ${colors.body.back}!important;
color: ${colors.body.fore}!important;
display: flex;
flex-direction: column;
height: 100%;
}
#\\title {
all: unset;
display: block;
padding: 12px ${PADDING}px;
font-weight: bold;
font-size: 18px;
line-height: 1.2;
cursor: pointer;
}
#\\title:hover {
text-decoration: underline;
text-decoration-skip: ink;
}
#\\title:hover + #\\meta {
opacity: 1.0;
}
#\\meta {
position: absolute;
font: bold 14px/${TOP_BORDER}px sans-serif;
height: ${TOP_BORDER}px;
top: -${TOP_BORDER}px;
left: -${BORDER}px;
right: ${BORDER * 2}px;
padding: 0 0 0 ${BORDER + PADDING}px;
display: flex;
align-items: center;
cursor: s-resize;
}
#\\meta b {
height: ${TOP_BORDER}px;
display: inline-block;
padding: 0 6px;
margin-left: -6px;
margin-right: 3px;
}
#\\close {
position: absolute;
top: -${TOP_BORDER}px;
right: -${BORDER}px;
width: ${BORDER * 3}px;
flex: none;
cursor: pointer;
padding: .5ex 1ex;
font: normal 15px/1.0 sans-serif;
color: #fff8;
}
#\\close:after {
content: "x";
}
#\\close:active {
background-color: rgba(0,0,0,.2);
}
#\\close:hover {
background-color: rgba(0,0,0,.1);
}
#\\parts {
position: relative;
overflow-y: overlay; /* will replace with scrollbar-gutter once it's implemented */
overflow-x: hidden;
flex-grow: 2;
outline: none;
margin: 0;
padding: ${PADDING}px ${PADDING - PROSE_MARGIN}px ${PADDING}px ${PADDING}px !important;
}
#\\parts > .question-status {
margin: -${PADDING}px -${PADDING}px ${PADDING}px;
padding-left: ${PADDING}px;
}
#\\parts .question-originals-of-duplicate {
margin: -${PADDING}px -${PADDING}px ${PADDING}px;
padding: ${PADDING / 2 >> 0}px ${PADDING}px;
}
#\\parts > .question-status h2 {
font-weight: normal;
}
#\\parts a.SEpreviewable {
text-decoration: underline !important;
text-decoration-skip: ink;
}
#\\parts .comment-actions {
width: 20px !important;
}
#\\parts .comment-edit,
#\\parts .delete-tag,
#\\parts .comment-actions > :not(.comment-score) {
display: none;
}
#\\parts .comments {
border-top: none;
}
#\\parts .comments .comment:last-child .comment-text {
border-bottom: none;
}
#\\parts .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);
}
#\\parts .post-menu > span {
opacity: .35;
}
#\\parts #user-menu {
position: absolute;
}
.\\userCard {
position: absolute;
display: none;
transition: opacity .25s cubic-bezier(.88,.02,.92,.66) .5s;
margin-top: -3rem;
}
#\\parts .wmd-preview a:not(.post-tag),
#\\parts .postcell a:not(.post-tag),
#\\parts .comment-copy a:not(.post-tag) {
border-bottom: none;
}
#\\answers-title {
margin: .5ex 1ex 0 0;
font-size: 18px;
line-height: 1.0;
float: left;
}
#\\answers-title p {
font-size: 11px;
font-weight: normal;
max-width: 8em;
line-height: 1.0;
margin: 1ex 0 0 0;
padding: 0;
}
#\\answers-title b,
#\\answers-title label {
background: linear-gradient(#fff8 30%, #fff);
width: 10px;
height: 10px;
padding: 2px;
margin-right: 2px;
box-shadow: 0 1px 3px #0008;
border-radius: 3px;
font-weight: normal;
display: inline-block;
vertical-align: middle;
}
#\\answers-title b::after {
content: "";
display: block;
width: 100%;
height: 100%;
background: url('data:image/svg+xml;base64,${SVG_ARROW}') no-repeat center;
}
#\\answers-title b[mirrored]::after {
transform: scaleX(-1);
}
#\\answers-title label {
width: auto;
color: ${KBD_COLOR};
}
#\\answers {
all: unset;
display: block;
padding: 10px 10px 10px ${PADDING}px;
font-weight: bold;
line-height: 1.0;
border-top: 4px solid ${colors.answer.back}5e;
background-color: ${colors.answer.back}5e;
color: ${colors.answer.fore};
word-break: break-word;
}
#\\answers a {
color: ${colors.answer.fore};
text-decoration: none;
font-size: 11px;
font-family: monospace;
width: 32px !important;
display: inline-block;
position: relative;
vertical-align: top;
margin: 0 1ex 1ex 0;
padding: 0 0 1.1ex 0;
}
[\\type*="deleted"] #\\answers a {
color: ${colors.deleted.fore};
}
#\\answers img {
width: 32px;
height: 32px;
}
#\\answers a.deleted-answer {
color: ${colors.deleted.fore};
background: transparent;
opacity: 0.25;
}
#\\answers a.deleted-answer:hover {
opacity: 1.0;
}
#\\answers a:hover:not(.SEpreviewed) {
text-decoration: underline;
text-decoration-skip: ink;
}
#\\answers a.SEpreviewed {
background-color: ${colors.answer.fore};
color: ${colors.answer.foreInv};
outline: 4px solid ${colors.answer.fore};
}
#\\answers a::after {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40px;
position: absolute;
content: attr(title);
top: 44px;
left: 0;
font: normal .75rem/1.0 sans-serif;
opacity: .7;
}
#\\answers a:only-child::after {
max-width: calc(${WIDTH}px - 10em);
}
#\\answers a:hover::after {
opacity: 1;
}
.\\accepted::before {
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);
}
@-webkit-keyframes highlight {
from {background: #ffcf78}
to {background: none}
}
`,
...Object.keys(colors).map(s => `
#\\title {
background-color: ${colors[s].back}5e;
color: ${colors[s].fore};
}
#\\meta {
color: ${colors[s].fore};
}
#\\meta b {
color: ${colors[s].foreInv};
background: ${colors[s].fore};
}
#\\close {
color: ${colors[s].fore};
}
#\\parts::-webkit-scrollbar {
background-color: ${colors[s].back}19;
}
#\\parts::-webkit-scrollbar-thumb {
background-color: ${colors[s].back}32;
}
#\\parts::-webkit-scrollbar-thumb:hover {
background-color: ${colors[s].back}4b;
}
#\\parts::-webkit-scrollbar-thumb:active {
background-color: ${colors[s].back}c0;
}
`
// language=JS
.replace(/#\\/g, `[\\type$="${s}"] $&`)
),
...['deleted', 'closed'].map(s => /* language=CSS */ `
#\\answers {
border-top-color: ${colors[s].back}5e;
background-color: ${colors[s].back}5e;
color: ${colors[s].fore};
}
#\\answers a.SEpreviewed {
background-color: ${colors[s].fore};
color: ${colors[s].foreInv};
}
#\\answers a.SEpreviewed:after {
border-color: ${colors[s].fore};
}
`
// language=JS
.replace(/#\\/g, `[\\type$="${s}"] $&`)
),
GM_getResourceText(`HL-style${isDark ? '-dark' : ''}`),
].join('\n').replace(/\\/g, `${ID}-`);
}
static applyRemScale(id, css) {
const el = pv.styles.get(id);
if (pv.remScale && pv.remScale !== 1 && !pv.stylesScaled.has(id)) {
css = (css || el.textContent).replace(/([:\s])((?:\d*\.?)?\d+)(?=rem([;}\s]|\/\*))/gi,
(_, prev, size) => prev + (pv.remScale * size));
pv.stylesScaled.add(id);
}
el.textContent = css;
}
}
function $(selector, node = pv.shadow) {
return node && node.querySelector(selector);
}
Object.assign($, {
all(selector, node = pv.shadow) {
return node ? [...node.querySelectorAll(selector)] : [];
},
on(eventName, node, fn, options) {
return node.addEventListener(eventName, fn, options);
},
off(eventName, node, fn, options) {
return node.removeEventListener(eventName, fn, options);
},
remove(selector, node = pv.shadow) {
for (const el of node.querySelectorAll(selector))
el.remove();
},
text(selector, node = pv.shadow) {
const el = typeof selector === 'string' ?
node && node.querySelector(selector) :
selector;
return el ? el.textContent.trim() : '';
},
create(
selector,
opts = {},
children = opts.children ||
(typeof opts !== 'object' || Util.isIterable(opts)) && opts
) {
const EOL = selector.length;
const idStart = (selector.indexOf('#') + 1 || EOL + 1) - 1;
const clsStart = (selector.indexOf('.', idStart < EOL ? idStart : 0) + 1 || EOL + 1) - 1;
const tagEnd = Math.min(idStart, clsStart);
const tag = (tagEnd < EOL ? selector.slice(0, tagEnd) : selector) || opts.tag || 'div';
const id = idStart < EOL && selector.slice(idStart + 1, clsStart) || opts.id || '';
const cls = clsStart < EOL && selector.slice(clsStart + 1).replace(/\./g, ' ') ||
opts.className ||
'';
const el = id && pv.shadow && pv.shadow.getElementById(id) ||
document.createElement(tag);
if (el.id !== id)
el.id = id;
if (el.className !== cls)
el.className = cls;
const hasOwnProperty = Object.hasOwnProperty;
for (const key in opts) {
if (!hasOwnProperty.call(opts, key))
continue;
const value = opts[key];
switch (key) {
case 'tag':
case 'id':
case 'className':
case 'children':
break;
case 'dataset': {
const dataset = el.dataset;
for (const k in value) {
if (hasOwnProperty.call(value, k)) {
const v = value[k];
if (dataset[k] !== v)
dataset[k] = v;
}
}
break;
}
case 'attributes': {
for (const k in value) {
if (hasOwnProperty.call(value, k)) {
const v = value[k];
if (el.getAttribute(k) !== v)
el.setAttribute(k, v);
}
}
break;
}
default:
if (el[key] !== value)
el[key] = value;
}
}
if (children) {
if (!hasOwnProperty.call(opts, 'textContent'))
el.textContent = '';
$.appendChildren(el, children);
}
let before, after, parent;
if ((before = opts.before) && before !== el.nextSibling && before !== el)
before.insertAdjacentElement('beforebegin', el);
else if ((after = opts.after) && after !== el.previousSibling && after !== el)
after.insertAdjacentElement('afterend', el);
else if ((parent = opts.parent) && parent !== el.parentNode)
parent.appendChild(el);
return el;
},
appendChild(parent, child, shouldClone = true) {
if (!child)
return;
if (child.nodeType)
return parent.appendChild(shouldClone ? document.importNode(child, true) : child);
if (Util.isIterable(child))
return $.appendChildren(parent, child, shouldClone);
else
return parent.appendChild(document.createTextNode(child));
},
appendChildren(newParent, children) {
if (!Util.isIterable(children))
return $.appendChild(newParent, children);
const fragment = document.createDocumentFragment();
for (const el of children)
$.appendChild(fragment, el);
return newParent.appendChild(fragment);
},
setStyle(el, ...props) {
const style = el.style;
const s0 = style.cssText;
let s = s0;
for (const p of props) {
if (!p)
continue;
const [name, value, important = true] = p;
const rValue = value + (important && value ? ' !important' : '');
const rx = new RegExp(`(^|[\\s;])${name}(\\s*:\\s*)([^;]*?)(\\s*(?:;|$))`, 'i');
const m = rx.exec(s);
if (!m && value) {
const rule = name + ': ' + rValue;
s += !s || s.endsWith(';') ? rule : '; ' + rule;
continue;
}
if (!m && !value)
continue;
const [, sep1, sep2, oldValue, sep3] = m;
if (value !== oldValue) {
s = s.slice(0, m.index) +
sep1 + (rValue ? name + sep2 + rValue + sep3 : '') +
s.slice(m.index + m[0].length);
}
}
if (s !== s0)
style.cssText = s;
},
});