SE Preview on hover

Shows preview of the linked questions/answers on hover

目前为 2017-02-13 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name SE Preview on hover
  3. // @description Shows preview of the linked questions/answers on hover
  4. // @version 0.0.8
  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. back: '80, 133, 195',
  36. fore: '#265184',
  37. },
  38. answer: {
  39. back: '112, 195, 80',
  40. fore: '#3f7722',
  41. },
  42. };
  43.  
  44. let xhr;
  45. let preview = {
  46. frame: null,
  47. link: null,
  48. hover: {x:0, y:0},
  49. timer: 0,
  50. CSScache: {},
  51. stylesOverride: '',
  52. };
  53.  
  54. const rxPreviewable = getURLregexForMatchedSites();
  55. const thisPageUrls = getPageBaseUrls(location.href);
  56.  
  57. initStyles();
  58. initPolyfills();
  59. setMutationHandler('a', onLinkAdded, {processExisting: true});
  60.  
  61. /**************************************************************/
  62.  
  63. function onLinkAdded(links) {
  64. for (let i = 0, link; (link = links[i++]); ) {
  65. if (isLinkPreviewable(link)) {
  66. link.removeAttribute('title');
  67. link.addEventListener('mouseover', onLinkHovered);
  68. }
  69. }
  70. }
  71.  
  72. function onLinkHovered(e) {
  73. if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)
  74. return;
  75. preview.link = this;
  76. preview.link.addEventListener('mousemove', onLinkMouseMove);
  77. preview.link.addEventListener('mouseout', abortPreview);
  78. preview.link.addEventListener('mousedown', abortPreview);
  79. restartPreviewTimer();
  80. }
  81.  
  82. function onLinkMouseMove(e) {
  83. let stoppedMoving = Math.abs(preview.hover.x - e.clientX) < 2 &&
  84. Math.abs(preview.hover.y - e.clientY) < 2;
  85. if (!stoppedMoving)
  86. return;
  87. preview.hover.x = e.clientX;
  88. preview.hover.y = e.clientY;
  89. restartPreviewTimer();
  90. }
  91.  
  92. function restartPreviewTimer() {
  93. clearTimeout(preview.timer);
  94. preview.timer = setTimeout(() => {
  95. preview.timer = 0;
  96. if (preview.link.matches(':hover'))
  97. downloadPreview(preview.link.href);
  98. }, PREVIEW_DELAY);
  99. }
  100.  
  101. function abortPreview() {
  102. preview.link.removeEventListener('mousemove', onLinkMouseMove);
  103. preview.link.removeEventListener('mouseout', abortPreview);
  104. preview.link.removeEventListener('mousedown', abortPreview);
  105. clearTimeout(preview.timer);
  106. preview.timer = setTimeout(() => {
  107. preview.timer = 0;
  108. if (preview.frame && !preview.frame.matches(':hover'))
  109. hideAndRemove(preview.frame);
  110. }, 1000);
  111. if (xhr)
  112. xhr.abort();
  113. }
  114.  
  115. function hideAndRemove(element, transition) {
  116. if (transition) {
  117. element.style.transition = typeof transition == 'number' ? `opacity ${transition}s ease-in-out` : transition;
  118. return setTimeout(hideAndRemove, 0, element);
  119. }
  120. element.style.opacity = 0;
  121. element.addEventListener('transitionend', function remove() {
  122. element.removeEventListener('transitionend', remove);
  123. element.remove();
  124. });
  125. }
  126.  
  127. function downloadPreview(url) {
  128. xhr = GM_xmlhttpRequest({
  129. method: 'GET',
  130. url: url,
  131. onload: showPreview,
  132. });
  133. }
  134.  
  135. function showPreview(data) {
  136. let doc = new DOMParser().parseFromString(data.responseText, 'text/html');
  137. if (!doc || !doc.head) {
  138. console.error(GM_info.script.name, 'empty document received:', data);
  139. return;
  140. }
  141.  
  142. if (!$(doc, 'base'))
  143. doc.head.insertAdjacentHTML('afterbegin', `<base href="${data.finalUrl}">`);
  144.  
  145. let answerIdMatch = data.finalUrl.match(/questions\/.+?\/(\d+)/);
  146. let postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
  147. let post = $(doc, postId + ' .post-text');
  148. if (!post)
  149. return;
  150. let title = $(doc, 'meta[property="og:title"]').content;
  151. let comments = $(doc, `${postId} .comments`);
  152. let commentsMore = +$(comments, 'tbody').dataset.remainingCommentsCount && $(doc, `${postId} .js-show-link.comments-link`);
  153. let answers; // = answerIdMatch ? null : $$(doc, '.answer');
  154.  
  155. $$remove(doc, 'script, .post-menu');
  156.  
  157. let externalsReady = [preview.stylesOverride];
  158. let stylesToGet = new Set();
  159. let afterBodyHtml = '';
  160.  
  161. fetchExternals();
  162. maybeRender();
  163.  
  164. function fetchExternals() {
  165. let codeBlocks = $$(post, 'pre code');
  166. if (codeBlocks.length) {
  167. codeBlocks.forEach(e => e.parentElement.classList.add('prettyprint'));
  168. externalsReady.push(
  169. '<script> StackExchange = {}; </script>',
  170. '<script src="https://cdn.sstatic.net/Js/prettify-full.en.js"></script>'
  171. );
  172. afterBodyHtml = '<script> prettyPrint(); </script>';
  173. }
  174.  
  175. $$(doc, 'style, link[rel="stylesheet"]').forEach(e => {
  176. if (e.localName == 'style')
  177. externalsReady.push(e.outerHTML);
  178. else if (e.href in preview.CSScache)
  179. externalsReady.push(preview.CSScache[e.href]);
  180. else {
  181. stylesToGet.add(e.href);
  182. GM_xmlhttpRequest({
  183. method: 'GET',
  184. url: e.href,
  185. onload: data => {
  186. externalsReady.push(preview.CSScache[e.href] = '<style>' + data.responseText + '</style>');
  187. stylesToGet.delete(e.href);
  188. maybeRender();
  189. },
  190. });
  191. }
  192. });
  193.  
  194. }
  195.  
  196. function maybeRender() {
  197. if (stylesToGet.size)
  198. return;
  199. if (!preview.frame) {
  200. preview.frame = document.createElement('iframe');
  201. preview.frame.id = 'SEpreview';
  202. }
  203. preview.frame.classList.toggle('SEpreviewIsAnswer', !!answerIdMatch);
  204. document.body.appendChild(preview.frame);
  205.  
  206. let bodyHtml = [post.parentElement, comments, commentsMore].map(e => e ? e.outerHTML : '').join('');
  207. let allHtml = `<head>${externalsReady.join('')}</head>
  208. <body${answerIdMatch ? ' class="SEpreviewIsAnswer"' : ''}>
  209. <a id="SEpreviewTitle" href="${data.finalUrl}">${title}</a>
  210. <div id="SEpreviewBody">${bodyHtml}</div>
  211. ${!answers ? '' : '<div id="SEpreviewAnswers">' + answers.length + ' answer' + (answers.length > 1 ? 's' : '') + '</div>'}
  212. ${afterBodyHtml}
  213. </body>`;
  214. try {
  215. let pvDoc = preview.frame.contentDocument;
  216. pvDoc.open();
  217. pvDoc.write(allHtml);
  218. pvDoc.close();
  219. } catch(e) {
  220. preview.frame.srcdoc = `<html>${allHtml}</html>`;
  221. }
  222. preview.frame.contentWindow.onload = () => {
  223. preview.frame.style.opacity = 1;
  224. let pvDoc = preview.frame.contentDocument;
  225. pvDoc.addEventListener('mouseover', retainMainScrollPos);
  226. pvDoc.addEventListener('click', interceptLinks);
  227. if (answers)
  228. $(pvDoc, '#SEpreviewAnswers').addEventListener('click', revealAnswers);
  229. };
  230. }
  231.  
  232. function interceptLinks(e) {
  233. if (e.target.matches('.js-show-link.comments-link')) {
  234. hideAndRemove(e.target, 0.5);
  235. downloadComments();
  236. }
  237. else if (e.button || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)
  238. return;
  239. else if (isLinkPreviewable(e.target, getPageBaseUrls(preview.link.href)))
  240. downloadPreview(e.target.href);
  241. else
  242. return;
  243. e.preventDefault();
  244. }
  245.  
  246. function downloadComments() {
  247. GM_xmlhttpRequest({
  248. method: 'GET',
  249. url: new URL(data.finalUrl).origin + '/posts/' + comments.id.match(/\d+/)[0] + '/comments',
  250. onload: r => showComments(r.responseText),
  251. });
  252. }
  253.  
  254. function showComments(html) {
  255. let tbody = $(preview.frame.contentDocument, `#${comments.id} tbody`);
  256. let oldIds = new Set([...tbody.rows].map(e => e.id));
  257. tbody.innerHTML = html;
  258. for (let tr of tbody.rows)
  259. if (!oldIds.has(tr.id))
  260. tr.classList.add('new-comment-highlight');
  261. }
  262. }
  263.  
  264. function retainMainScrollPos(e) {
  265. let scrollPos = {x:scrollX, y:scrollY};
  266. document.addEventListener('scroll', preventScroll);
  267. document.addEventListener('mouseover', releaseScrollLock);
  268.  
  269. function preventScroll(e) {
  270. scrollTo(scrollPos.x, scrollPos.y);
  271. }
  272. function releaseScrollLock(e) {
  273. document.removeEventListener('mouseout', releaseScrollLock);
  274. document.removeEventListener('scroll', preventScroll);
  275. }
  276. }
  277.  
  278. function getURLregexForMatchedSites() {
  279. return new RegExp('https?://(\\w*\\.)*(' + GM_info.script.matches.map(m =>
  280. m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.')
  281. ).join('|') + ')/(questions|q|a)/\\d+');
  282. }
  283.  
  284. function getPageBaseUrls(url) {
  285. let base = (url.match(rxPreviewable) || [])[0];
  286. return {
  287. base,
  288. short: base ? base.replace('/questions/', '/q/') : undefined,
  289. };
  290. }
  291.  
  292. function isLinkPreviewable(link, pageUrls = thisPageUrls) {
  293. return rxPreviewable.test(link.href) &&
  294. !link.matches('.short-link') &&
  295. !link.href.startsWith(pageUrls.base) &&
  296. !link.href.startsWith(pageUrls.short);
  297. }
  298.  
  299. function $(node__optional, selector) {
  300. // or $(selector) {
  301. return (node__optional || document).querySelector(selector || node__optional);
  302. }
  303.  
  304. function $$(node__optional, selector) {
  305. // or $$(selector) {
  306. return (node__optional || document).querySelectorAll(selector || node__optional);
  307. }
  308.  
  309. function $$remove(node__optional, selector) {
  310. // or $$remove(selector) {
  311. (node__optional || document).querySelectorAll(selector || node__optional)
  312. .forEach(e => e.remove());
  313. }
  314.  
  315. function initPolyfills() {
  316. NodeList.prototype.forEach = NodeList.prototype.forEach || Array.prototype.forEach;
  317. }
  318.  
  319. function initStyles() {
  320. GM_addStyle(`
  321. #SEpreview {
  322. all: unset;
  323. box-sizing: content-box;
  324. width: 720px; /* 660px + 30px + 30px */
  325. height: 33%;
  326. min-height: 200px;
  327. position: fixed;
  328. opacity: 0;
  329. transition: opacity .5s cubic-bezier(.88,.02,.92,.66);
  330. right: 0;
  331. bottom: 0;
  332. padding: 0;
  333. margin: 0;
  334. background: white;
  335. box-shadow: 0 0 100px rgba(0,0,0,0.5);
  336. z-index: 999999;
  337. border: 8px solid rgb(${COLORS.question.back});
  338. }
  339. #SEpreview.SEpreviewIsAnswer {
  340. border-color: rgb(${COLORS.answer.back});
  341. }
  342. `);
  343.  
  344. preview.stylesOverride = `<style>
  345. body, html {
  346. min-width: unset!important;
  347. box-shadow: none!important;
  348. }
  349. html, body {
  350. background: unset!important;;
  351. }
  352. body {
  353. display: flex;
  354. flex-direction: column;
  355. height: 100vh;
  356. }
  357. #SEpreviewTitle {
  358. all: unset;
  359. display: block;
  360. padding: 20px 30px;
  361. font-weight: bold;
  362. font-size: 20px;
  363. line-height: 1.3;
  364. background-color: rgba(${COLORS.question.back}, 0.37);
  365. color: ${COLORS.question.fore};
  366. }
  367. #SEpreviewTitle:hover {
  368. text-decoration: underline;
  369. }
  370. #SEpreviewBody {
  371. padding: 30px!important;
  372. overflow: auto;
  373. }
  374. #SEpreviewBody::-webkit-scrollbar {
  375. background-color: rgba(${COLORS.question.back}, 0.1);
  376. }
  377. #SEpreviewBody::-webkit-scrollbar-thumb {
  378. background-color: rgba(${COLORS.question.back}, 0.2);
  379. }
  380. #SEpreviewBody::-webkit-scrollbar-thumb:hover {
  381. background-color: rgba(${COLORS.question.back}, 0.3);
  382. }
  383. #SEpreviewBody::-webkit-scrollbar-thumb:active {
  384. background-color: rgba(${COLORS.question.back}, 0.75);
  385. }
  386.  
  387. body.SEpreviewIsAnswer #SEpreviewTitle {
  388. background-color: rgba(${COLORS.answer.back}, 0.37);
  389. color: ${COLORS.answer.fore};
  390. }
  391. body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar {
  392. background-color: rgba(${COLORS.answer.back}, 0.1);
  393. }
  394. body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb {
  395. background-color: rgba(${COLORS.answer.back}, 0.2);
  396. }
  397. body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb:hover {
  398. background-color: rgba(${COLORS.answer.back}, 0.3);
  399. }
  400. body.SEpreviewIsAnswer #SEpreviewBody::-webkit-scrollbar-thumb:active {
  401. background-color: rgba(${COLORS.answer.back}, 0.75);
  402. }
  403.  
  404. #SEpreviewAnswers {
  405. all: unset;
  406. display: block;
  407. padding: 10px 30px;
  408. font-weight: bold;
  409. font-size: 20px;
  410. line-height: 1.3;
  411. border-top: 4px solid rgba(${COLORS.answer.back}, 0.37);
  412. background-color: rgba(${COLORS.answer.back}, 0.37);
  413. color: ${COLORS.answer.fore};
  414. }
  415.  
  416. .comments .new-comment-highlight {
  417. -webkit-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  418. -moz-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  419. animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  420. }
  421.  
  422. @-webkit-keyframes highlight {
  423. from {background-color: #ffcf78}
  424. to {background-color: none}
  425. }
  426. </style>`;
  427. }