// ==UserScript==
// @name Futaba Image Preview
// @namespace http://2chan.net/
// @version 0.2.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 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.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;
const div = self?.parentElement;
if (!(self instanceof HTMLImageElement) || !div) return;
if (self.width === imageMinSize) {
self.width = self.naturalWidth > imageMaxSize ? self.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 = (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 parentElm = linkElm.parentElement;
const [, , , fileName] = match;
if (parentElm instanceof HTMLFontElement || isFindFileNameFromStorage(fileName)) {
removeLoading(linkElm);
return;
}
const data = await fetchData(linkElm.href, 'blob');
if (!data) {
setFailedText(linkElm);
removeLoading(linkElm);
return;
}
const imageElm = await setImageElm(data, linkElm);
if (imageElm instanceof HTMLImageElement) {
setResNumber(linkElm, fileName);
imageElm.onload = () => scrollIfAutoScrollIsEnabled();
}
removeLoading(linkElm);
};
const searchLinkElements = async (targetElm) => {
const processBatch = async (batch, match) => {
const promises = batch.map(async (linkElm) => {
if (!linkElm.classList.contains('userjs-preview-link')) return;
await insertURLData(linkElm, match);
});
await Promise.all(promises);
};
const linkElms = targetElm.querySelectorAll('a[href*="2chan.net/up"], a[href^="f"]');
if (!linkElms.length) return;
const regExp =
/((tsumanne\.net\/si\/data|\w+\.2chan\.net\/up[0-9]?\/src)\/)?(fu?[0-9]{5,8}\.(jpe?g|png|gif|webp|bmp))/;
for (const linkElm of linkElms) {
const match = linkElm.href.match(regExp);
if (match === null) continue;
setLoading(linkElm);
}
for (let i = 0; i < linkElms.length; i += 5) {
const linkElm = linkElms[i];
const match = linkElm.href.match(regExp);
if (match === null) continue;
const batch = Array.from(linkElms).slice(i, i + 5);
await processBatch(batch, match);
}
};
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 mutationLinkElements = async (mutations) => {
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (!(addedNode instanceof HTMLElement)) continue;
const newBlockQuotes = addedNode.querySelectorAll('blockquote');
createAnchorLink(newBlockQuotes);
deleteDuplicate(newBlockQuotes);
searchLinkElements(addedNode);
}
}
};
// ふたクロが無い環境用にアンカーリンクを生成したい
const exec = () => {
const threadElm = document.querySelector('.thre');
const hasFutakuroElm = document.querySelector('#fvw_menu') !== null;
const isTsumanne = location.hostname === 'tsumanne.net';
const isFutakuro = location.hostname === 'kako.futakuro.com';
if (!isTsumanne && !isFutakuro && !initExecCreateLink && threadElm instanceof HTMLElement && !hasFutakuroElm) {
const quoteElms = threadElm.querySelectorAll('blockquote');
initExecCreateLink = true;
if (initTimer) {
clearTimeout(initTimer);
}
createAnchorLink(quoteElms);
searchLinkElements(threadElm);
}
};
let threadElm = document.querySelector('.thre');
if (threadElm instanceof HTMLElement) {
searchLinkElements(threadElm);
const observer = new MutationObserver(mutationLinkElements);
observer.observe(threadElm, {
childList: true,
});
initTimer = setTimeout(exec, 1500);
}
})();