feat(editor): 完善快捷键注册机制

This commit is contained in:
roymondchen 2023-06-16 17:39:50 +08:00
parent b37568b440
commit 9716aceabf
9 changed files with 397 additions and 240 deletions

View File

@ -100,6 +100,7 @@ import keybindingService from './services/keybinding';
import propsService from './services/props'; import propsService from './services/props';
import storageService from './services/storage'; import storageService from './services/storage';
import uiService from './services/ui'; import uiService from './services/ui';
import keybindingConfig from './utils/keybinding-config';
import editorProps from './editorProps'; import editorProps from './editorProps';
import { initServiceEvents, initServiceState } from './initService'; import { initServiceEvents, initServiceState } from './initService';
import type { Services } from './type'; import type { Services } from './type';
@ -136,6 +137,8 @@ export default defineComponent({
initServiceEvents(props, emit, services); initServiceEvents(props, emit, services);
initServiceState(props, services); initServiceState(props, services);
keybindingService.registe(keybindingConfig);
keybindingService.registeEl('global');
provide('services', services); provide('services', services);

View File

@ -25,6 +25,7 @@ export const initServiceState = (
eventsService, eventsService,
uiService, uiService,
codeBlockService, codeBlockService,
keybindingService,
}: Services, }: Services,
) => { ) => {
// 初始值变化,重新设置节点信息 // 初始值变化,重新设置节点信息
@ -104,6 +105,7 @@ export const initServiceState = (
uiService.resetState(); uiService.resetState();
componentListService.resetState(); componentListService.resetState();
codeBlockService.resetState(); codeBlockService.resetState();
keybindingService.reset();
}); });
}; };

View File

@ -50,8 +50,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, inject, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue';
import KeyController from 'keycon';
import { difference, throttle, union } from 'lodash-es'; import { difference, throttle, union } from 'lodash-es';
import { TMagicScrollbar, TMagicTree } from '@tmagic/design'; import { TMagicScrollbar, TMagicTree } from '@tmagic/design';
@ -248,53 +247,73 @@ const windowBlurHandler = () => {
isCtrlKeyDown.value = false; isCtrlKeyDown.value = false;
}; };
const globalKeyupHandler = () => { keybindingService?.registeCommand('layer-panel-not-ctrl-keydown', (e) => {
if (document.activeElement !== tree.value?.$el) { if (e.key !== keybindingService.ctrlKey) {
isCtrlKeyDown.value = false; isCtrlKeyDown.value = false;
} }
}; });
let keycon: KeyController | undefined; keybindingService?.registeCommand('layer-panel-ctrl-keydown', () => {
isCtrlKeyDown.value = true;
});
keybindingService?.registeCommand('layer-panel-ctrl-keyup', () => {
isCtrlKeyDown.value = false;
});
keybindingService?.registeCommand('layer-panel-global-keydwon', () => {
if (!tree.value?.$el.contains(document.activeElement)) {
isCtrlKeyDown.value = false;
}
});
keybindingService?.registe([
{
command: 'layer-panel-not-ctrl-keydown',
when: [['layer-panel', 'keydown']],
},
{
command: 'layer-panel-ctrl-keydown',
keybinding: 'ctrl',
when: [['layer-panel', 'keydown']],
},
{
command: 'layer-panel-ctrl-keyup',
keybinding: 'ctrl',
when: [['layer-panel', 'keyup']],
},
{
command: 'layer-panel-global-keydwon',
keybinding: 'ctrl',
when: [['global', 'keydown']],
},
]);
watch(tree, () => {
if (tree.value?.$el) {
keybindingService?.registeEl('layer-panel', tree.value.$el);
tree.value.$el.addEventListener('blur', windowBlurHandler);
} else {
keybindingService?.unregisteEl('layer-panel');
}
});
onMounted(() => { onMounted(() => {
editorService?.on('remove', editorServiceRemoveHandler); editorService?.on('remove', editorServiceRemoveHandler);
if (tree.value?.$el) {
// ctrlctrl
keybindingService?.on('keyup', globalKeyupHandler);
keycon = new KeyController(tree.value.$el);
const isMac = /mac os x/.test(navigator.userAgent.toLowerCase());
const ctrl = isMac ? 'meta' : 'ctrl';
keycon
.keydown((e) => {
if (e.key !== ctrl) {
isCtrlKeyDown.value = false;
}
})
.keydown(ctrl, () => {
isCtrlKeyDown.value = true;
})
.keyup(ctrl, () => {
isCtrlKeyDown.value = false;
});
tree.value.$el.addEventListener('blur', windowBlurHandler);
}
globalThis.addEventListener('blur', windowBlurHandler); globalThis.addEventListener('blur', windowBlurHandler);
}); });
onUnmounted(() => { onBeforeUnmount(() => {
keycon?.destroy();
editorService?.off('remove', editorServiceRemoveHandler);
keybindingService?.off('keyup', globalKeyupHandler);
globalThis.removeEventListener('blur', windowBlurHandler);
tree.value?.$el.removeEventListener('blur', windowBlurHandler); tree.value?.$el.removeEventListener('blur', windowBlurHandler);
}); });
onUnmounted(() => {
editorService?.off('remove', editorServiceRemoveHandler);
globalThis.removeEventListener('blur', windowBlurHandler);
});
// //
const highlightHandler = throttle((data: MNode) => { const highlightHandler = throttle((data: MNode) => {
highlight(data); highlight(data);

View File

@ -2,6 +2,7 @@
<ScrollViewer <ScrollViewer
class="m-editor-stage" class="m-editor-stage"
ref="stageWrap" ref="stageWrap"
tabindex="-1"
:width="stageRect?.width" :width="stageRect?.width"
:height="stageRect?.height" :height="stageRect?.height"
:wrap-width="stageContainerRect?.width" :wrap-width="stageContainerRect?.width"
@ -74,6 +75,10 @@ watchEffect(() => {
stage = useStage(stageOptions); stage = useStage(stageOptions);
stage.on('select', () => {
stageWrap.value?.container?.focus();
});
services?.editorService.set('stage', markRaw(stage)); services?.editorService.set('stage', markRaw(stage));
stage?.mount(stageContainer.value); stage?.mount(stageContainer.value);
@ -120,13 +125,17 @@ const resizeObserver = new ResizeObserver((entries) => {
}); });
onMounted(() => { onMounted(() => {
stageWrap.value?.container && resizeObserver.observe(stageWrap.value.container); if (stageWrap.value?.container) {
resizeObserver.observe(stageWrap.value.container);
services?.keybindingService.registeEl('stage', stageWrap.value.container);
}
}); });
onUnmounted(() => { onUnmounted(() => {
stage?.destroy(); stage?.destroy();
resizeObserver.disconnect(); resizeObserver.disconnect();
services?.editorService.set('stage', null); services?.editorService.set('stage', null);
services?.keybindingService.unregisteEl('stage');
}); });
const contextmenuHandler = (e: MouseEvent) => { const contextmenuHandler = (e: MouseEvent) => {

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="m-editor-workspace" tabindex="-1"> <div class="m-editor-workspace">
<Breadcrumb></Breadcrumb> <Breadcrumb></Breadcrumb>
<slot name="stage"> <slot name="stage">
@ -18,7 +18,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject } from 'vue'; import { computed, inject } from 'vue';
import type { MenuButton, MenuComponent, Services } from '@editor/type'; import { MenuButton, MenuComponent, Services } from '@editor/type';
import Breadcrumb from './Breadcrumb.vue'; import Breadcrumb from './Breadcrumb.vue';
import PageBar from './PageBar.vue'; import PageBar from './PageBar.vue';

View File

@ -1,237 +1,182 @@
import KeyController from 'keycon'; import KeyController, { KeyControllerEvent } from 'keycon';
import { isPage } from '@tmagic/utils'; import { isPage } from '@tmagic/utils';
import { KeyBindingCacheItem, KeyBindingCommand, KeyBindingItem } from '@editor/type';
import BaseService from './BaseService'; import BaseService from './BaseService';
import editorService from './editor'; import editorService from './editor';
import uiService from './ui'; import uiService from './ui';
class Keybinding extends BaseService { class Keybinding extends BaseService {
private keycon = new KeyController(); public ctrlKey = /mac os x/.test(navigator.userAgent.toLowerCase()) ? 'meta' : 'ctrl';
private ctrlKey = /mac os x/.test(navigator.userAgent.toLowerCase()) ? 'meta' : 'ctrl'; private controllers = new Map<string, KeyController>();
constructor() { private bindingList: KeyBindingCacheItem[] = [];
super();
this.keycon private commands: Record<KeyBindingCommand | string, (e: KeyboardEvent) => void | Promise<void>> = {
.keyup((e) => { [KeyBindingCommand.DELETE_NODE]: () => {
this.emit('keyup', e.inputEvent); const nodes = editorService.get('nodes');
})
.keyup('delete', (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
this.removeNode();
})
.keyup('backspace', (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
this.removeNode();
})
.keydown([this.ctrlKey, 'c'], (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
if (!nodes || isPage(nodes[0])) return;
editorService.remove(nodes);
},
[KeyBindingCommand.COPY_NODE]: () => {
const nodes = editorService.get('nodes'); const nodes = editorService.get('nodes');
nodes && editorService.copy(nodes); nodes && editorService.copy(nodes);
}) },
.keydown([this.ctrlKey, 'v'], (e) => { [KeyBindingCommand.CUT_NODE]: () => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
const nodes = editorService.get('nodes');
nodes && editorService.paste({ offsetX: 10, offsetY: 10 });
})
.keydown([this.ctrlKey, 'x'], (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
const nodes = editorService.get('nodes'); const nodes = editorService.get('nodes');
if (!nodes || isPage(nodes[0])) return; if (!nodes || isPage(nodes[0])) return;
editorService.copy(nodes); editorService.copy(nodes);
editorService.remove(nodes); editorService.remove(nodes);
}) },
.keydown([this.ctrlKey, 'z'], (e) => { [KeyBindingCommand.PASTE_NODE]: () => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) { const nodes = editorService.get('nodes');
return; nodes && editorService.paste({ offsetX: 10, offsetY: 10 });
} },
[KeyBindingCommand.UNDO]: () => {
e.inputEvent.preventDefault();
editorService.undo(); editorService.undo();
}) },
.keydown([this.ctrlKey, 'shift', 'z'], (e) => { [KeyBindingCommand.REDO]: () => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.redo(); editorService.redo();
}) },
.keydown('up', (e) => { [KeyBindingCommand.ZOOM_IN]: () => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.move(0, -1);
})
.keydown('down', (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.move(0, 1);
})
.keydown('left', (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.move(-1, 0);
})
.keydown('right', (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.move(1, 0);
})
.keydown([this.ctrlKey, 'up'], (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.move(0, -10);
})
.keydown([this.ctrlKey, 'down'], (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.move(0, 10);
})
.keydown([this.ctrlKey, 'left'], (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.move(-10, 0);
})
.keydown([this.ctrlKey, 'right'], (e) => {
e.inputEvent.preventDefault();
editorService.move(10, 0);
})
.keydown('tab', (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.selectNextNode();
})
.keydown([this.ctrlKey, 'tab'], (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
editorService.selectNextPage();
})
.keydown([this.ctrlKey, '='], (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
uiService.zoom(0.1); uiService.zoom(0.1);
}) },
.keydown([this.ctrlKey, 'numpadplus'], (e) => { [KeyBindingCommand.ZOOM_OUT]: () => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
uiService.zoom(0.1);
})
.keydown([this.ctrlKey, '-'], (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
uiService.zoom(-0.1); uiService.zoom(-0.1);
}) },
.keydown([this.ctrlKey, 'numpad-'], (e) => { [KeyBindingCommand.ZOOM_RESET]: () => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
uiService.zoom(-0.1);
})
.keydown([this.ctrlKey, '0'], async (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
uiService.set('zoom', await uiService.calcZoom());
})
.keydown([this.ctrlKey, '1'], (e) => {
if (this.isDisabledKeyEvent(e.inputEvent.target)) {
return;
}
e.inputEvent.preventDefault();
uiService.set('zoom', 1); uiService.set('zoom', 1);
},
[KeyBindingCommand.ZOOM_FIT]: async () => {
uiService.set('zoom', await uiService.calcZoom());
},
[KeyBindingCommand.MOVE_UP_1]: () => {
editorService.move(0, -1);
},
[KeyBindingCommand.MOVE_DOWN_1]: () => {
editorService.move(0, 1);
},
[KeyBindingCommand.MOVE_LEFT_1]: () => {
editorService.move(-1, 0);
},
[KeyBindingCommand.MOVE_RIGHT_1]: () => {
editorService.move(1, 0);
},
[KeyBindingCommand.MOVE_UP_10]: () => {
editorService.move(0, -10);
},
[KeyBindingCommand.MOVE_DOWN_10]: () => {
editorService.move(0, 10);
},
[KeyBindingCommand.MOVE_LEFT_10]: () => {
editorService.move(-10, 0);
},
[KeyBindingCommand.MOVE_RIGHT_10]: () => {
editorService.move(10, 0);
},
[KeyBindingCommand.SWITCH_NODE]: () => {
editorService.selectNextNode();
},
};
public registeCommand(command: string, handler: (e: KeyboardEvent) => void | Promise<void>) {
this.commands[command] = handler;
}
public unregisteCommand(command: string) {
delete this.commands[command];
}
public registeEl(name: string, el?: HTMLElement) {
if (name !== 'global' && !el) {
throw new Error('只有name为global可以不传el');
}
const keycon = new KeyController(el);
this.controllers.set(name, keycon);
this.bind(name);
}
public unregisteEl(name: string) {
this.controllers.get(name)?.destroy();
this.controllers.delete(name);
this.bindingList.forEach((item) => {
item.binded = false;
}); });
} }
public registe(maps: KeyBindingItem[]) {
for (const keybindingItem of maps) {
const { command, keybinding, when } = keybindingItem;
for (const [type = '', eventType = 'keydown'] of when) {
const cacheItem: KeyBindingCacheItem = { type, command, keybinding, eventType, binded: false };
this.bindingList.push(cacheItem);
}
}
this.bind();
}
public reset() {
this.controllers.forEach((keycon) => {
keycon.destroy();
});
this.controllers.clear();
this.bindingList = [];
}
public destroy() { public destroy() {
this.keycon.destroy(); this.reset();
} }
private isDisabledKeyEvent(node: EventTarget | null) { private bind(name?: string) {
const el = node as HTMLElement | null; for (const item of this.bindingList) {
const { type, eventType, command, keybinding, binded } = item;
if (!el) return false; if (name && name !== type) {
continue;
// 当前是在输入框中,禁止响应画布快捷键
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable;
} }
private removeNode() { if (binded) {
const nodes = editorService.get('nodes'); continue;
}
if (!nodes || isPage(nodes[0])) return; const keycon = this.controllers.get(type);
editorService.remove(nodes); if (!keycon) {
continue;
}
const handler = (e: KeyControllerEvent) => {
e.inputEvent.preventDefault();
this.commands[command]?.(e.inputEvent);
};
this.getKeyconKeys(keybinding).forEach((keys) => {
if (keys[0]) {
keycon[eventType](keys, handler);
} else {
keycon[eventType](handler);
}
});
item.binded = true;
}
}
private getKeyconKeys(keybinding: string | string[] = '') {
const splitKey = (key: string) => key.split('+').map((k) => (k === 'ctrl' ? this.ctrlKey : k));
if (Array.isArray(keybinding)) {
return keybinding.map((key) => splitKey(key));
}
return [splitKey(keybinding)];
} }
} }

View File

@ -6,6 +6,10 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
&:focus-visible {
outline: none;
}
} }
.m-editor-stage-container { .m-editor-stage-container {

View File

@ -429,3 +429,58 @@ export interface EventSelectConfig {
/** 联动代码配置 */ /** 联动代码配置 */
codeActionConfig?: FormItem; codeActionConfig?: FormItem;
} }
export enum KeyBindingCommand {
/** 复制 */
COPY_NODE = 'tmagic-system-copy-node',
/** 粘贴 */
PASTE_NODE = 'tmagic-system-paste-node',
/** 删除 */
DELETE_NODE = 'tmagic-system-delete-node',
/** 剪切 */
CUT_NODE = 'tmagic-system-cut-node',
/** 撤销 */
UNDO = 'tmagic-system-undo',
/** 重做 */
REDO = 'tmagic-system-redo',
/** 放大 */
ZOOM_IN = 'tmagic-system-zoom-in',
/** 缩小 */
ZOOM_OUT = 'tmagic-system-zoom-out',
/** 缩放到实际大小 */
ZOOM_RESET = 'tmagic-system-zoom-reset',
/** 缩放以适应 */
ZOOM_FIT = 'tmagic-system-zoom-fit',
/** 向上移动1px */
MOVE_UP_1 = 'tmagic-system-move-up-1',
/** 向下移动1px */
MOVE_DOWN_1 = 'tmagic-system-move-down-1',
/** 向左移动1px */
MOVE_LEFT_1 = 'tmagic-system-move-left-1',
/** 向右移动1px */
MOVE_RIGHT_1 = 'tmagic-system-move-right-1',
/** 向上移动10px */
MOVE_UP_10 = 'tmagic-system-move-up-10',
/** 向下移动10px */
MOVE_DOWN_10 = 'tmagic-system-move-down-10',
/** 向左移动10px */
MOVE_LEFT_10 = 'tmagic-system-move-left-10',
/** 向右移动10px */
MOVE_RIGHT_10 = 'tmagic-system-move-right-10',
/** 切换组件 */
SWITCH_NODE = 'tmagic-system-switch-node',
}
export interface KeyBindingItem {
command: KeyBindingCommand | string;
keybinding?: string | string[];
when: [string, 'keyup' | 'keydown'][];
}
export interface KeyBindingCacheItem {
type: string;
command: KeyBindingCommand | string;
keybinding?: string | string[];
eventType: 'keyup' | 'keydown';
binded: boolean;
}

View File

@ -0,0 +1,120 @@
import { KeyBindingCommand, KeyBindingItem } from '@editor/type';
export default [
{
command: KeyBindingCommand.DELETE_NODE,
keybinding: ['delete', 'backspace'],
when: [
['stage', 'keyup'],
['layer-panel', 'keydown'],
],
},
{
command: KeyBindingCommand.COPY_NODE,
keybinding: 'ctrl+c',
when: [
['stage', 'keydown'],
['layer-panel', 'keydown'],
],
},
{
command: KeyBindingCommand.PASTE_NODE,
keybinding: 'ctrl+v',
when: [
['stage', 'keydown'],
['layer-panel', 'keydown'],
],
},
{
command: KeyBindingCommand.CUT_NODE,
keybinding: 'ctrl+x',
when: [
['stage', 'keydown'],
['layer-panel', 'keydown'],
],
},
{
command: KeyBindingCommand.UNDO,
keybinding: 'ctrl+z',
when: [
['stage', 'keydown'],
['layer-panel', 'keydown'],
],
},
{
command: KeyBindingCommand.REDO,
keybinding: 'ctrl+shift+z',
when: [
['stage', 'keydown'],
['layer-panel', 'keydown'],
],
},
{
command: KeyBindingCommand.MOVE_UP_1,
keybinding: 'up',
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.MOVE_DOWN_1,
keybinding: 'down',
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.MOVE_LEFT_1,
keybinding: 'left',
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.MOVE_RIGHT_1,
keybinding: 'right',
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.MOVE_UP_10,
keybinding: 'ctrl+up',
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.MOVE_DOWN_10,
keybinding: 'ctrl+down',
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.MOVE_LEFT_10,
keybinding: 'ctrl+left',
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.MOVE_RIGHT_10,
keybinding: 'ctrl+right',
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.SWITCH_NODE,
keybinding: 'tab',
when: [
['stage', 'keydown'],
['layer-panel', 'keydown'],
],
},
{
command: KeyBindingCommand.ZOOM_IN,
keybinding: ['ctrl+=', 'ctrl+numpadplus'],
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.ZOOM_OUT,
keybinding: ['ctrl+-', 'ctrl+numpad-'],
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.ZOOM_FIT,
keybinding: 'ctrl+0',
when: [['stage', 'keydown']],
},
{
command: KeyBindingCommand.ZOOM_RESET,
keybinding: 'ctrl+1',
when: [['stage', 'keydown']],
},
] as KeyBindingItem[];