// ==UserScript==
// @name ejchan
// @namespace ejchan-fix
// @version 1.8
// @description Фиксы и улучшения для Ежчана: всплывающие посты под ссылкой (прокручиваются вместе со страницей); запоминание громкости и звука видео (Firefox и Chrome); плавающий просмотр картинок и видео (перетаскивание, масштаб колесом, листание стрелками); правильный счётчик новых постов в заголовке вкладки; встраивание картинок, видео и аудио по ссылкам catbox/litterbox прямо в посты; заливка файлов на litterbox (срок 1ч–3 дня) со вставкой ссылок в пост — для постинга без пасскода
// @match *://ejchan.net/*
// @match *://*.ejchan.net/*
// @match *://ejchan.site/*
// @match *://*.ejchan.site/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ---- настройки ----
const CENTER_CAP = 40; // на сколько px правее левого края ссылки начинается пост (максимум; обычно середина ссылки)
const OFFSET_Y = 0; // зазор между нижним краем ссылки и верхом поста
const MARGIN = 8; // минимальный отступ от краёв окна
const SELECTOR_LINK = 'a, .post__ref';
let lastLink = null; // последняя ссылка, на которую наводили курсор
const anchors = new WeakMap(); // всплывающий пост -> ссылка-источник
document.addEventListener('mouseover', (e) => {
const t = e.target;
const a = t && t.closest ? t.closest(SELECTOR_LINK) : null;
if (a) lastLink = a;
}, true);
// Где в координатах окна находится точка (0,0) для absolute-позиционирования этого элемента
function origin(pv) {
const op = pv.offsetParent;
if (
op &&
op !== document.documentElement &&
(op !== document.body || getComputedStyle(op).position !== 'static')
) {
const pr = op.getBoundingClientRect();
return {
x: pr.left + op.clientLeft - op.scrollLeft,
y: pr.top + op.clientTop - op.scrollTop,
};
}
return { x: -window.scrollX, y: -window.scrollY };
}
function place(pv) {
const a = anchors.get(pv);
if (!a || !a.isConnected) return;
const r = a.getBoundingClientRect();
const s = pv.style;
// absolute вместо fixed: пост прокручивается вместе со страницей
s.setProperty('position', 'absolute', 'important');
s.setProperty('right', 'auto', 'important');
s.setProperty('bottom', 'auto', 'important');
s.setProperty('margin', '0', 'important');
s.setProperty('transform', 'none', 'important');
const w = pv.offsetWidth;
const vw = document.documentElement.clientWidth;
let x = r.left + Math.min(r.width / 2, CENTER_CAP);
if (w) x = Math.min(x, vw - w - MARGIN); // не вылезать за правый край окна
x = Math.max(MARGIN, x);
const y = r.bottom + OFFSET_Y;
const o = origin(pv);
s.setProperty('left', (x - o.x) + 'px', 'important');
s.setProperty('top', (y - o.y) + 'px', 'important');
}
const mo = new MutationObserver((records) => {
const todo = new Set();
for (const m of records) {
if (m.type === 'childList') {
m.addedNodes.forEach((n) => {
if (n.nodeType !== 1) return;
const list = [];
if (n.matches('.pv')) list.push(n);
n.querySelectorAll('.pv').forEach((el) => list.push(el));
list.forEach((pv) => {
anchors.set(pv, lastLink); // новый пост — запоминаем ссылку-источник
todo.add(pv);
});
});
} else if (m.type === 'attributes') {
const t = m.target;
if (t.classList && t.classList.contains('pv')) {
if (!anchors.has(t)) anchors.set(t, lastLink);
todo.add(t); // сайт перекинул пост по своим координатам — возвращаем на место
}
}
}
todo.forEach(place);
mo.takeRecords(); // выкидываем записи о наших собственных изменениях, чтобы не зациклиться
});
mo.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
});
})();
// =====================================================================
// Запоминание громкости видео (mp4, webm и т.д.)
// =====================================================================
(function () {
'use strict';
if (!/(^|\.)ejchan\.(net|site)$/i.test(location.hostname)) return;
const VOL_KEY = 'ejchan-us-video-volume';
function load() {
try {
const v = JSON.parse(localStorage.getItem(VOL_KEY) || 'null');
if (v && Number.isFinite(v.volume)) return v;
} catch (e) { /* ignore */ }
return null;
}
function save(v) {
try {
localStorage.setItem(VOL_KEY, JSON.stringify({ volume: v.volume, muted: v.muted }));
} catch (e) { /* ignore */ }
}
// Состояние по каждому видео:
// exp — значения, которые выставили мы (чтобы отличить свой volumechange от пользовательского)
// user — пользователь уже менял громкость на этом видео (больше не перебиваем)
const state = new WeakMap();
const st = (v) => {
let s = state.get(v);
if (!s) { s = { exp: null, user: false }; state.set(v, s); }
return s;
};
// превью видео из встраивания catbox и формы постинга — немые миниатюры, не трогаем
const skip = (v) => !v || v.tagName !== 'VIDEO' ||
!!(v.closest && v.closest('.ejcb, .pf__preview, .pf__form'));
const same = (a, b) => Math.abs(a - b) < 0.005;
function apply(v) {
if (skip(v)) return;
const s = st(v);
if (s.user) return;
const saved = load();
if (!saved) return;
const vol = Math.min(1, Math.max(0, saved.volume));
const muted = !!saved.muted;
s.exp = { volume: vol, muted };
if (!same(v.volume, vol)) v.volume = vol;
if (v.muted !== muted) {
try { v.muted = muted; } catch (e) { /* ignore */ }
}
}
// volumechange не всплывает — ловим на фазе перехвата (window, чтобы точно успеть раньше сайта)
window.addEventListener('volumechange', (e) => {
const v = e.target;
if (skip(v)) return;
const s = st(v);
// это событие от нашей же установки — пропускаем
if (s.exp && !s.user && same(v.volume, s.exp.volume) && v.muted === s.exp.muted) return;
// в Chrome при блокировке автовоспроизведения со звуком браузер сам глушит видео —
// такое "выключение звука" без жеста пользователя не сохраняем.
// Проверка только там, где есть userActivation (старый Firefox ESR его не знает).
const ua = navigator.userActivation;
if (ua && !ua.isActive && v.muted && s.exp && !s.exp.muted) return;
s.user = true;
save(v);
}, true);
// Chrome сбрасывает/переигрывает состояние на разных стадиях загрузки, а часть событий
// успевает пройти до вставки элемента в DOM, поэтому применяем на нескольких стадиях.
['loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'play', 'playing'].forEach((type) => {
window.addEventListener(type, (e) => {
const v = e.target;
if (skip(v)) return;
if (type === 'loadstart') st(v).user = false; // новый файл — снова применяем сохранённое
apply(v);
}, true);
});
// Страховка: перед первым взаимодействием с плеером ещё раз выставляем сохранённое
// (если Chrome успел сбросить громкость до этого момента).
window.addEventListener('pointerdown', (e) => {
const v = e.target && e.target.closest ? e.target.closest('video') : null;
if (skip(v)) return;
apply(v);
}, true);
function scanNode(n) {
if (n.nodeType !== 1) return;
if (n.tagName === 'VIDEO') apply(n);
else if (n.querySelectorAll) n.querySelectorAll('video').forEach(apply);
}
// видео, появившиеся в DOM (+ повтор в следующем кадре: Svelte выставляет src уже после вставки)
new MutationObserver((records) => {
for (const m of records) m.addedNodes.forEach((n) => {
scanNode(n);
setTimeout(() => scanNode(n), 0);
});
}).observe(document.documentElement, { childList: true, subtree: true });
document.querySelectorAll('video').forEach(apply);
})();
// =====================================================================
// Плавающий просмотр картинок и видео: перетаскивание мышью, масштаб колесом
// =====================================================================
(function () {
'use strict';
if (!/(^|\.)ejchan\.(net|site)$/i.test(location.hostname)) return;
// ---- настройки ----
const FIT = 0.7; // начальный размер: не больше этой доли окна (и не больше оригинала)
const ZOOM_STEP = 1.15; // шаг масштаба колесом
const MIN_SIZE = 40; // минимальный размер стороны, px
const MAX_SCALE = 10; // максимум относительно оригинального размера
const DRAG_THRESHOLD = 3; // px — меньше считается кликом (закрывает картинку)
const CONTROLS_H = 48; // px снизу у видео — зона плеера, там не перетаскиваем
const style = document.createElement('style');
style.textContent = `
.ejv {
position: fixed; z-index: 2147483000; margin: 0; padding: 0;
box-sizing: content-box; cursor: move; touch-action: none;
user-select: none; -webkit-user-select: none;
background: rgba(0,0,0,.2) center / 100% 100% no-repeat;
box-shadow: 0 4px 24px rgba(0,0,0,.6);
outline: 1px solid rgba(255,255,255,.25);
}
.ejv img {
display: block; width: 100%; height: 100%;
max-width: none; max-height: none; margin: 0; border: 0;
user-select: none; -webkit-user-select: none; -webkit-user-drag: none;
}
.ejv__info {
position: absolute; left: 0; bottom: 0; padding: 2px 6px;
font: 12px/1.4 sans-serif; color: #fff; background: rgba(0,0,0,.6);
pointer-events: none; opacity: 0; transition: opacity .15s;
white-space: nowrap;
}
.ejv:hover .ejv__info { opacity: 1; }
.ejv--video { background: #000; }
.ejv video {
display: block; width: 100%; height: 100%;
max-width: none; max-height: none; margin: 0; border: 0;
object-fit: contain; background: #000;
}
.ejv--video .ejv__info { top: 0; bottom: auto; z-index: 2; }
.ejv__drag {
position: absolute; left: 0; top: 0; right: 0;
bottom: min(${CONTROLS_H}px, 40%);
z-index: 1; cursor: move; touch-action: none; background: transparent;
}
`;
(document.head || document.documentElement).appendChild(style);
let cur = null; // { el, img, info, link, nw, nh, x, y, w, h, video }
const galType = (a) => a.dataset.galType || 'image';
function isImageLink(a) {
return galType(a) === 'image';
}
function isMediaLink(a) {
const t = galType(a);
return t === 'image' || t === 'video';
}
function render() {
if (!cur) return;
const s = cur.el.style;
s.left = cur.x + 'px';
s.top = cur.y + 'px';
s.width = cur.w + 'px';
s.height = cur.h + 'px';
if (cur.nw) {
cur.info.textContent = `${cur.nw}×${cur.nh} · ${Math.round(cur.w / cur.nw * 100)}%`;
}
}
function layout() {
if (!cur) return;
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
const k = Math.min(1, (FIT * vw) / cur.nw, (FIT * vh) / cur.nh);
cur.w = Math.max(1, cur.nw * k);
cur.h = Math.max(1, cur.nh * k);
cur.x = (vw - cur.w) / 2;
cur.y = (vh - cur.h) / 2;
cur.el.style.visibility = '';
render();
}
function close() {
if (!cur) return;
if (cur.video) {
// останавливаем и прерываем загрузку
try { cur.video.pause(); cur.video.removeAttribute('src'); cur.video.load(); } catch (e) { /* ignore */ }
}
cur.el.remove();
cur = null;
}
function openVideo(a) {
const src = a.getAttribute('href');
const thumbEl = a.querySelector('img');
const thumb = thumbEl ? thumbEl.getAttribute('src') : '';
const nw = +a.dataset.galW || 0;
const nh = +a.dataset.galH || 0;
const el = document.createElement('div');
el.className = 'ejv ejv--video';
const video = document.createElement('video');
video.controls = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.preload = 'auto';
if (thumb) video.poster = thumb;
const info = document.createElement('div');
info.className = 'ejv__info';
// прозрачный слой поверх видео (кроме панели управления): за него тащим окно.
// Без него Firefox начинает нативный drag-n-drop <video> и сам ставит видео на паузу.
const handle = document.createElement('div');
handle.className = 'ejv__drag';
handle.title = 'Тянуть — переместить, клик — пауза, двойной клик — полный экран, колесо — масштаб';
el.appendChild(video);
el.appendChild(handle);
el.appendChild(info);
cur = { el, img: null, video, info, link: a, nw, nh, x: 0, y: 0, w: 0, h: 0 };
const me = cur;
video.addEventListener('loadedmetadata', () => {
if (cur !== me) return;
const vw = video.videoWidth, vh = video.videoHeight;
if (vw && vh && (!me.nw || !me.nh || Math.abs(vw / vh - me.nw / me.nh) > 0.02)) {
me.nw = vw;
me.nh = vh;
layout();
}
}, { once: true });
video.addEventListener('error', () => {
if (cur !== me) return;
if (!me.nw || !me.nh) { me.nw = 480; me.nh = 270; layout(); }
}, { once: true });
bindInteractions(el, handle, () => {
if (video.paused) {
const p = video.play();
if (p && p.catch) p.catch(() => {});
} else {
video.pause();
}
});
handle.addEventListener('dblclick', (e) => {
e.preventDefault();
e.stopPropagation();
try {
if (document.fullscreenElement) document.exitFullscreen();
else if (video.requestFullscreen) video.requestFullscreen();
} catch (err) { /* ignore */ }
});
document.body.appendChild(el);
if (nw && nh) layout();
else el.style.visibility = 'hidden'; // ждём метаданные
video.src = src;
const p = video.play();
if (p && p.catch) p.catch(() => { /* автозапуск заблокирован — пользователь нажмёт play */ });
}
function open(a) {
if (cur && cur.link === a) { close(); return; } // повторный клик по той же миниатюре — закрыть
close();
if (galType(a) === 'video') { openVideo(a); return; }
const src = a.getAttribute('href');
const thumbEl = a.querySelector('img');
const thumb = thumbEl ? thumbEl.getAttribute('src') : '';
const nw = +a.dataset.galW || 0;
const nh = +a.dataset.galH || 0;
const el = document.createElement('div');
el.className = 'ejv';
if (thumb) el.style.backgroundImage = `url("${thumb.replace(/"/g, '%22')}")`;
const img = document.createElement('img');
img.alt = '';
img.draggable = false;
img.decoding = 'async';
if (a.dataset.ejExt) img.referrerPolicy = 'no-referrer'; // внешние хостинги (catbox)
const info = document.createElement('div');
info.className = 'ejv__info';
el.appendChild(img);
el.appendChild(info);
cur = { el, img, video: null, info, link: a, nw, nh, x: 0, y: 0, w: 0, h: 0 };
const me = cur;
img.addEventListener('load', () => {
el.style.backgroundImage = 'none'; // убираем миниатюру (для прозрачных картинок)
if (cur !== me) return;
if (!me.nw || !me.nh) {
me.nw = img.naturalWidth || 300;
me.nh = img.naturalHeight || 300;
layout();
}
}, { once: true });
img.addEventListener('error', () => {
if (cur !== me) return;
if (!me.nw || !me.nh) { me.nw = 300; me.nh = 300; layout(); }
}, { once: true });
bindInteractions(el, el, close);
document.body.appendChild(el);
if (nw && nh) layout();
else el.style.visibility = 'hidden'; // ждём, пока станет известен размер
img.src = src;
}
// el — окно (масштаб колесом), handle — за что тащим, onClick — клик без перетаскивания
function bindInteractions(el, handle, onClick) {
// масштаб колесом относительно курсора
el.addEventListener('wheel', (e) => {
if (!cur || cur.el !== el) return;
e.preventDefault();
e.stopPropagation();
const f = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
let nw = cur.w * f;
const maxW = Math.max(cur.nw * MAX_SCALE, MIN_SIZE);
if (nw > maxW) nw = maxW;
let nh = nw * (cur.nh / cur.nw);
if (f < 1 && Math.max(nw, nh) < MIN_SIZE) return;
const real = nw / cur.w;
if (real === 1) return;
cur.x = e.clientX - (e.clientX - cur.x) * real;
cur.y = e.clientY - (e.clientY - cur.y) * real;
cur.w = nw;
cur.h = nh;
render();
}, { passive: false });
// перетаскивание за handle; клик без перетаскивания — onClick
let drag = null;
handle.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || !cur) return;
e.preventDefault();
drag = { id: e.pointerId, sx: e.clientX, sy: e.clientY, ox: cur.x, oy: cur.y, moved: false };
try { handle.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
});
handle.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id || !cur) return;
const dx = e.clientX - drag.sx;
const dy = e.clientY - drag.sy;
if (!drag.moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) drag.moved = true;
if (drag.moved) {
cur.x = drag.ox + dx;
cur.y = drag.oy + dy;
render();
}
});
let wasClick = false;
const end = (e) => {
if (!drag || e.pointerId !== drag.id) return;
wasClick = !drag.moved && e.type === 'pointerup';
drag = null;
try { handle.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
};
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
el.addEventListener('dragstart', (e) => e.preventDefault());
// реагируем именно на click, чтобы он не "провалился" на миниатюру под окном
handle.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (wasClick) onClick();
wasClick = false;
});
// клики по панели управления видео не должны долетать до страницы
if (handle !== el) el.addEventListener('click', (e) => e.stopPropagation());
}
function navigate(dir) {
if (!cur) return;
const list = [...document.querySelectorAll('a[data-gallery]')]
.filter((x) => !x.closest('.pv') && isMediaLink(x));
if (!list.length) return;
let i = list.indexOf(cur.link);
if (i === -1) {
const href = cur.link.getAttribute('href');
i = list.findIndex((x) => x.getAttribute('href') === href);
}
const next = list[((i === -1 ? 0 : i + dir) + list.length) % list.length];
if (next && next !== cur.link) open(next);
}
// перехватываем открытие картинок и видео до встроенной галереи сайта
window.addEventListener('click', (e) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target && e.target.closest ? e.target.closest('a[data-gallery]') : null;
if (!a) return;
if (!isMediaLink(a)) { close(); return; } // iframe-встраивания (YouTube и т.п.) — пусть открывает сайт
e.preventDefault();
e.stopPropagation();
open(a);
}, true);
window.addEventListener('keydown', (e) => {
if (!cur) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
// фокус на плеере — стрелки перематывают видео, не трогаем их
if (t && t.tagName === 'VIDEO' && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return;
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); e.stopPropagation(); navigate(-1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); navigate(1); }
}, true);
window.addEventListener('popstate', close);
// клик где-либо на странице вне окна — закрыть.
// Переключение вкладок/окон событий мыши на странице не даёт, поэтому окно там не закроется.
window.addEventListener('pointerdown', (e) => {
if (!cur || e.button !== 0) return;
const t = e.target;
if (!t || !t.closest) return;
if (cur.el.contains(t)) return; // внутри окна
if (t.closest('a[data-gallery]')) return; // миниатюры: открытие другой / закрытие той же решает обработчик click
// клик по полосе прокрутки страницы
const de = document.documentElement;
if (e.clientX >= de.clientWidth || e.clientY >= de.clientHeight) return;
close();
}, true);
})();
// =====================================================================
// Счётчик новых постов во вкладке: "(N) Тред" показывает только посты,
// пришедшие, пока вкладка была в фоне. Сбрасывается при возврате на вкладку.
// =====================================================================
(function () {
'use strict';
if (!/(^|\.)ejchan\.(net|site)$/i.test(location.hostname)) return;
const PREFIX_RE = /^\(\d+\)\s+/; // префикс, который ставит сайт (и мы)
let baseline = null; // номер последнего поста на момент ухода со вкладки
const isThread = () => /\/res\/\d+\.html/.test(location.pathname);
function posts() {
const res = [];
document.querySelectorAll('article.post[id^="p"]').forEach((el) => {
if (el.closest('.pv')) return; // всплывающие превью не считаем
const no = Number(el.id.slice(1));
if (Number.isFinite(no)) res.push({ el, no });
});
return res;
}
function maxNo() {
let m = 0;
for (const p of posts()) if (p.no > m) m = p.no;
return m;
}
function countNew() {
if (baseline == null) return 0;
let n = 0;
for (const p of posts()) {
if (p.no > baseline && !p.el.classList.contains('post--mine')) n++;
}
return n;
}
let busy = false;
function update() {
if (!isThread() || busy) return;
const base = document.title.replace(PREFIX_RE, '');
const n = document.hidden ? countNew() : 0;
const want = (n > 0 ? `(${n}) ` : '') + base;
if (document.title !== want) {
busy = true;
document.title = want;
busy = false;
}
}
let timer = null;
function schedule() {
if (timer) return;
// setTimeout, а не requestAnimationFrame: rAF не срабатывает в фоновых вкладках
timer = setTimeout(() => { timer = null; update(); }, 50);
}
document.addEventListener('visibilitychange', () => {
baseline = document.hidden ? maxNo() : null;
update();
});
// сайт переписывает заголовок своим (неправильным) числом — сразу исправляем
new MutationObserver(update).observe(document.head || document.documentElement, {
childList: true, subtree: true, characterData: true,
});
// новые посты в DOM
new MutationObserver(schedule).observe(document.body || document.documentElement, {
childList: true, subtree: true,
});
if (document.hidden) baseline = maxNo();
update();
})();
// =====================================================================
// Встраивание файлов с catbox.moe / litterbox (litter.catbox.moe) прямо в пост
// =====================================================================
(function () {
'use strict';
if (!/(^|\.)ejchan\.(net|site)$/i.test(location.hostname)) return;
// ---- настройки ----
const MAX_PER_POST = 10; // максимум встраиваемых файлов в одном посте
const THUMB = 200; // максимальный размер миниатюры, px
const HOST_RE = /^(?:[a-z0-9-]+\.)*(?:catbox\.moe|litterbox\.moe)$/i;
const IMG_RE = /\.(png|jpe?g|gif|webp|avif|bmp)$/i;
const VID_RE = /\.(mp4|webm|mov|m4v|ogv)$/i;
const AUD_RE = /\.(mp3|ogg|oga|opus|wav|flac|m4a)$/i;
const style = document.createElement('style');
style.textContent = `
.ejcb { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.ejcb__item {
position: relative; display: block; line-height: 0;
max-width: ${THUMB}px; max-height: ${THUMB}px;
border-radius: var(--radius, 4px); overflow: hidden;
background: rgba(0,0,0,.15); text-decoration: none !important;
}
.ejcb__item img, .ejcb__item video {
display: block; max-width: ${THUMB}px; max-height: ${THUMB}px;
width: auto; height: auto; object-fit: contain; pointer-events: none;
}
.ejcb__item video { min-width: 80px; min-height: 60px; background: #000; }
.ejcb__badge {
position: absolute; right: 3px; bottom: 3px; padding: 1px 4px;
font: 11px/1.3 sans-serif; color: #fff; background: rgba(0,0,0,.65);
border-radius: 3px; pointer-events: none;
}
.ejcb__play {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
font: 28px/1 sans-serif; color: #fff; text-shadow: 0 0 6px #000; pointer-events: none;
}
.ejcb__fail {
display: inline-block; padding: 6px 8px; line-height: 1.3;
font-size: 12px; opacity: .7; border: 1px dashed currentColor;
border-radius: var(--radius, 4px);
}
.ejcb__audio { display: block; max-width: 100%; width: 320px; }
`;
(document.head || document.documentElement).appendChild(style);
function classify(href) {
if (!href) return null;
let u;
try { u = new URL(href, location.href); } catch (e) { return null; }
if (!/^https?:$/.test(u.protocol) || !HOST_RE.test(u.hostname)) return null;
const p = u.pathname;
const ext = (p.match(/\.([a-z0-9]+)$/i) || [, ''])[1].toLowerCase();
if (IMG_RE.test(p)) return { type: 'image', url: u.href, ext };
if (VID_RE.test(p)) return { type: 'video', url: u.href, ext };
if (AUD_RE.test(p)) return { type: 'audio', url: u.href, ext };
return null;
}
function fail(node, it) {
const s = document.createElement('a');
s.className = 'ejcb__fail';
s.href = it.url;
s.target = '_blank';
s.rel = 'noreferrer';
s.textContent = `✕ файл недоступен (${it.ext})`;
s.title = 'Файл удалён или срок хранения истёк';
node.replaceWith(s);
}
function badge(text) {
const b = document.createElement('span');
b.className = 'ejcb__badge';
b.textContent = text;
return b;
}
function makeItem(it) {
if (it.type === 'audio') {
const au = document.createElement('audio');
au.className = 'ejcb__audio';
au.controls = true;
au.preload = 'none';
au.src = it.url;
return au;
}
const a = document.createElement('a');
a.className = 'ejcb__item';
a.href = it.url;
a.target = '_blank';
a.rel = 'noreferrer';
a.title = it.url;
// data-gallery: картинки открываются плавающим просмотрщиком, видео — галереей сайта
a.dataset.gallery = '';
a.dataset.galType = it.type;
a.dataset.ejExt = '1';
if (it.type === 'image') {
const img = document.createElement('img');
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
img.draggable = false;
img.referrerPolicy = 'no-referrer';
img.addEventListener('load', () => {
if (img.naturalWidth && img.naturalHeight) {
a.dataset.galW = img.naturalWidth;
a.dataset.galH = img.naturalHeight;
}
}, { once: true });
img.addEventListener('error', () => fail(a, it), { once: true });
img.src = it.url;
a.appendChild(img);
} else {
const v = document.createElement('video');
v.muted = true;
v.preload = 'metadata';
v.playsInline = true;
v.addEventListener('error', () => fail(a, it), { once: true });
v.src = it.url + '#t=0.1';
a.appendChild(v);
const play = document.createElement('span');
play.className = 'ejcb__play';
play.textContent = '▶';
a.appendChild(play);
}
a.appendChild(badge(it.ext));
return a;
}
function render(body) {
const items = [];
const seen = new Set();
body.querySelectorAll('a[href]').forEach((a) => {
if (a.closest('.ejcb')) return;
const it = classify(a.getAttribute('href'));
if (!it || seen.has(it.url) || items.length >= MAX_PER_POST) return;
seen.add(it.url);
items.push(it);
});
const key = items.map((i) => i.url).join('\n');
let box = null;
for (const c of body.children) if (c.classList.contains('ejcb')) { box = c; break; }
if (!items.length) { if (box) box.remove(); return; }
if (box && box.dataset.key === key) return; // уже отрисовано
if (!box) {
box = document.createElement('div');
box.className = 'ejcb';
body.appendChild(box);
}
box.dataset.key = key;
box.textContent = '';
items.forEach((it) => box.appendChild(makeItem(it)));
}
function scan() {
const bodies = new Set();
document.querySelectorAll('.post__body a[href]').forEach((a) => {
if (a.closest('.ejcb')) return;
if (classify(a.getAttribute('href'))) bodies.add(a.closest('.post__body'));
});
// посты, где ссылки пропали (редактирование) — убрать старые встраивания
document.querySelectorAll('.post__body > .ejcb').forEach((c) => bodies.add(c.parentElement));
bodies.forEach((b) => b && render(b));
}
let timer = null;
function schedule() {
if (timer) return;
timer = setTimeout(() => { timer = null; scan(); }, 100);
}
new MutationObserver(schedule).observe(document.body || document.documentElement, {
childList: true, subtree: true,
});
scan();
})();
// =====================================================================
// Заливка файлов на litterbox вместо прикрепления (для постинга без пасскода).
// Галочка в форме ответа; при включённой галочке выбранные / перетащенные /
// вставленные файлы улетают на litterbox, а ссылки дописываются в текст поста.
// =====================================================================
(function () {
'use strict';
if (!/(^|\.)ejchan\.(net|site)$/i.test(location.hostname)) return;
const API = 'https://litterbox.catbox.moe/resources/internals/api.php';
const KEY_ON = 'ejchan-us-litter-on';
const KEY_TIME = 'ejchan-us-litter-time';
const TIMES = [['1h', '1 час'], ['12h', '12 часов'], ['24h', '1 день'], ['72h', '3 дня']];
const MAX_BYTES = 1024 * 1024 * 1024; // лимит litterbox — 1 ГБ
const get = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch (e) { return d; } };
const set = (k, v) => { try { localStorage.setItem(k, v); } catch (e) { /* ignore */ } };
const isOn = () => get(KEY_ON, '0') === '1';
const lifetime = () => {
const t = get(KEY_TIME, '72h');
return TIMES.some(([v]) => v === t) ? t : '72h';
};
const style = document.createElement('style');
style.textContent = `
.ejlb { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin: 6px 0; font-size: .9em; }
.ejlb label { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; }
.ejlb select { font: inherit; padding: 1px 4px; }
.ejlb__status { width: 100%; font-size: .9em; opacity: .85; white-space: pre-wrap; word-break: break-all; }
.ejlb__status--err { color: #ff6b6b; opacity: 1; }
.pf__form.ejlb-on .pf__drop { outline: 2px dashed #6fb3ff; outline-offset: -2px; }
`;
(document.head || document.documentElement).appendChild(style);
// ---- UI ----
function ensureUI(form) {
if (form.querySelector('.ejlb')) return;
const box = document.createElement('div');
box.className = 'ejlb';
box.dataset.nodrag = '';
const lab = document.createElement('label');
lab.title = 'Файлы не прикрепляются к посту, а заливаются на litterbox.catbox.moe; ссылки дописываются в текст';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = isOn();
lab.appendChild(cb);
lab.appendChild(document.createTextNode('Заливать файлы на litterbox'));
const sel = document.createElement('select');
sel.title = 'Срок хранения на litterbox';
TIMES.forEach(([v, t]) => {
const o = document.createElement('option');
o.value = v;
o.textContent = t;
sel.appendChild(o);
});
sel.value = lifetime();
const status = document.createElement('div');
status.className = 'ejlb__status';
status.hidden = true;
const sync = () => {
sel.disabled = !cb.checked;
form.classList.toggle('ejlb-on', cb.checked);
// текст в зоне файлов
const hint = form.querySelector('.pf__drop-hint');
if (hint) hint.title = cb.checked ? 'Файлы будут залиты на litterbox' : '';
};
cb.addEventListener('change', (e) => {
e.stopPropagation();
set(KEY_ON, cb.checked ? '1' : '0');
sync();
});
sel.addEventListener('change', (e) => {
e.stopPropagation();
set(KEY_TIME, sel.value);
});
sync();
box.appendChild(lab);
box.appendChild(sel);
box.appendChild(status);
const anchor = form.querySelector('.pf__drop');
if (anchor) anchor.parentNode.insertBefore(box, anchor);
else form.appendChild(box);
}
function formOn(form) {
const cb = form && form.querySelector('.ejlb input[type="checkbox"]');
return !!(cb && cb.checked);
}
function setStatus(form, text, err) {
const st = form.querySelector('.ejlb__status');
if (!st) return;
st.hidden = !text;
st.textContent = text || '';
st.classList.toggle('ejlb__status--err', !!err);
}
// ---- вставка ссылок в текст поста (так, чтобы Svelte увидел изменение) ----
function appendText(form, text) {
const ta = form.querySelector('textarea.pf__body');
if (!ta) return false;
const cur = ta.value;
const add = (cur && !/\n$/.test(cur) ? '\n' : '') + text;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
setter.call(ta, cur + add);
ta.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
// ---- загрузка ----
// ВАЖНО: никаких обработчиков на x.upload — их наличие заставляет браузер слать
// CORS-preflight (OPTIONS), а litterbox отвечает на него 405. Без них multipart POST —
// «простой» запрос, preflight не нужен, а ответ разрешён (Access-Control-Allow-Origin: *).
function upload(file, time) {
return new Promise((resolve, reject) => {
const fd = new FormData();
fd.append('reqtype', 'fileupload');
fd.append('time', time);
fd.append('fileToUpload', file, file.name || 'file');
const x = new XMLHttpRequest();
x.open('POST', API);
x.timeout = 10 * 60 * 1000;
x.onload = () => {
const r = (x.responseText || '').trim();
if (x.status >= 200 && x.status < 300 && /^https:\/\/\S+$/.test(r)) resolve(r);
else reject(new Error(r.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').slice(0, 150) || `HTTP ${x.status}`));
};
x.onerror = () => reject(new Error('сеть недоступна (catbox заблокирован?)'));
x.ontimeout = () => reject(new Error('превышено время ожидания'));
x.send(fd);
});
}
const queues = new WeakMap(); // форма -> Promise (заливаем по очереди)
function handleFiles(form, files) {
files = Array.from(files || []).filter(Boolean);
if (!files.length) return;
const time = lifetime();
const prev = queues.get(form) || Promise.resolve();
const job = prev.then(async () => {
const errors = [];
for (let i = 0; i < files.length; i++) {
const f = files[i];
const name = f.name || 'файл';
if (f.size > MAX_BYTES) { errors.push(`«${name}»: больше 1 ГБ`); continue; }
const mb = (f.size / 1048576).toFixed(1);
setStatus(form, `Litterbox: заливается ${i + 1}/${files.length} «${name}» (${mb} МБ)…`);
try {
const url = await upload(f, time);
if (!form.isConnected || !appendText(form, url)) {
errors.push(`форма закрыта, ссылка: ${url}`);
}
} catch (e) {
errors.push(`«${name}»: ${e.message}`);
}
}
if (!form.isConnected) return;
if (errors.length) setStatus(form, 'Litterbox, ошибки:\n' + errors.join('\n'), true);
else setStatus(form, `Litterbox: залито ${files.length} шт., ссылки добавлены в текст`);
});
queues.set(form, job.catch(() => {}));
setStatus(form, `Litterbox: подготовка ${files.length} шт.…`);
}
// ---- перехват файлов до обработчиков сайта (фаза перехвата на window) ----
window.addEventListener('change', (e) => {
const inp = e.target;
if (!inp || !inp.matches || !inp.matches('.pf__file-input')) return;
const form = inp.closest('.pf__form');
if (!formOn(form)) return;
e.stopImmediatePropagation();
e.stopPropagation();
const files = Array.from(inp.files || []);
inp.value = '';
handleFiles(form, files);
}, true);
window.addEventListener('drop', (e) => {
const zone = e.target && e.target.closest ? e.target.closest('.pf__drop') : null;
const form = zone && zone.closest('.pf__form');
if (!formOn(form)) return;
const files = e.dataTransfer && e.dataTransfer.files;
if (!files || !files.length) return;
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
handleFiles(form, files);
// сбросить подсветку зоны, которую сайт снимает в своём обработчике drop
zone.dispatchEvent(new Event('dragleave'));
}, true);
window.addEventListener('paste', (e) => {
const form = e.target && e.target.closest ? e.target.closest('.pf__form') : null;
if (!formOn(form)) return;
const files = Array.from((e.clipboardData && e.clipboardData.items) || [])
.filter((it) => it.kind === 'file')
.map((it) => it.getAsFile())
.filter(Boolean);
if (!files.length) return; // обычный текст вставляется как обычно
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
handleFiles(form, files);
}, true);
// ---- форма создаётся/пересоздаётся динамически ----
const attach = () => document.querySelectorAll('form.pf__form').forEach(ensureUI);
new MutationObserver(attach).observe(document.body || document.documentElement, {
childList: true, subtree: true,
});
attach();
})();