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