futaba-image-preview

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

  1. // ==UserScript==
  2. // @name futaba-image-preview
  3. // @namespace http://2chan.net/
  4. // @version 0.7.4
  5. // @description ふたばちゃんねるのスレッド上で「あぷ」「あぷ小」の画像をプレビュー表示する
  6. // @author ame-chan
  7. // @match http://*.2chan.net/b/res/*
  8. // @match https://*.2chan.net/b/res/*
  9. // @match http://kako.futakuro.com/futa/*
  10. // @match https://kako.futakuro.com/futa/*
  11. // @match https://tsumanne.net/si/data/*
  12. // @icon https://www.google.com/s2/favicons?sz=64&domain=2chan.net
  13. // @grant GM_xmlhttpRequest
  14. // @grant GM_setValue
  15. // @grant GM_getValue
  16. // @license MIT
  17. // @run-at document-idle
  18. // @connect 2chan.net
  19. // @connect img.2chan.net
  20. // @connect dec.2chan.net
  21. // @require https://cdn.jsdelivr.net/npm/exifreader@4.20.0/dist/exif-reader.min.js
  22. // ==/UserScript==
  23. (async () => {
  24. 'use strict';
  25. const resNumberStorage = {};
  26. let initExecCreateLink = false;
  27. let initTimer;
  28. const addedStyle = `<style id="userjs-preview-style">
  29. .zoom_button.not_copy_button {
  30. display: none;
  31. }
  32. .userjs-preview-link {
  33. padding-right: 24px;
  34. 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');
  35. background-repeat: no-repeat;
  36. background-position: right center;
  37. }
  38. .userjs-preview-imageWrap {
  39. max-width: calc(100vw - 200px);
  40. width: fit-content;
  41. }
  42. .userjs-preview-inner {
  43. position: relative;
  44. width: fit-content;
  45. }
  46. .userjs-preview-inner.is-caution::after {
  47. position: absolute;
  48. top: 0;
  49. left: 0;
  50. content: "";
  51. display: block;
  52. width: 100%;
  53. height: 100%;
  54. backdrop-filter: blur(40px);
  55. border-radius: 4px;
  56. }
  57. .userjs-preview-image {
  58. max-width: calc(100vw - 200px) !important;
  59. max-height: none !important;
  60. transition: all 0.2s ease-in-out;
  61. border-radius: 4px;
  62. cursor: pointer;
  63. }
  64. .userjs-preview-close {
  65. position: absolute;
  66. top: -12px;
  67. right: -12px;
  68. width: 24px;
  69. height: 24px;
  70. line-height: 1;
  71. color: #fff;
  72. background-color: hsl(347.94deg 100% 60.98%);
  73. border: none;
  74. border-radius: 50%;
  75. cursor: pointer;
  76. z-index: 10;
  77. }
  78. .userjs-preview-close:hover {
  79. background-color: hsl(347.94deg 100% 75.98%);
  80. }
  81. .userjs-preview-close::before {
  82. position: absolute;
  83. top: 50%;
  84. left: 50%;
  85. content: "";
  86. display: block;
  87. width: 16px;
  88. height: 16px;
  89. background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="16" width="12" viewBox="0 0 384 512"><path fill="%23FFFFFF" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>');
  90. background-repeat: no-repeat;
  91. background-size: 16px 16px;
  92. transform: translate3d(-50%, -50%, 0);
  93. }
  94. .userjs-preview-title {
  95. display: flex;
  96. flex-direction: row;
  97. margin: 8px 0 16px;
  98. gap: 16px;
  99. padding: 16px;
  100. line-height: 1.6 !important;
  101. color: #ff3860 !important;
  102. background-color: #fff;
  103. border-radius: 4px;
  104. }
  105. .userjs-prompt {
  106. color: #888;
  107. border-left: 4px solid #888;
  108. padding: 0 0 0 16px;
  109. }
  110. .fat-settings button + p {
  111. margin-top: 16px;
  112. }
  113. </style>`;
  114. const settingsStyle = `<style id="fat-style">
  115. .fat-icon {
  116. position: fixed;
  117. right: 16px;
  118. bottom: 16px;
  119. padding: 8px;
  120. width: 24px;
  121. height: 24px;
  122. z-index: 9999;
  123. background-color: #fff;
  124. border-radius: 50%;
  125. box-shadow: 0 2px 10px rgb(0 0 0 / 30%);
  126. cursor: pointer;
  127. }
  128. .fat-icon::before {
  129. display: block;
  130. width: 24px;
  131. height: 24px;
  132. content: "";
  133. background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0'%3F%3E%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' width='100px' height='100px'%3E%3Cpath d='M47.16,21.221l-5.91-0.966c-0.346-1.186-0.819-2.326-1.411-3.405l3.45-4.917c0.279-0.397,0.231-0.938-0.112-1.282 l-3.889-3.887c-0.347-0.346-0.893-0.391-1.291-0.104l-4.843,3.481c-1.089-0.602-2.239-1.08-3.432-1.427l-1.031-5.886 C28.607,2.35,28.192,2,27.706,2h-5.5c-0.49,0-0.908,0.355-0.987,0.839l-0.956,5.854c-1.2,0.345-2.352,0.818-3.437,1.412l-4.83-3.45 c-0.399-0.285-0.942-0.239-1.289,0.106L6.82,10.648c-0.343,0.343-0.391,0.883-0.112,1.28l3.399,4.863 c-0.605,1.095-1.087,2.254-1.438,3.46l-5.831,0.971c-0.482,0.08-0.836,0.498-0.836,0.986v5.5c0,0.485,0.348,0.9,0.825,0.985 l5.831,1.034c0.349,1.203,0.831,2.362,1.438,3.46l-3.441,4.813c-0.284,0.397-0.239,0.942,0.106,1.289l3.888,3.891 c0.343,0.343,0.884,0.391,1.281,0.112l4.87-3.411c1.093,0.601,2.248,1.078,3.445,1.424l0.976,5.861C21.3,47.647,21.717,48,22.206,48 h5.5c0.485,0,0.9-0.348,0.984-0.825l1.045-5.89c1.199-0.353,2.348-0.833,3.43-1.435l4.905,3.441 c0.398,0.281,0.938,0.232,1.282-0.111l3.888-3.891c0.346-0.347,0.391-0.894,0.104-1.292l-3.498-4.857 c0.593-1.08,1.064-2.222,1.407-3.408l5.918-1.039c0.479-0.084,0.827-0.5,0.827-0.985v-5.5C47.999,21.718,47.644,21.3,47.16,21.221z M25,32c-3.866,0-7-3.134-7-7c0-3.866,3.134-7,7-7s7,3.134,7,7C32,28.866,28.866,32,25,32z'/%3E%3C/svg%3E");
  134. background-repeat: no-repeat;
  135. background-size: cover;
  136. transition: all 0.3s ease;
  137. transform: rotate(0deg);
  138. }
  139. .fat-icon:hover::before {
  140. transform: rotate(180deg);
  141. }
  142. .fat-settings {
  143. position: fixed;
  144. bottom: 72px;
  145. right: 16px;
  146. display: flex;
  147. flex-direction: column;
  148. padding: 16px;
  149. max-width: 80%;
  150. width: calc(350px - 32px);
  151. height: fit-content;
  152. color: #202020;
  153. background-color: #fff;
  154. border-radius: 6px;
  155. transition: transform 0.3s ease;
  156. transform: translateX(400px);
  157. z-index: 10001;
  158. }
  159. .fat-settings p {
  160. margin: 0;
  161. padding: 0;
  162. font-size: 16px;
  163. }
  164. .fat-settings button + p {
  165. margin-top: 16px;
  166. }
  167. .fat-settings p span {
  168. font-size: 13px;
  169. }
  170. .fat-settings textarea {
  171. margin-top: 8px;
  172. padding: 8px;
  173. height: 150px;
  174. max-height: 400px;
  175. min-height: 100px;
  176. line-height: 1.3;
  177. letter-spacing: 0.5px;
  178. font-weight: 400;
  179. font-family: Verdana;
  180. border-radius: 4px;
  181. border: 1px solid #ccc;
  182. resize: vertical;
  183. }
  184. .fat-settings button {
  185. margin-top: 16px;
  186. padding: 8px 16px;
  187. width: fit-content;
  188. color: #fff;
  189. font-size: 13px;
  190. border: 0px;
  191. border-radius: 4px;
  192. background-color: #00d1b2;
  193. appearance: none;
  194. cursor: pointer;
  195. }
  196. .fat-settings button:hover {
  197. filter: saturate(130%);
  198. }
  199. .fat-settings button:active {
  200. filter: saturate(150%);
  201. }
  202. .fat-settings.is-visible {
  203. transform: translateX(0);
  204. }
  205. </style>`;
  206. if (!document.querySelector('#userjs-preview-style')) {
  207. document.head.insertAdjacentHTML('beforeend', addedStyle);
  208. }
  209. if (!document.querySelector('#fat-style')) {
  210. document.head.insertAdjacentHTML('beforeend', settingsStyle);
  211. }
  212. const getCloseFileName = async () => JSON.parse((await GM_getValue('closeFileName')) || '[]');
  213. const getMinSize = async () => {
  214. const defaultValue = '480';
  215. const storageValue = await GM_getValue('minSize');
  216. return storageValue || defaultValue;
  217. };
  218. const hasFutakuroElm = () => document.querySelector('#fvw_menu') !== null;
  219. // あぷ・あぷ小ファイルの文字列を見つけたらリンクに変換する(既にリンクになってたらスキップする)
  220. const createAnchorLink = (elms) => {
  221. const processNode = (node) => {
  222. const regex = /((?<!<a[^>]*>)(fu?)([0-9]{5,8})\.(jpe?g|png|webp|gif|bmp)(?![^<]*<\/a>))/g;
  223. if (node.nodeType === 3) {
  224. let textNode = node;
  225. // テキストノードの親要素がaタグである場合、処理をスキップ
  226. if (textNode.parentNode?.nodeName === 'A') {
  227. return;
  228. }
  229. let match;
  230. while ((match = regex.exec(textNode.data)) !== null) {
  231. const [fullMatch, _, type, digits, ext] = match;
  232. const url =
  233. type === 'fu'
  234. ? `//dec.2chan.net/up2/src/${type}${digits}.${ext}`
  235. : `//dec.2chan.net/up/src/${type}${digits}.${ext}`;
  236. const anchor = document.createElement('a');
  237. anchor.href = url;
  238. anchor.classList.add('is-createLink');
  239. anchor.dataset.from = 'userjs-preview';
  240. anchor.textContent = fullMatch;
  241. const nextTextNode = textNode.splitText(match.index);
  242. nextTextNode.data = nextTextNode.data.substring(fullMatch.length);
  243. textNode.parentNode.insertBefore(anchor, nextTextNode);
  244. textNode = nextTextNode;
  245. }
  246. } else if (node.nodeType !== 1 || node.tagName !== 'BR') {
  247. const childNodes = Array.from(node.childNodes);
  248. childNodes.forEach((childNode) => processNode(childNode));
  249. }
  250. };
  251. for (const el of elms) {
  252. processNode(el);
  253. }
  254. };
  255. const setFailedText = (linkElm) => {
  256. if (linkElm && linkElm instanceof HTMLAnchorElement) {
  257. linkElm.insertAdjacentHTML('afterend', '<span class="userjs-preview-title">データ取得失敗</span>');
  258. }
  259. };
  260. const getArrayBuffer = (path) => {
  261. return new Promise((resolve) => {
  262. GM_xmlhttpRequest({
  263. method: 'GET',
  264. url: path,
  265. responseType: 'blob',
  266. onload: ({ response }) => {
  267. return resolve(response);
  268. },
  269. onerror: (error) => {
  270. console.log(error);
  271. },
  272. });
  273. });
  274. };
  275. class FileReaderEx extends FileReader {
  276. constructor() {
  277. super();
  278. }
  279. #readAs(blob, ctx) {
  280. return new Promise((res, rej) => {
  281. super.addEventListener('load', ({ target }) => target?.result && res(target.result));
  282. super.addEventListener('error', ({ target }) => target?.error && rej(target.error));
  283. super[ctx](blob);
  284. });
  285. }
  286. readAsArrayBuffer(blob) {
  287. return this.#readAs(blob, 'readAsArrayBuffer');
  288. }
  289. readAsDataURL(blob) {
  290. return this.#readAs(blob, 'readAsDataURL');
  291. }
  292. }
  293. const getPromptData = async (div, img) => {
  294. const getGridPosition = ({ x, y }) => {
  295. const xPos = Math.round((x - 0.1) / 0.2);
  296. const yPos = Math.round((y - 0.1) / 0.2);
  297. const column = String.fromCharCode(65 + xPos);
  298. const row = yPos + 1;
  299. return `${column}${row}`;
  300. };
  301. const escapeHtml = (str) => {
  302. if (!str) return '';
  303. return str
  304. .replace(/&/g, '&amp;')
  305. .replace(/</g, '&lt;')
  306. .replace(/>/g, '&gt;')
  307. .replace(/"/g, '&quot;')
  308. .replace(/'/g, '&#039;');
  309. };
  310. const createV4Prompt = (v4_prompt) => {
  311. const charCaptions = v4_prompt?.caption?.char_captions;
  312. let text = escapeHtml(v4_prompt?.caption?.base_caption) || '';
  313. if (charCaptions) {
  314. text += '<br>';
  315. text += charCaptions.reduce((temp, data, idx) => {
  316. temp += `<strong>▼character prompt ${idx + 1}</strong><br>`;
  317. temp += escapeHtml(data.char_caption) + '<br>';
  318. if (data.centers && data.centers.length) {
  319. for (const center of data.centers) {
  320. temp += `<strong>position</strong><br>`;
  321. temp += `${getGridPosition(center)}<br>`;
  322. }
  323. }
  324. return temp;
  325. }, '');
  326. }
  327. return text;
  328. };
  329. const promptParser = (tags) => {
  330. const parse = JSON.parse(tags?.['Comment']?.value || '{}');
  331. if (Object.keys(parse).length === 0) {
  332. if (img.src.endsWith('.webp')) {
  333. const webpParseArray = tags?.['UserComment']?.value || [];
  334. const text = webpParseArray
  335. .filter((code) => code !== 0)
  336. .map((code) => String.fromCharCode(code))
  337. .join('');
  338. const match = text.match(/Comment: ({.+})/);
  339. if (match && JSON.parse(match[1])) {
  340. return JSON.parse(match[1]);
  341. }
  342. }
  343. return {};
  344. }
  345. return parse;
  346. };
  347. try {
  348. const isExifImageType = img.src.endsWith('.png') || img.src.endsWith('.webp');
  349. if (!isExifImageType || !window.ExifReader) return;
  350. const buffer = await getArrayBuffer(img.src);
  351. const data = await new FileReaderEx().readAsDataURL(buffer);
  352. const tags = await window.ExifReader.load(data);
  353. const parse = promptParser(tags);
  354. const { prompt, v4_prompt } = parse;
  355. if (!prompt && !v4_prompt) return;
  356. const p = document.createElement('p');
  357. let html = prompt;
  358. p.classList.add('userjs-prompt');
  359. if (v4_prompt) {
  360. html = createV4Prompt(v4_prompt);
  361. }
  362. if (prompt || v4_prompt) {
  363. p.innerHTML = html;
  364. } else {
  365. p.textContent = 'プロンプトがありません';
  366. }
  367. div.appendChild(p);
  368. } catch (error) {
  369. console.error(error);
  370. }
  371. };
  372. const wrapperClickHandler = (e) => {
  373. const self = e.currentTarget;
  374. if (self.classList.contains('is-caution')) {
  375. self.classList.remove('is-caution');
  376. }
  377. };
  378. const makeCloseButton = () => {
  379. const closeButtonElm = document.createElement('button');
  380. closeButtonElm.classList.add('userjs-preview-close');
  381. return closeButtonElm;
  382. };
  383. const setSetting = async () => {
  384. const delay = (time = 500) => new Promise((resolve) => setTimeout(() => resolve(true), time));
  385. const value = await getMinSize();
  386. const toggleSetting = () => {
  387. const settingElm = document.querySelector('[data-fat="settings"]');
  388. settingElm?.classList.toggle('is-visible');
  389. };
  390. const saveSetting = async () => {
  391. const minSizeElm = document.querySelector(`[data-fat="minSize"]`);
  392. if (!minSizeElm) return;
  393. const value = minSizeElm.value;
  394. if (value === '' || /^[0-9]+$/.test(value) === false) {
  395. alert('数値を入力してください');
  396. return;
  397. }
  398. await GM_setValue('minSize', value);
  399. const settingElm = document.querySelector('[data-fat="settings"]');
  400. settingElm?.classList.remove('is-visible');
  401. await delay(300);
  402. location.reload();
  403. };
  404. const iconHTML = `<div class="fat-icon" data-fat="icon"></div>`;
  405. const settingInnerHTML = `<p>デフォルトの画像サイズを指定、0で非表示</p>
  406. <input type="text" data-fat="minSize" value="${value}">
  407. <button type="button" data-fat="minSizeSave">条件を保存してリロード</button>`;
  408. const settingHTML = `<div class="fat-settings" data-fat="settings">
  409. ${settingInnerHTML}
  410. </div>`;
  411. await delay(1000);
  412. const hasFatIconElm = document.querySelector('[data-fat="icon"]') !== null;
  413. const fatSettingsElm = document.querySelector('[data-fat="settings"]');
  414. if (!hasFatIconElm) {
  415. document.body.insertAdjacentHTML('afterbegin', iconHTML);
  416. await delay(100);
  417. const settingIconElm = document.querySelector(`[data-fat="icon"]`);
  418. settingIconElm?.addEventListener('click', toggleSetting);
  419. }
  420. if (fatSettingsElm !== null) {
  421. fatSettingsElm.insertAdjacentHTML('beforeend', settingInnerHTML);
  422. await delay(100);
  423. const settingSaveElm = document.querySelector(`[data-fat="minSizeSave"]`);
  424. settingSaveElm?.addEventListener('click', saveSetting);
  425. } else {
  426. document.body.insertAdjacentHTML('afterbegin', settingHTML);
  427. await delay(100);
  428. const settingSaveElm = document.querySelector(`[data-fat="minSizeSave"]`);
  429. settingSaveElm?.addEventListener('click', saveSetting);
  430. }
  431. };
  432. const closeBtnEventHandler = async (e, div, fileName) => {
  433. e.stopPropagation();
  434. div.remove();
  435. const data = await getCloseFileName();
  436. if (!data.includes(fileName)) {
  437. data.push(fileName);
  438. GM_setValue('closeFileName', JSON.stringify(data));
  439. }
  440. };
  441. const setImageElm = async (linkElm) => {
  442. const imageMinSize = Number(await getMinSize());
  443. const imageMaxSize = 1024;
  444. const imageEventHandler = (e) => {
  445. const self = e.currentTarget;
  446. if (!(self instanceof HTMLImageElement)) return;
  447. const naturalWidth = self.naturalWidth;
  448. if (naturalWidth < imageMinSize) {
  449. self.width = self.width === naturalWidth ? imageMinSize : naturalWidth;
  450. } else if (self.width === imageMinSize) {
  451. self.width = naturalWidth > imageMaxSize ? naturalWidth : imageMaxSize;
  452. } else {
  453. self.width = imageMinSize;
  454. }
  455. };
  456. const fileName = (linkElm.textContent || '').trim();
  457. const closeFileName = await getCloseFileName();
  458. if (closeFileName.includes(fileName)) {
  459. return;
  460. }
  461. const resText = linkElm.closest('blockquote')?.textContent;
  462. const div = document.createElement('div');
  463. const innerDiv = document.createElement('div');
  464. div.classList.add('userjs-preview-imageWrap');
  465. innerDiv.classList.add('userjs-preview-inner');
  466. if (/注意|グロ/g.test(resText || '')) {
  467. innerDiv.classList.add('is-caution');
  468. }
  469. const img = document.createElement('img');
  470. const closeBtnElm = makeCloseButton();
  471. img.addEventListener('load', () => {
  472. if (img.naturalWidth < imageMinSize) {
  473. img.width = img.naturalWidth;
  474. }
  475. getPromptData(div, img);
  476. });
  477. img.addEventListener('error', () => setFailedText(linkElm));
  478. img.src = linkElm.href;
  479. img.width = imageMinSize;
  480. img.classList.add('userjs-preview-image');
  481. innerDiv.appendChild(img);
  482. innerDiv.appendChild(closeBtnElm);
  483. div.appendChild(innerDiv);
  484. img.addEventListener('click', imageEventHandler);
  485. innerDiv.addEventListener('click', wrapperClickHandler);
  486. closeBtnElm.addEventListener('click', (e) => closeBtnEventHandler(e, div, fileName));
  487. linkElm.insertAdjacentElement('afterend', div);
  488. return img;
  489. };
  490. const setLoading = async (linkElm) => {
  491. const parentElm = linkElm.parentElement;
  492. if (parentElm instanceof HTMLFontElement) {
  493. return;
  494. }
  495. linkElm.classList.add('userjs-preview-link');
  496. };
  497. const removeLoading = (targetElm) => targetElm.classList.remove('userjs-preview-link');
  498. // ふたクロで「新着レスに自動スクロール」にチェックが入っている場合画像差し込み後に下までスクロールさせる
  499. const scrollIfAutoScrollIsEnabled = () => {
  500. const checkboxElm = document.querySelector('#autolive_scroll');
  501. const readmoreElm = document.querySelector('#res_menu');
  502. if (checkboxElm === null || readmoreElm === null || !checkboxElm?.checked) {
  503. return;
  504. }
  505. const elementHeight = readmoreElm.offsetHeight;
  506. const viewportHeight = window.innerHeight;
  507. const offsetTop = readmoreElm.offsetTop;
  508. window.scrollTo({
  509. top: offsetTop - viewportHeight + elementHeight,
  510. behavior: 'smooth',
  511. });
  512. };
  513. const setResNumber = (linkElm, fileName) => {
  514. const tdElm = linkElm.closest('td.rtd');
  515. const resNumber = tdElm?.querySelector('.rsc');
  516. if (resNumber && resNumber.textContent) {
  517. const num = Number(resNumber.textContent);
  518. const storage = resNumberStorage[num];
  519. if (Number.isInteger(num) && fileName) {
  520. if (typeof storage === 'undefined') {
  521. resNumberStorage[num] = [fileName];
  522. } else if (Array.isArray(storage) && !storage.includes(fileName)) {
  523. storage.push(fileName);
  524. }
  525. }
  526. }
  527. };
  528. const isFindFileNameFromStorage = (fileName) =>
  529. Object.keys(resNumberStorage).some((key) => {
  530. const arr = resNumberStorage?.[Number(key)];
  531. return arr && fileName && arr.includes(fileName);
  532. });
  533. const insertURLData = async (linkElm, match) => {
  534. const [, , , fileName] = match;
  535. const imageElm = await setImageElm(linkElm);
  536. if (imageElm instanceof HTMLImageElement) {
  537. linkElm.classList.add('is-intersecting');
  538. setResNumber(linkElm, fileName);
  539. imageElm.onload = () => scrollIfAutoScrollIsEnabled();
  540. }
  541. removeLoading(linkElm);
  542. };
  543. const linkRegExp =
  544. /((tsumanne\.net\/si\/data|\w+\.2chan\.net\/up[0-9]?\/src)\/)?(fu?[0-9]{5,8}\.(jpe?g|png|gif|webp|bmp))/;
  545. class LinkObserver {
  546. targetLink;
  547. isObserving;
  548. matchLink;
  549. options;
  550. constructor(targetLink) {
  551. this.targetLink = targetLink;
  552. this.isObserving = this.targetLink.classList.contains('is-observing');
  553. this.matchLink = this.targetLink.href.match(linkRegExp);
  554. this.options = {
  555. rootMargin: '800px 0px 0px 0px',
  556. };
  557. }
  558. observer() {
  559. return new IntersectionObserver(async ([entry], observer) => {
  560. if (entry.isIntersecting) {
  561. observer.disconnect();
  562. const linkElm = entry.target;
  563. if (this.matchLink && linkElm instanceof HTMLAnchorElement) {
  564. await setLoading(linkElm);
  565. if (linkElm.classList.contains('userjs-preview-link')) {
  566. await insertURLData(linkElm, this.matchLink);
  567. }
  568. }
  569. }
  570. }, this.options);
  571. }
  572. check() {
  573. if (this.matchLink === null) {
  574. return false;
  575. }
  576. const isQuoteText = this.targetLink.closest('font[color="#789922"]') !== null;
  577. const [, , , fileName] = this.matchLink;
  578. if (isQuoteText || isFindFileNameFromStorage(fileName)) {
  579. return false;
  580. }
  581. return true;
  582. }
  583. init() {
  584. const isCheckOK = this.check();
  585. if (isCheckOK && !this.isObserving && this.matchLink) {
  586. this.targetLink.classList.add('is-observing');
  587. this.observer().observe(this.targetLink);
  588. }
  589. }
  590. }
  591. const getLinkElm = (threElm) => {
  592. const linkElms = threElm.querySelectorAll('a[href*="2chan.net/up"], a[href^="f"]');
  593. if (linkElms.length) {
  594. return linkElms;
  595. }
  596. return [];
  597. };
  598. const deleteDuplicate = (blockquoteElms) => {
  599. for (const blockquoteElm of blockquoteElms) {
  600. const anchorElms = blockquoteElm.querySelectorAll('a[data-orig]');
  601. for (const anchorElm of anchorElms) {
  602. const newAnchorElm = anchorElm.querySelector('a[data-from]');
  603. if (newAnchorElm !== null) {
  604. anchorElm.outerHTML = newAnchorElm.outerHTML;
  605. }
  606. }
  607. }
  608. };
  609. const setLinkObserver = (linkElms) => {
  610. for (const linkElm of linkElms) {
  611. if (linkElm instanceof HTMLAnchorElement) {
  612. const linkObserver = new LinkObserver(linkElm);
  613. linkObserver.init();
  614. }
  615. }
  616. };
  617. const mutationLinkElements = async (mutations) => {
  618. const futakuroState = hasFutakuroElm();
  619. for (const mutation of mutations) {
  620. for (const addedNode of mutation.addedNodes) {
  621. if (!(addedNode instanceof HTMLElement)) continue;
  622. const newBlockQuotes = addedNode.querySelectorAll('blockquote');
  623. if (!futakuroState) {
  624. createAnchorLink(newBlockQuotes);
  625. deleteDuplicate(newBlockQuotes);
  626. }
  627. for (const newBlockQuote of newBlockQuotes) {
  628. const linkElms = newBlockQuote.querySelectorAll('a');
  629. if (linkElms.length) {
  630. setLinkObserver(linkElms);
  631. }
  632. }
  633. }
  634. }
  635. };
  636. // ふたクロが無い環境用にアンカーリンクを生成したい
  637. const exec = () => {
  638. const threadElm = document.querySelector('.thre');
  639. const isTsumanne = location.hostname === 'tsumanne.net';
  640. const isFutakuro = location.hostname === 'kako.futakuro.com';
  641. if (!isTsumanne && !isFutakuro && !hasFutakuroElm() && !initExecCreateLink && threadElm instanceof HTMLElement) {
  642. const quoteElms = threadElm.querySelectorAll('blockquote');
  643. initExecCreateLink = true;
  644. if (initTimer) {
  645. clearTimeout(initTimer);
  646. }
  647. createAnchorLink(quoteElms);
  648. for (const quoteElm of quoteElms) {
  649. const linkElms = quoteElm.querySelectorAll('.is-createLink');
  650. setLinkObserver(linkElms);
  651. }
  652. }
  653. };
  654. const threadElm = document.querySelector('.thre');
  655. if (threadElm instanceof HTMLElement) {
  656. setSetting();
  657. const linkElms = getLinkElm(threadElm);
  658. setLinkObserver(linkElms);
  659. const observer = new MutationObserver(mutationLinkElements);
  660. observer.observe(threadElm, {
  661. childList: true,
  662. subtree: true,
  663. });
  664. initTimer = setTimeout(exec, 1500);
  665. }
  666. })();

QingJ © 2025

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