From 5d946d51d643f0ef7e7730fa527b7ca96e330907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Lizo=C5=84?= Date: Mon, 18 Oct 2021 19:53:49 +0200 Subject: [PATCH] feat(keyboard): keep key pressed for multiple `keydown` events (#728) * Initial POC * WIP: test * Fix types * Reduce getNextKeyDef complexity * Fix tests * Fix type import * Implement feedback on triggering event * fix: only trigger one `keyup` for repeated `keydown` * fix: do not leak `keyboardState.repeatKey` Co-authored-by: Philipp Fritsche --- src/__tests__/keyboard/getNextKeyDef.ts | 22 +- .../keyboard/keyboardImplementation.ts | 119 +++++++++ src/keyboard/getNextKeyDef.ts | 250 ++++++++++++------ src/keyboard/keyboardImplementation.ts | 29 +- src/keyboard/types.ts | 7 + 5 files changed, 331 insertions(+), 96 deletions(-) diff --git a/src/__tests__/keyboard/getNextKeyDef.ts b/src/__tests__/keyboard/getNextKeyDef.ts index 7313fe5f..679be074 100644 --- a/src/__tests__/keyboard/getNextKeyDef.ts +++ b/src/__tests__/keyboard/getNextKeyDef.ts @@ -77,6 +77,14 @@ cases( text: '[Control>]', modifiers: {releaseSelf: false}, }, + 'keep key pressed with repeatModifier': { + text: '{Control>2}', + modifiers: {releaseSelf: false}, + }, + 'release after repeatModifier': { + text: '{Control>2/}', + modifiers: {releaseSelf: true}, + }, 'no releaseSelf on legacy modifier': { text: '{ctrl}', modifiers: {releaseSelf: false}, @@ -96,15 +104,23 @@ cases( { 'invalid descriptor': { text: '{!}', - expectedError: 'Expected key descriptor but found "!" in "{!}"', + expectedError: 'but found "!" in "{!}"', }, 'missing descriptor': { text: '', - expectedError: 'Expected key descriptor but found "" in ""', + expectedError: 'but found "" in ""', }, 'missing closing bracket': { text: '{a)', - expectedError: 'Expected closing bracket but found ")" in "{a)"', + expectedError: 'but found ")" in "{a)"', + }, + 'invalid repeat modifier': { + text: '{a>e}', + expectedError: 'but found "e" in "{a>e}"', + }, + 'missing bracket after repeat modifier': { + text: '{a>3)', + expectedError: 'but found ")" in "{a>3)"', }, }, ) diff --git a/src/__tests__/keyboard/keyboardImplementation.ts b/src/__tests__/keyboard/keyboardImplementation.ts index be9d8329..1fb9fa59 100644 --- a/src/__tests__/keyboard/keyboardImplementation.ts +++ b/src/__tests__/keyboard/keyboardImplementation.ts @@ -15,3 +15,122 @@ test('no character input if `altKey` or `ctrlKey` is pressed', () => { expect(eventWasFired('keypress')).toBe(false) expect(eventWasFired('input')).toBe(false) }) + +test('do not leak repeatKey in state', () => { + const {element} = setup(``) + ;(element as HTMLInputElement).focus() + + const keyboardState = userEvent.keyboard('{a>2}') + expect(keyboardState).toHaveProperty('repeatKey', undefined) +}) + +describe('pressing and releasing keys', () => { + it('fires event with releasing key twice', () => { + const {element, getEventSnapshot, clearEventCalls} = setup(``) + + ;(element as HTMLInputElement).focus() + clearEventCalls() + + userEvent.keyboard('{ArrowLeft>}{ArrowLeft}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` +Events fired on: input[value=""] + +input[value=""] - keydown: ArrowLeft (37) +input[value=""] - select +input[value=""] - keyup: ArrowLeft (37) +input[value=""] - keydown: ArrowLeft (37) +input[value=""] - select +input[value=""] - keyup: ArrowLeft (37) +`) + }) + + it('fires event without releasing key', () => { + const {element, getEventSnapshot, clearEventCalls} = setup(``) + + ;(element as HTMLInputElement).focus() + clearEventCalls() + + userEvent.keyboard('{a>}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` +Events fired on: input[value="a"] + +input[value=""] - keydown: a (97) +input[value=""] - keypress: a (97) +input[value="a"] - input +`) + }) + + it('fires event multiple times without releasing key', () => { + const {element, getEventSnapshot, clearEventCalls} = setup(``) + ;(element as HTMLInputElement).focus() + clearEventCalls() + + userEvent.keyboard('{a>2}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` +Events fired on: input[value="aa"] + +input[value=""] - keydown: a (97) +input[value=""] - keypress: a (97) +input[value="a"] - input +input[value="a"] - keydown: a (97) +input[value="a"] - keypress: a (97) +input[value="aa"] - input +`) + }) + + it('fires event multiple times and releases key', () => { + const {element, getEventSnapshot, clearEventCalls} = setup(``) + ;(element as HTMLInputElement).focus() + clearEventCalls() + + userEvent.keyboard('{a>2/}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` +Events fired on: input[value="aa"] + +input[value=""] - keydown: a (97) +input[value=""] - keypress: a (97) +input[value="a"] - input +input[value="a"] - keydown: a (97) +input[value="a"] - keypress: a (97) +input[value="aa"] - input +input[value="aa"] - keyup: a (97) +`) + }) + + it('fires event multiple times for multiple keys', () => { + const {element, getEventSnapshot, clearEventCalls} = setup(``) + ;(element as HTMLInputElement).focus() + clearEventCalls() + + userEvent.keyboard('{a>2}{b>2/}{c>2}{/a}') + + expect(getEventSnapshot()).toMatchInlineSnapshot(` +Events fired on: input[value="aabbcc"] + +input[value=""] - keydown: a (97) +input[value=""] - keypress: a (97) +input[value="a"] - input +input[value="a"] - keydown: a (97) +input[value="a"] - keypress: a (97) +input[value="aa"] - input +input[value="aa"] - keydown: b (98) +input[value="aa"] - keypress: b (98) +input[value="aab"] - input +input[value="aab"] - keydown: b (98) +input[value="aab"] - keypress: b (98) +input[value="aabb"] - input +input[value="aabb"] - keyup: b (98) +input[value="aabb"] - keydown: c (99) +input[value="aabb"] - keypress: c (99) +input[value="aabbc"] - input +input[value="aabbc"] - keydown: c (99) +input[value="aabbc"] - keypress: c (99) +input[value="aabbcc"] - input +input[value="aabbcc"] - keyup: a (97) +`) + }) +}) diff --git a/src/keyboard/getNextKeyDef.ts b/src/keyboard/getNextKeyDef.ts index a7003894..4e6a2e09 100644 --- a/src/keyboard/getNextKeyDef.ts +++ b/src/keyboard/getNextKeyDef.ts @@ -1,5 +1,24 @@ import {keyboardKey, keyboardOptions} from './types' +enum bracketDict { + '{' = '}', + '[' = ']', +} + +enum legacyModifiers { + 'alt' = 'alt', + 'ctrl' = 'ctrl', + 'meta' = 'meta', + 'shift' = 'shift', +} + +enum legacyKeyMap { + ctrl = 'Control', + del = 'Delete', + esc = 'Escape', + space = ' ', +} + /** * Get the next key from keyMap * @@ -7,6 +26,8 @@ import {keyboardKey, keyboardOptions} from './types' * Everything else will be interpreted as a typed character - e.g. `a`. * Brackets `{` and `[` can be escaped by doubling - e.g. `foo[[bar` translates to `foo[bar`. * Keeping the key pressed can be written as `{key>}`. + * When keeping the key pressed you can choose how long (how many keydown and keypress) the key is pressed `{key>3}`. + * You can then release the key per `{key>3/}` or keep it pressed and continue with the next key. * Modifiers like `{shift}` imply being kept pressed. This can be turned of per `{shift/}`. */ export function getNextKeyDef( @@ -17,123 +38,180 @@ export function getNextKeyDef( consumedLength: number releasePrevious: boolean releaseSelf: boolean + repeat: number } { - const startBracket = ['{', '['].includes(text[0]) ? text[0] : '' - const startModifier = startBracket && text[1] === '/' ? '/' : '' + const { + type, + descriptor, + consumedLength, + releasePrevious, + releaseSelf, + repeat, + } = readNextDescriptor(text) - const descriptorStart = startBracket.length + startModifier.length - const descriptor = startBracket - ? text[descriptorStart] === startBracket - ? startBracket - : text.slice(descriptorStart).match(/^\w+/)?.[0] - : text[descriptorStart] + const keyDef = options.keyboardMap.find(def => { + if (type === '[') { + return def.code?.toLowerCase() === descriptor.toLowerCase() + } else if (type === '{') { + const key = mapLegacyKey(descriptor) + return def.key?.toLowerCase() === key.toLowerCase() + } + return def.key === descriptor + }) ?? { + key: 'Unknown', + code: 'Unknown', + [type === '[' ? 'code' : 'key']: descriptor, + } - if (!descriptor) { - throw new Error( - getErrorMessage('key descriptor', text[descriptorStart], text), - ) + return { + keyDef, + consumedLength, + releasePrevious, + releaseSelf, + repeat, } +} + +function readNextDescriptor(text: string) { + let pos = 0 + const startBracket = + text[pos] in bracketDict ? (text[pos] as keyof typeof bracketDict) : '' + + pos += startBracket.length - const descriptorEnd = descriptorStart + descriptor.length - const endModifier = - startBracket && - descriptor !== startBracket && - ['/', '>'].includes(text[descriptorEnd]) - ? text[descriptorEnd] - : '' - - const endBracket = - !startBracket || descriptor === startBracket - ? '' - : startBracket === '{' - ? '}' - : ']' - - if (endBracket && text[descriptorEnd + endModifier.length] !== endBracket) { + // `foo{{bar` is an escaped char at position 3, + // but `foo{{{>5}bar` should be treated as `{` pressed down for 5 keydowns. + const startBracketRepeated = startBracket + ? (text.match(new RegExp(`^\\${startBracket}+`)) as RegExpMatchArray)[0] + .length + : 0 + const isEscapedChar = + startBracketRepeated === 2 || + (startBracket === '{' && startBracketRepeated > 3) + + const type = isEscapedChar ? '' : startBracket + + return { + type, + ...(type === '' ? readPrintableChar(text, pos) : readTag(text, pos, type)), + } +} + +function readPrintableChar(text: string, pos: number) { + const descriptor = text[pos] + + assertDescriptor(descriptor, text, pos) + + pos += descriptor.length + + return { + consumedLength: pos, + descriptor, + releasePrevious: false, + releaseSelf: true, + repeat: 1, + } +} + +function readTag( + text: string, + pos: number, + startBracket: keyof typeof bracketDict, +) { + const releasePreviousModifier = text[pos] === '/' ? '/' : '' + + pos += releasePreviousModifier.length + + const descriptor = text.slice(pos).match(/^\w+/)?.[0] + + assertDescriptor(descriptor, text, pos) + + pos += descriptor.length + + const repeatModifier = text.slice(pos).match(/^>\d+/)?.[0] ?? '' + + pos += repeatModifier.length + + const releaseSelfModifier = + text[pos] === '/' || (!repeatModifier && text[pos] === '>') ? text[pos] : '' + + pos += releaseSelfModifier.length + + const expectedEndBracket = bracketDict[startBracket] + const endBracket = text[pos] === expectedEndBracket ? expectedEndBracket : '' + + if (!endBracket) { throw new Error( getErrorMessage( - 'closing bracket', - text[descriptorEnd + endModifier.length], + [ + !repeatModifier && 'repeat modifier', + !releaseSelfModifier && 'release modifier', + `"${expectedEndBracket}"`, + ] + .filter(Boolean) + .join(' or '), + text[pos], text, ), ) } - const modifiers = { - consumedLength: [ + pos += endBracket.length + + return { + consumedLength: pos, + descriptor, + releasePrevious: !!releasePreviousModifier, + repeat: repeatModifier ? Math.max(Number(repeatModifier.substr(1)), 1) : 1, + releaseSelf: hasReleaseSelf( startBracket, - startModifier, descriptor, - endModifier, - endBracket, - ] - .map(c => c.length) - .reduce((a, b) => a + b), - - releasePrevious: startModifier === '/', - releaseSelf: hasReleaseSelf(startBracket, descriptor, endModifier), + releaseSelfModifier, + repeatModifier, + ), } +} - if (isPrintableCharacter(startBracket, descriptor)) { - return { - ...modifiers, - keyDef: options.keyboardMap.find(k => k.key === descriptor) ?? { - key: descriptor, - code: 'Unknown', - }, - } - } else if (startBracket === '{') { - const key = mapLegacyKey(descriptor) - return { - ...modifiers, - keyDef: options.keyboardMap.find( - k => k.key?.toLowerCase() === key.toLowerCase(), - ) ?? {key: descriptor, code: 'Unknown'}, - } - } else { - return { - ...modifiers, - keyDef: options.keyboardMap.find( - k => k.code?.toLowerCase() === descriptor.toLowerCase(), - ) ?? {key: 'Unknown', code: descriptor}, - } +function assertDescriptor( + descriptor: string | undefined, + text: string, + pos: number, +): asserts descriptor is string { + if (!descriptor) { + throw new Error(getErrorMessage('key descriptor', text[pos], text)) } } +function getEnumValue(f: Record, key: string): T | undefined { + return f[key] +} + function hasReleaseSelf( - startBracket: string, + startBracket: keyof typeof bracketDict, descriptor: string, - endModifier: string, + releaseSelfModifier: string, + repeatModifier: string, ) { - if (endModifier === '/' || !startBracket) { - return true + if (releaseSelfModifier) { + return releaseSelfModifier === '/' } + + if (repeatModifier) { + return false + } + if ( startBracket === '{' && - ['alt', 'ctrl', 'meta', 'shift'].includes(descriptor.toLowerCase()) + getEnumValue(legacyModifiers, descriptor.toLowerCase()) ) { return false } - return endModifier !== '>' -} -function mapLegacyKey(descriptor: string) { - return ( - { - ctrl: 'Control', - del: 'Delete', - esc: 'Escape', - space: ' ', - }[descriptor] ?? descriptor - ) + return true } -function isPrintableCharacter(startBracket: string, descriptor: string) { - return ( - !startBracket || - startBracket === descriptor || - (startBracket === '{' && descriptor.length === 1) - ) +function mapLegacyKey(descriptor: string) { + return getEnumValue(legacyKeyMap, descriptor) ?? descriptor } function getErrorMessage( diff --git a/src/keyboard/keyboardImplementation.ts b/src/keyboard/keyboardImplementation.ts index 2b3ed8f8..1f68f930 100644 --- a/src/keyboard/keyboardImplementation.ts +++ b/src/keyboard/keyboardImplementation.ts @@ -18,10 +18,8 @@ export async function keyboardImplementation( const {document} = options const getCurrentElement = () => getActive(document) - const {keyDef, consumedLength, releasePrevious, releaseSelf} = getNextKeyDef( - text, - options, - ) + const {keyDef, consumedLength, releasePrevious, releaseSelf, repeat} = + state.repeatKey ?? getNextKeyDef(text, options) const replace = applyPlugins( plugins.replaceBehavior, @@ -33,7 +31,9 @@ export async function keyboardImplementation( if (!replace) { const pressed = state.pressed.find(p => p.keyDef === keyDef) - if (pressed) { + // Release the key automatically if it was pressed before. + // Do not release the key on iterations on `state.repeatKey`. + if (pressed && !state.repeatKey) { keyup( keyDef, getCurrentElement, @@ -55,16 +55,31 @@ export async function keyboardImplementation( keypress(keyDef, getCurrentElement, options, state) } - if (releaseSelf) { + // Release the key only on the last iteration on `state.repeatKey`. + if (releaseSelf && repeat <= 1) { keyup(keyDef, getCurrentElement, options, state, unpreventedDefault) } } } - if (text.length > consumedLength) { + if (repeat > 1) { + state.repeatKey = { + // don't consume again on the next iteration + consumedLength: 0, + keyDef, + releasePrevious, + releaseSelf, + repeat: repeat - 1, + } + } else { + delete state.repeatKey + } + + if (text.length > consumedLength || repeat > 1) { if (options.delay > 0) { await wait(options.delay) } + return keyboardImplementation(text.slice(consumedLength), options, state) } return void undefined diff --git a/src/keyboard/types.ts b/src/keyboard/types.ts index 1fc949d5..70cead57 100644 --- a/src/keyboard/types.ts +++ b/src/keyboard/types.ts @@ -1,3 +1,5 @@ +import {getNextKeyDef} from './getNextKeyDef' + /** * @internal Do not create/alter this by yourself as this type might be subject to changes. */ @@ -36,6 +38,11 @@ export type keyboardState = { E.g. ^1 */ carryChar: string + + /** + Repeat keydown and keypress event + */ + repeatKey?: ReturnType } export type keyboardOptions = {