// ==UserScript==
// @name 4chan Thumbnail Viewer & Collage Creator
// @namespace http://tampermonkey.net/
// @version 0.9.9
// @description Mark posts as highlights on the thread or in a thumbnail grid, review your selection, and make collages out of them.
// @match https://boards.4channel.org/*/thread/*
// @match https://boards.4chan.org/*/thread/*
// @grant GM_xmlhttpRequest
// @connect i.4cdn.org
// ==/UserScript==
(function() {
'use strict';
// =========================
// 1. CSS Styles
// =========================
const style = document.createElement('style');
style.innerHTML = `
/* Base highlight button */
.highlight-btn {
position: absolute;
top: 5px;
right: 5px;
background: rgba(255,255,0,0.7);
border: 1px solid #000;
padding: 2px 5px;
cursor: pointer;
z-index: 10;
font-weight: bold;
}
.highlighted {
border: 3px solid red !important;
}
/* Generic overlay for views */
.overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 1000;
overflow: auto;
padding: 10px;
display: flex;
flex-direction: column;
}
.overlay-content {
flex: 1;
display: flex;
flex-wrap: wrap;
justify-content: center;
position: relative;
}
/* Info button and content */
.overlay-info-button {
position: absolute;
top: 10px;
left: 10px;
background: #008;
color: #fff;
border: none;
padding: 4px 8px;
cursor: pointer;
z-index: 1100;
border-radius: 3px;
font-size: 14px;
}
.overlay-info-content {
position: absolute;
top: 45px;
left: 10px;
background: rgba(0,0,0,0.7);
color: #fff;
font-size: 12px;
padding: 5px;
border-radius: 3px;
z-index: 1100;
display: none;
max-width: 300px;
}
.overlay-close-button {
position: absolute;
top: 10px;
left: 70px; /* Adjust this value to position it right next to the '?' button */
background: red;
color: #fff;
border: none;
padding: 4px 8px;
cursor: pointer;
z-index: 1100;
border-radius: 3px;
font-size: 14px;
}
/* Thumbnail item */
.thumb-item {
margin: 5px;
position: relative;
cursor: pointer;
}
.thumb-item img {
transition: all 0.3s;
/* Default unexpanded size */
max-width: 150px;
max-height: 150px;
}
/* Selected thumbnails get a green border */
.thumb-item.selected {
outline: 4px solid #0f0;
}
/* Options (Highlights) menu in bottom right */
.chan-hv-options-menu {
position: fixed;
bottom: 10px;
right: 10px;
background: rgba(0,0,0,0.8);
padding: 5px;
border-radius: 3px;
z-index: 1001;
}
.chan-hv-menu-header {
cursor: pointer;
color: #fff;
font-size: 16px;
user-select: none;
}
.chan-hv-menu-content {
display: none;
margin-top: 5px;
}
.chan-hv-menu-content button {
display: block;
margin: 3px 0;
background: #008;
color: #fff;
border: none;
padding: 5px 10px;
cursor: pointer;
font-size: 14px;
}
/* Collage button */
.collage-btn {
position: fixed;
top: 10px;
right: 10px;
z-index: 1100;
padding: 8px 12px;
background: #080;
color: #fff;
border: none;
cursor: pointer;
font-size: 14px;
}
/* Zoom slider styling */
.zoom-slider {
width: 200px;
}
/* Thread Thumbnails view */
.thumbnails-grid img {
margin: 5px;
}
/* Remove preset image size limits for thread view */
.thumbnails-grid .thumb-item img {
max-width: none;
max-height: none;
}
/* Header image input container in review view */
.header-input-container {
margin-bottom: 10px;
color: #fff;
font-size: 16px;
background-color: rgba(0, 0, 0, 0.5);
padding: 8px;
border-radius: 5px;
width: 100%;
text-align: center;
}
/* Review highlights view layout */
.review-highlights-container {
display: block !important;
}
.thumbs-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
/* Counter style */
#selected-counter {
margin: 10px;
color: #fff;
font-size: 16px;
}
/* Lightbox overlay for expanded images */
.lightbox-overlay {
position: fixed;
top: 0; left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
}
.lightbox-overlay img {
max-width: 90vw;
max-height: 90vh;
}
`;
document.head.appendChild(style);
// =========================
// 2. Global Variables
// =========================
const highlightedImages = []; // Each: { post, thumbSrc, fullSrc }
let selectedHeaderImage = null; // Object: { src, width, height }
// =========================
// Global: Update review counter
// =========================
function updateReviewCounter() {
const counterDiv = document.getElementById('selected-counter');
if (counterDiv) {
const count = document.querySelectorAll('.review-highlights-container .thumb-item.selected').length;
counterDiv.textContent = 'Selected: ' + count;
}
}
// =========================
// 3. Add Highlight Buttons to Posts
// =========================
function addHighlightButtons() {
const posts = document.querySelectorAll('.post');
posts.forEach(post => {
const thumbLink = post.querySelector('a.fileThumb');
if (!thumbLink) return;
const btn = document.createElement('div');
btn.textContent = '★';
btn.className = 'highlight-btn';
post.style.position = 'relative';
btn.addEventListener('click', e => {
e.stopPropagation();
if (post.classList.contains('highlighted')) {
post.classList.remove('highlighted');
btn.style.background = 'rgba(255,255,0,0.7)';
const idx = highlightedImages.findIndex(item => item.post === post);
if (idx > -1) highlightedImages.splice(idx, 1);
} else {
post.classList.add('highlighted');
btn.style.background = 'rgba(0,255,0,0.7)';
const img = thumbLink.querySelector('img');
if (img) {
highlightedImages.push({
post: post,
thumbSrc: img.src,
fullSrc: thumbLink.href
});
}
}
});
post.appendChild(btn);
});
}
// =========================
// 4. Unified Thumbnail Behavior
// =========================
function setupUnifiedThumbnailBehavior(container) {
const thumbItems = container.querySelectorAll('.thumb-item');
thumbItems.forEach(item => {
const img = item.querySelector('img');
if (!img.dataset.thumb) {
img.dataset.thumb = img.src;
}
item.addEventListener('click', e => {
if (e.ctrlKey) {
if (item.classList.contains('selected')) {
item.classList.remove('selected');
removeFromHighlights(img.dataset.full);
if (container.classList.contains('review-highlights-container')) {
item.remove();
}
} else {
item.classList.add('selected');
addToHighlights(img.dataset.full, img.dataset.thumb);
}
// Update counter when selection toggles.
updateReviewCounter();
} else {
const parentContainer = item.parentElement;
const imageNodes = parentContainer.querySelectorAll("img");
const imageList = Array.from(imageNodes).map(i => i.dataset.full);
const currentIndex = imageList.indexOf(img.dataset.full);
showLightbox(imageList, currentIndex);
}
e.stopPropagation();
});
});
container.tabIndex = 0;
container.focus();
}
function addToHighlights(fullSrc, thumbSrc) {
if (!highlightedImages.find(item => item.fullSrc === fullSrc)) {
highlightedImages.push({ post: null, thumbSrc, fullSrc });
}
}
function removeFromHighlights(fullSrc) {
const idx = highlightedImages.findIndex(item => item.fullSrc === fullSrc);
if (idx > -1) highlightedImages.splice(idx, 1);
}
// =========================
// Lightbox Overlay with Navigation
// =========================
function showLightbox(imageList, currentIndex) {
const overlay = document.createElement('div');
overlay.className = 'lightbox-overlay';
const fullImg = document.createElement('img');
fullImg.src = imageList[currentIndex];
overlay.appendChild(fullImg);
function keyHandler(e) {
if (e.key === 'ArrowLeft') {
currentIndex = (currentIndex - 1 + imageList.length) % imageList.length;
fullImg.src = imageList[currentIndex];
e.preventDefault();
} else if (e.key === 'ArrowRight') {
currentIndex = (currentIndex + 1) % imageList.length;
fullImg.src = imageList[currentIndex];
e.preventDefault();
} else if (e.key === 'Escape') {
document.body.removeChild(overlay);
window.removeEventListener('keydown', keyHandler);
e.preventDefault();
}
}
window.addEventListener('keydown', keyHandler);
overlay.addEventListener('click', () => {
document.body.removeChild(overlay);
window.removeEventListener('keydown', keyHandler);
});
document.body.appendChild(overlay);
}
// =========================
// 5. Create a Generic Overlay View
// =========================
function createOverlayView(contentGenerator, closeCallback) {
const originalBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const existing = document.querySelector('.overlay');
if (existing) {
existing.parentNode.removeChild(existing);
}
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.tabIndex = 0;
overlay.addEventListener('keydown', e => {
if (e.key === 'Escape' && !document.querySelector('.lightbox-overlay')) {
cleanup();
}
});
const infoButton = document.createElement('button');
infoButton.className = 'overlay-info-button';
infoButton.textContent = '?';
const infoContent = document.createElement('div');
infoContent.className = 'overlay-info-content';
infoContent.textContent = '(click to expand/contract, ctrl+click to select/deselect, esc to close view)';
infoButton.addEventListener('click', () => {
infoContent.style.display = (infoContent.style.display === 'none' || infoContent.style.display === '')
? 'block'
: 'none';
});
const closeButton = document.createElement('button');
closeButton.className = 'overlay-close-button';
closeButton.textContent = 'X';
closeButton.addEventListener('click', cleanup);
overlay.appendChild(infoButton);
overlay.appendChild(infoContent);
overlay.appendChild(closeButton);
const content = contentGenerator();
overlay.appendChild(content);
overlay.focus();
function cleanup() {
document.body.style.overflow = originalBodyOverflow;
document.body.removeChild(overlay);
if (closeCallback) closeCallback();
}
overlay.addEventListener('click', e => {
if (e.target === overlay) cleanup();
});
document.body.appendChild(overlay);
}
// =========================
// 6. Review Highlights View
// =========================
function showReviewHighlightsView() {
const contentGenerator = () => {
const container = document.createElement('div');
container.className = 'overlay-content review-highlights-container';
const headerContainer = document.createElement('div');
headerContainer.className = 'header-input-container';
headerContainer.innerHTML = `<label style="margin-right:5px;">Header image (optional):</label>`;
const headerInput = document.createElement('input');
headerInput.type = 'file';
headerInput.accept = 'image/*';
headerInput.addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(evt) {
const tempImg = new Image();
tempImg.onload = function() {
selectedHeaderImage = {
src: evt.target.result,
width: tempImg.naturalWidth,
height: tempImg.naturalHeight
};
};
tempImg.src = evt.target.result;
};
reader.readAsDataURL(file);
});
headerContainer.appendChild(headerInput);
container.appendChild(headerContainer);
// Add counter element
const counterDiv = document.createElement('div');
counterDiv.id = 'selected-counter';
counterDiv.textContent = 'Selected: 0';
container.appendChild(counterDiv);
const thumbsContainer = document.createElement('div');
thumbsContainer.className = 'thumbs-container';
highlightedImages.forEach(item => {
const thumbDiv = document.createElement('div');
thumbDiv.className = 'thumb-item';
thumbDiv.classList.add('selected');
const img = document.createElement('img');
img.src = item.thumbSrc;
img.dataset.thumb = item.thumbSrc;
img.dataset.full = item.fullSrc;
thumbDiv.appendChild(img);
thumbsContainer.appendChild(thumbDiv);
});
container.appendChild(thumbsContainer);
const collageBtn = document.createElement('button');
collageBtn.textContent = "Create Collages";
collageBtn.className = "collage-btn";
collageBtn.addEventListener("click", createCollage);
container.appendChild(collageBtn);
setTimeout(() => { setupUnifiedThumbnailBehavior(thumbsContainer); updateReviewCounter(); }, 0);
return container;
};
createOverlayView(contentGenerator);
}
// =========================
// 7. Thread Thumbnails View
// =========================
function showThreadThumbnailsView() {
const contentGenerator = () => {
const container = document.createElement('div');
container.className = 'overlay-content thumbnails-grid';
const sliderContainer = document.createElement('div');
sliderContainer.style.textAlign = 'center';
sliderContainer.style.width = '100%';
sliderContainer.style.marginBottom = '10px';
const zoomLabel = document.createElement('label');
zoomLabel.textContent = 'Zoom: ';
const zoomSlider = document.createElement('input');
zoomSlider.type = 'range';
zoomSlider.className = 'zoom-slider';
zoomSlider.min = 50;
zoomSlider.max = 400;
zoomSlider.value = 100;
zoomSlider.style.margin = '10px';
sliderContainer.appendChild(zoomLabel);
sliderContainer.appendChild(zoomSlider);
container.appendChild(sliderContainer);
const thumbsContainer = document.createElement('div');
thumbsContainer.className = 'thumbs-container';
const thumbs = document.querySelectorAll('a.fileThumb img');
thumbs.forEach(imgEl => {
const thumbDiv = document.createElement('div');
thumbDiv.className = 'thumb-item';
const clone = document.createElement('img');
clone.src = imgEl.src;
clone.dataset.thumb = imgEl.src;
const parentLink = imgEl.closest('a.fileThumb');
clone.dataset.full = parentLink ? parentLink.href : imgEl.src;
// When the image loads, store its base width (capped at 200px)
clone.onload = function() {
const naturalW = clone.naturalWidth;
const baseW = naturalW > 200 ? 200 : naturalW;
clone.dataset.baseWidth = baseW;
const scale = zoomSlider.value / 100;
clone.style.width = (baseW * scale) + 'px';
};
if (highlightedImages.some(item => item.fullSrc === clone.dataset.full)) {
thumbDiv.classList.add('selected');
}
thumbDiv.appendChild(clone);
thumbsContainer.appendChild(thumbDiv);
});
container.appendChild(thumbsContainer);
setTimeout(() => { setupUnifiedThumbnailBehavior(thumbsContainer); }, 0);
zoomSlider.addEventListener('input', () => {
const scale = zoomSlider.value / 100;
const images = thumbsContainer.querySelectorAll('img');
images.forEach(img => {
if (!img.closest('.thumb-item').classList.contains('expanded')) {
const base = img.dataset.baseWidth ? parseFloat(img.dataset.baseWidth) : 150;
img.style.width = (base * scale) + 'px';
img.style.height = 'auto';
}
});
});
return container;
};
createOverlayView(contentGenerator);
}
// =========================
// 8. Create Collage (and trigger PNG download)
// =========================
async function createCollage() {
const numCollages = parseInt(prompt("Enter number of collages:", "1"), 10);
const targetMP = parseFloat(prompt("Enter target megapixels per image (e.g., 1 for 1MP):", "0.3"));
const targetAspect = parseFloat(prompt("Enter target aspect ratio (e.g., 1 for square, 1.33 for 4:3):", "1"));
if (!numCollages || !targetMP || !targetAspect) {
alert("Invalid input.");
return;
}
const targetArea = targetMP * 1e6;
let L = [];
for (let i = 0; i < highlightedImages.length; i++) {
const imgData = highlightedImages[i];
try {
const dims = await getImageDimensions(imgData.fullSrc);
L.push({
src: imgData.fullSrc,
width: dims.width,
height: dims.height
});
} catch (err) {
console.error("Failed to load image dimensions for", imgData.fullSrc, err);
}
}
if (L.length === 0) {
alert("No images available for collage.");
return;
}
// Sort images by natural aspect ratio.
L.sort((a, b) => (a.width / a.height) - (b.width / b.height));
// Use our modified round-robin layout with targetAspect.
const collages = roundRobinCollageLayout(L, numCollages, targetArea, selectedHeaderImage, targetAspect);
if (!collages || collages.length === 0) {
alert("Collage layout failed.");
return;
}
let collagePromises = collages.map((collage, collageIndex) => {
return new Promise(resolve => {
const canvas = document.createElement("canvas");
canvas.width = collage.width;
canvas.height = collage.height;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
let placementPromises = collage.placements.map(placement => {
return new Promise(r => {
fetchImageAsBlob(placement.image.src)
.then(blob => {
const blobUrl = URL.createObjectURL(blob);
const imageEl = new Image();
imageEl.onload = function() {
ctx.drawImage(imageEl,
placement.x,
placement.y,
placement.width,
placement.height
);
URL.revokeObjectURL(blobUrl);
r();
};
imageEl.onerror = function(e) {
console.error("Error loading image:", placement.image.src, e);
r();
};
imageEl.src = blobUrl;
})
.catch(err => {
console.error("GM_xmlhttpRequest error:", err);
r();
});
});
});
Promise.all(placementPromises).then(() => {
const dataUrl = canvas.toDataURL('image/png');
const url = window.location.href;
const boardMatch = url.match(/boards\.4chan(?:nel)?\.org\/([^\/]+)\//);
const board = boardMatch ? boardMatch[1] : "unknown";
const threadMatch = url.match(/thread\/(\d+)/);
const thread = threadMatch ? threadMatch[1] : "unknown";
const currentUnixTime = Math.floor(Date.now() / 1000);
const filename = `highlights_${board}_${thread}_${currentUnixTime}_${collageIndex+1}.png`;
const a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
resolve();
});
});
});
Promise.all(collagePromises).then(() => {
const existing = document.querySelector('.overlay');
if (existing) existing.parentNode.removeChild(existing);
});
}
// =========================
// 9. Round-Robin Collage Layout with Custom Row Grouping, Row Balancing, and Height Adjustment
// =========================
function roundRobinCollageLayout(L, numCollages, targetArea, headerImage, targetAspect) {
// Scale each image so that its area is proportional to targetArea.
L.forEach(img => {
let s = Math.sqrt(targetArea / (img.width * img.height));
img.scaledWidth = img.width * s;
img.scaledHeight = img.height * s;
});
// Sort images by aspect ratio (narrow to wide)
L.sort((a, b) => (a.width / a.height) - (b.width / b.height));
// Distribute images round-robin into groups.
let groups = [];
for (let i = 0; i < numCollages; i++) {
groups.push([]);
}
for (let i = 0; i < L.length; i++) {
groups[i % numCollages].push(L[i]);
}
let collages = [];
groups.forEach((group, groupIndex) => {
if (group.length === 0) return;
let m = group.length;
// Custom row grouping: number of images per row = ceil(sqrt(m) * targetAspect)
let numColumns = Math.ceil(Math.sqrt(m) * targetAspect);
let rows = [];
for (let i = 0; i < m; i += numColumns) {
rows.push(group.slice(i, i + numColumns));
}
// Row Balancing: For each row (except the last), swap its first image with the image that is the r-th widest overall.
let allBoxes = rows.flat();
let sortedByWidth = [...allBoxes].sort((a, b) => b.scaledWidth - a.scaledWidth);
for (let r = 0; r < rows.length - 1; r++) {
let desired = sortedByWidth[r]; // r-th widest overall
if (rows[r][0] !== desired) {
for (let r2 = r; r2 < rows.length; r2++) {
let idx = rows[r2].indexOf(desired);
if (idx !== -1) {
let temp = rows[r][0];
rows[r][0] = rows[r2][idx];
rows[r2][idx] = temp;
break;
}
}
}
}
// --- New Height Adjustment based on final (justified) row heights ---
if (rows.length > 1) {
// First, compute metrics for each row based on the pre-justified layout.
const rowMetrics = rows.map(row => {
// Use the first image's scaledHeight as the base height.
const baseHeight = row[0].scaledHeight;
let totalWidth = 0;
for (const img of row) {
// Scale each image to the base height.
const factor = baseHeight / img.scaledHeight;
totalWidth += img.scaledWidth * factor;
}
return { baseHeight, totalWidth };
});
// Determine the maximum row width among all rows.
const maxRowWidth = Math.max(...rowMetrics.map(m => m.totalWidth));
// Now compute the final height of each row after justification.
// (Justification scales the row so that its total width equals maxRowWidth.)
const finalHeights = rowMetrics.map(m => m.baseHeight * (maxRowWidth / m.totalWidth));
// Get the final height of the bottom row.
const bottomFinalHeight = finalHeights[finalHeights.length - 1];
// Find the smallest final height among all rows except the bottom.
let minFinalHeight = Infinity;
for (let i = 0; i < finalHeights.length - 1; i++) {
if (finalHeights[i] < minFinalHeight) {
minFinalHeight = finalHeights[i];
}
}
// Only perform adjustment if the bottom row's final height exceeds 1.8x the smallest.
if (bottomFinalHeight > 1.8 * minFinalHeight) {
const bottomRow = rows[rows.length - 1];
if (bottomRow.length === 1) {
// If the bottom row has a single image, move it into the tallest row (excluding the bottom).
let tallestRowIndex = -1;
let maxFinalHeight = -Infinity;
for (let i = 0; i < finalHeights.length - 1; i++) {
if (finalHeights[i] > maxFinalHeight) {
maxFinalHeight = finalHeights[i];
tallestRowIndex = i;
}
}
if (tallestRowIndex !== -1) {
const imageToMove = bottomRow.pop();
rows[tallestRowIndex].push(imageToMove);
// Remove the bottom row if it becomes empty.
if (bottomRow.length === 0) {
rows.pop();
}
}
} else {
// Otherwise, take the widest image from the shortest row (by final height) among non-bottom rows
let donorRowIndex = -1;
let minRowFinalHeight = Infinity;
for (let i = 0; i < finalHeights.length - 1; i++) {
if (finalHeights[i] < minRowFinalHeight) {
minRowFinalHeight = finalHeights[i];
donorRowIndex = i;
}
}
if (donorRowIndex !== -1 && rows[donorRowIndex].length > 0) {
const donorRow = rows[donorRowIndex];
// Find the widest image in the donor row.
let widestImageIndex = 0;
let maxWidth = donorRow[0].scaledWidth;
for (let j = 1; j < donorRow.length; j++) {
if (donorRow[j].scaledWidth > maxWidth) {
maxWidth = donorRow[j].scaledWidth;
widestImageIndex = j;
}
}
const donorImage = donorRow.splice(widestImageIndex, 1)[0];
bottomRow.push(donorImage);
}
}
}
}
// Randomize each row.
rows = rows.map(row => row.sort(() => Math.random() - 0.5));
// Insert header image (if any) into the beginning of the narrowest row by total width.
if (headerImage) {
let narrowestRowIndex = 0;
let minRowWidth = Infinity;
rows.forEach((row, idx) => {
let width = row.reduce((sum, img) => sum + img.scaledWidth, 0);
if (width < minRowWidth) {
minRowWidth = width;
narrowestRowIndex = idx;
}
});
const baseHeight = rows[narrowestRowIndex][0].scaledHeight;
const factor = baseHeight / headerImage.height;
const headerScaled = {
src: headerImage.src,
scaledWidth: headerImage.width * factor,
scaledHeight: baseHeight
};
rows[narrowestRowIndex].unshift(headerScaled);
}
// Justify rows: For each row, compute a layout with common height, then scale rows to the maximum width.
let rowLayouts = rows.map(row => {
let baseHeight = row[0].scaledHeight;
let cells = row.map(img => {
let factor = baseHeight / img.scaledHeight;
return {
image: img,
width: img.scaledWidth * factor,
height: baseHeight
};
});
let totalWidth = cells.reduce((sum, cell) => sum + cell.width, 0);
return { cells, width: totalWidth, height: baseHeight };
});
let maxRowWidth = Math.max(...rowLayouts.map(r => r.width));
rowLayouts = rowLayouts.map(r => {
if (r.width < maxRowWidth) {
let factor = maxRowWidth / r.width;
return {
cells: r.cells.map(cell => ({
image: cell.image,
width: cell.width * factor,
height: cell.height * factor
})),
width: r.width * factor,
height: r.height * factor
};
} else {
return r;
}
});
let placements = [];
let y = 0;
rowLayouts.forEach(r => {
let x = 0;
r.cells.forEach(cell => {
placements.push({
image: cell.image,
x: x,
y: y,
width: cell.width,
height: cell.height
});
x += cell.width;
});
y += r.height;
});
collages.push({ width: maxRowWidth, height: y, placements });
});
return collages;
}
// =========================
// 10. Utility: Fetch image as blob.
// =========================
function fetchImageAsBlob(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
responseType: "blob",
onload: function(response) {
if (response.status === 200) {
resolve(response.response);
} else {
reject(new Error("Failed to fetch image. Status: " + response.status));
}
},
onerror: function(err) {
reject(err);
}
});
});
}
// =========================
// 11. Utility: Get image dimensions.
// =========================
function getImageDimensions(url) {
return new Promise((resolve, reject) => {
fetchImageAsBlob(url)
.then(blob => {
const blobUrl = URL.createObjectURL(blob);
const img = new Image();
img.onload = function() {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
URL.revokeObjectURL(blobUrl);
};
img.onerror = function(e) { reject(e); };
img.src = blobUrl;
})
.catch(reject);
});
}
// =========================
// 12. Options Menu (Renamed "Highlights" and positioned bottom right)
// =========================
function addOptionsMenu() {
const menu = document.createElement('div');
menu.className = 'chan-hv-options-menu';
const header = document.createElement('div');
header.className = 'chan-hv-menu-header';
header.textContent = '► Highlights';
header.addEventListener('click', () => {
const content = menu.querySelector('.chan-hv-menu-content');
if (content.style.display === 'block') {
content.style.display = 'none';
header.textContent = '► Highlights';
} else {
content.style.display = 'block';
header.textContent = '▼ Highlights';
}
});
menu.appendChild(header);
const content = document.createElement('div');
content.className = 'chan-hv-menu-content';
content.style.display = 'none';
const reviewBtn = document.createElement('button');
reviewBtn.textContent = 'Review Highlights';
reviewBtn.addEventListener('click', showReviewHighlightsView);
const threadBtn = document.createElement('button');
threadBtn.textContent = 'View Thread Thumbnails';
threadBtn.addEventListener('click', showThreadThumbnailsView);
content.appendChild(reviewBtn);
content.appendChild(threadBtn);
menu.appendChild(content);
document.body.appendChild(menu);
}
// =========================
// 13. Initialization
// =========================
function init() {
addHighlightButtons();
addOptionsMenu();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();