Twitter/X Split Image Merger

Merges 2+ part vertical or grid split images.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
})();