DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Electron Adventures: Episode 39: Keyboard Shortcut Modifier Keys

For simplicity up to this point I was only supporting single key shortcuts. Of course keyboards don't have enough keys, so a lot of shortcuts are made up of base key plus a a number of "modifier keys".

What we're going to do in this episode:

  • support for modifier keys
  • support for multiple key shortcuts per command
  • display that information in command palette

How modifier keys work

Modifier keys is one area where operating systems never converged. Most obviously they have different names (Alt vs Option; Command vs Meta vs Windows), and OSX also has icons for them, but I'm not sure if that should be used as they have poor recognizability, and most external keyboards will use names not icons.

Windows Control can map to OSX Control, or OSX Command, depending on context. Windows has Right Alt and Left Alt used for different purposes - and it's not unheard of to map both shifts or controls differently too.

Browsers - and therefore Electron - don't really expose this fully. As far as browsers are concerned, there's 4 modifier keys - Alt, Control, Shift, and Meta, and every time you press any key, it will have those 4 flags set to true or false. If you want something fancier, like different functionality for left and right Control, you'll have to do it yourself. Fortunately we don't need this here, and this 4 modifier model is good enough for most apps.

To do modifiers properly, you'll need to write different shortcuts for OSX and Windows, but that's tiny part of your codebase, and generally not a big deal.

I'll be using OSX conventions here to keep the code simple.

src/commands.js

I replaced single one-key shortcut key: "F10" by an array of possible shortcuts, like shortcuts: [{key: "F2"}, {key: "P", cmd: true, shift: true}].
This unfortunately means the commands no longer fit on a single line, but I can live with it.

In a somewhat fancier app, we'd either have osxShortcuts and windowsShortcuts are separate fields, or just have the whole mapping come from some user-editable settings file.

export default [
  {
    shortcuts: [{key: "F2"}, {key: "P", cmd: true, shift: true}],
    action: ["app", "openPalette"]
  },
  {
    name: "Close Palette",
    shortcuts: [{key: "Escape"}],
    action: ["app", "closePalette"],
  },
  {
    name: "Enter Directory",
    shortcuts: [{key: "Enter"}],
    action: ["activePanel", "activateItem"],
  },
  {
    name: "Flip Selection",
    shortcuts: [{key: " "}],
    action: ["activePanel", "flipItem"],
  },
  {
    name: "Go to First File",
    shortcuts: [{key: "Home"}],
    action: ["activePanel", "firstItem"],
  },
  {
    name: "Go to Last File",
    shortcuts: [{key: "End"}],
    action: ["activePanel", "lastItem"],
  },
  {
    name: "Go to Next File",
    shortcuts: [{key: "ArrowDown"}, {key: "N", ctrl: true}],
    action: ["activePanel", "nextItem"],
  },
  {
    name: "Go to Previous File",
    shortcuts: [{key: "ArrowUp"}, {key: "P", ctrl: true}],
    action: ["activePanel", "previousItem"],
  },
  {
    name: "Page Down",
    shortcuts: [{key: "PageDown"}],
    action: ["activePanel", "pageDown"],
  },
  {
    name: "Page Up",
    shortcuts: [{key: "PageUp"}],
    action: ["activePanel", "pageUp"],
  },
  {
    name: "Quit",
    shortcuts: [{key: "F10"}],
    action: ["app", "quit"],
  },
  {
    name: "Switch Panel",
    shortcuts: [{key: "Tab"}],
    action: ["app", "switchPanel"],
  },
]
Enter fullscreen mode Exit fullscreen mode

src/Keyboard.svelte

<script>
  import commands from "./commands.js"
  import { getContext } from "svelte"

  export let active

  let { eventBus } = getContext("app")

  function matchingShortcut(e, shortcut) {
    return (
      (shortcut.key.toLowerCase() === e.key.toLowerCase()) &&
      ((!!shortcut.ctrl) === e.ctrlKey) &&
      ((!!shortcut.alt) === e.altKey) &&
      ((!!shortcut.shift) === e.shiftKey) &&
      ((!!shortcut.cmd) === e.metaKey)
    )
  }

  function handleKey(e) {
    if (!active) {
      return
    }
    for (let command of commands) {
      for (let shortcut of command.shortcuts) {
        if (matchingShortcut(e, shortcut)) {
          e.preventDefault()
          e.stopPropagation()
          eventBus.emit(...command.action)
          return
        }
      }
    }
  }
</script>

<svelte:window on:keydown={handleKey} />
Enter fullscreen mode Exit fullscreen mode

A keydown event matches command, if key is the same, and all four modifier flags are the same.

We do !! trick so we can say {key: "N", ctrl: true} and not {key: "N", ctrl: true, alt: false, cmd: false, shift: false}.

There's a small surprise here, the .toLowerCase(). Not doing that is actually a somewhat common bug in apps on all platform I'm seeing even today.

When you type Cmd-N, your browser will emit {cmd: true, key: 'n'} (lowercase). But when you do Cmd-Shift-N, then browser will do {cmd: true, shift: true, key: 'N'} (uppercase). That's annoying thing to consisder, but most apps get that far.

The usual bug is that when CapsLock is pressed, what you get for Cmd-N, your browser will emit {cmd: true, key: 'N'} (uppercase), and for Cmd-Shift-N you might get {cmd: true, shift: true, key: 'N'} or {cmd: true, shift: true, key: 'n'} depending on system. Shortcuts breaking when CapsLock is pressed is a very common bug, and we can avoid it with this one line fix.

src/CommandPaletteEntry.svelte

<script>
  import Shortcut from "./Shortcut.svelte"
  import { getContext } from "svelte"
  let { eventBus } = getContext("app")

  export let name
  export let match = undefined
  export let shortcuts = []
  export let action

  function handleClick() {
    eventBus.emit("app", "closePalette")
    eventBus.emit(...action)
  }
</script>

<li on:click={handleClick}>
  <span class="name">
    {#if match}
      {#each match as [part, highlight]}
        {#if highlight}
          <em>{part}</em>
        {:else}
          {part}
        {/if}
      {/each}
    {:else}
      {name}
    {/if}
  </span>
  {#each shortcuts as shortcut}
    <Shortcut {...shortcut} />
  {/each}
</li>

<style>
  li {
    display: flex;
    padding: 0px 8px;
  }
  li:first-child {
    background-color: #66b;
  }
  .name {
    flex: 1;
  }
  .name em {
    color: #ff2;
    font-weight: bold;
    font-style: normal;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

CommandPaletteEntry component simply outsources the job of displaying shortcuts to Shortcut component, passing all props throuh a splat.

src/Shortcut.svelte

<script>
  export let alt = false
  export let cmd = false
  export let ctrl = false
  export let shift = false
  export let key

  function keyName(key) {
    if (key === " ") {
      return "Space"
    } else {
      return key
    }
  }
</script>

<span class="shortcut">
  {#if alt}
    <span class="key">Alt</span>
  {/if}
  {#if cmd}
    <span class="key">Cmd</span>
  {/if}
  {#if ctrl}
    <span class="key">Ctrl</span>
  {/if}
  {#if shift}
    <span class="key">Shift</span>
  {/if}
  <span class="key">{keyName(key)}</span>
</span>

<style>
  .shortcut {
    display: flex;
    margin-left: 8px;
  }
  .key {
    display: inline-block;
    background-color: hsl(180,100%,30%);
    padding: 2px;
    border: 1px solid  hsl(180,100%,20%);
    border-radius: 20%;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

And finally we display each shortcut. As we want to support shortcuts like {key: "N", ctrl: true}, we need to provide default values for all the potentially missing props. Otherwise Svelte would generate warnings in console in development mode (not in production mode).

There's a bit of nested flexbox styling here, so keys in the same shortcuts are together, but keys in multiple shortcuts for the same command are separated by a bit of space. You can see this on the screenshot below.

This code could use OSX modifier symbols instead of their names, but I think it's more obvious this way.

Result

Here's the results:

Episode 39 Screenshot

In the next episode, we'll take a small side trip and explore other ways to route events.

As usual, all the code for the episode is here.

Discussion (0)