SE Preview on hover

Shows preview of the linked questions/answers on hover

目前为 2018-05-21 提交的版本。查看 最新版本

// ==UserScript==
// @name           SE Preview on hover
// @description    Shows preview of the linked questions/answers on hover
// @version        0.6.3
// @author         wOxxOm
// @namespace      wOxxOm.scripts
// @license        MIT License
// @match          *://*.stackoverflow.com/*
// @match          *://*.superuser.com/*
// @match          *://*.serverfault.com/*
// @match          *://*.askubuntu.com/*
// @match          *://*.stackapps.com/*
// @match          *://*.mathoverflow.net/*
// @match          *://*.stackexchange.com/*
// @include        /https?:\/\/www\.?google(\.com?)?(\.\w\w)?\/(webhp|q|.*?[?#]q=|search).*/
// @match          *://*.bing.com/*
// @match          *://*.yahoo.com/*
// @match          *://*.yahoo.co.jp/*
// @match          *://*.yahoo.cn/*
// @include        /https?:\/\/(\w+\.)*yahoo.(com|\w\w(\.\w\w)?)\/.*/
// @require        https://gf.qytechs.cn/scripts/12228/code/setMutationHandler.js
// @require        https://gf.qytechs.cn/scripts/27531/code/LZString-2xspeedup.js
// @grant          GM_addStyle
// @grant          GM_xmlhttpRequest
// @grant          GM_getValue
// @grant          GM_setValue
// @connect        stackoverflow.com
// @connect        superuser.com
// @connect        serverfault.com
// @connect        askubuntu.com
// @connect        stackapps.com
// @connect        mathoverflow.net
// @connect        stackexchange.com
// @connect        cdn.sstatic.net
// @run-at         document-end
// @noframes
// ==/UserScript==

/* jshint lastsemic:true, multistr:true, laxbreak:true, -W030, -W041, -W084 */

const PREVIEW_DELAY = 200;
const BUSY_CURSOR_DELAY = 1000;
const CACHE_DURATION = 1 * 60 * 1000; // 1 minute for the recently active posts, scales up logarithmically
const MIN_HEIGHT = 400; // px
const COLORS = {
  question: {
    backRGB: '80, 133, 195',
    fore: '#265184',
  },
  answer: {
    backRGB: '112, 195, 80',
    fore: '#3f7722',
    foreInv: 'white',
  },
  deleted: {
    backRGB: '181, 103, 103',
    fore: 'rgb(181, 103, 103)',
    foreInv: 'white',
  },
  closed: {
    backRGB: '255, 206, 93',
    fore: 'rgb(194, 136, 0)',
    foreInv: 'white',
  },
};

let xhr;
const xhrNoSSL = new Set();
const preview = {
  frame: null,
  link: null,
  hover: {x:0, y:0},
  timer: 0,
  timerCursor: 0,
  stylesOverride: '',
};
const lockScroll = {};

const {full: rxPreviewable, siteOnly: rxPreviewableSite} = getURLregexForMatchedSites();
const thisPageUrls = getPageBaseUrls(location.href);

initStyles();
initPolyfills();
setMutationHandler('a, .question-summary .answered, .question-summary .answered-accepted', onLinkAdded, {processExisting: true});
setTimeout(cleanupCache, 10000);

/**************************************************************/

function onLinkAdded(links) {
  for (let i = 0, link; (link = links[i++]); ) {
    if (link.localName != 'a' || isLinkPreviewable(link)) {
      link.removeAttribute('title');
      $on('mouseover', link, onLinkHovered);
    }
  }
}

function onLinkHovered(e) {
  if (hasKeyModifiers(e))
    return;
  preview.link = this;
  $on('mousemove', this, onLinkMouseMove);
  $on('mouseout', this, abortPreview);
  $on('mousedown', this, abortPreview);
  restartPreviewTimer(this);
}

function onLinkMouseMove(e) {
  let stoppedMoving = Math.abs(preview.hover.x - e.clientX) < 2 &&
    Math.abs(preview.hover.y - e.clientY) < 2;
  if (!stoppedMoving)
    return;
  preview.hover.x = e.clientX;
  preview.hover.y = e.clientY;
  restartPreviewTimer(this);
}

function restartPreviewTimer(link) {
  clearTimeout(preview.timer);
  preview.timer = setTimeout(() => {
    preview.timer = 0;
    if (!link.matches(':hover'))
      return releaseLinkListeners(link);
    $off('mousemove', link, onLinkMouseMove);
    if (link.localName != 'a')
      link.href = $('a', link.closest('.question-summary')).href;
    downloadPreview(link);
  }, PREVIEW_DELAY);
}

function abortPreview(e) {
  releaseLinkListeners(this);
  preview.timer = setTimeout(link => {
    if (link == preview.link && preview.frame && !preview.frame.matches(':hover'))
      preview.frame.contentWindow.postMessage('SEpreview-hidden', '*');
  }, PREVIEW_DELAY * 3, this);
  if (xhr)
    xhr.abort();
  if (this.style.cursor == 'wait')
    this.style.cursor = '';
}

function releaseLinkListeners(link = preview.link) {
  $off('mousemove', link, onLinkMouseMove);
  $off('mouseout', link, abortPreview);
  $off('mousedown', link, abortPreview);
  stopTimers();
}

function stopTimers(names) {
  for (let k in preview) {
    if (k.startsWith('timer') && preview[k]) {
      clearTimeout(preview[k]);
      preview[k] = 0;
    }
  }
}

function fadeOut(element, transition) {
  return new Promise(resolve => {
    if (transition) {
      element.style.setProperty(
              'transition',
              typeof transition == 'number' ? `opacity ${transition}s ease-in-out` : transition,
              'important');
      setTimeout(doFadeOut);
    } else
      doFadeOut();

    function doFadeOut() {
      element.style.setProperty('opacity', '0', 'important');
      $on('transitionend', element, done);
      $on('visibilitychange', done);
      function done(e) {
        $off('transitionend', element, done);
        $off('visibilitychange', done);
        if (element.style.opacity == '0')
          element.style.setProperty('display', 'none', 'important');
        resolve();
      }
    }
  });
}

function fadeIn(element) {
  element.style.setProperty('opacity', '0', 'important');
  element.style.setProperty('display', 'block', 'important');
  setTimeout(() => element.style.setProperty('opacity', '1', 'important'));
}

function downloadPreview(link) {
  const showAnswers = link.localName != 'a';
  const cached = readCache(link.href);
  if (cached)
    return showPreview(Object.assign(cached, {showAnswers}));

  preview.timerCursor = setTimeout(() => {
    preview.timerCursor = 0;
    link.style.setProperty('cursor', 'wait', 'important');
  }, BUSY_CURSOR_DELAY);

  doXHR(link.href).then(r => {
    const html = r.responseText;
    const finalUrl = r.finalUrl;
    if (link.matches(':hover') || preview.frame && preview.frame.matches(':hover'))
      return {
        html,
        finalUrl,
        lastActivity: showPreview({finalUrl, html, showAnswers}),
      };
  }).then(({html, finalUrl, lastActivity} = {}) => {
    if (preview.timerCursor)
      clearTimeout(preview.timerCursor), preview.timerCursor = 0;
    if (link.style.cursor == 'wait')
      link.style.cursor = '';
    if (lastActivity) {
      const inactiveDays = Math.max(0, (Date.now() - lastActivity) / (24 * 3600 * 1000));
      const cacheDuration = CACHE_DURATION * Math.pow(Math.log(inactiveDays + 1) + 1, 2);
      setTimeout(writeCache, 1000, {url: link.href, finalUrl, html, cacheDuration});
    }
  });
}

function initPreview() {
  preview.frame = document.createElement('iframe');
  preview.frame.id = 'SEpreview';
  document.body.appendChild(preview.frame);
  makeResizable();

  lockScroll.attach = e => {
    if (lockScroll.pos)
      return;
    lockScroll.pos = {x: scrollX, y: scrollY};
    $on('scroll', document, lockScroll.run);
    $on('mouseover', document, lockScroll.detach);
  };
  lockScroll.run = e => scrollTo(lockScroll.pos.x, lockScroll.pos.y);
  lockScroll.detach = e => {
    if (!lockScroll.pos)
      return;
    lockScroll.pos = null;
    $off('mouseover', document, lockScroll.detach);
    $off('scroll', document, lockScroll.run);
  };

  const killer = mutations => mutations.forEach(m => [...m.addedNodes].forEach(n => n.remove()));
  const killerMO = {
    head: new MutationObserver(killer),
    documentElement: new MutationObserver(killer),
  };
  preview.killInvaders = {
    start: () => Object.keys(killerMO).forEach(k => killerMO[k].observe(preview.frame.contentDocument[k], {childList: true})),
    stop: () => Object.keys(killerMO).forEach(k => killerMO[k].disconnect()),
  };
}

function showPreview({finalUrl, html, doc, showAnswers}) {
  doc = doc || new DOMParser().parseFromString(html, 'text/html');
  if (!doc || !doc.head)
    return error('no HEAD in the document received for', finalUrl);

  if (!$('base', doc))
    doc.head.insertAdjacentHTML('afterbegin', `<base href="${finalUrl}">`);

  const answerIdMatch = !showAnswers
    ? finalUrl.match(/questions\/\d+\/[^\/]+\/(\d+)/)
    : [0, ($('[id^="answer-"]', doc) || {id:''}).id.replace(/^answer-/, '')];
  const isQuestion = !answerIdMatch;
  const postNumber = isQuestion ? finalUrl.match(/\d+/)[0] : answerIdMatch[1];
  const postId = answerIdMatch ? '#answer-' + answerIdMatch[1] : '#question';
  const post = $(postId + ' .post-text', doc);
  if (!post)
    return error('No parsable post found', doc);
  const isDeleted = !!post.closest('.deleted-answer');
  const title = $('meta[property="og:title"]', doc).content;
  const status = isQuestion && !$('.question-status', post) ? $('.question-status', doc) : null;
  const isClosed = $('.question-originals-of-duplicate, .close-as-off-topic-status-list, .close-status-suffix', doc);
  const comments = $(`${postId} .comments`, doc);
  const commentsHidden = +$('[data-remaining-comments-count]', comments).dataset.remainingCommentsCount;
  const commentsShowLink = commentsHidden && $(`${postId} .js-show-link.comments-link`, doc);
  const finalUrlOfQuestion = getCacheableUrl(finalUrl);
  const lastActivity = tryCatch(() => new Date($('.lastactivity-link', doc).title).getTime()) || Date.now();
  const answers = $$('.answer', doc);
  const hasAnswers = answers.length > (isQuestion ? 0 : 1);

  markPreviewableLinks(doc);
  $$remove('script', doc);

  if (!preview.frame)
    initPreview();

  let pvDoc, pvWin;
  preview.frame.style.setProperty('display', '', 'important');
  preview.frame.setAttribute('SEpreview-type',
    isDeleted ? 'deleted' : isQuestion ? (isClosed ? 'closed' : 'question') : 'answer');
  preview.frame.classList.toggle('SEpreview-hasAnswers', hasAnswers);

  return onFrameReady(preview.frame).then(
    () => {
      pvDoc = preview.frame.contentDocument;
      pvWin = preview.frame.contentWindow;
      initPolyfills(pvWin);
      preview.killInvaders.stop();
    })
    .then(addStyles)
    .then(render)
    .then(show)
    .then(() => lastActivity);

  function markPreviewableLinks(container) {
    for (let link of $$('a:not(.SEpreviewable)', container)) {
      if (rxPreviewable.test(link.href)) {
        link.removeAttribute('title');
        link.classList.add('SEpreviewable');
      }
    }
  }

  function markHoverableUsers(container) {
    for (let link of $$('a[href*="/users/"]', container)) {
      if (rxPreviewableSite.test(link.href) && link.pathname.match(/^\/users\/\d+/)) {
        link.onmouseover = loadUserCard;
        link.classList.add('SEpreview-userLink');
      }
    }
  }

  function addStyles() {
    const SEpreviewStyles = $replaceOrCreate({
        id: 'SEpreviewStyles',
      tag: 'style', parent: pvDoc.head, className: 'SEpreview-reuse',
      innerHTML: preview.stylesOverride,
    });
    $replaceOrCreate($$('style', doc).map(e => ({
      id: 'SEpreview' + e.innerHTML.replace(/\W+/g, '').length,
      tag: 'style', before: SEpreviewStyles, className: 'SEpreview-reuse',
      innerHTML: e.innerHTML,
    })));
    return onStyleSheetsReady({
      doc: pvDoc,
      urls: $$('link[rel="stylesheet"]', doc).map(e => e.href),
      onBeforeRequest: preview.frame.style.opacity != '1' ? null : () => {
        preview.frame.style.setProperty('transition', 'border-color .5s ease-in-out', 'important');
        $on('transitionend', preview.frame, () => preview.frame.style.transition = '', {once: true});
      },
    }).then(els => {
      els.forEach(e => e && (e.className = 'SEpreview-reuse'));
    });
  }

  function render() {
    pvDoc.body.setAttribute('SEpreview-type', preview.frame.getAttribute('SEpreview-type'));

    $replaceOrCreate([{
    // base
      id: 'SEpreview-base', tag: 'base',
      parent: pvDoc.head,
            href: $('base', doc).href,
    }, {
    // title
      id: 'SEpreview-title', tag: 'a',
      parent: pvDoc.body, className: 'SEpreviewable',
      href: finalUrlOfQuestion,
      textContent: title,
    }, {
    // close button
      id: 'SEpreview-close',
      parent: pvDoc.body,
      title: 'Or press Esc key while the preview is focused (also when just shown)',
    }, {
    // vote count, date, views#
      id: 'SEpreview-meta',
      parent: pvDoc.body,
      innerHTML: [
        $text('.vote-count-post', post.closest('.post-layout')).replace(/(-?)(\d+)/,
          (s, sign, v) => s == '0' ? '' : `<b>${s}</b> vote${+v > 1 ? 's' : ''}, `),
        isQuestion
          ? $$('#qinfo tr', doc)
            .map(row => $$('.label-key', row).map($text).join(' '))
            .join(', ').replace(/^((.+?) (.+?), .+?), .+? \3$/, '$1')
          : [...$$('.user-action-time', post.closest('.answer'))]
            .reverse().map($text).join(', ')
      ].join('')
    }, {
    // content wrapper
      id: 'SEpreview-body',
      parent: pvDoc.body,
      className: isDeleted ? 'deleted-answer' : '',
      children: [status, post.parentElement, comments, commentsShowLink],
    }]);

    // delinkify/remove non-functional items in post-menu
    $$remove('.short-link, .flag-post-link', pvDoc);
    $$('.post-menu a:not(.edit-post)', pvDoc).forEach(a => {
      if (a.children.length)
        a.outerHTML = `<span>${a.innerHTML}</span>`;
      else
        a.remove();
    });

    // add a timeline link
    $('.post-menu', pvDoc).insertAdjacentHTML('beforeend',
      '<span class="lsep">|</span>' +
      `<a href="/posts/${postNumber}/timeline">timeline</a>`);

    // prettify code blocks
    const codeBlocks = $$('pre code', pvDoc);
    if (codeBlocks.length) {
      codeBlocks.forEach(e => e.parentElement.classList.add('prettyprint'));
      if (!pvWin.StackExchange) {
        pvWin.StackExchange = {};
        let script = $scriptIn(pvDoc.head);
        script.text = 'StackExchange = {}';
        script = $scriptIn(pvDoc.head);
        script.src = 'https://cdn.sstatic.net/Js/prettify-full.en.js';
        script.setAttribute('onload', 'prettyPrint()');
      } else
        $scriptIn(pvDoc.body).text = 'prettyPrint()';
    }

    // render bottom shelf
    if (hasAnswers) {
      $replaceOrCreate({
        id: 'SEpreview-answers',
        parent: pvDoc.body,
        innerHTML: answers.map(renderShelfAnswer).join(' '),
      });
    } else
      $$remove('#SEpreview-answers', pvDoc);

    // cleanup leftovers from previously displayed post and foreign elements not injected by us
    $$('style, link, body script, html > *:not(head):not(body), .post-menu .lsep + .lsep', pvDoc).forEach(e => {
      if (e.classList.contains('SEpreview-reuse'))
        e.classList.remove('SEpreview-reuse');
      else
        e.remove();
    });
  }

  function renderShelfAnswer(e) {
    const shortUrl = $('.short-link', e).href.replace(/(\d+)\/\d+/, '$1');
    const extraClasses = (e.matches(postId) ? ' SEpreviewed' : '') +
          (e.matches('.deleted-answer') ? ' deleted-answer' : '') +
        ($('.vote-accepted-on', e) ? ' SEpreview-accepted' : '');
    const author = $('.post-signature:last-child', e);
    const title = $text('.user-details a', author) + ' (rep ' +
        $text('.reputation-score', author) + ')\n' +
        $text('.user-action-time', author);
    const gravatar = $('img, .anonymous-gravatar, .community-wiki', author);
    return (
      `<a href="${shortUrl}" title="${title}" class="SEpreviewable${extraClasses}">` +
        $text('.vote-count-post', e).replace(/^0$/, '&nbsp;') + ' ' +
        (!gravatar ? '' : gravatar.src ? `<img src="${gravatar.src}">` : gravatar.outerHTML) +
      '</a>');
  }

  function show() {
    pvDoc.onmouseover = lockScroll.attach;
    pvDoc.onclick = onClick;
    pvDoc.onkeydown = e => { if (!hasKeyModifiers(e) && e.keyCode == 27) hide() };
    pvWin.onmessage = e => { if (e.data == 'SEpreview-hidden') hide({fade: true}) };

    markHoverableUsers(pvDoc);
    preview.killInvaders.start();

    $('#SEpreview-body', pvDoc).scrollTop = 0;
    preview.frame.style.setProperty('opacity', '1', 'important');
    preview.frame.focus();
  }

  function hide({fade = false} = {}) {
    releaseLinkListeners();
    releasePreviewListeners();
    const cleanup = () => preview.frame.style.opacity == '0' && $removeChildren(pvDoc.body);
    if (fade)
      fadeOut(preview.frame).then(cleanup);
    else {
      preview.frame.style.setProperty('opacity', '0', 'important');
      preview.frame.style.setProperty('display', 'none', 'important');
      cleanup();
    }
  }

  function releasePreviewListeners(e) {
    pvWin.onmessage = null;
    pvDoc.onmouseover = null;
    pvDoc.onclick = null;
    pvDoc.onkeydown = null;
  }

  function onClick(e) {
    if (e.target.id == 'SEpreview-close')
      return hide();

    const link = e.target.closest('a');
    if (!link)
      return;

    if (link.matches('.js-show-link.comments-link')) {
      fadeOut(link, 0.5);
      loadComments();
      return e.preventDefault();
    }

    if (e.button || hasKeyModifiers(e) || !link.matches('.SEpreviewable'))
      return (link.target = '_blank');

    e.preventDefault();

    if (link.id == 'SEpreview-title')
      showPreview({doc, finalUrl: finalUrlOfQuestion});
    else if (link.matches('#SEpreview-answers a'))
      showPreview({doc, finalUrl: finalUrlOfQuestion + '/' + link.pathname.match(/\/(\d+)/)[1]});
    else
      downloadPreview(link);
  }

  function loadComments() {
    const url = new URL(finalUrl).origin + '/posts/' + comments.id.match(/\d+/)[0] + '/comments';
    doXHR(url).then(r => {
      const list = $(`#${comments.id} .comments-list`, pvDoc);
      const oldIds = new Set([...list.children].map(e => e.id));
      list.innerHTML = r.responseText;
      list.closest('.comments').style.setProperty('display', 'block', 'important');
      for (const cmt of list.children)
        if (!oldIds.has(cmt.id))
          cmt.classList.add('new-comment-highlight');
      markPreviewableLinks(list);
      markHoverableUsers(list);
    });
  }

  function loadUserCard(e, ready) {
    if (ready !== true)
      return setTimeout(loadUserCard, PREVIEW_DELAY * 2, e, true);
    const link = e.target.closest('a');
    if (!link.matches(':hover'))
      return;
    let timer;
    let userCard = link.nextElementSibling;
    if (userCard && userCard.matches('.SEpreview-userCard'))
      return fadeInUserCard();
    const url = link.origin + '/users/user-info/' + link.pathname.match(/\d+/)[0];

    Promise.resolve(
      readCache(url) ||
      doXHR(url).then(r => {
        writeCache({url, html: r.responseText, cacheDuration: CACHE_DURATION * 100});
        return {html: r.responseText};
      })
    ).then(renderUserCard);

    function renderUserCard({html}) {
      const linkBounds = link.getBoundingClientRect();
      const wrapperBounds = $('#SEpreview-body', pvDoc).getBoundingClientRect();
      userCard = $replaceOrCreate({id: 'user-menu-tmp', className: 'SEpreview-userCard', innerHTML: html, after: link});
      userCard.style.setProperty('left', Math.min(linkBounds.left - 20, pvWin.innerWidth - 350) + 'px', 'important');
      if (linkBounds.bottom + 100 > wrapperBounds.bottom)
        userCard.style.setProperty('margin-top', '-5rem', 'important');
      userCard.onmouseout = e => {
        if (e.target != userCard || userCard.contains(e.relatedTarget))
          if (e.relatedTarget) // null if mouse is outside the preview
            return;
        fadeOut(userCard);
        clearTimeout(timer);
        timer = 0;
      };
      fadeInUserCard();
    }

    function fadeInUserCard() {
      if (userCard.id != 'user-menu') {
        $$('#user-menu', pvDoc).forEach(e => e.id = e.style.display = '' );
        userCard.id = 'user-menu';
      }
      userCard.style.setProperty('opacity', '0', 'important');
      userCard.style.setProperty('display', 'block', 'important');
      timer = setTimeout(() => timer && userCard.style.setProperty('opacity', '1', 'important'));
    }
  }
}

function getCacheableUrl(url) {
  // strips queries and hashes and anything after the main part https://site/questions/####/title/
  return url
    .replace(/(\/q(?:uestions)?\/\d+\/[^\/]+).*/, '$1')
    .replace(/(\/a(?:nswers)?\/\d+).*/, '$1')
    .replace(/[?#].*$/, '');
}

function readCache(url) {
  const keyUrl = getCacheableUrl(url);
  const meta = (localStorage[keyUrl] || '').split('\t');
  const expired = +meta[0] < Date.now();
  const finalUrl = meta[1] || url;
  const keyFinalUrl = meta[1] ? getCacheableUrl(finalUrl) : keyUrl;
  return !expired && {
    finalUrl,
    html: LZString.decompressFromUTF16(localStorage[keyFinalUrl + '\thtml']),
  };
}

function writeCache({url, finalUrl, html, cacheDuration = CACHE_DURATION, cleanupRetry}) {
  // keyUrl=expires
  // redirected keyUrl=expires+finalUrl, and an additional entry keyFinalUrl=expires is created
  // keyFinalUrl\thtml=html
  cacheDuration = Math.max(CACHE_DURATION, Math.min(0xDEADBEEF, Math.floor(cacheDuration)));
  finalUrl = (finalUrl || url).replace(/[?#].*/, '');
  const keyUrl = getCacheableUrl(url);
  const keyFinalUrl = getCacheableUrl(finalUrl);
  const expires = Date.now() + cacheDuration;
  const lz = LZString.compressToUTF16(html);
  if (!tryCatch(() => localStorage[keyFinalUrl + '\thtml'] = lz)) {
    if (cleanupRetry)
      return error('localStorage write error');
    cleanupCache({aggressive: true});
    setIimeout(writeCache, 0, {url, finalUrl, html, cacheDuration, cleanupRetry: true});
  }
  localStorage[keyFinalUrl] = expires;
  if (keyUrl != keyFinalUrl)
    localStorage[keyUrl] = expires + '\t' + finalUrl;
  setTimeout(() => {
    [keyUrl, keyFinalUrl, keyFinalUrl + '\thtml'].forEach(e => localStorage.removeItem(e));
  }, cacheDuration + 1000);
}

function cleanupCache({aggressive = false} = {}) {
  Object.keys(localStorage).forEach(k => {
    if (k.match(/^https?:\/\/[^\t]+$/)) {
      let meta = (localStorage[k] || '').split('\t');
      if (+meta[0] > Date.now() && !aggressive)
        return;
      if (meta[1])
        localStorage.removeItem(meta[1]);
      localStorage.removeItem(`${meta[1] || k}\thtml`);
      localStorage.removeItem(k);
    }
  });
}

function onFrameReady(frame) {
  if (frame.contentDocument.readyState == 'complete')
    return Promise.resolve();
  else
    return new Promise(resolve => {
      $on('load', frame, function onLoad() {
        $off('load', frame, onLoad);
        resolve();
      });
    });
}

function onStyleSheetsReady({urls, doc = document, onBeforeRequest = null}) {
  return Promise.all(
    urls.map(url => $(`link[href="${url}"]`, doc) || new Promise(resolve => {
      if (typeof onBeforeRequest == 'function')
        onBeforeRequest(url);
      doXHR(url).then(() => {
        const sheetElement = $replaceOrCreate({tag: 'link', href: url, rel: 'stylesheet', parent: doc.head});
        const timeout = setTimeout(doResolve, 100);
        sheetElement.onload = doResolve;
        function doResolve() {
          sheetElement.onload = null;
          clearTimeout(timeout);
          resolve(sheetElement);
        }
      }).catch(() => resolve());
    }))
  ).then(elements => {
    let current = $('link', doc.head);
    if (!current || !elements[0]) return [];
    const ordered = urls.map(url => $(`link[href="${url}"]`, doc));
    for (const el of elements) {
      if (current !== el) {
        doc.head.insertBefore(el, current);
      } else {
        while ((current = current.nextElementSibling))
          if (current.localName === 'link')
            break;
      }
    }
    return elements;
  });
}

function getURLregexForMatchedSites() {
  const sites = 'https?://(\\w*\\.)*(' + GM_info.script.matches.map(
    m => m.match(/^.*?\/\/\W*(\w.*?)\//)[1].replace(/\./g, '\\.')).join('|') + ')/';
  return {
    full: new RegExp(sites + '(questions|q|a|posts\/comments)/\\d+'),
    siteOnly: new RegExp(sites),
  };
}

function isLinkPreviewable(link) {
  if (!rxPreviewable.test(link.href) || link.matches('.short-link'))
    return false;
  const inPreview = preview.frame && link.ownerDocument == preview.frame.contentDocument;
  const pageUrls = inPreview ? getPageBaseUrls(preview.link.href) : thisPageUrls;
  const url = httpsUrl(link.href);
  return url.indexOf(pageUrls.base) &&
       url.indexOf(pageUrls.short);
}

function getPageBaseUrls(url) {
  const base = httpsUrl((url.match(rxPreviewable) || [])[0]);
  return base ? {
    base,
    short: base.replace('/questions/', '/q/'),
  } : {};
}

function httpsUrl(url) {
  return (url || '').replace(/^http:/, 'https:');
}

function doXHR(options) {
  options = typeof options == 'string' ? {url: options} : options;
  options = Object.assign({method: 'GET'}, options);
  const useHttpUrl = () => options.url = options.url.replace(/^https/, 'http');
  const hostname = new URL(options.url).hostname;
  if (xhrNoSSL.has(hostname))
    useHttpUrl();
  else {
    options.url = options.url.replace(/^http:/, 'https:');
    options.onerror = e => {
      useHttpUrl();
      xhrNoSSL.add(hostname);
      xhr = GM_xmlhttpRequest(options);
    };
  }
  if (options.onload)
    return (xhr = GM_xmlhttpRequest(options));
  else
    return new Promise(resolve => {
      xhr = GM_xmlhttpRequest(Object.assign(options, {onload: resolve}));
    });
}

function makeResizable() {
  let heightOnClick;
  const pvDoc = preview.frame.contentDocument;
  const topBorderHeight = (preview.frame.offsetHeight - preview.frame.clientHeight) / 2;
  setHeight(GM_getValue('height', innerHeight / 3) |0);

  // mouseover in the main page is fired only on the border of the iframe
  $on('mouseover', preview.frame, onOverAttach);
  $on('message', preview.frame.contentWindow, e => {
    if (e.data != 'SEpreview-hidden')
      return;
    if (heightOnClick) {
      releaseResizeListeners();
      setHeight(heightOnClick);
    }
    if (preview.frame.style.cursor)
      onOutDetach();
  });

  function setCursorStyle(e) {
    return (preview.frame.style.setProperty('cursor', e.offsetY <= 0 ? 's-resize' : '', 'important'));
  }

  function onOverAttach(e) {
    setCursorStyle(e);
    $on('mouseout', preview.frame, onOutDetach);
    $on('mousemove', preview.frame, setCursorStyle);
    $on('mousedown', onDownStartResize);
  }

  function onOutDetach(e) {
    if (!e || !e.relatedTarget || !pvDoc.contains(e.relatedTarget)) {
      $off('mouseout', preview.frame, onOutDetach);
      $off('mousemove', preview.frame, setCursorStyle);
      $off('mousedown', onDownStartResize);
      preview.frame.style.cursor = '';
    }
  }

  function onDownStartResize(e) {
    if (!preview.frame.style.cursor)
      return;
    heightOnClick = preview.frame.clientHeight;

    $off('mouseover', preview.frame, onOverAttach);
    $off('mousemove', preview.frame, setCursorStyle);
    $off('mouseout', preview.frame, onOutDetach);

    document.documentElement.style.setProperty('cursor', 's-resize', 'important');
    document.body.style.setProperty('pointer-events', 'none', 'important');
    $on('mousemove', onMoveResize);
    $on('mouseup',  onUpConfirm);
  }

  function onMoveResize(e) {
    setHeight(innerHeight - topBorderHeight - e.clientY);
    getSelection().removeAllRanges();
    preview.frame.contentWindow.getSelection().removeAllRanges();
  }

  function onUpConfirm(e) {
    GM_setValue('height', pvDoc.body.clientHeight);
    releaseResizeListeners(e);
  }

  function releaseResizeListeners() {
    $off('mouseup', releaseResizeListeners);
    $off('mousemove', onMoveResize);

    $on('mouseover', preview.frame, onOverAttach);
    onOverAttach({});

    document.body.style.pointerEvents = '';
    document.documentElement.style.cursor = '';
    heightOnClick = 0;
  }
}

function setHeight(height) {
  const currentHeight = preview.frame.clientHeight;
  const borderHeight = preview.frame.offsetHeight - currentHeight;
  const newHeight = Math.max(MIN_HEIGHT, Math.min(innerHeight - borderHeight, height));
  if (newHeight != currentHeight)
    preview.frame.style.setProperty('height', newHeight + 'px', 'important');
}

function $(selector, node = document) {
  return node.querySelector(selector);
}

function $$(selector, node = document) {
  return node.querySelectorAll(selector);
}

function $text(selector, node = document) {
  const e = typeof selector == 'string' ? node.querySelector(selector) : selector;
  return e ? e.textContent.trim() : '';
}

function $$remove(selector, node = document) {
  node.querySelectorAll(selector).forEach(e => e.remove());
}

function $appendChildren(newParent, elements) {
  const doc = newParent.ownerDocument;
  const fragment = doc.createDocumentFragment();
  for (let e of elements)
    if (e)
       fragment.appendChild(e.ownerDocument == doc ? e : doc.importNode(e, true));
  newParent.appendChild(fragment);
}

function $removeChildren(el) {
  if (el.children.length)
    el.innerHTML = ''; // the fastest as per https://jsperf.com/innerhtml-vs-removechild/256
}

function $replaceOrCreate(options) {
  if (typeof options.map == 'function')
    return options.map($replaceOrCreate);
    const doc = (options.parent || options.before || options.after).ownerDocument;
  const el = doc.getElementById(options.id) || doc.createElement(options.tag || 'div');
  for (let key of Object.keys(options)) {
    const value = options[key];
    switch (key) {
      case 'tag':
      case 'parent':
      case 'before':
      case 'after':
        break;
      case 'dataset':
        for (let dataAttr of Object.keys(value))
          if (el.dataset[dataAttr] != value[dataAttr])
            el.dataset[dataAttr] = value[dataAttr];
        break;
      case 'children':
        $removeChildren(el);
        $appendChildren(el, options[key]);
        break;
      default:
        if (key in el && el[key] != value)
          el[key] = value;
    }
  }
  if (!el.parentElement)
      (options.parent || (options.before || options.after).parentElement)
      .insertBefore(el, options.before || (options.after && options.after.nextElementSibling));
  return el;
}

function $scriptIn(element) {
  return element.appendChild(element.ownerDocument.createElement('script'));
}

function $on(eventName, ...args) {
// eventName, selector, node, callback, options
// eventName, selector, callback, options
// eventName, node, callback, options
// eventName, callback, options
  let i = 0;
  const selector = typeof args[i] == 'string' ? args[i++] : null;
  const node = args[i].nodeType ? args[i++] : document;
  const callback = args[i++];
  const options = args[i];

  const actualNode = selector ? node.querySelector(selector) : node;
  const method = this == 'removeEventListener' ? this : 'addEventListener';
  actualNode[method](eventName, callback, options);
}

function $off() {
  $on.apply('removeEventListener', arguments);
}

function hasKeyModifiers(e) {
  return e.ctrlKey || e.altKey || e.shiftKey || e.metaKey;
}

function log(...args) {
  console.log(GM_info.script.name, ...args);
}

function error(...args) {
  console.error(GM_info.script.name, ...args);
  console.trace();
}

function tryCatch(fn) {
  try { return fn() }
  catch(e) {}
}

function initPolyfills(context = window) {
  for (let method of ['forEach', 'filter', 'map', 'every', 'some', context.Symbol.iterator])
    if (!context.NodeList.prototype[method])
      context.NodeList.prototype[method] = context.Array.prototype[method];
}

function initStyles() {
  GM_addStyle(`
    #SEpreview {
      all: unset;
      box-sizing: content-box;
      width: 720px; /* 660px + 30px + 30px */
      height: 33%;
      min-height: ${MIN_HEIGHT}px;
      position: fixed;
      transition: opacity .25s cubic-bezier(.88,.02,.92,.66);
      right: 0;
      bottom: 0;
      padding: 0;
      margin: 0;
      background: white;
      opacity: 0;
      box-shadow: 0 0 100px rgba(0,0,0,0.5);
      z-index: 999999;
      border-width: 8px;
      border-style: solid;
      border-color: transparent;
    }
    #SEpreview:not([style*="opacity: 1"]) {
      pointer-events: none;
    }
    #SEpreview[SEpreview-type="question"].SEpreview-hasAnswers {
      border-image: linear-gradient(rgb(${COLORS.question.backRGB}) 66%, rgb(${COLORS.answer.backRGB})) 1 1;
    }
  `
  + Object.keys(COLORS).map(s => `
    #SEpreview[SEpreview-type="${s}"] {
      border-color: rgb(${COLORS[s].backRGB});
    }
  `).join('')
  );

  preview.stylesOverride = `
    html, body {
      min-width: unset!important;
      box-shadow: none!important;
      padding: 0!important;
      margin: 0!important;
      background: unset!important;;
    }
    body {
      display: flex;
      flex-direction: column;
      height: 100vh;
    }
    #SEpreview-body a.SEpreviewable {
      text-decoration: underline !important;
      text-decoration-skip: ink;
    }
    #SEpreview-title {
      all: unset;
      display: block;
      padding: 20px 30px;
      font-weight: bold;
      font-size: 18px;
      line-height: 1.2;
      cursor: pointer;
    }
    #SEpreview-title:hover {
      text-decoration: underline;
      text-decoration-skip: ink;
    }
    #SEpreview-meta {
      position: absolute;
      top: .5ex;
      left: 30px;
      opacity: 0.5;
    }
    #SEpreview-title:hover + #SEpreview-meta {
      opacity: 1.0;
    }

    #SEpreview-close {
      position: absolute;
      top: 0;
      right: 0;
      flex: none;
      cursor: pointer;
      padding: .5ex 1ex;
    }
    #SEpreview-close:after {
      content: "x"; }
    #SEpreview-close:active {
      background-color: rgba(0,0,0,.1); }
    #SEpreview-close:hover {
      background-color: rgba(0,0,0,.05); }

    #SEpreview-body {
      position: relative;
      padding: 30px!important;
      overflow: auto;
      flex-grow: 2;
    }
    #SEpreview-body > .question-status {
      margin: -30px -30px 30px;
      padding-left: 30px;
    }
    #SEpreview-body .question-originals-of-duplicate {
      margin: -30px -30px 30px;
      padding: 15px 30px;
    }
    #SEpreview-body > .question-status h2 {
      font-weight: normal;
    }

    #SEpreview-answers {
      all: unset;
      display: block;
      padding: 10px 10px 10px 30px;
      font-weight: bold;
      line-height: 1.0;
      border-top: 4px solid rgba(${COLORS.answer.backRGB}, 0.37);
      background-color: rgba(${COLORS.answer.backRGB}, 0.37);
      color: ${COLORS.answer.fore};
      word-break: break-word;
    }
    #SEpreview-answers:before {
      content: "Answers:";
      margin-right: 1ex;
      font-size: 20px;
      line-height: 48px;
    }
    #SEpreview-answers a {
      color: ${COLORS.answer.fore};
      text-decoration: none;
      font-size: 11px;
      font-family: monospace;
      width: 32px;
      display: inline-block;
      vertical-align: top;
      margin: 0 1ex 1ex  0;
    }
    #SEpreview-answers img {
      width: 32px;
      height: 32px;
    }
    .SEpreview-accepted {
      position: relative;
    }
    .SEpreview-accepted:after {
      content: "✔";
      position: absolute;
      display: block;
      top: 1.3ex;
      right: -0.7ex;
      font-size: 32px;
      color: #4bff2c;
      text-shadow: 1px 2px 2px rgba(0,0,0,0.5);
    }
    #SEpreview-answers a.deleted-answer {
      color: ${COLORS.deleted.fore};
      background: transparent;
      opacity: 0.25;
    }
    #SEpreview-answers a.deleted-answer:hover {
      opacity: 1.0;
    }
    #SEpreview-answers a:hover:not(.SEpreviewed) {
      text-decoration: underline;
      text-decoration-skip: ink;
    }
    #SEpreview-answers a.SEpreviewed {
      background-color: ${COLORS.answer.fore};
      color: ${COLORS.answer.foreInv};
      position: relative;
    }
    #SEpreview-answers a.SEpreviewed:before {
      display: block;
      content: " ";
      position: absolute;
      left: -4px;
      top: -4px;
      right: -4px;
      bottom: -4px;
      border: 4px solid ${COLORS.answer.fore};
    }

    #SEpreview-body .comment-edit,
    #SEpreview-body .delete-tag,
    #SEpreview-body .comment-actions td:last-child {
      display: none;
    }
    #SEpreview-body .comments {
      border-top: none;
    }
    #SEpreview-body .comments tr:last-child td {
      border-bottom: none;
    }
    #SEpreview-body .comments .new-comment-highlight .comment-text {
      -webkit-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
      -moz-animation: highlight 9s cubic-bezier(0,.8,.37,.88);
      animation: highlight 9s cubic-bezier(0,.8,.37,.88);
    }

    #SEpreview-body .post-menu > span {
      opacity: .35;
    }
    #SEpreview-body #user-menu {
      position: absolute;
    }
    .SEpreview-userCard {
      position: absolute;
      display: none;
      transition: opacity .25s cubic-bezier(.88,.02,.92,.66) .5s;
      margin-top: -3rem;
    }

    #SEpreview-body .wmd-preview a:not(.post-tag),
    #SEpreview-body .post-text a:not(.post-tag),
    #SEpreview-body .comment-copy a:not(.post-tag) {
      border-bottom: none;
    }

    @-webkit-keyframes highlight {
      from {background-color: #ffcf78}
      to   {background-color: none}
    }
  `
  + Object.keys(COLORS).map(s => `
    body[SEpreview-type="${s}"] #SEpreview-title {
      background-color: rgba(${COLORS[s].backRGB}, 0.37);
      color: ${COLORS[s].fore};
    }
    body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar {
      background-color: rgba(${COLORS[s].backRGB}, 0.1); }
    body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb {
      background-color: rgba(${COLORS[s].backRGB}, 0.2); }
    body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb:hover {
      background-color: rgba(${COLORS[s].backRGB}, 0.3); }
    body[SEpreview-type="${s}"] #SEpreview-body::-webkit-scrollbar-thumb:active {
      background-color: rgba(${COLORS[s].backRGB}, 0.75); }
  `).join('')
  + ['deleted', 'closed'].map(s => `
    body[SEpreview-type="${s}"] #SEpreview-answers {
      border-top-color: rgba(${COLORS[s].backRGB}, 0.37);
      background-color: rgba(${COLORS[s].backRGB}, 0.37);
      color: ${COLORS[s].fore};
    }
    body[SEpreview-type="${s}"] #SEpreview-answers a.SEpreviewed {
      background-color: ${COLORS[s].fore};
      color: ${COLORS[s].foreInv};
    }
    body[SEpreview-type="${s}"] #SEpreview-answers a.SEpreviewed:after {
      border-color: ${COLORS[s].fore};
    }
  `).join('');
}

QingJ © 2025

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