SE Preview on hover

Shows preview of the linked questions/answers on hover

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