Greasy Fork

Greasy Fork is available in English.

Twitter/X Layout Modifier

Remove right sidebar, expand middle column, and inject custom vertical image stack with sizing fixes

当前为 2025-12-31 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Twitter/X Layout Modifier
// @namespace    http://tampermonkey.net/
// @license MIT
// @version      1.4.2
// @description  Remove right sidebar, expand middle column, and inject custom vertical image stack with sizing fixes
// @author       maye9999
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // 1. CSS for Structural Layout Changes (Sidebar & Column Width)
    const style = document.createElement('style');
    style.innerHTML = `
        /* Remove the right sidebar */
        [data-testid="sidebarColumn"] {
            display: none !important;
        }

        /* Expand the middle column (primaryColumn) */
        [data-testid="primaryColumn"] {
            max-width: 100% !important;
            width: 100% !important;
            flex-basis: auto !important;
        }

        /* Ensure parent containers allow expansion */
        div:has(> [data-testid="primaryColumn"]) {
             max-width: 100% !important;
             width: 100% !important;
        }
        
        .r-1ye8kvj {
            max-width: 100% !important;
        }
        
        /* Custom class for our injected container */
        .ag-custom-media-stack {
            display: flex;
            flex-direction: row;   /* Allow horizontal flow */
            flex-wrap: wrap;       /* Allow wrapping */
            width: 100%;
            margin-top: 10px;
            gap: 12px;             /* Space between images (both horizontal and vertical) */
            align-items: flex-start;
        }
        
        /* Image styling */
        .ag-custom-media-stack img {
            width: auto;
            max-width: 100%;       /* [EDIT HERE] Change to eg. '800px' or '80%' to limit width on laptops */
            max-height: 85vh;      /* [EDIT HERE] Change to eg. '60vh' to make images shorter/fit screen better */
            height: auto;
            border-radius: 12px;
            display: block;
            border: 1px solid rgba(255,255,255,0.1);
            cursor: pointer;
            object-fit: contain;
            flex: 0 1 auto;        /* Allow image to be its natural size, but shrink if needed */
        }
        
        /* Lightbox-like effect on click (optional simple zoom) */
        .ag-custom-media-stack img:active {
            transform: scale(1.02);
            transition: transform 0.1s;
        }
    `;
    document.head.appendChild(style);


    // 2. JavaScript for Custom Layout Injection
    function processTweets() {
        // Find all tweets or photo-containing elements
        const photos = document.querySelectorAll('[data-testid="tweetPhoto"]');

        // Map to store unique root containers we've found in this pass
        // Map<Element, Array<{type: 'img'|'video', src?: string, node?: Element, aspectRatio?: number}>>
        const rootsToProcess = new Map();

        photos.forEach(photo => {
            if (photo.hasAttribute('data-ag-processed')) return;

            // Determine content type: Image or Video?
            // Videos are often nested inside tweetPhoto or siblings in the same grid.
            // Actually, for mixed grids, Twitter might use tweetPhoto for images and something else for video, 
            // OR reuse tweetPhoto but put a video player inside.
            // Based on profile.html, videoPlayer is inside placementTracking inside tweetPhoto (or similar structure).

            let itemData = null;
            const videoComponent = photo.querySelector('[data-testid="videoPlayer"]');

            if (videoComponent) {
                // It's a video!
                // We want to grab the whole player to preserve functionality.
                // We also need the aspect ratio to size it correctly.
                // Twitter usually puts a padding-bottom on a child div for aspect ratio.
                let ratio = 0.5625; // Default 16:9
                const spacer = videoComponent.querySelector('div[style*="padding-bottom"]');
                if (spacer) {
                    const pb = spacer.style.paddingBottom;
                    if (pb && pb.includes('%')) {
                        ratio = parseFloat(pb) / 100;
                    }
                }

                itemData = {
                    type: 'video',
                    node: videoComponent, // We will move this node
                    aspectRatio: ratio
                };
            } else {
                // It's an image
                const img = photo.querySelector('img');
                if (!img) return; // Skip if no image found

                let src = img.src;
                if (src.includes('&name=')) {
                    src = src.replace(/&name=[a-z0-9]+/, '&name=large');
                }
                itemData = {
                    type: 'img',
                    src: src
                };
            }

            // Find root container logic (same as before)
            const tweet = photo.closest('[data-testid="tweet"]');
            if (tweet) {
                const allImagesInTweet = Array.from(tweet.querySelectorAll('[data-testid="tweetPhoto"]'));
                if (allImagesInTweet.length > 0) {
                    let ancestor = allImagesInTweet[0];
                    if (allImagesInTweet.length > 1) {
                        const parents = new Set();
                        let p = ancestor;
                        while (p && p !== tweet) {
                            parents.add(p);
                            p = p.parentElement;
                        }
                        let p2 = allImagesInTweet[1].parentElement;
                        while (p2 && p2 !== tweet) {
                            if (parents.has(p2)) {
                                ancestor = p2;
                                break;
                            }
                            p2 = p2.parentElement;
                        }
                    }

                    let contentRoot = ancestor;
                    while (contentRoot.parentElement && contentRoot.parentElement !== tweet) {
                        if (contentRoot.parentElement.querySelector('[data-testid="tweetText"]')) {
                            break;
                        }
                        contentRoot = contentRoot.parentElement;
                    }

                    if (!rootsToProcess.has(contentRoot)) {
                        rootsToProcess.set(contentRoot, []);
                    }

                    const list = rootsToProcess.get(contentRoot);
                    // Avoid duplicates
                    if (itemData.type === 'img') {
                        if (!list.some(i => i.type === 'img' && i.src === itemData.src)) {
                            list.push(itemData);
                        }
                    } else {
                        // For video, check reference equality or something? 
                        // Just check if we already added this node
                        if (!list.some(i => i.type === 'video' && i.node === itemData.node)) {
                            list.push(itemData);
                        }
                    }

                    photo.setAttribute('data-ag-processed', 'true');
                }
            }
        });

        // Now process the roots
        rootsToProcess.forEach((items, root) => {
            if (root.getAttribute('data-ag-replaced')) return;

            // Hide Root
            root.style.display = "none";
            root.setAttribute('data-ag-replaced', 'true');

            // Inject Custom Stack
            const stack = document.createElement('div');
            stack.className = 'ag-custom-media-stack';

            items.forEach((item) => {
                if (item.type === 'img') {
                    const img = document.createElement('img');
                    img.src = item.src;
                    img.onclick = (e) => {
                        e.stopPropagation();
                        window.open(item.src, '_blank');
                    };
                    stack.appendChild(img);
                } else if (item.type === 'video') {
                    // Wrap video in a container to handle layout
                    const wrapper = document.createElement('div');
                    wrapper.style.cssText = `
                        flex: 0 1 auto;
                        width: 100%;
                        position: relative;
                        border-radius: 12px;
                        overflow: hidden;
                        border: 1px solid rgba(255,255,255,0.1);
                     `;

                    // Calculate Max Width to respect 85vh constraint
                    // If Height = Width * Ratio, and Height_Max = 85vh
                    // Then Width_Max = 85vh / Ratio
                    if (item.aspectRatio > 0) {
                        // 85vh in pixels (approx) or use calc
                        // Using calc is safer: calc(85vh / ratio)
                        // But ratio is a number. Let's use JS to set a clean max-width percentage or pixel value if possible, 
                        // or just standard CSS max-height won't work on padding-hack boxes.

                        // We can set 'max-width' on the wrapper.
                        // 1 / ratio = inverse ratio (width/height)
                        // max-width = 85vh * (1/ratio)

                        const inverseRatio = 1 / item.aspectRatio;
                        wrapper.style.maxWidth = `calc(85vh * ${inverseRatio})`;
                    }

                    // Move the node
                    wrapper.appendChild(item.node);

                    // Ensure the video player fills our wrapper and is visible
                    // CRITICAL FIX: The original node is position:absolute. We must reset it.
                    item.node.style.cssText = `
                        position: relative !important;
                        top: auto !important;
                        left: auto !important;
                        right: auto !important;
                        bottom: auto !important;
                        width: 100% !important;
                        height: auto !important;
                        display: block !important;
                        max-width: none !important;
                        opacity: 1 !important;
                        transform: none !important;
                     `;

                    stack.appendChild(wrapper);
                }
            });

            root.insertAdjacentElement('afterend', stack);
        });
    }

    // 3. Observe the DOM for new tweets
    const observer = new MutationObserver((mutations) => {
        processTweets();
    });

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

    // Initial run
    setTimeout(processTweets, 500);
    setInterval(processTweets, 2000);

})();