mirror of
https://github.com/alibaba/lowcode-engine.git
synced 2026-04-20 04:18:05 +00:00
feat(designer): add builtin hotkeys
This commit is contained in:
parent
82c10d2abb
commit
2ec5883a11
@ -20,11 +20,13 @@ import {
|
|||||||
getRectTarget,
|
getRectTarget,
|
||||||
Rect,
|
Rect,
|
||||||
CanvasPoint,
|
CanvasPoint,
|
||||||
|
hotkey,
|
||||||
} from '../designer';
|
} from '../designer';
|
||||||
import { parseProps } from './utils/parse-props';
|
import { parseProps } from './utils/parse-props';
|
||||||
import { isElement } from '@ali/lowcode-globals';
|
import { isElement } from '@ali/lowcode-globals';
|
||||||
import { ComponentMetadata } from '@ali/lowcode-globals';
|
import { ComponentMetadata } from '@ali/lowcode-globals';
|
||||||
import { BuiltinSimulatorRenderer } from './renderer';
|
import { BuiltinSimulatorRenderer } from './renderer';
|
||||||
|
import clipboard from '../designer/clipboard';
|
||||||
|
|
||||||
export interface LibraryItem {
|
export interface LibraryItem {
|
||||||
package: string;
|
package: string;
|
||||||
@ -196,6 +198,8 @@ export class BuiltinSimulatorHost implements ISimulatorHost<BuiltinSimulatorProp
|
|||||||
// wait 准备 iframe 内容、依赖库注入
|
// wait 准备 iframe 内容、依赖库注入
|
||||||
const renderer = await createSimulator(this, iframe, vendors);
|
const renderer = await createSimulator(this, iframe, vendors);
|
||||||
|
|
||||||
|
// TODO: !!! thinkof reload onload
|
||||||
|
|
||||||
// wait 业务组件被第一次消费,否则会渲染出错
|
// wait 业务组件被第一次消费,否则会渲染出错
|
||||||
await this.componentsConsumer.waitFirstConsume();
|
await this.componentsConsumer.waitFirstConsume();
|
||||||
|
|
||||||
@ -209,11 +213,17 @@ export class BuiltinSimulatorHost implements ISimulatorHost<BuiltinSimulatorProp
|
|||||||
this._contentDocument = this._contentWindow.document;
|
this._contentDocument = this._contentWindow.document;
|
||||||
this.viewport.setScrollTarget(this._contentWindow);
|
this.viewport.setScrollTarget(this._contentWindow);
|
||||||
this.setupEvents();
|
this.setupEvents();
|
||||||
// hotkey.mount(this.contentWindow);
|
|
||||||
// clipboard.injectCopyPaster(this.ownerDocument);
|
// bind hotkey & clipboard
|
||||||
|
hotkey.mount(this._contentWindow);
|
||||||
|
clipboard.injectCopyPaster(this._contentDocument);
|
||||||
|
// TODO: dispose the bindings
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEvents() {
|
setupEvents() {
|
||||||
|
// TODO: Thinkof move events control to simulator renderer
|
||||||
|
// just listen special callback
|
||||||
|
// because iframe maybe reload
|
||||||
this.setupDragAndClick();
|
this.setupDragAndClick();
|
||||||
this.setupHovering();
|
this.setupHovering();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ function getDataFromPasteEvent(event: ClipboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// { componentsMap, componentsTree, ... }
|
||||||
return JSON.parse(clipboardData.getData('text/plain'));
|
return JSON.parse(clipboardData.getData('text/plain'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
/*
|
/*
|
||||||
@ -17,11 +18,13 @@ function getDataFromPasteEvent(event: ClipboardEvent) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
// paste the text by div
|
// TODO: open the parser implement
|
||||||
|
return null;
|
||||||
|
/*
|
||||||
return {
|
return {
|
||||||
code: clipboardData.getData('text/plain'),
|
code: clipboardData.getData('text/plain'),
|
||||||
maps: {},
|
maps: {},
|
||||||
};
|
};*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +36,7 @@ class Clipboard {
|
|||||||
this.isCopyPaster(e.target);
|
this.isCopyPaster(e.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
isCopyPaster(el: any) {
|
private isCopyPaster(el: any) {
|
||||||
return this.copyPasters.includes(el);
|
return this.copyPasters.includes(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +60,9 @@ class Clipboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
injectCopyPaster(document: Document) {
|
injectCopyPaster(document: Document) {
|
||||||
|
if (this.copyPasters.find(x => x.ownerDocument === document)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const copyPaster = document.createElement<'textarea'>('textarea');
|
const copyPaster = document.createElement<'textarea'>('textarea');
|
||||||
copyPaster.style.cssText = 'position: relative;left: -9999px;';
|
copyPaster.style.cssText = 'position: relative;left: -9999px;';
|
||||||
document.body.appendChild(copyPaster);
|
document.body.appendChild(copyPaster);
|
||||||
@ -5,6 +5,7 @@ import BuiltinDragGhostComponent from './drag-ghost';
|
|||||||
import { Designer, DesignerProps } from './designer';
|
import { Designer, DesignerProps } from './designer';
|
||||||
import { ProjectView } from '../project';
|
import { ProjectView } from '../project';
|
||||||
import './designer.less';
|
import './designer.less';
|
||||||
|
import clipboard from './clipboard';
|
||||||
|
|
||||||
export class DesignerView extends Component<DesignerProps> {
|
export class DesignerView extends Component<DesignerProps> {
|
||||||
readonly designer: Designer;
|
readonly designer: Designer;
|
||||||
@ -32,6 +33,7 @@ export class DesignerView extends Component<DesignerProps> {
|
|||||||
if (onMount) {
|
if (onMount) {
|
||||||
onMount(this.designer);
|
onMount(this.designer);
|
||||||
}
|
}
|
||||||
|
clipboard.injectCopyPaster(document)
|
||||||
this.designer.postEvent('mount', this.designer);
|
this.designer.postEvent('mount', this.designer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
autorun,
|
autorun,
|
||||||
} from '@ali/lowcode-globals';
|
} from '@ali/lowcode-globals';
|
||||||
import { Project } from '../project';
|
import { Project } from '../project';
|
||||||
import { Node, DocumentModel, insertChildren, isRootNode } from '../document';
|
import { Node, DocumentModel, insertChildren, isRootNode, NodeParent } from '../document';
|
||||||
import { ComponentMeta } from '../component-meta';
|
import { ComponentMeta } from '../component-meta';
|
||||||
import { INodeSelector } from '../simulator';
|
import { INodeSelector } from '../simulator';
|
||||||
import { Scroller, IScrollable } from './scroller';
|
import { Scroller, IScrollable } from './scroller';
|
||||||
@ -19,6 +19,7 @@ import { ActiveTracker } from './active-tracker';
|
|||||||
import { Hovering } from './hovering';
|
import { Hovering } from './hovering';
|
||||||
import { DropLocation, LocationData, isLocationChildrenDetail } from './location';
|
import { DropLocation, LocationData, isLocationChildrenDetail } from './location';
|
||||||
import { OffsetObserver, createOffsetObserver } from './offset-observer';
|
import { OffsetObserver, createOffsetObserver } from './offset-observer';
|
||||||
|
import { focusing } from './focusing';
|
||||||
|
|
||||||
export interface DesignerProps {
|
export interface DesignerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -40,7 +41,6 @@ export interface DesignerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Designer {
|
export class Designer {
|
||||||
// readonly hotkey: Hotkey;
|
|
||||||
readonly dragon = new Dragon(this);
|
readonly dragon = new Dragon(this);
|
||||||
readonly activeTracker = new ActiveTracker();
|
readonly activeTracker = new ActiveTracker();
|
||||||
readonly hovering = new Hovering();
|
readonly hovering = new Hovering();
|
||||||
@ -156,6 +156,9 @@ export class Designer {
|
|||||||
this.postEvent('designer.init', this);
|
this.postEvent('designer.init', this);
|
||||||
setupSelection();
|
setupSelection();
|
||||||
setupHistory();
|
setupHistory();
|
||||||
|
|
||||||
|
// TODO: 先简单实现,后期通过焦点赋值
|
||||||
|
focusing.focusDesigner = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
postEvent(event: string, ...args: any[]) {
|
postEvent(event: string, ...args: any[]) {
|
||||||
@ -198,7 +201,7 @@ export class Designer {
|
|||||||
/**
|
/**
|
||||||
* 获得合适的插入位置
|
* 获得合适的插入位置
|
||||||
*/
|
*/
|
||||||
getSuitableInsertion() {
|
getSuitableInsertion(): { target: NodeParent; index?: number } | null {
|
||||||
const activedDoc = this.project.currentDocument;
|
const activedDoc = this.project.currentDocument;
|
||||||
if (!activedDoc) {
|
if (!activedDoc) {
|
||||||
return null;
|
return null;
|
||||||
@ -296,7 +299,7 @@ export class Designer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSchema(schema?: ProjectSchema) {
|
setSchema(schema?: ProjectSchema) {
|
||||||
this.project.setSchema(schema);
|
this.project.load(schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
@obx.val private _componentMetasMap = new Map<string, ComponentMeta>();
|
@obx.val private _componentMetasMap = new Map<string, ComponentMeta>();
|
||||||
|
|||||||
8
packages/designer/src/designer/focusing.ts
Normal file
8
packages/designer/src/designer/focusing.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Designer } from './designer';
|
||||||
|
|
||||||
|
// TODO:
|
||||||
|
class Focusing {
|
||||||
|
focusDesigner?: Designer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const focusing = new Focusing();
|
||||||
@ -1,618 +1,117 @@
|
|||||||
interface KeyMap {
|
import { Hotkey, isFormEvent } from '@ali/lowcode-globals';
|
||||||
[key: number]: string;
|
import { focusing } from './focusing';
|
||||||
}
|
import { insertChildren } from '../document';
|
||||||
|
import clipboard from './clipboard';
|
||||||
|
|
||||||
interface CtrlKeyMap {
|
export const hotkey = new Hotkey();
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionEvent {
|
// hotkey binding
|
||||||
type: string;
|
hotkey.bind(['backspace', 'del'], (e: KeyboardEvent) => {
|
||||||
}
|
const doc = focusing.focusDesigner?.currentDocument;
|
||||||
|
if (isFormEvent(e) || !doc) {
|
||||||
interface HotkeyCallbacks {
|
return;
|
||||||
[key: string]: HotkeyCallbackCfg[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HotkeyDirectMap {
|
|
||||||
[key: string]: HotkeyCallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HotkeyCallback = (e: KeyboardEvent, combo?: string) => any | false;
|
|
||||||
|
|
||||||
interface HotkeyCallbackCfg {
|
|
||||||
callback: HotkeyCallback;
|
|
||||||
modifiers: string[];
|
|
||||||
action: string;
|
|
||||||
seq?: string;
|
|
||||||
level?: number;
|
|
||||||
combo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyInfo {
|
|
||||||
key: string;
|
|
||||||
modifiers: string[];
|
|
||||||
action: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SequenceLevels {
|
|
||||||
[key: string]: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAP: KeyMap = {
|
|
||||||
8: 'backspace',
|
|
||||||
9: 'tab',
|
|
||||||
13: 'enter',
|
|
||||||
16: 'shift',
|
|
||||||
17: 'ctrl',
|
|
||||||
18: 'alt',
|
|
||||||
20: 'capslock',
|
|
||||||
27: 'esc',
|
|
||||||
32: 'space',
|
|
||||||
33: 'pageup',
|
|
||||||
34: 'pagedown',
|
|
||||||
35: 'end',
|
|
||||||
36: 'home',
|
|
||||||
37: 'left',
|
|
||||||
38: 'up',
|
|
||||||
39: 'right',
|
|
||||||
40: 'down',
|
|
||||||
45: 'ins',
|
|
||||||
46: 'del',
|
|
||||||
91: 'meta',
|
|
||||||
93: 'meta',
|
|
||||||
224: 'meta',
|
|
||||||
};
|
|
||||||
|
|
||||||
const KEYCODE_MAP: KeyMap = {
|
|
||||||
106: '*',
|
|
||||||
107: '+',
|
|
||||||
109: '-',
|
|
||||||
110: '.',
|
|
||||||
111: '/',
|
|
||||||
186: ';',
|
|
||||||
187: '=',
|
|
||||||
188: ',',
|
|
||||||
189: '-',
|
|
||||||
190: '.',
|
|
||||||
191: '/',
|
|
||||||
192: '`',
|
|
||||||
219: '[',
|
|
||||||
220: '\\',
|
|
||||||
221: ']',
|
|
||||||
222: "'",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SHIFT_MAP: CtrlKeyMap = {
|
|
||||||
'~': '`',
|
|
||||||
'!': '1',
|
|
||||||
'@': '2',
|
|
||||||
'#': '3',
|
|
||||||
$: '4',
|
|
||||||
'%': '5',
|
|
||||||
'^': '6',
|
|
||||||
'&': '7',
|
|
||||||
'*': '8',
|
|
||||||
'(': '9',
|
|
||||||
')': '0',
|
|
||||||
_: '-',
|
|
||||||
'+': '=',
|
|
||||||
':': ';',
|
|
||||||
'"': "'",
|
|
||||||
'<': ',',
|
|
||||||
'>': '.',
|
|
||||||
'?': '/',
|
|
||||||
'|': '\\',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SPECIAL_ALIASES: CtrlKeyMap = {
|
|
||||||
option: 'alt',
|
|
||||||
command: 'meta',
|
|
||||||
return: 'enter',
|
|
||||||
escape: 'esc',
|
|
||||||
plus: '+',
|
|
||||||
mod: /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'meta' : 'ctrl',
|
|
||||||
};
|
|
||||||
|
|
||||||
let REVERSE_MAP: CtrlKeyMap;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* loop through the f keys, f1 to f19 and add them to the map
|
|
||||||
* programatically
|
|
||||||
*/
|
|
||||||
for (let i = 1; i < 20; ++i) {
|
|
||||||
MAP[111 + i] = 'f' + i;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* loop through to map numbers on the numeric keypad
|
|
||||||
*/
|
|
||||||
for (let i = 0; i <= 9; ++i) {
|
|
||||||
MAP[i + 96] = String(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* takes the event and returns the key character
|
|
||||||
*/
|
|
||||||
function characterFromEvent(e: KeyboardEvent): string {
|
|
||||||
const keyCode = e.keyCode || e.which;
|
|
||||||
// for keypress events we should return the character as is
|
|
||||||
if (e.type === 'keypress') {
|
|
||||||
let character = String.fromCharCode(keyCode);
|
|
||||||
// if the shift key is not pressed then it is safe to assume
|
|
||||||
// that we want the character to be lowercase. this means if
|
|
||||||
// you accidentally have caps lock on then your key bindings
|
|
||||||
// will continue to work
|
|
||||||
//
|
|
||||||
// the only side effect that might not be desired is if you
|
|
||||||
// bind something like 'A' cause you want to trigger an
|
|
||||||
// event when capital A is pressed caps lock will no longer
|
|
||||||
// trigger the event. shift+a will though.
|
|
||||||
if (!e.shiftKey) {
|
|
||||||
character = character.toLowerCase();
|
|
||||||
}
|
|
||||||
return character;
|
|
||||||
}
|
}
|
||||||
// for non keypress events the special maps are needed
|
e.preventDefault();
|
||||||
if (MAP[keyCode]) {
|
|
||||||
return MAP[keyCode];
|
const sel = doc.selection;
|
||||||
|
const topItems = sel.getTopNodes();
|
||||||
|
// TODO: check can remove
|
||||||
|
topItems.forEach(node => {
|
||||||
|
doc.removeNode(node);
|
||||||
|
});
|
||||||
|
sel.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
hotkey.bind('escape', (e: KeyboardEvent) => {
|
||||||
|
// const currentFocus = focusing.current;
|
||||||
|
const sel = focusing.focusDesigner?.currentDocument?.selection;
|
||||||
|
if (isFormEvent(e) || !sel) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (KEYCODE_MAP[keyCode]) {
|
e.preventDefault();
|
||||||
return KEYCODE_MAP[keyCode];
|
|
||||||
|
sel.clear();
|
||||||
|
// currentFocus.esc();
|
||||||
|
});
|
||||||
|
|
||||||
|
// command + c copy command + x cut
|
||||||
|
hotkey.bind(['command+c', 'ctrl+c', 'command+x', 'ctrl+x'], (e, action) => {
|
||||||
|
const doc = focusing.focusDesigner?.currentDocument;
|
||||||
|
if (isFormEvent(e) || !doc) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// if it is not in the special map
|
e.preventDefault();
|
||||||
// with keydown and keyup events the character seems to always
|
|
||||||
// come in as an uppercase character whether you are pressing shift
|
|
||||||
// or not. we should make sure it is always lowercase for comparisons
|
|
||||||
return String.fromCharCode(keyCode).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeypressEvent extends KeyboardEvent {
|
/*
|
||||||
type: 'keypress';
|
const doc = getCurrentDocument();
|
||||||
}
|
if (isFormEvent(e) || !doc || !(focusing.id === 'outline' || focusing.id === 'canvas')) {
|
||||||
|
return;
|
||||||
function isPressEvent(e: KeyboardEvent | ActionEvent): e is KeypressEvent {
|
|
||||||
return e.type === 'keypress';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isFormEvent(e: KeyboardEvent) {
|
|
||||||
const t = e.target as HTMLFormElement;
|
|
||||||
if (!t) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
*/
|
||||||
|
|
||||||
if (t.form || /^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName)) {
|
const selected = doc.selection.getTopNodes(true);
|
||||||
return true;
|
if (!selected || selected.length < 1) return;
|
||||||
|
|
||||||
|
const componentsMap = {};
|
||||||
|
const componentsTree = selected.map(item => item.export(false));
|
||||||
|
|
||||||
|
const data = { type: 'nodeSchema', componentsMap, componentsTree };
|
||||||
|
|
||||||
|
clipboard.setData(data);
|
||||||
|
/*
|
||||||
|
const cutMode = action.indexOf('x') > 0;
|
||||||
|
if (cutMode) {
|
||||||
|
const parentNode = selected.getParent();
|
||||||
|
parentNode.select();
|
||||||
|
selected.remove();
|
||||||
}
|
}
|
||||||
if (/write/.test(window.getComputedStyle(t).getPropertyValue('-webkit-user-modify'))) {
|
*/
|
||||||
return true;
|
});
|
||||||
|
|
||||||
|
// command + v paste
|
||||||
|
hotkey.bind(['command+v', 'ctrl+v'], (e) => {
|
||||||
|
const designer = focusing.focusDesigner;
|
||||||
|
const doc = designer?.currentDocument;
|
||||||
|
if (isFormEvent(e) || !designer || !doc) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return false;
|
clipboard.waitPasteData(e, ({ componentsTree }) => {
|
||||||
}
|
if (componentsTree) {
|
||||||
/**
|
const { target, index } = designer.getSuitableInsertion() || {};
|
||||||
* checks if two arrays are equal
|
if (!target) {
|
||||||
*/
|
return;
|
||||||
function modifiersMatch(modifiers1: string[], modifiers2: string[]): boolean {
|
|
||||||
return modifiers1.sort().join(',') === modifiers2.sort().join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* takes a key event and figures out what the modifiers are
|
|
||||||
*/
|
|
||||||
function eventModifiers(e: KeyboardEvent): string[] {
|
|
||||||
const modifiers = [];
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
modifiers.push('shift');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.altKey) {
|
|
||||||
modifiers.push('alt');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.ctrlKey) {
|
|
||||||
modifiers.push('ctrl');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.metaKey) {
|
|
||||||
modifiers.push('meta');
|
|
||||||
}
|
|
||||||
|
|
||||||
return modifiers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* determines if the keycode specified is a modifier key or not
|
|
||||||
*/
|
|
||||||
function isModifier(key: string): boolean {
|
|
||||||
return key === 'shift' || key === 'ctrl' || key === 'alt' || key === 'meta';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* reverses the map lookup so that we can look for specific keys
|
|
||||||
* to see what can and can't use keypress
|
|
||||||
*
|
|
||||||
* @return {Object}
|
|
||||||
*/
|
|
||||||
function getReverseMap(): CtrlKeyMap {
|
|
||||||
if (!REVERSE_MAP) {
|
|
||||||
REVERSE_MAP = {};
|
|
||||||
for (const key in MAP) {
|
|
||||||
// pull out the numeric keypad from here cause keypress should
|
|
||||||
// be able to detect the keys from the character
|
|
||||||
if (Number(key) > 95 && Number(key) < 112) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
const nodes = insertChildren(target, componentsTree, index);
|
||||||
if (MAP.hasOwnProperty(key)) {
|
if (nodes) {
|
||||||
REVERSE_MAP[MAP[key]] = key;
|
doc.selection.selectAll(nodes.map(o => o.id));
|
||||||
|
setTimeout(() => designer.activeTracker.track(nodes[0]), 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
return REVERSE_MAP;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* picks the best action based on the key combination
|
|
||||||
*/
|
|
||||||
function pickBestAction(key: string, modifiers: string[], action?: string): string {
|
|
||||||
// if no action was picked in we should try to pick the one
|
|
||||||
// that we think would work best for this key
|
|
||||||
if (!action) {
|
|
||||||
action = getReverseMap()[key] ? 'keydown' : 'keypress';
|
|
||||||
}
|
|
||||||
// modifier keys don't work as expected with keypress,
|
|
||||||
// switch to keydown
|
|
||||||
if (action === 'keypress' && modifiers.length) {
|
|
||||||
action = 'keydown';
|
|
||||||
}
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// command + z undo
|
||||||
* Converts from a string key combination to an array
|
hotkey.bind(['command+z', 'ctrl+z'], (e) => {
|
||||||
*
|
const his = focusing.focusDesigner?.currentHistory;
|
||||||
* @param {string} combination like "command+shift+l"
|
if (isFormEvent(e) || !his) {
|
||||||
* @return {Array}
|
return;
|
||||||
*/
|
|
||||||
function keysFromString(combination: string): string[] {
|
|
||||||
if (combination === '+') {
|
|
||||||
return ['+'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
combination = combination.replace(/\+{2}/g, '+plus');
|
e.preventDefault();
|
||||||
return combination.split('+');
|
his.back();
|
||||||
}
|
});
|
||||||
|
|
||||||
/**
|
// command + shift + z redo
|
||||||
* Gets info for a specific key combination
|
hotkey.bind(['command+y', 'ctrl+y', 'command+shift+z'], (e) => {
|
||||||
*
|
const his = focusing.focusDesigner?.currentHistory;
|
||||||
* @param combination key combination ("command+s" or "a" or "*")
|
if (isFormEvent(e) || !his) {
|
||||||
*/
|
return;
|
||||||
function getKeyInfo(combination: string, action?: string): KeyInfo {
|
|
||||||
let keys: string[] = [];
|
|
||||||
let key = '';
|
|
||||||
let i: number;
|
|
||||||
const modifiers: string[] = [];
|
|
||||||
|
|
||||||
// take the keys from this pattern and figure out what the actual
|
|
||||||
// pattern is all about
|
|
||||||
keys = keysFromString(combination);
|
|
||||||
|
|
||||||
for (i = 0; i < keys.length; ++i) {
|
|
||||||
key = keys[i];
|
|
||||||
|
|
||||||
// normalize key names
|
|
||||||
if (SPECIAL_ALIASES[key]) {
|
|
||||||
key = SPECIAL_ALIASES[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
// if this is not a keypress event then we should
|
|
||||||
// be smart about using shift keys
|
|
||||||
// this will only work for US keyboards however
|
|
||||||
if (action && action !== 'keypress' && SHIFT_MAP[key]) {
|
|
||||||
key = SHIFT_MAP[key];
|
|
||||||
modifiers.push('shift');
|
|
||||||
}
|
|
||||||
|
|
||||||
// if this key is a modifier then add it to the list of modifiers
|
|
||||||
if (isModifier(key)) {
|
|
||||||
modifiers.push(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
// depending on what the key combination is
|
his.forward();
|
||||||
// we will try to pick the best event for it
|
});
|
||||||
action = pickBestAction(key, modifiers, action);
|
|
||||||
|
|
||||||
return {
|
hotkey.mount(window);
|
||||||
key,
|
|
||||||
modifiers,
|
|
||||||
action,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* actually calls the callback function
|
|
||||||
*
|
|
||||||
* if your callback function returns false this will use the jquery
|
|
||||||
* convention - prevent default and stop propogation on the event
|
|
||||||
*/
|
|
||||||
function fireCallback(callback: HotkeyCallback, e: KeyboardEvent, combo?: string, sequence?: string): void {
|
|
||||||
if (callback(e, combo) === false) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Hotkey {
|
|
||||||
private callBacks: HotkeyCallbacks = {};
|
|
||||||
private directMap: HotkeyDirectMap = {};
|
|
||||||
private sequenceLevels: SequenceLevels = {};
|
|
||||||
private resetTimer = 0;
|
|
||||||
private ignoreNextKeyup: boolean | string = false;
|
|
||||||
private ignoreNextKeypress = false;
|
|
||||||
private nextExpectedAction: boolean | string = false;
|
|
||||||
|
|
||||||
mount(window: Window) {
|
|
||||||
const document = window.document;
|
|
||||||
const handleKeyEvent = this.handleKeyEvent.bind(this);
|
|
||||||
document.addEventListener('keypress', handleKeyEvent, false);
|
|
||||||
document.addEventListener('keydown', handleKeyEvent, false);
|
|
||||||
document.addEventListener('keyup', handleKeyEvent, false);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keypress', handleKeyEvent, false);
|
|
||||||
document.removeEventListener('keydown', handleKeyEvent, false);
|
|
||||||
document.removeEventListener('keyup', handleKeyEvent, false);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
bind(combos: string[] | string, callback: HotkeyCallback, action?: string): Hotkey {
|
|
||||||
this.bindMultiple(Array.isArray(combos) ? combos : [combos], callback, action);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* resets all sequence counters except for the ones passed in
|
|
||||||
*/
|
|
||||||
private resetSequences(doNotReset?: SequenceLevels): void {
|
|
||||||
// doNotReset = doNotReset || {};
|
|
||||||
let activeSequences = false;
|
|
||||||
let key = '';
|
|
||||||
for (key in this.sequenceLevels) {
|
|
||||||
if (doNotReset && doNotReset[key]) {
|
|
||||||
activeSequences = true;
|
|
||||||
} else {
|
|
||||||
this.sequenceLevels[key] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!activeSequences) {
|
|
||||||
this.nextExpectedAction = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* finds all callbacks that match based on the keycode, modifiers,
|
|
||||||
* and action
|
|
||||||
*/
|
|
||||||
private getMatches(
|
|
||||||
character: string,
|
|
||||||
modifiers: string[],
|
|
||||||
e: KeyboardEvent | ActionEvent,
|
|
||||||
sequenceName?: string,
|
|
||||||
combination?: string,
|
|
||||||
level?: number,
|
|
||||||
): HotkeyCallbackCfg[] {
|
|
||||||
let i: number;
|
|
||||||
let callback: HotkeyCallbackCfg;
|
|
||||||
const matches: HotkeyCallbackCfg[] = [];
|
|
||||||
const action: string = e.type;
|
|
||||||
|
|
||||||
// if there are no events related to this keycode
|
|
||||||
if (!this.callBacks[character]) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// if a modifier key is coming up on its own we should allow it
|
|
||||||
if (action === 'keyup' && isModifier(character)) {
|
|
||||||
modifiers = [character];
|
|
||||||
}
|
|
||||||
|
|
||||||
// loop through all callbacks for the key that was pressed
|
|
||||||
// and see if any of them match
|
|
||||||
for (i = 0; i < this.callBacks[character].length; ++i) {
|
|
||||||
callback = this.callBacks[character][i];
|
|
||||||
|
|
||||||
// if a sequence name is not specified, but this is a sequence at
|
|
||||||
// the wrong level then move onto the next match
|
|
||||||
if (!sequenceName && callback.seq && this.sequenceLevels[callback.seq] !== callback.level) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the action we are looking for doesn't match the action we got
|
|
||||||
// then we should keep going
|
|
||||||
if (action !== callback.action) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if this is a keypress event and the meta key and control key
|
|
||||||
// are not pressed that means that we need to only look at the
|
|
||||||
// character, otherwise check the modifiers as well
|
|
||||||
//
|
|
||||||
// chrome will not fire a keypress if meta or control is down
|
|
||||||
// safari will fire a keypress if meta or meta+shift is down
|
|
||||||
// firefox will fire a keypress if meta or control is down
|
|
||||||
if ((isPressEvent(e) && !e.metaKey && !e.ctrlKey) || modifiersMatch(modifiers, callback.modifiers)) {
|
|
||||||
const deleteCombo = !sequenceName && callback.combo === combination;
|
|
||||||
const deleteSequence = sequenceName && callback.seq === sequenceName && callback.level === level;
|
|
||||||
if (deleteCombo || deleteSequence) {
|
|
||||||
this.callBacks[character].splice(i, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
matches.push(callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleKey(character: string, modifiers: string[], e: KeyboardEvent): void {
|
|
||||||
const callbacks: HotkeyCallbackCfg[] = this.getMatches(character, modifiers, e);
|
|
||||||
let i: number;
|
|
||||||
const doNotReset: SequenceLevels = {};
|
|
||||||
let maxLevel = 0;
|
|
||||||
let processedSequenceCallback = false;
|
|
||||||
|
|
||||||
// Calculate the maxLevel for sequences so we can only execute the longest callback sequence
|
|
||||||
for (i = 0; i < callbacks.length; ++i) {
|
|
||||||
if (callbacks[i].seq) {
|
|
||||||
maxLevel = Math.max(maxLevel, callbacks[i].level || 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loop through matching callbacks for this key event
|
|
||||||
for (i = 0; i < callbacks.length; ++i) {
|
|
||||||
// fire for all sequence callbacks
|
|
||||||
// this is because if for example you have multiple sequences
|
|
||||||
// bound such as "g i" and "g t" they both need to fire the
|
|
||||||
// callback for matching g cause otherwise you can only ever
|
|
||||||
// match the first one
|
|
||||||
if (callbacks[i].seq) {
|
|
||||||
// only fire callbacks for the maxLevel to prevent
|
|
||||||
// subsequences from also firing
|
|
||||||
//
|
|
||||||
// for example 'a option b' should not cause 'option b' to fire
|
|
||||||
// even though 'option b' is part of the other sequence
|
|
||||||
//
|
|
||||||
// any sequences that do not match here will be discarded
|
|
||||||
// below by the resetSequences call
|
|
||||||
if (callbacks[i].level !== maxLevel) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedSequenceCallback = true;
|
|
||||||
|
|
||||||
// keep a list of which sequences were matches for later
|
|
||||||
doNotReset[callbacks[i].seq || ''] = 1;
|
|
||||||
fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there were no sequence matches but we are still here
|
|
||||||
// that means this is a regular match so we should fire that
|
|
||||||
if (!processedSequenceCallback) {
|
|
||||||
fireCallback(callbacks[i].callback, e, callbacks[i].combo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ignoreThisKeypress = e.type === 'keypress' && this.ignoreNextKeypress;
|
|
||||||
if (e.type === this.nextExpectedAction && !isModifier(character) && !ignoreThisKeypress) {
|
|
||||||
this.resetSequences(doNotReset);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ignoreNextKeypress = processedSequenceCallback && e.type === 'keydown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleKeyEvent(e: KeyboardEvent): void {
|
|
||||||
const character = characterFromEvent(e);
|
|
||||||
|
|
||||||
// no character found then stop
|
|
||||||
if (!character) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// need to use === for the character check because the character can be 0
|
|
||||||
if (e.type === 'keyup' && this.ignoreNextKeyup === character) {
|
|
||||||
this.ignoreNextKeyup = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.handleKey(character, eventModifiers(e), e);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetSequenceTimer(): void {
|
|
||||||
if (this.resetTimer) {
|
|
||||||
clearTimeout(this.resetTimer);
|
|
||||||
}
|
|
||||||
this.resetTimer = window.setTimeout(this.resetSequences, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bindSequence(combo: string, keys: string[], callback: HotkeyCallback, action?: string): void {
|
|
||||||
// const self: any = this;
|
|
||||||
this.sequenceLevels[combo] = 0;
|
|
||||||
const increaseSequence = (nextAction: string) => {
|
|
||||||
return () => {
|
|
||||||
this.nextExpectedAction = nextAction;
|
|
||||||
++this.sequenceLevels[combo];
|
|
||||||
this.resetSequenceTimer();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const callbackAndReset = (e: KeyboardEvent): void => {
|
|
||||||
fireCallback(callback, e, combo);
|
|
||||||
|
|
||||||
if (action !== 'keyup') {
|
|
||||||
this.ignoreNextKeyup = characterFromEvent(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(this.resetSequences, 10);
|
|
||||||
};
|
|
||||||
for (let i = 0; i < keys.length; ++i) {
|
|
||||||
const isFinal = i + 1 === keys.length;
|
|
||||||
const wrappedCallback = isFinal ? callbackAndReset : increaseSequence(action || getKeyInfo(keys[i + 1]).action);
|
|
||||||
this.bindSingle(keys[i], wrappedCallback, action, combo, i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bindSingle(
|
|
||||||
combination: string,
|
|
||||||
callback: HotkeyCallback,
|
|
||||||
action?: string,
|
|
||||||
sequenceName?: string,
|
|
||||||
level?: number,
|
|
||||||
): void {
|
|
||||||
// store a direct mapped reference for use with HotKey.trigger
|
|
||||||
this.directMap[`${combination}:${action}`] = callback;
|
|
||||||
|
|
||||||
// make sure multiple spaces in a row become a single space
|
|
||||||
combination = combination.replace(/\s+/g, ' ');
|
|
||||||
|
|
||||||
const sequence: string[] = combination.split(' ');
|
|
||||||
let info: KeyInfo;
|
|
||||||
|
|
||||||
// if this pattern is a sequence of keys then run through this method
|
|
||||||
// to reprocess each pattern one key at a time
|
|
||||||
if (sequence.length > 1) {
|
|
||||||
this.bindSequence(combination, sequence, callback, action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
info = getKeyInfo(combination, action);
|
|
||||||
|
|
||||||
// make sure to initialize array if this is the first time
|
|
||||||
// a callback is added for this key
|
|
||||||
this.callBacks[info.key] = this.callBacks[info.key] || [];
|
|
||||||
|
|
||||||
// remove an existing match if there is one
|
|
||||||
this.getMatches(info.key, info.modifiers, { type: info.action }, sequenceName, combination, level);
|
|
||||||
|
|
||||||
// add this call back to the array
|
|
||||||
// if it is a sequence put it at the beginning
|
|
||||||
// if not put it at the end
|
|
||||||
//
|
|
||||||
// this is important because the way these are processed expects
|
|
||||||
// the sequence ones to come first
|
|
||||||
this.callBacks[info.key][sequenceName ? 'unshift' : 'push']({
|
|
||||||
callback,
|
|
||||||
modifiers: info.modifiers,
|
|
||||||
action: info.action,
|
|
||||||
seq: sequenceName,
|
|
||||||
level,
|
|
||||||
combo: combination,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private bindMultiple(combinations: string[], callback: HotkeyCallback, action?: string) {
|
|
||||||
for (const item of combinations) {
|
|
||||||
this.bindSingle(item, callback, action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
604
packages/globals/src/utils/hotkey.ts
Normal file
604
packages/globals/src/utils/hotkey.ts
Normal file
@ -0,0 +1,604 @@
|
|||||||
|
interface KeyMap {
|
||||||
|
[key: number]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CtrlKeyMap {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionEvent {
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HotkeyCallbacks {
|
||||||
|
[key: string]: HotkeyCallbackCfg[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HotkeyDirectMap {
|
||||||
|
[key: string]: HotkeyCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HotkeyCallback = (e: KeyboardEvent, combo?: string) => any | false;
|
||||||
|
|
||||||
|
interface HotkeyCallbackCfg {
|
||||||
|
callback: HotkeyCallback;
|
||||||
|
modifiers: string[];
|
||||||
|
action: string;
|
||||||
|
seq?: string;
|
||||||
|
level?: number;
|
||||||
|
combo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyInfo {
|
||||||
|
key: string;
|
||||||
|
modifiers: string[];
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SequenceLevels {
|
||||||
|
[key: string]: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAP: KeyMap = {
|
||||||
|
8: 'backspace',
|
||||||
|
9: 'tab',
|
||||||
|
13: 'enter',
|
||||||
|
16: 'shift',
|
||||||
|
17: 'ctrl',
|
||||||
|
18: 'alt',
|
||||||
|
20: 'capslock',
|
||||||
|
27: 'esc',
|
||||||
|
32: 'space',
|
||||||
|
33: 'pageup',
|
||||||
|
34: 'pagedown',
|
||||||
|
35: 'end',
|
||||||
|
36: 'home',
|
||||||
|
37: 'left',
|
||||||
|
38: 'up',
|
||||||
|
39: 'right',
|
||||||
|
40: 'down',
|
||||||
|
45: 'ins',
|
||||||
|
46: 'del',
|
||||||
|
91: 'meta',
|
||||||
|
93: 'meta',
|
||||||
|
224: 'meta',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEYCODE_MAP: KeyMap = {
|
||||||
|
106: '*',
|
||||||
|
107: '+',
|
||||||
|
109: '-',
|
||||||
|
110: '.',
|
||||||
|
111: '/',
|
||||||
|
186: ';',
|
||||||
|
187: '=',
|
||||||
|
188: ',',
|
||||||
|
189: '-',
|
||||||
|
190: '.',
|
||||||
|
191: '/',
|
||||||
|
192: '`',
|
||||||
|
219: '[',
|
||||||
|
220: '\\',
|
||||||
|
221: ']',
|
||||||
|
222: "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHIFT_MAP: CtrlKeyMap = {
|
||||||
|
'~': '`',
|
||||||
|
'!': '1',
|
||||||
|
'@': '2',
|
||||||
|
'#': '3',
|
||||||
|
$: '4',
|
||||||
|
'%': '5',
|
||||||
|
'^': '6',
|
||||||
|
'&': '7',
|
||||||
|
'*': '8',
|
||||||
|
'(': '9',
|
||||||
|
')': '0',
|
||||||
|
_: '-',
|
||||||
|
'+': '=',
|
||||||
|
':': ';',
|
||||||
|
'"': "'",
|
||||||
|
'<': ',',
|
||||||
|
'>': '.',
|
||||||
|
'?': '/',
|
||||||
|
'|': '\\',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPECIAL_ALIASES: CtrlKeyMap = {
|
||||||
|
option: 'alt',
|
||||||
|
command: 'meta',
|
||||||
|
return: 'enter',
|
||||||
|
escape: 'esc',
|
||||||
|
plus: '+',
|
||||||
|
mod: /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'meta' : 'ctrl',
|
||||||
|
};
|
||||||
|
|
||||||
|
let REVERSE_MAP: CtrlKeyMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loop through the f keys, f1 to f19 and add them to the map
|
||||||
|
* programatically
|
||||||
|
*/
|
||||||
|
for (let i = 1; i < 20; ++i) {
|
||||||
|
MAP[111 + i] = 'f' + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loop through to map numbers on the numeric keypad
|
||||||
|
*/
|
||||||
|
for (let i = 0; i <= 9; ++i) {
|
||||||
|
MAP[i + 96] = String(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* takes the event and returns the key character
|
||||||
|
*/
|
||||||
|
function characterFromEvent(e: KeyboardEvent): string {
|
||||||
|
const keyCode = e.keyCode || e.which;
|
||||||
|
// for keypress events we should return the character as is
|
||||||
|
if (e.type === 'keypress') {
|
||||||
|
let character = String.fromCharCode(keyCode);
|
||||||
|
// if the shift key is not pressed then it is safe to assume
|
||||||
|
// that we want the character to be lowercase. this means if
|
||||||
|
// you accidentally have caps lock on then your key bindings
|
||||||
|
// will continue to work
|
||||||
|
//
|
||||||
|
// the only side effect that might not be desired is if you
|
||||||
|
// bind something like 'A' cause you want to trigger an
|
||||||
|
// event when capital A is pressed caps lock will no longer
|
||||||
|
// trigger the event. shift+a will though.
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
character = character.toLowerCase();
|
||||||
|
}
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
// for non keypress events the special maps are needed
|
||||||
|
if (MAP[keyCode]) {
|
||||||
|
return MAP[keyCode];
|
||||||
|
}
|
||||||
|
if (KEYCODE_MAP[keyCode]) {
|
||||||
|
return KEYCODE_MAP[keyCode];
|
||||||
|
}
|
||||||
|
// if it is not in the special map
|
||||||
|
// with keydown and keyup events the character seems to always
|
||||||
|
// come in as an uppercase character whether you are pressing shift
|
||||||
|
// or not. we should make sure it is always lowercase for comparisons
|
||||||
|
return String.fromCharCode(keyCode).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeypressEvent extends KeyboardEvent {
|
||||||
|
type: 'keypress';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPressEvent(e: KeyboardEvent | ActionEvent): e is KeypressEvent {
|
||||||
|
return e.type === 'keypress';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checks if two arrays are equal
|
||||||
|
*/
|
||||||
|
function modifiersMatch(modifiers1: string[], modifiers2: string[]): boolean {
|
||||||
|
return modifiers1.sort().join(',') === modifiers2.sort().join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* takes a key event and figures out what the modifiers are
|
||||||
|
*/
|
||||||
|
function eventModifiers(e: KeyboardEvent): string[] {
|
||||||
|
const modifiers = [];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
modifiers.push('shift');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.altKey) {
|
||||||
|
modifiers.push('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
modifiers.push('ctrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.metaKey) {
|
||||||
|
modifiers.push('meta');
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* determines if the keycode specified is a modifier key or not
|
||||||
|
*/
|
||||||
|
function isModifier(key: string): boolean {
|
||||||
|
return key === 'shift' || key === 'ctrl' || key === 'alt' || key === 'meta';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reverses the map lookup so that we can look for specific keys
|
||||||
|
* to see what can and can't use keypress
|
||||||
|
*
|
||||||
|
* @return {Object}
|
||||||
|
*/
|
||||||
|
function getReverseMap(): CtrlKeyMap {
|
||||||
|
if (!REVERSE_MAP) {
|
||||||
|
REVERSE_MAP = {};
|
||||||
|
for (const key in MAP) {
|
||||||
|
// pull out the numeric keypad from here cause keypress should
|
||||||
|
// be able to detect the keys from the character
|
||||||
|
if (Number(key) > 95 && Number(key) < 112) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MAP.hasOwnProperty(key)) {
|
||||||
|
REVERSE_MAP[MAP[key]] = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return REVERSE_MAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* picks the best action based on the key combination
|
||||||
|
*/
|
||||||
|
function pickBestAction(key: string, modifiers: string[], action?: string): string {
|
||||||
|
// if no action was picked in we should try to pick the one
|
||||||
|
// that we think would work best for this key
|
||||||
|
if (!action) {
|
||||||
|
action = getReverseMap()[key] ? 'keydown' : 'keypress';
|
||||||
|
}
|
||||||
|
// modifier keys don't work as expected with keypress,
|
||||||
|
// switch to keydown
|
||||||
|
if (action === 'keypress' && modifiers.length) {
|
||||||
|
action = 'keydown';
|
||||||
|
}
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts from a string key combination to an array
|
||||||
|
*
|
||||||
|
* @param {string} combination like "command+shift+l"
|
||||||
|
* @return {Array}
|
||||||
|
*/
|
||||||
|
function keysFromString(combination: string): string[] {
|
||||||
|
if (combination === '+') {
|
||||||
|
return ['+'];
|
||||||
|
}
|
||||||
|
|
||||||
|
combination = combination.replace(/\+{2}/g, '+plus');
|
||||||
|
return combination.split('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets info for a specific key combination
|
||||||
|
*
|
||||||
|
* @param combination key combination ("command+s" or "a" or "*")
|
||||||
|
*/
|
||||||
|
function getKeyInfo(combination: string, action?: string): KeyInfo {
|
||||||
|
let keys: string[] = [];
|
||||||
|
let key = '';
|
||||||
|
let i: number;
|
||||||
|
const modifiers: string[] = [];
|
||||||
|
|
||||||
|
// take the keys from this pattern and figure out what the actual
|
||||||
|
// pattern is all about
|
||||||
|
keys = keysFromString(combination);
|
||||||
|
|
||||||
|
for (i = 0; i < keys.length; ++i) {
|
||||||
|
key = keys[i];
|
||||||
|
|
||||||
|
// normalize key names
|
||||||
|
if (SPECIAL_ALIASES[key]) {
|
||||||
|
key = SPECIAL_ALIASES[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// if this is not a keypress event then we should
|
||||||
|
// be smart about using shift keys
|
||||||
|
// this will only work for US keyboards however
|
||||||
|
if (action && action !== 'keypress' && SHIFT_MAP[key]) {
|
||||||
|
key = SHIFT_MAP[key];
|
||||||
|
modifiers.push('shift');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if this key is a modifier then add it to the list of modifiers
|
||||||
|
if (isModifier(key)) {
|
||||||
|
modifiers.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// depending on what the key combination is
|
||||||
|
// we will try to pick the best event for it
|
||||||
|
action = pickBestAction(key, modifiers, action);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
modifiers,
|
||||||
|
action,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* actually calls the callback function
|
||||||
|
*
|
||||||
|
* if your callback function returns false this will use the jquery
|
||||||
|
* convention - prevent default and stop propogation on the event
|
||||||
|
*/
|
||||||
|
function fireCallback(callback: HotkeyCallback, e: KeyboardEvent, combo?: string, sequence?: string): void {
|
||||||
|
if (callback(e, combo) === false) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Hotkey {
|
||||||
|
private callBacks: HotkeyCallbacks = {};
|
||||||
|
private directMap: HotkeyDirectMap = {};
|
||||||
|
private sequenceLevels: SequenceLevels = {};
|
||||||
|
private resetTimer = 0;
|
||||||
|
private ignoreNextKeyup: boolean | string = false;
|
||||||
|
private ignoreNextKeypress = false;
|
||||||
|
private nextExpectedAction: boolean | string = false;
|
||||||
|
|
||||||
|
mount(window: Window) {
|
||||||
|
const document = window.document;
|
||||||
|
const handleKeyEvent = this.handleKeyEvent.bind(this);
|
||||||
|
document.addEventListener('keypress', handleKeyEvent, false);
|
||||||
|
document.addEventListener('keydown', handleKeyEvent, false);
|
||||||
|
document.addEventListener('keyup', handleKeyEvent, false);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keypress', handleKeyEvent, false);
|
||||||
|
document.removeEventListener('keydown', handleKeyEvent, false);
|
||||||
|
document.removeEventListener('keyup', handleKeyEvent, false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bind(combos: string[] | string, callback: HotkeyCallback, action?: string): Hotkey {
|
||||||
|
this.bindMultiple(Array.isArray(combos) ? combos : [combos], callback, action);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resets all sequence counters except for the ones passed in
|
||||||
|
*/
|
||||||
|
private resetSequences(doNotReset?: SequenceLevels): void {
|
||||||
|
// doNotReset = doNotReset || {};
|
||||||
|
let activeSequences = false;
|
||||||
|
let key = '';
|
||||||
|
for (key in this.sequenceLevels) {
|
||||||
|
if (doNotReset && doNotReset[key]) {
|
||||||
|
activeSequences = true;
|
||||||
|
} else {
|
||||||
|
this.sequenceLevels[key] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!activeSequences) {
|
||||||
|
this.nextExpectedAction = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* finds all callbacks that match based on the keycode, modifiers,
|
||||||
|
* and action
|
||||||
|
*/
|
||||||
|
private getMatches(
|
||||||
|
character: string,
|
||||||
|
modifiers: string[],
|
||||||
|
e: KeyboardEvent | ActionEvent,
|
||||||
|
sequenceName?: string,
|
||||||
|
combination?: string,
|
||||||
|
level?: number,
|
||||||
|
): HotkeyCallbackCfg[] {
|
||||||
|
let i: number;
|
||||||
|
let callback: HotkeyCallbackCfg;
|
||||||
|
const matches: HotkeyCallbackCfg[] = [];
|
||||||
|
const action: string = e.type;
|
||||||
|
|
||||||
|
// if there are no events related to this keycode
|
||||||
|
if (!this.callBacks[character]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// if a modifier key is coming up on its own we should allow it
|
||||||
|
if (action === 'keyup' && isModifier(character)) {
|
||||||
|
modifiers = [character];
|
||||||
|
}
|
||||||
|
|
||||||
|
// loop through all callbacks for the key that was pressed
|
||||||
|
// and see if any of them match
|
||||||
|
for (i = 0; i < this.callBacks[character].length; ++i) {
|
||||||
|
callback = this.callBacks[character][i];
|
||||||
|
|
||||||
|
// if a sequence name is not specified, but this is a sequence at
|
||||||
|
// the wrong level then move onto the next match
|
||||||
|
if (!sequenceName && callback.seq && this.sequenceLevels[callback.seq] !== callback.level) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the action we are looking for doesn't match the action we got
|
||||||
|
// then we should keep going
|
||||||
|
if (action !== callback.action) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if this is a keypress event and the meta key and control key
|
||||||
|
// are not pressed that means that we need to only look at the
|
||||||
|
// character, otherwise check the modifiers as well
|
||||||
|
//
|
||||||
|
// chrome will not fire a keypress if meta or control is down
|
||||||
|
// safari will fire a keypress if meta or meta+shift is down
|
||||||
|
// firefox will fire a keypress if meta or control is down
|
||||||
|
if ((isPressEvent(e) && !e.metaKey && !e.ctrlKey) || modifiersMatch(modifiers, callback.modifiers)) {
|
||||||
|
const deleteCombo = !sequenceName && callback.combo === combination;
|
||||||
|
const deleteSequence = sequenceName && callback.seq === sequenceName && callback.level === level;
|
||||||
|
if (deleteCombo || deleteSequence) {
|
||||||
|
this.callBacks[character].splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.push(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKey(character: string, modifiers: string[], e: KeyboardEvent): void {
|
||||||
|
const callbacks: HotkeyCallbackCfg[] = this.getMatches(character, modifiers, e);
|
||||||
|
let i: number;
|
||||||
|
const doNotReset: SequenceLevels = {};
|
||||||
|
let maxLevel = 0;
|
||||||
|
let processedSequenceCallback = false;
|
||||||
|
|
||||||
|
// Calculate the maxLevel for sequences so we can only execute the longest callback sequence
|
||||||
|
for (i = 0; i < callbacks.length; ++i) {
|
||||||
|
if (callbacks[i].seq) {
|
||||||
|
maxLevel = Math.max(maxLevel, callbacks[i].level || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loop through matching callbacks for this key event
|
||||||
|
for (i = 0; i < callbacks.length; ++i) {
|
||||||
|
// fire for all sequence callbacks
|
||||||
|
// this is because if for example you have multiple sequences
|
||||||
|
// bound such as "g i" and "g t" they both need to fire the
|
||||||
|
// callback for matching g cause otherwise you can only ever
|
||||||
|
// match the first one
|
||||||
|
if (callbacks[i].seq) {
|
||||||
|
// only fire callbacks for the maxLevel to prevent
|
||||||
|
// subsequences from also firing
|
||||||
|
//
|
||||||
|
// for example 'a option b' should not cause 'option b' to fire
|
||||||
|
// even though 'option b' is part of the other sequence
|
||||||
|
//
|
||||||
|
// any sequences that do not match here will be discarded
|
||||||
|
// below by the resetSequences call
|
||||||
|
if (callbacks[i].level !== maxLevel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedSequenceCallback = true;
|
||||||
|
|
||||||
|
// keep a list of which sequences were matches for later
|
||||||
|
doNotReset[callbacks[i].seq || ''] = 1;
|
||||||
|
fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there were no sequence matches but we are still here
|
||||||
|
// that means this is a regular match so we should fire that
|
||||||
|
if (!processedSequenceCallback) {
|
||||||
|
fireCallback(callbacks[i].callback, e, callbacks[i].combo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignoreThisKeypress = e.type === 'keypress' && this.ignoreNextKeypress;
|
||||||
|
if (e.type === this.nextExpectedAction && !isModifier(character) && !ignoreThisKeypress) {
|
||||||
|
this.resetSequences(doNotReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ignoreNextKeypress = processedSequenceCallback && e.type === 'keydown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeyEvent(e: KeyboardEvent): void {
|
||||||
|
const character = characterFromEvent(e);
|
||||||
|
|
||||||
|
// no character found then stop
|
||||||
|
if (!character) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// need to use === for the character check because the character can be 0
|
||||||
|
if (e.type === 'keyup' && this.ignoreNextKeyup === character) {
|
||||||
|
this.ignoreNextKeyup = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleKey(character, eventModifiers(e), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetSequenceTimer(): void {
|
||||||
|
if (this.resetTimer) {
|
||||||
|
clearTimeout(this.resetTimer);
|
||||||
|
}
|
||||||
|
this.resetTimer = window.setTimeout(this.resetSequences, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindSequence(combo: string, keys: string[], callback: HotkeyCallback, action?: string): void {
|
||||||
|
// const self: any = this;
|
||||||
|
this.sequenceLevels[combo] = 0;
|
||||||
|
const increaseSequence = (nextAction: string) => {
|
||||||
|
return () => {
|
||||||
|
this.nextExpectedAction = nextAction;
|
||||||
|
++this.sequenceLevels[combo];
|
||||||
|
this.resetSequenceTimer();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const callbackAndReset = (e: KeyboardEvent): void => {
|
||||||
|
fireCallback(callback, e, combo);
|
||||||
|
|
||||||
|
if (action !== 'keyup') {
|
||||||
|
this.ignoreNextKeyup = characterFromEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(this.resetSequences, 10);
|
||||||
|
};
|
||||||
|
for (let i = 0; i < keys.length; ++i) {
|
||||||
|
const isFinal = i + 1 === keys.length;
|
||||||
|
const wrappedCallback = isFinal ? callbackAndReset : increaseSequence(action || getKeyInfo(keys[i + 1]).action);
|
||||||
|
this.bindSingle(keys[i], wrappedCallback, action, combo, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindSingle(
|
||||||
|
combination: string,
|
||||||
|
callback: HotkeyCallback,
|
||||||
|
action?: string,
|
||||||
|
sequenceName?: string,
|
||||||
|
level?: number,
|
||||||
|
): void {
|
||||||
|
// store a direct mapped reference for use with HotKey.trigger
|
||||||
|
this.directMap[`${combination}:${action}`] = callback;
|
||||||
|
|
||||||
|
// make sure multiple spaces in a row become a single space
|
||||||
|
combination = combination.replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
const sequence: string[] = combination.split(' ');
|
||||||
|
let info: KeyInfo;
|
||||||
|
|
||||||
|
// if this pattern is a sequence of keys then run through this method
|
||||||
|
// to reprocess each pattern one key at a time
|
||||||
|
if (sequence.length > 1) {
|
||||||
|
this.bindSequence(combination, sequence, callback, action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info = getKeyInfo(combination, action);
|
||||||
|
|
||||||
|
// make sure to initialize array if this is the first time
|
||||||
|
// a callback is added for this key
|
||||||
|
this.callBacks[info.key] = this.callBacks[info.key] || [];
|
||||||
|
|
||||||
|
// remove an existing match if there is one
|
||||||
|
this.getMatches(info.key, info.modifiers, { type: info.action }, sequenceName, combination, level);
|
||||||
|
|
||||||
|
// add this call back to the array
|
||||||
|
// if it is a sequence put it at the beginning
|
||||||
|
// if not put it at the end
|
||||||
|
//
|
||||||
|
// this is important because the way these are processed expects
|
||||||
|
// the sequence ones to come first
|
||||||
|
this.callBacks[info.key][sequenceName ? 'unshift' : 'push']({
|
||||||
|
callback,
|
||||||
|
modifiers: info.modifiers,
|
||||||
|
action: info.action,
|
||||||
|
seq: sequenceName,
|
||||||
|
level,
|
||||||
|
combo: combination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindMultiple(combinations: string[], callback: HotkeyCallback, action?: string) {
|
||||||
|
for (const item of combinations) {
|
||||||
|
this.bindSingle(item, callback, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,3 +19,4 @@ export * from './shallow-equal';
|
|||||||
export * from './unique-id';
|
export * from './unique-id';
|
||||||
export * from './get-public-path';
|
export * from './get-public-path';
|
||||||
export * from './is-form-event';
|
export * from './is-form-event';
|
||||||
|
export * from './hotkey';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user