Greasy Fork

猴子都会用的Bangumi bbcode辅助工具

在 Bangumi 文本框工具栏中添加对齐按钮、渐变生成器和图片尺寸调整功能(带预览)

// ==UserScript==
// @name         猴子都会用的Bangumi bbcode辅助工具
// @namespace    https://github.com/wakabayu
// @version      1.5
// @description  在 Bangumi 文本框工具栏中添加对齐按钮、渐变生成器和图片尺寸调整功能(带预览)
// @include      /^https?:\/\/(bgm\.tv|chii\.in|bangumi\.tv)\/.*/
// @grant        none
// @author       wataame
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';
    let previewWindow = null;
    let lastSelectedText = '';

    // 防抖函数
    function debounce(func, wait) {
        let timeout;
        return function executedFunction(...args) {
            const later = () => {
                clearTimeout(timeout);
                func(...args);
            };
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
        };
    }

    // 安全执行函数
    function safelyExecute(func, errorMessage) {
        try {
            return func();
        } catch (error) {
            console.error(error);
            alert(errorMessage || '操作失败,请重试');
            return null;
        }
    }

    // 添加对齐和渐变按钮
    function addToolbarButtons() {
        document.querySelectorAll('.markItUpHeader').forEach(toolbar => {
            if (toolbar.querySelector('.alignmentButton') || toolbar.querySelector('.gradientButton')) return;
            const textarea = toolbar.closest('.markItUpContainer').querySelector('textarea');
            if (!textarea) return;
            const alignments = [
                { label: '◧L', bbcode: 'left', title: '左对齐 [left]' },
                { label: '▣C', bbcode: 'center', title: '居中对齐 [center]' },
                { label: '◨R', bbcode: 'right', title: '右对齐 [right]' }
            ];
            alignments.forEach(alignment => addButton(toolbar, alignment.label, alignment.title, () => applyBBCode(textarea, alignment.bbcode)));
            addButton(toolbar, '渐变', '生成渐变文字', () => {
                const selectedText = getSelectedText(textarea);
                if (!selectedText) return alert('请先选中需要应用渐变的文字');
                openColorPicker(selectedText, textarea);
            });
        });
    }

    function addButton(toolbar, label, title, onClick) {
        const button = document.createElement('a');
        button.href = 'javascript:void(0);';
        button.className = `${title.includes('对齐') ? 'alignmentButton' : 'gradientButton'}`;
        button.title = title;
        button.innerHTML = `<span style="font-weight: bold; margin: 0 8px;">${label}</span>`;
        button.onclick = onClick;
        button.style.margin = '0 6px';
        toolbar.appendChild(button);
    }

    function applyBBCode(textarea, bbcode) {
        const selectedText = getSelectedText(textarea);
        const wrappedText = `[${bbcode}]${selectedText}[/${bbcode}]`;
        replaceSelectedText(textarea, wrappedText);
    }

    function getSelectedText(textarea) {
        return textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
    }

    function replaceSelectedText(textarea, newText) {
        const start = textarea.selectionStart;
        const end = textarea.selectionEnd;
        textarea.setRangeText(newText, start, end, 'end');
    }

    // 创建通用弹窗
    function createPopup(id, content) {
        const popup = document.createElement('div');
        popup.id = id;
        popup.style = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: #fff;
            padding: 20px;
            border: 1px solid #ccc;
            z-index: 9999;
            box-shadow: 0 0 10px rgba(0,0,0,0.1)
        `;
        popup.innerHTML = content;
        return popup;
    }

    // 颜色选择器相关函数
    function openColorPicker(selectedText, textarea) {
        if (document.getElementById('colorPickerContainer')) return;
        const colorPickerContainer = createPopup('colorPickerContainer', `
            <label>选择起始颜色:<input type="color" value="#0ebeff" id="startColor"></label><br>
            <label>选择结束颜色:<input type="color" value="#5e95e6" id="endColor"></label><br>
            <label>输入步数:<input type="number" min="1" value="${selectedText.length}" id="steps"></label><br>
            <button id="generate">生成</button> <button id="cancel">取消</button><br>
            <div id="historyContainer" style="margin-top: 10px;"><strong>最近使用的方案:</strong><br></div>
        `);
        document.body.appendChild(colorPickerContainer);
        loadGradientHistory();

        document.querySelector('#generate').onclick = () => {
            const startColor = document.querySelector('#startColor').value;
            const endColor = document.querySelector('#endColor').value;
            const steps = parseInt(document.querySelector('#steps').value);
            if (isNaN(steps) || steps <= 0) return alert('请输入有效的步数');
            const gradientText = generateGradientText(selectedText, startColor, endColor, steps);
            replaceSelectedText(textarea, gradientText);
            saveGradientHistory(startColor, endColor);
            closePopup(colorPickerContainer);
        };

        document.querySelector('#cancel').onclick = () => closePopup(colorPickerContainer);
    }

    function generateGradientText(text, startColor, endColor, steps) {
        const startRGB = hexToRgb(startColor), endRGB = hexToRgb(endColor);
        const segmentLength = Math.ceil(text.length / steps);
        return Array.from({ length: steps }, (_, i) => {
            const ratio = i / (steps - 1);
            const color = `#${rgbToHex(interpolate(startRGB.r, endRGB.r, ratio))}${rgbToHex(interpolate(startRGB.g, endRGB.g, ratio))}${rgbToHex(interpolate(startRGB.b, endRGB.b, ratio))}`;
            return `[color=${color}]${text.slice(i * segmentLength, (i + 1) * segmentLength)}[/color]`;
        }).join('');
    }

    function interpolate(start, end, ratio) {
        return clamp(Math.round(start + ratio * (end - start)));
    }

    const clamp = value => Math.max(0, Math.min(255, value));
    const hexToRgb = hex => ({ r: parseInt(hex.slice(1, 3), 16), g: parseInt(hex.slice(3, 5), 16), b: parseInt(hex.slice(5, 7), 16) });
    const rgbToHex = value => value.toString(16).padStart(2, '0');

    function closePopup(container) {
        document.body.removeChild(container);
    }

    function saveGradientHistory(startColor, endColor) {
        const history = JSON.parse(localStorage.getItem('gradientHistory') || '[]');
        const newEntry = { start: startColor, end: endColor };
        if (!history.some(entry => entry.start === startColor && entry.end === endColor)) {
            history.unshift(newEntry);
            if (history.length > 5) history.pop();
            localStorage.setItem('gradientHistory', JSON.stringify(history));
        }
    }

    function loadGradientHistory() {
        const historyContainer = document.querySelector('#historyContainer');
        const history = JSON.parse(localStorage.getItem('gradientHistory') || '[]');
        historyContainer.innerHTML = '<strong>最近方案:</strong><br>';
        history.forEach((entry, index) => {
            const historyButton = document.createElement('button');
            historyButton.style = `background: linear-gradient(to right, ${entry.start}, ${entry.end}); border: none; color: #fff; padding: 5px; margin: 2px; cursor: pointer;`;
            historyButton.innerText = `方案 ${index + 1}`;
            historyButton.onclick = () => {
                document.querySelector('#startColor').value = entry.start;
                document.querySelector('#endColor').value = entry.end;
            };
            historyContainer.appendChild(historyButton);
        });
    }

    // 图片尺寸调整相关函数
    function createOrUpdatePreviewWindow(selectedText, textarea) {
        if (previewWindow) previewWindow.remove();
        previewWindow = createPopup('imgSizeSelector', `
            <div style="text-align: center; margin-bottom: 15px;">
                <strong>调整图片尺寸</strong>
            </div>
            <div id="previewArea" style="
                margin: 10px auto;
                text-align: center;
                max-height: 300px;
                overflow: auto;
                border: 1px solid #ddd;
                padding: 10px;
            ">
                <img id="previewImage" style="max-width: 100%; height: auto;" />
            </div>
            <div id="sizeInfo" style="margin: 10px 0; text-align: center; font-size: 14px;"></div>
            <div style="display: flex; justify-content: center; gap: 10px; margin: 15px 0;">
                <div id="presetButtons" style="display: flex; gap: 5px;"></div>
            </div>
            <div style="display: flex; justify-content: center; gap: 10px; margin: 15px 0;">
                <input type="number" id="customWidth" placeholder="宽度" style="width: 80px; padding: 5px;">
                <span style="line-height: 30px;">×</span>
                <input type="number" id="customHeight" placeholder="高度" style="width: 80px; padding: 5px;">
                <button id="lockRatio" style="padding: 0 8px;" title="锁定宽高比">🔒</button>
            </div>
            <div style="text-align: center; margin-top: 15px;">
                <button id="applySize" style="margin-right: 10px; padding: 5px 15px;">应用</button>
                <button id="closeWindow" style="padding: 5px 15px;">关闭</button>
            </div>
        `);
        document.body.appendChild(previewWindow);

        // 获取元素引用
        const sizeInfo = previewWindow.querySelector('#sizeInfo');
        const presetButtons = previewWindow.querySelector('#presetButtons');
        const customWidth = previewWindow.querySelector('#customWidth');
        const customHeight = previewWindow.querySelector('#customHeight');
        const lockRatioBtn = previewWindow.querySelector('#lockRatio');
        const applyButton = previewWindow.querySelector('#applySize');
        const closeButton = previewWindow.querySelector('#closeWindow');
        const previewImage = previewWindow.querySelector('#previewImage');
        const previewArea = previewWindow.querySelector('#previewArea');

        // 状态变量
        let originalWidth = 0;
        let originalHeight = 0;
        let selectedWidth = 0;
        let selectedHeight = 0;
        let isRatioLocked = true;
        let aspectRatio = 1;

        // 预设尺寸按钮
        const presets = [
            { label: '25%', value: 0.25 },
            { label: '50%', value: 0.5 },
            { label: '75%', value: 0.75 },
            { label: '100%', value: 1 }
        ];

        // 创建预设按钮
        presets.forEach(preset => {
            const button = document.createElement('button');
            button.innerText = preset.label;
            button.style = `
                padding: 5px 10px;
                cursor: pointer;
                border: 1px solid #ccc;
                border-radius: 4px;
                background: #f0f0f0;
            `;
            button.onclick = () => {
                selectedWidth = Math.round(originalWidth * preset.value);
                selectedHeight = Math.round(originalHeight * preset.value);
                updateInputs();
                highlightButton(button);
            };
            presetButtons.appendChild(button);
        });

        // 锁定比例按钮
        lockRatioBtn.style.background = isRatioLocked ? '#4a4a4a' : '#f0f0f0';
        lockRatioBtn.style.color = isRatioLocked ? '#fff' : '#000';
        lockRatioBtn.onclick = () => {
            isRatioLocked = !isRatioLocked;
            lockRatioBtn.style.background = isRatioLocked ? '#4a4a4a' : '#f0f0f0';
            lockRatioBtn.style.color = isRatioLocked ? '#fff' : '#000';
        };

        function updateInputs() {
            customWidth.value = selectedWidth;
            customHeight.value = selectedHeight;
            updateSizeInfo();
            updatePreview();
        }

        function updateSizeInfo() {
            const widthPercent = Math.round((selectedWidth / originalWidth) * 100);
            const heightPercent = Math.round((selectedHeight / originalHeight) * 100);
            sizeInfo.textContent = `当前: ${selectedWidth} × ${selectedHeight} (${widthPercent}%)
                               原始: ${originalWidth} × ${originalHeight}`;
        }

        function updatePreview() {
            if (previewImage) {
                // 添加加载提示
                previewImage.style.opacity = '0.3';
                previewImage.style.transition = 'opacity 0.3s';

                previewImage.onload = () => {
                    previewImage.style.opacity = '1';
                };

                previewImage.style.width = `${selectedWidth}px`;
                previewImage.style.height = `${selectedHeight}px`;

                // 调整预览区域的大小
                const maxPreviewWidth = Math.min(selectedWidth, window.innerWidth * 0.8);
                const maxPreviewHeight = Math.min(selectedHeight, 300);
                previewArea.style.width = `${maxPreviewWidth}px`;
                previewArea.style.height = `${maxPreviewHeight}px`;

                // 如果图片尺寸超过预览区域,添加提示
                if (selectedWidth > maxPreviewWidth || selectedHeight > maxPreviewHeight) {
                    previewArea.title = '图片已按比例缩放以适应预览窗口';
                } else {
                    previewArea.title = '';
                }
            }
        }

        function highlightButton(activeButton) {
            presetButtons.querySelectorAll('button').forEach(btn => {
                btn.style.background = '#f0f0f0';
                btn.style.color = '#000';
            });
            activeButton.style.background = '#4a4a4a';
            activeButton.style.color = '#fff';
        }

        function clearButtonHighlight() {
            presetButtons.querySelectorAll('button').forEach(btn => {
                btn.style.background = '#f0f0f0';
                btn.style.color = '#000';
            });
        }

        // 添加拖动调整大小功能
        function addResizeDrag(previewImage) {
            let isResizing = false;
            let startX, startY, startWidth, startHeight;

            previewImage.style.cursor = 'se-resize';

            previewImage.addEventListener('mousedown', (e) => {
                isResizing = true;
                startX = e.clientX;
                startY = e.clientY;
                startWidth = selectedWidth;
                startHeight = selectedHeight;

                document.addEventListener('mousemove', handleMouseMove);
                document.addEventListener('mouseup', () => {
                    isResizing = false;
                    document.removeEventListener('mousemove', handleMouseMove);
                }, { once: true });
            });

            function handleMouseMove(e) {
                if (!isResizing) return;

                const deltaX = e.clientX - startX;
                const deltaY = e.clientY - startY;

                if (isRatioLocked) {
                    // 保持宽高比
                    const ratio = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX / startWidth : deltaY / startHeight;
                    selectedWidth = Math.max(10, Math.round(startWidth * (1 + ratio)));
                    selectedHeight = Math.max(10, Math.round(startHeight * (1 + ratio)));
                } else {
                    selectedWidth = Math.max(10, startWidth + deltaX);
                    selectedHeight = Math.max(10, startHeight + deltaY);
                }

                updateInputs();
            }
        }

        // 输入框事件处理
        customWidth.oninput = () => {
            const width = parseInt(customWidth.value);
            if (width > 0) {
                selectedWidth = width;
                if (isRatioLocked) {
                    selectedHeight = Math.round(width / aspectRatio);
                    customHeight.value = selectedHeight;
                }
                updateSizeInfo();
                updatePreview();
                clearButtonHighlight();
            }
        };

        customHeight.oninput = () => {
            const height = parseInt(customHeight.value);
            if (height > 0) {
                selectedHeight = height;
                if (isRatioLocked) {
                    selectedWidth = Math.round(height * aspectRatio);
                    customWidth.value = selectedWidth;
                }
                updateSizeInfo();
                updatePreview();
                clearButtonHighlight();
            }
        };

        // 按钮事件处理
        closeButton.onclick = () => {
            previewWindow.remove();
            lastSelectedText = '';
        };

        applyButton.onclick = () => {
            if (selectedWidth && selectedHeight) {
                const imgTagRegex = /\[img(?:=(\d+),(\d+))?\](https?:\/\/[^\s]+)\[\/img\]/;
                const match = selectedText.match(imgTagRegex);
                if (match) {
                    const imgURL = match[3];
                    const newCode = `[img=${selectedWidth},${selectedHeight}]${imgURL}[/img]`;
                    textarea.value = textarea.value.replace(selectedText, newCode);
                }
            }
            previewWindow.remove();
            lastSelectedText = '';
        };

        // 解析当前BBCode并获取图片信息
        const imgTagRegex = /\[img(?:=(\d+),(\d+))?\](https?:\/\/[^\s]+)\[\/img\]/;
        const match = selectedText.match(imgTagRegex);
        if (!match) {
            previewWindow.remove();
            return;
        }

        const initialWidth = match[1] ? parseInt(match[1], 10) : null;
        const initialHeight = match[2] ? parseInt(match[2], 10) : null;
        const imgURL = match[3];

        // 获取图片原始尺寸
        const tempImg = new Image();
        tempImg.onload = () => {
            originalWidth = tempImg.naturalWidth;
            originalHeight = tempImg.naturalHeight;
            aspectRatio = originalWidth / originalHeight;

            // 设置预览图片的源
            previewImage.src = tempImg.src;

            // 添加拖动调整大小功能
            addResizeDrag(previewImage);

            if (initialWidth && initialHeight) {
                selectedWidth = initialWidth;
                selectedHeight = initialHeight;
                const scale = initialWidth / originalWidth;
                const percentage = Math.round(scale * 100);
                updateInputs();
                // 选中对应预设按钮
                presetButtons.querySelectorAll('button').forEach((btn, index) => {
                    if (percentage === presets[index].value * 100) {
                        highlightButton(btn);
                    }
                });
            } else {
                selectedWidth = originalWidth;
                selectedHeight = originalHeight;
                updateInputs();
                highlightButton(presetButtons.lastElementChild); // 100%
            }
        };
        tempImg.src = imgURL;
    }

    // 监听文本选择变化
    const debouncedHandleSelectionChange = debounce(handleSelectionChange, 250);
    document.addEventListener('selectionchange', debouncedHandleSelectionChange);

    function handleSelectionChange() {
        const textarea = document.activeElement;
        if (textarea && textarea.tagName === 'TEXTAREA') {
            const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd).trim();
            if (selectedText !== lastSelectedText && selectedText.match(/\[img(?:=(\d+),(\d+))?\]https?:\/\/[^\s]+\[\/img\]/)) {
                createOrUpdatePreviewWindow(selectedText, textarea);
                lastSelectedText = selectedText;
            }
        }
    }

    // 初始化
    const observer = new MutationObserver(() => addToolbarButtons());
    observer.observe(document.body, { childList: true, subtree: true });
})();