futaba-image-preview

ふたばちゃんねるのスレッド上で「あぷ」「あぷ小」の画像をプレビュー表示する

目前為 2023-08-05 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         futaba-image-preview
// @namespace    http://2chan.net/
// @version      0.3.0
// @description  ふたばちゃんねるのスレッド上で「あぷ」「あぷ小」の画像をプレビュー表示する
// @author       ame-chan
// @match        http://*.2chan.net/b/res/*
// @match        https://*.2chan.net/b/res/*
// @match        http://kako.futakuro.com/futa/*
// @match        https://kako.futakuro.com/futa/*
// @match        https://tsumanne.net/si/data/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=2chan.net
// @grant        GM_xmlhttpRequest
// @license      MIT
// @run-at       document-idle
// @connect      2chan.net
// @connect      *.2chan.net
// @connect      tsumanne.net
// ==/UserScript==
(async () => {
  'use strict';
  const resNumberStorage = {};
  let initExecCreateLink = false;
  let initTimer;
  const addedStyle = `<style id="userjs-preview-style">
  .zoom_button.not_copy_button {
    display: none;
  }
  .userjs-preview-link {
    padding-right: 24px;
    background-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2038%2038%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23000%22%3E%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Cg%20transform%3D%22translate(1%201)%22%20stroke-width%3D%222%22%3E%3Ccircle%20stroke-opacity%3D%22.5%22%20cx%3D%2218%22%20cy%3D%2218%22%20r%3D%2218%22%2F%3E%3Cpath%20d%3D%22M36%2018c0-9.94-8.06-18-18-18%22%3E%20%3CanimateTransform%20attributeName%3D%22transform%22%20type%3D%22rotate%22%20from%3D%220%2018%2018%22%20to%3D%22360%2018%2018%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E');
    background-repeat: no-repeat;
    background-position: right center;
  }
  .userjs-preview-imageWrap {
    max-width: calc(100vw - 200px);
    width: fit-content;
  }
  .userjs-preview-image {
    max-width: calc(100vw - 200px) !important;
    max-height: none !important;
    transition: all 0.2s ease-in-out;
    border-radius: 4px;
    cursor: pointer;
  }
  .userjs-preview-title {
    display: flex;
    flex-direction: row;
    margin: 8px 0 16px;
    gap: 16px;
    padding: 16px;
    line-height: 1.6 !important;
    color: #ff3860 !important;
    background-color: #fff;
    border-radius: 4px;
  }
  </style>`;
  if (!document.querySelector('#userjs-preview-style')) {
    document.head.insertAdjacentHTML('beforeend', addedStyle);
  }
  class FileReaderEx extends FileReader {
    constructor() {
      super();
    }
    #readAs(blob, ctx) {
      return new Promise((res, rej) => {
        super.addEventListener('load', ({ target }) => target?.result && res(target.result));
        super.addEventListener('error', ({ target }) => target?.error && rej(target.error));
        super[ctx](blob);
      });
    }
    readAsArrayBuffer(blob) {
      return this.#readAs(blob, 'readAsArrayBuffer');
    }
    readAsDataURL(blob) {
      return this.#readAs(blob, 'readAsDataURL');
    }
  }
  const hasFutakuroElm = () => document.querySelector('#fvw_menu') !== null;
  // あぷ・あぷ小ファイルの文字列を見つけたらリンクに変換する(既にリンクになってたらスキップする)
  const createAnchorLink = (elms) => {
    const processNode = (node) => {
      const regex = /((?<!<a[^>]*>)(fu?)([0-9]{5,8})\.(jpe?g|png|webp|gif|bmp)(?![^<]*<\/a>))/g;
      if (node.nodeType === 3) {
        let textNode = node;
        // テキストノードの親要素がaタグである場合、処理をスキップ
        if (textNode.parentNode?.nodeName === 'A') {
          return;
        }
        let match;
        while ((match = regex.exec(textNode.data)) !== null) {
          const [fullMatch, _, type, digits, ext] = match;
          const url =
            type === 'fu'
              ? `//dec.2chan.net/up2/src/${type}${digits}.${ext}`
              : `//dec.2chan.net/up/src/${type}${digits}.${ext}`;
          const anchor = document.createElement('a');
          anchor.href = url;
          anchor.classList.add('is-createLink');
          anchor.dataset.from = 'userjs-preview';
          anchor.textContent = fullMatch;
          const nextTextNode = textNode.splitText(match.index);
          nextTextNode.data = nextTextNode.data.substring(fullMatch.length);
          textNode.parentNode.insertBefore(anchor, nextTextNode);
          textNode = nextTextNode;
        }
      } else if (node.nodeType !== 1 || node.tagName !== 'BR') {
        const childNodes = Array.from(node.childNodes);
        childNodes.forEach((childNode) => processNode(childNode));
      }
    };
    for (const el of elms) {
      processNode(el);
    }
  };
  const fetchData = (url, responseType) =>
    new Promise((resolve) => {
      let options = {
        method: 'GET',
        url,
        timeout: 10000,
        onload: (result) => {
          if (result.status === 200) {
            return resolve(result.response);
          }
          return resolve(false);
        },
        onerror: () => resolve(false),
        ontimeout: () => resolve(false),
      };
      if (typeof responseType === 'string') {
        options = {
          ...options,
          responseType,
        };
      }
      GM_xmlhttpRequest(options);
    });
  const setFailedText = (linkElm) => {
    if (linkElm && linkElm instanceof HTMLAnchorElement) {
      linkElm.insertAdjacentHTML('afterend', '<span class="userjs-preview-title">データ取得失敗</span>');
    }
  };
  const setImageElm = async (blob, linkElm) => {
    const imageMinSize = 480;
    const imageMaxSize = 1024;
    const imageEventHandler = (e) => {
      const self = e.currentTarget;
      if (!(self instanceof HTMLImageElement)) return;
      const naturalWidth = self.naturalWidth;
      if (naturalWidth < imageMinSize) {
        self.width = self.width === naturalWidth ? imageMinSize : naturalWidth;
      } else if (self.width === imageMinSize) {
        self.width = naturalWidth > imageMaxSize ? naturalWidth : imageMaxSize;
      } else {
        self.width = imageMinSize;
      }
    };
    const dataUrl = await new FileReaderEx().readAsDataURL(blob);
    const div = document.createElement('div');
    div.classList.add('userjs-preview-imageWrap');
    const img = document.createElement('img');
    img.addEventListener('load', () => {
      if (img.naturalWidth < imageMinSize) {
        img.width = img.naturalWidth;
      }
    });
    img.src = dataUrl;
    img.width = imageMinSize;
    img.classList.add('userjs-preview-image');
    div.appendChild(img);
    img.addEventListener('click', imageEventHandler);
    linkElm.insertAdjacentElement('afterend', div);
    return img;
  };
  const setLoading = async (linkElm) => {
    const parentElm = linkElm.parentElement;
    if (parentElm instanceof HTMLFontElement) {
      return;
    }
    linkElm.classList.add('userjs-preview-link');
  };
  const removeLoading = (targetElm) => targetElm.classList.remove('userjs-preview-link');
  // ふたクロで「新着レスに自動スクロール」にチェックが入っている場合画像差し込み後に下までスクロールさせる
  const scrollIfAutoScrollIsEnabled = () => {
    const checkboxElm = document.querySelector('#autolive_scroll');
    const readmoreElm = document.querySelector('#res_menu');
    if (checkboxElm === null || readmoreElm === null || !checkboxElm?.checked) {
      return;
    }
    const elementHeight = readmoreElm.offsetHeight;
    const viewportHeight = window.innerHeight;
    const offsetTop = readmoreElm.offsetTop;
    window.scrollTo({
      top: offsetTop - viewportHeight + elementHeight,
      behavior: 'smooth',
    });
  };
  const setResNumber = (linkElm, fileName) => {
    const tdElm = linkElm.closest('td.rtd');
    const resNumber = tdElm?.querySelector('.rsc');
    if (resNumber && resNumber.textContent) {
      const num = Number(resNumber.textContent);
      const storage = resNumberStorage[num];
      if (Number.isInteger(num) && fileName) {
        if (typeof storage === 'undefined') {
          resNumberStorage[num] = [fileName];
        } else if (Array.isArray(storage) && !storage.includes(fileName)) {
          storage.push(fileName);
        }
      }
    }
  };
  const isFindFileNameFromStorage = (fileName) =>
    Object.keys(resNumberStorage).some((key) => {
      const arr = resNumberStorage?.[Number(key)];
      return arr && fileName && arr.includes(fileName);
    });
  const insertURLData = async (linkElm, match) => {
    const [, , , fileName] = match;
    const data = await fetchData(linkElm.href, 'blob');
    if (!data) {
      setFailedText(linkElm);
      removeLoading(linkElm);
      return;
    }
    const imageElm = await setImageElm(data, linkElm);
    if (imageElm instanceof HTMLImageElement) {
      linkElm.classList.add('is-intersecting');
      setResNumber(linkElm, fileName);
      imageElm.onload = () => scrollIfAutoScrollIsEnabled();
    }
    removeLoading(linkElm);
  };
  const linkRegExp =
    /((tsumanne\.net\/si\/data|\w+\.2chan\.net\/up[0-9]?\/src)\/)?(fu?[0-9]{5,8}\.(jpe?g|png|gif|webp|bmp))/;
  class LinkObserver {
    targetLink;
    isObserving;
    matchLink;
    options;
    constructor(targetLink) {
      this.targetLink = targetLink;
      this.isObserving = this.targetLink.classList.contains('is-observing');
      this.matchLink = this.targetLink.href.match(linkRegExp);
      this.options = {
        rootMargin: '800px 0px 0px 0px',
      };
    }
    observer() {
      return new IntersectionObserver(async ([entry], observer) => {
        if (entry.isIntersecting) {
          observer.disconnect();
          const linkElm = entry.target;
          if (this.matchLink && linkElm instanceof HTMLAnchorElement) {
            await setLoading(linkElm);
            if (linkElm.classList.contains('userjs-preview-link')) {
              await insertURLData(linkElm, this.matchLink);
            }
          }
        }
      }, this.options);
    }
    check() {
      if (this.matchLink === null) {
        return false;
      }
      const isQuoteText = this.targetLink.closest('font[color="#789922"]') !== null;
      const [, , , fileName] = this.matchLink;
      if (isQuoteText || isFindFileNameFromStorage(fileName)) {
        return false;
      }
      return true;
    }
    init() {
      const isCheckOK = this.check();
      if (isCheckOK && !this.isObserving && this.matchLink) {
        this.targetLink.classList.add('is-observing');
        this.observer().observe(this.targetLink);
      }
    }
  }
  const getLinkElm = (threElm) => {
    const linkElms = threElm.querySelectorAll('a[href*="2chan.net/up"], a[href^="f"]');
    if (linkElms.length) {
      return linkElms;
    }
    return [];
  };
  const deleteDuplicate = (blockquoteElms) => {
    for (const blockquoteElm of blockquoteElms) {
      const anchorElms = blockquoteElm.querySelectorAll('a[data-orig]');
      for (const anchorElm of anchorElms) {
        const newAnchorElm = anchorElm.querySelector('a[data-from]');
        if (newAnchorElm !== null) {
          anchorElm.outerHTML = newAnchorElm.outerHTML;
        }
      }
    }
  };
  const setLinkObserver = (linkElms) => {
    for (const linkElm of linkElms) {
      if (linkElm instanceof HTMLAnchorElement) {
        const linkObserver = new LinkObserver(linkElm);
        linkObserver.init();
      }
    }
  };
  const mutationLinkElements = async (mutations) => {
    const futakuroState = hasFutakuroElm();
    for (const mutation of mutations) {
      for (const addedNode of mutation.addedNodes) {
        if (!(addedNode instanceof HTMLElement)) continue;
        const newBlockQuotes = addedNode.querySelectorAll('blockquote');
        if (!futakuroState) {
          createAnchorLink(newBlockQuotes);
          deleteDuplicate(newBlockQuotes);
        }
        for (const newBlockQuote of newBlockQuotes) {
          const linkElms = newBlockQuote.querySelectorAll('a');
          if (linkElms.length) {
            setLinkObserver(linkElms);
          }
        }
      }
    }
  };
  // ふたクロが無い環境用にアンカーリンクを生成したい
  const exec = () => {
    const threadElm = document.querySelector('.thre');
    const isTsumanne = location.hostname === 'tsumanne.net';
    const isFutakuro = location.hostname === 'kako.futakuro.com';
    if (!isTsumanne && !isFutakuro && !hasFutakuroElm() && !initExecCreateLink && threadElm instanceof HTMLElement) {
      const quoteElms = threadElm.querySelectorAll('blockquote');
      initExecCreateLink = true;
      if (initTimer) {
        clearTimeout(initTimer);
      }
      createAnchorLink(quoteElms);
      for (const quoteElm of quoteElms) {
        const linkElms = quoteElm.querySelectorAll('.is-createLink');
        setLinkObserver(linkElms);
      }
    }
  };
  const threadElm = document.querySelector('.thre');
  if (threadElm instanceof HTMLElement) {
    const linkElms = getLinkElm(threadElm);
    setLinkObserver(linkElms);
    const observer = new MutationObserver(mutationLinkElements);
    observer.observe(threadElm, {
      childList: true,
      subtree: true,
    });
    initTimer = setTimeout(exec, 1500);
  }
})();