SE Preview on hover

Shows preview of the linked questions/answers on hover

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

  1. // ==UserScript==
  2. // @name SE Preview on hover
  3. // @description Shows preview of the linked questions/answers on hover
  4. // @version 0.1.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. // @require https://greasyfork.org/scripts/12228/code/setMutationHandler.js
  16. // @grant GM_addStyle
  17. // @grant GM_xmlhttpRequest
  18. // @connect stackoverflow.com
  19. // @connect superuser.com
  20. // @connect serverfault.com
  21. // @connect askubuntu.com
  22. // @connect stackapps.com
  23. // @connect mathoverflow.net
  24. // @connect stackexchange.com
  25. // @connect cdn.sstatic.net
  26. // @run-at document-end
  27. // @noframes
  28. // ==/UserScript==
  29.  
  30. /* jshint lastsemic:true, multistr:true, laxbreak:true, -W030, -W041, -W084 */
  31.  
  32. const PREVIEW_DELAY = 100;
  33. const COLORS = {
  34. question: {
  35. backRGB: '80, 133, 195',
  36. foreRGB: '#265184',
  37. },
  38. answer: {
  39. backRGB: '112, 195, 80',
  40. foreRGB: '#3f7722',
  41. foreInv: 'white',
  42. },
  43. };
  44.  
  45. let xhr;
  46. let preview = {
  47. frame: null,
  48. link: null,
  49. hover: {x:0, y:0},
  50. timer: 0,
  51. abortTimer: 0,
  52. CSScache: {},
  53. stylesOverride: '',
  54. };
  55.  
  56. const rxPreviewable = getURLregexForMatchedSites();
  57. const thisPageUrls = getPageBaseUrls(location.href);
  58.  
  59. initStyles();
  60. initPolyfills();
  61. setMutationHandler('a', onLinkAdded, {processExisting: true});
  62.  
  63. /**************************************************************/
  64.  
  65. function onLinkAdded(links) {
  66. for (let i = 0, link; (link = links[i++]); ) {
  67. if (isLinkPreviewable(link)) {
  68. link.removeAttribute('title');
  69. link.addEventListener('mouseover', onLinkHovered);
  70. }
  71. }
  72. }
  73.  
  74. function onLinkHovered(e) {
  75. if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)
  76. return;
  77. preview.link = this;
  78. preview.link.addEventListener('mousemove', onLinkMouseMove);
  79. preview.link.addEventListener('mouseout', abortPreview);
  80. preview.link.addEventListener('mousedown', abortPreview);
  81. restartPreviewTimer();
  82. }
  83.  
  84. function onLinkMouseMove(e) {
  85. let stoppedMoving = Math.abs(preview.hover.x - e.clientX) < 2 &&
  86. Math.abs(preview.hover.y - e.clientY) < 2;
  87. if (!stoppedMoving)
  88. return;
  89. preview.hover.x = e.clientX;
  90. preview.hover.y = e.clientY;
  91. restartPreviewTimer();
  92. }
  93.  
  94. function restartPreviewTimer() {
  95. clearTimeout(preview.timer);
  96. clearTimeout(preview.abortTimer);
  97. preview.timer = setTimeout(() => {
  98. preview.timer = 0;
  99. releaseLinkListeners();
  100. if (preview.link.matches(':hover'))
  101. downloadPreview(preview.link.href);
  102. }, PREVIEW_DELAY);
  103. }
  104.  
  105. function releaseLinkListeners() {
  106. preview.link.removeEventListener('mousemove', onLinkMouseMove);
  107. preview.link.removeEventListener('mouseout', abortPreview);
  108. preview.link.removeEventListener('mousedown', abortPreview);
  109. clearTimeout(preview.timer);
  110. }
  111.  
  112. function abortPreview(e) {
  113. releaseLinkListeners();
  114. preview.abortTimer = setTimeout(() => {
  115. preview.abortTimer = 0;
  116. if (e.target == preview.link && preview.frame && !preview.frame.matches(':hover'))
  117. hideAndRemove(preview.frame);
  118. }, PREVIEW_DELAY * 3);
  119. if (xhr)
  120. xhr.abort();
  121. }
  122.  
  123. function hideAndRemove(element, transition) {
  124. if (transition) {
  125. element.style.transition = typeof transition == 'number' ? `opacity ${transition}s ease-in-out` : transition;
  126. return setTimeout(hideAndRemove, 0, element);
  127. }
  128. element.style.opacity = 0;
  129. element.addEventListener('transitionend', function remove() {
  130. element.removeEventListener('transitionend', remove);
  131. element.remove();
  132. });
  133. }
  134.  
  135. function downloadPreview(url) {
  136. xhr = GM_xmlhttpRequest({
  137. method: 'GET',
  138. url: httpsUrl(url),
  139. onload: showPreview,
  140. });
  141. }
  142.  
  143. function showPreview(data) {
  144. let doc = data.SEpreviewDoc || new DOMParser().parseFromString(data.responseText, 'text/html');
  145. if (!doc || !doc.head) {
  146. error('empty document received:', data);
  147. return;
  148. }
  149.  
  150. if (!$(doc, 'base'))
  151. doc.head.insertAdjacentHTML('afterbegin', `<base href="${data.finalUrl}">`);
  152.  
  153. const answerIdMatch = data.finalUrl.match(/questions\/.+?\/(\d+)/);
  154. const isQuestion = !answerIdMatch;
  155. let postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
  156. let post = $(doc, postId + ' .post-text');
  157. if (!post)
  158. return error('No parsable post found', doc);
  159. const title = $(doc, 'meta[property="og:title"]').content;
  160. let comments = $(doc, `${postId} .comments`);
  161. let commentsHidden = +$(comments, 'tbody').dataset.remainingCommentsCount;
  162. let commentsShowLink = commentsHidden && $(doc, `${postId} .js-show-link.comments-link`);
  163.  
  164. let externalsReady = [preview.stylesOverride];
  165. let externalsToGet = new Set();
  166. let afterBodyHtml = '';
  167.  
  168. fetchExternals();
  169. maybeRender();
  170.  
  171. function fetchExternals() {
  172. let codeBlocks = $$(post, 'pre code');
  173. if (codeBlocks.length) {
  174. codeBlocks.forEach(e => e.parentElement.classList.add('prettyprint'));
  175. externalsReady.push(
  176. '<script> StackExchange = {}; </script>',
  177. '<script src="https://cdn.sstatic.net/Js/prettify-full.en.js"></script>'
  178. );
  179. afterBodyHtml += '<script> prettyPrint(); </script>';
  180. }
  181.  
  182. $$(doc, 'style, link[rel="stylesheet"]').forEach(e => {
  183. if (e.localName == 'style')
  184. externalsReady.push(e.outerHTML);
  185. else if (e.href in preview.CSScache)
  186. externalsReady.push(preview.CSScache[e.href]);
  187. else {
  188. externalsToGet.add(e.href);
  189. GM_xmlhttpRequest({
  190. method: 'GET',
  191. url: e.href,
  192. onload: data => {
  193. externalsReady.push(preview.CSScache[e.href] = '<style>' + data.responseText + '</style>');
  194. externalsToGet.delete(e.href);
  195. maybeRender();
  196. },
  197. });
  198. }
  199. });
  200.  
  201. }
  202.  
  203. function maybeRender() {
  204. if (externalsToGet.size)
  205. return;
  206. if (!preview.frame) {
  207. preview.frame = document.createElement('iframe');
  208. preview.frame.id = 'SEpreview';
  209. }
  210. preview.frame.classList.toggle('SEpreviewIsAnswer', !!answerIdMatch);
  211. document.body.appendChild(preview.frame);
  212.  
  213. const answers = $$(doc, '.answer');
  214. const answersShown = answers.length > (isQuestion ? 0 : 1);
  215. if (answersShown) {
  216. afterBodyHtml += '<div id="SEpreviewAnswers">Answers:&nbsp;' +
  217. answers.map((e, index) =>
  218. `<a href="${$(e, '.short-link').href.replace(/(\d+)\/\d+/, '$1')}"
  219. title="${
  220. $text(e, '.user-details a') + ' (' +
  221. $text(e, '.reputation-score') + ') ' +
  222. $text(e, '.user-action-time') +
  223. $text(e, '.vote-count-post').replace(/\d+/, s => !s ? '' : ', votes: ' + s)}"
  224. class="${e.matches(postId) ? 'SEpreviewed' : ''}"
  225. >${index + 1}</a>`
  226. ).join('') + '</div>';
  227. }
  228.  
  229. $$remove(doc, 'script');
  230.  
  231. let html = `<head>${externalsReady.join('')}</head>
  232. <body${answerIdMatch ? ' class="SEpreviewIsAnswer"' : ''}>
  233. <a id="SEpreviewTitle" href="${
  234. isQuestion ? data.finalUrl : data.finalUrl.replace(/\/\d+[^\/]*$/, '')
  235. }">${title}</a>
  236. <div id="SEpreviewBody">${
  237. [post.parentElement, comments, commentsShowLink]
  238. .map(e => e ? e.outerHTML : '').join('')
  239. }</div>
  240. ${afterBodyHtml}
  241. </body>`;
  242.  
  243. try {
  244. let pvDoc = preview.frame.contentDocument;
  245. pvDoc.open();
  246. pvDoc.write(html);
  247. pvDoc.close();
  248. } catch(e) {
  249. preview.frame.srcdoc = `<html>${html}</html>`;
  250. }
  251.  
  252. onFrameReady(preview.frame, function() {
  253. this.onload = null;
  254. this.style.opacity = 1;
  255. this.contentDocument.addEventListener('mouseover', retainMainScrollPos);
  256. this.contentDocument.addEventListener('click', interceptLinks);
  257. });
  258. }
  259.  
  260. function interceptLinks(e) {
  261. const link = e.target;
  262. if (link.localName != 'a')
  263. return;
  264. if (link.matches('.js-show-link.comments-link')) {
  265. hideAndRemove(link, 0.5);
  266. downloadComments();
  267. }
  268. else if (e.button || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)
  269. return (link.target = '_blank');
  270. else if (link.matches('#SEpreviewAnswers a, a#SEpreviewTitle'))
  271. showPreview({
  272. finalUrl: link.href.includes('/questions/')
  273. ? link.href
  274. : data.finalUrl.replace(/(\/\d+[^\/]*|\?.*)?$/g, '') + '/' + link.pathname.match(/\d+/)[0],
  275. SEpreviewDoc: doc,
  276. });
  277. else if (!isLinkPreviewable(link))
  278. return (link.target = '_blank');
  279. else if (!link.matches('.SEpreviewed'))
  280. downloadPreview(link.href);
  281. e.preventDefault();
  282. }
  283.  
  284. function downloadComments() {
  285. GM_xmlhttpRequest({
  286. method: 'GET',
  287. url: new URL(data.finalUrl).origin + '/posts/' + comments.id.match(/\d+/)[0] + '/comments',
  288. onload: r => showComments(r.responseText),
  289. });
  290. }
  291.  
  292. function showComments(html) {
  293. let tbody = $(preview.frame.contentDocument, `#${comments.id} tbody`);
  294. let oldIds = new Set([...tbody.rows].map(e => e.id));
  295. tbody.innerHTML = html;
  296. for (let tr of tbody.rows)
  297. if (!oldIds.has(tr.id))
  298. tr.classList.add('new-comment-highlight');
  299. }
  300. }
  301.  
  302. function retainMainScrollPos(e) {
  303. let scrollPos = {x:scrollX, y:scrollY};
  304. document.addEventListener('scroll', preventScroll);
  305. document.addEventListener('mouseover', releaseScrollLock);
  306.  
  307. function preventScroll(e) {
  308. scrollTo(scrollPos.x, scrollPos.y);
  309. log('prevented main page scroll');
  310. }
  311. function releaseScrollLock(e) {
  312. document.removeEventListener('mouseout', releaseScrollLock);
  313. document.removeEventListener('scroll', preventScroll);
  314. }
  315. }
  316.  
  317. function getURLregexForMatchedSites() {
  318. return new RegExp('https?://(\\w*\\.)*(' + GM_info.script.matches.map(m =>
  319. m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.')
  320. ).join('|') + ')/(questions|q|a)/\\d+');
  321. }
  322.  
  323. function getPageBaseUrls(url) {
  324. let base = httpsUrl((url.match(rxPreviewable) || [])[0]);
  325. return base ? {
  326. base,
  327. short: base.replace('/questions/', '/q/'),
  328. } : {};
  329. }
  330.  
  331. function isLinkPreviewable(link) {
  332. const inPreview = link.ownerDocument != document;
  333. if (!rxPreviewable.test(link.href) || link.matches('.short-link'))
  334. return false;
  335. const pageUrls = inPreview ? getPageBaseUrls(preview.link.href) : thisPageUrls;
  336. const url = httpsUrl(link.href);
  337. return !url.startsWith(pageUrls.base) &&
  338. !url.startsWith(pageUrls.short);
  339. }
  340.  
  341. function onFrameReady(frame, callback) {
  342. if (frame.contentDocument.readyState == 'complete')
  343. return callback.call(frame);
  344. else
  345. frame.onload = callback;
  346. }
  347.  
  348. function httpsUrl(url) {
  349. return (url || '').replace(/^http:/, 'https:');
  350. }
  351.  
  352. function $(node__optional, selector) {
  353. return (node__optional || document).querySelector(selector || node__optional);
  354. }
  355.  
  356. function $$(node__optional, selector) {
  357. return (node__optional || document).querySelectorAll(selector || node__optional);
  358. }
  359.  
  360. function $text(node__optional, selector) {
  361. let e = $(node__optional, selector);
  362. return e ? e.textContent.trim() : '';
  363. }
  364.  
  365. function $$remove(node__optional, selector) {
  366. (node__optional || document).querySelectorAll(selector || node__optional)
  367. .forEach(e => e.remove());
  368. }
  369.  
  370. function log(...args) {
  371. console.log(GM_info.script.name, ...args);
  372. }
  373.  
  374. function error(...args) {
  375. console.error(GM_info.script.name, ...args);
  376. }
  377.  
  378. function initPolyfills() {
  379. NodeList.prototype.forEach = NodeList.prototype.forEach || Array.prototype.forEach;
  380. NodeList.prototype.map = NodeList.prototype.map || Array.prototype.map;
  381. }
  382.  
  383. function initStyles() {
  384. GM_addStyle(`
  385. #SEpreview {
  386. all: unset;
  387. box-sizing: content-box;
  388. width: 720px; /* 660px + 30px + 30px */
  389. height: 33%;
  390. min-height: 200px;
  391. position: fixed;
  392. opacity: 0;
  393. transition: opacity .5s cubic-bezier(.88,.02,.92,.66);
  394. right: 0;
  395. bottom: 0;
  396. padding: 0;
  397. margin: 0;
  398. background: white;
  399. box-shadow: 0 0 100px rgba(0,0,0,0.5);
  400. z-index: 999999;
  401. border: 8px solid rgb(${COLORS.question.backRGB});
  402. }
  403. #SEpreview.SEpreviewIsAnswer {
  404. border-color: rgb(${COLORS.answer.backRGB});
  405. }
  406. `);
  407.  
  408. preview.stylesOverride = `<style>
  409. body, html {
  410. min-width: unset!important;
  411. box-shadow: none!important;
  412. }
  413. html, body {
  414. background: unset!important;;
  415. }
  416. body {
  417. display: flex;
  418. flex-direction: column;
  419. height: 100vh;
  420. }
  421. #SEpreviewTitle {
  422. all: unset;
  423. display: block;
  424. padding: 20px 30px;
  425. font-weight: bold;
  426. font-size: 20px;
  427. line-height: 1.3;
  428. background-color: rgba(${COLORS.question.backRGB}, 0.37);
  429. color: ${COLORS.question.foreRGB};
  430. cursor: pointer;
  431. }
  432. #SEpreviewTitle:hover {
  433. text-decoration: underline;
  434. }
  435. #SEpreviewBody {
  436. padding: 30px!important;
  437. overflow: auto;
  438. flex-grow: 2;
  439. }
  440. #SEpreviewBody .post-menu {
  441. display: none!important;
  442. }
  443. #SEpreviewBody::-webkit-scrollbar {
  444. background-color: rgba(${COLORS.question.backRGB}, 0.1);
  445. }
  446. #SEpreviewBody::-webkit-scrollbar-thumb {
  447. background-color: rgba(${COLORS.question.backRGB}, 0.2);
  448. }
  449. #SEpreviewBody::-webkit-scrollbar-thumb:hover {
  450. background-color: rgba(${COLORS.question.backRGB}, 0.3);
  451. }
  452. #SEpreviewBody::-webkit-scrollbar-thumb:active {
  453. background-color: rgba(${COLORS.question.backRGB}, 0.75);
  454. }
  455.  
  456. body.SEpreviewIsAnswer #SEpreviewTitle {
  457. background-color: rgba(${COLORS.answer.backRGB}, 0.37);
  458. color: ${COLORS.answer.foreRGB};
  459. }
  460. body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar {
  461. background-color: rgba(${COLORS.answer.backRGB}, 0.1);
  462. }
  463. body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb {
  464. background-color: rgba(${COLORS.answer.backRGB}, 0.2);
  465. }
  466. body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb:hover {
  467. background-color: rgba(${COLORS.answer.backRGB}, 0.3);
  468. }
  469. body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb:active {
  470. background-color: rgba(${COLORS.answer.backRGB}, 0.75);
  471. }
  472.  
  473. #SEpreviewAnswers {
  474. all: unset;
  475. display: block;
  476. padding: 10px 30px;
  477. font-weight: bold;
  478. font-size: 20px;
  479. line-height: 1.3;
  480. border-top: 4px solid rgba(${COLORS.answer.backRGB}, 0.37);
  481. background-color: rgba(${COLORS.answer.backRGB}, 0.37);
  482. color: ${COLORS.answer.foreRGB};
  483. word-break: break-word;
  484. }
  485. #SEpreviewAnswers a {
  486. color: ${COLORS.answer.foreRGB};
  487. padding: .25ex .75ex;
  488. text-decoration: none;
  489. }
  490. #SEpreviewAnswers a:hover:not(.SEpreviewed) {
  491. text-decoration: underline;
  492. }
  493. #SEpreviewAnswers a.SEpreviewed {
  494. background-color: ${COLORS.answer.foreRGB};
  495. color: ${COLORS.answer.foreInv};
  496. }
  497.  
  498. .comments .new-comment-highlight {
  499. -webkit-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  500. -moz-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  501. animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  502. }
  503.  
  504. @-webkit-keyframes highlight {
  505. from {background-color: #ffcf78}
  506. to {background-color: none}
  507. }
  508. </style>`;
  509. }