Twitter/X Split Image Merger

Merges 2+ part vertical or grid split images.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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