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