SE Preview on hover

Shows preview of the linked questions/answers on hover

当前为 2017-03-05 提交的版本,查看 最新版本

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