- // ==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;
- },
- });