Greasy Fork

Greasy Fork is available in English.

Twitter/X 微博式看图工具 - 添加缩放、拖拽和旋转功能

为 Twitter/X 图片添加缩放、拖拽和旋转功能。非常适合在桌面端阅读看不清的大段文字图片或长截图。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Twitter/X Image Viewer Enhanced - Adds zoom, drag, and rotation capabilities to Twitter/X images.
// @name:zh-CN   Twitter/X 微博式看图工具 - 添加缩放、拖拽和旋转功能
// @namespace    http://greasyfork.icu/en/users/1551895-piliplan
// @version      1.3.3
// @description  Adds zoom, drag, and rotation capabilities to Twitter/X images. Perfect for reading large text images or vertical screenshots that are hard to see on the desktop.
// @description:zh-CN 为 Twitter/X 图片添加缩放、拖拽和旋转功能。非常适合在桌面端阅读看不清的大段文字图片或长截图。
// @author       PILIPLAN
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    let state = {
        scale: 1,
        rotate: 0,
        x: 0,
        y: 0,
        isDragging: false,
        startX: 0,
        startY: 0
    };

    let lastToolbarX = 0;
    let lastToolbarY = 0;
    let activeElement = null;
    let toolbar = null;

    function isInsideScrollable(element) {
        let el = element;
        while (el && el !== document.body) {
            const style = window.getComputedStyle(el);
            const isScrollableY = style.overflowY === 'auto' || style.overflowY === 'scroll';
            const canScroll = el.scrollHeight > el.clientHeight;

            if (isScrollableY && canScroll) {
                return true;
            }
            el = el.parentElement;
        }
        return false;
    }

    function findCenterElement() {
        const layersContainer = document.querySelector('#layers');
        if (!layersContainer) return null;

        const candidates = layersContainer.querySelectorAll('img, div[style*="background-image"]');
        let bestCandidate = null;
        let minDistance = Infinity;

        const anchor = getAnchorPoint();
        const centerX = anchor ? anchor.x : window.innerWidth / 2;
        const centerY = window.innerHeight / 2;

        candidates.forEach(el => {
            const rect = el.getBoundingClientRect();
            if (rect.width < 50 || rect.height < 50) return;
            if (window.getComputedStyle(el).opacity === '0') return;

            let inSidebar = false;
            let pNode = el.parentElement;
            while (pNode && pNode !== document.body) {
                const style = window.getComputedStyle(pNode);
                if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
                    inSidebar = true;
                    break;
                }
                pNode = pNode.parentElement;
            }
            if (inSidebar) return;

            const dist = Math.sqrt(
                Math.pow((rect.left + rect.width / 2) - centerX, 2) +
                Math.pow((rect.top + rect.height / 2) - centerY, 2)
            );

            const isCloser = dist < minDistance - 1;
            const isSameDistButImg = Math.abs(dist - minDistance) <= 1 && el.tagName === 'IMG' && (!bestCandidate || bestCandidate.tagName !== 'IMG');

            if (isCloser || isSameDistButImg) {
                minDistance = dist;
                bestCandidate = el;
            }
        });

        if (bestCandidate && minDistance < 600) {
            let target = bestCandidate;
            let parent = target.parentElement;
            for (let i = 0; i < 5; i++) {
                if (parent && parent.tagName === 'DIV' &&
                    Math.abs(parent.offsetWidth - bestCandidate.offsetWidth) < 50 &&
                    Math.abs(parent.offsetHeight - bestCandidate.offsetHeight) < 50) {
                    target = parent;
                }
                parent = parent ? parent.parentElement : null;
            }
            return target;
        }

        return null;
    }

    function getAnchorPoint() {
        const btn = document.querySelector('#layers[data-testid="like"], #layers[data-testid="unlike"], #layers [data-testid="reply"], #layers[data-testid="retweet"]');
        if (btn) {
            const group = btn.closest('[role="group"]');
            if (group) {
                const rect = group.getBoundingClientRect();
                return { x: rect.left + rect.width / 2, y: rect.top };
            }
        }
        return null;
    }

    function updateToolbarPosition() {
        if (!toolbar) return;

        const anchor = getAnchorPoint();
        let targetX, targetY;

        if (anchor) {
            targetX = anchor.x;
            targetY = window.innerHeight - anchor.y + 10;
        } else {
            targetX = window.innerWidth / 2;
            targetY = 40;
        }

        if (Math.abs(targetX - lastToolbarX) < 0.5 && Math.abs(targetY - lastToolbarY) < 0.5) {
            return;
        }

        if (anchor) {
            toolbar.style.left = targetX + 'px';
            toolbar.style.bottom = targetY + 'px';
            if(toolbar.style.transform !== 'translateX(-50%)') toolbar.style.transform = 'translateX(-50%)';
        } else {
            toolbar.style.left = '50%';
            toolbar.style.bottom = '40px';
        }

        lastToolbarX = targetX;
        lastToolbarY = targetY;
    }


    function elevateUI() {
        const bottomBtn = document.querySelector('#layers [data-testid="like"], #layers[data-testid="unlike"], #layers [data-testid="reply"], #layers [data-testid="retweet"]');
        if (bottomBtn) {
            const group = bottomBtn.closest('[role="group"]');
            if (group && group.dataset.elevated !== 'true') {
                let p = group;
                for (let i = 0; i < 5; i++) {
                    if (p && p.tagName === 'DIV') {
                        p.style.setProperty('z-index', '10000', 'important');
                        if (window.getComputedStyle(p).position === 'static') {
                            p.style.setProperty('position', 'relative', 'important');
                        }
                        p = p.parentElement;
                    }
                }
                group.dataset.elevated = 'true';
            }
        }

        const topBtn = document.querySelector('#layers [data-testid="app-bar-back"]');
        if (topBtn && topBtn.dataset.elevated !== 'true') {
            let p = topBtn;
            for (let i = 0; i < 5; i++) {
                if (p && p.tagName === 'DIV') {
                    p.style.setProperty('z-index', '10000', 'important');
                    if (window.getComputedStyle(p).position === 'static') {
                        p.style.setProperty('position', 'relative', 'important');
                    }
                    p = p.parentElement;
                }
            }
            topBtn.dataset.elevated = 'true';
        }
    }

    function unclipParents(element) {
        if (!element) return;
        let parent = element.parentElement;
        for (let i = 0; i < 8; i++) {
            if (parent && parent.style) {
                const style = window.getComputedStyle(parent);
                if (style.overflow !== 'visible') parent.style.setProperty('overflow', 'visible', 'important');
                if (style.maskType || style.webkitMask) {
                     parent.style.mask = 'none';
                     parent.style.webkitMask = 'none';
                }
            }
            parent = parent ? parent.parentElement : null;
        }
    }

    function updateTransform() {
        if (!activeElement) activeElement = findCenterElement();
        if (!activeElement) return;

        unclipParents(activeElement);

        activeElement.style.transform = `
            translate(${state.x}px, ${state.y}px)
            rotate(${state.rotate}deg)
            scale(${state.scale})
        `;

        activeElement.style.transition = state.isDragging ? 'none' : 'transform 0.1s linear';
        activeElement.style.cursor = state.isDragging ? 'grabbing' : 'grab';
        activeElement.style.zIndex = '9999';

        if (activeElement.tagName === 'DIV' && activeElement.style.backgroundImage) {
            activeElement.style.backgroundSize = 'contain';
        }
    }

    function zoom(delta) {
        state.scale += delta;
        if (state.scale < 0.1) state.scale = 0.1;
        initDragEvents();
        updateTransform();
    }

    function rotate(deg) {
        state.rotate += deg;
        updateTransform();
    }

    function reset() {
        if (!activeElement) {
             state = { scale: 1, rotate: 0, x: 0, y: 0, isDragging: false, startX: 0, startY: 0 };
             return;
        }

        let currentRot = state.rotate % 360;
        if (currentRot > 180) currentRot -= 360;
        if (currentRot < -180) currentRot += 360;

        activeElement.style.transition = 'none';
        activeElement.style.transform = `
            translate(${state.x}px, ${state.y}px)
            rotate(${currentRot}deg)
            scale(${state.scale})
        `;

        void activeElement.offsetHeight;

        state = { scale: 1, rotate: 0, x: 0, y: 0, isDragging: false, startX: 0, startY: 0 };

        activeElement.style.transition = 'transform 0.2s ease-out';
        activeElement.style.transform = '';
        activeElement.style.cursor = 'grab';
        activeElement.style.zIndex = '';
    }

    window.addEventListener('wheel', (e) => {
        if (!location.href.includes('/photo/')) return;
        if (isInsideScrollable(e.target)) return;

        const target = findCenterElement();
        if (!target) return;

        activeElement = target;

        e.preventDefault();
        e.stopImmediatePropagation();

        const delta = e.deltaY > 0 ? -0.1 : 0.1;
        zoom(delta);

    }, { passive: false });

    function initDragEvents() {
        if (!activeElement) activeElement = findCenterElement();
        if (!activeElement || activeElement.dataset.dragBound) return;

        activeElement.dataset.dragBound = 'true';
        activeElement.style.cursor = 'grab';

        activeElement.addEventListener('mousedown', (e) => {
            if (e.button !== 0) return;
            e.preventDefault();
            e.stopPropagation();
            state.isDragging = true;
            state.startX = e.clientX - state.x;
            state.startY = e.clientY - state.y;
            activeElement.style.cursor = 'grabbing';
        });
    }

    window.addEventListener('mousemove', (e) => {
        if (!state.isDragging) return;
        e.preventDefault();
        state.x = e.clientX - state.startX;
        state.y = e.clientY - state.startY;
        updateTransform();
    });

    window.addEventListener('mouseup', () => {
        state.isDragging = false;
        if (activeElement) activeElement.style.cursor = 'grab';
    });

    function createToolbar() {
        if (document.getElementById('x-fusion-toolbar')) return;

        toolbar = document.createElement('div');
        toolbar.id = 'x-fusion-toolbar';

        toolbar.style.cssText = `
            position: fixed;
            transform: translateX(-50%);
            z-index: 2147483647;
            background: rgba(0, 0, 0, 0.4);
            padding: 8px 25px;
            border-radius: 50px;
            display: flex;
            gap: 25px;
            backdrop-filter: blur(8px);
            border: 1px solid rgba(255,255,255,0.15);
            box-shadow: 0 4px 15px rgba(0,0,0,0.3);
            opacity: 0; pointer-events: none; transition: opacity 0.2s;
        `;

        toolbar.addEventListener('mousedown', e => e.stopPropagation());

        const btns =[
            { t: '⟲', f: () => rotate(-90), title: 'Rotate Left' },
            { t: '-', f: () => zoom(-0.25), title: 'Zoom Out' },
            { t: 'RESET', f: reset, s: 'font-size:12px; font-weight:700; letter-spacing:1px; opacity:0.9;', title: 'Reset All' },
            { t: '+', f: () => zoom(0.25), title: 'Zoom In' },
            { t: '⟳', f: () => rotate(90), title: 'Rotate Right' }
        ];

        btns.forEach(b => {
            const s = document.createElement('span');
            s.innerHTML = b.t;
            s.style.cssText = `
                color: rgba(255, 255, 255, 0.9);
                cursor: pointer; font-size: 22px; width: 30px;
                display: flex; justify-content: center; align-items: center;
                user-select: none; transition: transform 0.1s;
                text-shadow: 0 1px 2px rgba(0,0,0,0.5);
                ${b.s||''}
            `;
            s.onmouseover = () => { s.style.color = '#fff'; s.style.transform = 'scale(1.1)'; };
            s.onmouseout = () => { s.style.color = 'rgba(255, 255, 255, 0.9)'; s.style.transform = 'scale(1)'; };
            s.onclick = (e) => { e.stopPropagation(); b.f(); };
            toolbar.appendChild(s);
        });

        document.body.appendChild(toolbar);
    }

    function loop() {
        const isPhotoView = location.href.includes('/photo/');
        if (!toolbar) createToolbar();

        if (isPhotoView) {
            toolbar.style.opacity = '1';
            toolbar.style.pointerEvents = 'auto';

            updateToolbarPosition();
            elevateUI();

            if (!activeElement) {
                 activeElement = findCenterElement();
                 if (activeElement) initDragEvents();
            }
        } else {
            toolbar.style.opacity = '0';
            toolbar.style.pointerEvents = 'none';
            if (state.scale !== 1 || state.rotate !== 0) reset();
            activeElement = null;
        }

        requestAnimationFrame(loop);
    }

    requestAnimationFrame(loop);

    window.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') reset();
    });

})();