SE Preview on hover

Shows preview of the linked questions/answers on hover

当前为 2017-02-20 提交的版本,查看 最新版本

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