X Split Image Merger (Pro)_EN

Automatically merge 4-part split images on X (Twitter) in strict 1-2-3-4 order, bypassing CORS issues.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         X Split Image Merger (Pro)_EN
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Automatically merge 4-part split images on X (Twitter) in strict 1-2-3-4 order, bypassing CORS issues.
// @author       https://x.com/Jiangshi_i
// @match        *://*.x.com/*
// @match        *://*.twitter.com/*
// @grant        GM_xmlhttpRequest
// @connect      pbs.twimg.com
// @icon         https://abs.twimg.com/favicons/twitter.3.ico
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 1. Use MutationObserver instead of setInterval for better performance
    const observer = new MutationObserver(checkForImages);
    observer.observe(document.body, { childList: true, subtree: true });

    function checkForImages() {
        // Accurately locate the main tweet content to avoid grabbing profile pictures
        const posts = document.querySelectorAll('article[data-testid="tweet"]');

        posts.forEach(post => {
            // Must be an img inside tweetPhoto to ensure it's a media image in the post
            const images = post.querySelectorAll('[data-testid="tweetPhoto"] img');

            // If there are exactly 4 images and no button has been added yet
            if (images.length === 4 && !post.querySelector('.merge-btn-pro')) {
                addMergeButton(post, images);
            }
        });
    }

    function addMergeButton(post, images) {
        const buttonGroup = post.querySelector('[role="group"]'); // Usually the row with Retweet/Like buttons
        const button = document.createElement('button');
        button.textContent = 'Merge Images ⬇️';
        button.className = 'merge-btn-pro';
        // Simple UI styling
        button.style.cssText = `
            margin: 10px 0;
            padding: 8px 16px;
            background-color: #1d9bf0;
            color: white;
            border: none;
            border-radius: 9999px;
            font-weight: bold;
            cursor: pointer;
            font-size: 14px;
        `;

        button.onclick = (e) => {
            e.stopPropagation(); // Prevent accidentally clicking into the tweet
            button.textContent = 'Processing...';
            button.style.backgroundColor = '#536471';
            mergeImages(images, button);
        };

        // Insert the button below the images, above the interaction buttons
        if (buttonGroup) {
            buttonGroup.parentElement.insertBefore(button, buttonGroup);
        } else {
            post.appendChild(button);
        }
    }

    // Bypass CORS restrictions using Tampermonkey's built-in request to get Blob data
    function fetchImageAsBase64(url) {
        return new Promise((resolve, reject) => {
            // Replace the image quality parameter with 'orig' to get the highest resolution
            const highResUrl = url.replace(/&name=[^&]+/, '&name=orig');

            GM_xmlhttpRequest({
                method: 'GET',
                url: highResUrl,
                responseType: 'blob',
                onload: function(response) {
                    const reader = new FileReader();
                    reader.onloadend = () => resolve(reader.result);
                    reader.readAsDataURL(response.response);
                },
                onerror: reject
            });
        });
    }

    async function mergeImages(imageNodes, btnElement) {
        try {
            // Convert NodeList to an array. The order here is strictly: 0(Top-Left), 1(Top-Right), 2(Bottom-Left), 3(Bottom-Right)
            const imgUrls = Array.from(imageNodes).map(img => img.src);

            // 1. Fetch all image Base64 data concurrently (Promise.all ensures the returned array order matches imgUrls)
            const base64List = await Promise.all(imgUrls.map(fetchImageAsBase64));

            // 2. Convert Base64 to Image objects for Canvas drawing
            const loadedImages = await Promise.all(base64List.map(base64 => {
                return new Promise(resolve => {
                    const img = new Image();
                    img.onload = () => resolve(img);
                    img.src = base64;
                });
            }));

            // 3. Calculate canvas dimensions
            // Assuming all images have the same width, use the first image's width as the baseline
            const targetWidth = loadedImages[0].naturalWidth;
            let totalHeight = 0;

            // Calculate the proportional height for each segment based on the target width and accumulate total height
            loadedImages.forEach(img => {
                const scale = targetWidth / img.naturalWidth;
                totalHeight += img.naturalHeight * scale;
            });

            // 4. Create canvas and draw in strict order
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            canvas.width = targetWidth;
            canvas.height = totalHeight;

            let currentY = 0;
            // Since loadedImages is in 1-2-3-4 order, iterating and drawing directly results in top-to-bottom order
            loadedImages.forEach(img => {
                const scale = targetWidth / img.naturalWidth;
                const drawHeight = img.naturalHeight * scale;
                ctx.drawImage(img, 0, currentY, targetWidth, drawHeight);
                currentY += drawHeight; // Move Y coordinate downwards
            });

            // 5. Trigger download
            downloadCanvas(canvas);

            // Restore button state
            btnElement.textContent = 'Merged ✅';
            btnElement.style.backgroundColor = '#00ba7c';
            setTimeout(() => {
                btnElement.textContent = 'Merge Images ⬇️';
                btnElement.style.backgroundColor = '#1d9bf0';
            }, 3000);

        } catch (error) {
            console.error("Failed to merge images:", error);
            btnElement.textContent = 'Failed ❌';
            btnElement.style.backgroundColor = '#f4212e';
            alert('Image download failed. This might be due to network issues or CORS restrictions. Press F12 to check the console for errors.');
        }
    }

    function downloadCanvas(canvas) {
        const link = document.createElement('a');
        link.download = `X_Merged_Image_${new Date().getTime()}.png`;
        link.href = canvas.toDataURL('image/png', 1.0);
        link.click();
    }
})();