语雀快捷键修改

修改语雀的快捷键

// ==UserScript==
// @name         语雀快捷键修改
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  修改语雀的快捷键
// @author       AIJake
// @match        https://*.yuque.com/*
// @icon         https://www.google.com/s2/favicons?domain=yuque.com
// @grant        none
// ==/UserScript==

const keyboardList = [
    [8, 'Backspace', 'Backspace'],
    [9, 'Tab', 'Tab'],
    [13, 'Enter', 'Enter'],
    [16, 'Shift', 'ShiftLeft'],
    [17, 'Control', 'ControlLeft'],
    [18, 'Alt', 'AltLeft'],
    [19, 'Pause', 'Pause'],
    [20, 'CapsLock', 'CapsLock'],
    [27, 'Escape', 'Escape'],
    [32, ' ', 'Space'],
    [33, 'PageUp', 'PageUp'],
    [34, 'PageDown', 'PageDown'],
    [35, 'End', 'End'],
    [36, 'Home', 'Home'],
    [37, 'ArrowLeft', 'ArrowLeft'],
    [38, 'ArrowUp', 'ArrowUp'],
    [39, 'ArrowRight', 'ArrowRight'],
    [40, 'ArrowDown', 'ArrowDown'],
    [44, 'PrintScreen', 'PrintScreen'],
    [45, 'Insert', 'Insert'],
    [46, 'Delete', 'Delete'],
    [48, '0', 'Digit0'],
    [49, '1', 'Digit1'],
    [50, '2', 'Digit2'],
    [51, '3', 'Digit3'],
    [52, '4', 'Digit4'],
    [53, '5', 'Digit5'],
    [54, '6', 'Digit6'],
    [55, '7', 'Digit7'],
    [56, '8', 'Digit8'],
    [57, '9', 'Digit9'],
    [65, 'a', 'KeyA'],
    [66, 'b', 'KeyB'],
    [67, 'c', 'KeyC'],
    [68, 'd', 'KeyD'],
    [69, 'e', 'KeyE'],
    [70, 'f', 'KeyF'],
    [71, 'g', 'KeyG'],
    [72, 'h', 'KeyH'],
    [73, 'i', 'KeyI'],
    [74, 'j', 'KeyJ'],
    [75, 'k', 'KeyK'],
    [76, 'l', 'KeyL'],
    [77, 'm', 'KeyM'],
    [78, 'n', 'KeyN'],
    [79, 'o', 'KeyO'],
    [80, 'p', 'KeyP'],
    [81, 'q', 'KeyQ'],
    [82, 'r', 'KeyR'],
    [83, 's', 'KeyS'],
    [84, 't', 'KeyT'],
    [85, 'u', 'KeyU'],
    [86, 'v', 'KeyV'],
    [87, 'w', 'KeyW'],
    [88, 'x', 'KeyX'],
    [89, 'y', 'KeyY'],
    [90, 'z', 'KeyZ'],
    [91, 'Meta', 'MetaLeft'],
    [93, 'ContextMenu', 'ContextMenu'],
    [112, 'F1', 'F1'],
    [113, 'F2', 'F2'],
    [114, 'F3', 'F3'],
    [115, 'F4', 'F4'],
    [116, 'F5', 'F5'],
    [117, 'F6', 'F6'],
    [118, 'F7', 'F7'],
    [119, 'F8', 'F8'],
    [120, 'F9', 'F9'],
    [121, 'F10', 'F10'],
    [122, 'F11', 'F11'],
    [123, 'F12', 'F12'],
    [144, 'NumLock', 'NumLock'],
    [145, 'ScrollLock', 'ScrollLock'],
    [186, ';', 'Semicolon'],
    [187, '=', 'Equal'],
    [188, ',', 'Comma'],
    [189, '-', 'Minus'],
    [190, '.', 'Period'],
    [191, '/', 'Slash'],
    [192, '`', 'Backquote'],
    [219, '[', 'BracketLeft'],
    [220, '\\', 'Backslash'],
    [221, ']', 'BracketRight'],
    [222, '\'', 'Quote'],
    [106, '*', 'NumpadMultiply'],
    [107, '+', 'NumpadAdd'],
    [111, '/', 'NumpadDivide'],
]

const numpadKeyboard = [
    [48, '0', 'Numpad0'],
    [49, '1', 'Numpad1'],
    [50, '2', 'Numpad2'],
    [51, '3', 'Numpad3'],
    [52, '4', 'Numpad4'],
    [53, '5', 'Numpad5'],
    [54, '6', 'Numpad6'],
    [55, '7', 'Numpad7'],
    [56, '8', 'Numpad8'],
    [57, '9', 'Numpad9'],
    [189, '-', 'NumpadSubtract'],
];

const keyMap = { 'Space': 32, 32: 'Space', 'Ctrl': 17, '⌘': 91 };
const keyCodeMap = {};
keyboardList.forEach(([keyCode, key, code]) => {
    keyMap[key] = keyCode;
    keyMap[keyCode] = key;
    keyCodeMap[key] = code;
    keyCodeMap[keyCode] = code;
    keyCodeMap[code] = keyCode;
});

const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;

if (isMac) {
    keyMap['Option'] = keyMap['Alt'];
    keyMap[keyMap['Alt']] = 'Option';
}

const CtrlCmd = (1 << 11) >>> 0;
const Shift = (1 << 10) >>> 0;
const Alt = (1 << 9) >>> 0;
const WinCtrl = (1 << 8) >>> 0;

function upcaseHead(str) {
    return str.toUpperCase().slice(0, 1) + str.slice(1);
}

function hash2keys(hash) {
    let keys = [];
    if (hash & CtrlCmd) {
        keys.push('Ctrl');
    }
    if (hash & WinCtrl) {
        keys.push('⌘');
    }
    if (hash & Shift) {
        keys.push('Shift');
    }
    if (hash & Alt) {
        if (isMac) {
            keys.push('Option');
        } else {
            keys.push('Alt');
        }
    }
    const code = hash & 0xff;
    if (code === 91) {
        keys.push('⌘');
    } else {
        keys.push(code === 32 ? 'Space' : upcaseHead(keyMap[code]));
    }
    return keys;
}

function keys2hash(keys) {
    return keys.reduce((hash, key, index) => {
        const lowKey = key.toLowerCase();
        if (!keyMap[key] && !keyMap[lowKey]) {
            throw new Error(`not valid key ${key}`);
        }
        if (index === keys.length - 1) {
            if (key === 'Space') {
                return hash | 32;
            } else if (key === '⌘') {
                return hash | 91;
            }
            return hash | (keyMap[key] || keyMap[lowKey]);
        } else {
            if (key === '⌘') {
                return hash |= WinCtrl;
            }
            switch (keyMap[key]) {
                case keyMap['Control']: return hash |= CtrlCmd;
                case keyMap['Meta']: return hash |= WinCtrl;
                case keyMap['Shift']: return hash |= Shift;
                case keyMap['Alt']: return hash |= Alt;
                default: throw new Error(`key "${key}" is not last key`);
            }
        }
    }, 0);
}


function keyboardEvent2hash(e) {
    let hash = 0;
    const code = keyMap[e.key] || e.keyCode || e.charCode;
    if (e.ctrlKey || code === keyMap['Ctrl']) {
        hash |= CtrlCmd;
    }
    if (e.metaKey || code === keyMap['Meta']) {
        hash |= WinCtrl;
    };
    if (e.shiftKey || code === keyMap['Shift']) {
        hash |= Shift;
    }
    if (e.altKey || code === keyMap['Alt']) {
        hash |= Alt;
    }
    hash |= code;
    return hash;
}

function str2hash(keyCode) {
    const keys = keyCode.split(/\s*\+\s*/g);
    return keys2hash(keys);
}

function hash2event(hash) {
    const keyCode = (hash & 0xff);
    return {
        keyCode: keyCode,
        which: keyCode,
        key: keyMap[keyCode],
        code: keyCodeMap[keyCode],
        altKey: !!(hash & Alt),
        shiftKey: !!(hash & Shift),
        ctrlKey: !!(hash & CtrlCmd),
        metaKey: !!(hash & WinCtrl),
    };
}

function replaceKeyBoardEventByHash(e, hash) {
    return {
        stopPropagation: () => e.stopPropagation(),
        preventDefault: () => e.preventDefault(),
        stopImmediatePropagation: () => e.stopImmediatePropagation(),
        isTrusted: true,
        srcElement: e.srcElement,
        target: e.target,
        type: e.type,
        view: e.view,
        sourceCapabilities: e.sourceCapabilities,
        bubbles: e.bubbles,
        cancelBubble: e.cancelBubble,
        cancelable: e.cancelable,
        composed: e.composed,
        currentTarget: e.currentTarget,
        defaultPrevented: e.defaultPrevented,
        detail: e.detail,
        eventPhase: e.eventPhase,
        isComposing: e.isComposing,
        timeStamp: e.timeStamp,
        location: e.location,
        path: e.path,
        repeat: e.repeat,
        returnValue: e.returnValue,
        ...hash2event(hash),
    }
}

let hasShowShotkey = false;
// 用来记录data-testid记录的值和新的快捷键hash绑定关系
let RecordChange = {};
let HashReplaceMap = {};
const LOCAL_KEY = 'hotkey-replace';

function save2Store() {
    localStorage.setItem(LOCAL_KEY, JSON.stringify({
        RecordChange,
        HashReplaceMap,
    }));
}

function loadStore() {
    const data = localStorage.getItem(LOCAL_KEY);
    console.info('store data', data);
    if (data) {
        try {
            const store = JSON.parse(data);
            RecordChange = store.RecordChange;
            HashReplaceMap = store.HashReplaceMap;
        } catch (e) {
            console.error(e);
        }
    }
}

// 在原来的handle外面包裹一层
// 可以在需要触发的时候替换掉event
function warpHandle(handle) {
    return e => {
        const hash = keyboardEvent2hash(e);
        if (HashReplaceMap[hash]) {
            // 替换成原来的事件
            return handle(replaceKeyBoardEventByHash(e, HashReplaceMap[hash]));
        }
        return handle(e);
    }
}


(function () {
    'use strict';
    const oldAddEventListener = HTMLElement.prototype.addEventListener
    HTMLElement.prototype.addEventListener = function (name, handle, ...args) {
        if (name === 'keydown') {
            oldAddEventListener.apply(this, [name, warpHandle(handle), ...args]);
        } else {
            oldAddEventListener.apply(this, [name, handle, ...args]);
        }
    }
    loadStore();
    const style = document.createElement('style');
    style.innerText = `
.hotkey-item div[data-testid] {
   cursor: pointer;
   position: relative;
}

input.keyboard-record {
  position: absolute;
  padding: 0 4px;
  z-index: 1;
  right: 0;
  top: 0;
  width: 160px;
  height: 100%;
  border: 1px solid #f0f0f0;
  outline: none;
}
    `;
    document.head.appendChild(style);

    // 创建input 让用户输入新的快捷键
    const input = document.createElement('input');
    input.placeholder = "请按快捷键,按enter结束";
    input.className = "keyboard-record";

    let recordKeys; // 用户输入的快捷键 字符串
    let recordHash; // 用户输入的hash值
    let originHash; // 原始绑定的hash值
    let originTestId; // 当前元素的testid

    // 提交新的快捷键
    const replaceCommit = () => {
        if (recordHash && originHash !== recordHash) {
            // 记录hash替换
            HashReplaceMap[recordHash] = originHash;
            // 记录应该testid和新的hash
            RecordChange[originTestId] = recordHash;
            input.parentNode.innerHTML = recordKeys.map(v => `<kbd>${v}</kbd>`).join('<span>+</span>');
        } else {
            if (RecordChange[originTestId]) {
                delete HashReplaceMap[RecordChange[originTestId]];
                delete RecordChange[originTestId];
            }
            input.parentNode.innerHTML = hash2keys(originHash).map(v => `<kbd>${v}</kbd>`).join('<span>+</span>');
        }
        // 存储当前的记录值
        save2Store();
        recordKeys = undefined;
        recordHash = undefined;
        originTestId = undefined;
        input.value = '';
        return;
    };
    // blur之后提交
    input.onblur = replaceCommit;
    // 监听用户输入的新的快捷键
    input.onkeydown = e => {
        e.stopPropagation();
        e.preventDefault();
        if (e.key === 'Enter') {
            return replaceCommit();
        }
        recordHash = keyboardEvent2hash(e);
        recordKeys = hash2keys(recordHash);
        input.value = hash2keys(recordHash).join('+');
    };

    document.body.addEventListener('click', e => {
        try {
            // 没有点击过 则点击之后修改ui
            if (!hasShowShotkey && e.target.closest('#siteTipGuide')) {
                hasShowShotkey = true;
                setTimeout(() => {
                    Object.keys(RecordChange).forEach(key => {
                        const dom = document.querySelector(`.hotkey-item div[data-testid="${key}"]`);
                        if (dom) {
                            // 生成原来的hash值
                            const keys = [];
                            // 将快捷键记录的内容转成key数组
                            dom.querySelectorAll('kbd').forEach(e => keys.push(e.textContent));
                            // 将原来的快捷键hash绑定上
                            dom.dataset.hash = keys2hash(keys);
                            dom.innerHTML = hash2keys(RecordChange[key]).map(v => `<kbd>${v}</kbd>`).join('<span>+</span>');
                        }
                    });
                }, 100);
                return;
            }
            // 判断是否点击在快捷键设置上
            const hotKeyBindDOM = e.target.closest('div[data-testid]');
            if (hotKeyBindDOM && hotKeyBindDOM.closest('.hotkey-item')) {
                // 记录哪些ui需要修改
                originTestId = hotKeyBindDOM.dataset['testid'];
                // 如果已经记录了原始hash值则直接使用, 未记录则重新生成
                if (hotKeyBindDOM.dataset.hash) {
                    originHash = Number(hotKeyBindDOM.dataset.hash);
                } else {
                    // 生成原来的hash值
                    const keys = [];
                    // 将快捷键记录的内容转成key数组
                    hotKeyBindDOM.querySelectorAll('kbd').forEach(e => keys.push(e.textContent));
                    originHash = keys2hash(keys);
                    hotKeyBindDOM.dataset.hash = originHash;
                }
                // 插入input 让用户输入新的快捷键
                hotKeyBindDOM.appendChild(input);
                input.focus();
            }
        } catch (e) {

        }
    });
})();