Skip to content

Commit

Permalink
Merge pull request #91 from github/chord-mapper
Browse files Browse the repository at this point in the history
Add chord/sequence support to mapper tool
  • Loading branch information
iansan5653 committed Sep 12, 2023
2 parents 71279ce + 6db787b commit aa78cee
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 70 deletions.
50 changes: 50 additions & 0 deletions .github/workflows/publish-pages.yml
@@ -0,0 +1,50 @@
name: Publish Pages site

on:
push:
branches:
- master

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- uses: actions/setup-node@v3
with:
node-version: 12.x

- run: npm install

- run: npm run build

- name: Copy files
run: |
mkdir _site
cp -r pages _site
cp -r dist _site
- name: Fix permissions
run: |
chmod -c -R +rX "_site/" | while read line; do
echo "::warning title=Invalid file permissions automatically fixed::$line"
done
- uses: actions/upload-pages-artifact@v2

deploy:
needs: build

permissions:
pages: write
id-token: write

environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

runs-on: ubuntu-latest
steps:
- id: deployment
uses: actions/deploy-pages@v2
1 change: 1 addition & 0 deletions .gitignore
@@ -1,2 +1,3 @@
node_modules/
dist/
_site/
52 changes: 0 additions & 52 deletions examples/hotkey_mapper.html

This file was deleted.

3 changes: 1 addition & 2 deletions examples/demo.html → pages/demo.html
Expand Up @@ -17,8 +17,7 @@
<a href="#ok" data-hotkey="o k">Press <kbd>o k</kbd> click this link</a>

<script type="module">
// import {install} from '../dist/index.js'
import {install} from 'https://unpkg.com/@github/hotkey@latest?module'
import {install} from '../dist/index.js'

for (const el of document.querySelectorAll('[data-hotkey]')) {
install(el)
Expand Down
92 changes: 92 additions & 0 deletions pages/hotkey_mapper.html
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>hotkey | Mapper Tool</title>
<link href="https://unpkg.com/@primer/css@^21.0.8/dist/primer.css" rel="stylesheet" />
<script type="module" src="https://unpkg.com/@github/clipboard-copy-element@latest?module"></script>
</head>

<body>
<div class="mx-auto my-3 col-12 col-md-8 col-lg-6">
<h1 id="app-name">Hotkey Code</h1>
<p id="hint">Press a key combination to see the corresponding hotkey string. Quickly press another combination to build a sequence.</p>
<div class="position-relative">
<input
readonly
role="application"
aria-roledescription="Input Capture"
autofocus
aria-labelledby="app-name"
aria-describedby="hint sequence-hint"
aria-live="assertive"
aria-atomic="true"
id="hotkey-code"
class="border rounded-2 mt-2 p-6 f1 text-mono"
style="width: 100%"
/>

<div class="position-absolute bottom-2 left-3 right-3 d-flex" style="align-items: center; gap: 8px">
<!-- This indicates that the input is listening for a sequence press. Ideally we'd have a way to tell screen
readers this too, but if we make this live and add more text it will get annoying because it will conflict
with the already-live input above. -->
<p id="sequence-status" class="color-fg-subtle" style="margin: 0" aria-hidden hidden></p>

<span style="flex: 1"></span>

<button id="reset-button" class="btn">Reset</button>

<clipboard-copy for="hotkey-code" class="btn">
Copy to clipboard
</clipboard-copy>
</div>
</div>
</div>

<script type="module">
import {eventToHotkeyString} from '../dist/index.js'
import sequenceTracker from '../dist/sequence.js'

const hotkeyCodeElement = document.getElementById('hotkey-code')
const sequenceStatusElement = document.getElementById('sequence-status')
const resetButtonElement = document.getElementById('reset-button')

const sequenceTracker = new sequenceTracker({
onReset() {
sequenceStatusElement.hidden = true
}
})

let currentsequence = null

hotkeyCodeElement.addEventListener('keydown', event => {
if (event.key === "Tab")
return;

event.preventDefault();
event.stopPropagation();

currentsequence = eventToHotkeyString(event)
event.currentTarget.value = [...sequenceTracker.path, currentsequence].join(' ');
})

hotkeyCodeElement.addEventListener('keyup', () => {
// we don't just build the sequence from the keyup event because keyups don't necessarily map to keydowns - for
// example, the keyup event for meta+b is just meta.
if (currentsequence) {
sequenceTracker.registerKeypress(currentsequence)
sequenceStatusElement.hidden = false
currentsequence = null
}
})

resetButtonElement.addEventListener('click', () => {
sequenceTracker.reset()
hotkeyCodeElement.value = ''
})
</script>
</body>

</html>
File renamed without changes.
25 changes: 10 additions & 15 deletions src/index.ts
@@ -1,18 +1,17 @@
import {Leaf, RadixTrie} from './radix-trie'
import {fireDeterminedAction, expandHotkeyToEdges, isFormField} from './utils'
import eventToHotkeyString from './hotkey'
import SequenceTracker from './sequence'

const hotkeyRadixTrie = new RadixTrie<HTMLElement>()
const elementsLeaves = new WeakMap<HTMLElement, Array<Leaf<HTMLElement>>>()
let currentTriePosition: RadixTrie<HTMLElement> | Leaf<HTMLElement> = hotkeyRadixTrie
let resetTriePositionTimer: number | null = null
let path: string[] = []

function resetTriePosition() {
path = []
resetTriePositionTimer = null
currentTriePosition = hotkeyRadixTrie
}
const sequenceTracker = new SequenceTracker({
onReset() {
currentTriePosition = hotkeyRadixTrie
}
})

function keyDownHandler(event: KeyboardEvent) {
if (event.defaultPrevented) return
Expand All @@ -22,19 +21,15 @@ function keyDownHandler(event: KeyboardEvent) {
if (!target.id) return
if (!target.ownerDocument.querySelector(`[data-hotkey-scope="${target.id}"]`)) return
}
if (resetTriePositionTimer != null) {
window.clearTimeout(resetTriePositionTimer)
}
resetTriePositionTimer = window.setTimeout(resetTriePosition, 1500)

// If the user presses a hotkey that doesn't exist in the Trie,
// they've pressed a wrong key-combo and we should reset the flow
const newTriePosition = (currentTriePosition as RadixTrie<HTMLElement>).get(eventToHotkeyString(event))
if (!newTriePosition) {
resetTriePosition()
sequenceTracker.reset()
return
}
path.push(eventToHotkeyString(event))
sequenceTracker.registerKeypress(eventToHotkeyString(event))

currentTriePosition = newTriePosition
if (newTriePosition instanceof Leaf) {
Expand All @@ -53,11 +48,11 @@ function keyDownHandler(event: KeyboardEvent) {
}

if (elementToFire && shouldFire) {
fireDeterminedAction(elementToFire, path)
fireDeterminedAction(elementToFire, sequenceTracker.path)
event.preventDefault()
}

resetTriePosition()
sequenceTracker.reset()
}
}

Expand Down
42 changes: 42 additions & 0 deletions src/sequence.ts
@@ -0,0 +1,42 @@
interface SequenceTrackerOptions {
onReset?: () => void
}

export default class SequenceTracker {
static readonly CHORD_TIMEOUT = 1500

private _path: readonly string[] = []
private timer: number | null = null
private onReset

constructor({onReset}: SequenceTrackerOptions = {}) {
this.onReset = onReset
}

get path(): readonly string[] {
return this._path
}

registerKeypress(hotkey: string): void {
this._path = [...this._path, hotkey]
this.startTimer()
}

reset(): void {
this.killTimer()
this._path = []
this.onReset?.()
}

private killTimer(): void {
if (this.timer != null) {
window.clearTimeout(this.timer)
}
this.timer = null
}

private startTimer(): void {
this.killTimer()
this.timer = window.setTimeout(() => this.reset(), SequenceTracker.CHORD_TIMEOUT)
}
}
2 changes: 1 addition & 1 deletion src/utils.ts
Expand Up @@ -18,7 +18,7 @@ export function isFormField(element: Node): boolean {
)
}

export function fireDeterminedAction(el: HTMLElement, path: string[]): void {
export function fireDeterminedAction(el: HTMLElement, path: readonly string[]): void {
const delegateEvent = new CustomEvent('hotkey-fire', {cancelable: true, detail: {path}})
const cancelled = !el.dispatchEvent(delegateEvent)
if (cancelled) return
Expand Down

0 comments on commit aa78cee

Please sign in to comment.