NovelAI 快捷键权重调整

通过Ctrl+↑/↓快速调整输入光标所在的Tag权重,Ctrl+←/→移动Tag位置(使用时会自动格式化提示词,删除多余空格+逗号,自动在逗号后添加空格,不会删除换行)。支持所有Prompt输入框。

// ==UserScript==
// @name         NovelAI 快捷键权重调整
// @namespace    https://novelai.net
// @match        https://novelai.net/image
// @match        https://novelai.github.io/image
// @icon         https://novelai.net/_next/static/media/goose_blue.1580a990.svg
// @license      MIT
// @version      1.7
// @author       Takoro
// @description  通过Ctrl+↑/↓快速调整输入光标所在的Tag权重,Ctrl+←/→移动Tag位置(使用时会自动格式化提示词,删除多余空格+逗号,自动在逗号后添加空格,不会删除换行)。支持所有Prompt输入框。
// ==/UserScript==

(function() {
    'use strict';

    function getActiveInputElement() {
        const selection = window.getSelection();
        if (!selection.rangeCount) return null;
        const node = selection.focusNode;
        const pElement = node.nodeType === 3 ? node.parentElement.closest('p') : node.closest('p');

        if (pElement && (
            pElement.closest('.prompt-input-box-prompt') ||
            pElement.closest('.prompt-input-box-base-prompt') ||
            pElement.closest('.prompt-input-box-negative-prompt') ||
            pElement.closest('.prompt-input-box-undesired-content') ||
            pElement.closest('[class*="character-prompt-input"]')
        )) {
            return pElement;
        }
        return null;
    }

    function getSelectedTagInfo(inputElement) {
        if (!inputElement) return null;

        const selection = window.getSelection();
        if (!selection.rangeCount) return null;

        const range = selection.getRangeAt(0);
        const node = range.startContainer;
        const offset = range.startOffset;

        const fullText = inputElement.textContent || '';

        let globalOffset = 0;
        if (node.nodeType === 3) {
            const treeWalker = document.createTreeWalker(
                inputElement,
                NodeFilter.SHOW_TEXT
            );
            let currentNode;
            while ((currentNode = treeWalker.nextNode())) {
                if (currentNode === node) break;
                globalOffset += currentNode.length;
            }
            globalOffset += offset;
        } else {
            globalOffset = offset;
        }

        let start = globalOffset;
        while (start > 0 && fullText[start - 1] !== ',' && fullText[start - 1] !== '\n') {
            start--;
        }
        let end = globalOffset;
        while (end < fullText.length && fullText[end] !== ',' && fullText[end] !== '\n') {
            end++;
        }

        if (fullText[start] === ',' || fullText[start] === '\n') start++;
        if (fullText[end - 1] === ',') end--;

        const tagText = fullText.slice(start, end).trim();
        return tagText ? { tagText, start, end, fullText } : null;
    }

    function parseWeight(text) {
        const cleanText = text.replace(/:{2,}/g, '::').replace(/:+$/, '');
        const weightMatch = cleanText.match(/^(-?[\d.]+)::(.+?)(?:::|$)/);

        if (weightMatch) {
            const weight = parseFloat(weightMatch[1]);
            return {
                weight: isNaN(weight) ? 1.0 : weight,
                tag: weightMatch[2].trim()
            };
        }

        return { weight: 1.0, tag: text.trim() };
    }

    function adjustWeight(text, direction) {
        const { weight, tag } = parseWeight(text);
        let newWeight = weight + (direction * 0.1);
        newWeight = Math.round(newWeight * 10) / 10;

        if (Math.abs(newWeight - 1.0) < 0.001) {
            return tag;
        }

        return `${newWeight}::${tag}::`;
    }

    function modifyInputText(inputElement, newText, start, end, fullText) {
        const newContent = fullText.slice(0, start) + newText + fullText.slice(end);

        if (inputElement.childNodes.length === 1 && inputElement.firstChild.nodeType === 3) {
            inputElement.firstChild.textContent = newContent;
        } else {
            const newTextNode = document.createTextNode(newContent);
            inputElement.innerHTML = '';
            inputElement.appendChild(newTextNode);
        }

        const newRange = document.createRange();
        newRange.setStart(inputElement.firstChild, start);
        newRange.setEnd(inputElement.firstChild, start + newText.length);

        const selection = window.getSelection();
        selection.removeAllRanges();
        selection.addRange(newRange);

        const inputEvent = new Event('input', { bubbles: true });
        inputElement.dispatchEvent(inputEvent);
    }

    // 更新整个输入框内容并设置光标位置
    function updateInputContent(inputElement, newContent, selectionStart, selectionEnd) {
        if (inputElement.childNodes.length === 1 && inputElement.firstChild.nodeType === 3) {
            inputElement.firstChild.textContent = newContent;
        } else {
            const newTextNode = document.createTextNode(newContent);
            inputElement.innerHTML = '';
            inputElement.appendChild(newTextNode);
        }

        const newRange = document.createRange();
        newRange.setStart(inputElement.firstChild, selectionStart);
        newRange.setEnd(inputElement.firstChild, selectionEnd);

        const selection = window.getSelection();
        selection.removeAllRanges();
        selection.addRange(newRange);

        const inputEvent = new Event('input', { bubbles: true });
        inputElement.dispatchEvent(inputEvent);
    }

    // 移动标签函数
    function moveTag(inputElement, tagInfo, direction) {
        const { tagText, fullText } = tagInfo;
        // 分割文本成标签数组,基于逗号和换行
        const tags = fullText.split(/[\n,]+/).map(t => t.trim()).filter(t => t !== '');
        // 找到当前标签的索引
        const currentTag = tagText.trim();
        const index = tags.findIndex(t => t === currentTag);
        if (index === -1) return; // 未找到标签

        let newIndex;
        if (direction === -1) { // 向左移动
            if (index === 0) return; // 已经在最左,无法移动
            newIndex = index - 1;
            [tags[index], tags[newIndex]] = [tags[newIndex], tags[index]]; // 交换
        } else if (direction === 1) { // 向右移动
            if (index === tags.length - 1) return; // 已经在最右,无法移动
            newIndex = index + 1;
            [tags[index], tags[newIndex]] = [tags[newIndex], tags[index]]; // 交换
        } else {
            return;
        }

        // 重新构建文本,用逗号和空格连接
        const newFullText = tags.join(', ') + ',';

        // 计算新文本中每个标签的位置
        function getTagPositions(tags) {
            let currentPos = 0;
            const positions = [];
            for (let i = 0; i < tags.length; i++) {
                const start = currentPos;
                const end = start + tags[i].length;
                positions.push({ start, end });
                currentPos = end;
                if (i < tags.length - 1) {
                    currentPos += 2; // 添加 ', ' 的长度
                }
            }
            // 添加最后一个逗号的位置
            positions.push({ start: currentPos, end: currentPos + 1 });
            return positions;
        }

        const positions = getTagPositions(tags);
        const newStart = positions[newIndex].start;
        const newEnd = positions[newIndex].end;

        // 更新输入框
        updateInputContent(inputElement, newFullText, newStart, newEnd);
    }

    function handleKeydown(event) {
        const inputElement = getActiveInputElement();
        if (!inputElement) return;

        if (!event.ctrlKey) return;

        if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
            event.preventDefault();
            event.stopPropagation();

            const tagInfo = getSelectedTagInfo(inputElement);
            if (!tagInfo) return;

            const direction = (event.key === 'ArrowUp') ? 1 : -1;
            const newText = adjustWeight(tagInfo.tagText, direction);

            modifyInputText(inputElement, newText, tagInfo.start, tagInfo.end, tagInfo.fullText);
        } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
            event.preventDefault();
            event.stopPropagation();

            const tagInfo = getSelectedTagInfo(inputElement);
            if (!tagInfo) return;

            const direction = (event.key === 'ArrowLeft') ? -1 : 1;
            moveTag(inputElement, tagInfo, direction);
        }
    }

    function init() {
        const checkInterval = setInterval(() => {
            const inputElement = getActiveInputElement();
            if (inputElement) {
                clearInterval(checkInterval);
                document.addEventListener('keydown', handleKeydown, true);
            }
        }, 500);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();