// ==UserScript==
// @name futaba-image-preview
// @namespace http://2chan.net/
// @version 0.6.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
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// @run-at document-idle
// @connect 2chan.net
// @connect img.2chan.net
// @connect dec.2chan.net
// @require https://cdn.jsdelivr.net/npm/exifreader@4.20.0/dist/exif-reader.min.js
// @downloadURL none
// ==/UserScript==
(async () => {
'use strict';
const resNumberStorage = {};
let initExecCreateLink = false;
let initTimer;
const addedStyle = ``;
const settingsStyle = ``;
if (!document.querySelector('#userjs-preview-style')) {
document.head.insertAdjacentHTML('beforeend', addedStyle);
}
if (!document.querySelector('#fat-style')) {
document.head.insertAdjacentHTML('beforeend', settingsStyle);
}
const getCloseFileName = async () => JSON.parse((await GM_getValue('closeFileName')) || '[]');
const getMinSize = async () => {
const defaultValue = '480';
const storageValue = await GM_getValue('minSize');
return storageValue || defaultValue;
};
const hasFutakuroElm = () => document.querySelector('#fvw_menu') !== null;
// あぷ・あぷ小ファイルの文字列を見つけたらリンクに変換する(既にリンクになってたらスキップする)
const createAnchorLink = (elms) => {
const processNode = (node) => {
const regex = /((?]*>)(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 setFailedText = (linkElm) => {
if (linkElm && linkElm instanceof HTMLAnchorElement) {
linkElm.insertAdjacentHTML('afterend', 'データ取得失敗');
}
};
const getArrayBuffer = (path) => {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: path,
responseType: 'blob',
onload: ({ response }) => {
return resolve(response);
},
onerror: (error) => {
console.log(error);
},
});
});
};
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 getPromptData = async (div, img) => {
try {
if (!img.src.endsWith('.png') || !window.ExifReader) return;
const buffer = await getArrayBuffer(img.src);
const data = await new FileReaderEx().readAsDataURL(buffer);
const tags = await window.ExifReader.load(data);
const parse = JSON.parse(tags?.['Comment']?.value || '{}');
if (Object.keys(parse).length === 0) return;
const { prompt } = parse;
const p = document.createElement('p');
p.classList.add('userjs-prompt');
p.textContent = prompt;
div.appendChild(p);
} catch (error) {
console.error(error);
}
};
const wrapperClickHandler = (e) => {
const self = e.currentTarget;
if (self.classList.contains('is-caution')) {
self.classList.remove('is-caution');
}
};
const makeCloseButton = () => {
const closeButtonElm = document.createElement('button');
closeButtonElm.classList.add('userjs-preview-close');
return closeButtonElm;
};
const setSetting = async () => {
const delay = (time = 500) => new Promise((resolve) => setTimeout(() => resolve(true), time));
const value = await getMinSize();
const toggleSetting = () => {
const settingElm = document.querySelector('[data-fat="settings"]');
settingElm?.classList.toggle('is-visible');
};
const saveSetting = async () => {
const minSizeElm = document.querySelector(`[data-fat="minSize"]`);
if (!minSizeElm) return;
const value = minSizeElm.value;
if (value === '' || /^[0-9]+$/.test(value) === false) {
alert('数値を入力してください');
return;
}
await GM_setValue('minSize', value);
const settingElm = document.querySelector('[data-fat="settings"]');
settingElm?.classList.remove('is-visible');
await delay(300);
location.reload();
};
const iconHTML = `
`;
const settingInnerHTML = `デフォルトの画像サイズを指定、0で非表示
`;
const settingHTML = `
${settingInnerHTML}
`;
await delay(1000);
const hasFatIconElm = document.querySelector('[data-fat="icon"]') !== null;
const fatSettingsElm = document.querySelector('[data-fat="settings"]');
if (!hasFatIconElm) {
document.body.insertAdjacentHTML('afterbegin', iconHTML);
await delay(100);
const settingIconElm = document.querySelector(`[data-fat="icon"]`);
settingIconElm?.addEventListener('click', toggleSetting);
}
if (fatSettingsElm !== null) {
fatSettingsElm.insertAdjacentHTML('beforeend', settingInnerHTML);
await delay(100);
const settingSaveElm = document.querySelector(`[data-fat="minSizeSave"]`);
settingSaveElm?.addEventListener('click', saveSetting);
} else {
document.body.insertAdjacentHTML('afterbegin', settingHTML);
await delay(100);
const settingSaveElm = document.querySelector(`[data-fat="minSizeSave"]`);
settingSaveElm?.addEventListener('click', saveSetting);
}
};
const closeBtnEventHandler = async (e, div, fileName) => {
e.stopPropagation();
div.remove();
const data = await getCloseFileName();
if (!data.includes(fileName)) {
data.push(fileName);
GM_setValue('closeFileName', JSON.stringify(data));
}
};
const setImageElm = async (linkElm) => {
const imageMinSize = Number(await getMinSize());
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 fileName = (linkElm.textContent || '').trim();
const closeFileName = await getCloseFileName();
if (closeFileName.includes(fileName)) {
return;
}
const resText = linkElm.closest('blockquote')?.textContent;
const div = document.createElement('div');
const innerDiv = document.createElement('div');
div.classList.add('userjs-preview-imageWrap');
innerDiv.classList.add('userjs-preview-inner');
if (/注意|グロ/g.test(resText || '')) {
div.classList.add('is-caution');
}
const img = document.createElement('img');
const closeBtnElm = makeCloseButton();
img.addEventListener('load', () => {
if (img.naturalWidth < imageMinSize) {
img.width = img.naturalWidth;
}
getPromptData(div, img);
});
img.addEventListener('error', () => setFailedText(linkElm));
img.src = linkElm.href;
img.width = imageMinSize;
img.classList.add('userjs-preview-image');
innerDiv.appendChild(img);
innerDiv.appendChild(closeBtnElm);
div.appendChild(innerDiv);
img.addEventListener('click', imageEventHandler);
div.addEventListener('click', wrapperClickHandler);
closeBtnElm.addEventListener('click', (e) => closeBtnEventHandler(e, div, fileName));
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 imageElm = await setImageElm(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) {
setSetting();
const linkElms = getLinkElm(threadElm);
setLinkObserver(linkElms);
const observer = new MutationObserver(mutationLinkElements);
observer.observe(threadElm, {
childList: true,
subtree: true,
});
initTimer = setTimeout(exec, 1500);
}
})();