乐趣区

关于前端:Monaco-Editor-中的-Keybinding-机制

一、前言

前段时间碰到了一个 Keybinding 相干的问题,于是探索了一番,首先大家可能会有两个问题:Monaco Editor 是啥?Keybinding 又是啥?

  • Monaco Editor
    微软开源的一个代码编辑器,为 VS Code 的编辑器提供反对,Monaco Editor 外围代码与 VS Code 是共用的(都在 VS Code github 仓库中)。
  • Keybinding
    Monaco Editor 中实现快捷键性能的机制(其实精确来说,应该是局部机制),能够使得通过快捷键来执行操作,例如关上命令面板、切换主题以及编辑器中的一些快捷操作等。

本文次要是 针对 Monaco Editor 的 Keybinding 机制进行介绍,因为源码残缺的逻辑比拟庞杂,所以本文中的展现的源码以及流程会有肯定的简化。

文中应用的代码版本:

Monaco Editor:0.30.1

VS Code:1.62.1

二、举个🌰

这里应用 monaco-editor 创立了一个简略的例子,后文会基于这个例子来进行介绍。

import React, {useRef, useEffect, useState} from "react";
import * as monaco from "monaco-editor";
import {codeText} from "./help";

const Editor = () => {const domRef = useRef<HTMLDivElement>(null);
    const [actionDispose, setActionDispose] = useState<monaco.IDisposable>();

    useEffect(() => {
        const editorIns = monaco.editor.create(domRef.current!, {
            value: codeText,
            language: "typescript",
            theme: "vs-dark",
        });
        const action = {
            id: 'test',
            label: 'test',
            precondition: 'isChrome == true',
            keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL],
            run: () => {window.alert('chrome: cmd + k');
            },
        };

        setActionDispose(editorIns.addAction(action));
        editorIns.focus();

        return () => {editorIns.dispose();
        };
    }, []);

    const onClick = () => {actionDispose?.dispose();
        window.alert('已卸载');
    };

    return (
        <div>
            <div ref={domRef} className='editor-container' />
            <button className='cancel-button' onClick={onClick}> 卸载 keybinding</button>
        </div>
    );
};

export default Editor;

三、原理机制

1. 概览

依据下面的例子,Keybinding 机制的总体流程能够简略的分为以下几步:

  • 初始化:次要是初始化服务以及给 dom 增加监听事件
  • 注册:注册 keybinding 和 command
  • 执行:通过按快捷键触发执行对应的 keybinding 和 command
  • 卸载:革除注册的 keybinding 和 command

2. 初始化

回到下面例子中创立 editor 的代码:

const editorIns = monaco.editor.create(domRef.current!, {
    value: codeText,
    language: "typescript",
    theme: "vs-dark",
});

初始化过程如下:

创立 editor 之前会先初始化 services,通过实例化 DynamicStandaloneServices 类创立服务:

let services = new DynamicStandaloneServices(domElement, override);

在 constructor 函数中会执行以下代码注册 keybindingService:

let keybindingService = ensure(IKeybindingService, () =>
    this._register(
        new StandaloneKeybindingService(
            contextKeyService,
            commandService,
            telemetryService,
            notificationService,
            logService,
            domElement
        )
    )
);

其中 this._register 办法和 ensure 办法会别离将 StandaloneKeybindingServices 实例保留到 disposable 对象(用于卸载)和 this._serviceCollection 中(用于执行过程查找 keybinding)。

实例化 StandaloneKeybindingService,在 constructor 函数中增加 DOM 监听事件:

this._register(
    dom.addDisposableListener(
        domNode,
        dom.EventType.KEY_DOWN,
        (e: KeyboardEvent) => {const keyEvent = new StandardKeyboardEvent(e);
            const shouldPreventDefault = this._dispatch(
                keyEvent,
                keyEvent.target
            );
            if (shouldPreventDefault) {keyEvent.preventDefault();
                keyEvent.stopPropagation();}
        }
    )
);

以上代码中的 dom.addDisposableListener 办法,会通过 addEventListener 的形式,在 domNode 上增加一个 keydown 事件的监听函数,并且返回一个 DomListener 的实例,该实例蕴含一个用于移除事件监听的 dispose 办法。而后通过 this._register 办法将 DomListener 的实例保存起来。

3. 注册 keybindings

回到例子中的代码:

const action = {
    id: 'test',
    label: 'test',
    precondition: 'isChrome == true',
    keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL],
    run: () => {window.alert('chrome: cmd + k');
    },
};
setActionDispose(editorIns.addAction(action));

注册过程如下:

当通过 editorIns.addAction 来注册 keybinding 时,会调用 StandaloneKeybindingServices 实例的 addDynamicKeybinding 办法来注册 keybinding。

public addDynamicKeybinding(
    commandId: string,
    _keybinding: number,
    handler: ICommandHandler,
    when: ContextKeyExpression | undefined
): IDisposable {const keybinding = createKeybinding(_keybinding, OS);
    const toDispose = new DisposableStore();
    
    if (keybinding) {
        this._dynamicKeybindings.push({
            keybinding: keybinding.parts,
            command: commandId,
            when: when,
            weight1: 1000,
            weight2: 0,
            extensionId: null,
            isBuiltinExtension: false,
        });
        
        toDispose.add(toDisposable(() => {for (let i = 0; i < this._dynamicKeybindings.length; i++) {let kb = this._dynamicKeybindings[i];
                    if (kb.command === commandId) {this._dynamicKeybindings.splice(i, 1);
                        this.updateResolver({source: KeybindingSource.Default,});
                        return;
                    }
                }
            })
        );
    }
    
    toDispose.add(CommandsRegistry.registerCommand(commandId, handler));
    this.updateResolver({source: KeybindingSource.Default});
    
    return toDispose;
}

会先依据传入的 _keybinding 创立 keybinding 实例,而后连同 command、when 等其余信息存入_dynamicKeybindings 数组中,同时会注册对应的 command,当前面触发 keybinding 时便执行对应的 command。返回的 toDispose 实例则用于勾销对应的 keybinding 和 command。

回到下面代码中创立 keybinding 实例的中央,createKeybinding 办法会依据传入的 _keybinding 数字和 OS 类型失去实例,大抵构造如下(已省略局部属性):

{
    parts: [
        {
            ctrlKey: boolean,
            shiftKey: boolean,
            altKey: boolean,
            metaKey: boolean,
            keyCode: KeyCode,
        }
    ],
}

那么,是怎么通过一个 number 失去所有按键信息的呢?往下看↓↓↓

4. key 的转换

先看看一开始传入的 keybinding 是什么:

const action = {
    id: 'test',
    label: 'test',
    precondition: 'isChrome == true',
    keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL],
    run: () => {window.alert('chrome: cmd + k');
    },
};

传入的 keybinding 就是下面代码中的 keybindings 数组中的元素,monaco.KeyMod.CtrlCmd = 2048,monaco.KeyCode.KeyL = 42,对应的数字是 monaco-editor 中定义的枚举值,与实在的 keyCode 存在对应关系。所以注册时传入的 keybinding 参数为:2048 | 42 = 2090

先简略理解下 JS 中的位运算(操作的是 32 位带符号的二进制整数,上面例子中只用 8 位简略示意):

按位与(AND)&

对应的位都为 1 则返回 1,否则返回 0

例如:

00001010 // 10

00000110 // 6

——

00000010 // 2

按位或(OR)|

对应的位,只有有一个为 1 则返回 1,否则返回 0

00001010 // 10

00000110 // 6

——-

00001110 // 14

左移(Left shift)<<

将二进制数每一位向左挪动指定位数,左侧移出的位舍弃,右侧补 0

00001010 // 10

——- // 10 << 2

00101000 // 40

右移 >>

将二进制数每位向右挪动指定位数,右侧移出的位舍弃,左侧用原来最右边的数补齐

00001010 // 10

——- // 10 >> 2

00000010 // 2

无符号右移 >>>

将二进制数每位向右挪动指定位数,右侧移出的位舍弃,左侧补 0

00001010 // 10

——- // 10 >> 2

00000010 // 2

接下来看下是怎么依据一个数字,创立出对应的 keybinding 实例:

export function createKeybinding(keybinding: number, OS: OperatingSystem): Keybinding | null {if (keybinding === 0) {return null;}
    const firstPart = (keybinding & 0x0000FFFF) >>> 0;
    // 解决分两步的 keybinding,例如:shift shift,若无第二局部,则 chordPart = 0
    const chordPart = (keybinding & 0xFFFF0000) >>> 16;
    if (chordPart !== 0) {
        return new ChordKeybinding([createSimpleKeybinding(firstPart, OS),
            createSimpleKeybinding(chordPart, OS)
        ]);
    }
    return new ChordKeybinding([createSimpleKeybinding(firstPart, OS)]);
}

看下 createSimpleKeybinding 办法做了什么

const enum BinaryKeybindingsMask {CtrlCmd = (1 << 11) >>> 0, // 2048
    Shift = (1 << 10) >>> 0,   // 1024
    Alt = (1 << 9) >>> 0,      // 512
    WinCtrl = (1 << 8) >>> 0,  // 256
    KeyCode = 0x000000FF       // 255
}

export function createSimpleKeybinding(keybinding: number, OS: OperatingSystem): SimpleKeybinding {const ctrlCmd = (keybinding & BinaryKeybindingsMask.CtrlCmd ? true : false);
    const winCtrl = (keybinding & BinaryKeybindingsMask.WinCtrl ? true : false);
    const ctrlKey = (OS === OperatingSystem.Macintosh ? winCtrl : ctrlCmd);
    const shiftKey = (keybinding & BinaryKeybindingsMask.Shift ? true : false);
    const altKey = (keybinding & BinaryKeybindingsMask.Alt ? true : false);
    const metaKey = (OS === OperatingSystem.Macintosh ? ctrlCmd : winCtrl);
    const keyCode = (keybinding & BinaryKeybindingsMask.KeyCode);

    return new SimpleKeybinding(ctrlKey, shiftKey, altKey, metaKey, keyCode);
}

拿下面的例子:keybinding = monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL,即 keybinding = 2048 | 42 = 2090,而后看下面代码中的:

const ctrlCmd = (keybinding & BinaryKeybindingsMask.CtrlCmd ? true : false);

运算如下:

100000101010 // 2090 -> keybinding

100000000000 // 2048 -> CtrlCmd

———– // &

100000000000 // 2048 -> CtrlCmd

再看 keyCode 的运算:

const keyCode = (keybinding & BinaryKeybindingsMask.KeyCode)

100000101010 // 2090 -> keybinding

000011111111 // 255 -> KeyCode

———– // &

000000101010 // 42 -> KeyL

于是便失去了 ctrlKey,shiftKey,altKey,metaKey,keyCode 这些值,接下来便由这些值生成 SimpleKeybinding 实例,该实例蕴含了下面的这些按键信息以及一些操作方法。

至此,曾经实现了 keybinding 的注册,将 keybinding 实例及相干信息存入了 StandaloneKeybindingService 实例的 _dynamicKeybindings 数组中,对应的 command 也注册到了 CommandsRegistry 中。

5. 执行

当用户在键盘上按下快捷键时,便会触发 keybinding 对应 command 的执行,执行过程如下:

回到 StandaloneKeybindingServices 初始化的时候,在 domNode 上绑定了 keydown 事件监听函数:

(e: KeyboardEvent) => {const keyEvent = new StandardKeyboardEvent(e);
    const shouldPreventDefault = this._dispatch(keyEvent, keyEvent.target);
    if (shouldPreventDefault) {keyEvent.preventDefault();
        keyEvent.stopPropagation();}
};

当 keydown 事件触发后,便会执行这个监听函数,首先会实例化一个 StandardKeyboardEvent 实例,该实例蕴含了一些按键信息和办法,大抵构造如下(已省略局部属性):

{
    target: HTMLElement,
    ctrlKey: boolean,
    shiftKey: boolean,
    altKey: boolean,
    metaKey: boolean,
    keyCode: KeyCode,
}

其中 keyCode 是通过解决后失去的,由原始键盘事件的 keyCode 转换为 monoco-editor 中的 keyCode,转换过程次要就是兼容一些不同的浏览器,并依据映射关系失去最终的 keyCode。准换办法如下:

function extractKeyCode(e: KeyboardEvent): KeyCode {if (e.charCode) {
        // "keypress" events mostly
        let char = String.fromCharCode(e.charCode).toUpperCase();
        return KeyCodeUtils.fromString(char);
    }
    
    const keyCode = e.keyCode;
    
    // browser quirks
    if (keyCode === 3) {return KeyCode.PauseBreak;} else if (browser.isFirefox) {if (keyCode === 59) {return KeyCode.Semicolon;} else if (keyCode === 107) {return KeyCode.Equal;} else if (keyCode === 109) {return KeyCode.Minus;} else if (platform.isMacintosh && keyCode === 224) {return KeyCode.Meta;}
    } else if (browser.isWebKit) {if (keyCode === 91) {return KeyCode.Meta;} else if (platform.isMacintosh && keyCode === 93) {// the two meta keys in the Mac have different key codes (91 and 93)
            return KeyCode.Meta;
        } else if (!platform.isMacintosh && keyCode === 92) {return KeyCode.Meta;}
    }
    
    // cross browser keycodes:
    return EVENT_KEY_CODE_MAP[keyCode] || KeyCode.Unknown;
}

失去了 keyEvent 实例对象后,便通过 this._dispatch(keyEvent, keyEvent.target) 执行。

protected _dispatch(
    e: IKeyboardEvent,
    target: IContextKeyServiceTarget
): boolean {
    return this._doDispatch(this.resolveKeyboardEvent(e),
        target,
        /*isSingleModiferChord*/ false
    );
}

间接调用了 this._doDispatch 办法,通过 this.resolveKeyboardEvent(e) 办法解决传入的 keyEvent,失去一个蕴含了许多 keybinding 操作方法的实例。

接下来次要看下 _doDispatch 办法次要干了啥(以下仅展现了局部代码):

private _doDispatch(
    keybinding: ResolvedKeybinding,
    target: IContextKeyServiceTarget,
    isSingleModiferChord = false
): boolean {const resolveResult = this._getResolver().resolve(
        contextValue,
        currentChord,
        firstPart
    );

    if (resolveResult && resolveResult.commandId) {if (typeof resolveResult.commandArgs === 'undefined') {
            this._commandService
                .executeCommand(resolveResult.commandId)
                .then(undefined, (err) =>
                    this._notificationService.warn(err)
                );
        } else {
            this._commandService
                .executeCommand(
                    resolveResult.commandId,
                    resolveResult.commandArgs
                )
                .then(undefined, (err) =>
                    this._notificationService.warn(err)
                );
        }
    }
}

次要是找到 keybinding 对应的 command 并执行,_getResolver 办法会拿到已注册的 keybinding,而后通过 resolve 办法找到对应的 keybinding 及 command 信息。而执行 command 则会从 CommandsRegistry 中找到对应已注册的 command,而后执行 command 的 handler 函数(即 keybinding 的回调函数)。

6. 卸载

先看看一开始的例子中的代码:

const onClick = () => {actionDispose?.dispose();
    window.alert('已卸载');
};

卸载过程如下:

回到刚开始注册时:setActionDispose(editorIns.addAction(action)),addAction 办法会返回一个 disposable 对象,setActionDispose 将该对象保留了起来。通过调用该对象的 dispose 办法:actionDispose.dispose(),便可卸载该 action,对应的 command 和 keybinding 便都会被卸载。

四、结语

对 Monaco Editor 的 Keybinding 机制进行简略形容,就是通过监听用户的键盘输入,找到对应注册的 keybinding 和 command,而后执行对应的回调函数。但认真探索的话,每个过程都有很多解决逻辑,本文也只是对其做了一个大体的介绍,实际上还有许多相干的细节没有讲到,感兴趣的同学能够摸索摸索。

退出移动版