FrostFall

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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