SE Preview on hover

Shows preview of the linked questions/answers on hover

当前为 2019-01-10 提交的版本,查看 最新版本

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址