SE Preview on hover

Shows preview of the linked questions/answers on hover while Ctrl key is held

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

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