SE Preview on hover

Shows preview of the linked questions/answers on hover

当前为 2018-07-26 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name SE Preview on hover
  3. // @description Shows preview of the linked questions/answers on hover
  4. // @version 0.6.3
  5. // @author wOxxOm
  6. // @namespace wOxxOm.scripts
  7. // @license MIT License
  8. // @match *://*.stackoverflow.com/*
  9. // @match *://*.superuser.com/*
  10. // @match *://*.serverfault.com/*
  11. // @match *://*.askubuntu.com/*
  12. // @match *://*.stackapps.com/*
  13. // @match *://*.mathoverflow.net/*
  14. // @match *://*.stackexchange.com/*
  15. // @include /https?:\/\/www\.?google(\.com?)?(\.\w\w)?\/(webhp|q|.*?[?#]q=|search).*/
  16. // @match *://*.bing.com/*
  17. // @match *://*.yahoo.com/*
  18. // @match *://*.yahoo.co.jp/*
  19. // @match *://*.yahoo.cn/*
  20. // @include /https?:\/\/(\w+\.)*yahoo.(com|\w\w(\.\w\w)?)\/.*/
  21. // @require https://greasyfork.org/scripts/12228/code/setMutationHandler.js
  22. // @require https://greasyfork.org/scripts/27531/code/LZString-2xspeedup.js
  23. // @grant GM_addStyle
  24. // @grant GM_xmlhttpRequest
  25. // @grant GM_getValue
  26. // @grant GM_setValue
  27. // @connect stackoverflow.com
  28. // @connect superuser.com
  29. // @connect serverfault.com
  30. // @connect askubuntu.com
  31. // @connect stackapps.com
  32. // @connect mathoverflow.net
  33. // @connect stackexchange.com
  34. // @connect cdn.sstatic.net
  35. // @run-at document-end
  36. // @noframes
  37. // ==/UserScript==
  38.  
  39. /* jshint lastsemic:true, multistr:true, laxbreak:true, -W030, -W041, -W084 */
  40.  
  41. const PREVIEW_DELAY = 200;
  42. const BUSY_CURSOR_DELAY = 1000;
  43. const CACHE_DURATION = 1 * 60 * 1000; // 1 minute for the recently active posts, scales up logarithmically
  44. const MIN_HEIGHT = 400; // px
  45. const COLORS = {
  46. question: {
  47. backRGB: '80, 133, 195',
  48. fore: '#265184',
  49. },
  50. answer: {
  51. backRGB: '112, 195, 80',
  52. fore: '#3f7722',
  53. foreInv: 'white',
  54. },
  55. deleted: {
  56. backRGB: '181, 103, 103',
  57. fore: 'rgb(181, 103, 103)',
  58. foreInv: 'white',
  59. },
  60. closed: {
  61. backRGB: '255, 206, 93',
  62. fore: 'rgb(194, 136, 0)',
  63. foreInv: 'white',
  64. },
  65. };
  66.  
  67. let xhr;
  68. const xhrNoSSL = new Set();
  69. const preview = {
  70. frame: null,
  71. link: null,
  72. hover: {x:0, y:0},
  73. timer: 0,
  74. timerCursor: 0,
  75. stylesOverride: '',
  76. };
  77. const lockScroll = {};
  78.  
  79. const {full: rxPreviewable, siteOnly: rxPreviewableSite} = getURLregexForMatchedSites();
  80. const thisPageUrls = getPageBaseUrls(location.href);
  81.  
  82. initStyles();
  83. initPolyfills();
  84. setMutationHandler('a, .question-summary .answered, .question-summary .answered-accepted', onLinkAdded, {processExisting: true});
  85. setTimeout(cleanupCache, 10000);
  86.  
  87. /**************************************************************/
  88.  
  89. function onLinkAdded(links) {
  90. for (let i = 0, link; (link = links[i++]); ) {
  91. if (link.localName != 'a' || isLinkPreviewable(link)) {
  92. link.removeAttribute('title');
  93. $on('mouseover', link, onLinkHovered);
  94. }
  95. }
  96. }
  97.  
  98. function onLinkHovered(e) {
  99. if (hasKeyModifiers(e))
  100. return;
  101. preview.link = this;
  102. $on('mousemove', this, onLinkMouseMove);
  103. $on('mouseout', this, abortPreview);
  104. $on('mousedown', this, abortPreview);
  105. restartPreviewTimer(this);
  106. }
  107.  
  108. function onLinkMouseMove(e) {
  109. let stoppedMoving = Math.abs(preview.hover.x - e.clientX) < 2 &&
  110. Math.abs(preview.hover.y - e.clientY) < 2;
  111. if (!stoppedMoving)
  112. return;
  113. preview.hover.x = e.clientX;
  114. preview.hover.y = e.clientY;
  115. restartPreviewTimer(this);
  116. }
  117.  
  118. function restartPreviewTimer(link) {
  119. clearTimeout(preview.timer);
  120. preview.timer = setTimeout(() => {
  121. preview.timer = 0;
  122. if (!link.matches(':hover'))
  123. return releaseLinkListeners(link);
  124. $off('mousemove', link, onLinkMouseMove);
  125. if (link.localName != 'a')
  126. link.href = $('a', link.closest('.question-summary')).href;
  127. downloadPreview(link);
  128. }, PREVIEW_DELAY);
  129. }
  130.  
  131. function abortPreview(e) {
  132. releaseLinkListeners(this);
  133. preview.timer = setTimeout(link => {
  134. if (link == preview.link && preview.frame && !preview.frame.matches(':hover'))
  135. preview.frame.contentWindow.postMessage('SEpreview-hidden', '*');
  136. }, PREVIEW_DELAY * 3, this);
  137. if (xhr)
  138. xhr.abort();
  139. if (this.style.cursor == 'wait')
  140. this.style.cursor = '';
  141. }
  142.  
  143. function releaseLinkListeners(link = preview.link) {
  144. $off('mousemove', link, onLinkMouseMove);
  145. $off('mouseout', link, abortPreview);
  146. $off('mousedown', link, abortPreview);
  147. stopTimers();
  148. }
  149.  
  150. function stopTimers(names) {
  151. for (let k in preview) {
  152. if (k.startsWith('timer') && preview[k]) {
  153. clearTimeout(preview[k]);
  154. preview[k] = 0;
  155. }
  156. }
  157. }
  158.  
  159. function fadeOut(element, transition) {
  160. return new Promise(resolve => {
  161. if (transition) {
  162. element.style.setProperty(
  163. 'transition',
  164. typeof transition == 'number' ? `opacity ${transition}s ease-in-out` : transition,
  165. 'important');
  166. setTimeout(doFadeOut);
  167. } else
  168. doFadeOut();
  169.  
  170. function doFadeOut() {
  171. element.style.setProperty('opacity', '0', 'important');
  172. $on('transitionend', element, done);
  173. $on('visibilitychange', done);
  174. function done(e) {
  175. $off('transitionend', element, done);
  176. $off('visibilitychange', done);
  177. if (element.style.opacity == '0')
  178. element.style.setProperty('display', 'none', 'important');
  179. resolve();
  180. }
  181. }
  182. });
  183. }
  184.  
  185. function fadeIn(element) {
  186. element.style.setProperty('opacity', '0', 'important');
  187. element.style.setProperty('display', 'block', 'important');
  188. setTimeout(() => element.style.setProperty('opacity', '1', 'important'));
  189. }
  190.  
  191. function downloadPreview(link) {
  192. const showAnswers = link.localName != 'a';
  193. const cached = readCache(link.href);
  194. if (cached)
  195. return showPreview(Object.assign(cached, {showAnswers}));
  196.  
  197. preview.timerCursor = setTimeout(() => {
  198. preview.timerCursor = 0;
  199. link.style.setProperty('cursor', 'wait', 'important');
  200. }, BUSY_CURSOR_DELAY);
  201.  
  202. doXHR(link.href).then(r => {
  203. const html = r.responseText;
  204. const finalUrl = r.finalUrl;
  205. if (link.matches(':hover') || preview.frame && preview.frame.matches(':hover'))
  206. return {
  207. html,
  208. finalUrl,
  209. lastActivity: showPreview({finalUrl, html, showAnswers}),
  210. };
  211. }).then(({html, finalUrl, lastActivity} = {}) => {
  212. if (preview.timerCursor)
  213. clearTimeout(preview.timerCursor), preview.timerCursor = 0;
  214. if (link.style.cursor == 'wait')
  215. link.style.cursor = '';
  216. if (lastActivity) {
  217. const inactiveDays = Math.max(0, (Date.now() - lastActivity) / (24 * 3600 * 1000));
  218. const cacheDuration = CACHE_DURATION * Math.pow(Math.log(inactiveDays + 1) + 1, 2);
  219. setTimeout(writeCache, 1000, {url: link.href, finalUrl, html, cacheDuration});
  220. }
  221. });
  222. }
  223.  
  224. function initPreview() {
  225. preview.frame = document.createElement('iframe');
  226. preview.frame.id = 'SEpreview';
  227. document.body.appendChild(preview.frame);
  228. makeResizable();
  229.  
  230. lockScroll.attach = e => {
  231. if (lockScroll.pos)
  232. return;
  233. lockScroll.pos = {x: scrollX, y: scrollY};
  234. $on('scroll', document, lockScroll.run);
  235. $on('mouseover', document, lockScroll.detach);
  236. };
  237. lockScroll.run = e => scrollTo(lockScroll.pos.x, lockScroll.pos.y);
  238. lockScroll.detach = e => {
  239. if (!lockScroll.pos)
  240. return;
  241. lockScroll.pos = null;
  242. $off('mouseover', document, lockScroll.detach);
  243. $off('scroll', document, lockScroll.run);
  244. };
  245.  
  246. const killer = mutations => mutations.forEach(m => [...m.addedNodes].forEach(n => n.remove()));
  247. const killerMO = {
  248. head: new MutationObserver(killer),
  249. documentElement: new MutationObserver(killer),
  250. };
  251. preview.killInvaders = {
  252. start: () => Object.keys(killerMO).forEach(k => killerMO[k].observe(preview.frame.contentDocument[k], {childList: true})),
  253. stop: () => Object.keys(killerMO).forEach(k => killerMO[k].disconnect()),
  254. };
  255. }
  256.  
  257. function showPreview({finalUrl, html, doc, showAnswers}) {
  258. doc = doc || new DOMParser().parseFromString(html, 'text/html');
  259. if (!doc || !doc.head)
  260. return error('no HEAD in the document received for', finalUrl);
  261.  
  262. if (!$('base', doc))
  263. doc.head.insertAdjacentHTML('afterbegin', `<base href="${finalUrl}">`);
  264.  
  265. const answerIdMatch = !showAnswers
  266. ? finalUrl.match(/questions\/\d+\/[^\/]+\/(\d+)/)
  267. : [0, ($('[id^="answer-"]', doc) || {id:''}).id.replace(/^answer-/, '')];
  268. const isQuestion = !answerIdMatch;
  269. const postNumber = isQuestion ? finalUrl.match(/\d+/)[0] : answerIdMatch[1];
  270. const postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
  271. const post = $(postId + ' .post-text', doc);
  272. if (!post)
  273. return error('No parsable post found', doc);
  274. const isDeleted = !!post.closest('.deleted-answer');
  275. const title = $('meta[property="og:title"]', doc).content;
  276. const status = isQuestion && !$('.question-status', post) ? $('.question-status', doc) : null;
  277. const isClosed = $('.question-originals-of-duplicate, .close-as-off-topic-status-list, .close-status-suffix', doc);
  278. const comments = $(`${postId} .comments`, doc);
  279. const commentsHidden = +$('[data-remaining-comments-count]', comments).dataset.remainingCommentsCount;
  280. const commentsShowLink = commentsHidden && $(`${postId} .js-show-link.comments-link`, doc);
  281. const finalUrlOfQuestion = getCacheableUrl(finalUrl);
  282. const lastActivity = tryCatch(() => new Date($('.lastactivity-link', doc).title).getTime()) || Date.now();
  283. const answers = $$('.answer', doc);
  284. const hasAnswers = answers.length > (isQuestion ? 0 : 1);
  285.  
  286. markPreviewableLinks(doc);
  287. $$remove('script', doc);
  288.  
  289. if (!preview.frame)
  290. initPreview();
  291.  
  292. let pvDoc, pvWin;
  293. preview.frame.style.setProperty('display', '', 'important');
  294. preview.frame.setAttribute('SEpreview-type',
  295. isDeleted ? 'deleted' : isQuestion ? (isClosed ? 'closed' : 'question') : 'answer');
  296. preview.frame.classList.toggle('SEpreview-hasAnswers', hasAnswers);
  297.  
  298. return onFrameReady(preview.frame).then(
  299. () => {
  300. pvDoc = preview.frame.contentDocument;
  301. pvWin = preview.frame.contentWindow;
  302. initPolyfills(pvWin);
  303. preview.killInvaders.stop();
  304. })
  305. .then(addStyles)
  306. .then(render)
  307. .then(show)
  308. .then(() => lastActivity);
  309.  
  310. function markPreviewableLinks(container) {
  311. for (let link of $$('a:not(.SEpreviewable)', container)) {
  312. if (rxPreviewable.test(link.href)) {
  313. link.removeAttribute('title');
  314. link.classList.add('SEpreviewable');
  315. }
  316. }
  317. }
  318.  
  319. function markHoverableUsers(container) {
  320. for (let link of $$('a[href*="/users/"]', container)) {
  321. if (rxPreviewableSite.test(link.href) && link.pathname.match(/^\/users\/\d+/)) {
  322. link.onmouseover = loadUserCard;
  323. link.classList.add('SEpreview-userLink');
  324. }
  325. }
  326. }
  327.  
  328. function addStyles() {
  329. const SEpreviewStyles = $replaceOrCreate({
  330. id: 'SEpreviewStyles',
  331. tag: 'style', parent: pvDoc.head, className: 'SEpreview-reuse',
  332. innerHTML: preview.stylesOverride,
  333. });
  334. $replaceOrCreate($$('style', doc).map(e => ({
  335. id: 'SEpreview' + e.innerHTML.replace(/\W+/g, '').length,
  336. tag: 'style', before: SEpreviewStyles, className: 'SEpreview-reuse',
  337. innerHTML: e.innerHTML,
  338. })));
  339. return onStyleSheetsReady({
  340. doc: pvDoc,
  341. urls: $$('link[rel="stylesheet"]', doc).map(e => e.href),
  342. onBeforeRequest: preview.frame.style.opacity != '1' ? null : () => {
  343. preview.frame.style.setProperty('transition', 'border-color .5s ease-in-out', 'important');
  344. $on('transitionend', preview.frame, () => preview.frame.style.transition = '', {once: true});
  345. },
  346. }).then(els => {
  347. els.forEach(e => e && (e.className = 'SEpreview-reuse'));
  348. });
  349. }
  350.  
  351. function render() {
  352. pvDoc.body.setAttribute('SEpreview-type', preview.frame.getAttribute('SEpreview-type'));
  353.  
  354. $replaceOrCreate([{
  355. // base
  356. id: 'SEpreview-base', tag: 'base',
  357. parent: pvDoc.head,
  358. href: $('base', doc).href,
  359. }, {
  360. // title
  361. id: 'SEpreview-title', tag: 'a',
  362. parent: pvDoc.body, className: 'SEpreviewable',
  363. href: finalUrlOfQuestion,
  364. textContent: title,
  365. }, {
  366. // close button
  367. id: 'SEpreview-close',
  368. parent: pvDoc.body,
  369. title: 'Or press Esc key while the preview is focused (also when just shown)',
  370. }, {
  371. // vote count, date, views#
  372. id: 'SEpreview-meta',
  373. parent: pvDoc.body,
  374. innerHTML: [
  375. $text('.vote-count-post', post.closest('.post-layout')).replace(/(-?)(\d+)/,
  376. (s, sign, v) => s == '0' ? '' : `<b>${s}</b> vote${+v > 1 ? 's' : ''}, `),
  377. isQuestion
  378. ? $$('#qinfo tr', doc)
  379. .map(row => $$('.label-key', row).map($text).join(' '))
  380. .join(', ').replace(/^((.+?) (.+?), .+?), .+? \3$/, '$1')
  381. : [...$$('.user-action-time', post.closest('.answer'))]
  382. .reverse().map($text).join(', ')
  383. ].join('')
  384. }, {
  385. // content wrapper
  386. id: 'SEpreview-body',
  387. parent: pvDoc.body,
  388. className: isDeleted ? 'deleted-answer' : '',
  389. children: [status, post.parentElement, comments, commentsShowLink],
  390. }]);
  391.  
  392. // delinkify/remove non-functional items in post-menu
  393. $$remove('.short-link, .flag-post-link', pvDoc);
  394. $$('.post-menu a:not(.edit-post)', pvDoc).forEach(a => {
  395. if (a.children.length)
  396. a.outerHTML = `<span>${a.innerHTML}</span>`;
  397. else
  398. a.remove();
  399. });
  400.  
  401. // add a timeline link
  402. $('.post-menu', pvDoc).insertAdjacentHTML('beforeend',
  403. '<span class="lsep">|</span>' +
  404. `<a href="/posts/${postNumber}/timeline">timeline</a>`);
  405.  
  406. // prettify code blocks
  407. const codeBlocks = $$('pre code', pvDoc);
  408. if (codeBlocks.length) {
  409. codeBlocks.forEach(e => e.parentElement.classList.add('prettyprint'));
  410. if (!pvWin.StackExchange) {
  411. pvWin.StackExchange = {};
  412. let script = $scriptIn(pvDoc.head);
  413. script.text = 'StackExchange = {}';
  414. script = $scriptIn(pvDoc.head);
  415. script.src = 'https://cdn.sstatic.net/Js/prettify-full.en.js';
  416. script.setAttribute('onload', 'prettyPrint()');
  417. } else
  418. $scriptIn(pvDoc.body).text = 'prettyPrint()';
  419. }
  420.  
  421. // render bottom shelf
  422. if (hasAnswers) {
  423. $replaceOrCreate({
  424. id: 'SEpreview-answers',
  425. parent: pvDoc.body,
  426. innerHTML: answers.map(renderShelfAnswer).join(' '),
  427. });
  428. } else
  429. $$remove('#SEpreview-answers', pvDoc);
  430.  
  431. // cleanup leftovers from previously displayed post and foreign elements not injected by us
  432. $$('style, link, body script, html > *:not(head):not(body), .post-menu .lsep + .lsep', pvDoc).forEach(e => {
  433. if (e.classList.contains('SEpreview-reuse'))
  434. e.classList.remove('SEpreview-reuse');
  435. else
  436. e.remove();
  437. });
  438. }
  439.  
  440. function renderShelfAnswer(e) {
  441. const shortUrl = $('.short-link', e).href.replace(/(\d+)\/\d+/, '$1');
  442. const extraClasses = (e.matches(postId) ? ' SEpreviewed' : '') +
  443. (e.matches('.deleted-answer') ? ' deleted-answer' : '') +
  444. ($('.vote-accepted-on', e) ? ' SEpreview-accepted' : '');
  445. const author = $('.post-signature:last-child', e);
  446. const title = $text('.user-details a', author) + ' (rep ' +
  447. $text('.reputation-score', author) + ')\n' +
  448. $text('.user-action-time', author);
  449. const gravatar = $('img, .anonymous-gravatar, .community-wiki', author);
  450. return (
  451. `<a href="${shortUrl}" title="${title}" class="SEpreviewable${extraClasses}">` +
  452. $text('.vote-count-post', e).replace(/^0$/, '&nbsp;') + ' ' +
  453. (!gravatar ? '' : gravatar.src ? `<img src="${gravatar.src}">` : gravatar.outerHTML) +
  454. '</a>');
  455. }
  456.  
  457. function show() {
  458. pvDoc.onmouseover = lockScroll.attach;
  459. pvDoc.onclick = onClick;
  460. pvDoc.onkeydown = e => { if (!hasKeyModifiers(e) && e.keyCode == 27) hide() };
  461. pvWin.onmessage = e => { if (e.data == 'SEpreview-hidden') hide({fade: true}) };
  462.  
  463. markHoverableUsers(pvDoc);
  464. preview.killInvaders.start();
  465.  
  466. $('#SEpreview-body', pvDoc).scrollTop = 0;
  467. preview.frame.style.setProperty('opacity', '1', 'important');
  468. preview.frame.focus();
  469. }
  470.  
  471. function hide({fade = false} = {}) {
  472. releaseLinkListeners();
  473. releasePreviewListeners();
  474. const cleanup = () => preview.frame.style.opacity == '0' && $removeChildren(pvDoc.body);
  475. if (fade)
  476. fadeOut(preview.frame).then(cleanup);
  477. else {
  478. preview.frame.style.setProperty('opacity', '0', 'important');
  479. preview.frame.style.setProperty('display', 'none', 'important');
  480. cleanup();
  481. }
  482. }
  483.  
  484. function releasePreviewListeners(e) {
  485. pvWin.onmessage = null;
  486. pvDoc.onmouseover = null;
  487. pvDoc.onclick = null;
  488. pvDoc.onkeydown = null;
  489. }
  490.  
  491. function onClick(e) {
  492. if (e.target.id == 'SEpreview-close')
  493. return hide();
  494.  
  495. const link = e.target.closest('a');
  496. if (!link)
  497. return;
  498.  
  499. if (link.matches('.js-show-link.comments-link')) {
  500. fadeOut(link, 0.5);
  501. loadComments();
  502. return e.preventDefault();
  503. }
  504.  
  505. if (e.button || hasKeyModifiers(e) || !link.matches('.SEpreviewable'))
  506. return (link.target = '_blank');
  507.  
  508. e.preventDefault();
  509.  
  510. if (link.id == 'SEpreview-title')
  511. showPreview({doc, finalUrl: finalUrlOfQuestion});
  512. else if (link.matches('#SEpreview-answers a'))
  513. showPreview({doc, finalUrl: finalUrlOfQuestion + '/' + link.pathname.match(/\/(\d+)/)[1]});
  514. else
  515. downloadPreview(link);
  516. }
  517.  
  518. function loadComments() {
  519. const url = new URL(finalUrl).origin + '/posts/' + comments.id.match(/\d+/)[0] + '/comments';
  520. doXHR(url).then(r => {
  521. const list = $(`#${comments.id} .comments-list`, pvDoc);
  522. const oldIds = new Set([...list.children].map(e => e.id));
  523. list.innerHTML = r.responseText;
  524. list.closest('.comments').style.setProperty('display', 'block', 'important');
  525. for (const cmt of list.children)
  526. if (!oldIds.has(cmt.id))
  527. cmt.classList.add('new-comment-highlight');
  528. markPreviewableLinks(list);
  529. markHoverableUsers(list);
  530. });
  531. }
  532.  
  533. function loadUserCard(e, ready) {
  534. if (ready !== true)
  535. return setTimeout(loadUserCard, PREVIEW_DELAY * 2, e, true);
  536. const link = e.target.closest('a');
  537. if (!link.matches(':hover'))
  538. return;
  539. let timer;
  540. let userCard = link.nextElementSibling;
  541. if (userCard && userCard.matches('.SEpreview-userCard'))
  542. return fadeInUserCard();
  543. const url = link.origin + '/users/user-info/' + link.pathname.match(/\d+/)[0];
  544.  
  545. Promise.resolve(
  546. readCache(url) ||
  547. doXHR(url).then(r => {
  548. writeCache({url, html: r.responseText, cacheDuration: CACHE_DURATION * 100});
  549. return {html: r.responseText};
  550. })
  551. ).then(renderUserCard);
  552.  
  553. function renderUserCard({html}) {
  554. const linkBounds = link.getBoundingClientRect();
  555. const wrapperBounds = $('#SEpreview-body', pvDoc).getBoundingClientRect();
  556. userCard = $replaceOrCreate({id: 'user-menu-tmp', className: 'SEpreview-userCard', innerHTML: html, after: link});
  557. userCard.style.setProperty('left', Math.min(linkBounds.left - 20, pvWin.innerWidth - 350) + 'px', 'important');
  558. if (linkBounds.bottom + 100 > wrapperBounds.bottom)
  559. userCard.style.setProperty('margin-top', '-5rem', 'important');
  560. userCard.onmouseout = e => {
  561. if (e.target != userCard || userCard.contains(e.relatedTarget))
  562. if (e.relatedTarget) // null if mouse is outside the preview
  563. return;
  564. fadeOut(userCard);
  565. clearTimeout(timer);
  566. timer = 0;
  567. };
  568. fadeInUserCard();
  569. }
  570.  
  571. function fadeInUserCard() {
  572. if (userCard.id != 'user-menu') {
  573. $$('#user-menu', pvDoc).forEach(e => e.id = e.style.display = '' );
  574. userCard.id = 'user-menu';
  575. }
  576. userCard.style.setProperty('opacity', '0', 'important');
  577. userCard.style.setProperty('display', 'block', 'important');
  578. timer = setTimeout(() => timer && userCard.style.setProperty('opacity', '1', 'important'));
  579. }
  580. }
  581. }
  582.  
  583. function getCacheableUrl(url) {
  584. // strips queries and hashes and anything after the main part https://site/questions/####/title/
  585. return url
  586. .replace(/(\/q(?:uestions)?\/\d+\/[^\/]+).*/, '$1')
  587. .replace(/(\/a(?:nswers)?\/\d+).*/, '$1')
  588. .replace(/[?#].*$/, '');
  589. }
  590.  
  591. function readCache(url) {
  592. const keyUrl = getCacheableUrl(url);
  593. const meta = (localStorage[keyUrl] || '').split('\t');
  594. const expired = +meta[0] < Date.now();
  595. const finalUrl = meta[1] || url;
  596. const keyFinalUrl = meta[1] ? getCacheableUrl(finalUrl) : keyUrl;
  597. return !expired && {
  598. finalUrl,
  599. html: LZString.decompressFromUTF16(localStorage[keyFinalUrl + '\thtml']),
  600. };
  601. }
  602.  
  603. function writeCache({url, finalUrl, html, cacheDuration = CACHE_DURATION, cleanupRetry}) {
  604. // keyUrl=expires
  605. // redirected keyUrl=expires+finalUrl, and an additional entry keyFinalUrl=expires is created
  606. // keyFinalUrl\thtml=html
  607. cacheDuration = Math.max(CACHE_DURATION, Math.min(0xDEADBEEF, Math.floor(cacheDuration)));
  608. finalUrl = (finalUrl || url).replace(/[?#].*/, '');
  609. const keyUrl = getCacheableUrl(url);
  610. const keyFinalUrl = getCacheableUrl(finalUrl);
  611. const expires = Date.now() + cacheDuration;
  612. const lz = LZString.compressToUTF16(html);
  613. if (!tryCatch(() => localStorage[keyFinalUrl + '\thtml'] = lz)) {
  614. if (cleanupRetry)
  615. return error('localStorage write error');
  616. cleanupCache({aggressive: true});
  617. setIimeout(writeCache, 0, {url, finalUrl, html, cacheDuration, cleanupRetry: true});
  618. }
  619. localStorage[keyFinalUrl] = expires;
  620. if (keyUrl != keyFinalUrl)
  621. localStorage[keyUrl] = expires + '\t' + finalUrl;
  622. setTimeout(() => {
  623. [keyUrl, keyFinalUrl, keyFinalUrl + '\thtml'].forEach(e => localStorage.removeItem(e));
  624. }, cacheDuration + 1000);
  625. }
  626.  
  627. function cleanupCache({aggressive = false} = {}) {
  628. Object.keys(localStorage).forEach(k => {
  629. if (k.match(/^https?:\/\/[^\t]+$/)) {
  630. let meta = (localStorage[k] || '').split('\t');
  631. if (+meta[0] > Date.now() && !aggressive)
  632. return;
  633. if (meta[1])
  634. localStorage.removeItem(meta[1]);
  635. localStorage.removeItem(`${meta[1] || k}\thtml`);
  636. localStorage.removeItem(k);
  637. }
  638. });
  639. }
  640.  
  641. function onFrameReady(frame) {
  642. if (frame.contentDocument.readyState == 'complete')
  643. return Promise.resolve();
  644. else
  645. return new Promise(resolve => {
  646. $on('load', frame, function onLoad() {
  647. $off('load', frame, onLoad);
  648. resolve();
  649. });
  650. });
  651. }
  652.  
  653. function onStyleSheetsReady({urls, doc = document, onBeforeRequest = null}) {
  654. return Promise.all(
  655. urls.map(url => $(`link[href="${url}"]`, doc) || new Promise(resolve => {
  656. if (typeof onBeforeRequest == 'function')
  657. onBeforeRequest(url);
  658. doXHR(url).then(() => {
  659. const sheetElement = $replaceOrCreate({tag: 'link', href: url, rel: 'stylesheet', parent: doc.head});
  660. const timeout = setTimeout(doResolve, 100);
  661. sheetElement.onload = doResolve;
  662. function doResolve() {
  663. sheetElement.onload = null;
  664. clearTimeout(timeout);
  665. resolve(sheetElement);
  666. }
  667. }).catch(() => resolve());
  668. }))
  669. ).then(elements => {
  670. let current = $('link', doc.head);
  671. if (!current || !elements[0]) return [];
  672. const ordered = urls.map(url => $(`link[href="${url}"]`, doc));
  673. for (const el of elements) {
  674. if (current !== el) {
  675. doc.head.insertBefore(el, current);
  676. } else {
  677. while ((current = current.nextElementSibling))
  678. if (current.localName === 'link')
  679. break;
  680. }
  681. }
  682. return elements;
  683. });
  684. }
  685.  
  686. function getURLregexForMatchedSites() {
  687. const sites = 'https?://(\\w*\\.)*(' + GM_info.script.matches.map(
  688. m => m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.')).join('|') + ')/';
  689. return {
  690. full: new RegExp(sites + '(questions|q|a|posts\/comments)/\\d+'),
  691. siteOnly: new RegExp(sites),
  692. };
  693. }
  694.  
  695. function isLinkPreviewable(link) {
  696. if (!rxPreviewable.test(link.href) || link.matches('.short-link'))
  697. return false;
  698. const inPreview = preview.frame && link.ownerDocument == preview.frame.contentDocument;
  699. const pageUrls = inPreview ? getPageBaseUrls(preview.link.href) : thisPageUrls;
  700. const url = httpsUrl(link.href);
  701. return url.indexOf(pageUrls.base) &&
  702. url.indexOf(pageUrls.short);
  703. }
  704.  
  705. function getPageBaseUrls(url) {
  706. const base = httpsUrl((url.match(rxPreviewable) || [])[0]);
  707. return base ? {
  708. base,
  709. short: base.replace('/questions/', '/q/'),
  710. } : {};
  711. }
  712.  
  713. function httpsUrl(url) {
  714. return (url || '').replace(/^http:/, 'https:');
  715. }
  716.  
  717. function doXHR(options) {
  718. options = typeof options == 'string' ? {url: options} : options;
  719. options = Object.assign({method: 'GET'}, options);
  720. const useHttpUrl = () => options.url = options.url.replace(/^https/, 'http');
  721. const hostname = new URL(options.url).hostname;
  722. if (xhrNoSSL.has(hostname))
  723. useHttpUrl();
  724. else {
  725. options.url = options.url.replace(/^http:/, 'https:');
  726. options.onerror = e => {
  727. useHttpUrl();
  728. xhrNoSSL.add(hostname);
  729. xhr = GM_xmlhttpRequest(options);
  730. };
  731. }
  732. if (options.onload)
  733. return (xhr = GM_xmlhttpRequest(options));
  734. else
  735. return new Promise(resolve => {
  736. xhr = GM_xmlhttpRequest(Object.assign(options, {onload: resolve}));
  737. });
  738. }
  739.  
  740. function makeResizable() {
  741. let heightOnClick;
  742. const pvDoc = preview.frame.contentDocument;
  743. const topBorderHeight = (preview.frame.offsetHeight - preview.frame.clientHeight) / 2;
  744. setHeight(GM_getValue('height', innerHeight / 3) |0);
  745.  
  746. // mouseover in the main page is fired only on the border of the iframe
  747. $on('mouseover', preview.frame, onOverAttach);
  748. $on('message', preview.frame.contentWindow, e => {
  749. if (e.data != 'SEpreview-hidden')
  750. return;
  751. if (heightOnClick) {
  752. releaseResizeListeners();
  753. setHeight(heightOnClick);
  754. }
  755. if (preview.frame.style.cursor)
  756. onOutDetach();
  757. });
  758.  
  759. function setCursorStyle(e) {
  760. return (preview.frame.style.setProperty('cursor', e.offsetY <= 0 ? 's-resize' : '', 'important'));
  761. }
  762.  
  763. function onOverAttach(e) {
  764. setCursorStyle(e);
  765. $on('mouseout', preview.frame, onOutDetach);
  766. $on('mousemove', preview.frame, setCursorStyle);
  767. $on('mousedown', onDownStartResize);
  768. }
  769.  
  770. function onOutDetach(e) {
  771. if (!e || !e.relatedTarget || !pvDoc.contains(e.relatedTarget)) {
  772. $off('mouseout', preview.frame, onOutDetach);
  773. $off('mousemove', preview.frame, setCursorStyle);
  774. $off('mousedown', onDownStartResize);
  775. preview.frame.style.cursor = '';
  776. }
  777. }
  778.  
  779. function onDownStartResize(e) {
  780. if (!preview.frame.style.cursor)
  781. return;
  782. heightOnClick = preview.frame.clientHeight;
  783.  
  784. $off('mouseover', preview.frame, onOverAttach);
  785. $off('mousemove', preview.frame, setCursorStyle);
  786. $off('mouseout', preview.frame, onOutDetach);
  787.  
  788. document.documentElement.style.setProperty('cursor', 's-resize', 'important');
  789. document.body.style.setProperty('pointer-events', 'none', 'important');
  790. $on('mousemove', onMoveResize);
  791. $on('mouseup', onUpConfirm);
  792. }
  793.  
  794. function onMoveResize(e) {
  795. setHeight(innerHeight - topBorderHeight - e.clientY);
  796. getSelection().removeAllRanges();
  797. preview.frame.contentWindow.getSelection().removeAllRanges();
  798. }
  799.  
  800. function onUpConfirm(e) {
  801. GM_setValue('height', pvDoc.body.clientHeight);
  802. releaseResizeListeners(e);
  803. }
  804.  
  805. function releaseResizeListeners() {
  806. $off('mouseup', releaseResizeListeners);
  807. $off('mousemove', onMoveResize);
  808.  
  809. $on('mouseover', preview.frame, onOverAttach);
  810. onOverAttach({});
  811.  
  812. document.body.style.pointerEvents = '';
  813. document.documentElement.style.cursor = '';
  814. heightOnClick = 0;
  815. }
  816. }
  817.  
  818. function setHeight(height) {
  819. const currentHeight = preview.frame.clientHeight;
  820. const borderHeight = preview.frame.offsetHeight - currentHeight;
  821. const newHeight = Math.max(MIN_HEIGHT, Math.min(innerHeight - borderHeight, height));
  822. if (newHeight != currentHeight)
  823. preview.frame.style.setProperty('height', newHeight + 'px', 'important');
  824. }
  825.  
  826. function $(selector, node = document) {
  827. return node.querySelector(selector);
  828. }
  829.  
  830. function $$(selector, node = document) {
  831. return node.querySelectorAll(selector);
  832. }
  833.  
  834. function $text(selector, node = document) {
  835. const e = typeof selector == 'string' ? node.querySelector(selector) : selector;
  836. return e ? e.textContent.trim() : '';
  837. }
  838.  
  839. function $$remove(selector, node = document) {
  840. node.querySelectorAll(selector).forEach(e => e.remove());
  841. }
  842.  
  843. function $appendChildren(newParent, elements) {
  844. const doc = newParent.ownerDocument;
  845. const fragment = doc.createDocumentFragment();
  846. for (let e of elements)
  847. if (e)
  848. fragment.appendChild(e.ownerDocument == doc ? e : doc.importNode(e, true));
  849. newParent.appendChild(fragment);
  850. }
  851.  
  852. function $removeChildren(el) {
  853. if (el.children.length)
  854. el.innerHTML = ''; // the fastest as per https://jsperf.com/innerhtml-vs-removechild/256
  855. }
  856.  
  857. function $replaceOrCreate(options) {
  858. if (typeof options.map == 'function')
  859. return options.map($replaceOrCreate);
  860. const doc = (options.parent || options.before || options.after).ownerDocument;
  861. const el = doc.getElementById(options.id) || doc.createElement(options.tag || 'div');
  862. for (let key of Object.keys(options)) {
  863. const value = options[key];
  864. switch (key) {
  865. case 'tag':
  866. case 'parent':
  867. case 'before':
  868. case 'after':
  869. break;
  870. case 'dataset':
  871. for (let dataAttr of Object.keys(value))
  872. if (el.dataset[dataAttr] != value[dataAttr])
  873. el.dataset[dataAttr] = value[dataAttr];
  874. break;
  875. case 'children':
  876. $removeChildren(el);
  877. $appendChildren(el, options[key]);
  878. break;
  879. default:
  880. if (key in el && el[key] != value)
  881. el[key] = value;
  882. }
  883. }
  884. if (!el.parentElement)
  885. (options.parent || (options.before || options.after).parentElement)
  886. .insertBefore(el, options.before || (options.after && options.after.nextElementSibling));
  887. return el;
  888. }
  889.  
  890. function $scriptIn(element) {
  891. return element.appendChild(element.ownerDocument.createElement('script'));
  892. }
  893.  
  894. function $on(eventName, ...args) {
  895. // eventName, selector, node, callback, options
  896. // eventName, selector, callback, options
  897. // eventName, node, callback, options
  898. // eventName, callback, options
  899. let i = 0;
  900. const selector = typeof args[i] == 'string' ? args[i++] : null;
  901. const node = args[i].nodeType ? args[i++] : document;
  902. const callback = args[i++];
  903. const options = args[i];
  904.  
  905. const actualNode = selector ? node.querySelector(selector) : node;
  906. const method = this == 'removeEventListener' ? this : 'addEventListener';
  907. actualNode[method](eventName, callback, options);
  908. }
  909.  
  910. function $off() {
  911. $on.apply('removeEventListener', arguments);
  912. }
  913.  
  914. function hasKeyModifiers(e) {
  915. return e.ctrlKey || e.altKey || e.shiftKey || e.metaKey;
  916. }
  917.  
  918. function log(...args) {
  919. console.log(GM_info.script.name, ...args);
  920. }
  921.  
  922. function error(...args) {
  923. console.error(GM_info.script.name, ...args);
  924. console.trace();
  925. }
  926.  
  927. function tryCatch(fn) {
  928. try { return fn() }
  929. catch(e) {}
  930. }
  931.  
  932. function initPolyfills(context = window) {
  933. for (let method of ['forEach', 'filter', 'map', 'every', 'some', context.Symbol.iterator])
  934. if (!context.NodeList.prototype[method])
  935. context.NodeList.prototype[method] = context.Array.prototype[method];
  936. }
  937.  
  938. function initStyles() {
  939. GM_addStyle(`
  940. #SEpreview {
  941. all: unset;
  942. box-sizing: content-box;
  943. width: 720px; /* 660px + 30px + 30px */
  944. height: 33%;
  945. min-height: ${MIN_HEIGHT}px;
  946. position: fixed;
  947. transition: opacity .25s cubic-bezier(.88,.02,.92,.66);
  948. right: 0;
  949. bottom: 0;
  950. padding: 0;
  951. margin: 0;
  952. background: white;
  953. opacity: 0;
  954. box-shadow: 0 0 100px rgba(0,0,0,0.5);
  955. z-index: 999999;
  956. border-width: 8px;
  957. border-style: solid;
  958. border-color: transparent;
  959. }
  960. #SEpreview:not([style*="opacity: 1"]) {
  961. pointer-events: none;
  962. }
  963. #SEpreview[SEpreview-type="question"].SEpreview-hasAnswers {
  964. border-image: linear-gradient(rgb(${COLORS.question.backRGB}) 66%, rgb(${COLORS.answer.backRGB})) 1 1;
  965. }
  966. `
  967. + Object.keys(COLORS).map(s => `
  968. #SEpreview[SEpreview-type="${s}"] {
  969. border-color: rgb(${COLORS[s].backRGB});
  970. }
  971. `).join('')
  972. );
  973.  
  974. preview.stylesOverride = `
  975. html, body {
  976. min-width: unset!important;
  977. box-shadow: none!important;
  978. padding: 0!important;
  979. margin: 0!important;
  980. background: unset!important;;
  981. }
  982. body {
  983. display: flex;
  984. flex-direction: column;
  985. height: 100vh;
  986. }
  987. #SEpreview-body a.SEpreviewable {
  988. text-decoration: underline !important;
  989. text-decoration-skip: ink;
  990. }
  991. #SEpreview-title {
  992. all: unset;
  993. display: block;
  994. padding: 20px 30px;
  995. font-weight: bold;
  996. font-size: 18px;
  997. line-height: 1.2;
  998. cursor: pointer;
  999. }
  1000. #SEpreview-title:hover {
  1001. text-decoration: underline;
  1002. text-decoration-skip: ink;
  1003. }
  1004. #SEpreview-meta {
  1005. position: absolute;
  1006. top: .5ex;
  1007. left: 30px;
  1008. opacity: 0.5;
  1009. }
  1010. #SEpreview-title:hover + #SEpreview-meta {
  1011. opacity: 1.0;
  1012. }
  1013.  
  1014. #SEpreview-close {
  1015. position: absolute;
  1016. top: 0;
  1017. right: 0;
  1018. flex: none;
  1019. cursor: pointer;
  1020. padding: .5ex 1ex;
  1021. }
  1022. #SEpreview-close:after {
  1023. content: "x"; }
  1024. #SEpreview-close:active {
  1025. background-color: rgba(0,0,0,.1); }
  1026. #SEpreview-close:hover {
  1027. background-color: rgba(0,0,0,.05); }
  1028.  
  1029. #SEpreview-body {
  1030. position: relative;
  1031. padding: 30px!important;
  1032. overflow: auto;
  1033. flex-grow: 2;
  1034. }
  1035. #SEpreview-body > .question-status {
  1036. margin: -30px -30px 30px;
  1037. padding-left: 30px;
  1038. }
  1039. #SEpreview-body .question-originals-of-duplicate {
  1040. margin: -30px -30px 30px;
  1041. padding: 15px 30px;
  1042. }
  1043. #SEpreview-body > .question-status h2 {
  1044. font-weight: normal;
  1045. }
  1046.  
  1047. #SEpreview-answers {
  1048. all: unset;
  1049. display: block;
  1050. padding: 10px 10px 10px 30px;
  1051. font-weight: bold;
  1052. line-height: 1.0;
  1053. border-top: 4px solid rgba(${COLORS.answer.backRGB}, 0.37);
  1054. background-color: rgba(${COLORS.answer.backRGB}, 0.37);
  1055. color: ${COLORS.answer.fore};
  1056. word-break: break-word;
  1057. }
  1058. #SEpreview-answers:before {
  1059. content: "Answers:";
  1060. margin-right: 1ex;
  1061. font-size: 20px;
  1062. line-height: 48px;
  1063. }
  1064. #SEpreview-answers a {
  1065. color: ${COLORS.answer.fore};
  1066. text-decoration: none;
  1067. font-size: 11px;
  1068. font-family: monospace;
  1069. width: 32px;
  1070. display: inline-block;
  1071. vertical-align: top;
  1072. margin: 0 1ex 1ex 0;
  1073. }
  1074. #SEpreview-answers img {
  1075. width: 32px;
  1076. height: 32px;
  1077. }
  1078. .SEpreview-accepted {
  1079. position: relative;
  1080. }
  1081. .SEpreview-accepted:after {
  1082. content: "✔";
  1083. position: absolute;
  1084. display: block;
  1085. top: 1.3ex;
  1086. right: -0.7ex;
  1087. font-size: 32px;
  1088. color: #4bff2c;
  1089. text-shadow: 1px 2px 2px rgba(0,0,0,0.5);
  1090. }
  1091. #SEpreview-answers a.deleted-answer {
  1092. color: ${COLORS.deleted.fore};
  1093. background: transparent;
  1094. opacity: 0.25;
  1095. }
  1096. #SEpreview-answers a.deleted-answer:hover {
  1097. opacity: 1.0;
  1098. }
  1099. #SEpreview-answers a:hover:not(.SEpreviewed) {
  1100. text-decoration: underline;
  1101. text-decoration-skip: ink;
  1102. }
  1103. #SEpreview-answers a.SEpreviewed {
  1104. background-color: ${COLORS.answer.fore};
  1105. color: ${COLORS.answer.foreInv};
  1106. position: relative;
  1107. }
  1108. #SEpreview-answers a.SEpreviewed:before {
  1109. display: block;
  1110. content: " ";
  1111. position: absolute;
  1112. left: -4px;
  1113. top: -4px;
  1114. right: -4px;
  1115. bottom: -4px;
  1116. border: 4px solid ${COLORS.answer.fore};
  1117. }
  1118.  
  1119. #SEpreview-body .comment-edit,
  1120. #SEpreview-body .delete-tag,
  1121. #SEpreview-body .comment-actions td:last-child {
  1122. display: none;
  1123. }
  1124. #SEpreview-body .comments {
  1125. border-top: none;
  1126. }
  1127. #SEpreview-body .comments tr:last-child td {
  1128. border-bottom: none;
  1129. }
  1130. #SEpreview-body .comments .new-comment-highlight .comment-text {
  1131. -webkit-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1132. -moz-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1133. animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1134. }
  1135.  
  1136. #SEpreview-body .post-menu > span {
  1137. opacity: .35;
  1138. }
  1139. #SEpreview-body #user-menu {
  1140. position: absolute;
  1141. }
  1142. .SEpreview-userCard {
  1143. position: absolute;
  1144. display: none;
  1145. transition: opacity .25s cubic-bezier(.88,.02,.92,.66) .5s;
  1146. margin-top: -3rem;
  1147. }
  1148.  
  1149. #SEpreview-body .wmd-preview a:not(.post-tag),
  1150. #SEpreview-body .post-text a:not(.post-tag),
  1151. #SEpreview-body .comment-copy a:not(.post-tag) {
  1152. border-bottom: none;
  1153. }
  1154.  
  1155. @-webkit-keyframes highlight {
  1156. from {background-color: #ffcf78}
  1157. to {background-color: none}
  1158. }
  1159. `
  1160. + Object.keys(COLORS).map(s => `
  1161. body[SEpreview-type="${s}"] #SEpreview-title {
  1162. background-color: rgba(${COLORS[s].backRGB}, 0.37);
  1163. color: ${COLORS[s].fore};
  1164. }
  1165. body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar {
  1166. background-color: rgba(${COLORS[s].backRGB}, 0.1); }
  1167. body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb {
  1168. background-color: rgba(${COLORS[s].backRGB}, 0.2); }
  1169. body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb:hover {
  1170. background-color: rgba(${COLORS[s].backRGB}, 0.3); }
  1171. body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb:active {
  1172. background-color: rgba(${COLORS[s].backRGB}, 0.75); }
  1173. `).join('')
  1174. + ['deleted', 'closed'].map(s => `
  1175. body[SEpreview-type="${s}"] #SEpreview-answers {
  1176. border-top-color: rgba(${COLORS[s].backRGB}, 0.37);
  1177. background-color: rgba(${COLORS[s].backRGB}, 0.37);
  1178. color: ${COLORS[s].fore};
  1179. }
  1180. body[SEpreview-type="${s}"] #SEpreview-answers a.SEpreviewed {
  1181. background-color: ${COLORS[s].fore};
  1182. color: ${COLORS[s].foreInv};
  1183. }
  1184. body[SEpreview-type="${s}"] #SEpreview-answers a.SEpreviewed:after {
  1185. border-color: ${COLORS[s].fore};
  1186. }
  1187. `).join('');
  1188. }