OpenWebUI Force HTML Image Renderer with Lightbox

OpenWebUI 强制 HTML 图片渲染, 识别 <img> 标签,基于 OpenWebUI v0.6.10 开发。带有灯箱功能。使用前请更改 @match 后面的 URL 以使脚本在你的 OpenWebUI URL中工作。

От 25.05.2025. Виж последната версия.

// ==UserScript==
// @name         OpenWebUI Force HTML Image Renderer with Lightbox
// @version      1.2.0
// @description  OpenWebUI 强制 HTML 图片渲染, 识别 <img> 标签,基于 OpenWebUI v0.6.10 开发。带有灯箱功能。使用前请更改 @match 后面的 URL 以使脚本在你的 OpenWebUI URL中工作。
// @author       B3000Kcn
// @match        https://your.openwebui.url/*
// @run-at       document-idle
// @license      MIT
// @namespace https://greasyfork.org/users/1474401
// ==/UserScript==

(function() {
    'use strict';

    // --- 灯箱及交互相关变量和函数 ---
    let lightboxInstance = null;
    let currentLightboxImage = null;
    let currentScale = 1.0;
    let currentTranslateX = 0;
    let currentTranslateY = 0;

    const ZOOM_STEP = 0.2;
    const MIN_SCALE = 0.1;
    const MAX_SCALE = 8.0;

    let isPanning = false;
    let panStartX = 0, panStartY = 0;
    let imageStartTranslateX = 0, imageStartTranslateY = 0;

    let isPinching = false;
    let initialPinchDistance = 0;
    let initialPinchScale = 1.0;


    function createLightboxStyles() {
        // Note: currentTranslateX and currentTranslateY are used in the keyframes animation.
        // They will be their initial values (0) when this function is first called.
        // This is fine as the animation is for the initial appearance.
        const css = `
            .gm-lightbox-overlay {
                position: fixed; top: 0; left: 0; width: 100%; height: 100%;
                background-color: rgba(0, 0, 0, 0.88);
                display: flex; justify-content: center; align-items: center;
                z-index: 99999; cursor: pointer;
                opacity: 0; animation: gm-lightbox-fadein-overlay 0.25s forwards;
            }
            .gm-lightbox-image-container {
                position: relative;
                cursor: default;
                animation: gm-lightbox-fadein-content 0.25s forwards;
            }
            .gm-lightbox-image {
                max-width: 90vw; max-height: 90vh; display: block;
                /* border: 2px solid rgba(255,255,255,0.9); */ /* <-- 移除了边框 */
                box-shadow: 0 8px 30px rgba(0,0,0,0.7);
                border-radius: 3px;
                transition: transform 0.15s cubic-bezier(0.25, 0.1, 0.25, 1);
                transform-origin: center center;
                user-select: none;
                -webkit-user-drag: none;
            }
            @keyframes gm-lightbox-fadein-overlay { from { opacity: 0; } to { opacity: 1; } }
            @keyframes gm-lightbox-fadein-content { from { opacity: 0; transform: translate(${currentTranslateX}px, ${currentTranslateY}px) scale(0.9); } to { opacity: 1; transform: translate(${currentTranslateX}px, ${currentTranslateY}px) scale(1); } }
        `;
        const styleSheet = document.createElement("style");
        styleSheet.type = "text/css";
        styleSheet.innerText = css;
        document.head.appendChild(styleSheet);
    }

    function applyCurrentTransform() {
        if (currentLightboxImage) {
            currentLightboxImage.style.transform = `translate(${currentTranslateX}px, ${currentTranslateY}px) scale(${currentScale})`;
            if (!isPanning) {
                currentLightboxImage.style.cursor = (currentScale > 1.01) ? 'grab' : 'pointer';
            }
        }
    }

    function zoomImage(zoomInFactor) { // Simplified, zoom from center
        if (!currentLightboxImage) return;
        const oldScale = currentScale;
        let newScale;
        if (typeof zoomInFactor === 'boolean') {
            newScale = zoomInFactor ? oldScale * (1 + ZOOM_STEP) : oldScale / (1 + ZOOM_STEP);
        } else { // From wheel, zoomInFactor is the multiplier (not used in current wheel impl)
             newScale = oldScale * zoomInFactor; // This branch not hit by current wheel
        }
        currentScale = Math.max(MIN_SCALE, Math.min(newScale, MAX_SCALE));
        applyCurrentTransform();
    }
    
    function resetZoomAndPan() {
        if (!currentLightboxImage) return;
        currentScale = 1.0;
        currentTranslateX = 0;
        currentTranslateY = 0;
        applyCurrentTransform();
    }

    function handleImageWheelZoom(event) {
        if (!currentLightboxImage) return;
        event.preventDefault();
        event.stopPropagation();
        // True for zoom in (wheel up, deltaY < 0), False for zoom out
        zoomImage(event.deltaY < 0);
    }

    function handleMouseDownPan(event) {
        if (event.button !== 0 || !currentLightboxImage || currentScale <= 1.01) return;
        isPanning = true;
        panStartX = event.clientX;
        panStartY = event.clientY;
        imageStartTranslateX = currentTranslateX;
        imageStartTranslateY = currentTranslateY;
        currentLightboxImage.style.cursor = 'grabbing';
        event.preventDefault();
        window.addEventListener('mousemove', handleMouseMovePan, { passive: false });
        window.addEventListener('mouseup', handleMouseUpPan, { passive: false });
    }

    function handleMouseMovePan(event) {
        if (!isPanning || !currentLightboxImage) return;
        event.preventDefault();
        const dx = event.clientX - panStartX;
        const dy = event.clientY - panStartY;
        currentTranslateX = imageStartTranslateX + dx;
        currentTranslateY = imageStartTranslateY + dy;
        applyCurrentTransform();
    }

    function handleMouseUpPan() {
        if (!isPanning) return;
        isPanning = false;
        if (currentLightboxImage) {
             applyCurrentTransform();
        }
        window.removeEventListener('mousemove', handleMouseMovePan);
        window.removeEventListener('mouseup', handleMouseUpPan);
    }

    function getTouchDistance(touch1, touch2) {
        return Math.hypot(touch1.clientX - touch2.clientX, touch1.clientY - touch2.clientY);
    }

    function handleTouchStart(event) {
        if (!currentLightboxImage) return;
        const touches = event.touches;
        if (touches.length === 2) {
            isPinching = true;
            isPanning = false; 
            initialPinchDistance = getTouchDistance(touches[0], touches[1]);
            initialPinchScale = currentScale;
            event.preventDefault();
        } else if (touches.length === 1 && currentScale > 1.01) {
            isPanning = true;
            isPinching = false;
            panStartX = touches[0].clientX;
            panStartY = touches[0].clientY;
            imageStartTranslateX = currentTranslateX;
            imageStartTranslateY = currentTranslateY;
            event.preventDefault();
        }
    }

    function handleTouchMove(event) {
        if (!currentLightboxImage) return;
        event.preventDefault(); 
        const touches = event.touches;
        if (isPinching && touches.length === 2) {
            const currentPinchDistance = getTouchDistance(touches[0], touches[1]);
            if (initialPinchDistance > 0) {
                let newScale = initialPinchScale * (currentPinchDistance / initialPinchDistance);
                currentScale = Math.max(MIN_SCALE, Math.min(newScale, MAX_SCALE));
                applyCurrentTransform();
            }
        } else if (isPanning && touches.length === 1) {
            const dx = touches[0].clientX - panStartX;
            const dy = touches[0].clientY - panStartY;
            currentTranslateX = imageStartTranslateX + dx;
            currentTranslateY = imageStartTranslateY + dy;
            applyCurrentTransform();
        }
    }

    function handleTouchEnd(event) {
        if (isPinching && event.touches.length < 2) {
            isPinching = false;
        }
        if (isPanning && event.touches.length < 1) {
            isPanning = false;
            if (currentLightboxImage) applyCurrentTransform();
        }
    }

    function showLightbox(imageUrl) {
        if (lightboxInstance) hideLightbox();

        currentScale = 1.0;
        currentTranslateX = 0;
        currentTranslateY = 0;

        const overlay = document.createElement('div');
        overlay.className = 'gm-lightbox-overlay';
        overlay.addEventListener('click', hideLightbox); 

        const imageContainer = document.createElement('div');
        imageContainer.className = 'gm-lightbox-image-container';
        imageContainer.addEventListener('click', (e) => e.stopPropagation()); 

        const img = document.createElement('img');
        img.className = 'gm-lightbox-image';
        img.src = imageUrl;
        img.alt = 'Lightbox image';
        img.draggable = false; 
        currentLightboxImage = img;
        applyCurrentTransform(); 

        img.onload = () => {
            applyCurrentTransform(); 
            img.addEventListener('wheel', handleImageWheelZoom, { passive: false });
            img.addEventListener('mousedown', handleMouseDownPan, { passive: false });
            
            imageContainer.addEventListener('touchstart', handleTouchStart, { passive: false });
            imageContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
            imageContainer.addEventListener('touchend', handleTouchEnd);
            imageContainer.addEventListener('touchcancel', handleTouchEnd);
        };
        img.onerror = () => { console.warn("Lightbox: Image failed to load."); hideLightbox(); };

        imageContainer.appendChild(img);
        overlay.appendChild(imageContainer);
        document.body.appendChild(overlay);
        lightboxInstance = overlay;
        document.addEventListener('keydown', handleLightboxKeyDown);
    }

    function hideLightbox() {
        if (lightboxInstance) {
            if (currentLightboxImage) {
                currentLightboxImage = null;
            }
            window.removeEventListener('mousemove', handleMouseMovePan); 
            window.removeEventListener('mouseup', handleMouseUpPan);

            lightboxInstance.remove();
            lightboxInstance = null;
            document.removeEventListener('keydown', handleLightboxKeyDown);
            currentScale = 1.0;
            currentTranslateX = 0;
            currentTranslateY = 0;
            isPanning = false;
            isPinching = false;
        }
    }

    function handleLightboxKeyDown(event) {
        if (!lightboxInstance) return;
        if (event.key === 'Escape') {
            hideLightbox();
        } else if (event.key === '+' || event.key === '=') {
            zoomImage(true); event.preventDefault();
        } else if (event.key === '-') {
            zoomImage(false); event.preventDefault();
        } else if (event.key === '0') {
            resetZoomAndPan(); event.preventDefault();
        }
    }

    // --- 脚本核心常量和函数 ---
    const SCRIPT_VERSION = '1.0.0-dom-hybrid.6'; // 更新版本号
    const TARGET_DIV_DEPTH = 22;
    const PROCESSED_CLASS = `html-img-rendered-v1-0-0`;
    const ALLOWED_CONTAINER_TAGS = ['P', 'SPAN', 'PRE', 'CODE', 'LI', 'BLOCKQUOTE', 'FIGCAPTION', 'LABEL', 'SMALL', 'STRONG', 'EM', 'B', 'I', 'U', 'SUB', 'SUP', 'MARK', 'DEL', 'INS', 'TD', 'TH', 'DT', 'DD'];
    const TAGS_TO_SKIP_PROCESSING = ['SCRIPT', 'STYLE', 'HEAD', 'META', 'LINK', 'TITLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'BUTTON', 'SELECT', 'FORM', 'IFRAME', 'VIDEO', 'AUDIO', 'CANVAS', 'SVG', 'IMG'];
    const escapedImgTagRegex = /&lt;img\s+(.*?)&gt;/gi;
    const directImgTagRegex = /<img\s+([^>]*)>/gi;

    function getElementDepth(element) {
        let depth = 0; let el = element;
        while (el) { depth++; el = el.parentElement; }
        return depth;
    }
    function getAttributeValue(attributesString, attributeName) {
        if (!attributesString || !attributeName) return null;
        try {
            let regex = new RegExp(`${attributeName}\\s*=\\s*(["'])(.*?)\\1`, 'i');
            let match = attributesString.match(regex);
            if (match && match[2] !== undefined) return match[2];
            regex = new RegExp(`${attributeName}\\s*=\\s*([^\\s"'<>]+)`, 'i');
            match = attributesString.match(regex);
            if (match && match[1] !== undefined) return match[1];
            return null;
        } catch (e) { return null; }
    }
    function processImageTag(match, attributesStringOriginal, isEscaped) {
        if (typeof match !== 'string' || match.length === 0) return match;
        if (!isEscaped && match.startsWith("<img") && match.includes('data-force-rendered="true"')) return match;
        const attributesString = attributesStringOriginal.trim();
        const src = getAttributeValue(attributesString, 'src');
        if (!src || (typeof src === 'string' && src.toLowerCase().startsWith('javascript:'))) return match;
        let width = getAttributeValue(attributesString, 'width');
        let height = getAttributeValue(attributesString, 'height');
        const styleString = getAttributeValue(attributesString, 'style');
        const alt = getAttributeValue(attributesString, 'alt');
        if ((!width || String(width).trim() === '') && styleString) {
            const styleWidthMatch = styleString.match(/width\s*:\s*([^;!]+)/i);
            if (styleWidthMatch && styleWidthMatch[1]) width = styleWidthMatch[1].trim();
        }
        if ((!height || String(height).trim() === '') && styleString) {
            const styleHeightMatch = styleString.match(/height\s*:\s*([^;!]+)/i);
            if (styleHeightMatch && styleHeightMatch[1]) height = styleHeightMatch[1].trim();
        }
        const img = document.createElement('img');
        img.src = src;
        img.alt = alt ? alt : `用户HTML图片 (${isEscaped ? '转义' : '直接'}) v${SCRIPT_VERSION}`;
        img.setAttribute('data-force-rendered', 'true');
        if (width && String(width).trim() !== '') {
            const wStr = String(width).trim();
            if (wStr.includes('%') || wStr.match(/^[0-9.]+(em|rem|px|vw|vh|pt|cm|mm|in|auto)$/i)) img.style.width = wStr;
            else img.setAttribute('width', wStr.replace(/px$/i, ''));
        }
        if (height && String(height).trim() !== '') {
            const hStr = String(height).trim();
            if (hStr.includes('%') || hStr.match(/^[0-9.]+(em|rem|px|vw|vh|pt|cm|mm|in|auto)$/i)) img.style.height = hStr;
            else img.setAttribute('height', hStr.replace(/px$/i, ''));
        }
        img.style.maxWidth = '100%';
        img.style.display = 'block';
        const imgHTML = img.outerHTML;
        return `<p class="userscript-image-paragraph" style="margin: 0.5em 0; line-height: normal;">${imgHTML}</p>`;
    }
    function renderImagesInElement(element, forcedByObserver = false) {
        if (!element || typeof element.classList === 'undefined' || TAGS_TO_SKIP_PROCESSING.includes(element.tagName.toUpperCase()) || element.isContentEditable) return;
        const currentDepth = getElementDepth(element);
        if (currentDepth !== TARGET_DIV_DEPTH) return;
        const isAllowedTypeAtTargetDepth = (element.tagName.toUpperCase() === 'DIV') || ALLOWED_CONTAINER_TAGS.includes(element.tagName.toUpperCase());
        if (!isAllowedTypeAtTargetDepth) return;
        if (element.classList.contains(PROCESSED_CLASS)) {
            if (forcedByObserver) element.classList.remove(PROCESSED_CLASS);
            else return;
        }
        let madeChangeOverall = false;
        const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
        const textNodesToModify = [];
        let textNodeWalkerCurrentNode;
        while (textNodeWalkerCurrentNode = walker.nextNode()) {
            let parentCheck = textNodeWalkerCurrentNode.parentElement;
            let inSkippedSubtree = false;
            while (parentCheck && parentCheck !== element.parentElement) {
                if (parentCheck.tagName && TAGS_TO_SKIP_PROCESSING.includes(parentCheck.tagName.toUpperCase())) { inSkippedSubtree = true; break; }
                if (parentCheck === element) break;
                parentCheck = parentCheck.parentElement;
            }
            if (!inSkippedSubtree && textNodeWalkerCurrentNode.nodeValue && (textNodeWalkerCurrentNode.nodeValue.includes('&lt;img') || textNodeWalkerCurrentNode.nodeValue.includes('<img'))) {
                textNodesToModify.push(textNodeWalkerCurrentNode);
            }
        }
        for (const textNode of textNodesToModify) {
            if (!textNode.parentNode) continue;
            const originalNodeValue = textNode.nodeValue;
            let newTextContent = originalNodeValue;
            let currentTextNodeMadeChange = false;
            const createReplacer = (isEscaped) => (match, attrs) => {
                const replacement = processImageTag(match, attrs, isEscaped);
                if (replacement !== match) currentTextNodeMadeChange = true;
                return replacement;
            };
            if (newTextContent.includes('&lt;img')) newTextContent = newTextContent.replace(escapedImgTagRegex, createReplacer(true));
            if (newTextContent.includes('<img')) newTextContent = newTextContent.replace(directImgTagRegex, createReplacer(false));
            if (currentTextNodeMadeChange) {
                madeChangeOverall = true;
                const fragmentToInsert = document.createDocumentFragment();
                const tempParsingDiv = document.createElement('div');
                tempParsingDiv.innerHTML = newTextContent;
                Array.from(tempParsingDiv.childNodes).forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        const imagesToProcess = [];
                        if (node.classList && node.classList.contains('userscript-image-paragraph')) {
                            const imgElement = node.querySelector('img[data-force-rendered="true"]');
                            if (imgElement) imagesToProcess.push(imgElement);
                        } else if (node.tagName === 'IMG' && node.getAttribute('data-force-rendered') === 'true') {
                            imagesToProcess.push(node);
                        } else {
                            node.querySelectorAll('img[data-force-rendered="true"]').forEach(imgElem => imagesToProcess.push(imgElem));
                        }
                        imagesToProcess.forEach(imgEl => {
                            imgEl.style.cursor = 'pointer'; 
                            imgEl.title = '点击查看大图 (滚轮/双指缩放, Esc关闭, 可拖拽)';
                            imgEl.addEventListener('click', function(e) {
                                e.preventDefault(); e.stopPropagation(); 
                                showLightbox(this.src);
                            });
                        });
                    }
                    fragmentToInsert.appendChild(node);
                });
                try {
                    if (textNode.parentNode) textNode.parentNode.replaceChild(fragmentToInsert, textNode);
                } catch (e) { console.error(`${SCRIPT_VERSION} Error replacing text node:`, e); }
            }
        }
        if (madeChangeOverall) {
            if (!element.classList.contains(PROCESSED_CLASS)) element.classList.add(PROCESSED_CLASS);
        } else if (!element.classList.contains(PROCESSED_CLASS) && textNodesToModify.length > 0) {
            element.classList.add(PROCESSED_CLASS);
        }
    }

    const observerCallback = (mutationsList) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        renderImagesInElement(node, true);
                        if (typeof node.querySelectorAll === 'function') {
                            const relevantChildSelectors = ALLOWED_CONTAINER_TAGS.join(',') + ',DIV';
                            node.querySelectorAll(relevantChildSelectors).forEach(child => {
                                if (child.nodeType === Node.ELEMENT_NODE) renderImagesInElement(child, true);
                            });
                        }
                    } else if (node.nodeType === Node.TEXT_NODE && node.parentElement) {
                        renderImagesInElement(node.parentElement, true);
                    }
                });
            } else if (mutation.type === 'characterData') {
                if (mutation.target && mutation.target.parentElement) {
                    renderImagesInElement(mutation.target.parentElement, true);
                }
            }
        }
    };
    const observer = new MutationObserver(observerCallback);
    const observerConfig = { childList: true, subtree: true, characterData: true };
    function initialScan() {
        const allRelevantSelectors = ALLOWED_CONTAINER_TAGS.join(',') + ',DIV';
        document.querySelectorAll(allRelevantSelectors).forEach(el => {
            if (el.offsetParent !== null || document.body.contains(el)) {
                renderImagesInElement(el, false);
            }
        });
    }
    function activateScript() {
        createLightboxStyles();
        setTimeout(() => {
            initialScan();
            observer.observe(document.body, observerConfig);
        }, 1500);
    }

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        activateScript();
    } else {
        document.addEventListener('DOMContentLoaded', activateScript, { once: true });
    }

})();