图形编辑器开发:快捷键的管理

快捷键操作在图形编辑器中是很高频的操作,能让用户快速高效地执行特定命令。那么今天就来学习图形编辑器是
首页 新闻资讯 行业资讯 图形编辑器开发:快捷键的管理

d5fb10371a081ec0af1539207a7162944397f3.png

大家好,我是前端西瓜哥。

快捷键操作在图形编辑器中是很高频的操作,能让用户快速高效地执行特定命令。

那么今天就来学习图形编辑器是如何做快捷键的管理的。

图片

编辑器 github 地址:

https://github.com/F-star/suika

线上体验:

https://blog.fstars.wang/app/suika/

简单的快捷键绑定

我们先看看原生的键盘事件能否满足需求。

假设我们需要判断用户是否按下了 Ctrl + C(需要精准匹配),如果按下了就执行 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 都可以删除选中元素(这个大多第三方快捷键轮子是支持的);

  • 可以根据不同操作系统绑定不同的快捷键,比如复制,我希望在 Windows 系统为 Ctrl+C,在 MacOS 系统则是 Command+C。

  • 提供环境上下文,绑定的函数可以通过它决定是否被调用,比如我希望移动图形的时候不能执行 Delete 对应删除操作。

  • 支持短路匹配,只执行第一个匹配条件。这是为了防止快捷键冲突,一个快捷键执行了多个行为。当然如果你就是希望一个快捷键要执行多个行为,那可以考虑补充一个 next 方法。

  • 某个快捷键绑定可以设置为高优先级,比如激活某个工具时,要注册一些快捷键,需要高优先级,以便覆盖掉和其他的同名快捷键。

快捷键管理类

考虑上面这些功能点,我们来实现这个快捷键管理类 KeyBindingManager。

classKeyBindingManager{// 传入一个入口类对象 Editor,之后需要用到它的变量constructor(privateeditor:Editor){}}

keyBinding 对象

一份快捷键绑定(keyBinding)由下面几个部分组成:

key,快捷键描述。理论上应该用 "Ctrl+C" 这种字符串来描述,但它实现起来比较麻烦,要解析,要转换(比如 / 要转成 Slash 去匹配 event.code)。

所以我换成了一个对象:{ CtrlKey: true, keyCode: 'KeyC' }。不用解析,不用转换,直接和 event 的属性对比即可。这个是 精准 匹配,即不能有多余的修饰键。

此外,key 也支持传入数组,这种情况比较少,对应一个行为有多个快捷键的情况。比如删除操作,我们可以传入 [{ keyCode: 'Delete' }, { keyCode: 'Backspace' }]。

winKey,快捷键描述(Windows 特供版)。这个参数是可选的,如果不提供,所有系统都会使用 key 参数。如果提供,且用户操作系统为 Windows,会使用  winKey,忽略 key。

when,是否满足上下文。也是可选的。when 是一个方法,可以通过它拿到一些上下文参数,通过这些参数决定返回的布尔值。如果为 true,表示匹配到了,并执行对应的响应行为;如果为 false,没匹配到,继续找下一个。when 可不提供,表示永远满足条件。

action,快捷键匹配后要执行的方法。

TypeScript 类型签名为:

interfaceIKeyBinding{key:IKey|IKey[];winKey?:IKey|IKey[];when?:(ctx:IWhenCtx)=>boolean;action:(e:KeyboardEvent)=>void;}interfaceIKey{ctrlKey?:boolean;shiftKey?:boolean;altKey?:boolean;metaKey?:boolean;// KeyboardEvent['code'] 或 '*'(匹配任何按键)keyCode:string;}interfaceIWhenCtx{isToolDragging:boolean;// 是否在拖拽中(比如移动工具移动图形中)}

快捷键注册

我们需要用有序表来根据注册顺序保存 keyBinding 的,这里我选择用 Map 数据结构,它是一种有序数据结构。

classKeyBindingManager{// 用 MapprivatekeyBindingMap=newMap<number,IKeyBinding>();privateid=0;//...// 注册一个快捷键register(keybinding:IKeyBinding){constid=this.id;this.keyBindingMap.set(id,keybinding);this.id++;returnid;}// 注销快捷键unregister(id:number){this.keyBindingMap.delete(id);}}

注册方法 register 会返回一个唯一 id,如果需要注销,需要将这个 id 传给注销方法 unregister。

事件的解绑方式有 3 种,这里选择的是类似 setTimeout 返回一个订阅 id 的风格。

事件订阅的几种实现风格

实际上 3 种写法都没啥差别,都是要把绑定事件方法返回的结果保存下来,在合适的时机调用解绑方法。

哦对了,还有注册高优先级快捷键的方法:

classKeyBindingManager{// ...// 绑定一个高优先级快捷键绑定(会放到 Map 的开头)registerWithHighPrior(keybinding:IKeyBinding){constid=this.id;constmap=newMap<number,IKeyBinding>();map.set(id,keybinding);for(const[key,val]ofthis.keyBindingMap){map.set(key,val);}this.keyBindingMap=map;this.id++;returnid;}}

其实就是把这个快捷键注册到 Map 的开头。

如果你需要更细的粒度,比如低优先级、中优先级、高优先级,那你可以考虑传多一个优先级枚举值或一个数值,然后在正确的位置插入。感觉并没有太多需要用到这种粒度的场景。

短路匹配逻辑

然后就是快捷键的匹配逻辑:

  • 匹配顺序根据注册顺序(有特例,就是前面说的高优先级快捷键绑定,会插队,插到队伍开头)。

  • 使用精准匹配(key 或 winKey),以及 when 方法是否为 true,都为 true 时执行 action。

  • 使用短路逻辑,即只执行第一个匹配的(后面可能也有其他匹配的,但不执行)。这个其实是设计模式的责任链模式,像是 express 或 koa 的路由匹配机制也是责任链模式。

实现如下:

constisWindows=navigator.platform.toLowerCase().includes('win')||navigator.userAgent.includes('Windows');classKeyBindingManager{// ...// 绑定到原生键盘按下事件上bindEvent(){if(this.isBound)return;this.isBound=true;document.addEventListener('keydown',this.handleAction);}// 找到匹配的 keyBinding,执行其 actionprivatehandleAction=(e:KeyboardEvent)=>{if(e.targetinstanceofHTMLInputElement||e.targetinstanceofHTMLTextAreaElement){return;}let isMatch=false;// 生成上下文对象,可根据需要扩充constctx:IWhenCtx={isToolDragging:this.editor.toolManager.isDragging,};for(constkeyBinding ofthis.keyBindingMap.values()){// 先看看 when 是否为 true(when 可不提供)if(!keyBinding.when||keyBinding.when(ctx)){// 如果是 Windows 操作系统,看看 winKey 对不对if(isWindows){if(keyBinding.winKey&&this.isKeyMatch(keyBinding.winKey,e)){isMatch=true;}}// 其他操作系统,看 key 是否匹配elseif(this.isKeyMatch(keyBinding.key,e)){isMatch=true;}}// 匹配if(isMatch){e.preventDefault();keyBinding.action(e);// 执行对应 action(行为)break;// 结束,不继续遍历}}};privateisKeyMatch(key:IKey|IKey[],e:KeyboardEvent):boolean{if(Array.isArray(key)){returnkey.some((k)=>this.isKeyMatch(k,e));}if(key.keyCode=='*')returntrue;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);}}

用法举例

类写好了,看看用法。

删除快捷键的写法:

constdeleteAction=()=>{// 删除选中元素};editor.keybindingManager.register({// Backspace 或 Delete 都可以删除key:[{keyCode:'Backspace'},{keyCode:'Delete'}],// 只能在没有发生拖拽的情况下下删除(比如移动图形时不能删除)when:(ctx)=>!ctx.isToolDragging,action:deleteAction,});

复制快捷键的写法:

constcopyHandler=()=>{// 复制}editor.keybindingManager.register({key:{metaKey:true,keyCode:'KeyC'},// Windows 环境下的快捷键winKey:{ctrlKey:true,keyCode:'KeyC'},action:copyHandler,});

一些优化点

  • 如果你考虑一些非美式键盘,比如法语键盘,因为按键布局位置发生了变化,需要做键位的重映射,确保物理位置不变,确保用户的肌肉记忆有效。

  • 简化快捷键描述的写法,使用类似 Ctrl+/ 的更简洁写法。如果你需要类似 VSCode 一样提供 JSON 文件给支持用户自己设置快捷键,这个还是要实现的。