Twitter/X Split Image Merger

Merges 2+ part vertical or grid split images.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==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();
})();