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.1
  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. const PREVIEW_DELAY = 100;
  31. var xhr;
  32. var preview;
  33. var previewLink;
  34. var previewTimer;
  35. var previewCSScache = {};
  36. var hovering = {stoppedAt: {x:0, y:0}};
  37.  
  38. const rx = getURLregexForMatchedSites();
  39. const thisPageBaseUrl = (location.href.match(rx) || [])[0];
  40. const thisPageBaseUrlShort = thisPageBaseUrl ? thisPageBaseUrl.replace('/questions/', '/q/') : undefined;
  41.  
  42. const stylesOverride = `<style>
  43. body, html {
  44. min-width: unset!important;
  45. box-shadow: none!important;
  46. }
  47. html, body {
  48. background: unset!important;;
  49. }
  50. body {
  51. display: flex;
  52. flex-direction: column;
  53. height: 100%;
  54. }
  55. #SEpreviewTitle {
  56. all: unset;
  57. display: block;
  58. padding: 20px 30px;
  59. font-weight: bold;
  60. font-size: 20px;
  61. line-height: 1.3;
  62. background-color: rgba(80, 133, 195, 0.37);
  63. color: #265184;
  64. }
  65. #SEpreviewTitle:hover {
  66. text-decoration: underline;
  67. }
  68. #SEpreviewBody {
  69. padding: 30px!important;
  70. overflow: auto;
  71. }
  72. #SEpreviewBody::-webkit-scrollbar {
  73. background-color: rgba(80, 133, 195, 0.1);
  74. }
  75. #SEpreviewBody::-webkit-scrollbar-thumb {
  76. background-color: rgba(80, 133, 195, 0.2);
  77. }
  78. #SEpreviewBody::-webkit-scrollbar-thumb:hover {
  79. background-color: rgba(80, 133, 195, 0.3);
  80. }
  81. #SEpreviewBody::-webkit-scrollbar-thumb:active {
  82. background-color: rgba(80, 133, 195, 0.75);
  83. }
  84.  
  85. body.SEpreviewAnswer #SEpreviewTitle {
  86. background-color: rgba(112, 195, 80, 0.37);
  87. color: #3f7722;
  88. }
  89. }
  90. body.SEpreviewAnswer #SEpreviewBody::-webkit-scrollbar {
  91. background-color: rgba(112, 195, 80, 0.1);
  92. }
  93. body.SEpreviewAnswer #SEpreviewBody::-webkit-scrollbar-thumb {
  94. background-color: rgba(112, 195, 80, 0.2);
  95. }
  96. body.SEpreviewAnswer #SEpreviewBody::-webkit-scrollbar-thumb:hover {
  97. background-color: rgba(112, 195, 80, 0.3);
  98. }
  99. body.SEpreviewAnswer #SEpreviewBody::-webkit-scrollbar-thumb:active {
  100. background-color: rgba(112, 195, 80, 0.75);
  101. }
  102. </style>`;
  103.  
  104. GM_addStyle(`
  105. #SEpreview {
  106. all: unset;
  107. box-sizing: content-box;
  108. width: 720px; /* 660px + 30px + 30px */
  109. height: 33%;
  110. min-height: 200px;
  111. position: fixed;
  112. transition: opacity .25s ease-in-out;
  113. right: 0;
  114. bottom: 0;
  115. padding: 0;
  116. margin: 0;
  117. background: white;
  118. box-shadow: 0 0 100px rgba(0,0,0,0.5);
  119. z-index: 999999;
  120. border: 8px solid rgb(80, 133, 195);
  121. }
  122. #SEpreview.SEpreviewAnswer {
  123. border-color: rgb(112, 195, 80);
  124. }
  125. `);
  126.  
  127. processExistingAndSetMutationHandler('a', onLinkAdded);
  128.  
  129. /**************************************************************/
  130.  
  131. function onLinkAdded(links) {
  132. for (var i = 0, link; (link = links[i++]); ) {
  133. if (rx.test(link.href) &&
  134. !link.href.startsWith(thisPageBaseUrl) &&
  135. !link.href.startsWith(thisPageBaseUrlShort)
  136. ) {
  137. link.removeAttribute('title');
  138. link.addEventListener('mouseover', onLinkHovered);
  139. }
  140. }
  141. }
  142.  
  143. function onLinkHovered(e) {
  144. if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)
  145. return;
  146. previewLink = this;
  147. previewLink.addEventListener('mousemove', onLinkMouseMove);
  148. previewLink.addEventListener('mouseout', abortPreview);
  149. previewLink.addEventListener('mousedown', abortPreview);
  150. restartPreviewTimer();
  151. }
  152.  
  153. function onLinkMouseMove(e) {
  154. if (Math.abs(hovering.stoppedAt.x - e.clientX) < 2 && Math.abs(hovering.stoppedAt.y - e.clientY) < 2)
  155. return;
  156. hovering.stoppedAt.x = e.clientX;
  157. hovering.stoppedAt.y = e.clientY;
  158. restartPreviewTimer();
  159. }
  160.  
  161. function restartPreviewTimer() {
  162. clearTimeout(previewTimer);
  163. previewTimer = setTimeout(() => {
  164. previewTimer = 0;
  165. if (!previewLink.matches(':hover'))
  166. return;
  167. downloadPage();
  168. }, PREVIEW_DELAY);
  169. }
  170.  
  171. function abortPreview() {
  172. previewLink.removeEventListener('mousemove', onLinkMouseMove);
  173. previewLink.removeEventListener('mouseout', abortPreview);
  174. previewLink.removeEventListener('mousedown', abortPreview);
  175. clearTimeout(previewTimer);
  176. previewTimer = setTimeout(() => {
  177. previewTimer = 0;
  178. if (preview && !preview.matches(':hover'))
  179. hidePreview();
  180. }, 500);
  181. if (xhr)
  182. xhr.abort();
  183. }
  184.  
  185. function downloadPage() {
  186. xhr = GM_xmlhttpRequest({
  187. method: 'GET',
  188. url: previewLink.href,
  189. onload: showPreview,
  190. });
  191. }
  192.  
  193. function showPreview(data) {
  194. var doc = new DOMParser().parseFromString(data.responseText, 'text/html');
  195. if (!doc || !doc.head) {
  196. console.error(GM_info.script.name, 'empty document received:', data);
  197. return;
  198. }
  199.  
  200. if (!$(doc, 'base'))
  201. doc.head.insertAdjacentHTML('afterbegin', `<base href="${data.finalUrl}">`);
  202.  
  203. var answerIdMatch = data.finalUrl.match(/questions\/.+?\/(\d+)/);
  204. var postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
  205. var post = $(doc, postId + ' .post-text');
  206. if (!post)
  207. return;
  208. var title = $(doc, 'meta[property="og:title"]').content;
  209. var comments = $(doc, postId + ' .comments');
  210.  
  211. $$remove(doc, 'script, .post-menu');
  212.  
  213. var externalsReady = [stylesOverride];
  214. var stylesToGet = new Set();
  215. var afterBodyHtml = '';
  216.  
  217. fetchExternals();
  218. maybeRender();
  219.  
  220. function fetchExternals() {
  221. var codeBlocks = $$(post, 'pre code');
  222. if (codeBlocks.length) {
  223. codeBlocks.forEach(e => e.parentElement.classList.add('prettyprint'));
  224. externalsReady.push(
  225. '<script> StackExchange = {}; </script>',
  226. '<script src="https://cdn.sstatic.net/Js/prettify-full.en.js"></script>'
  227. );
  228. afterBodyHtml = '<script> prettyPrint(); </script>';
  229. }
  230.  
  231. $$(doc, 'style, link[rel="stylesheet"]').forEach(e => {
  232. if (e.localName == 'style')
  233. externalsReady.push(e.outerHTML);
  234. else if (e.href in previewCSScache)
  235. externalsReady.push(previewCSScache[e.href]);
  236. else {
  237. stylesToGet.add(e.href);
  238. GM_xmlhttpRequest({
  239. method: 'GET',
  240. url: e.href,
  241. onload: data => {
  242. externalsReady.push(previewCSScache[e.href] = '<style>' + data.responseText + '</style>');
  243. stylesToGet.delete(e.href);
  244. maybeRender();
  245. },
  246. });
  247. }
  248. });
  249.  
  250. }
  251.  
  252. function maybeRender() {
  253. if (stylesToGet.size)
  254. return;
  255. initPreview();
  256. preview.classList.toggle('SEpreviewAnswer', !!answerIdMatch);
  257. document.body.appendChild(preview);
  258. var headHtml = externalsReady.join('');
  259. var bodyHtml = [post.parentElement, comments].map(e => e ? e.outerHTML || e : '').join('');
  260. var pvDoc = preview.contentDocument;
  261. pvDoc.open();
  262. pvDoc.write(`
  263. <head>${headHtml}</head>
  264. <body${answerIdMatch ? ' class="SEpreviewAnswer"' : ''}>
  265. <a id="SEpreviewTitle" href="${data.finalUrl}">${title}</a>
  266. <div id="SEpreviewBody">${bodyHtml}</div>
  267. ${afterBodyHtml}
  268. </body>`);
  269. pvDoc.close();
  270. }
  271. }
  272.  
  273. function initPreview() {
  274. if (preview)
  275. return;
  276. preview = document.createElement('iframe');
  277. preview.id = 'SEpreview';
  278. preview.sandbox = 'allow-same-origin allow-scripts';
  279. preview.addEventListener('mouseenter', retainMainScrollPos);
  280. }
  281.  
  282. function retainMainScrollPos(e) {
  283. var scrollPos;
  284. document.addEventListener('scroll', onScroll, {passive: false});
  285. preview.addEventListener('mouseleave', onMouseLeave);
  286. function onScroll(e) {
  287. if (scrollPos)
  288. scrollTo(scrollPos.x, scrollPos.y);
  289. }
  290. function onMouseLeave(e) {
  291. scrollPos = null;
  292. preview.removeEventListener('mouseleave', onMouseLeave);
  293. document.removeEventListener('scroll', onScroll, {passive: false});
  294. }
  295. }
  296.  
  297. function hidePreview() {
  298. preview.remove();
  299. }
  300.  
  301. function getURLregexForMatchedSites() {
  302. return new RegExp('https?://(\\w*\\.)*(' + GM_info.script.matches.map(m =>
  303. m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.')
  304. ).join('|') + ')/(questions|q|a)/\\d+');
  305. }
  306.  
  307. function $(node__optional, selector) {
  308. // or $(selector) {
  309. return (node__optional || document).querySelector(selector || node__optional);
  310. }
  311.  
  312. function $$(node__optional, selector) {
  313. // or $$(selector) {
  314. return (node__optional || document).querySelectorAll(selector || node__optional);
  315. }
  316.  
  317. function $$remove(node__optional, selector) {
  318. // or $$remove(selector) {
  319. (node__optional || document).querySelectorAll(selector || node__optional)
  320. .forEach(e => e.remove());
  321. }