圖形編輯器開發(fā):快捷鍵的管理
大家好,我是前端西瓜哥。
快捷鍵操作在圖形編輯器中是很高頻的操作,能讓用戶快速高效地執(zhí)行特定命令。
那么今天就來學習圖形編輯器是如何做快捷鍵的管理的。
編輯器 github 地址:
https://github.com/F-star/suika
線上體驗:
https://blog.fstars.wang/app/suika/
簡單的快捷鍵綁定
我們先看看原生的鍵盤事件能否滿足需求。
假設我們需要判斷用戶是否按下了 Ctrl + C(需要精準匹配),如果按下了就執(zhí)行 copy 方法。
用原生事件,我們要這樣寫:
window.addEventListener('keydown', (e) => {
const { ctrlKey, shiftKey, altKey, metaKey } = e;
if (ctrlKey && !shiftKey && !altKey && !metaKey && e.code === 'KeyC') {
copy();
}
})
寫法有點繁瑣。我們希望能簡化一下寫法。
一開始我并不太在意快捷鍵綁定的管理,因為復雜度還沒起來,就找了一個輪子 hotkeys-js。
import hotkeys from 'hotkeys-js';
hotkeys('ctrl+c', copy);
hotkeys-js 是原生事件的一層簡單的封裝,簡化了寫法并提高了可讀性。
如果你的圖形編輯器并不復雜,用一些易用性不錯的快捷鍵庫是不錯的選擇。
快捷鍵高級能力
原生事件和一些常見的快捷鍵庫可以處理一些簡單的場景,但圖形編輯器的場景往往更復雜。
圖形編輯器還需要的快捷鍵高級能力有:
- 給一個行為設置多個不同快捷鍵,比如 Delete 或 Backspace 都可以刪除選中元素(這個大多第三方快捷鍵輪子是支持的);
- 可以根據(jù)不同操作系統(tǒng)綁定不同的快捷鍵,比如復制,我希望在 Windows 系統(tǒng)為 Ctrl+C,在 MacOS 系統(tǒng)則是 Command+C。
- 提供環(huán)境上下文,綁定的函數(shù)可以通過它決定是否被調用,比如我希望移動圖形的時候不能執(zhí)行 Delete 對應刪除操作。
- 支持短路匹配,只執(zhí)行第一個匹配條件。這是為了防止快捷鍵沖突,一個快捷鍵執(zhí)行了多個行為。當然如果你就是希望一個快捷鍵要執(zhí)行多個行為,那可以考慮補充一個 next 方法。
- 某個快捷鍵綁定可以設置為高優(yōu)先級,比如激活某個工具時,要注冊一些快捷鍵,需要高優(yōu)先級,以便覆蓋掉和其他的同名快捷鍵。
快捷鍵管理類
考慮上面這些功能點,我們來實現(xiàn)這個快捷鍵管理類 KeyBindingManager。
class KeyBindingManager {
// 傳入一個入口類對象 Editor,之后需要用到它的變量
constructor(private editor: Editor) {}
}
keyBinding 對象
一份快捷鍵綁定(keyBinding)由下面幾個部分組成:
key,快捷鍵描述。理論上應該用 "Ctrl+C" 這種字符串來描述,但它實現(xiàn)起來比較麻煩,要解析,要轉換(比如 / 要轉成 Slash 去匹配 event.code)。
所以我換成了一個對象:{ CtrlKey: true, keyCode: 'KeyC' }。不用解析,不用轉換,直接和 event 的屬性對比即可。這個是 精準 匹配,即不能有多余的修飾鍵。
此外,key 也支持傳入數(shù)組,這種情況比較少,對應一個行為有多個快捷鍵的情況。比如刪除操作,我們可以傳入 [{ keyCode: 'Delete' }, { keyCode: 'Backspace' }]。
winKey,快捷鍵描述(Windows 特供版)。這個參數(shù)是可選的,如果不提供,所有系統(tǒng)都會使用 key 參數(shù)。如果提供,且用戶操作系統(tǒng)為 Windows,會使用 winKey,忽略 key。
when,是否滿足上下文。也是可選的。when 是一個方法,可以通過它拿到一些上下文參數(shù),通過這些參數(shù)決定返回的布爾值。如果為 true,表示匹配到了,并執(zhí)行對應的響應行為;如果為 false,沒匹配到,繼續(xù)找下一個。when 可不提供,表示永遠滿足條件。
action,快捷鍵匹配后要執(zhí)行的方法。
TypeScript 類型簽名為:
interface IKeyBinding {
key: IKey | IKey[];
winKey?: IKey | IKey[];
when?: (ctx: IWhenCtx) => boolean;
action: (e: KeyboardEvent) => void;
}
interface IKey {
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
// KeyboardEvent['code'] 或 '*'(匹配任何按鍵)
keyCode: string;
}
interface IWhenCtx {
isToolDragging: boolean; // 是否在拖拽中(比如移動工具移動圖形中)
}
快捷鍵注冊
我們需要用有序表來根據(jù)注冊順序保存 keyBinding 的,這里我選擇用 Map 數(shù)據(jù)結構,它是一種有序數(shù)據(jù)結構。
class KeyBindingManager {
// 用 Map
private keyBindingMap = new Map<number, IKeyBinding>();
private id = 0;
//...
// 注冊一個快捷鍵
register(keybinding: IKeyBinding) {
const id = this.id;
this.keyBindingMap.set(id, keybinding);
this.id++;
return id;
}
// 注銷快捷鍵
unregister(id: number) {
this.keyBindingMap.delete(id);
}
}
注冊方法 register 會返回一個唯一 id,如果需要注銷,需要將這個 id 傳給注銷方法 unregister。
事件的解綁方式有 3 種,這里選擇的是類似 setTimeout 返回一個訂閱 id 的風格。
實際上 3 種寫法都沒啥差別,都是要把綁定事件方法返回的結果保存下來,在合適的時機調用解綁方法。
哦對了,還有注冊高優(yōu)先級快捷鍵的方法:
class KeyBindingManager {
// ...
// 綁定一個高優(yōu)先級快捷鍵綁定(會放到 Map 的開頭)
registerWithHighPrior(keybinding: IKeyBinding) {
const id = this.id;
const map = new Map<number, IKeyBinding>();
map.set(id, keybinding);
for (const [key, val] of this.keyBindingMap) {
map.set(key, val);
}
this.keyBindingMap = map;
this.id++;
return id;
}
}
其實就是把這個快捷鍵注冊到 Map 的開頭。
如果你需要更細的粒度,比如低優(yōu)先級、中優(yōu)先級、高優(yōu)先級,那你可以考慮傳多一個優(yōu)先級枚舉值或一個數(shù)值,然后在正確的位置插入。感覺并沒有太多需要用到這種粒度的場景。
短路匹配邏輯
然后就是快捷鍵的匹配邏輯:
- 匹配順序根據(jù)注冊順序(有特例,就是前面說的高優(yōu)先級快捷鍵綁定,會插隊,插到隊伍開頭)。
- 使用精準匹配(key 或 winKey),以及 when 方法是否為 true,都為 true 時執(zhí)行 action。
- 使用短路邏輯,即只執(zhí)行第一個匹配的(后面可能也有其他匹配的,但不執(zhí)行)。這個其實是設計模式的責任鏈模式,像是 express 或 koa 的路由匹配機制也是責任鏈模式。
實現(xiàn)如下:
const isWindows =
navigator.platform.toLowerCase().includes('win') ||
navigator.userAgent.includes('Windows');
class KeyBindingManager {
// ...
// 綁定到原生鍵盤按下事件上
bindEvent() {
if (this.isBound) return;
this.isBound = true;
document.addEventListener('keydown', this.handleAction);
}
// 找到匹配的 keyBinding,執(zhí)行其 action
private handleAction = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
let isMatch = false;
// 生成上下文對象,可根據(jù)需要擴充
const ctx: IWhenCtx = {
isToolDragging: this.editor.toolManager.isDragging,
};
for (const keyBinding of this.keyBindingMap.values()) {
// 先看看 when 是否為 true(when 可不提供)
if (!keyBinding.when || keyBinding.when(ctx)) {
// 如果是 Windows 操作系統(tǒng),看看 winKey 對不對
if (isWindows) {
if (keyBinding.winKey && this.isKeyMatch(keyBinding.winKey, e)) {
isMatch = true;
}
}
// 其他操作系統(tǒng),看 key 是否匹配
else if (this.isKeyMatch(keyBinding.key, e)) {
isMatch = true;
}
}
// 匹配
if (isMatch) {
e.preventDefault();
keyBinding.action(e); // 執(zhí)行對應 action(行為)
break; // 結束,不繼續(xù)遍歷
}
}
};
private isKeyMatch(key: IKey | IKey[], e: KeyboardEvent): boolean {
if (Array.isArray(key)) {
return key.some((k) => this.isKeyMatch(k, e));
}
if (key.keyCode == '*') return true;
const {
ctrlKey = false,
shiftKey = false,
altKey = false,
metaKey = false,
} = key;
return (
ctrlKey == e.ctrlKey &&
shiftKey == e.shiftKey &&
altKey == e.altKey &&
metaKey == e.metaKey &&
key.keyCode == e.code
);
}
}
用法舉例
類寫好了,看看用法。
刪除快捷鍵的寫法:
const deleteAction = () => {
// 刪除選中元素
};
editor.keybindingManager.register({
// Backspace 或 Delete 都可以刪除
key: [{ keyCode: 'Backspace' }, { keyCode: 'Delete' }],
// 只能在沒有發(fā)生拖拽的情況下下刪除(比如移動圖形時不能刪除)
when: (ctx) => !ctx.isToolDragging,
action: deleteAction,
});
復制快捷鍵的寫法:
const copyHandler = () => {
// 復制
}
editor.keybindingManager.register({
key: { metaKey: true, keyCode: 'KeyC' },
// Windows 環(huán)境下的快捷鍵
winKey: { ctrlKey: true, keyCode: 'KeyC' },
action: copyHandler,
});
一些優(yōu)化點
- 如果你考慮一些非美式鍵盤,比如法語鍵盤,因為按鍵布局位置發(fā)生了變化,需要做鍵位的重映射,確保物理位置不變,確保用戶的肌肉記憶有效。
- 簡化快捷鍵描述的寫法,使用類似 Ctrl+/ 的更簡潔寫法。如果你需要類似 VSCode 一樣提供 JSON 文件給支持用戶自己設置快捷鍵,這個還是要實現(xiàn)的。