Twitter/X Split Image Merger

Merges 2+ part vertical or grid split images.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като 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();
})();