SE Preview on hover

Shows preview of the linked questions/answers on hover

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

  1. // ==UserScript==
  2. // @name SE Preview on hover
  3. // @description Shows preview of the linked questions/answers on hover
  4. // @version 0.4.3
  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. // @include /https?:\/\/www\.?google(\.com?)?(\.\w\w)?\/(webhp|q|.*?[?#]q=|search).*/
  16. // @require https://gf.qytechs.cn/scripts/12228/code/setMutationHandler.js
  17. // @require https://gf.qytechs.cn/scripts/27531/code/LZString-2xspeedup.js
  18. // @grant GM_addStyle
  19. // @grant GM_xmlhttpRequest
  20. // @grant GM_getValue
  21. // @grant GM_setValue
  22. // @connect stackoverflow.com
  23. // @connect superuser.com
  24. // @connect serverfault.com
  25. // @connect askubuntu.com
  26. // @connect stackapps.com
  27. // @connect mathoverflow.net
  28. // @connect stackexchange.com
  29. // @connect cdn.sstatic.net
  30. // @run-at document-end
  31. // @noframes
  32. // ==/UserScript==
  33.  
  34. /* jshint lastsemic:true, multistr:true, laxbreak:true, -W030, -W041, -W084 */
  35.  
  36. const PREVIEW_DELAY = 200;
  37. const CACHE_DURATION = 1 * 60 * 1000; // 1 minute for the recently active posts, scales up logarithmically
  38. const MIN_HEIGHT = 400; // px
  39. const COLORS = {
  40. question: {
  41. backRGB: '80, 133, 195',
  42. fore: '#265184',
  43. },
  44. answer: {
  45. backRGB: '112, 195, 80',
  46. fore: '#3f7722',
  47. foreInv: 'white',
  48. },
  49. deleted: {
  50. backRGB: '181, 103, 103',
  51. fore: 'rgb(181, 103, 103)',
  52. foreInv: 'white',
  53. },
  54. closed: {
  55. backRGB: '255, 206, 93',
  56. fore: 'rgb(194, 136, 0)',
  57. foreInv: 'white',
  58. },
  59. };
  60.  
  61. let xhr;
  62. const xhrNoSSL = new Set();
  63. const preview = {
  64. frame: null,
  65. link: null,
  66. hover: {x:0, y:0},
  67. timer: 0,
  68. stylesOverride: '',
  69. };
  70. const lockScroll = {};
  71.  
  72. const {full: rxPreviewable, siteOnly: rxPreviewableSite} = getURLregexForMatchedSites();
  73. const thisPageUrls = getPageBaseUrls(location.href);
  74.  
  75. initStyles();
  76. initPolyfills();
  77. setMutationHandler('a', onLinkAdded, {processExisting: true});
  78. setTimeout(cleanupCache, 10000);
  79.  
  80. /**************************************************************/
  81.  
  82. function onLinkAdded(links) {
  83. for (let i = 0, link; (link = links[i++]); ) {
  84. if (isLinkPreviewable(link)) {
  85. link.removeAttribute('title');
  86. $on('mouseover', link, onLinkHovered);
  87. }
  88. }
  89. }
  90.  
  91. function onLinkHovered(e) {
  92. if (hasKeyModifiers(e) || !document.hasFocus())
  93. return;
  94. preview.link = this;
  95. $on('mousemove', this, onLinkMouseMove);
  96. $on('mouseout', this, abortPreview);
  97. $on('mousedown', this, abortPreview);
  98. restartPreviewTimer(this);
  99. }
  100.  
  101. function onLinkMouseMove(e) {
  102. let stoppedMoving = Math.abs(preview.hover.x - e.clientX) < 2 &&
  103. Math.abs(preview.hover.y - e.clientY) < 2;
  104. if (!stoppedMoving)
  105. return;
  106. preview.hover.x = e.clientX;
  107. preview.hover.y = e.clientY;
  108. restartPreviewTimer(this);
  109. }
  110.  
  111. function restartPreviewTimer(link) {
  112. clearTimeout(preview.timer);
  113. preview.timer = setTimeout(() => {
  114. preview.timer = 0;
  115. $off('mousemove', link, onLinkMouseMove);
  116. if (link.matches(':hover'))
  117. downloadPreview(link.href);
  118. }, PREVIEW_DELAY);
  119. }
  120.  
  121. function abortPreview(e) {
  122. releaseLinkListeners(this);
  123. preview.timer = setTimeout(link => {
  124. if (link == preview.link && preview.frame && !preview.frame.matches(':hover'))
  125. preview.frame.contentWindow.postMessage('SEpreview-hidden', '*');
  126. }, PREVIEW_DELAY * 3, this);
  127. if (xhr)
  128. xhr.abort();
  129. }
  130.  
  131. function releaseLinkListeners(link = preview.link) {
  132. $off('mousemove', link, onLinkMouseMove);
  133. $off('mouseout', link, abortPreview);
  134. $off('mousedown', link, abortPreview);
  135. clearTimeout(preview.timer);
  136. }
  137.  
  138. function fadeOut(element, transition) {
  139. return new Promise(resolve => {
  140. if (transition) {
  141. element.style.transition = typeof transition == 'number' ? `opacity ${transition}s ease-in-out` : transition;
  142. setTimeout(doFadeOut);
  143. } else
  144. doFadeOut();
  145.  
  146. function doFadeOut() {
  147. element.style.opacity = '0';
  148. $on('transitionend', element, function done() {
  149. $off('transitionend', element, done);
  150. if (element.style.opacity == '0')
  151. element.style.display = 'none';
  152. resolve();
  153. });
  154. }
  155. });
  156. }
  157.  
  158. function fadeIn(element) {
  159. element.style.opacity = '0';
  160. element.style.display = 'block';
  161. setTimeout(() => element.style.opacity = '1');
  162. }
  163.  
  164. function downloadPreview(url) {
  165. const cached = readCache(url);
  166. if (cached)
  167. return showPreview(cached);
  168. doXHR({url: httpsUrl(url)}).then(r => {
  169. const html = r.responseText;
  170. const lastActivity = +showPreview({finalUrl: r.finalUrl, html});
  171. if (!lastActivity)
  172. return;
  173. const inactiveDays = Math.max(0, (Date.now() - lastActivity) / (24 * 3600 * 1000));
  174. const cacheDuration = CACHE_DURATION * Math.pow(Math.log(inactiveDays + 1) + 1, 2);
  175. setTimeout(writeCache, 1000, {url, finalUrl: r.finalUrl, html, cacheDuration});
  176. });
  177. }
  178.  
  179. function initPreview() {
  180. preview.frame = document.createElement('iframe');
  181. preview.frame.id = 'SEpreview';
  182. document.body.appendChild(preview.frame);
  183. makeResizable();
  184.  
  185. lockScroll.attach = e => {
  186. if (lockScroll.pos)
  187. return;
  188. lockScroll.pos = {x: scrollX, y: scrollY};
  189. $on('scroll', document, lockScroll.run);
  190. $on('mouseover', document, lockScroll.detach);
  191. };
  192. lockScroll.run = e => scrollTo(lockScroll.pos.x, lockScroll.pos.y);
  193. lockScroll.detach = e => {
  194. if (!lockScroll.pos)
  195. return;
  196. lockScroll.pos = null;
  197. $off('mouseover', document, lockScroll.detach);
  198. $off('scroll', document, lockScroll.run);
  199. };
  200. }
  201.  
  202. function showPreview({finalUrl, html, doc}) {
  203. doc = doc || new DOMParser().parseFromString(html, 'text/html');
  204. if (!doc || !doc.head)
  205. return error('no HEAD in the document received for', finalUrl);
  206.  
  207. if (!$('base', doc))
  208. doc.head.insertAdjacentHTML('afterbegin', `<base href="${finalUrl}">`);
  209.  
  210. const answerIdMatch = finalUrl.match(/questions\/\d+\/[^\/]+\/(\d+)/);
  211. const isQuestion = !answerIdMatch;
  212. const postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
  213. const post = $(postId + ' .post-text', doc);
  214. if (!post)
  215. return error('No parsable post found', doc);
  216. const isDeleted = !!post.closest('.deleted-answer');
  217. const title = $('meta[property="og:title"]', doc).content;
  218. const status = isQuestion && !$('.question-status', post) ? $('.question-status', doc) : null;
  219. const isClosed = $('.question-originals-of-duplicate, .close-as-off-topic-status-list, .close-status-suffix', doc);
  220. const comments = $(`${postId} .comments`, doc);
  221. const commentsHidden = +$('tbody', comments).dataset.remainingCommentsCount;
  222. const commentsShowLink = commentsHidden && $(`${postId} .js-show-link.comments-link`, doc);
  223. const finalUrlOfQuestion = getCacheableUrl(finalUrl);
  224. const lastActivity = tryCatch(() => new Date($('.lastactivity-link', doc).title).getTime()) || Date.now();
  225.  
  226. markPreviewableLinks(doc);
  227. $$remove('script', doc);
  228.  
  229. if (!preview.frame)
  230. initPreview();
  231.  
  232. let pvDoc, pvWin;
  233. preview.frame.style.display = '';
  234. preview.frame.setAttribute('SEpreview-type',
  235. isDeleted ? 'deleted' : isQuestion ? (isClosed ? 'closed' : 'question') : 'answer');
  236. onFrameReady(preview.frame).then(
  237. () => {
  238. pvDoc = preview.frame.contentDocument;
  239. pvWin = preview.frame.contentWindow;
  240. initPolyfills(pvWin);
  241. })
  242. .then(addStyles)
  243. .then(render)
  244. .then(show);
  245. return lastActivity;
  246.  
  247. function markPreviewableLinks(container) {
  248. for (let link of $$('a:not(.SEpreviewable)', container)) {
  249. if (rxPreviewable.test(link.href)) {
  250. link.removeAttribute('title');
  251. link.classList.add('SEpreviewable');
  252. }
  253. }
  254. }
  255.  
  256. function markHoverableUsers(container) {
  257. for (let link of $$('a[href*="/users/"]', container)) {
  258. if (rxPreviewableSite.test(link.href) && link.pathname.match(/^\/users\/\d+/)) {
  259. link.onmouseover = loadUserCard;
  260. link.classList.add('SEpreview-userLink');
  261. }
  262. }
  263. }
  264.  
  265. function addStyles() {
  266. const SEpreviewStyles = $replaceOrCreate({
  267. id: 'SEpreviewStyles',
  268. tag: 'style', parent: pvDoc.head, className: 'SEpreview-reuse',
  269. innerHTML: preview.stylesOverride,
  270. });
  271. $replaceOrCreate($$('style', doc).map(e => ({
  272. id: 'SEpreview' + e.innerHTML.replace(/\W+/g, '').length,
  273. tag: 'style', before: SEpreviewStyles, className: 'SEpreview-reuse',
  274. innerHTML: e.innerHTML,
  275. })));
  276. return onStyleSheetsReady(
  277. $replaceOrCreate($$('link[rel="stylesheet"]', doc).map(e => ({
  278. id: e.href.replace(/\W+/g, ''),
  279. tag: 'link', before: SEpreviewStyles, className: 'SEpreview-reuse',
  280. href: e.href, rel: 'stylesheet',
  281. })))
  282. );
  283. }
  284.  
  285. function render() {
  286. pvDoc.body.setAttribute('SEpreview-type', preview.frame.getAttribute('SEpreview-type'));
  287.  
  288. $replaceOrCreate([{
  289. // title
  290. id: 'SEpreview-title', tag: 'a',
  291. parent: pvDoc.body, className: 'SEpreviewable',
  292. href: finalUrlOfQuestion,
  293. textContent: title,
  294. }, {
  295. // close button
  296. id: 'SEpreview-close',
  297. parent: pvDoc.body,
  298. title: 'Or press Esc key while the preview is focused (also when just shown)',
  299. }, {
  300. // vote count, date, views#
  301. id: 'SEpreview-meta',
  302. parent: pvDoc.body,
  303. innerHTML: [
  304. $text('.vote-count-post', post.closest('table')).replace(/(-?)(\d+)/,
  305. (s, sign, v) => s == '0' ? '' : `<b>${s}</b> vote${+v > 1 ? 's' : ''}, `),
  306. isQuestion
  307. ? $$('#qinfo tr', doc)
  308. .map(row => $$('.label-key', row).map($text).join(' '))
  309. .join(', ').replace(/^((.+?) (.+?), .+?), .+? \3$/, '$1')
  310. : [...$$('.user-action-time', post.closest('.answer'))]
  311. .reverse().map($text).join(', ')
  312. ].join('')
  313. }, {
  314. // content wrapper
  315. id: 'SEpreview-body',
  316. parent: pvDoc.body,
  317. className: isDeleted ? 'deleted-answer' : '',
  318. children: [status, post.parentElement, comments, commentsShowLink],
  319. }]);
  320.  
  321. // delinkify/remove non-functional items in post-menu
  322. $$remove('.short-link, .flag-post-link', pvDoc);
  323. $$('.post-menu a:not(.edit-post)', pvDoc).forEach(a => {
  324. if (a.children.length)
  325. a.outerHTML = `<span>${a.innerHTML}</span>`;
  326. else
  327. a.remove();
  328. });
  329.  
  330. // add a timeline link
  331. if (isQuestion)
  332. $('.post-menu', pvDoc).insertAdjacentHTML('beforeend',
  333. '<span class="lsep">|</span>' +
  334. `<a href="/posts/${new URL(finalUrl).pathname.match(/\d+/)[0]}/timeline">timeline</a>`);
  335.  
  336. // prettify code blocks
  337. const codeBlocks = $$('pre code', pvDoc);
  338. if (codeBlocks.length) {
  339. codeBlocks.forEach(e => e.parentElement.classList.add('prettyprint'));
  340. if (!pvWin.StackExchange) {
  341. pvWin.StackExchange = {};
  342. let script = $scriptIn(pvDoc.head);
  343. script.text = 'StackExchange = {}';
  344. script = $scriptIn(pvDoc.head);
  345. script.src = 'https://cdn.sstatic.net/Js/prettify-full.en.js';
  346. script.setAttribute('onload', 'prettyPrint()');
  347. } else
  348. $scriptIn(pvDoc.body).text = 'prettyPrint()';
  349. }
  350.  
  351. // render bottom shelf
  352. const answers = $$('.answer', doc);
  353. if (answers.length > (isQuestion ? 0 : 1)) {
  354. $replaceOrCreate({
  355. id: 'SEpreview-answers',
  356. parent: pvDoc.body,
  357. innerHTML: answers.map(renderShelfAnswer).join(' '),
  358. });
  359. } else
  360. $$remove('#SEpreview-answers', pvDoc);
  361.  
  362. // cleanup leftovers from previously displayed post and foreign elements not injected by us
  363. $$('style, link, body script, html > *:not(head):not(body), .post-menu .lsep + .lsep', pvDoc).forEach(e => {
  364. if (e.classList.contains('SEpreview-reuse'))
  365. e.classList.remove('SEpreview-reuse');
  366. else
  367. e.remove();
  368. });
  369. }
  370.  
  371. function renderShelfAnswer(e) {
  372. const shortUrl = $('.short-link', e).href.replace(/(\d+)\/\d+/, '$1');
  373. const extraClasses = (e.matches(postId) ? ' SEpreviewed' : '') +
  374. (e.matches('.deleted-answer') ? ' deleted-answer' : '') +
  375. ($('.vote-accepted-on', e) ? ' SEpreview-accepted' : '');
  376. const author = $('.post-signature:last-child', e);
  377. const title = $text('.user-details a', author) + ' (rep ' +
  378. $text('.reputation-score', author) + ')\n' +
  379. $text('.user-action-time', author);
  380. const gravatar = $('img, .anonymous-gravatar, .community-wiki', author);
  381. return (
  382. `<a href="${shortUrl}" title="${title}" class="SEpreviewable${extraClasses}">` +
  383. $text('.vote-count-post', e).replace(/^0$/, '&nbsp;') + ' ' +
  384. (!gravatar ? '' : gravatar.src ? `<img src="${gravatar.src}">` : gravatar.outerHTML) +
  385. '</a>');
  386. }
  387.  
  388. function show() {
  389. pvDoc.onmouseover = lockScroll.attach;
  390. pvDoc.onclick = onClick;
  391. pvDoc.onkeydown = e => !hasKeyModifiers(e) && e.keyCode == 27 ? hide() : null;
  392. pvWin.onmessage = e => e.data == 'SEpreview-hidden' ? hide({fade: true}) : null;
  393. markHoverableUsers(pvDoc);
  394. $('#SEpreview-body', pvDoc).scrollTop = 0;
  395. preview.frame.style.opacity = '1';
  396. preview.frame.focus();
  397. }
  398.  
  399. function hide({fade = false} = {}) {
  400. releaseLinkListeners();
  401. releasePreviewListeners();
  402. const maybeZap = () => preview.frame.style.opacity == '0' && $removeChildren(pvDoc.body);
  403. if (fade)
  404. fadeOut(preview.frame).then(maybeZap);
  405. else {
  406. preview.frame.style.opacity = '0';
  407. preview.frame.style.display = 'none';
  408. maybeZap();
  409. }
  410. }
  411.  
  412. function releasePreviewListeners(e) {
  413. pvWin.onmessage = null;
  414. pvDoc.onmouseover = null;
  415. pvDoc.onclick = null;
  416. pvDoc.onkeydown = null;
  417. }
  418.  
  419. function onClick(e) {
  420. if (e.target.id == 'SEpreview-close')
  421. return hide();
  422.  
  423. const link = e.target.closest('a');
  424. if (!link)
  425. return;
  426.  
  427. if (link.matches('.js-show-link.comments-link')) {
  428. fadeOut(link, 0.5);
  429. loadComments();
  430. return e.preventDefault();
  431. }
  432.  
  433. if (e.button || hasKeyModifiers(e) || !link.matches('.SEpreviewable'))
  434. return (link.target = '_blank');
  435.  
  436. e.preventDefault();
  437.  
  438. if (link.id == 'SEpreview-title')
  439. showPreview({doc, finalUrl: finalUrlOfQuestion});
  440. else if (link.matches('#SEpreview-answers a'))
  441. showPreview({doc, finalUrl: finalUrlOfQuestion + '/' + link.pathname.match(/\/(\d+)/)[1]});
  442. else
  443. downloadPreview(link.href);
  444. }
  445.  
  446. function loadComments() {
  447. const url = new URL(finalUrl).origin + '/posts/' + comments.id.match(/\d+/)[0] + '/comments';
  448. doXHR({url}).then(r => {
  449. const tbody = $(`#${comments.id} tbody`, pvDoc);
  450. const oldIds = new Set([...tbody.rows].map(e => e.id));
  451. tbody.innerHTML = r.responseText;
  452. tbody.closest('.comments').style.display = 'block';
  453. for (let tr of tbody.rows)
  454. if (!oldIds.has(tr.id))
  455. tr.classList.add('new-comment-highlight');
  456. markPreviewableLinks(tbody);
  457. markHoverableUsers(tbody);
  458. });
  459. }
  460.  
  461. function loadUserCard(e, ready) {
  462. if (ready !== true)
  463. return setTimeout(loadUserCard, PREVIEW_DELAY * 2, e, true);
  464. const link = e.target.closest('a');
  465. if (!link.matches(':hover'))
  466. return;
  467. let timer;
  468. let userCard = link.nextElementSibling;
  469. if (userCard && userCard.matches('.SEpreview-userCard'))
  470. return fadeInUserCard();
  471. const url = link.origin + '/users/user-info/' + link.pathname.match(/\d+/)[0];
  472.  
  473. Promise.resolve(
  474. readCache(url) ||
  475. doXHR({url}).then(r => {
  476. writeCache({url, html: r.responseText, cacheDuration: CACHE_DURATION * 100});
  477. return {html: r.responseText};
  478. })
  479. ).then(renderUserCard);
  480.  
  481. function renderUserCard({html}) {
  482. const linkBounds = link.getBoundingClientRect();
  483. const wrapperBounds = $('#SEpreview-body', pvDoc).getBoundingClientRect();
  484. userCard = $replaceOrCreate({id: 'user-menu-tmp', className: 'SEpreview-userCard', innerHTML: html, after: link});
  485. userCard.style.left = Math.min(linkBounds.left - 20, pvWin.innerWidth - 350) + 'px';
  486. if (linkBounds.bottom + 100 > wrapperBounds.bottom)
  487. userCard.style.marginTop = '-5rem';
  488. userCard.onmouseout = e => {
  489. if (e.target != userCard || userCard.contains(e.relatedTarget))
  490. if (e.relatedTarget) // null if mouse is outside the preview
  491. return;
  492. fadeOut(userCard);
  493. clearTimeout(timer);
  494. timer = 0;
  495. };
  496. fadeInUserCard();
  497. }
  498.  
  499. function fadeInUserCard() {
  500. if (userCard.id != 'user-menu') {
  501. $$('#user-menu', pvDoc).forEach(e => e.id = e.style.display = '' );
  502. userCard.id = 'user-menu';
  503. }
  504. userCard.style.opacity = '0';
  505. userCard.style.display = 'block';
  506. timer = setTimeout(() => timer && (userCard.style.opacity = '1'));
  507. }
  508. }
  509. }
  510.  
  511. function getCacheableUrl(url) {
  512. // strips queries and hashes and anything after the main part https://site/questions/####/title/
  513. return url
  514. .replace(/(\/q(?:uestions)?\/\d+\/[^\/]+).*/, '$1')
  515. .replace(/(\/a(?:nswers)?\/\d+).*/, '$1')
  516. .replace(/[?#].*$/, '');
  517. }
  518.  
  519. function readCache(url) {
  520. keyUrl = getCacheableUrl(url);
  521. const meta = (localStorage[keyUrl] || '').split('\t');
  522. const expired = +meta[0] < Date.now();
  523. const finalUrl = meta[1] || url;
  524. const keyFinalUrl = meta[1] ? getCacheableUrl(finalUrl) : keyUrl;
  525. return !expired && {
  526. finalUrl,
  527. html: LZString.decompressFromUTF16(localStorage[keyFinalUrl + '\thtml']),
  528. };
  529. }
  530.  
  531. function writeCache({url, finalUrl, html, cacheDuration = CACHE_DURATION, cleanupRetry}) {
  532. // keyUrl=expires
  533. // redirected keyUrl=expires+finalUrl, and an additional entry keyFinalUrl=expires is created
  534. // keyFinalUrl\thtml=html
  535. cacheDuration = Math.max(CACHE_DURATION, Math.min(0xDEADBEEF, Math.floor(cacheDuration)));
  536. finalUrl = (finalUrl || url).replace(/[?#].*/, '');
  537. const keyUrl = getCacheableUrl(url);
  538. const keyFinalUrl = getCacheableUrl(finalUrl);
  539. const expires = Date.now() + cacheDuration;
  540. const lz = LZString.compressToUTF16(html);
  541. if (!tryCatch(() => localStorage[keyFinalUrl + '\thtml'] = lz)) {
  542. if (cleanupRetry)
  543. return error('localStorage write error');
  544. cleanupCache({aggressive: true});
  545. setIimeout(writeCache, 0, {url, finalUrl, html, cacheDuration, cleanupRetry: true});
  546. }
  547. localStorage[keyFinalUrl] = expires;
  548. if (keyUrl != keyFinalUrl)
  549. localStorage[keyUrl] = expires + '\t' + finalUrl;
  550. setTimeout(() => {
  551. [keyUrl, keyFinalUrl, keyFinalUrl + '\thtml'].forEach(e => localStorage.removeItem(e));
  552. }, cacheDuration + 1000);
  553. }
  554.  
  555. function cleanupCache({aggressive = false} = {}) {
  556. Object.keys(localStorage).forEach(k => {
  557. if (k.match(/^https?:\/\/[^\t]+$/)) {
  558. let meta = (localStorage[k] || '').split('\t');
  559. if (+meta[0] > Date.now() && !aggressive)
  560. return;
  561. if (meta[1])
  562. localStorage.removeItem(meta[1]);
  563. localStorage.removeItem(`${meta[1] || k}\thtml`);
  564. localStorage.removeItem(k);
  565. }
  566. });
  567. }
  568.  
  569. function onFrameReady(frame) {
  570. if (frame.contentDocument.readyState == 'complete')
  571. return Promise.resolve();
  572. else
  573. return new Promise(resolve => {
  574. $on('load', frame, function onLoad() {
  575. $off('load', frame, onLoad);
  576. resolve();
  577. });
  578. });
  579. }
  580.  
  581. function onStyleSheetsReady(linkElements) {
  582. let retryCount = 0;
  583. return new Promise(function retry(resolve) {
  584. if (linkElements.every(e => e.sheet && e.sheet.href == e.href))
  585. resolve();
  586. else if (retryCount++ > 10)
  587. resolve();
  588. else
  589. setTimeout(retry, 0, resolve);
  590. });
  591. }
  592.  
  593. function getURLregexForMatchedSites() {
  594. const sites = 'https?://(\\w*\\.)*(' + GM_info.script.matches.map(
  595. m => m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.')).join('|') + ')/';
  596. return {
  597. full: new RegExp(sites + '(questions|q|a|posts\/comments)/\\d+'),
  598. siteOnly: new RegExp(sites),
  599. };
  600. }
  601.  
  602. function isLinkPreviewable(link) {
  603. if (!rxPreviewable.test(link.href) || link.matches('.short-link'))
  604. return false;
  605. const inPreview = preview.frame && link.ownerDocument == preview.frame.contentDocument;
  606. const pageUrls = inPreview ? getPageBaseUrls(preview.link.href) : thisPageUrls;
  607. const url = httpsUrl(link.href);
  608. return url.indexOf(pageUrls.base) &&
  609. url.indexOf(pageUrls.short);
  610. }
  611.  
  612. function getPageBaseUrls(url) {
  613. const base = httpsUrl((url.match(rxPreviewable) || [])[0]);
  614. return base ? {
  615. base,
  616. short: base.replace('/questions/', '/q/'),
  617. } : {};
  618. }
  619.  
  620. function httpsUrl(url) {
  621. return (url || '').replace(/^http:/, 'https:');
  622. }
  623.  
  624. function doXHR(options) {
  625. options = Object.assign({method: 'GET'}, options);
  626. const useHttpUrl = () => options.url = options.url.replace(/^https/, 'http');
  627. const hostname = new URL(options.url).hostname;
  628. if (xhrNoSSL.has(hostname))
  629. useHttpUrl();
  630. else if (options.url.startsWith('https')) {
  631. options.onerror = e => {
  632. useHttpUrl();
  633. xhrNoSSL.add(hostname);
  634. xhr = GM_xmlhttpRequest(options);
  635. };
  636. }
  637. if (options.onload)
  638. return (xhr = GM_xmlhttpRequest(options));
  639. else
  640. return new Promise(resolve => {
  641. xhr = GM_xmlhttpRequest(Object.assign(options, {onload: resolve}));
  642. });
  643. }
  644.  
  645. function makeResizable() {
  646. let heightOnClick;
  647. const pvDoc = preview.frame.contentDocument;
  648. const topBorderHeight = (preview.frame.offsetHeight - preview.frame.clientHeight) / 2;
  649. setHeight(GM_getValue('height', innerHeight / 3) |0);
  650.  
  651. // mouseover in the main page is fired only on the border of the iframe
  652. $on('mouseover', preview.frame, onOverAttach);
  653. $on('message', preview.frame.contentWindow, e => {
  654. if (e.data != 'SEpreview-hidden')
  655. return;
  656. if (heightOnClick) {
  657. releaseResizeListeners();
  658. setHeight(heightOnClick);
  659. }
  660. if (preview.frame.style.cursor)
  661. onOutDetach();
  662. });
  663.  
  664. function setCursorStyle(e) {
  665. return (preview.frame.style.cursor = e.offsetY <= 0 ? 's-resize' : '');
  666. }
  667.  
  668. function onOverAttach(e) {
  669. setCursorStyle(e);
  670. $on('mouseout', preview.frame, onOutDetach);
  671. $on('mousemove', preview.frame, setCursorStyle);
  672. $on('mousedown', onDownStartResize);
  673. }
  674.  
  675. function onOutDetach(e) {
  676. if (!e || !e.relatedTarget || !pvDoc.contains(e.relatedTarget)) {
  677. $off('mouseout', preview.frame, onOutDetach);
  678. $off('mousemove', preview.frame, setCursorStyle);
  679. $off('mousedown', onDownStartResize);
  680. preview.frame.style.cursor = '';
  681. }
  682. }
  683.  
  684. function onDownStartResize(e) {
  685. if (!preview.frame.style.cursor)
  686. return;
  687. heightOnClick = preview.frame.clientHeight;
  688.  
  689. $off('mouseover', preview.frame, onOverAttach);
  690. $off('mousemove', preview.frame, setCursorStyle);
  691. $off('mouseout', preview.frame, onOutDetach);
  692.  
  693. document.documentElement.style.cursor = 's-resize';
  694. document.body.style.cssText += ';pointer-events: none!important';
  695. $on('mousemove', onMoveResize);
  696. $on('mouseup', onUpConfirm);
  697. }
  698.  
  699. function onMoveResize(e) {
  700. setHeight(innerHeight - topBorderHeight - e.clientY);
  701. getSelection().removeAllRanges();
  702. preview.frame.contentWindow.getSelection().removeAllRanges();
  703. }
  704.  
  705. function onUpConfirm(e) {
  706. GM_setValue('height', pvDoc.body.clientHeight);
  707. releaseResizeListeners(e);
  708. }
  709.  
  710. function releaseResizeListeners() {
  711. $off('mouseup', releaseResizeListeners);
  712. $off('mousemove', onMoveResize);
  713.  
  714. $on('mouseover', preview.frame, onOverAttach);
  715. onOverAttach({});
  716.  
  717. document.body.style.pointerEvents = '';
  718. document.documentElement.style.cursor = '';
  719. heightOnClick = 0;
  720. }
  721. }
  722.  
  723. function setHeight(height) {
  724. const currentHeight = preview.frame.clientHeight;
  725. const borderHeight = preview.frame.offsetHeight - currentHeight;
  726. const newHeight = Math.max(MIN_HEIGHT, Math.min(innerHeight - borderHeight, height));
  727. if (newHeight != currentHeight)
  728. preview.frame.style.height = newHeight + 'px';
  729. }
  730.  
  731. function $(selector, node = document) {
  732. return node.querySelector(selector);
  733. }
  734.  
  735. function $$(selector, node = document) {
  736. return node.querySelectorAll(selector);
  737. }
  738.  
  739. function $text(selector, node = document) {
  740. const e = typeof selector == 'string' ? node.querySelector(selector) : selector;
  741. return e ? e.textContent.trim() : '';
  742. }
  743.  
  744. function $$remove(selector, node = document) {
  745. node.querySelectorAll(selector).forEach(e => e.remove());
  746. }
  747.  
  748. function $appendChildren(newParent, elements) {
  749. const doc = newParent.ownerDocument;
  750. const fragment = doc.createDocumentFragment();
  751. for (let e of elements)
  752. if (e)
  753. fragment.appendChild(e.ownerDocument == doc ? e : doc.importNode(e, true));
  754. newParent.appendChild(fragment);
  755. }
  756.  
  757. function $removeChildren(el) {
  758. if (el.children.length)
  759. el.innerHTML = ''; // the fastest as per https://jsperf.com/innerhtml-vs-removechild/256
  760. }
  761.  
  762. function $replaceOrCreate(options) {
  763. if (typeof options.map == 'function')
  764. return options.map($replaceOrCreate);
  765. const doc = (options.parent || options.before || options.after).ownerDocument;
  766. const el = doc.getElementById(options.id) || doc.createElement(options.tag || 'div');
  767. for (let key of Object.keys(options)) {
  768. const value = options[key];
  769. switch (key) {
  770. case 'tag':
  771. case 'parent':
  772. case 'before':
  773. case 'after':
  774. break;
  775. case 'dataset':
  776. for (let dataAttr of Object.keys(value))
  777. if (el.dataset[dataAttr] != value[dataAttr])
  778. el.dataset[dataAttr] = value[dataAttr];
  779. break;
  780. case 'children':
  781. $removeChildren(el);
  782. $appendChildren(el, options[key]);
  783. break;
  784. default:
  785. if (key in el && el[key] != value)
  786. el[key] = value;
  787. }
  788. }
  789. if (!el.parentElement)
  790. (options.parent || (options.before || options.after).parentElement)
  791. .insertBefore(el, options.before || (options.after && options.after.nextElementSibling));
  792. return el;
  793. }
  794.  
  795. function $scriptIn(element) {
  796. return element.appendChild(element.ownerDocument.createElement('script'));
  797. }
  798.  
  799. function $on(eventName, ...args) {
  800. // eventName, selector, node, callback, options
  801. // eventName, selector, callback, options
  802. // eventName, node, callback, options
  803. // eventName, callback, options
  804. let i = 0;
  805. const selector = typeof args[i] == 'string' ? args[i++] : null;
  806. const node = args[i].nodeType ? args[i++] : document;
  807. const callback = args[i++];
  808. const options = args[i];
  809.  
  810. const actualNode = selector ? node.querySelector(selector) : node;
  811. const method = this == 'removeEventListener' ? this : 'addEventListener';
  812. actualNode[method](eventName, callback, options);
  813. }
  814.  
  815. function $off() {
  816. $on.apply('removeEventListener', arguments);
  817. }
  818.  
  819. function hasKeyModifiers(e) {
  820. return e.ctrlKey || e.altKey || e.shiftKey || e.metaKey;
  821. }
  822.  
  823. function log(...args) {
  824. console.log(GM_info.script.name, ...args);
  825. }
  826.  
  827. function error(...args) {
  828. console.error(GM_info.script.name, ...args);
  829. }
  830.  
  831. function tryCatch(fn) {
  832. try { return fn() }
  833. catch(e) {}
  834. }
  835.  
  836. function initPolyfills(context = window) {
  837. for (let method of ['forEach', 'filter', 'map', 'every', context.Symbol.iterator])
  838. if (!context.NodeList.prototype[method])
  839. context.NodeList.prototype[method] = context.Array.prototype[method];
  840. }
  841.  
  842. function initStyles() {
  843. GM_addStyle(`
  844. #SEpreview {
  845. all: unset;
  846. box-sizing: content-box;
  847. width: 720px; /* 660px + 30px + 30px */
  848. height: 33%;
  849. min-height: ${MIN_HEIGHT}px;
  850. position: fixed;
  851. opacity: 0;
  852. transition: opacity .25s cubic-bezier(.88,.02,.92,.66);
  853. right: 0;
  854. bottom: 0;
  855. padding: 0;
  856. margin: 0;
  857. background: white;
  858. box-shadow: 0 0 100px rgba(0,0,0,0.5);
  859. z-index: 999999;
  860. border-width: 8px;
  861. border-style: solid;
  862. }
  863. `
  864. + Object.keys(COLORS).map(s => `
  865. #SEpreview[SEpreview-type="${s}"] {
  866. border-color: rgb(${COLORS[s].backRGB});
  867. }
  868. `).join('')
  869. );
  870.  
  871. preview.stylesOverride = `
  872. body, html {
  873. min-width: unset!important;
  874. box-shadow: none!important;
  875. padding: 0!important;
  876. margin: 0!important;
  877. }
  878. html, body {
  879. background: unset!important;;
  880. }
  881. body {
  882. display: flex;
  883. flex-direction: column;
  884. height: 100vh;
  885. }
  886. #SEpreview-body a.SEpreviewable {
  887. text-decoration: underline !important;
  888. }
  889. #SEpreview-title {
  890. all: unset;
  891. display: block;
  892. padding: 20px 30px;
  893. font-weight: bold;
  894. font-size: 18px;
  895. line-height: 1.2;
  896. cursor: pointer;
  897. }
  898. #SEpreview-title:hover {
  899. text-decoration: underline;
  900. }
  901. #SEpreview-meta {
  902. position: absolute;
  903. top: .5ex;
  904. left: 30px;
  905. opacity: 0.5;
  906. }
  907. #SEpreview-title:hover + #SEpreview-meta {
  908. opacity: 1.0;
  909. }
  910.  
  911. #SEpreview-close {
  912. position: absolute;
  913. top: 0;
  914. right: 0;
  915. flex: none;
  916. cursor: pointer;
  917. padding: .5ex 1ex;
  918. }
  919. #SEpreview-close:after {
  920. content: "x"; }
  921. #SEpreview-close:active {
  922. background-color: rgba(0,0,0,.1); }
  923. #SEpreview-close:hover {
  924. background-color: rgba(0,0,0,.05); }
  925.  
  926. #SEpreview-body {
  927. position: relative;
  928. padding: 30px!important;
  929. overflow: auto;
  930. flex-grow: 2;
  931. }
  932. #SEpreview-body > .question-status {
  933. margin: -30px -30px 30px;
  934. padding-left: 30px;
  935. }
  936. #SEpreview-body .question-originals-of-duplicate {
  937. margin: -30px -30px 30px;
  938. padding: 15px 30px;
  939. }
  940. #SEpreview-body > .question-status h2 {
  941. font-weight: normal;
  942. }
  943.  
  944. #SEpreview-answers {
  945. all: unset;
  946. display: block;
  947. padding: 10px 10px 10px 30px;
  948. font-weight: bold;
  949. line-height: 1.0;
  950. border-top: 4px solid rgba(${COLORS.answer.backRGB}, 0.37);
  951. background-color: rgba(${COLORS.answer.backRGB}, 0.37);
  952. color: ${COLORS.answer.fore};
  953. word-break: break-word;
  954. }
  955. #SEpreview-answers:before {
  956. content: "Answers:";
  957. margin-right: 1ex;
  958. font-size: 20px;
  959. line-height: 48px;
  960. }
  961. #SEpreview-answers a {
  962. color: ${COLORS.answer.fore};
  963. text-decoration: none;
  964. font-size: 11px;
  965. font-family: monospace;
  966. width: 32px;
  967. display: inline-block;
  968. vertical-align: top;
  969. margin: 0 1ex 1ex 0;
  970. }
  971. #SEpreview-answers img {
  972. width: 32px;
  973. height: 32px;
  974. }
  975. .SEpreview-accepted {
  976. position: relative;
  977. }
  978. .SEpreview-accepted:after {
  979. content: "✔";
  980. position: absolute;
  981. display: block;
  982. top: 1.3ex;
  983. right: -0.7ex;
  984. font-size: 32px;
  985. color: #4bff2c;
  986. text-shadow: 1px 2px 2px rgba(0,0,0,0.5);
  987. }
  988. #SEpreview-answers a.deleted-answer {
  989. color: ${COLORS.deleted.fore};
  990. background: transparent;
  991. opacity: 0.25;
  992. }
  993. #SEpreview-answers a.deleted-answer:hover {
  994. opacity: 1.0;
  995. }
  996. #SEpreview-answers a:hover:not(.SEpreviewed) {
  997. text-decoration: underline;
  998. }
  999. #SEpreview-answers a.SEpreviewed {
  1000. background-color: ${COLORS.answer.fore};
  1001. color: ${COLORS.answer.foreInv};
  1002. position: relative;
  1003. }
  1004. #SEpreview-answers a.SEpreviewed:after {
  1005. display: block;
  1006. content: " ";
  1007. position: absolute;
  1008. left: -4px;
  1009. top: -4px;
  1010. right: -4px;
  1011. bottom: -4px;
  1012. border: 4px solid ${COLORS.answer.fore};
  1013. }
  1014.  
  1015. .comment-edit,
  1016. .delete-tag,
  1017. .comment-actions td:last-child {
  1018. display: none;
  1019. }
  1020. .comments {
  1021. border-top: none;
  1022. }
  1023. .comments tr:last-child td {
  1024. border-bottom: none;
  1025. }
  1026. .comments .new-comment-highlight {
  1027. -webkit-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1028. -moz-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1029. animation: highlight 9s cubic-bezier(0,.8,.37,.88);
  1030. }
  1031.  
  1032. .post-menu > span {
  1033. opacity: .35;
  1034. }
  1035. #user-menu {
  1036. position: absolute;
  1037. }
  1038. .SEpreview-userCard {
  1039. position: absolute;
  1040. display: none;
  1041. transition: opacity .25s cubic-bezier(.88,.02,.92,.66) .5s;
  1042. margin-top: -3rem;
  1043. }
  1044.  
  1045. .wmd-preview a:not(.post-tag), .post-text a:not(.post-tag), .comment-copy a:not(.post-tag) {
  1046. border-bottom: none;
  1047. }
  1048.  
  1049. @-webkit-keyframes highlight {
  1050. from {background-color: #ffcf78}
  1051. to {background-color: none}
  1052. }
  1053. `
  1054. + Object.keys(COLORS).map(s => `
  1055. body[SEpreview-type="${s}"] #SEpreview-title {
  1056. background-color: rgba(${COLORS[s].backRGB}, 0.37);
  1057. color: ${COLORS[s].fore};
  1058. }
  1059. body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar {
  1060. background-color: rgba(${COLORS[s].backRGB}, 0.1); }
  1061. body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb {
  1062. background-color: rgba(${COLORS[s].backRGB}, 0.2); }
  1063. body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb:hover {
  1064. background-color: rgba(${COLORS[s].backRGB}, 0.3); }
  1065. body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb:active {
  1066. background-color: rgba(${COLORS[s].backRGB}, 0.75); }
  1067. `).join('')
  1068. + ['deleted', 'closed'].map(s => `
  1069. body[SEpreview-type="${s}"] #SEpreview-answers {
  1070. border-top-color: rgba(${COLORS[s].backRGB}, 0.37);
  1071. background-color: rgba(${COLORS[s].backRGB}, 0.37);
  1072. color: ${COLORS[s].fore};
  1073. }
  1074. body[SEpreview-type="${s}"] #SEpreview-answers a.SEpreviewed {
  1075. background-color: ${COLORS[s].fore};
  1076. color: ${COLORS[s].foreInv};
  1077. }
  1078. body[SEpreview-type="${s}"] #SEpreview-answers a.SEpreviewed:after {
  1079. border-color: ${COLORS[s].fore};
  1080. }
  1081. `).join('');
  1082. }

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址