Merges 2+ part vertical or grid split images.
// ==UserScript==
// @name Twitter/X Split Image Merger
// @namespace https://spin.rip/
// @version 1.2
// @description Merges 2+ part vertical or grid split images.
// @match *://twitter.com/*
// @match *://x.com/*
// @grant none
// @license AGPL-3.0
// ==/UserScript==
(function() {
'use strict';
function getCommonAncestor(elements) {
if (!elements || elements.length === 0) return null;
let ancestor = elements[0].parentElement;
while (ancestor) {
if (elements.every(el => ancestor.contains(el))) return ancestor;
ancestor = ancestor.parentElement;
}
return null;
}
function getMediaRoot(mediaContainer) {
let mediaRoot = mediaContainer;
while (mediaRoot.parentElement && mediaRoot.parentElement.closest('article')) {
if (mediaRoot.getAttribute('data-testid') === 'tweetText') {
break;
}
const parent = mediaRoot.parentElement;
const hasImportantSibling = Array.from(parent.children).some(child => {
if (child === mediaRoot) return false;
return child.querySelector('[data-testid="tweetText"]') ||
child.querySelector('[role="group"]') ||
child.querySelector('time') ||
child.getAttribute('data-testid') === 'tweetText';
});
if (hasImportantSibling) break;
mediaRoot = parent;
}
return mediaRoot;
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
async function mergeVertical(imageUrls) {
const images = await Promise.all(imageUrls.map(loadImage));
const width = Math.max(...images.map(img => img.width));
const height = images.reduce((sum, img) => sum + img.height, 0);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
let currentY = 0;
for (const img of images) {
ctx.drawImage(img, 0, currentY);
currentY += img.height;
}
return canvas;
}
async function mergeGrid(imageUrls) {
const images = await Promise.all(imageUrls.map(loadImage));
const [tl, tr, bl, br] = images;
const width = tl.width + tr.width;
const height = tl.height + bl.height;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(tl, 0, 0);
ctx.drawImage(tr, tl.width, 0);
ctx.drawImage(bl, 0, tl.height);
ctx.drawImage(br, tl.width, tl.height);
return canvas;
}
function checkAndInject() {
const tweets = document.querySelectorAll('article[data-testid="tweet"]');
tweets.forEach(tweet => {
const allPhotoLinks = Array.from(tweet.querySelectorAll('a[href*="/photo/"]'));
if (allPhotoLinks.length === 0) return;
const groupedLinks = {};
allPhotoLinks.forEach(link => {
const match = link.href.match(/(.*\/status\/\d+)\/photo\//);
if (match) {
const baseUrl = match[1];
if (!groupedLinks[baseUrl]) groupedLinks[baseUrl] = [];
groupedLinks[baseUrl].push(link);
}
});
Object.values(groupedLinks).forEach(photoLinks => {
if (photoLinks.length >= 2) {
const mediaContainer = getCommonAncestor(photoLinks);
if (mediaContainer && !mediaContainer.dataset.mergerAdded) {
mediaContainer.dataset.mergerAdded = 'true';
const mediaRoot = getMediaRoot(mediaContainer);
mediaRoot.style.position = 'relative';
let gridMode = false;
// grid mode should only be available when there are exactly 4 images
const gridEligible = photoLinks.length === 4;
const btnWrapper = document.createElement('div');
btnWrapper.style.cssText = `
position: absolute;
top: 12px;
left: 0;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
pointer-events: none;
`;
// collapsed/expanded container
const controls = document.createElement('div');
controls.style.cssText = `
pointer-events: auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0;
border-radius: 999px;
backdrop-filter: blur(8px);
transition: width 0.22s ease, padding 0.22s ease, background 0.22s ease;
width: 34px;
background: rgba(0, 0, 0, 0.0);
overflow: hidden;
`;
const btn = document.createElement('button');
btn.innerText = 'merge images';
btn.style.cssText = `
pointer-events: auto;
background: rgba(29, 155, 240, 0.85);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(8px);
border-radius: 999px;
padding: 8px 18px;
font-weight: bold;
cursor: pointer;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transition: all 0.2s ease;
white-space: nowrap;
`;
const swapBtn = document.createElement('button');
swapBtn.innerText = '⇅';
swapBtn.title = gridEligible ? 'toggle: vertical / grid merge' : 'grid needs 4 images';
swapBtn.style.cssText = `
pointer-events: auto;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(8px);
border-radius: 999px;
width: 34px;
height: 34px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
`;
if (!gridEligible) {
swapBtn.disabled = true;
swapBtn.style.opacity = '0.45';
swapBtn.style.cursor = 'not-allowed';
}
// collapsed presentation (circle button only)
let expanded = false;
let collapseTimer = null;
function applyCollapsed() {
expanded = false;
if (collapseTimer) {
clearTimeout(collapseTimer);
collapseTimer = null;
}
controls.style.width = '34px';
controls.style.padding = '0';
controls.style.background = 'rgba(0, 0, 0, 0.0)';
// circle look
btn.style.width = '36px';
btn.style.height = '36px';
btn.style.padding = '0';
btn.style.borderRadius = '50%';
btn.style.fontSize = '16px';
btn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.20)';
btn.innerText = '🧩';
swapBtn.style.opacity = '0';
swapBtn.style.width = '0';
swapBtn.style.margin = '0';
swapBtn.style.pointerEvents = 'none';
swapBtn.style.border = '0';
}
function applyExpanded() {
expanded = true;
if (collapseTimer) {
clearTimeout(collapseTimer);
collapseTimer = null;
}
controls.style.width = 'auto';
controls.style.padding = '0';
controls.style.background = 'rgba(0, 0, 0, 0.0)';
btn.style.width = 'auto';
btn.style.height = 'auto';
btn.style.padding = '8px 18px';
btn.style.borderRadius = '999px';
btn.style.fontSize = '14px';
btn.innerText = 'merge images';
swapBtn.style.opacity = '1';
swapBtn.style.width = '34px';
swapBtn.style.pointerEvents = gridEligible ? 'auto' : 'none';
swapBtn.style.border = gridEligible ? '1px solid rgba(255, 255, 255, 0.3)' : '0';
}
function scheduleCollapse() {
if (collapseTimer) clearTimeout(collapseTimer);
collapseTimer = setTimeout(() => {
applyCollapsed();
}, 5000);
}
controls.addEventListener('mouseenter', () => {
applyExpanded();
});
controls.addEventListener('mouseleave', () => {
scheduleCollapse();
});
swapBtn.onmouseover = () => {
if (!swapBtn.disabled) swapBtn.style.background = 'rgba(29, 155, 240, 0.75)';
};
swapBtn.onmouseout = () => swapBtn.style.background = gridMode
? 'rgba(29, 155, 240, 0.85)'
: 'rgba(0, 0, 0, 0.55)';
swapBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (!gridEligible) return;
gridMode = !gridMode;
swapBtn.title = gridMode ? 'mode: grid (2x2)' : 'mode: vertical';
swapBtn.style.background = gridMode
? 'rgba(29, 155, 240, 0.85)'
: 'rgba(0, 0, 0, 0.55)';
swapBtn.innerText = gridMode ? '⊞' : '⇅';
});
btn.onmouseover = () => {
if (expanded) btn.style.transform = 'scale(1.05)';
};
btn.onmouseout = () => btn.style.transform = 'scale(1)';
controls.appendChild(swapBtn);
controls.appendChild(btn);
btnWrapper.appendChild(controls);
mediaRoot.appendChild(btnWrapper);
applyCollapsed();
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// if they click while collapsed, open it immediately
if (!expanded) applyExpanded();
btn.innerText = 'stitching...';
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.disabled = true;
swapBtn.disabled = true;
try {
const domOrderedUrls = [];
photoLinks.forEach(link => {
const img = link.querySelector('img');
if (img) {
const urlObj = new URL(img.src);
urlObj.searchParams.set('name', 'orig');
domOrderedUrls.push(urlObj.toString());
}
});
// if user somehow toggled grid, force back to vertical unless exactly 4
const useGrid = gridMode && domOrderedUrls.length === 4;
const canvas = useGrid
? await mergeGrid(domOrderedUrls)
: await mergeVertical(domOrderedUrls);
const mergedImg = document.createElement('img');
mergedImg.src = canvas.toDataURL('image/png');
mergedImg.style.cssText = `
width: 100%;
height: auto;
border-radius: 16px;
display: block;
border: 1px solid var(--cpft-border-color, rgba(255,255,255,0.1));
margin-top: 12px;
margin-bottom: 12px;
`;
if (mediaRoot.getAttribute('data-testid') === 'tweetText') {
photoLinks.forEach(link => {
let wrapper = link.closest('.r-6gpygo') || link.parentElement;
if (wrapper) wrapper.style.display = 'none';
});
} else {
mediaRoot.style.display = 'none';
}
mediaRoot.parentNode.insertBefore(mergedImg, mediaRoot.nextSibling);
// FIX: Remove the buttons once we're done so they don't linger
btnWrapper.remove();
} catch (err) {
console.error("failed to merge images:", err);
btn.innerText = '❌ error (check console)';
btn.style.background = 'rgba(244, 33, 46, 0.9)';
btn.disabled = false;
swapBtn.disabled = false;
}
});
}
}
});
});
}
const observer = new MutationObserver(() => {
clearTimeout(window.mergeCheckTimeout);
window.mergeCheckTimeout = setTimeout(checkAndInject, 300);
});
observer.observe(document.body, { childList: true, subtree: true });
checkAndInject();
})();