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).
| Keys | Action | Scope | Change |
|---|
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 });
}
});