Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add chord/sequence support to mapper tool #91

Merged
merged 9 commits into from
Sep 12, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/publish-pages.yml
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +22 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(kinda tangential but ...)

it would be nice if the action and local dev were in sync... but I guess we don't need to run locally from a _site directory. Maybe we just need instructions on running locally.

I've been running locally via npx serve . ... not sure about you? It might be nice to add serve as a dev dependency and add instructions in the README about running locally.

Copy link
Member Author

@iansan5653 iansan5653 Sep 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah that would be useful. I've been running npm run build and then serving with the VSCode live preview, which is a bit cumbersome, especially since build doesn't support a watch mode. Maybe in a future PR?

- 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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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 chord.</p>
<div class="position-relative">
<input
readonly
role="application"
aria-roledescription="Input Capture"
autofocus
aria-labelledby="app-name"
aria-describedby="hint chord-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 chord 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="chord-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 ChordTracker from '../dist/chord.js'

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

const chordTracker = new ChordTracker({
onReset() {
chordStatusElement.hidden = true
}
})

let currentChord = null

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

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

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

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

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

</html>
File renamed without changes.
42 changes: 42 additions & 0 deletions src/chord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
interface ChordTrackerOptions {
onReset?: () => void
}

export default class ChordTracker {
static readonly CHORD_TIMEOUT = 1500

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

constructor({onReset}: ChordTrackerOptions = {}) {
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(), ChordTracker.CHORD_TIMEOUT)
}
}
25 changes: 10 additions & 15 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import {Leaf, RadixTrie} from './radix-trie'
import {fireDeterminedAction, expandHotkeyToEdges, isFormField} from './utils'
import eventToHotkeyString from './hotkey'
import ChordTracker from './chord'

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 chordTracker = new ChordTracker({
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()
chordTracker.reset()
return
}
path.push(eventToHotkeyString(event))
chordTracker.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, chordTracker.path)
event.preventDefault()
}

resetTriePosition()
chordTracker.reset()
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
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
Loading