Twitter/X Split Image Merger

Merges 2+ part vertical or grid split images.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();