diff --git a/packages/designer/src/builtin-simulator/host.ts b/packages/designer/src/builtin-simulator/host.ts index cda082b65..b47e2b3ec 100644 --- a/packages/designer/src/builtin-simulator/host.ts +++ b/packages/designer/src/builtin-simulator/host.ts @@ -20,11 +20,13 @@ import { getRectTarget, Rect, CanvasPoint, + hotkey, } from '../designer'; import { parseProps } from './utils/parse-props'; import { isElement } from '@ali/lowcode-globals'; import { ComponentMetadata } from '@ali/lowcode-globals'; import { BuiltinSimulatorRenderer } from './renderer'; +import clipboard from '../designer/clipboard'; export interface LibraryItem { package: string; @@ -196,6 +198,8 @@ export class BuiltinSimulatorHost implements ISimulatorHost x.ownerDocument === document)) { + return; + } const copyPaster = document.createElement<'textarea'>('textarea'); copyPaster.style.cssText = 'position: relative;left: -9999px;'; document.body.appendChild(copyPaster); diff --git a/packages/designer/src/designer/designer-view.tsx b/packages/designer/src/designer/designer-view.tsx index 7155188da..848f938c8 100644 --- a/packages/designer/src/designer/designer-view.tsx +++ b/packages/designer/src/designer/designer-view.tsx @@ -5,6 +5,7 @@ import BuiltinDragGhostComponent from './drag-ghost'; import { Designer, DesignerProps } from './designer'; import { ProjectView } from '../project'; import './designer.less'; +import clipboard from './clipboard'; export class DesignerView extends Component { readonly designer: Designer; @@ -32,6 +33,7 @@ export class DesignerView extends Component { if (onMount) { onMount(this.designer); } + clipboard.injectCopyPaster(document) this.designer.postEvent('mount', this.designer); } diff --git a/packages/designer/src/designer/designer.ts b/packages/designer/src/designer/designer.ts index c9eecb651..4312ea80e 100644 --- a/packages/designer/src/designer/designer.ts +++ b/packages/designer/src/designer/designer.ts @@ -10,7 +10,7 @@ import { autorun, } from '@ali/lowcode-globals'; 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 { INodeSelector } from '../simulator'; import { Scroller, IScrollable } from './scroller'; @@ -19,6 +19,7 @@ import { ActiveTracker } from './active-tracker'; import { Hovering } from './hovering'; import { DropLocation, LocationData, isLocationChildrenDetail } from './location'; import { OffsetObserver, createOffsetObserver } from './offset-observer'; +import { focusing } from './focusing'; export interface DesignerProps { className?: string; @@ -40,7 +41,6 @@ export interface DesignerProps { } export class Designer { - // readonly hotkey: Hotkey; readonly dragon = new Dragon(this); readonly activeTracker = new ActiveTracker(); readonly hovering = new Hovering(); @@ -156,6 +156,9 @@ export class Designer { this.postEvent('designer.init', this); setupSelection(); setupHistory(); + + // TODO: 先简单实现,后期通过焦点赋值 + focusing.focusDesigner = this; } postEvent(event: string, ...args: any[]) { @@ -198,7 +201,7 @@ export class Designer { /** * 获得合适的插入位置 */ - getSuitableInsertion() { + getSuitableInsertion(): { target: NodeParent; index?: number } | null { const activedDoc = this.project.currentDocument; if (!activedDoc) { return null; @@ -296,7 +299,7 @@ export class Designer { } setSchema(schema?: ProjectSchema) { - this.project.setSchema(schema); + this.project.load(schema); } @obx.val private _componentMetasMap = new Map(); diff --git a/packages/designer/src/designer/focusing.ts b/packages/designer/src/designer/focusing.ts new file mode 100644 index 000000000..8e24db041 --- /dev/null +++ b/packages/designer/src/designer/focusing.ts @@ -0,0 +1,8 @@ +import { Designer } from './designer'; + +// TODO: +class Focusing { + focusDesigner?: Designer; +} + +export const focusing = new Focusing(); diff --git a/packages/designer/src/designer/hotkey.ts b/packages/designer/src/designer/hotkey.ts index 42a589025..a77c64a9e 100644 --- a/packages/designer/src/designer/hotkey.ts +++ b/packages/designer/src/designer/hotkey.ts @@ -1,618 +1,117 @@ -interface KeyMap { - [key: number]: string; -} +import { Hotkey, isFormEvent } from '@ali/lowcode-globals'; +import { focusing } from './focusing'; +import { insertChildren } from '../document'; +import clipboard from './clipboard'; -interface CtrlKeyMap { - [key: string]: string; -} +export const hotkey = new Hotkey(); -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; +// hotkey binding +hotkey.bind(['backspace', 'del'], (e: KeyboardEvent) => { + const doc = focusing.focusDesigner?.currentDocument; + if (isFormEvent(e) || !doc) { + return; } - // for non keypress events the special maps are needed - if (MAP[keyCode]) { - return MAP[keyCode]; + e.preventDefault(); + + 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]) { - return KEYCODE_MAP[keyCode]; + e.preventDefault(); + + 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 - // 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(); -} + e.preventDefault(); -interface KeypressEvent extends KeyboardEvent { - type: 'keypress'; -} - -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; + /* + const doc = getCurrentDocument(); + if (isFormEvent(e) || !doc || !(focusing.id === 'outline' || focusing.id === 'canvas')) { + return; } + e.preventDefault(); + */ - if (t.form || /^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName)) { - return true; + const selected = doc.selection.getTopNodes(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; -} -/** - * 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; + clipboard.waitPasteData(e, ({ componentsTree }) => { + if (componentsTree) { + const { target, index } = designer.getSuitableInsertion() || {}; + if (!target) { + return; } - - if (MAP.hasOwnProperty(key)) { - REVERSE_MAP[MAP[key]] = key; + const nodes = insertChildren(target, componentsTree, index); + if (nodes) { + 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; -} -/** - * 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 ['+']; +// command + z undo +hotkey.bind(['command+z', 'ctrl+z'], (e) => { + const his = focusing.focusDesigner?.currentHistory; + if (isFormEvent(e) || !his) { + return; } - combination = combination.replace(/\+{2}/g, '+plus'); - return combination.split('+'); -} + e.preventDefault(); + his.back(); +}); -/** - * 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); - } +// command + shift + z redo +hotkey.bind(['command+y', 'ctrl+y', 'command+shift+z'], (e) => { + const his = focusing.focusDesigner?.currentHistory; + if (isFormEvent(e) || !his) { + return; } + e.preventDefault(); - // depending on what the key combination is - // we will try to pick the best event for it - action = pickBestAction(key, modifiers, action); + his.forward(); +}); - 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); - } - } -} +hotkey.mount(window); diff --git a/packages/globals/src/utils/hotkey.ts b/packages/globals/src/utils/hotkey.ts new file mode 100644 index 000000000..570e13ebe --- /dev/null +++ b/packages/globals/src/utils/hotkey.ts @@ -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); + } + } +} diff --git a/packages/globals/src/utils/index.ts b/packages/globals/src/utils/index.ts index b1583025d..c61e07cd1 100644 --- a/packages/globals/src/utils/index.ts +++ b/packages/globals/src/utils/index.ts @@ -19,3 +19,4 @@ export * from './shallow-equal'; export * from './unique-id'; export * from './get-public-path'; export * from './is-form-event'; +export * from './hotkey';