// ==UserScript==
// @name 8chan to 4chan Post Injector
// @version 2.4
// @description Injects 8chan.moe posts into 4chan threads by matching subject or text
// @match https://boards.4chan.org/*/thread/*
// @grant GM_xmlhttpRequest
// @connect 8chan.moe
// @connect a.4cdn.org
// @run-at document-idle
// @license CC0
// @namespace https://greasyfork.org/users/1463728
// ==/UserScript==
// !! REQUIRES 4CHANX TO WORK !!
(function() {
'use strict';
//console.log('[Injector] Script loaded at', window.location.href);
const boardMapping = {};
// highlight style
const style = document.createElement('style');
style.textContent = `
.nativeCell .post {
background-color: rgba(144, 238, 255, 0.15);
border: 1px solid rgba(144, 238, 255, 0.3);
}
.nativeCell .backlink { margin-right: 4px; }
.nativeCell .fileThumb {
float: left;
margin-left: 20px;
margin-right: 20px;
margin-top: 3px;
margin-bottom: 5px;
}
.nativeCell .fileThumb img {
display: block;
max-width: 250px; /* Optional: limit image width */
height: auto;
}
.fileContainer {
display: flex;
flex-wrap: wrap;
gap: 10px;
width: 100%;
clear: both;
margin-top: 8px;
}
`;
document.head.appendChild(style);
let lazyLoadObserver;
function initLazyLoadObserver() {
lazyLoadObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (!img.dataset.src) return;
const thumbUrl = img.dataset.src;
img.classList.remove('lazy-load');
// Remove src placeholder if exists
img.removeAttribute('src');
gmFetchDataUrl(thumbUrl)
.then(dataUrl => {
img.src = dataUrl;
return md5FromDataUrl(dataUrl)
.then(md5 => ({ md5 })) // wrap successful md5
.catch(() => ({ md5: btoa(thumbUrl) })); // fallback fake md5
})
.then(({ md5 }) => {
img.dataset.md5 = md5;
const thumbLink = img.closest('a.fileThumb');
if (thumbLink) thumbLink.dataset.md5 = md5;
})
.catch(console.error)
.finally(() => lazyLoadObserver.unobserve(img));
}
});
}, {
root: null,
rootMargin: '500px',
threshold: 0.01
});
}
function gmFetchJson(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({ method: 'GET', url, responseType: 'json',
onload: res => res.status >= 200 && res.status < 300 ? resolve(res.response) : reject(res),
onerror: reject
});
});
}
function gmFetchDataUrl(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer',
onload: res => {
if (res.status >= 200 && res.status < 300) {
const blob = new Blob([res.response]);
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
} else reject(res);
},
onerror: reject
});
});
}
function getBoardThread() {
const parts = window.location.pathname.split('/');
let board = parts[1];
if (boardMapping[board]) board = boardMapping[board];
const threadId = parts[3];
return { board, threadId };
}
// Don't search for references to boards
const BannedKeywords = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'gif', 'h', 'hr', 'k', 'm', 'o', 'p', 'r', 's', 't', 'u', 'v', 'vg', 'vm', 'vmg', 'vr', 'vrpg', 'vst', 'w', 'wg', 'i', 'ic',
'r9k', 's4s', 'vip', 'cm', 'hm', 'lgbt', 'y', '3', 'aco', 'adv', 'an', 'bant', 'biz', 'cgl', 'ck', 'co', 'diy', 'fa', 'fit', 'gd', 'hc', 'his', 'int', 'jp', 'lit', 'mlp', 'mu', 'n',
'news', 'out', 'po', 'pol', 'pw', 'sci', 'soc', 'sp', 'tg', 'toy', 'trv', 'tv', 'vp', 'vt', 'wsg', 'wsr', 'x', 'xs', ''];
function extractSlashTag(str) {
// Avoid matching filepaths by checking surrounding characters
const m = str.match(/(?<![:/])\/([A-Za-z0-9]+)\/(?![\/.])/);
if (!m) return null;
const tag = m[1].toLowerCase();
// Banning board names so we don't get a random thread if for example it talks about /v/ meta
return BannedKeywords.includes(tag) || !tag ? null : tag;
}
function cleanFallback(str) {
const title = str.toLowerCase().replace(/[^\w\s]/g, ' ').trim();
const m = title.match(/^(.*?\b(?:thread|general)\b)/);
return m ? m[1].trim() : title;
}
async function fetch4chanSearchKey(board, threadId) {
try {
const json = await gmFetchJson(`https://a.4cdn.org/${board}/thread/${threadId}.json`);
const op = json.posts?.[0];
if (!op) {
//console.error('[Injector] No OP post found in thread');
return null;
}
// raw subject & first line of message
const subjectRaw = op.sub?.trim() || '';
const msgHtml = op.com || '';
const msgText = msgHtml
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>|>>\d+/g, '') // strip tags & >>replies
.trim();
const firstLine = (msgText.split('\n')[0] || '').trim();
// 1) If either raw string has a /tag/, return it *with* slashes
const slashInSub = extractSlashTag(subjectRaw);
const slashInMsg = extractSlashTag(firstLine);
if (slashInSub || slashInMsg) {
const tag = slashInSub || slashInMsg;
return `/${tag}/`;
}
// 2) Otherwise your original fallback: subject or first line
let searchKey = subjectRaw;
if (!searchKey && firstLine) {
searchKey = firstLine.substring(0, 50).trim();
}
if (!searchKey) {
console.error('[Injector] No subject or valid message for thread', threadId);
return null;
}
const cleaned = cleanTitle(searchKey);
return cleaned || null;
} catch (error) {
console.error('[Injector] Error fetching 4chan OP:', error);
// === DOM fallback ===
const opDom = document.querySelector('.post.op');
if (!opDom) {
console.error('[Injector] No OP element found in DOM fallback');
return null;
}
const subjectEl = opDom.querySelector('.subject');
const fallbackRaw = (subjectEl?.textContent.trim())
|| opDom.textContent.split('\n')[0].trim();
// Try slash-tag here too
const slash = extractSlashTag(fallbackRaw);
if (slash) {
return `/${slash}/`;
}
const cleaned = cleanTitle(fallbackRaw);
return cleaned || null;
}
}
function findNativeThread(catalog, searchKeyRaw) {
if (!searchKeyRaw) {
//console.error('[Injector] Empty search key');
return null;
}
// first see if our 4chan OP had a /tag/
const slash = extractSlashTag(searchKeyRaw);
if (slash) {
for (const t of catalog) {
if (
extractSlashTag(t.subject || '') === slash ||
extractSlashTag(t.message || '') === slash
) return t.threadId;
}
}
// otherwise fall back to full-string matching
const key = cleanFallback(searchKeyRaw);
if (!key) {
//console.error('[Injector] Empty cleaned search key');
return null;
}
for (const t of catalog) {
if (
cleanFallback(t.subject || '') === key ||
cleanFallback(t.message || '') === key
) return t.threadId;
}
console.error('[Injector] No matching thread for', searchKeyRaw);
return null;
}
function cleanTitle(title) {
title = title.toLowerCase().replace(/[^\w\s]/g, ''); // Lowercase and remove special characters
const match = title.match(/^(.*?\b(?:thread|general)\b)/i);
if (match) {
return match[1].trim();
}
return title.trim();
}
async function md5(arrayBuffer) {
const hashBuffer = await crypto.subtle.digest('MD5', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
function formatDateUTC(ts) {
const d = new Date(ts);
const pad = n => String(n).padStart(2, '0');
const mm = pad(d.getUTCMonth()+1), dd = pad(d.getUTCDate()), yy = String(d.getUTCFullYear()).slice(2);
const wk = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getUTCDay()];
const hh = pad(d.getUTCHours()), mi = pad(d.getUTCMinutes()), ss = pad(d.getUTCSeconds());
return `${mm}/${dd}/${yy}(${wk})${hh}:${mi}:${ss}`;
}
async function buildNativechanPost(post, board) {
const pid = post.postId;
const utc = Math.floor(new Date(post.creation).getTime()/1000);
const ts = formatDateUTC(post.creation);
const cont = document.createElement('div');
cont.className = 'postContainer replyContainer nativeCell';
cont.id = `pc${pid}`;
cont.setAttribute('data-native-inject','true');
cont.setAttribute('data-full-i-d',`${board}.${pid}`);
const side = document.createElement('div');
side.className='sideArrows'; side.id=`sa${pid}`;
side.innerHTML='<a class="hide-reply-button" href="javascript:;"><span class="fa fa-minus-square-o"></span></a>';
cont.appendChild(side);
const p = document.createElement('div');
p.className='post reply'; p.id=`p${pid}`;
// mobile header
const m = document.createElement('div');
m.className='postInfoM mobile'; m.id=`pim${pid}`;
m.innerHTML=`<span class="nameBlock"><span class="name">${post.name||'Anonymous'}</span><br></span><span class="dateTime postNum" data-utc="${utc}">${ts} <a href="#p${pid}" title="Link to this post">No.</a><a href="javascript:quote('${pid}');" title="Reply to this post">${pid}</a></span>`;
p.appendChild(m);
// desktop header
const dDiv = document.createElement('div');
dDiv.className='postInfo desktop'; dDiv.id=`pi${pid}`;
dDiv.innerHTML=`<input type="checkbox" name="${pid}" value="delete"> <span class="nameBlock"><span class="name">${post.name||'Anonymous'}</span> </span> <span class="dateTime" data-utc="${utc}">${ts}</span> <span class="postNum desktop"><a href="#p${pid}" title="Link to this post">No.</a><a href="javascript:quote('${pid}');" title="Reply to this post">${pid}</a></span><span class="container"></span>`;
p.appendChild(dDiv);
// file
// ─── replace your file-rendering block with this ───
if (post.files?.length) {
const fileContainer = document.createElement('div');
fileContainer.className = 'fileContainer';
fileContainer.id = `f${pid}`;
fileContainer.style.display = 'flex';
fileContainer.style.flexWrap = 'wrap';
fileContainer.style.width = '100%';
fileContainer.style.clear = 'both';
fileContainer.style.marginTop = '8px';
fileContainer.style.paddingLeft = '20px';
p.appendChild(fileContainer);
for (let i = 0; i < post.files.length; i++) {
const f = post.files[i];
// Outer .file div
const fileCol = document.createElement('div');
fileCol.className = 'file';
fileCol.id = `f${pid}-${i}`;
fileCol.style.margin = '3px 5px 5px 5px';
fileCol.style.float = 'left';
// ——— FileText
const fileText = document.createElement('div');
fileText.className = 'fileText';
fileText.id = `fT${pid}-${i}`;
const fileInfo = document.createElement('span');
fileInfo.className = 'file-info';
const nameLinkWrapper = document.createElement('a');
nameLinkWrapper.href = `https://8chan.moe${f.path}`;
nameLinkWrapper.target = '_blank';
// ——— Full + truncated filename handling
const fileNameSwitch = document.createElement('span');
fileNameSwitch.className = 'fnswitch';
const fileNameTrunc = document.createElement('span');
fileNameTrunc.className = 'fntrunc';
fileNameTrunc.textContent = truncateName(f.originalName, 30);
const fileNameFull = document.createElement('span');
fileNameFull.className = 'fnfull';
fileNameFull.textContent = f.originalName;
fileNameFull.style.display = 'none'; // Hide full name by default
fileNameSwitch.appendChild(fileNameTrunc);
fileNameSwitch.appendChild(fileNameFull);
nameLinkWrapper.appendChild(fileNameSwitch);
// ——— Download link
const dlLink = document.createElement('a');
dlLink.href = `https://8chan.moe${f.path}`;
dlLink.download = f.originalName;
dlLink.className = 'fa fa-download download-button';
dlLink.style.marginLeft = '4px';
// ——— Size + dimensions info
const infoText = document.createTextNode(
` (${Math.round(f.size / 1024)} KB${f.width ? `, ${f.width}×${f.height}` : ''})`
);
// Assemble fileInfo
fileInfo.appendChild(nameLinkWrapper);
fileInfo.appendChild(dlLink);
fileInfo.appendChild(infoText);
fileText.appendChild(fileInfo);
// ——— Thumbnail
const thumbLink = document.createElement('a');
thumbLink.className = 'fileThumb';
thumbLink.href = `https://8chan.moe${f.path}`;
thumbLink.target = '_blank';
thumbLink.style.display = 'block';
thumbLink.style.margin = '3px 0px 5px 0px'; // (instead of any default 20px left/right)
thumbLink.style.padding = '0';
const img = document.createElement('img');
img.className = 'thumb lazy-load';
img.loading = 'lazy';
img.dataset.src = `https://8chan.moe${f.thumb}`;
img.alt = f.originalName;
img.style.maxHeight = '125px';
img.style.maxWidth = '125px';
img.style.height = 'auto';
img.style.width = 'auto';
img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
img.dataset.width = f.width;
img.dataset.height = f.height;
img.dataset.size = f.size;
// ——— Mobile File Info (shows size+type on hover for mobile)
const mFileInfo = document.createElement('div');
mFileInfo.className = 'mFileInfo mobile';
mFileInfo.textContent = `${Math.round(f.size / 1024)} KB ${f.originalName.split('.').pop().toUpperCase()}`; // "11 KB PNG"
thumbLink.appendChild(img);
thumbLink.appendChild(mFileInfo);
// ——— Final Assembly
fileCol.appendChild(fileText); // text on top
fileCol.appendChild(thumbLink); // thumbnail below
fileContainer.appendChild(fileCol);
lazyLoadObserver.observe(img);
}
}
const msg = document.createElement('blockquote');
msg.className = 'postMessage';
msg.id = `m${pid}`;
// First process the content to convert greenText to quotes
const processedContent = processNativePost(post.markdown || post.message || '');
msg.innerHTML = processedContent;
p.appendChild(msg);
Array.from(msg.querySelectorAll('a.quotelink, a.quoteLink')).forEach(a => {
const href = a.getAttribute('href');
if (href && href.includes('#')) {
const quotedPid = href.split('#').pop();
// Set 4chanX attributes
a.className = 'quotelink';
a.setAttribute('href', `#p${quotedPid}`);
a.setAttribute('data-post', quotedPid);
a.setAttribute('data-board', board);
a.setAttribute('title', 'Reply to this post');
if (a.textContent.startsWith('>>')) {
a.textContent = `>>${quotedPid}`;
}
// Only add breaks if needed
const parent = a.parentNode;
const prev = a.previousSibling;
const next = a.nextSibling;
// Add break before if:
// - Not first child AND
// - Previous node isn't a break AND
// - Previous node isn't empty text
if (a !== parent.firstChild &&
!(prev?.nodeName === 'BR') &&
!(prev?.nodeType === 3 && /^\s*$/.test(prev.textContent))) {
parent.insertBefore(document.createElement('br'), a);
}
// Add break after if:
// - Not last child AND
// - Next node isn't a break AND
// - Next node isn't empty text
if (a !== parent.lastChild &&
!(next?.nodeName === 'BR') &&
!(next?.nodeType === 3 && /^\s*$/.test(next.textContent))) {
parent.insertBefore(document.createElement('br'), a.nextSibling);
}
}
});
// Clean up any double line breaks
msg.innerHTML = msg.innerHTML
.replace(/(<br>\s*){2,}/g, '<br>') // Multiple <br>s
.replace(/^(<br>)|(<br>)$/g, ''); // Leading/trailing <br>
// Don't add backlinks to this post's header - they belong to the quoted posts
dDiv.querySelector('.container').innerHTML = '';
cont.appendChild(p);
cont.sideArrows = side;
return cont;
}
function processNativePost(content) {
if (!content) return '';
// Handle all possible greenText variants
return content
// Normal case
.replace(/<span class="greenText">/g, '<span class="quote">')
// Escaped quotes case
.replace(/<span class=\\"greenText\\">/g, '<span class=\\"quote\\">')
// Newline variant
.replace(/\n<span class="greenText">>/g, '\n<span class="quote">>')
// Newline + escaped variant
.replace(/\n<span class=\\"greenText\\">>/g, '\n<span class=\\"quote\\">>')
// Handle greentext at start of line
.replace(/^<span class="greenText">>/gm, '<span class="quote">>');
}
async function md5FromDataUrl(dataUrl) {
const base64 = dataUrl.split(',')[1];
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const hashBuffer = await crypto.subtle.digest('MD5', bytes);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// helper for filename truncation
function truncateName(name, maxChars = 25) {
if (name.length <= maxChars) return name;
const dot = name.lastIndexOf('.');
const ext = dot >= 0 ? name.slice(dot) : '';
// reserve 5 chars for "(...)" plus extension length
const keep = maxChars - ext.length - 5;
return name.slice(0, keep) + '(...)' + ext;
}
async function bootstrap() {
//console.log('[Injector] bootstrap start');
const { board, threadId } = getBoardThread();
const searchKey = await fetch4chanSearchKey(board, threadId);
if (!searchKey) {
//console.error('[Injector] No search key generated');
return;
}
const catalog = await gmFetchJson(`https://8chan.moe/${board}/catalog.json`);
const nativeTid = findNativeThread(catalog, searchKey);
if (!nativeTid) return;
const data = await gmFetchJson(`https://8chan.moe/${board}/res/${nativeTid}.json`);
const threadEl = document.querySelector(`#t${threadId}`);
if (!threadEl) {
//console.error('[Injector] No threadEl');
return;
}
for (const post of data.posts) {
const node = await buildNativechanPost(post, board);
node.dataset.board = board;
node.dataset.threadId = threadId;
node.dataset.postId = post.postId;
// === Insert into the DOM ===
if (window.Posts?.insertPost) {
window.Posts.insertPost(node, threadEl);
} else {
const posts = Array.from(threadEl.querySelectorAll('.postContainer'));
const utc = Number(node.querySelector('.dateTime')?.dataset.utc || 0);
let inserted = false;
for (const postEl of posts) {
const postUtc = Number(postEl.querySelector('.dateTime')?.dataset.utc || 0);
if (utc < postUtc) {
threadEl.insertBefore(node, postEl);
inserted = true;
break;
}
}
if (!inserted) {
threadEl.appendChild(node);
}
}
// === Now notify 4chanX ===
if (window.dispatchEvent) {
const event = new CustomEvent('PostsInserted', { detail: node });
window.dispatchEvent(event);
}
// === Setup 4chanX Post objects ===
if (window.Posts?.posts && window.Post) {
const postObj = new window.Post(node);
postObj.isClone = true;
postObj.nodes = { root: node };
postObj.boardID = board;
postObj.threadID = threadId;
postObj.postID = post.postId.toString();
postObj.info = {
boardID: board,
threadID: threadId,
postID: post.postId.toString()
};
window.Posts.posts.set(`${board}.${post.postId}`, postObj);
if (window.Posts.add) {
window.Posts.add(node);
}
}
if (window.QuotePreview?.node) {
window.QuotePreview.node(node);
}
}
}
function waitFor4chanXReady() {
return new Promise(resolve => {
const checkReady = () => window.Main && typeof window.Main.ready === 'function' && window.Main.ready();
if (checkReady()) {
resolve();
} else {
const interval = setInterval(() => {
if (checkReady()) {
clearInterval(interval);
resolve();
}
}, 50);
}
});
}
function notify4chanXPostsInserted(posts, thread = null) {
const event = new CustomEvent('PostsInserted', {
detail: {
posts: Array.isArray(posts) ? posts : [posts],
thread: thread || (posts[0] && posts[0].closest('.thread'))
}
});
document.dispatchEvent(event);
}
(async function() {
'use strict';
//console.log('[Injector] Script loaded at', window.location.href);
initLazyLoadObserver();
if (document.readyState === 'loading') {
await new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve));
}
// Listen for the 4chanX event
let bootstrapped = false;
document.addEventListener('4chanXInitFinished', async () => {
//console.log('[Injector] 4chanXInitFinished event detected');
if (!bootstrapped) {
bootstrapped = true;
await bootstrap();
enableNativechanHoverZoom();
}
});
// Also fallback: poll for window.Main.ready()
const timeout = 10000; // 10 seconds max
const start = Date.now();
while (!bootstrapped && (Date.now() - start) < timeout) {
if (window.Main && typeof window.Main.ready === 'function' && window.Main.ready()) {
//console.log('[Injector] window.Main.ready() true by polling');
bootstrapped = true;
bootstrap();
break;
}
await new Promise(resolve => setTimeout(resolve, 200));
}
if (!bootstrapped) {
//console.warn('[Injector] 4chanX not ready after timeout.');
}
})();
// Move zoom and spinner with mouse
function moveZoom(e) {
const zoom = document.getElementById('nativechanHoverZoom');
const spinner = document.getElementById('nativechanHoverSpinner');
if (!zoom || !spinner) return;
const padding = 20;
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
const zoomWidth = zoom.offsetWidth;
const zoomHeight = zoom.offsetHeight;
let left = e.clientX + padding;
if (left + zoomWidth > screenWidth - padding) {
left = Math.max(e.clientX - zoomWidth - padding, padding);
}
zoom.style.left = `${left}px`;
let top = e.clientY - zoomHeight / 2;
if (top < padding) top = padding;
if (top + zoomHeight > screenHeight - padding) {
top = screenHeight - zoomHeight - padding;
}
zoom.style.top = `${top}px`;
spinner.style.left = `${e.clientX + 10}px`;
spinner.style.top = `${e.clientY + 10}px`;
}
// Hover Zoom
async function enableNativechanHoverZoom() {
const posts = document.querySelectorAll('.postContainer[data-native-inject="true"]');
const dataUrlCache = new Map();
for (const post of posts) {
const thumbLinks = post.querySelectorAll('a.fileThumb');
if (!thumbLinks.length) continue;
// Loop through each thumbnail
for (const thumbLink of thumbLinks) {
thumbLink.addEventListener('mouseover', function handleMouseOver(e) {
if (document.getElementById('nativechanHoverZoom')) return;
const fullImageUrl = thumbLink.href;
const thumbImg = thumbLink.querySelector('img');
if (!thumbImg) return;
// Skip if thumbnail is already large
if (thumbImg.naturalWidth > 300 || thumbImg.naturalHeight > 300) return;
let stillHovered = true;
let disableHoverZoom = false;
if (disableHoverZoom) return;
// Create zoomed image
const zoom = document.createElement('img');
zoom.id = 'nativechanHoverZoom';
Object.assign(zoom.style, {
position: 'fixed',
maxWidth: '98%',
maxHeight: '98%',
zIndex: 9999,
pointerEvents: 'none',
opacity: '0.8',
transition: 'opacity 0.2s ease'
});
zoom.src = thumbImg.src;
document.body.appendChild(zoom);
// Create spinner
const spinner = document.createElement('div');
spinner.id = 'nativechanHoverSpinner';
Object.assign(spinner.style, {
position: 'fixed',
width: '24px',
height: '24px',
border: '3px solid #ccc',
borderTop: '3px solid #333',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
zIndex: 10000,
pointerEvents: 'none'
});
document.body.appendChild(spinner);
// Add spinner animation CSS once
if (!document.getElementById('hoverZoomSpinnerStyle')) {
const style = document.createElement('style');
style.id = 'hoverZoomSpinnerStyle';
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
// Move zoom & spinner with mouse
function moveZoom(e) {
const pad = 20;
const sw = window.innerWidth;
const sh = window.innerHeight;
const zw = zoom.offsetWidth;
const zh = zoom.offsetHeight;
let left = e.clientX + pad;
if (left + zw > sw - pad) {
left = Math.max(e.clientX - zw - pad, pad);
}
zoom.style.left = `${left}px`;
let top = e.clientY - zh / 2;
if (top < pad) top = pad;
if (top + zh > sh - pad) {
top = sh - zh - pad;
}
zoom.style.top = `${top}px`;
spinner.style.left = `${e.clientX + 10}px`;
spinner.style.top = `${e.clientY + 10}px`;
}
document.addEventListener('mousemove', moveZoom);
moveZoom(e);
function cleanup() {
stillHovered = false;
disableHoverZoom = true;
zoom.remove();
spinner.remove();
document.removeEventListener('mousemove', moveZoom);
setTimeout(() => { disableHoverZoom = false; }, 300);
}
thumbLink.addEventListener('mousedown', cleanup, { once: true });
thumbLink.addEventListener('mouseout', cleanup, { once: true });
// Load full-size image
(async () => {
try {
let dataUrl = dataUrlCache.get(fullImageUrl);
if (!dataUrl) {
dataUrl = await gmFetchDataUrl(fullImageUrl);
dataUrlCache.set(fullImageUrl, dataUrl);
}
if (stillHovered) {
zoom.src = dataUrl;
zoom.style.opacity = '1';
}
} catch (err) {
console.error('[NativeHoverZoom] Failed to load full image', err);
} finally {
spinner.remove();
}
})();
});
} // end for thumbLinks
}
}
// Click to Expand
document.addEventListener('click', async function (e) {
const link = e.target.closest('a.fileThumb');
if (!link || !link.closest('.nativeCell')) return;
// remove hover zoom if its there
const zoom = document.getElementById('nativechanHoverZoom');
const spinner = document.getElementById('nativechanHoverSpinner');
if (zoom) zoom.remove();
if (spinner) spinner.remove();
document.removeEventListener('mousemove', moveZoom);
e.preventDefault();
const img = link.querySelector('img');
if (!img) return;
const postContainer = link.closest('.postContainer');
const post = link.closest('.post');
const fileDiv = link.closest('.file');
const isExpanded = img.dataset.expanded === 'true';
if (!isExpanded) {
img.dataset.thumbSrc = img.src;
// Add loading indicator
const loading = document.createElement('div');
loading.textContent = 'Loading...';
Object.assign(loading.style, {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '6px 12px',
background: 'rgba(0,0,0,0.7)',
color: 'white',
borderRadius: '4px',
fontSize: '14px',
zIndex: '10'
});
link.style.position = 'relative';
link.appendChild(loading);
try {
const dataUrl = await gmFetchDataUrl(link.href);
img.src = dataUrl;
// Expand the image properly
Object.assign(img.style, {
maxWidth: '100%',
maxHeight: '90vh',
width: 'auto',
height: 'auto',
display: 'block',
margin: '10px 0'
});
// Allow parent divs to expand naturally
Object.assign(link.style, {
display: 'block',
width: 'auto',
maxWidth: '100%',
margin: '10px 0',
float: 'none'
});
if (fileDiv) {
Object.assign(fileDiv.style, {
display: 'block',
width: 'auto',
maxWidth: '100%',
height: 'auto',
float: 'none'
});
}
if (post) {
Object.assign(post.style, {
display: 'block',
width: 'auto',
maxWidth: '100%',
height: 'auto'
});
}
if (postContainer) {
Object.assign(postContainer.style, {
display: 'block',
width: 'auto',
maxWidth: '100%',
height: 'auto'
});
}
img.dataset.expanded = 'true';
} catch (err) {
console.error('Failed to load full image', err);
} finally {
loading.remove();
link.style.position = ''; // Important! Clear relative positioning after loading
}
} else {
// Collapse back to thumbnail
img.src = img.dataset.thumbSrc;
Object.assign(img.style, {
maxHeight: '125px',
height: 'auto',
width: 'auto',
display: 'block',
margin: ''
});
link.style.display = '';
link.style.width = '';
link.style.maxWidth = '';
link.style.float = '';
link.style.margin = '';
if (fileDiv) {
fileDiv.style.display = '';
fileDiv.style.width = '';
fileDiv.style.maxWidth = '';
fileDiv.style.height = '';
fileDiv.style.float = '';
}
if (post) {
post.style.display = '';
post.style.width = '';
post.style.maxWidth = '';
post.style.height = '';
}
if (postContainer) {
postContainer.style.display = '';
postContainer.style.width = '';
postContainer.style.maxWidth = '';
postContainer.style.height = '';
}
delete img.dataset.expanded;
}
});
})();