SE Preview on hover

Shows preview of the linked questions/answers on hover

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

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

QingJ © 2025

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