Feature 325 Interaction

Keyboard Shortcut ปุ่มลัด

Global shortcuts and a command palette; the shortcut list is stored in SQL Server and can be changed.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Press any key

Waiting for a key…

Shortcuts in the shortcut table

Change a key and press Save. The new key works at once (and after a reload, because it is in SQL Server).

KeysActionScopeChange

What to notice

  • One keydown listener matches combos (ctrl+k) and sequences (g then d).
  • Shortcuts are ignored while you type in an input.
  • The same rows feed the help sheet (press ?) and the key handler.

Try it

Source code

5 parts, read from the files in this project
src/main/java/com/study/uiux/service/ShortcutService.java Change a key, refusing one that is already taken
@Transactional
public Shortcut changeKeys(int id, String keys) {
    String normalized = keys.strip().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
    Shortcut shortcut = repository.findById(id).orElseThrow(() -> ApiException.notFound("Shortcut " + id));
    for (Shortcut other : repository.findAll()) {
        if (!other.getId().equals(id) && other.getKeys().equals(normalized)) {
            throw ApiException.badRequest("'" + normalized + "' is already used for: " + other.getDescription());
        }
    }
    shortcut.setKeys(normalized);
    return shortcut;
}
src/main/resources/db/migration/V1__schema.sql shortcut table: the help sheet and the key handler both read it
CREATE TABLE shortcut (
    id          INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_shortcut PRIMARY KEY,
    keys_text   NVARCHAR(30)  NOT NULL,
    action_code NVARCHAR(40)  NOT NULL CONSTRAINT uq_shortcut_action UNIQUE,
    description NVARCHAR(150) NOT NULL,
    scope       NVARCHAR(20)  NOT NULL,
    sort_order  INT           NOT NULL
);
src/main/resources/templates/layout.html Command palette (Ctrl + K) and the shortcut cheat sheet (?)
<dialog class="modal palette" id="paletteDialog" aria-label="Command palette">
  <div class="palette-search">
    <svg class="i"><use href="#i-search"/></svg>
    <input id="paletteInput" placeholder="Type a lesson name, number or command…" autocomplete="off"
           role="combobox" aria-expanded="true" aria-controls="paletteList">
    <kbd>Esc</kbd>
  </div>
  <ul class="palette-list" id="paletteList" role="listbox"></ul>
  <div class="palette-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Esc</kbd> close</span></div>
</dialog>

<dialog class="modal" id="helpDialog" aria-labelledby="helpTitle">
  <div class="modal-head">
    <h2 id="helpTitle">Keyboard shortcuts</h2>
    <button class="icon-btn" type="button" data-close aria-label="Close"><svg class="i"><use href="#i-x"/></svg></button>
  </div>
  <div class="modal-body"><table class="table shortcut-table" id="helpTable"></table></div>
  <p class="muted small modal-note">The list comes from the <code>shortcut</code> table. Change keys in lesson 325.</p>
</dialog>
src/main/resources/static/js/core.js One keydown listener: combos (ctrl+k), single keys (?) and sequences (g h)
const actions = {
  palette: () => palette.open(),
  search: () => palette.open(),
  help: () => help.open(),
  theme: () => prefs.toggleTheme(),
  sidebar: toggleSidebar,
  fullscreen: () => fullscreen.toggle(),
  'font-up': () => changeFont(10),
  'font-down': () => changeFont(-10),
  'go-home': () => { location.href = '/'; },
  next: () => { if (cfg.next) location.href = cfg.next; },
  prev: () => { if (cfg.prev) location.href = cfg.prev; }
};

function comboOf(e) {
  let key = e.key.toLowerCase();
  if (key === ' ') key = 'space';
  const parts = [];
  if (e.ctrlKey || e.metaKey) parts.push('ctrl');
  if (e.altKey) parts.push('alt');
  // Shift is part of the character for "?" or "+", so only count it for letters and named keys.
  if (e.shiftKey && (key.length > 1 || /[a-z]/.test(key))) parts.push('shift');
  if (e.altKey && e.code === 'Equal') key = '=';   // Alt changes e.key on some layouts
  if (e.altKey && e.code === 'Minus') key = '-';
  parts.push(key);
  return parts.join('+');
}

let sequence = null, sequenceTimer = null;
const keyListeners = [];
document.addEventListener('keydown', (e) => {
  if (['control', 'alt', 'shift', 'meta'].includes(e.key.toLowerCase())) return;
  const combo = comboOf(e);
  keyListeners.forEach((fn) => fn(combo, e));
  if (e.defaultPrevented) return;
  const typing = isTyping(e.target);
  if (typing && !combo.startsWith('ctrl+')) return;   // never steal keys while the user types
  if (document.querySelector('dialog[open]') && combo !== 'ctrl+k') return;
  const list = cfg.shortcuts || [];
  const wanted = sequence ? sequence + ' ' + combo : combo;
  const hit = list.find((s) => s.keys === wanted);
  if (hit && actions[hit.action]) {
    e.preventDefault();
    sequence = null;
    actions[hit.action]();
    return;
  }
  if (!sequence && list.some((s) => s.keys.startsWith(combo + ' '))) {
    sequence = combo;                                 // wait for the second key of "g h"
    clearTimeout(sequenceTimer);
    sequenceTimer = setTimeout(() => { sequence = null; }, 1200);
    return;
  }
  sequence = null;
  if (e.key === 'Escape') { setDrawer(false); setAppearance(false); contextMenu.close(); }
});
src/main/resources/static/js/demos.js Save a new key with PUT /api/shortcuts/{id}
$('#scRows').addEventListener('submit', async (e) => {
  e.preventDefault();
  const tr = e.target.closest('tr'), keys = e.target.keys.value;
  try {
    await api('/api/shortcuts/' + tr.dataset.id, { method: 'PUT', body: { keys } });
    toast({ type: 'success', title: 'Shortcut saved', message: `Now press "${keys.trim().toLowerCase()}".` });
    render();
  } catch (err) {
    toast({ type: 'error', title: 'Not saved', message: err.data?.errors?.keys || err.message });
  }
});

Are you sure?

Rename

↑↓ moveEnter openEsc close