FrostFall

One-click tool to combine multiple Tweet photos into one continuous image.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         FrostFall
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  One-click tool to combine multiple Tweet photos into one continuous image.
// @author       blackjack
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // Styles
    const styles = `
.tic-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border-radius: 9999px;
    transition-duration: 0.2s;
    cursor: pointer;
    margin-left: 8px;
}

.tic-btn:hover {
    background-color: rgba(29, 155, 240, 0.1);
}

.tic-btn svg {
    color: rgb(83, 100, 113);
    fill: currentColor;
    width: 20px;
    height: 20px;
}

.tic-btn:hover svg {
    color: rgb(29, 155, 240);
}

.tic-loading {
    opacity: 0.5;
    pointer-events: none;
}

/* Modal Overlay */
.tic-modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    background-color: rgba(0, 0, 0, 0.85);
    z-index: 99999;
    display: flex;
    justify-content: center;
    align-items: center;
    opacity: 0;
    animation: tic-fade-in 0.2s forwards;
}

@keyframes tic-fade-in {
    to { opacity: 1; }
}

/* Modal Content Container */
.tic-modal-content {
    position: relative;
    max-width: 90vw;
    max-height: 90vh;
    display: flex;
    flex-direction: column;
    align-items: center;
}

/* The Combined Image */
.tic-modal-image {
    max-width: 100%;
    max-height: 85vh;
    object-fit: contain;
    box-shadow: 0 4px 20px rgba(0,0,0,0.5);
    border-radius: 4px;
}

/* Close Button (Top Right) */
.tic-modal-close {
    position: absolute;
    top: -40px;
    right: -40px;
    color: white;
    font-size: 30px;
    cursor: pointer;
    background: none;
    border: none;
    padding: 10px;
    line-height: 1;
}

.tic-modal-close:hover {
    color: #1d9bf0;
}

/* Action Bar (Download Button) */
.tic-modal-actions {
    margin-top: 15px;
}

.tic-btn-download {
    background-color: #1d9bf0;
    color: white;
    border: none;
    padding: 10px 24px;
    border-radius: 9999px;
    font-weight: bold;
    font-size: 15px;
    cursor: pointer;
    transition: background-color 0.2s;
    display: flex;
    align-items: center;
    gap: 8px;
}

.tic-btn-download:hover {
    background-color: #1a8cd8;
}
    `;

    GM_addStyle(styles);

    // Watch for DOM changes to inject buttons
    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            if (mutation.addedNodes.length) {
                injectButtons();
            }
        });
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    function injectButtons() {
        const groups = document.querySelectorAll('div[role="group"]:not(.tic-processed)');

        groups.forEach(group => {
            const article = group.closest('article[data-testid="tweet"]');
            if (!article) return;

            group.classList.add('tic-processed');

            const btn = createBtn();
            btn.onclick = (e) => handleCombine(e, article);

            group.appendChild(btn);
        });
    }

    function createBtn() {
        const div = document.createElement('div');
        div.className = 'tic-btn css-1dbjc4n r-18u37iz r-1h0z5md';
        div.setAttribute('role', 'button');
        div.setAttribute('aria-label', 'Combine Images');
        div.setAttribute('title', 'Combine Images');

        // Icon: Snowflake style (FrostFall theme)
        div.innerHTML = `
        <svg viewBox="0 0 24 24" aria-hidden="true" class="r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-18jsvk2">
            <g>
                <path d="M21 11h-3.64l1.88-2.61c.45-.63.3-1.51-.33-1.96-.63-.45-1.51-.3-1.96.33L15.2 9.2V6c0-.77-.63-1.4-1.4-1.4-.77 0-1.4.63-1.4 1.4v3.2l-1.75-2.43c-.45-.63-1.33-.78-1.96-.33-.63.45-.78 1.33-.33 1.96L10.24 11H6.6c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4h3.64l-1.88 2.61c-.45.63-.3 1.51.33 1.96.26.19.56.28.85.28.43 0 .86-.2 1.11-.56l1.75-2.43V19c0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4v-3.2l1.75 2.43c.25.35.68.56 1.11.56.29 0 .59-.09.85-.28.63-.45.78-1.33.33-1.96L17.36 14H21c.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4z"></path>
            </g>
        </svg>
        `;
        return div;
    }

    async function handleCombine(e, article) {
        e.stopPropagation();
        const btn = e.currentTarget;
        const originalContent = btn.innerHTML;

        try {
            btn.classList.add('tic-loading');
            btn.innerHTML = '<span style="font-size:12px">...</span>';

            const images = getTweetImages(article);

            if (images.length === 0) {
                alert('No images found in this tweet!');
                return;
            }

            const blobs = await Promise.all(images.map(url => fetchImage(url)));
            const bitmaps = await Promise.all(blobs.map(blob => createImageBitmap(blob)));

            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');

            let totalHeight = 0;
            let maxWidth = 0;

            bitmaps.forEach(bmp => {
                totalHeight += bmp.height;
                maxWidth = Math.max(maxWidth, bmp.width);
            });

            canvas.width = maxWidth;
            canvas.height = totalHeight;

            let currentY = 0;
            bitmaps.forEach(bmp => {
                const x = (maxWidth - bmp.width) / 2;
                ctx.drawImage(bmp, x, currentY);
                currentY += bmp.height;
            });

            canvas.toBlob((blob) => {
                showPreviewModal(blob);
            }, 'image/png');

        } catch (err) {
            console.error('Error combining images:', err);
            alert('Failed to combine images. See console for details.');
        } finally {
            btn.classList.remove('tic-loading');
            btn.innerHTML = originalContent;
        }
    }

    function showPreviewModal(blob) {
        const url = URL.createObjectURL(blob);

        const overlay = document.createElement('div');
        overlay.className = 'tic-modal-overlay';

        overlay.innerHTML = `
            <div class="tic-modal-content">
                <button class="tic-modal-close" aria-label="Close">×</button>
                <img src="${url}" class="tic-modal-image" alt="Combined Image">
                <div class="tic-modal-actions">
                    <button class="tic-btn-download">
                        <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M21 16v-2l-8-5-8 5v2h16zm2-8H1v11c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8zm-2 11H3v-2l8-5 8 5v2zM5 8h14V6H5v2z"></path></svg>
                        Download Image
                    </button>
                </div>
            </div>
        `;

        const closeBtn = overlay.querySelector('.tic-modal-close');
        const downloadBtn = overlay.querySelector('.tic-btn-download');
        const img = overlay.querySelector('.tic-modal-image');

        const escHandler = (e) => {
            if (e.key === 'Escape') {
                closeModal(overlay, url, escHandler);
            }
        };
        document.addEventListener('keydown', escHandler);

        closeBtn.onclick = () => closeModal(overlay, url, escHandler);

        overlay.onclick = (e) => {
            if (e.target === overlay) closeModal(overlay, url, escHandler);
        };

        downloadBtn.onclick = () => {
            const a = document.createElement('a');
            const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
            a.download = `frostfall-${timestamp}.png`;
            a.href = url;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
        };

        document.body.appendChild(overlay);
    }

    function closeModal(overlay, url, escHandler) {
        if (escHandler) {
            document.removeEventListener('keydown', escHandler);
        }

        if (overlay && overlay.parentNode) {
            overlay.parentNode.removeChild(overlay);
        }

        setTimeout(() => URL.revokeObjectURL(url), 100);
    }

    function getTweetImages(article) {
        const imgs = Array.from(article.querySelectorAll('img[src*="pbs.twimg.com/media"]'));

        return imgs.filter(img => {
            const closestTweet = img.closest('[data-testid="tweet"]');
            if (closestTweet !== article) return false;
            if (img.closest('div[role="link"]')) return false;
            return true;
        }).map(img => {
            const url = new URL(img.src);
            if (url.searchParams.has('name')) {
                url.searchParams.set('name', 'orig');
            }
            return url.toString();
        });
    }

    async function fetchImage(url) {
        const response = await fetch(url);
        if (!response.ok) throw new Error('Network error');
        return response.blob();
    }

    injectButtons();
})();