SE Preview on hover

Shows preview of the linked questions/answers on hover

当前为 2020-08-28 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name SE Preview on hover
  3. // @description Shows preview of the linked questions/answers on hover
  4. // @version 1.0.9
  5. // @author wOxxOm
  6. // @namespace wOxxOm.scripts
  7. // @license MIT License
  8. //
  9. // please use only matches for the previewable targets and make sure the domain
  10. // is extractable via [-.\w] so that it starts with . like .stackoverflow.com
  11. // @match *://*.stackoverflow.com/*
  12. // @match *://*.superuser.com/*
  13. // @match *://*.serverfault.com/*
  14. // @match *://*.askubuntu.com/*
  15. // @match *://*.stackapps.com/*
  16. // @match *://*.mathoverflow.net/*
  17. // @match *://*.stackexchange.com/*
  18. //
  19. // please use only includes for the container sites
  20. // @include *://www.google.com/search*/
  21. // @include /https?:\/\/(www\.)?google(\.com?)?(\.\w\w)?\/(webhp|q|.*?[?#]q=|search).*/
  22. // @include *://*.bing.com/*
  23. // @include *://*.yahoo.com/*
  24. // @include /https?:\/\/(\w+\.)*yahoo.(com|\w\w(\.\w\w)?)\/.*/
  25. //
  26. // @require https://greasyfork.org/scripts/27531/code/LZStringUnsafe.js
  27. // @require https://greasyfork.org/scripts/375977/code/StackOverflow-code-prettify-bundle.js
  28. //
  29. // @grant GM_addStyle
  30. // @grant GM_xmlhttpRequest
  31. // @grant GM_getValue
  32. // @grant GM_setValue
  33. // @grant GM_getResourceText
  34. //
  35. // @connect stackoverflow.com
  36. // @connect superuser.com
  37. // @connect serverfault.com
  38. // @connect askubuntu.com
  39. // @connect stackapps.com
  40. // @connect mathoverflow.net
  41. // @connect stackexchange.com
  42. // @connect sstatic.net
  43. // @connect gravatar.com
  44. // @connect imgur.com
  45. // @connect self
  46. //
  47. // @noframes
  48. // ==/UserScript==
  49.  
  50. /* global initPrettyPrint LZStringUnsafe */
  51. 'use strict';
  52.  
  53. Promise.resolve().then(() => {
  54. Detector.init();
  55. Security.init();
  56. Urler.init();
  57. Cache.init();
  58. });
  59.  
  60. const PREVIEW_DELAY = 200;
  61. const AUTOHIDE_DELAY = 1000;
  62. const BUSY_CURSOR_DELAY = 300;
  63. // 1 minute for the recently active posts, scales up logarithmically
  64. const CACHE_DURATION = 60e3;
  65.  
  66. const PADDING = 24;
  67. const QUESTION_WIDTH = 677; // unconstrained width of a question's .post_text
  68. const ANSWER_WIDTH = 657; // unconstrained width of an answer's .post_text
  69. const WIDTH = Math.max(QUESTION_WIDTH, ANSWER_WIDTH) + PADDING * 2;
  70. const BORDER = 8;
  71. const TOP_BORDER = 24;
  72. const MIN_HEIGHT = 200;
  73. let colors;
  74. const COLORS_LIGHT = {
  75. body: {
  76. back: '#ffffff',
  77. fore: '#000000',
  78. },
  79. question: {
  80. back: '#5894d8',
  81. fore: '#265184',
  82. foreInv: '#fff',
  83. },
  84. answer: {
  85. back: '#70c350',
  86. fore: '#3f7722',
  87. foreInv: '#fff',
  88. },
  89. deleted: {
  90. back: '#cd9898',
  91. fore: '#b56767',
  92. foreInv: '#fff',
  93. },
  94. closed: {
  95. back: '#ffce5d',
  96. fore: '#c28800',
  97. foreInv: '#fff',
  98. },
  99. };
  100. const COLORS_DARK = {
  101. body: {
  102. back: '#222222',
  103. fore: '#cccccc',
  104. },
  105. question: {
  106. back: '#004696',
  107. fore: '#6abaff',
  108. foreInv: '#004696',
  109. },
  110. answer: {
  111. back: '#004c1b',
  112. fore: '#39c466',
  113. foreInv: '#004c1b',
  114. },
  115. deleted: {
  116. back: '#4d0a0b',
  117. fore: '#b56767',
  118. foreInv: '#fff',
  119. },
  120. closed: {
  121. back: '#4b360a',
  122. fore: '#c28800',
  123. foreInv: '#fff',
  124. },
  125. };
  126. const ID = 'SEpreview';
  127. const EXPANDO = Symbol(ID);
  128.  
  129. const pv = {
  130. /** @type {Target} */
  131. target: null,
  132. /** @type {Element} */
  133. _frame: null,
  134. /** @type {Element} */
  135. get frame() {
  136. if (!this._frame)
  137. Preview.init();
  138. if (!document.contains(this._frame))
  139. document.body.appendChild(this._frame);
  140. return this._frame;
  141. },
  142. set frame(element) {
  143. this._frame = element;
  144. return element;
  145. },
  146. /** @type {Post} */
  147. post: {},
  148. hover: {x: 0, y: 0},
  149. stylesOverride: '',
  150. };
  151.  
  152. class Detector {
  153.  
  154. static init() {
  155. const sites = GM_info.script.matches.map(m => m.match(/[-.\w]+/)[0]);
  156. const rxsSites = 'https?://(\\w*\\.)*(' +
  157. GM_info.script.matches
  158. .map(m => m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.'))
  159. .join('|') +
  160. ')/';
  161. Detector.rxPreviewableSite = new RegExp(rxsSites);
  162. Detector.rxPreviewablePost = new RegExp(rxsSites + '(questions|q|a|posts/comments)/\\d+');
  163. Detector.pageUrls = getBaseUrls(location, Detector.rxPreviewablePost);
  164. Detector.isStackExchangePage = Detector.rxPreviewableSite.test(location);
  165.  
  166. const {
  167. rxPreviewablePost,
  168. isStackExchangePage: isSE,
  169. pageUrls: {base, baseShort},
  170. } = Detector;
  171.  
  172. // array of target elements accumulated in mutation observer
  173. // cleared in attachHoverListener
  174. const moQueue = [];
  175.  
  176. onMutation([{
  177. addedNodes: [document.body],
  178. }]);
  179.  
  180. new MutationObserver(onMutation)
  181. .observe(document.body, {
  182. childList: true,
  183. subtree: true,
  184. });
  185.  
  186. Detector.init = true;
  187.  
  188. function onMutation(mutations) {
  189. const alreadyScheduled = moQueue.length > 0;
  190. for (const {addedNodes} of mutations) {
  191. for (const n of addedNodes) {
  192. if (!n.localName)
  193. continue;
  194. if (n.localName === 'a') {
  195. moQueue.push(n);
  196. continue;
  197. }
  198. // not using ..spreading since there could be 100k links for all we know
  199. // and that might exceed JS engine stack limit which can be pretty low
  200. const targets = n.getElementsByTagName('a');
  201. for (let k = 0, len = targets.length; k < len; k++)
  202. moQueue.push(targets[k]);
  203. if (!isSE)
  204. continue;
  205. if (n.classList.contains('question-summary')) {
  206. moQueue.push(...n.getElementsByClassName('answered'));
  207. moQueue.push(...n.getElementsByClassName('answered-accepted'));
  208. continue;
  209. }
  210. for (const el of n.getElementsByClassName('question-summary')) {
  211. moQueue.push(...el.getElementsByClassName('answered'));
  212. moQueue.push(...el.getElementsByClassName('answered-accepted'));
  213. }
  214. }
  215. }
  216. if (!alreadyScheduled && moQueue.length)
  217. setTimeout(hoverize);
  218. }
  219.  
  220. function hoverize() {
  221. for (const el of moQueue) {
  222. if (el[EXPANDO] instanceof Target)
  223. continue;
  224. if (el.localName === 'a') {
  225. if (isSE && el.classList.contains('js-share-link'))
  226. continue;
  227. const previewable = isPreviewable(el) || !isSE && isEmbeddedUrlPreviewable(el);
  228. if (!previewable)
  229. continue;
  230. const url = Urler.makeHttps(el.href);
  231. if (url.startsWith(base) || url.startsWith(baseShort))
  232. continue;
  233. }
  234. Target.createHoverable(el);
  235. }
  236. moQueue.length = 0;
  237. }
  238.  
  239. function isPreviewable(a) {
  240. let href = false;
  241. const host = '.' + a.hostname;
  242. const hostLen = host.length;
  243. for (const stackSite of sites) {
  244. if (host[hostLen - stackSite.length] === '.' &&
  245. host.endsWith(stackSite) &&
  246. rxPreviewablePost.test(href || (href = a.href)))
  247. return true;
  248. }
  249. }
  250.  
  251. function isEmbeddedUrlPreviewable(a) {
  252. const url = a.href;
  253. let i = url.indexOf('http', 1);
  254. if (i < 0)
  255. return false;
  256. i = (
  257. url.indexOf('http://', i) + 1 ||
  258. url.indexOf('https://', i) + 1 ||
  259. url.indexOf('http%3A%2F%2F', i) + 1 ||
  260. url.indexOf('https%3A%2F%2F', i) + 1
  261. ) - 1;
  262. if (i < 0)
  263. return false;
  264. const j = url.indexOf('&', i);
  265. const embeddedUrl = url.slice(i, j > 0 ? j : undefined);
  266. return rxPreviewablePost.test(embeddedUrl);
  267. }
  268.  
  269. function getBaseUrls(url, rx) {
  270. if (!rx.test(url))
  271. return {};
  272. const base = Urler.makeHttps(RegExp.lastMatch);
  273. return {
  274. base,
  275. baseShort: base.replace('/questions/', '/q/'),
  276. };
  277. }
  278. }
  279. }
  280.  
  281. /**
  282. * @property {Element} element
  283. * @property {Boolean} isLink
  284. * @property {String} url
  285. * @property {Number} timer
  286. * @property {Number} timerCursor
  287. * @property {String} savedCursor
  288. */
  289. class Target {
  290.  
  291. /** @param {Element} el */
  292. static createHoverable(el) {
  293. const target = new Target(el);
  294. Object.defineProperty(el, EXPANDO, {value: target});
  295. el.removeAttribute('title');
  296. el.addEventListener('mouseover', Target._onMouseOver);
  297. return target;
  298. }
  299.  
  300. /** @param {Element} el */
  301. constructor(el) {
  302. this.element = el;
  303. this.isLink = el.localName === 'a';
  304. }
  305.  
  306. release() {
  307. $.off('mousemove', this.element, Target._onMove);
  308. $.off('mouseout', this.element, Target._onHoverEnd);
  309. $.off('mousedown', this.element, Target._onHoverEnd);
  310.  
  311. for (const k in this) {
  312. if (k.startsWith('timer') && this[k] >= 1) {
  313. clearTimeout(this[k]);
  314. this[k] = 0;
  315. }
  316. }
  317. BusyCursor.hide(this);
  318. pv.target = null;
  319. }
  320.  
  321. get url() {
  322. const el = this.element;
  323. if (this.isLink)
  324. return el.href;
  325. const a = $('a', el.closest('.question-summary'));
  326. if (a)
  327. return a.href;
  328. }
  329.  
  330. /** @param {MouseEvent} e */
  331. static _onMouseOver(e) {
  332. if (Util.hasKeyModifiers(e))
  333. return;
  334. const self = /** @type {Target} */ this[EXPANDO];
  335. if (self === Preview.target && Preview.shown() ||
  336. self === pv.target)
  337. return;
  338.  
  339. if (pv.target)
  340. pv.target.release();
  341. pv.target = self;
  342.  
  343. pv.hover.x = e.pageX;
  344. pv.hover.y = e.pageY;
  345.  
  346. $.on('mousemove', this, Target._onMove);
  347. $.on('mouseout', this, Target._onHoverEnd);
  348. $.on('mousedown', this, Target._onHoverEnd);
  349.  
  350. Target._restartTimer(self);
  351. }
  352.  
  353. /** @param {MouseEvent} e */
  354. static _onHoverEnd(e) {
  355. if (e.type === 'mouseout' && e.target !== this)
  356. return;
  357. const self = /** @type {Target} */ this[EXPANDO];
  358. if (pv.xhr && pv.target === self) {
  359. pv.xhr.abort();
  360. pv.xhr = null;
  361. }
  362. self.release();
  363. self.timer = setTimeout(Target._onAbortTimer, AUTOHIDE_DELAY, self);
  364. }
  365.  
  366. /** @param {MouseEvent} e */
  367. static _onMove(e) {
  368. const stoppedMoving =
  369. Math.abs(pv.hover.x - e.pageX) < 2 &&
  370. Math.abs(pv.hover.y - e.pageY) < 2;
  371. if (stoppedMoving) {
  372. pv.hover.x = e.pageX;
  373. pv.hover.y = e.pageY;
  374. Target._restartTimer(this[EXPANDO]);
  375. }
  376. }
  377.  
  378. /** @param {Target} self */
  379. static _restartTimer(self) {
  380. if (self.timer)
  381. clearTimeout(self.timer);
  382. self.timer = setTimeout(Target._onTimer, PREVIEW_DELAY, self);
  383. }
  384.  
  385. /** @param {Target} self */
  386. static _onTimer(self) {
  387. self.timer = 0;
  388. const el = self.element;
  389. if (!el.matches(':hover')) {
  390. self.release();
  391. return;
  392. }
  393. $.off('mousemove', el, Target._onMove);
  394.  
  395. if (self.url)
  396. Preview.start(self);
  397. }
  398.  
  399. /** @param {Target} self */
  400. static _onAbortTimer(self) {
  401. if ((self === pv.target || self === Preview.target) &&
  402. pv.frame && !pv.frame.matches(':hover')) {
  403. pv.target = null;
  404. Preview.hide({fade: true});
  405. }
  406. }
  407. }
  408.  
  409.  
  410. class BusyCursor {
  411.  
  412. /** @param {Target} target */
  413. static schedule(target) {
  414. target.timerCursor = setTimeout(BusyCursor._onTimer, BUSY_CURSOR_DELAY, target);
  415. }
  416.  
  417. /** @param {Target} target */
  418. static hide(target) {
  419. if (target.timerCursor) {
  420. clearTimeout(target.timerCursor);
  421. target.timerCursor = 0;
  422. }
  423. const style = target.element.style;
  424. if (style.cursor === 'wait')
  425. style.cursor = target.savedCursor;
  426. }
  427.  
  428. /** @param {Target} target */
  429. static _onTimer(target) {
  430. target.timerCursor = 0;
  431. target.savedCursor = target.element.style.cursor;
  432. $.setStyle(target.element, ['cursor', 'wait']);
  433. }
  434. }
  435.  
  436.  
  437. class Preview {
  438.  
  439. static init() {
  440. pv.frame = $.create(`#${ID}`, {parent: document.body});
  441. pv.shadow = pv.frame.attachShadow({mode: 'open'});
  442. pv.body = $.create(`body#${ID}-body`, {parent: pv.shadow});
  443.  
  444. const WRAP_AROUND = '(or wrap around to the question)';
  445. const TITLE_PREV = 'Previous answer\n' + WRAP_AROUND;
  446. const TITLE_NEXT = 'Next answer\n' + WRAP_AROUND;
  447. const TITLE_ENTER = 'Return to the question\n(Enter was Return initially)';
  448.  
  449. pv.answersTitle =
  450. $.create(`#${ID}-answers-title`, [
  451. 'Answers:',
  452. $.create('p', [
  453. 'Use ',
  454. $.create('b', {title: TITLE_PREV}),
  455. $.create('b', {title: TITLE_NEXT, attributes: {mirrored: ''}}),
  456. $.create('label', {title: TITLE_ENTER}, 'Enter'),
  457. ' to switch entries',
  458. ]),
  459. ]);
  460.  
  461. $.on('keydown', pv.frame, Preview.onKey);
  462. $.on('keyup', pv.frame, Util.consumeEsc);
  463.  
  464. $.on('mouseover', pv.body, ScrollLock.enable);
  465. $.on('click', pv.body, Preview.onClick);
  466.  
  467. Sizer.init();
  468. Styles.init();
  469. Preview.init = true;
  470. }
  471.  
  472. /** @param {Target} target */
  473. static async start(target) {
  474. Preview.target = target;
  475.  
  476. if (!Security.checked)
  477. Security.check();
  478.  
  479. const {url} = target;
  480.  
  481. let data = Cache.read(url);
  482. if (data) {
  483. const r = await Urler.get(url, {method: 'HEAD'});
  484. const postTime = Util.getResponseDate(r.responseHeaders);
  485. if (postTime >= data.time)
  486. data = null;
  487. }
  488.  
  489. if (!data) {
  490. BusyCursor.schedule(target);
  491. const {finalUrl, responseText: html} = await Urler.get(target.url);
  492. data = {finalUrl, html, unsaved: true};
  493. BusyCursor.hide(target);
  494. }
  495.  
  496. data.url = url;
  497. data.showAnswer = !target.isLink;
  498.  
  499. if (!Preview.prepare(data))
  500. Preview.target = null;
  501. else if (data.unsaved && data.lastActivity >= 1)
  502. Preview.save(data);
  503. }
  504.  
  505. static save({url, finalUrl, html, lastActivity}) {
  506. const inactiveDays = Math.max(0, (Date.now() - lastActivity) / (24 * 3600e3));
  507. const cacheDuration = CACHE_DURATION * Math.pow(Math.log(inactiveDays + 1) + 1, 2);
  508. setTimeout(Cache.write, 1000, {url, finalUrl, html, cacheDuration});
  509. }
  510.  
  511. // data is mutated: its lastActivity property is assigned!
  512. static prepare(data) {
  513. const {finalUrl, html, showAnswer, doc = Util.parseHtml(html)} = data;
  514.  
  515. if (!doc || !doc.head)
  516. return Util.error('no HEAD in the document received for', finalUrl);
  517.  
  518. if (!$('base', doc))
  519. $.create('base', {href: finalUrl, parent: doc.head});
  520.  
  521. let answerId;
  522. if (showAnswer) {
  523. const el = $('[id^="answer-"]', doc);
  524. answerId = el && el.id.match(/\d+/)[0];
  525. } else {
  526. answerId = finalUrl.match(/questions\/\d+\/[^/]+\/(\d+)|$/)[1];
  527. }
  528. const selector = answerId ? '#answer-' + answerId : '#question';
  529. const core = $(`${selector} .${answerId ? 'answer' : 'post'}cell`, doc);
  530. if (!core)
  531. return Util.error('No parsable post found', doc);
  532.  
  533. const isQuestion = !answerId;
  534. const status = isQuestion && $('[role="status"]', core);
  535. const isClosed = status && $('[href*="closed"]', status);
  536. const isDeleted = Boolean(core.closest('.deleted-answer'));
  537. const type = [
  538. isQuestion && 'question' || 'answer',
  539. isDeleted && 'deleted',
  540. isClosed && 'closed',
  541. ].filter(Boolean).join(' ');
  542. const answers = $.all('.answer', doc);
  543. const comments = $(`${selector} .comments`, doc);
  544. const commentsParent = comments.parentElement;
  545. const showMoreComments = $(`${selector} .js-show-link.comments-link`, doc);
  546. const lastActivity = Util.tryCatch(Util.extractTime, $('a[href*="?lastactivity"]', core)) ||
  547. Date.now();
  548. Object.assign(pv, {
  549. finalUrl,
  550. finalUrlOfQuestion: Urler.makeCacheable(finalUrl),
  551. });
  552. /** @typedef Post
  553. * @property {Document} doc
  554. * @property {String} html
  555. * @property {String} selector
  556. * @property {String} type
  557. * @property {String} id
  558. * @property {String} title
  559. * @property {Boolean} isQuestion
  560. * @property {Boolean} isDeleted
  561. * @property {Number} lastActivity
  562. * @property {Number} numAnswers
  563. * @property {Element} core
  564. * @property {Element} comments
  565. * @property {Element[]} answers
  566. * @property {Element[]} renderParts
  567. */
  568. Object.assign(pv.post, {
  569. doc,
  570. html,
  571. core,
  572. selector,
  573. answers,
  574. comments,
  575. type,
  576. isQuestion,
  577. isDeleted,
  578. lastActivity,
  579. id: isQuestion ? Urler.getFirstNumber(finalUrl) : answerId,
  580. title: $('meta[property="og:title"]', doc).content,
  581. numAnswers: answers.length,
  582. renderParts: [
  583. // including the parent so the right CSS kicks in
  584. core,
  585. commentsParent,
  586. ],
  587. });
  588.  
  589. $.remove('script', doc);
  590. // remove the comment actions block
  591. $.remove('.comment-form, [id^="comments-link-"], .hover-only-label', commentsParent);
  592. if (!commentsParent.contains(showMoreComments))
  593. commentsParent.appendChild(showMoreComments);
  594.  
  595. Promise.all([
  596. pv.frame,
  597. Preview.addStyles(),
  598. Security.ready(),
  599. ]).then(Preview.show);
  600.  
  601. data.lastActivity = lastActivity;
  602. return true;
  603. }
  604.  
  605. static show() {
  606. Render.all();
  607.  
  608. const style = getComputedStyle(pv.frame);
  609. if (style.opacity !== '1' || style.display !== 'block') {
  610. $.setStyle(pv.frame, ['display', 'block']);
  611. setTimeout($.setStyle, 0, pv.frame, ['opacity', '1']);
  612. }
  613.  
  614. pv.parts.focus();
  615. }
  616.  
  617. static hide({fade = false} = {}) {
  618. if (Preview.target) {
  619. Preview.target.release();
  620. Preview.target = null;
  621. }
  622.  
  623. pv.body.onmouseover = null;
  624. pv.body.onclick = null;
  625. pv.body.onkeydown = null;
  626.  
  627. if (fade) {
  628. Util.fadeOut(pv.frame)
  629. .then(Preview.eraseBoxIfHidden);
  630. } else {
  631. $.setStyle(pv.frame,
  632. ['opacity', '0'],
  633. ['display', 'none']);
  634. Preview.eraseBoxIfHidden();
  635. }
  636. }
  637.  
  638. static shown() {
  639. return pv.frame.style.opacity === '1';
  640. }
  641.  
  642. /** @param {KeyboardEvent} e */
  643. static onKey(e) {
  644. switch (e.key) {
  645. case 'Escape':
  646. Preview.hide({fade: true});
  647. break;
  648. case 'ArrowUp':
  649. case 'PageUp':
  650. if (pv.parts.scrollTop)
  651. return;
  652. break;
  653. case 'ArrowDown':
  654. case 'PageDown': {
  655. const {scrollTop: t, clientHeight: h, scrollHeight} = pv.parts;
  656. if (t + h < scrollHeight)
  657. return;
  658. break;
  659. }
  660. case 'ArrowLeft':
  661. case 'ArrowRight': {
  662. if (!pv.post.numAnswers)
  663. return;
  664. // current is 0 if isQuestion, 1 is the first answer
  665. const answers = $.all(`#${ID}-answers a`);
  666. const current = pv.post.numAnswers ?
  667. answers.indexOf($('.SEpreviewed')) + 1 :
  668. pv.post.isQuestion ? 0 : 1;
  669. const num = pv.post.numAnswers + 1;
  670. const dir = e.key === 'ArrowLeft' ? -1 : 1;
  671. const toShow = (current + dir + num) % num;
  672. const a = toShow ? answers[toShow - 1] : $(`#${ID}-title`);
  673. a.click();
  674. break;
  675. }
  676. case 'Enter':
  677. if (pv.post.isQuestion)
  678. return;
  679. $(`#${ID}-title`).click();
  680. break;
  681. default:
  682. return;
  683. }
  684. e.preventDefault();
  685. }
  686.  
  687. /** @param {MouseEvent} e */
  688. static onClick(e) {
  689. if (e.target.id === `${ID}-close`) {
  690. Preview.hide();
  691. return;
  692. }
  693.  
  694. const link = e.target.closest('a');
  695. if (!link)
  696. return;
  697.  
  698. if (link.matches('.js-show-link.comments-link')) {
  699. Util.fadeOut(link, 0.5);
  700. Preview.loadComments();
  701. e.preventDefault();
  702. return;
  703. }
  704.  
  705. if (e.button ||
  706. Util.hasKeyModifiers(e) ||
  707. !link.matches('.SEpreviewable')) {
  708. link.target = '_blank';
  709. return;
  710. }
  711.  
  712. e.preventDefault();
  713.  
  714. const {doc} = pv.post;
  715. if (link.id === `${ID}-title`)
  716. Preview.prepare({doc, finalUrl: pv.finalUrlOfQuestion});
  717. else if (link.matches(`#${ID}-answers a`))
  718. Preview.prepare({doc, finalUrl: pv.finalUrlOfQuestion + '/' + Urler.getFirstNumber(link)});
  719. else
  720. Preview.start(new Target(link));
  721. }
  722.  
  723. static eraseBoxIfHidden() {
  724. if (!Preview.shown())
  725. pv.body.textContent = '';
  726. }
  727.  
  728. static setHeight(height) {
  729. const currentHeight = pv.frame.clientHeight;
  730. const borderHeight = pv.frame.offsetHeight - currentHeight;
  731. const newHeight = Math.max(MIN_HEIGHT, Math.min(innerHeight - borderHeight, height));
  732. if (newHeight !== currentHeight)
  733. $.setStyle(pv.frame, ['height', newHeight + 'px']);
  734. }
  735.  
  736. static async addStyles() {
  737. const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
  738. colors = isDark ? COLORS_DARK : COLORS_LIGHT;
  739. pv.body.className = isDark ? 'theme-dark' : '';
  740. Styles.init(isDark);
  741.  
  742. let last = $.create(`style#${ID}-styles.${Styles.REUSABLE}`, {
  743. textContent: pv.stylesOverride,
  744. before: pv.shadow.firstChild,
  745. });
  746.  
  747. if (!pv.styles) {
  748. pv.styles = new Map();
  749. pv.stylesScaled = new Set();
  750. }
  751.  
  752. const toDownload = [];
  753. const sourceElements = $.all('link[rel="stylesheet"], style', pv.post.doc);
  754.  
  755. for (const {href, textContent, localName} of sourceElements) {
  756. const isLink = localName === 'link';
  757. const id = ID + '-style-' + (isLink ? href : await Util.sha256(textContent));
  758. const el = pv.styles.get(id);
  759. if (!el && isLink)
  760. toDownload.push(Urler.get({url: href, context: id}));
  761. last = $.create('style', {
  762. id,
  763. className: Styles.REUSABLE,
  764. textContent: isLink ? $.text(el) : textContent,
  765. after: last,
  766. });
  767. pv.styles.set(id, last);
  768. }
  769.  
  770. const downloaded = await Promise.all(toDownload);
  771.  
  772. for (const {responseText, context: id} of downloaded)
  773. Styles.applyRemScale(id, responseText);
  774.  
  775. if (!pv.remScale) {
  776. pv.remScale = parseFloat(getComputedStyle(pv.body).fontSize) /
  777. parseFloat(getComputedStyle(document.documentElement).fontSize);
  778. if (pv.remScale !== 1)
  779. for (const id of pv.styles.keys())
  780. Styles.applyRemScale(id);
  781. }
  782. }
  783.  
  784. static async loadComments() {
  785. const list = $(`#${pv.post.comments.id} .comments-list`);
  786. const url = new URL(pv.finalUrl).origin +
  787. '/posts/' + pv.post.comments.id.match(/\d+/)[0] + '/comments';
  788. list.innerHTML = (await Urler.get(url)).responseText;
  789. $.remove('.hover-only-label', list);
  790.  
  791. const oldIds = new Set([...list.children].map(e => e.id));
  792. for (const cmt of list.children) {
  793. if (!oldIds.has(cmt.id))
  794. cmt.classList.add('new-comment-highlight');
  795. }
  796.  
  797. $.setStyle(list.closest('.comments'), ['display', 'block']);
  798. Render.previewableLinks(list);
  799. Render.hoverableUsers(list);
  800. }
  801. }
  802.  
  803.  
  804. class Render {
  805.  
  806. static all() {
  807. pv.frame.classList.toggle(`${ID}-hasAnswerShelf`, pv.post.numAnswers > 0);
  808. pv.frame.setAttribute(`${ID}-type`, pv.post.type);
  809. pv.body.setAttribute(`${ID}-type`, pv.post.type);
  810.  
  811. $.create(`a#${ID}-title.SEpreviewable`, {
  812. href: pv.finalUrlOfQuestion,
  813. textContent: pv.post.title,
  814. parent: pv.body,
  815. });
  816.  
  817. $.create(`#${ID}-close`, {
  818. title: 'Or press Esc key while the preview is focused (also when just shown)',
  819. parent: pv.body,
  820. });
  821.  
  822. $.create(`#${ID}-meta`, {
  823. parent: pv.body,
  824. onmousedown: Sizer.onMouseDown,
  825. children: [
  826. Render._votes(),
  827. pv.post.isQuestion
  828. ? Render._questionMeta()
  829. : Render._answerMeta(),
  830. ],
  831. });
  832.  
  833. Render.previewableLinks(pv.post.doc);
  834.  
  835. pv.post.answerShelf = pv.post.answers.map(Render._answer);
  836. if (Security.noImages)
  837. Security.embedImages(...pv.post.renderParts);
  838.  
  839. pv.parts = $.create(`#${ID}-parts`, {
  840. className: pv.post.isDeleted ? 'deleted-answer' : '',
  841. tabIndex: 0,
  842. scrollTop: 0,
  843. parent: pv.body,
  844. children: pv.post.renderParts,
  845. });
  846.  
  847. Render.hoverableUsers(pv.parts);
  848.  
  849. if (pv.post.numAnswers) {
  850. $.create(`#${ID}-answers`, {parent: pv.body}, [
  851. pv.answersTitle,
  852. pv.post.answerShelf,
  853. ]);
  854. } else {
  855. $.remove(`#${ID}-answers`, pv.body);
  856. }
  857.  
  858. // delinkify/remove non-functional items in post-menu
  859. $.remove('.js-share-link, .flag-post-link', pv.body);
  860. for (const a of $.all('.post-menu a:not(.edit-post)')) {
  861. if (a.children.length)
  862. $.create('span', {before: a}, a.childNodes);
  863. a.remove();
  864. }
  865.  
  866. // add a timeline link
  867. $.appendChildren($('.post-menu'), [
  868. $.create('span.lsep'),
  869. $.create('a', {href: `/posts/${pv.post.id}/timeline`}, 'timeline'),
  870. ]);
  871.  
  872. // prettify code blocks
  873. const codeBlocks = $.all('pre code');
  874. if (codeBlocks.length) {
  875. codeBlocks.forEach(e =>
  876. e.parentElement.classList.add('prettyprint'));
  877. if (!pv.prettify)
  878. initPrettyPrint((pv.prettify = {}));
  879. if (typeof pv.prettify.prettyPrint === 'function')
  880. pv.prettify.prettyPrint(null, pv.body);
  881. }
  882.  
  883. const leftovers = $.all('style, link, script, .post-menu .lsep + .lsep');
  884. for (const el of leftovers) {
  885. if (el.classList.contains(Styles.REUSABLE))
  886. el.classList.remove(Styles.REUSABLE);
  887. else
  888. el.remove();
  889. }
  890.  
  891. pv.post.html = null;
  892. pv.post.core = null;
  893. pv.post.renderParts = null;
  894. pv.post.answers = null;
  895. pv.post.answerShelf = null;
  896. }
  897.  
  898. /** @param {Element} container */
  899. static previewableLinks(container) {
  900. for (const a of $.all('a:not(.SEpreviewable)', container)) {
  901. let href = a.getAttribute('href');
  902. if (!href)
  903. continue;
  904. if (!href.includes('://')) {
  905. href = a.href;
  906. a.setAttribute('href', href);
  907. }
  908. if (Detector.rxPreviewablePost.test(href)) {
  909. a.removeAttribute('title');
  910. a.classList.add('SEpreviewable');
  911. }
  912. }
  913. }
  914.  
  915. /** @param {Element} container */
  916. static hoverableUsers(container) {
  917. for (const a of $.all('a[href*="/users/"]', container)) {
  918. if (Detector.rxPreviewableSite.test(a.href) &&
  919. a.pathname.match(/^\/users\/\d+/)) {
  920. a.onmouseover = UserCard.onUserLinkHovered;
  921. a.classList.add(`${ID}-userLink`);
  922. }
  923. }
  924. }
  925.  
  926. /** @param {Element} el */
  927. static _answer(el) {
  928. const shortUrl = $('.js-share-link', el).href.replace(/(\d+)\/\d+/, '$1');
  929. const extraClasses =
  930. (el.matches(pv.post.selector) ? ' SEpreviewed' : '') +
  931. (el.matches('.deleted-answer') ? ' deleted-answer' : '') +
  932. (el.matches('.accepted-answer') ? ` ${ID}-accepted` : '');
  933. const author = $('.post-signature:last-child', el);
  934. const title =
  935. $.text('.user-details a', author) +
  936. ' (rep ' +
  937. $.text('.reputation-score', author) +
  938. ')\n' +
  939. $.text('.user-action-time', author);
  940. let gravatar = $('img, .anonymous-gravatar, .community-wiki', author);
  941. if (gravatar && Security.noImages)
  942. Security.embedImages(gravatar);
  943. if (gravatar && gravatar.src)
  944. gravatar = $.create('img', {src: gravatar.src});
  945. const a = $.create('a', {
  946. href: shortUrl,
  947. title: title,
  948. className: 'SEpreviewable' + extraClasses,
  949. textContent: $.text('.js-vote-count', el).replace(/^0$/, '\xA0') + ' ',
  950. children: gravatar,
  951. });
  952. return [a, ' '];
  953. }
  954.  
  955. static _votes() {
  956. const votes = $.text('.js-vote-count', pv.post.core.closest('.post-layout'));
  957. if (Number(votes))
  958. return $.create('b', `${votes} vote${Math.abs(votes) >= 2 ? 's' : ''}`);
  959. }
  960. static _questionMeta() {
  961. try {
  962. return [...$('time', pv.post.doc).closest('.grid').children]
  963. .map(el => el.textContent.trim())
  964. .map((s, i) => (i ? s.toLowerCase() : s))
  965. .join(', ');
  966. } catch (e) {
  967. return '';
  968. }
  969. }
  970.  
  971. static _answerMeta() {
  972. return $.all('.user-action-time', pv.post.core.closest('.answer'))
  973. .reverse()
  974. .map($.text)
  975. .join(', ');
  976. }
  977. }
  978.  
  979.  
  980. class UserCard {
  981.  
  982. _fadeIn() {
  983. this._retakeId(this);
  984. $.setStyle(this.element,
  985. ['opacity', '0'],
  986. ['display', 'block']);
  987. this.timer = setTimeout(() => {
  988. if (this.timer)
  989. $.setStyle(this.element, ['opacity', '1']);
  990. });
  991. }
  992.  
  993. _retakeId() {
  994. if (this.element.id !== 'user-menu') {
  995. const oldCard = $('#user-menu');
  996. if (oldCard)
  997. oldCard.id = oldCard.style.display = '';
  998. this.element.id = 'user-menu';
  999. }
  1000. }
  1001.  
  1002. // 'this' is the hoverable link enclosing the user's name/avatar
  1003. static onUserLinkHovered() {
  1004. clearTimeout(this[EXPANDO]);
  1005. this[EXPANDO] = setTimeout(UserCard._show, PREVIEW_DELAY * 2, this);
  1006. }
  1007.  
  1008. /** @param {HTMLAnchorElement} a */
  1009. static async _show(a) {
  1010. if (!a.matches(':hover'))
  1011. return;
  1012. const el = a.nextElementSibling;
  1013. const card = el && el.matches(`.${ID}-userCard`) && el[EXPANDO] ||
  1014. await UserCard._create(a);
  1015. card._fadeIn();
  1016. }
  1017.  
  1018. /** @param {HTMLAnchorElement} a */
  1019. static async _create(a) {
  1020. const url = a.origin + '/users/user-info/' + Urler.getFirstNumber(a);
  1021. let {html} = Cache.read(url) || {};
  1022. if (!html) {
  1023. html = (await Urler.get(url)).responseText;
  1024. Cache.write({url, html, cacheDuration: CACHE_DURATION * 100});
  1025. }
  1026.  
  1027. const dom = Util.parseHtml(html);
  1028. if (Security.noImages)
  1029. Security.embedImages(dom);
  1030.  
  1031. const b = a.getBoundingClientRect();
  1032. const pb = pv.parts.getBoundingClientRect();
  1033. const left = Math.min(b.left - 20, pb.right - 350) - pb.left + 'px';
  1034. const isClipped = b.bottom + 100 > pb.bottom;
  1035.  
  1036. const el = $.create(`#user-menu-tmp.${ID}-userCard`, {
  1037. attributes: {
  1038. style: `left: ${left} !important;` +
  1039. (isClipped ? 'margin-top: -5rem !important;' : ''),
  1040. },
  1041. onmouseout: UserCard._onMouseOut,
  1042. children: dom.body.children,
  1043. after: a,
  1044. });
  1045.  
  1046. const card = new UserCard(el);
  1047. Object.defineProperty(el, EXPANDO, {value: card});
  1048. card.element = el;
  1049. return card;
  1050. }
  1051.  
  1052. /** @param {MouseEvent} e */
  1053. static _onMouseOut(e) {
  1054. if (this.matches(':hover') ||
  1055. this.style.opacity === '0' /* fading out already */)
  1056. return;
  1057.  
  1058. const self = /** @type {UserCard} */ this[EXPANDO];
  1059. clearTimeout(self.timer);
  1060. self.timer = 0;
  1061.  
  1062. Util.fadeOut(this);
  1063. }
  1064. }
  1065.  
  1066.  
  1067. class Sizer {
  1068.  
  1069. static init() {
  1070. Preview.setHeight(GM_getValue('height', innerHeight / 3) >> 0);
  1071. }
  1072.  
  1073. /** @param {MouseEvent} e */
  1074. static onMouseDown(e) {
  1075. if (e.button !== 0 || Util.hasKeyModifiers(e))
  1076. return;
  1077. Sizer._heightDelta = innerHeight - e.clientY - pv.frame.clientHeight;
  1078. $.on('mousemove', document, Sizer._onMouseMove);
  1079. $.on('mouseup', document, Sizer._onMouseUp);
  1080. }
  1081.  
  1082. /** @param {MouseEvent} e */
  1083. static _onMouseMove(e) {
  1084. Preview.setHeight(innerHeight - e.clientY - Sizer._heightDelta);
  1085. getSelection().removeAllRanges();
  1086. }
  1087.  
  1088. /** @param {MouseEvent} e */
  1089. static _onMouseUp(e) {
  1090. GM_setValue('height', pv.frame.clientHeight);
  1091. $.off('mouseup', document, Sizer._onMouseUp);
  1092. $.off('mousemove', document, Sizer._onMouseMove);
  1093. }
  1094. }
  1095.  
  1096.  
  1097. class ScrollLock {
  1098.  
  1099. static enable() {
  1100. if (ScrollLock.active)
  1101. return;
  1102. ScrollLock.active = true;
  1103. ScrollLock.x = scrollX;
  1104. ScrollLock.y = scrollY;
  1105. $.on('mouseover', document.body, ScrollLock._onMouseOver);
  1106. $.on('scroll', document, ScrollLock._onScroll);
  1107. }
  1108.  
  1109. static disable() {
  1110. ScrollLock.active = false;
  1111. $.off('mouseover', document.body, ScrollLock._onMouseOver);
  1112. $.off('scroll', document, ScrollLock._onScroll);
  1113. }
  1114.  
  1115. static _onMouseOver() {
  1116. if (ScrollLock.active)
  1117. ScrollLock.disable();
  1118. }
  1119.  
  1120. static _onScroll() {
  1121. scrollTo(ScrollLock.x, ScrollLock.y);
  1122. }
  1123. }
  1124.  
  1125.  
  1126. class Security {
  1127.  
  1128. static init() {
  1129. if (Detector.isStackExchangePage) {
  1130. Security.checked = true;
  1131. Security.check = null;
  1132. }
  1133. Security.init = true;
  1134. }
  1135.  
  1136. static async check() {
  1137. Security.noImages = false;
  1138. Security._resolveOnReady = [];
  1139. Security._imageCache = new Map();
  1140.  
  1141. const {headers} = await fetch(location.href, {
  1142. method: 'HEAD',
  1143. cache: 'force-cache',
  1144. mode: 'same-origin',
  1145. credentials: 'same-origin',
  1146. });
  1147. const csp = headers.get('Content-Security-Policy');
  1148. const imgSrc = /(?:^|[\s;])img-src\s+([^;]+)/i.test(csp) && RegExp.$1.trim();
  1149. if (imgSrc)
  1150. Security.noImages = !/(^\s)(\*|https?:)(\s|$)/.test(imgSrc);
  1151.  
  1152. Security._resolveOnReady.forEach(fn => fn());
  1153. Security._resolveOnReady = null;
  1154. Security.checked = true;
  1155. Security.check = null;
  1156. }
  1157.  
  1158. /** @return Promise<void> */
  1159. static ready() {
  1160. return Security.checked ?
  1161. Promise.resolve() :
  1162. new Promise(done => Security._resolveOnReady.push(done));
  1163. }
  1164.  
  1165. static embedImages(...containers) {
  1166. for (const container of containers) {
  1167. if (!container)
  1168. continue;
  1169. if (Util.isIterable(container)) {
  1170. Security.embedImages(...container);
  1171. continue;
  1172. }
  1173. if (container.localName === 'img') {
  1174. Security._embedImage(container);
  1175. continue;
  1176. }
  1177. for (const img of container.getElementsByTagName('img'))
  1178. Security._embedImage(img);
  1179. }
  1180. }
  1181.  
  1182. static _embedImage(img) {
  1183. const src = img.src;
  1184. if (!src || src.startsWith('data:'))
  1185. return;
  1186. const data = Security._imageCache.get(src);
  1187. const alreadyFetching = Array.isArray(data);
  1188. if (alreadyFetching) {
  1189. data.push(img);
  1190. } else if (data) {
  1191. img.src = data;
  1192. return;
  1193. } else {
  1194. Security._imageCache.set(src, [img]);
  1195. Security._fetchImage(src);
  1196. }
  1197. $.setStyle(img, ['visibility', 'hidden']);
  1198. img.dataset.src = src;
  1199. img.removeAttribute('src');
  1200. }
  1201.  
  1202. static async _fetchImage(src) {
  1203. const r = await Urler.get({url: src, responseType: 'blob'});
  1204. const type = Util.getResponseMimeType(r.responseHeaders);
  1205. const blob = r.response;
  1206. const blobType = blob.type;
  1207. let dataUri = await Util.blobToBase64(blob);
  1208. if (blobType !== type)
  1209. dataUri = 'data:' + type + dataUri.slice(dataUri.indexOf(';'));
  1210.  
  1211. const images = Security._imageCache.get(src);
  1212. Security._imageCache.set(src, dataUri);
  1213.  
  1214. let detached = false;
  1215. for (const el of images) {
  1216. el.src = dataUri;
  1217. el.style.removeProperty('visibility');
  1218. if (!detached && el.ownerDocument !== document)
  1219. detached = true;
  1220. }
  1221.  
  1222. if (detached) {
  1223. for (const el of $.all(`img[data-src="${src}"]`)) {
  1224. el.src = dataUri;
  1225. el.style.removeProperty('visibility');
  1226. }
  1227. }
  1228. }
  1229. }
  1230.  
  1231.  
  1232. // eslint-disable-next-line no-redeclare
  1233. class Cache {
  1234.  
  1235. static init() {
  1236. Cache.timers = new Map();
  1237. setTimeout(Cache._cleanup, 10e3);
  1238. }
  1239.  
  1240. static read(url) {
  1241. const keyUrl = Urler.makeCacheable(url);
  1242. const [time, expires, finalUrl = url] = (localStorage[keyUrl] || '').split('\t');
  1243. const keyFinalUrl = Urler.makeCacheable(finalUrl);
  1244. return expires > Date.now() && {
  1245. time,
  1246. finalUrl,
  1247. html: LZStringUnsafe.decompressFromUTF16(localStorage[keyFinalUrl + '\thtml']),
  1248. };
  1249. }
  1250.  
  1251. // standard keyUrl = time,expiry
  1252. // keyUrl\thtml = html
  1253. // redirected keyUrl = time,expiry,finalUrl
  1254. // keyFinalUrl = time,expiry
  1255. // keyFinalUrl\thtml = html
  1256. static write({url, finalUrl, html, cacheDuration = CACHE_DURATION}) {
  1257.  
  1258. cacheDuration = Math.max(CACHE_DURATION, Math.min(0x7FFF0000, cacheDuration >> 0));
  1259. finalUrl = (finalUrl || url).replace(/[?#].*/, '');
  1260.  
  1261. const keyUrl = Urler.makeCacheable(url);
  1262. const keyFinalUrl = Urler.makeCacheable(finalUrl);
  1263. const lz = LZStringUnsafe.compressToUTF16(html);
  1264.  
  1265. if (!Util.tryCatch(Cache._writeRaw, keyFinalUrl + '\thtml', lz)) {
  1266. Cache._cleanup({aggressive: true});
  1267. if (!Util.tryCatch(Cache._writeRaw, keyFinalUrl + '\thtml', lz))
  1268. return Util.error('localStorage write error');
  1269. }
  1270.  
  1271. const time = Date.now();
  1272. const expiry = time + cacheDuration;
  1273. localStorage[keyFinalUrl] = time + '\t' + expiry;
  1274. if (keyUrl !== keyFinalUrl)
  1275. localStorage[keyUrl] = time + '\t' + expiry + '\t' + finalUrl;
  1276.  
  1277. const t = setTimeout(Cache._delete, cacheDuration + 1000,
  1278. keyUrl,
  1279. keyFinalUrl,
  1280. keyFinalUrl + '\thtml');
  1281.  
  1282. for (const url of [keyUrl, keyFinalUrl]) {
  1283. clearTimeout(Cache.timers.get(url));
  1284. Cache.timers.set(url, t);
  1285. }
  1286. }
  1287.  
  1288. static _writeRaw(k, v) {
  1289. localStorage[k] = v;
  1290. return true;
  1291. }
  1292.  
  1293. static _delete(...keys) {
  1294. for (const k of keys) {
  1295. delete localStorage[k];
  1296. Cache.timers.delete(k);
  1297. }
  1298. }
  1299.  
  1300. static _cleanup({aggressive = false} = {}) {
  1301. for (const k in localStorage) {
  1302. if ((k.startsWith('http://') || k.startsWith('https://')) &&
  1303. !k.includes('\t')) {
  1304. const [, expires, url] = (localStorage[k] || '').split('\t');
  1305. if (Number(expires) > Date.now() && !aggressive)
  1306. break;
  1307. if (url) {
  1308. delete localStorage[url];
  1309. Cache.timers.delete(url);
  1310. }
  1311. delete localStorage[(url || k) + '\thtml'];
  1312. delete localStorage[k];
  1313. Cache.timers.delete(k);
  1314. }
  1315. }
  1316. }
  1317. }
  1318.  
  1319.  
  1320. class Urler {
  1321.  
  1322. static init() {
  1323. Urler.xhr = null;
  1324. Urler.xhrNoSSL = new Set();
  1325. Urler.init = true;
  1326. }
  1327.  
  1328. static getFirstNumber(url) {
  1329. if (typeof url === 'string')
  1330. url = new URL(url);
  1331. return url.pathname.match(/\/(\d+)/)[1];
  1332. }
  1333.  
  1334. static makeHttps(url) {
  1335. if (!url)
  1336. return '';
  1337. if (url.startsWith('http:'))
  1338. return 'https:' + url.slice(5);
  1339. return url;
  1340. }
  1341.  
  1342. // strips queries and hashes and anything after the main part
  1343. // https://site/questions/NNNNNN/title/
  1344. static makeCacheable(url) {
  1345. return url
  1346. .replace(/(\/q(?:uestions)?\/\d+\/[^/]+).*/, '$1')
  1347. .replace(/(\/a(?:nswers)?\/\d+).*/, '$1')
  1348. .replace(/[?#].*$/, '');
  1349. }
  1350.  
  1351. static get(options) {
  1352. if (!options.url)
  1353. options = {url: options, method: 'GET'};
  1354. if (!options.method)
  1355. options = Object.assign({method: 'GET'}, options);
  1356.  
  1357. let url = options.url;
  1358. const hostname = new URL(url).hostname;
  1359.  
  1360. if (Urler.xhrNoSSL.has(hostname)) {
  1361. url = url.replace(/^https/, 'http');
  1362. } else {
  1363. url = Urler.makeHttps(url);
  1364. const _onerror = options.onerror;
  1365. options.onerror = () => {
  1366. options.onerror = _onerror;
  1367. options.url = url.replace(/^https/, 'http');
  1368. Urler.xhrNoSSL.add(hostname);
  1369. return Urler.get(options);
  1370. };
  1371. }
  1372.  
  1373. return new Promise(resolve => {
  1374. let xhr;
  1375. options.onload = r => {
  1376. if (pv.xhr === xhr)
  1377. pv.xhr = null;
  1378. resolve(r);
  1379. };
  1380. options.url = url;
  1381. xhr = pv.xhr = GM_xmlhttpRequest(options);
  1382. });
  1383. }
  1384. }
  1385.  
  1386.  
  1387. class Util {
  1388.  
  1389. static tryCatch(fn, ...args) {
  1390. try {
  1391. return fn(...args);
  1392. } catch (e) {}
  1393. }
  1394.  
  1395. static isIterable(o) {
  1396. return typeof o === 'object' && Symbol.iterator in o;
  1397. }
  1398.  
  1399. static parseHtml(html) {
  1400. if (!Util.parser)
  1401. Util.parser = new DOMParser();
  1402. return Util.parser.parseFromString(html, 'text/html');
  1403. }
  1404.  
  1405. static extractTime(element) {
  1406. return new Date(element.title).getTime();
  1407. }
  1408.  
  1409. static getResponseMimeType(headers) {
  1410. return headers.match(/^\s*content-type:\s*(.*)|$/mi)[1] ||
  1411. 'image/png';
  1412. }
  1413.  
  1414. static getResponseDate(headers) {
  1415. try {
  1416. return new Date(headers.match(/^\s*date:\s*(.*)/mi)[1]);
  1417. } catch (e) {}
  1418. }
  1419.  
  1420. static blobToBase64(blob) {
  1421. return new Promise((resolve, reject) => {
  1422. const reader = new FileReader();
  1423. reader.onerror = reject;
  1424. reader.onload = e => resolve(e.target.result);
  1425. reader.readAsDataURL(blob);
  1426. });
  1427. }
  1428.  
  1429. static async sha256(str) {
  1430. if (!pv.utf8encoder)
  1431. pv.utf8encoder = new TextEncoder('utf-8');
  1432. const buf = await crypto.subtle.digest('SHA-256', pv.utf8encoder.encode(str));
  1433. const blob = new Blob([buf]);
  1434. const url = await Util.blobToBase64(blob);
  1435. return url.slice(url.indexOf(',') + 1);
  1436. }
  1437.  
  1438. /** @param {KeyboardEvent} e */
  1439. static hasKeyModifiers(e) {
  1440. return e.ctrlKey || e.altKey || e.shiftKey || e.metaKey;
  1441. }
  1442.  
  1443. static fadeOut(el, transition) {
  1444. return new Promise(resolve => {
  1445. if (transition) {
  1446. if (typeof transition === 'number')
  1447. transition = `opacity ${transition}s ease-in-out`;
  1448. $.setStyle(el, ['transition', transition]);
  1449. setTimeout(doFadeOut);
  1450. } else {
  1451. doFadeOut();
  1452. }
  1453. function doFadeOut() {
  1454. $.setStyle(el, ['opacity', '0']);
  1455. $.on('transitionend', el, done);
  1456. $.on('visibilitychange', el, done);
  1457. }
  1458. function done() {
  1459. $.off('transitionend', el, done);
  1460. $.off('visibilitychange', el, done);
  1461. if (el.style.opacity === '0')
  1462. $.setStyle(el, ['display', 'none']);
  1463. resolve();
  1464. }
  1465. });
  1466. }
  1467.  
  1468. /** @param {KeyboardEvent} e */
  1469. static consumeEsc(e) {
  1470. if (e.key === 'Escape')
  1471. e.preventDefault();
  1472. }
  1473.  
  1474. static error(...args) {
  1475. console.error(GM_info.script.name, ...args);
  1476. }
  1477. }
  1478.  
  1479.  
  1480. class Styles {
  1481.  
  1482. static init(isDark) {
  1483. if (Styles.isDark === isDark)
  1484. return;
  1485.  
  1486. Styles.isDark = isDark;
  1487. Styles.REUSABLE = `${ID}-reusable`;
  1488.  
  1489. const KBD_COLOR = '#0008';
  1490.  
  1491. // language=HTML
  1492. const SVG_ARROW = btoa(`
  1493. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
  1494. <path stroke="${KBD_COLOR}" stroke-width="3" fill="none"
  1495. d="M2.5,8.5H15 M9,2L2.5,8.5L9,15"/>
  1496. </svg>`
  1497. .replace(/>\s+</g, '><')
  1498. .replace(/[\r\n]/g, ' ')
  1499. .replace(/\s\s+/g, ' ')
  1500. .trim()
  1501. );
  1502.  
  1503. const IMPORTANT = '!important;';
  1504.  
  1505. // language=CSS
  1506. pv.stylesOverride = [
  1507. `
  1508. :host {
  1509. all: initial;
  1510. border-color: transparent;
  1511. display: none;
  1512. opacity: 0;
  1513. height: 33%;
  1514. transition: opacity .25s cubic-bezier(.88,.02,.92,.66),
  1515. border-color .25s ease-in-out;
  1516. }
  1517. `,
  1518.  
  1519. `
  1520. :host {
  1521. box-sizing: content-box;
  1522. width: ${WIDTH}px;
  1523. min-height: ${MIN_HEIGHT}px;
  1524. position: fixed;
  1525. right: 0;
  1526. bottom: 0;
  1527. padding: 0;
  1528. margin: 0;
  1529. background: white;
  1530. box-shadow: 0 0 100px rgba(0,0,0,0.5);
  1531. z-index: 999999;
  1532. border-width: ${TOP_BORDER}px ${BORDER}px ${BORDER}px;
  1533. border-style: solid;
  1534. }
  1535. :host(:not([style*="opacity: 1"])) {
  1536. pointer-events: none;
  1537. }
  1538. :host([\\type$="question"].\\hasAnswerShelf) {
  1539. border-image: linear-gradient(
  1540. ${colors.question.back} 66%,
  1541. ${colors.answer.back}) 1 1;
  1542. }
  1543. `.replace(/;/g, IMPORTANT),
  1544.  
  1545. ...Object.entries(colors).map(([type, colors]) => `
  1546. :host([\\type$="${type}"]) {
  1547. border-color: ${colors.back} !important;
  1548. }
  1549. `),
  1550.  
  1551. `
  1552. #\\body {
  1553. min-width: unset!important;
  1554. box-shadow: none!important;
  1555. padding: 0!important;
  1556. margin: 0!important;
  1557. background: ${colors.body.back}!important;
  1558. color: ${colors.body.fore}!important;
  1559. display: flex;
  1560. flex-direction: column;
  1561. height: 100%;
  1562. }
  1563.  
  1564. #\\title {
  1565. all: unset;
  1566. display: block;
  1567. padding: 12px ${PADDING}px;
  1568. font-weight: bold;
  1569. font-size: 18px;
  1570. line-height: 1.2;
  1571. cursor: pointer;
  1572. }
  1573. #\\title:hover {
  1574. text-decoration: underline;
  1575. text-decoration-skip: ink;
  1576. }
  1577. #\\title:hover + #\\meta {
  1578. opacity: 1.0;
  1579. }
  1580.  
  1581. #\\meta {
  1582. position: absolute;
  1583. font: bold 14px/${TOP_BORDER}px sans-serif;
  1584. height: ${TOP_BORDER}px;
  1585. top: -${TOP_BORDER}px;
  1586. left: -${BORDER}px;
  1587. right: ${BORDER * 2}px;
  1588. padding: 0 0 0 ${BORDER + PADDING}px;
  1589. display: flex;
  1590. align-items: center;
  1591. cursor: s-resize;
  1592. }
  1593. #\\meta b {
  1594. height: ${TOP_BORDER}px;
  1595. display: inline-block;
  1596. padding: 0 6px;
  1597. margin-left: -6px;
  1598. margin-right: 3px;
  1599. }
  1600.  
  1601. #\\close {
  1602. position: absolute;
  1603. top: -${TOP_BORDER}px;
  1604. right: -${BORDER}px;
  1605. width: ${BORDER * 3}px;
  1606. flex: none;
  1607. cursor: pointer;
  1608. padding: .5ex 1ex;
  1609. font: normal 15px/1.0 sans-serif;
  1610. color: #fff8;
  1611. }
  1612. #\\close:after {
  1613. content: "x";
  1614. }
  1615. #\\close:active {
  1616. background-color: rgba(0,0,0,.2);
  1617. }
  1618. #\\close:hover {
  1619. background-color: rgba(0,0,0,.1);
  1620. }
  1621.  
  1622. #\\parts {
  1623. position: relative;
  1624. overflow-y: overlay; /* will replace with scrollbar-gutter once it's implemented */
  1625. overflow-x: hidden;
  1626. flex-grow: 2;
  1627. outline: none;
  1628. margin: 0;
  1629. }
  1630. [\\type^="question"] #\\parts {
  1631. padding: ${(WIDTH - QUESTION_WIDTH) / 2}px !important;
  1632. }
  1633. [\\type^="answer"] #\\parts {
  1634. padding: ${(WIDTH - ANSWER_WIDTH) / 2}px !important;
  1635. }
  1636. #\\parts > .question-status {
  1637. margin: -${PADDING}px -${PADDING}px ${PADDING}px;
  1638. padding-left: ${PADDING}px;
  1639. }
  1640. #\\parts .question-originals-of-duplicate {
  1641. margin: -${PADDING}px -${PADDING}px ${PADDING}px;
  1642. padding: ${PADDING / 2 >> 0}px ${PADDING}px;
  1643. }
  1644. #\\parts > .question-status h2 {
  1645. font-weight: normal;
  1646. }
  1647. #\\parts a.SEpreviewable {
  1648. text-decoration: underline !important;
  1649. text-decoration-skip: ink;
  1650. }
  1651.  
  1652. #\\parts .comment-actions {
  1653. width: 20px !important;
  1654. }
  1655. #\\parts .comment-edit,
  1656. #\\parts .delete-tag,
  1657. #\\parts .comment-actions > :not(.comment-score) {
  1658. display: none;
  1659. }
  1660. #\\parts .comments {
  1661. border-top: none;
  1662. }
  1663. #\\parts .comments .comment:last-child .comment-text {
  1664. border-bottom: none;
  1665. }
  1666. #\\parts .comments .new-comment-highlight .comment-text {
  1667. -webkit-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1668. -moz-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1669. animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1670. }
  1671. #\\parts .post-menu > span {
  1672. opacity: .35;
  1673. }
  1674.  
  1675. #\\parts #user-menu {
  1676. position: absolute;
  1677. }
  1678. .\\userCard {
  1679. position: absolute;
  1680. display: none;
  1681. transition: opacity .25s cubic-bezier(.88,.02,.92,.66) .5s;
  1682. margin-top: -3rem;
  1683. }
  1684. #\\parts .wmd-preview a:not(.post-tag),
  1685. #\\parts .postcell a:not(.post-tag),
  1686. #\\parts .comment-copy a:not(.post-tag) {
  1687. border-bottom: none;
  1688. }
  1689.  
  1690. #\\answers-title {
  1691. margin: .5ex 1ex 0 0;
  1692. font-size: 18px;
  1693. line-height: 1.0;
  1694. float: left;
  1695. }
  1696. #\\answers-title p {
  1697. font-size: 11px;
  1698. font-weight: normal;
  1699. max-width: 8em;
  1700. line-height: 1.0;
  1701. margin: 1ex 0 0 0;
  1702. padding: 0;
  1703. }
  1704. #\\answers-title b,
  1705. #\\answers-title label {
  1706. background: linear-gradient(#fff8 30%, #fff);
  1707. width: 10px;
  1708. height: 10px;
  1709. padding: 2px;
  1710. margin-right: 2px;
  1711. box-shadow: 0 1px 3px #0008;
  1712. border-radius: 3px;
  1713. font-weight: normal;
  1714. display: inline-block;
  1715. vertical-align: middle;
  1716. }
  1717. #\\answers-title b::after {
  1718. content: "";
  1719. display: block;
  1720. width: 100%;
  1721. height: 100%;
  1722. background: url('data:image/svg+xml;base64,${SVG_ARROW}') no-repeat center;
  1723. }
  1724. #\\answers-title b[mirrored]::after {
  1725. transform: scaleX(-1);
  1726. }
  1727. #\\answers-title label {
  1728. width: auto;
  1729. color: ${KBD_COLOR};
  1730. }
  1731.  
  1732. #\\answers {
  1733. all: unset;
  1734. display: block;
  1735. padding: 10px 10px 10px ${PADDING}px;
  1736. font-weight: bold;
  1737. line-height: 1.0;
  1738. border-top: 4px solid ${colors.answer.back}5e;
  1739. background-color: ${colors.answer.back}5e;
  1740. color: ${colors.answer.fore};
  1741. word-break: break-word;
  1742. }
  1743. #\\answers a {
  1744. color: ${colors.answer.fore};
  1745. text-decoration: none;
  1746. font-size: 11px;
  1747. font-family: monospace;
  1748. width: 32px !important;
  1749. display: inline-block;
  1750. position: relative;
  1751. vertical-align: top;
  1752. margin: 0 1ex 1ex 0;
  1753. padding: 0 0 1.1ex 0;
  1754. }
  1755. [\\type*="deleted"] #\\answers a {
  1756. color: ${colors.deleted.fore};
  1757. }
  1758. #\\answers img {
  1759. width: 32px;
  1760. height: 32px;
  1761. }
  1762. #\\answers a.deleted-answer {
  1763. color: ${colors.deleted.fore};
  1764. background: transparent;
  1765. opacity: 0.25;
  1766. }
  1767. #\\answers a.deleted-answer:hover {
  1768. opacity: 1.0;
  1769. }
  1770. #\\answers a:hover:not(.SEpreviewed) {
  1771. text-decoration: underline;
  1772. text-decoration-skip: ink;
  1773. }
  1774. #\\answers a.SEpreviewed {
  1775. background-color: ${colors.answer.fore};
  1776. color: ${colors.answer.foreInv};
  1777. outline: 4px solid ${colors.answer.fore};
  1778. }
  1779. #\\answers a::after {
  1780. white-space: nowrap;
  1781. overflow: hidden;
  1782. text-overflow: ellipsis;
  1783. max-width: 40px;
  1784. position: absolute;
  1785. content: attr(title);
  1786. top: 44px;
  1787. left: 0;
  1788. font: normal .75rem/1.0 sans-serif;
  1789. opacity: .7;
  1790. }
  1791. #\\answers a:only-child::after {
  1792. max-width: calc(${WIDTH}px - 10em);
  1793. }
  1794. #\\answers a:hover::after {
  1795. opacity: 1;
  1796. }
  1797. .\\accepted::before {
  1798. content: "✔";
  1799. position: absolute;
  1800. display: block;
  1801. top: 1.3ex;
  1802. right: -0.7ex;
  1803. font-size: 32px;
  1804. color: #4bff2c;
  1805. text-shadow: 1px 2px 2px rgba(0,0,0,0.5);
  1806. }
  1807.  
  1808. @-webkit-keyframes highlight {
  1809. from {background: #ffcf78}
  1810. to {background: none}
  1811. }
  1812. `,
  1813.  
  1814. ...Object.keys(colors).map(s => `
  1815. #\\title {
  1816. background-color: ${colors[s].back}5e;
  1817. color: ${colors[s].fore};
  1818. }
  1819. #\\meta {
  1820. color: ${colors[s].fore};
  1821. }
  1822. #\\meta b {
  1823. color: ${colors[s].foreInv};
  1824. background: ${colors[s].fore};
  1825. }
  1826. #\\close {
  1827. color: ${colors[s].fore};
  1828. }
  1829. #\\parts::-webkit-scrollbar {
  1830. background-color: ${colors[s].back}19;
  1831. }
  1832. #\\parts::-webkit-scrollbar-thumb {
  1833. background-color: ${colors[s].back}32;
  1834. }
  1835. #\\parts::-webkit-scrollbar-thumb:hover {
  1836. background-color: ${colors[s].back}4b;
  1837. }
  1838. #\\parts::-webkit-scrollbar-thumb:active {
  1839. background-color: ${colors[s].back}c0;
  1840. }
  1841. `
  1842. // language=JS
  1843. .replace(/#\\/g, `[\\type$="${s}"] $&`)
  1844. ),
  1845.  
  1846. ...['deleted', 'closed'].map(s => /* language=CSS */ `
  1847. #\\answers {
  1848. border-top-color: ${colors[s].back}5e;
  1849. background-color: ${colors[s].back}5e;
  1850. color: ${colors[s].fore};
  1851. }
  1852. #\\answers a.SEpreviewed {
  1853. background-color: ${colors[s].fore};
  1854. color: ${colors[s].foreInv};
  1855. }
  1856. #\\answers a.SEpreviewed:after {
  1857. border-color: ${colors[s].fore};
  1858. }
  1859. `
  1860. // language=JS
  1861. .replace(/#\\/g, `[\\type$="${s}"] $&`)
  1862. ),
  1863. ].join('\n').replace(/\\/g, `${ID}-`);
  1864. }
  1865.  
  1866. static applyRemScale(id, css) {
  1867. const el = pv.styles.get(id);
  1868. if (pv.remScale && pv.remScale !== 1 && !pv.stylesScaled.has(id)) {
  1869. css = (css || el.textContent).replace(/([:\s])((?:\d*\.?)?\d+)(?=rem([;}\s]|\/\*))/gi,
  1870. (_, prev, size) => prev + (pv.remScale * size));
  1871. pv.stylesScaled.add(id);
  1872. }
  1873. el.textContent = css;
  1874. }
  1875. }
  1876.  
  1877. function $(selector, node = pv.shadow) {
  1878. return node && node.querySelector(selector);
  1879. }
  1880.  
  1881. Object.assign($, {
  1882.  
  1883. all(selector, node = pv.shadow) {
  1884. return node ? [...node.querySelectorAll(selector)] : [];
  1885. },
  1886.  
  1887. on(eventName, node, fn, options) {
  1888. return node.addEventListener(eventName, fn, options);
  1889. },
  1890.  
  1891. off(eventName, node, fn, options) {
  1892. return node.removeEventListener(eventName, fn, options);
  1893. },
  1894.  
  1895. remove(selector, node = pv.shadow) {
  1896. for (const el of node.querySelectorAll(selector))
  1897. el.remove();
  1898. },
  1899.  
  1900. text(selector, node = pv.shadow) {
  1901. const el = typeof selector === 'string' ?
  1902. node && node.querySelector(selector) :
  1903. selector;
  1904. return el ? el.textContent.trim() : '';
  1905. },
  1906.  
  1907. create(
  1908. selector,
  1909. opts = {},
  1910. children = opts.children ||
  1911. (typeof opts !== 'object' || Util.isIterable(opts)) && opts
  1912. ) {
  1913. const EOL = selector.length;
  1914. const idStart = (selector.indexOf('#') + 1 || EOL + 1) - 1;
  1915. const clsStart = (selector.indexOf('.', idStart < EOL ? idStart : 0) + 1 || EOL + 1) - 1;
  1916. const tagEnd = Math.min(idStart, clsStart);
  1917. const tag = (tagEnd < EOL ? selector.slice(0, tagEnd) : selector) || opts.tag || 'div';
  1918. const id = idStart < EOL && selector.slice(idStart + 1, clsStart) || opts.id || '';
  1919. const cls = clsStart < EOL && selector.slice(clsStart + 1).replace(/\./g, ' ') ||
  1920. opts.className ||
  1921. '';
  1922. const el = id && pv.shadow && pv.shadow.getElementById(id) ||
  1923. document.createElement(tag);
  1924. if (el.id !== id)
  1925. el.id = id;
  1926. if (el.className !== cls)
  1927. el.className = cls;
  1928. const hasOwnProperty = Object.hasOwnProperty;
  1929. for (const key in opts) {
  1930. if (!hasOwnProperty.call(opts, key))
  1931. continue;
  1932. const value = opts[key];
  1933. switch (key) {
  1934. case 'tag':
  1935. case 'id':
  1936. case 'className':
  1937. case 'children':
  1938. break;
  1939. case 'dataset': {
  1940. const dataset = el.dataset;
  1941. for (const k in value) {
  1942. if (hasOwnProperty.call(value, k)) {
  1943. const v = value[k];
  1944. if (dataset[k] !== v)
  1945. dataset[k] = v;
  1946. }
  1947. }
  1948. break;
  1949. }
  1950. case 'attributes': {
  1951. for (const k in value) {
  1952. if (hasOwnProperty.call(value, k)) {
  1953. const v = value[k];
  1954. if (el.getAttribute(k) !== v)
  1955. el.setAttribute(k, v);
  1956. }
  1957. }
  1958. break;
  1959. }
  1960. default:
  1961. if (el[key] !== value)
  1962. el[key] = value;
  1963. }
  1964. }
  1965. if (children) {
  1966. if (!hasOwnProperty.call(opts, 'textContent'))
  1967. el.textContent = '';
  1968. $.appendChildren(el, children);
  1969. }
  1970. let before, after, parent;
  1971. if ((before = opts.before) && before !== el.nextSibling && before !== el)
  1972. before.insertAdjacentElement('beforebegin', el);
  1973. else if ((after = opts.after) && after !== el.previousSibling && after !== el)
  1974. after.insertAdjacentElement('afterend', el);
  1975. else if ((parent = opts.parent) && parent !== el.parentNode)
  1976. parent.appendChild(el);
  1977. return el;
  1978. },
  1979.  
  1980. appendChild(parent, child, shouldClone = true) {
  1981. if (!child)
  1982. return;
  1983. if (child.nodeType)
  1984. return parent.appendChild(shouldClone ? document.importNode(child, true) : child);
  1985. if (Util.isIterable(child))
  1986. return $.appendChildren(parent, child, shouldClone);
  1987. else
  1988. return parent.appendChild(document.createTextNode(child));
  1989. },
  1990.  
  1991. appendChildren(newParent, children) {
  1992. if (!Util.isIterable(children))
  1993. return $.appendChild(newParent, children);
  1994. const fragment = document.createDocumentFragment();
  1995. for (const el of children)
  1996. $.appendChild(fragment, el);
  1997. return newParent.appendChild(fragment);
  1998. },
  1999.  
  2000. setStyle(el, ...props) {
  2001. const style = el.style;
  2002. const s0 = style.cssText;
  2003. let s = s0;
  2004.  
  2005. for (const p of props) {
  2006. if (!p)
  2007. continue;
  2008.  
  2009. const [name, value, important = true] = p;
  2010. const rValue = value + (important && value ? ' !important' : '');
  2011. const rx = new RegExp(`(^|[\\s;])${name}(\\s*:\\s*)([^;]*?)(\\s*(?:;|$))`, 'i');
  2012. const m = rx.exec(s);
  2013.  
  2014. if (!m && value) {
  2015. const rule = name + ': ' + rValue;
  2016. s += !s || s.endsWith(';') ? rule : '; ' + rule;
  2017. continue;
  2018. }
  2019.  
  2020. if (!m && !value)
  2021. continue;
  2022.  
  2023. const [, sep1, sep2, oldValue, sep3] = m;
  2024. if (value !== oldValue) {
  2025. s = s.slice(0, m.index) +
  2026. sep1 + (rValue ? name + sep2 + rValue + sep3 : '') +
  2027. s.slice(m.index + m[0].length);
  2028. }
  2029. }
  2030.  
  2031. if (s !== s0)
  2032. style.cssText = s;
  2033. },
  2034. });