Add URL input area and automatically compress oversized JPEG, PNG, and WebP uploads to fit the site's 3MB limit while preserving maximum image quality.
// ==UserScript==
// @name LightNovelPub Comment Tools
// @namespace https://xgorn.com
// @version 0.0.2
// @description Add URL input area and automatically compress oversized JPEG, PNG, and WebP uploads to fit the site's 3MB limit while preserving maximum image quality.
// @author Noid
// @match https://lightnovelpub.org/novel/*
// @match https://lightnovelpub.org/profile/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=lightnovelpub.org
// @grant GM_xmlhttpRequest
// @connect *
// @license MIT
// ==/UserScript==
async function compressImage(file, targetSizeMB = 3) {
const targetSize = targetSizeMB * 1024 * 1024;
const img = new Image();
const url = URL.createObjectURL(file);
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = url;
});
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
canvas
.getContext('2d')
.drawImage(img, 0, 0);
let low = 0.01;
let high = 1.0;
let bestBlob = null;
for (let i = 0; i < 12; i++) {
const quality = (low + high) / 2;
const blob = await new Promise(resolve =>
canvas.toBlob(
resolve,
'image/jpeg',
quality
)
);
if (blob.size > targetSize) {
high = quality;
} else {
bestBlob = blob;
low = quality;
}
}
URL.revokeObjectURL(url);
console.log(
'Final size:',
(bestBlob.size / 1024 / 1024).toFixed(2),
'MB'
);
return new File(
[bestBlob],
file.name.replace(/\.\w+$/, '.jpg'),
{
type: 'image/jpeg'
}
);
}
async function handleImageUrl() {
const url = document.getElementById('imageUrlInput').value.trim();
if (!url) return;
try {
GM_xmlhttpRequest({
method: "GET",
url: url,
responseType: "blob",
onload: function(res) {
const blob = res.response;
const file = new File(
[blob],
"external.jpg",
{ type: blob.type }
);
handleImageFile(file);
},
onerror: function() {
alert("Failed to load image (blocked or invalid URL)");
}
});
} catch (err) {
alert('Failed to load image from URL: ' + err.message);
}
}
function injectImageUrlField() {
if (document.getElementById('imageUrlGroup')) return;
const fileInput = document.getElementById('imageFileInput');
if (!fileInput) return;
const wrapper = document.createElement('div');
wrapper.id = 'imageUrlGroup';
wrapper.innerHTML = `
<div style="display:flex; gap:8px;">
<input
id="imageUrlInput"
type="text"
placeholder="Paste image URL (https://...)"
style="flex:1; padding:6px;"
/>
<button type="button" class="image-button" id="imageUrlBtn">
<svg viewBox="0 0 24 24" fill="none">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"></path>
<path d="M7 10l5 5 5-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"></path>
<path d="M12 15V3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>
</button>
</div>
`;
fileInput.parentNode.appendChild(wrapper);
document.getElementById('imageUrlBtn')
.addEventListener('click', handleImageUrl);
}
(function() {
'use strict';
const style = document.createElement('style');
style.textContent = `
#imageUrlInput {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
background: rgba(255,255,255,0.05);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
height: 40px;
box-sizing: border-box;
transition: border-color 0.2s ease;
}
#imageUrlInput:focus {
border-color: rgba(255,255,255,0.4);
outline: none;
}
.image-button {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(59, 130, 246, 0.08));
border: 1px solid rgba(59, 130, 246, 0.35);
border-radius: 8px;
padding: 0.5rem;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
}
.image-button svg {
width: 20px;
height: 20px;
color: #3b82f6;
}
.image-button:hover {
background: linear-gradient(135deg,rgba(59,130,246,0.2),rgba(59,130,246,0.15));
border-color: rgba(59,130,246,0.5);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59,130,246,0.2)
}
.image-button:active {
transform: translateY(0)
}
.image-button svg {
width: 20px;
height: 20px;
color: #3b82f6
}
[data-theme="sepia"] .image-button {
background: rgba(210,105,30,0.1);
border-color: rgba(210,105,30,0.3)
}
[data-theme="sepia"] .image-button:hover {
background: rgba(210,105,30,0.2);
border-color: rgba(210,105,30,0.5)
}
[data-theme="sepia"] .image-button svg {
color: var(--primary)
}
`;
document.head.appendChild(style);
const originalHandleImageFile = unsafeWindow.handleImageFile;
unsafeWindow.handleImageFile = async function(file) {
if (
file.size > 3 * 1024 * 1024 &&
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
) {
console.log('Compressing...');
file = await compressImage(file, 3);
console.log(
'Compressed to',
(file.size / 1024 / 1024).toFixed(2),
'MB'
);
}
return originalHandleImageFile.call(this, file);
};
if (!window.imageUrlInitialized) {
injectImageUrlField();
window.imageUrlInitialized = true;
}
})();