快捷鍵函式庫

根據網址(正規表達式)聆聽按鍵事件點選指定元素的函式庫,提供點選規則與快捷鍵的 CRUD 操作。

بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.greasyforks.org/scripts/542910/1632051/%E5%BF%AB%E6%8D%B7%E9%8D%B5%E5%87%BD%E5%BC%8F%E5%BA%AB.js

/**
name => ShortcutLibrary
description => 根據網址(正規表達式)聆聽按鍵事件點選指定元素的函式庫,提供點選規則與快捷鍵的 CRUD 操作。
version => 1.2.0
author => Max
namespace => https://github.com/Max46656
license => MPL2.0
本程式具有以下依賴,須添加在你使用的腳本中
@grant        GM_getValue
@grant        GM_setValue
@grant        GM_info
@grant        GM_registerMenuCommand
呼叫方式
shortcutLib = new ShortcutLibrary({
            RuleC: false,
            RuleR: true,
            RuleU: ['shortcut']
        });
*/

class RuleManager {
    constructor() {
        this.clickRules = this.sanitizeRules(GM_getValue('clickRules', { rules: [] }));
    }

    addRule(newRule) {
        this.clickRules.rules.push(newRule);
        this.updateRules();
    }

    updateRule(index, updatedRule) {
        this.clickRules.rules[index] = updatedRule;
        this.updateRules();
    }

    deleteRule(index) {
        this.clickRules.rules.splice(index, 1);
        this.updateRules();
    }

    addRuleFromJSON(jsonString) {
        try {
            const rule = JSON.parse(jsonString);
            if (this.validateRule(rule)) {
                this.addRule(rule);
                return true;
            }
            return false;
        } catch (e) {
            console.error('Error parsing JSON:', e);
            return false;
        }
    }

    validateRule(rule) {
        const requiredFields = ['ruleName', 'urlPattern', 'selectorType', 'selector', 'nthElement', 'shortcut', 'ifLinkOpen', 'isEnabled'];
        return requiredFields.every(field => field in rule) &&
            typeof rule.ruleName === 'string' &&
            typeof rule.urlPattern === 'string' &&
            ['css', 'xpath'].includes(rule.selectorType) &&
            typeof rule.selector === 'string' &&
            Number.isInteger(rule.nthElement) &&
            typeof rule.shortcut === 'string' &&
            typeof rule.ifLinkOpen === 'boolean' &&
            typeof rule.isEnabled === 'boolean' &&
            this.isValidShortcut(rule.shortcut);
    }

    updateRules() {
        GM_setValue('clickRules', this.clickRules);
    }

    sanitizeRules(clickRules) {
        const defaultRule = {
            ruleName: '',
            urlPattern: '.*',
            selectorType: 'css',
            selector: '',
            nthElement: 1,
            shortcut: 'Control+A',
            ifLinkOpen: false,
            isEnabled: true
        };
        const validRules = clickRules.rules.filter(rule => {
            return rule && typeof rule === 'object' && rule.shortcut && this.isValidShortcut(rule.shortcut);
        }).map(rule => ({
            ...defaultRule,
            ...rule,
            ruleName: rule.ruleName || `規則 ${clickRules.rules.indexOf(rule) + 1}`,
            isEnabled: rule.isEnabled !== undefined ? rule.isEnabled : true
        }));
        return { rules: validRules };
    }

    isValidShortcut(shortcut) {
        const validModifiers = ['Control', 'Alt', 'Shift', 'CapsLock', 'NumLock'];
        if (!shortcut || typeof shortcut !== 'string') return false;
        const parts = shortcut.split('+');
        if (parts.length < 2 || parts.length > 3) return false;
        const mainKey = parts[parts.length - 1];
        const modifiers = parts.slice(0, -1);
        return modifiers.every(mod => validModifiers.includes(mod)) &&
            (mainKey.length === 1 || /^F[1-9]|F1[0-2]|Esc|Home|End|PageUp|PageDown|Insert|Delete|Tab|Enter|Eliminate|Backspace|ArrowUp|ArrowDown|ArrowLeft|ArrowRight$/.test(mainKey));
    }

    checkConflicts(newRule, currentUrl, excludeIndex = -1) {
        const conflicts = [];
        this.clickRules.rules.forEach((rule, index) => {
            if (index === excludeIndex) return;
            try {
                if (new RegExp(rule.urlPattern).test(currentUrl)) {
                    if (rule.shortcut.toLowerCase() === newRule.shortcut.toLowerCase()) {
                        conflicts.push({ type: 'shortcut', rule, index });
                    } else if (rule.selector === newRule.selector && rule.nthElement === newRule.nthElement) {
                        conflicts.push({ type: 'element', rule, index });
                    }
                }
            } catch (e) {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 的正規表達式無效: ${rule.urlPattern}`);
            }
        });
        return conflicts;
    }
}

class ShortcutHandler {
    constructor(ruleManager) {
        this.ruleManager = ruleManager;
        this.keydownHandler = (event) => this.handleKeydown(event);
        window.addEventListener('keydown', this.keydownHandler);
    }

    handleKeydown(event) {
        //console.log(event);
        const currentUrl = window.location.href;
        [...this.ruleManager.clickRules.rules].reverse().some((rule, index) => {
            try {
                if (!rule.isEnabled || !new RegExp(rule.urlPattern).test(currentUrl)) return false;

                const shortcutParts = rule.shortcut.split('+');
                const mainKey = shortcutParts[shortcutParts.length - 1];
                const modifiers = shortcutParts.slice(0, -1);

                const allModifiersPressed = modifiers.every(mod => event.getModifierState(mod));
                const mainKeyPressed = event.key.toUpperCase() === mainKey.toUpperCase();

                if (allModifiersPressed && mainKeyPressed) {
                    event.preventDefault();
                    const originalIndex = this.ruleManager.clickRules.rules.length - 1 - index;
                    this.clickElement(rule, originalIndex);
                    return true;
                }
                return false;
            } catch (e) {
                console.warn(`${GM_info.script.name}: 處理規則 "${rule.ruleName}" 時發生錯誤: ${e}`);
                return false;
            }
        });
        //console.log(this.ruleManager.clickRules.rules);
    }

    getElements(selectorType, selector) {
        try {
            if (selectorType === 'xpath') {
                const nodes = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
                const elements = [];
                for (let i = 0; i < nodes.snapshotLength; i++) {
                    elements.push(nodes.snapshotItem(i));
                }
                return elements;
            } else if (selectorType === 'css') {
                return Array.from(document.querySelectorAll(selector));
            }
            return [];
        } catch (e) {
            console.warn(`${GM_info.script.name}: 選擇器 "${selector}" 無效: ${e}`);
            return [];
        }
    }

    clickElement(rule, ruleIndex) {
        try {
            const elements = this.getElements(rule.selectorType, rule.selector);
            if (elements.length === 0) {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 未找到符合元素: ${rule.selector}`);
                return false;
            }

            let targetIndex;
            if (rule.nthElement > 0) {
                targetIndex = rule.nthElement - 1;
            } else if (rule.nthElement < 0) {
                targetIndex = elements.length + rule.nthElement;
            } else {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 的 nthElement 無效: 0 不允許`);
                return false;
            }

            if (targetIndex < 0 || targetIndex >= elements.length) {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 的 nthElement 無效: ${rule.nthElement}, 找到 ${elements.length} 個元素`);
                return false;
            }

            const targetElement = elements[targetIndex];
            if (targetElement) {
                console.log(`${GM_info.script.name}: 規則 "${rule.ruleName}" 成功點選元素:`, targetElement);
                if (rule.ifLinkOpen && targetElement.tagName === "A" && targetElement.href) {
                    window.location.href = targetElement.href;
                } else {
                    targetElement.click();
                }
                return true;
            } else {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 的目標元素未找到`);
                return false;
            }
        } catch (e) {
            console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 執行失敗: ${e}`);
            return false;
        }
    }
}

class MenuManager {
    constructor(ruleManager, config) {
        this.ruleManager = ruleManager;
        this.config = config;
        this.validModifierCombos = this.generateModifierCombos();
        this.currentExpandedRule = null;
        this.currentExpandedConflict = null;
        this.i18n = {
            'zh-TW': {
                titleAdd: '新增快捷鍵規則',
                titleManage: '管理快捷鍵規則',
                matchingRules: '符合的規則',
                noMatchingRules: '當前網頁無符合的規則。',
                addRuleSection: '新增規則',
                ruleName: '規則名稱:',
                urlPattern: '網址正規表達式:',
                selectorType: '選擇器類型:',
                selector: '選擇器:',
                nthElement: '第幾個元素(正數從頭計,負數從尾計):',
                shortcutModifiers: '快捷鍵修飾鍵組合:',
                shortcutMainKey: '快捷鍵主鍵:',
                ifLinkOpen: '若為連結則開啟(否則維持預設):',
                isEnabled: '啟用規則:',
                addRule: '新增規則',
                save: '儲存',
                delete: '刪除',
                ruleNamePlaceholder: '例如:我的規則',
                urlPatternPlaceholder: '例如:https://example.com/.*',
                selectorPlaceholder: '例如:button.submit 或 //button[@class="submit"]',
                nthElementPlaceholder: '例如:1 或 -1(最後一個)',
                mainKeyPlaceholder: '例如:A',
                invalidRegex: '無效的正規表達式',
                invalidSelector: '無效的選擇器',
                invalidMainKey: '無效的主鍵(需為單一字母或數字,例如:A 或 1)',
                conflictingRules: '衝突的規則',
                shortcutConflict: '使用相同的快捷鍵組合',
                elementConflict: '指向相同的目標元素',
                importJSON: '從 JSON 匯入規則'
            },
            'en': {
                titleAdd: 'Add New Shortcut Rule',
                titleManage: 'Manage Shortcut Rules',
                matchingRules: 'Matching Rules',
                noMatchingRules: 'No rules match the current URL.',
                addRuleSection: 'Add New Rule',
                ruleName: 'Rule Name:',
                urlPattern: 'URL Pattern (Regex):',
                selectorType: 'Selector Type:',
                selector: 'Selector:',
                nthElement: 'Nth Element (positive from start, negative from end):',
                shortcutModifiers: 'Shortcut Modifier Combination:',
                shortcutMainKey: 'Shortcut Main Key:',
                ifLinkOpen: 'If it is a link, open it (otherwise keep default):',
                isEnabled: 'Enable Rule:',
                addRule: 'Add Rule',
                save: 'Save',
                delete: 'Delete',
                ruleNamePlaceholder: 'e.g., My Rule',
                urlPatternPlaceholder: 'e.g., https://example\\.com/.*',
                selectorPlaceholder: 'e.g., button.submit or //button[@class="submit"]',
                nthElementPlaceholder: 'e.g., 1 or -1 (last element)',
                mainKeyPlaceholder: 'e.g., A',
                invalidRegex: 'Invalid regular expression',
                invalidSelector: 'Invalid selector',
                invalidMainKey: 'Invalid main key (must be a single character or key, e.g., A or F1)',
                conflictingRules: 'Conflicting Rules',
                shortcutConflict: 'Uses the same shortcut key combination',
                elementConflict: 'Targets the same element',
                importJSON: 'Import Rule from JSON'
            }
        };
    }

    getLanguage() {
        const lang = navigator.language || navigator.userLanguage;
        if (lang.startsWith('zh')) return 'zh-TW';
        return 'en';
    }

    validateRule(rule) {
        const i18n = this.i18n[this.getLanguage()];
        try {
            new RegExp(rule.urlPattern);
        } catch (e) {
            alert(`${i18n.invalidRegex}: ${rule.urlPattern}`);
            return false;
        }
        if (!rule.selector || !['css', 'xpath'].includes(rule.selectorType)) {
            alert(`${i18n.invalidSelector}: ${rule.selector}`);
            return false;
        }
        if (!this.validateShortcut(rule.shortcut)) {
            alert(`${i18n.invalidMainKey}: ${rule.shortcut}`);
            return false;
        }
        return true;
    }

    validateShortcut(shortcut) {
        const validModifiers = ['Control', 'Alt', 'Shift', 'CapsLock', 'NumLock'];
        if (!shortcut) return false;
        const parts = shortcut.split('+');
        if (parts.length < 2 || parts.length > 3) return false;
        const mainKey = parts[parts.length - 1];
        const modifiers = parts.slice(0, -1);
        return modifiers.every(mod => validModifiers.includes(mod)) &&
            (mainKey.length === 1 || /^F[1-9]|F1[0-2]|Esc|Home|End|PageUp|PageDown|Insert|Delete|Tab|Enter|Eliminate|Backspace|ArrowUp|ArrowDown|ArrowLeft|ArrowRight$/.test(mainKey));
    }

    generateModifierCombos() {
        const modifiers = ['CapsLock', 'NumLock', 'Control', 'Alt', 'Shift'];
        const combos = [];
        for (let i = 0; i < modifiers.length; i++) {
            combos.push(modifiers[i]);
            for (let j = i + 1; j < modifiers.length; j++) {
                combos.push(`${modifiers[i]}-${modifiers[j]}`);
            }
        }
        return combos;
    }

    createRuleElement(rule, ruleIndex) {
        const i18n = this.i18n[this.getLanguage()];
        const modifierCombo = rule.shortcut && this.validateShortcut(rule.shortcut)
        ? rule.shortcut.split('+').slice(0, -1).join('+') || rule.shortcut.split('+')[0]
        : 'Control';
        const mainKey = rule.shortcut && this.validateShortcut(rule.shortcut)
        ? rule.shortcut.split('+').pop()
        : 'A';
        const currentUrl = window.location.href;
        const conflicts = this.ruleManager.checkConflicts(rule, currentUrl, ruleIndex);
        const conflictHtml = conflicts.length > 0 ? `
            <div class="conflictHeader" id="conflictHeader${ruleIndex}">
                <strong>${i18n.conflictingRules}</strong>
            </div>
            <div class="conflictDetails" id="conflictDetails${ruleIndex}" style="display: none;">
                ${conflicts.map(conflict => `
                    <p>${conflict.type === 'shortcut' ? i18n.shortcutConflict : i18n.elementConflict}:
                    ${conflict.rule.ruleName} (快捷鍵: ${conflict.rule.shortcut}, 選擇器: ${conflict.rule.selector}, 第幾個元素: ${conflict.rule.nthElement})</p>
                `).join('')}
            </div>
        ` : '';

        const ruleDiv = document.createElement('div');
        ruleDiv.innerHTML = `
            <div class="ruleHeader" id="ruleHeader${ruleIndex}">
                <strong>${rule.ruleName || `規則 ${ruleIndex + 1}`}</strong>
            </div>
            <div class="readRule" id="readRule${ruleIndex}" style="display: none;">
                <div class="checkbox-container">
                    <label>${i18n.isEnabled}</label>
                    <input type="checkbox" id="updateIsEnabled${ruleIndex}" ${rule.isEnabled ? 'checked' : ''} ${this.config.RuleU.includes('isEnabled') ? '' : 'disabled'}>
                </div>
                <label>${i18n.ruleName}</label>
                <input type="text" id="updateRuleName${ruleIndex}" value="${rule.ruleName || ''}" ${this.config.RuleU.includes('ruleName') ? '' : 'readonly'}>
                <label>${i18n.urlPattern}</label>
                <input type="text" id="updateUrlPattern${ruleIndex}" value="${rule.urlPattern}" ${this.config.RuleU.includes('urlPattern') ? '' : 'readonly'}>
                <label>${i18n.selectorType}</label>
                <select id="updateSelectorType${ruleIndex}" ${this.config.RuleU.includes('selectorType') ? '' : 'disabled'}>
                    <option value="css" ${rule.selectorType === 'css' ? 'selected' : ''}>CSS</option>
                    <option value="xpath" ${rule.selectorType === 'xpath' ? 'selected' : ''}>XPath</option>
                </select>
                <label>${i18n.selector}</label>
                <input type="text" id="updateSelector${ruleIndex}" value="${rule.selector}" ${this.config.RuleU.includes('selector') ? '' : 'readonly'}>
                <label>${i18n.nthElement}</label>
                <input type="number" id="updateNthElement${ruleIndex}" value="${rule.nthElement}" ${this.config.RuleU.includes('nthElement') ? '' : 'readonly'} placeholder="${i18n.nthElementPlaceholder}">
                <label>${i18n.shortcutModifiers}</label>
                <select id="updateModifierCombo${ruleIndex}" ${this.config.RuleU.includes('shortcut') ? '' : 'disabled'}>
                    ${this.validModifierCombos.map(combo => `
                        <option value="${combo}" ${combo === modifierCombo ? 'selected' : ''}>${combo}</option>
                    `).join('')}
                </select>
                <label>${i18n.shortcutMainKey}</label>
                <input type="text" id="updateMainKey${ruleIndex}" value="${mainKey}" ${this.config.RuleU.includes('shortcut') ? '' : 'readonly'} placeholder="${i18n.mainKeyPlaceholder}">
                <div class="checkbox-container">
                    <label>${i18n.ifLinkOpen}</label>
                    <input type="checkbox" id="updateIfLink${ruleIndex}" ${rule.ifLinkOpen ? 'checked' : ''} ${this.config.RuleU.includes('ifLinkOpen') ? '' : 'disabled'}>
                </div>
                <button id="updateRule${ruleIndex}">${i18n.save}</button>
                ${this.config.RuleD ? `<button id="deleteRule${ruleIndex}">${i18n.delete}</button>` : ''}
                ${conflictHtml}
            </div>
        `;
        return ruleDiv;
    }

    initAddRuleMenu() {
        const i18n = this.i18n[this.getLanguage()];
        const menu = document.createElement('div');
        menu.style.position = 'fixed';
        menu.style.top = '10px';
        menu.style.right = '10px';
        menu.style.background = 'rgb(36, 36, 36)';
        menu.style.color = 'rgb(204, 204, 204)';
        menu.style.border = '1px solid rgb(80, 80, 80)';
        menu.style.padding = '10px';
        menu.style.zIndex = '10000';
        menu.style.maxWidth = '400px';
        menu.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)';
        menu.innerHTML = `
            <style>
                h1 { font-size: 2rem; }
                h2 { font-size: 1.5rem; }
                #shortcutMenu { overflow-y: auto; max-height: 80vh; }
                #shortcutMenu input:not([type="checkbox"]), #shortcutMenu select, #shortcutMenu button, #shortcutMenu textarea {
                    background: rgb(50, 50, 50);
                    color: rgb(204, 204, 204);
                    border: 1px solid rgb(80, 80, 80);
                    margin: 5px 0;
                    padding: 5px;
                    width: 100%;
                    box-sizing: border-box;
                }
                #shortcutMenu input[type="checkbox"] {
                    margin: 0 5px 0 0;
                    width: auto;
                    vertical-align: middle;
                }
                #shortcutMenu button { cursor: pointer; }
                #shortcutMenu button:hover { background: rgb(70, 70, 70); }
                #shortcutMenu label { margin-top: 5px; display: block; }
                #shortcutMenu .checkbox-container { display: flex; align-items: center; margin-top: 5px; }
                #shortcutMenu .headerContainer { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
                #shortcutMenu .closeButton { width: auto; padding: 5px 10px; margin: 0; }
            </style>
            <div id="shortcutMenu">
                <div class="headerContainer">
                    <h1>${GM_info.script.name}</h1>
                    <button id="closeMenu" class="closeButton">✕</button>
                </div>
                <h2>${i18n.titleAdd}</h2>
                <div class="checkbox-container">
                    <label>${i18n.isEnabled}</label>
                    <input type="checkbox" id="isEnabled" checked ${this.config.RuleU.includes('isEnabled') ? '' : 'disabled'}>
                </div>
                <label>${i18n.ruleName}</label>
                <input type="text" id="ruleName" placeholder="${i18n.ruleNamePlaceholder}" ${this.config.RuleU.includes('ruleName') ? '' : 'readonly'}>
                <label>${i18n.urlPattern}</label>
                <input type="text" id="urlPattern" value="${window.location.href}" ${this.config.RuleU.includes('urlPattern') ? '' : 'readonly'}>
                <label>${i18n.selectorType}</label>
                <select id="selectorType" ${this.config.RuleU.includes('selectorType') ? '' : 'disabled'}>
                    <option value="css">CSS</option>
                    <option value="xpath">XPath</option>
                </select>
                <label>${i18n.selector}</label>
                <input type="text" id="selector" placeholder="${i18n.selectorPlaceholder}" ${this.config.RuleU.includes('selector') ? '' : 'readonly'}>
                <label>${i18n.nthElement}</label>
                <input type="number" id="nthElement" value="1" placeholder="${i18n.nthElementPlaceholder}" ${this.config.RuleU.includes('nthElement') ? '' : 'readonly'}>
                <label>${i18n.shortcutModifiers}</label>
                <select id="modifierCombo" ${this.config.RuleU.includes('shortcut') ? '' : 'disabled'}>
                    ${this.validModifierCombos.map(combo => `<option value="${combo}">${combo}</option>`).join('')}
                </select>
                <label>${i18n.shortcutMainKey}</label>
                <input type="text" id="mainKey" placeholder="${i18n.mainKeyPlaceholder}" ${this.config.RuleU.includes('shortcut') ? '' : 'readonly'}>
                <div class="checkbox-container">
                    <label>${i18n.ifLinkOpen}</label>
                    <input type="checkbox" id="ifLinkOpen" ${this.config.RuleU.includes('ifLinkOpen') ? '' : 'disabled'}>
                </div>
                <button id="addRule" style="margin-top: 10px;">${i18n.addRule}</button>
                <h3>${i18n.importJSON}</h3>
                <textarea id="jsonInput" rows="5" placeholder="Paste JSON here (e.g., {\"ruleName\":\"Example\",\"urlPattern\":\".*\",\"selectorType\":\"css\",\"selector\":\".btn\",\"nthElement\":1,\"shortcut\":\"Control+B\",\"ifLinkOpen\":false,\"isEnabled\":true})"></textarea>
                <button id="importRule">${i18n.importJSON}</button>
            </div>
        `;
        document.body.appendChild(menu);

        document.getElementById('addRule').addEventListener('click', () => {
            const modifierCombo = document.getElementById('modifierCombo').value;
            const mainKey = document.getElementById('mainKey').value;
            const selector = document.getElementById('selector').value.replace(/"/g, "'");
            const newRule = {
                ruleName: document.getElementById('ruleName').value || `規則 ${this.ruleManager.clickRules.rules.length + 1}`,
                urlPattern: document.getElementById('urlPattern').value,
                selectorType: document.getElementById('selectorType').value,
                selector: selector,
                nthElement: parseInt(document.getElementById('nthElement').value) || 1,
                shortcut: `${modifierCombo}+${mainKey}`,
                ifLinkOpen: document.getElementById('ifLinkOpen').checked,
                isEnabled: document.getElementById('isEnabled').checked
            };
            if (!this.validateRule(newRule)) return;

            const conflicts = this.ruleManager.checkConflicts(newRule, window.location.href);
            conflicts.forEach(conflict => {
                console.warn(`${GM_info.script.name}: 新規則 "${newRule.ruleName}" 檢測到${conflict.type === 'shortcut' ? '相同的快捷鍵組合' : '相同的目標元素'}: 與規則 "${conflict.rule.ruleName}" 衝突 (快捷鍵: ${conflict.rule.shortcut}, 選擇器: ${conflict.rule.selector}, 第幾個元素: ${conflict.rule.nthElement})`);
            });

            this.ruleManager.addRule(newRule);
            document.getElementById('ruleName').value = '';
            document.getElementById('urlPattern').value = '';
            document.getElementById('selector').value = '';
            document.getElementById('nthElement').value = '1';
            document.getElementById('mainKey').value = '';
            document.getElementById('ifLinkOpen').checked = false;
            document.getElementById('isEnabled').checked = true;
            menu.remove();
        });

        document.getElementById('importRule').addEventListener('click', () => {
            const jsonString = document.getElementById('jsonInput').value;
            if (this.ruleManager.addRuleFromJSON(jsonString)) {
                alert('Rule added successfully');
                document.getElementById('jsonInput').value = '';
            } else {
                alert('Invalid JSON or rule format');
            }
        });

        document.getElementById('closeMenu').addEventListener('click', () => {
            menu.remove();
        });
    }

    initManageRulesMenu() {
        const i18n = this.i18n[this.getLanguage()];
        const menu = document.createElement('div');
        menu.style.position = 'fixed';
        menu.style.top = '10px';
        menu.style.right = '10px';
        menu.style.background = 'rgb(36, 36, 36)';
        menu.style.color = 'rgb(204, 204, 204)';
        menu.style.border = '1px solid rgb(80, 80, 80)';
        menu.style.padding = '10px';
        menu.style.zIndex = '10000';
        menu.style.maxWidth = '400px';
        menu.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)';
        menu.innerHTML = `
            <style>
                h1 { font-size: 2rem; }
                h2 { font-size: 1.5rem; }
                #shortcutMenu { overflow-y: auto; max-height: 80vh; }
                #shortcutMenu input:not([type="checkbox"]), #shortcutMenu select, #shortcutMenu button {
                    background: rgb(50, 50, 50);
                    color: rgb(204, 204, 204);
                    border: 1px solid rgb(80, 80, 80);
                    margin: 5px 0;
                    padding: 5px;
                    width: 100%;
                    box-sizing: border-box;
                }
                #shortcutMenu input[type="checkbox"] {
                    margin: 0 5px 0 0;
                    width: auto;
                    vertical-align: middle;
                }
                #shortcutMenu button { cursor: pointer; }
                #shortcutMenu button:hover { background: rgb(70, 70, 70); }
                #shortcutMenu label { margin-top: 5px; display: block; }
                #shortcutMenu .checkbox-container { display: flex; align-items: center; margin-top: 5px; }
                #shortcutMenu .ruleHeader, #shortcutMenu .conflictHeader { cursor: pointer; background: rgb(50, 50, 50); padding: 5px; margin: 5px 0; border-radius: 3px; }
                #shortcutMenu .readRule, #shortcutMenu .conflictDetails { padding: 5px; border: 1px solid rgb(80, 80, 80); border-radius: 3px; margin-bottom: 5px; }
                #shortcutMenu .headerContainer { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
                #shortcutMenu .closeButton { width: auto; padding: 5px 10px; margin: 0; }
            </style>
            <div id="shortcutMenu">
                <div class="headerContainer">
                    <h1>${GM_info.script.name}</h1>
                    <button id="closeMenu" class="closeButton">✕</button>
                </div>
                <h2>${i18n.titleManage}</h2>
                <div id="rulesList"></div>
            </div>
        `;
        document.body.appendChild(menu);

        this.updateRulesElement();

        document.getElementById('closeMenu').addEventListener('click', () => {
            this.collapseAllRules();
            this.collapseAllConflicts();
            menu.remove();
        });
    }

    updateRulesElement() {
        const rulesList = document.getElementById('rulesList');
        const i18n = this.i18n[this.getLanguage()];
        rulesList.innerHTML = `<h4>${i18n.matchingRules}</h4>`;
        const currentUrl = window.location.href;
        const matchingRules = this.ruleManager.clickRules.rules.filter(rule => {
            try {
                return new RegExp(rule.urlPattern).test(currentUrl);
            } catch (e) {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 的正規表達式無效: ${rule.urlPattern}`);
                return false;
            }
        });

        if (matchingRules.length === 0) {
            rulesList.innerHTML += `<p>${i18n.noMatchingRules}</p>`;
            return;
        }

        matchingRules.forEach((rule, index) => {
            const ruleIndex = this.ruleManager.clickRules.rules.indexOf(rule);
            const ruleDiv = this.createRuleElement(rule, ruleIndex);
            rulesList.appendChild(ruleDiv);

            document.getElementById(`ruleHeader${ruleIndex}`).addEventListener('click', () => {
                if (this.currentExpandedRule === ruleIndex) {
                    this.collapseAllRules();
                    this.collapseAllConflicts();
                } else {
                    this.collapseAllRules();
                    this.collapseAllConflicts();
                    const details = document.getElementById(`readRule${ruleIndex}`);
                    details.style.display = 'block';
                    this.currentExpandedRule = ruleIndex;
                }
            });

            const conflictHeader = document.getElementById(`conflictHeader${ruleIndex}`);
            if (conflictHeader) {
                conflictHeader.addEventListener('click', () => {
                    const conflictDetails = document.getElementById(`conflictDetails${ruleIndex}`);
                    if (this.currentExpandedConflict === ruleIndex) {
                        conflictDetails.style.display = 'none';
                        this.currentExpandedConflict = null;
                    } else {
                        this.collapseAllConflicts();
                        conflictDetails.style.display = 'block';
                        this.currentExpandedConflict = ruleIndex;
                    }
                });
            }

            document.getElementById(`updateRule${ruleIndex}`).addEventListener('click', () => {
                const modifierCombo = document.getElementById(`updateModifierCombo${ruleIndex}`).value;
                const mainKey = document.getElementById(`updateMainKey${ruleIndex}`).value;
                const selector = document.getElementById(`updateSelector${ruleIndex}`).value.replace(/"/g, "'");
                const updatedRule = {
                    ruleName: document.getElementById(`updateRuleName${ruleIndex}`).value || `規則 ${ruleIndex + 1}`,
                    urlPattern: document.getElementById(`updateUrlPattern${ruleIndex}`).value,
                    selectorType: document.getElementById(`updateSelectorType${ruleIndex}`).value,
                    selector: selector,
                    nthElement: parseInt(document.getElementById(`updateNthElement${ruleIndex}`).value) || 1,
                    shortcut: `${modifierCombo}+${mainKey}`,
                    ifLinkOpen: document.getElementById(`updateIfLink${ruleIndex}`).checked,
                    isEnabled: document.getElementById(`updateIsEnabled${ruleIndex}`).checked
                };
                if (!this.validateRule(updatedRule)) return;

                const conflicts = this.ruleManager.checkConflicts(updatedRule, window.location.href, ruleIndex);
                conflicts.forEach(conflict => {
                    console.warn(`${GM_info.script.name}: 更新規則 "${updatedRule.ruleName}" 檢測到${conflict.type === 'shortcut' ? '相同的快捷鍵組合' : '相同的目標元素'}: 與規則 "${conflict.rule.ruleName}" 衝突 (快捷鍵: ${conflict.rule.shortcut}, 選擇器: ${conflict.rule.selector}, 第幾個元素: ${conflict.rule.nthElement})`);
                });

                this.ruleManager.updateRule(ruleIndex, updatedRule);
                this.updateRulesElement();
            });

            const deleteButton = document.getElementById(`deleteRule${ruleIndex}`);
            if (deleteButton) {
                deleteButton.addEventListener('click', () => {
                    this.ruleManager.deleteRule(ruleIndex);
                    this.updateRulesElement();
                });
            }
        });
    }

    collapseAllRules() {
        if (this.currentExpandedRule !== null) {
            const ruleDetails = document.getElementById(`readRule${this.currentExpandedRule}`);
            if (ruleDetails) ruleDetails.style.display = 'none';
            this.currentExpandedRule = null;
        }
    }

    collapseAllConflicts() {
        if (this.currentExpandedConflict !== null) {
            const conflictDetails = document.getElementById(`conflictDetails${this.currentExpandedConflict}`);
            if (conflictDetails) conflictDetails.style.display = 'none';
            this.currentExpandedConflict = null;
        }
    }
}

class ShortcutAPI {
    constructor(config = {}) {
        this.config = {
            RuleC: true,
            RuleR: true,
            RuleU: ['ruleName', 'urlPattern', 'selectorType', 'selector', 'nthElement', 'shortcut', 'ifLinkOpen', 'isEnabled'],
            RuleD: true,
            ...config
        };
        this.ruleManager = new RuleManager();
        this.shortcutHandler = new ShortcutHandler(this.ruleManager);
        this.menuManager = null;
        if (this.config.RuleC || this.config.RuleR) {
            this.menuManager = new MenuManager(this.ruleManager, this.config);
            this.initMenus();
        }
    }

    initMenus() {
        const i18n = this.menuManager.i18n[this.menuManager.getLanguage()];
        if (this.config.RuleC) {
            GM_registerMenuCommand(i18n.titleAdd, () => this.menuManager.initAddRuleMenu());
        }
        if (this.config.RuleR) {
            GM_registerMenuCommand(i18n.titleManage, () => this.menuManager.initManageRulesMenu());
        }
    }

    addRule(rule) {
        this.ruleManager.addRule(rule);
    }

    getRules() {
        return this.ruleManager.clickRules.rules;
    }

    updateRule(index, updatedRule) {
        this.ruleManager.updateRule(index, updatedRule);
    }

    deleteRule(index) {
        this.ruleManager.deleteRule(index);
    }

    addRuleFromJSON(jsonString) {
        return this.ruleManager.addRuleFromJSON(jsonString);
    }

    initAddRuleMenu() {
        if (!this.menuManager) {
            this.menuManager = new MenuManager(this.ruleManager, this.config);
        }
        this.menuManager.initAddRuleMenu();
    }

    initManageRulesMenu() {
        if (!this.menuManager) {
            this.menuManager = new MenuManager(this.ruleManager, this.config);
        }
        this.menuManager.initManageRulesMenu();
    }
}

window.ShortcutLibrary = ShortcutAPI;
长期地址
遇到问题?请前往 GitHub 提 Issues。