NovelAI Image Helper

Adds a floating image toolkit on novelai.net for cropping and splicing images with specific constraints.

// ==UserScript==
// @name         NovelAI Image Helper
// @name:zh-CN   NovelAI 局部放大重绘
// @version      3.1
// @description  Adds a floating image toolkit on novelai.net for cropping and splicing images with specific constraints.
// @description:zh-CN 在 novelai.net 网站上添加一个浮动的图片工具箱,用于按特定规则裁剪和拼接图片。
// @author       axing
// @match        https://novelai.net/*
// @grant        GM_addStyle
// @require      https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js
// @namespace https://greasyfork.org/users/1285713
// ==/UserScript==

(function() {
    'use strict';

    // 1. 注入 Cropper.js 的 CSS
    const cropperCSS = document.createElement('link');
    cropperCSS.rel = 'stylesheet';
    cropperCSS.href = 'https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css';
    document.head.appendChild(cropperCSS);

    // 2. 创建 HTML 结构
    const panel = document.createElement('div');
    panel.id = 'nai-helper-panel';
    panel.innerHTML = `
        <div id="nai-helper-header">
            🖼️ 图片助手 (可拖动)
            <button id="nai-helper-btn-minimize" title="最小化">_</button>
        </div>
        <div id="nai-helper-content">
            <div class="nai-helper-image-container" id="nai-helper-container-a">
                <p>图片A (原图): 将图片拖拽至此</p>
                <img id="nai-helper-image-a" style="display:none; max-width: 100%;">
                <canvas id="nai-helper-canvas-a" style="display:none;"></canvas>
            </div>
            <div id="nai-helper-status-bar">请先将图片A拖拽至上方区域</div>
            <div class="nai-helper-controls">
                <div class="nai-helper-control-group">
                    <label for="nai-helper-scale-input">缩放倍率:</label>
                    <input type="number" id="nai-helper-scale-input" value="1.5" step="0.1" min="0.1">
                    <button id="nai-helper-btn-crop">✂️ 裁剪</button>
                </div>
                 <div class="nai-helper-control-group">
                    <button id="nai-helper-btn-splice">🧩 拼接</button>
                    <button id="nai-helper-btn-download" style="display:none;">📥 下载结果</button>
                 </div>
            </div>
            <div class="nai-helper-image-row">
                <div class="nai-helper-image-container-small" id="nai-helper-container-b">
                    <p>图片B (自行复制粘贴)</p>
                    <canvas id="nai-helper-canvas-b"></canvas>
                </div>
                <div class="nai-helper-image-container-small" id="nai-helper-container-c">
                    <p>图片C (拼接目标)</p>
                    <img id="nai-helper-image-c" style="display:none; max-width: 100%;">
                    <input type="file" id="nai-helper-file-c" class="nai-helper-file-input" accept="image/*">
                </div>
            </div>
        </div>
    `;
    document.body.appendChild(panel);

    const restoreBtn = document.createElement('button');
    restoreBtn.id = 'nai-helper-restore-btn';
    restoreBtn.title = '展开图片助手';
    restoreBtn.textContent = '🖼️';
    document.body.appendChild(restoreBtn);

    // 3. 添加 CSS 样式
    GM_addStyle(`
        #nai-helper-panel {
            position: fixed; width: 420px;
            background-color: #2c2c2c; border: 1px solid #555; border-radius: 8px;
            z-index: 99999; color: #eee; font-family: sans-serif; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
        }
        #nai-helper-restore-btn {
            position: fixed;
            width: 48px; height: 48px;
            background-color: rgba(60, 70, 90, 0.9); border: 2px solid rgba(255, 255, 255, 0.4);
            border-radius: 50%; z-index: 99999; color: #eee;
            font-size: 24px; line-height: 44px; text-align: center;
            cursor: move; display: none;
            box-shadow: 0 3px 10px rgba(0,0,0,0.4);
            transition: transform 0.2s ease, background-color 0.2s ease;
        }
        #nai-helper-restore-btn:hover {
            background-color: rgba(80, 90, 110, 0.95);
            transform: scale(1.1);
        }
        #nai-helper-header {
            position: relative; padding: 10px; cursor: move; background-color: #3a3a3a;
            border-bottom: 1px solid #555; border-radius: 8px 8px 0 0; text-align: center; font-weight: bold;
        }
        #nai-helper-btn-minimize {
            position: absolute; top: 50%; left: 10px; /* <-- 修改处 */
            transform: translateY(-50%);
            width: 24px; height: 24px; background: #505050; border: 1px solid #666;
            color: #ddd; font-weight: bold; line-height: 22px; text-align: center; cursor: pointer; border-radius: 4px;
        }
        #nai-helper-btn-minimize:hover { background: #656565; }
        #nai-helper-content {
            padding: 15px; display: flex; flex-direction: column; gap: 15px;
        }
        .nai-helper-image-container, .nai-helper-image-container-small {
            background-color: #1e1e1e; border: 2px dashed #444; border-radius: 5px; padding: 10px;
            text-align: center; position: relative; min-height: 100px; display: flex;
            align-items: center; justify-content: center; flex-direction: column; transition: border-color 0.2s;
        }
        .nai-helper-image-container p, .nai-helper-image-container-small p { color: #888; margin-bottom: 5px; }
        .nai-helper-image-container-small { min-height: 80px; }
        .nai-helper-image-row { display: flex; gap: 10px; }
        .nai-helper-image-row > div { flex: 1; }
        #nai-helper-panel img, #nai-helper-panel canvas { max-width: 100%; border-radius: 4px; }
        .nai-helper-file-input {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer;
        }
        .nai-helper-controls { display: flex; flex-direction: column; gap: 10px; }
        .nai-helper-control-group { display: flex; align-items: center; gap: 10px; }
        .nai-helper-control-group label { flex-shrink: 0; }
        #nai-helper-panel button {
            padding: 0; height: 30px; border: none; background-color: #5a5a5a; color: white;
            border-radius: 5px; cursor: pointer; flex-grow: 1;
        }
        #nai-helper-panel button:hover { background-color: #6a6a6a; }
        #nai-helper-panel input[type="number"] {
            width: 60px; padding: 5px; background-color: #1e1e1e; border: 1px solid #555;
            color: white; border-radius: 3px;
        }
        #nai-helper-status-bar {
            background-color: #1a1a1a; padding: 8px; text-align: center;
            border-radius: 4px; font-size: 0.9em; color: #aaa;
        }
        .cropper-view-box { outline-color: rgba(0, 173, 255, 0.75); }
    `);

    // 4. 变量和状态管理
    const header = document.getElementById('nai-helper-header');
    const btnMinimize = document.getElementById('nai-helper-btn-minimize');
    const imgA = document.getElementById('nai-helper-image-a');
    const canvasA = document.getElementById('nai-helper-canvas-a');
    const containerA = document.getElementById('nai-helper-container-a');
    const canvasB = document.getElementById('nai-helper-canvas-b');
    const ctxB = canvasB.getContext('2d');
    const imgC = document.getElementById('nai-helper-image-c');
    const fileC = document.getElementById('nai-helper-file-c');
    const containerC = document.getElementById('nai-helper-container-c');
    const btnCrop = document.getElementById('nai-helper-btn-crop');
    const btnSplice = document.getElementById('nai-helper-btn-splice');
    const btnDownload = document.getElementById('nai-helper-btn-download');
    const scaleInput = document.getElementById('nai-helper-scale-input');
    const statusBar = document.getElementById('nai-helper-status-bar');

    let cropper = null; let originalA_URL = null; let cropData = null;
    const roundTo64 = (num) => Math.round(num / 64) * 64;

    // 5. 功能实现

    // 计算并设置初始位置
    const initialTop = 50;
    const initialRight = 20;
    const initialLeft = window.innerWidth - panel.offsetWidth - initialRight;
    panel.style.top = `${initialTop}px`;
    panel.style.left = `${initialLeft}px`;
    restoreBtn.style.top = `${initialTop}px`;
    restoreBtn.style.left = `${initialLeft}px`;

    // 设置初始状态为最小化
    panel.style.display = 'none';
    restoreBtn.style.display = 'block';

    makeDraggable(panel, header);
    makeDraggable(restoreBtn);

    btnMinimize.addEventListener('click', (e) => {
        e.stopPropagation();
        const panelRect = panel.getBoundingClientRect();
        restoreBtn.style.top = `${panelRect.top}px`;
        restoreBtn.style.left = `${panelRect.left}px`;
        panel.style.display = 'none';
        restoreBtn.style.display = 'block';
    });

    restoreBtn.addEventListener('click', () => {
        const iconRect = restoreBtn.getBoundingClientRect();
        panel.style.top = `${iconRect.top}px`;
        panel.style.left = `${iconRect.left}px`;
        panel.style.display = 'block';
        restoreBtn.style.display = 'none';
    });

    setupImageLoader(containerA, null, (imgElement, dataURL) => {
        if (cropper) cropper.destroy();
        imgA.src = dataURL; originalA_URL = dataURL;
        imgA.style.display = 'block'; canvasA.style.display = 'none'; btnDownload.style.display = 'none';
        imgA.onload = () => {
            cropper = new Cropper(imgA, {
                viewMode: 1, dragMode: 'move', background: false, autoCropArea: 0.8,
                crop() {
                    let data = cropper.getData(true);
                    let snappedWidth = roundTo64(data.width); let snappedHeight = roundTo64(data.height);
                    if (snappedWidth < 64) snappedWidth = 64; if (snappedHeight < 64) snappedHeight = 64;
                    statusBar.textContent = `选区尺寸: ${snappedWidth} x ${snappedHeight}`;
                },
                cropend() {
                    let data = cropper.getData(true);
                    let newX = roundTo64(data.x); let newY = roundTo64(data.y);
                    let newWidth = roundTo64(data.width); let newHeight = roundTo64(data.height);
                    if (newWidth < 64) newWidth = 64; if (newHeight < 64) newHeight = 64;
                    cropper.setData({ x: newX, y: newY, width: newWidth, height: newHeight });
                },
            });
        };
    });

    setupImageLoader(containerC, fileC, (imgElement, dataURL) => {
        imgC.src = dataURL; imgC.style.display = 'block';
        statusBar.textContent = "图片C已加载。可以进行 [拼接] 操作";
    });

    btnCrop.addEventListener('click', () => {
        if (!cropper) return alert("请先加载图片A。");
        cropData = cropper.getData(true);
        const scale = parseFloat(scaleInput.value) || 1.0;
        if (cropData.width === 0 || cropData.height === 0) return alert("裁剪区域无效。");
        const croppedCanvas = cropper.getCroppedCanvas({ width: cropData.width, height: cropData.height });
        const scaledWidth = roundTo64(cropData.width * scale); const scaledHeight = roundTo64(cropData.height * scale);
        canvasB.width = scaledWidth; canvasB.height = scaledHeight;
        ctxB.clearRect(0, 0, scaledWidth, scaledHeight);
        ctxB.drawImage(croppedCanvas, 0, 0, scaledWidth, scaledHeight);
        statusBar.textContent = `裁剪成功! 原尺寸: ${cropData.width}x${cropData.height}, 缩放后: ${scaledWidth}x${scaledHeight}`;
    });

    btnSplice.addEventListener('click', () => {
        if (!originalA_URL || !cropData || !imgC.src) return alert("请确保图片A、B、C都已就绪。");
        const originalImage = new Image();
        originalImage.onload = () => {
            canvasA.width = originalImage.naturalWidth; canvasA.height = originalImage.naturalHeight;
            const ctxA = canvasA.getContext('2d');
            ctxA.drawImage(originalImage, 0, 0);
            const tempCanvasC = document.createElement('canvas');
            tempCanvasC.width = canvasB.width; tempCanvasC.height = canvasB.height;
            tempCanvasC.getContext('2d').drawImage(imgC, 0, 0, canvasB.width, canvasB.height);
            ctxA.drawImage(tempCanvasC, cropData.x, cropData.y, cropData.width, cropData.height);
            imgA.style.display = 'none';
            if (cropper) cropper.destroy();
            cropper = null; canvasA.style.display = 'block'; btnDownload.style.display = 'inline-block';
            statusBar.textContent = "拼接完成!可以下载结果。";
        };
        originalImage.src = originalA_URL;
    });

    btnDownload.addEventListener('click', () => {
        if (canvasA.style.display === 'none') return alert("没有可下载的拼接结果。");
        const link = document.createElement('a');
        link.download = 'spliced_image.png'; link.href = canvasA.toDataURL('image/png');
        link.click();
    });

    function makeDraggable(element, handle) {
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        const dragHandle = handle || element;

        dragHandle.onmousedown = dragMouseDown;

        function dragMouseDown(e) {
            if (handle && e.target.id === 'nai-helper-btn-minimize') return; // Don't drag if clicking the minimize button
            e.preventDefault();
            e.stopPropagation();
            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = closeDragElement;
            document.onmousemove = elementDrag;
        }

        function elementDrag(e) {
            e.preventDefault();
            e.stopPropagation();
            pos1 = pos3 - e.clientX;
            pos2 = pos4 - e.clientY;
            pos3 = e.clientX;
            pos4 = e.clientY;
            element.style.top = `${element.offsetTop - pos2}px`;
            element.style.left = `${element.offsetLeft - pos1}px`;
            element.style.right = 'auto';
        }

        function closeDragElement() {
            document.onmouseup = null; document.onmousemove = null;
        }
    }

    function setupImageLoader(container, fileInput, callback) {
        const dropZoneText = container.querySelector('p');
        const handleFile = (file) => {
            if (file && file.type.startsWith('image/')) {
                const reader = new FileReader();
                reader.onload = (e) => {
                    const imgElement = container.querySelector('img');
                    callback(imgElement, e.target.result);
                    if (container.id !== 'nai-helper-container-c' && dropZoneText) {
                        dropZoneText.style.display = 'none';
                    }
                };
                reader.readAsDataURL(file);
            }
        };
        container.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); container.style.borderColor = '#00adff'; });
        container.addEventListener('dragleave', (e) => { e.stopPropagation(); container.style.borderColor = '#444'; });
        container.addEventListener('drop', (e) => {
            e.preventDefault(); e.stopPropagation(); container.style.borderColor = '#444';
            if (e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);
        });
        if (fileInput) {
            fileInput.addEventListener('change', (e) => {
                if (e.target.files.length > 0) handleFile(e.target.files[0]);
            });
        }
    }
})();