0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【shortcut.js】JSでキーボードショートカットを入れる

Last updated at Posted at 2025-07-15

shortcut.jsをつかってキーボードショートカットを入れる方法。
ハンドラー関数の使い方をよく忘れるのでそれも一緒に書きました。

目次

shortcut.js

基本機能

基本的には以下のような関数(registerSafeShortcut 関数)を自作して保存し,これを用いてショートカットを付与していくのが安全です。以下の関数の内容は本頁を見れば簡単に分かります。

registerSafeShortcut 関数
function registerSafeShortcut(key, handler, options = {}) {
  shortcut.remove(key);
  shortcut.add(key, handler, {
    ...options,
    disable_in_input: true,
  });
}

// 使用例
registerSafeShortcut("Ctrl+S", (e) => handleSave(context, e));

最初にバインドされているショートカットをクリアしてから登録します。文字入力中はショートカットが発火しません。

ショートカットの追加

shortcut.add(shortcut_combination, callback, options)

指定したショートカットキーに対応する関数を登録します。ショートカットの削除(後述)で初期化してから使うのをお勧めします。(上記のregisterSafeShortcut 関数にはすでに入っています)

引数

引数 説明
shortcut_combination String "Ctrl+A""Shift+1" のような形式のショートカットキー文字列
callback Function 指定のショートカットが押されたときに実行される関数
options(省略可) Object カスタマイズ用オプション(下記参照)

サポートされるオプション

オプション名 デフォルト値 説明
type String 'keydown' イベント種類: 'keydown', 'keyup', 'keypress'
disable_in_input Boolean false input / textarea で無効にする
target DOM ノード document イベント監視対象のエレメント
propagate Boolean false イベントをバブリングさせるか否か
keycode Number false 特定のキーコードを指定してハンドリングする

使用例

例1: Ctrl+B でアラート表示(伝播:あり)
shortcut.add("Ctrl+B", function() {
  alert("Bold!");
}, {
  type: "keydown",
  propagate: true
});
例2: Shift+1 を押したら console に出力
shortcut.add("Shift+1", function() {
  console.log("Shift + 1 pressed!");
});
例3: 要素に入力中のときは動作しないようにする(例:'1')
shortcut.add("1", function() {
  alert("1 pressed outside of inputs");
}, {
  disable_in_input: true
});
例3.1: 要素に入力中のときは動作しない関数をより効率的に実装

複数のショートカットで毎回 disable_in_input: true を書くのは冗長なので,その場合は共通処理をラップして使うと便利。

// 共通ラッパー関数で disable_in_input を常時適用
function registerShortcut(key, callback, options = {}) {
  shortcut.add(key, callback, {
    ...options,
    disable_in_input: true
  });
}

// 使用例
registerShortcut("Ctrl+B", () => alert("Bold!"));
registerShortcut("Alt+S", () => alert("Save!"));
registerShortcut("Shift+1", () => alert("Exclamation!"));
例3.2: contenteditable も対象にしたい場合

shortcut.js の内部ソースを書き換えてください。(40行目)ちなみにオープンソースなので自由に書き換え可。

shortcut.js
  if (
    element.tagName === 'INPUT' ||
    element.tagName === 'TEXTAREA'
+   || element.isContentEditable
  ) return;
例4: 特定の DOM 要素内だけで有効化
shortcut.add("Alt+Enter", function(){
  alert("Alt + Enter pressed on a specific DIV");
}, {
  target: document.getElementById("myDiv")
});
例5: マウスクリックでもショートカットでも発火させたい

ハンドラー関数をショートカットでも呼び出す場合。

// ハンドラー関数
function goToNext() {
  console.log("Ctrl + Right pressed!");
}

// ボタンクリック時の処理に登録
document.getElementById("myDiv").onclick = goToNext;

// Ctrl + 右矢印キーでも同じ関数を呼び出す
shortcut.add("Ctrl+Right", goToNext, { disable_in_input: true });
例5.1: 将来的にハンドラーを差し替えたいとき

例5と動作は全く同じですが,次のように変数で関数を保持しておくとハンドラーを差し替えやすくなります。ユーザーがカスタムできるようにする機能を付けたいときなど。

// ハンドラー関数
function goToNext() {
  console.log("Ctrl + Right pressed!");
}

// 変数で関数を保持
let goToNextHandler = goToNext;

// ボタンクリック時の処理に登録してCtrl + 右矢印キーでも同じ関数を呼び出す
document.getElementById("myDiv").onclick = goToNextHandler;
shortcut.add("Ctrl+Right", goToNextHandler, { disable_in_input: true });
例5.2: ハンドラー関数に引数がある場合

同じ関数を「要素のイベントハンドラー」と「ショートカット」両方に登録し,かつ引数を渡したい場合,そのまま関数を渡してしまうと引数が渡せないため,工夫が必要になります。

構造は例5と基本的に同じですが,ラップ関数(匿名関数 or アロー関数)を使います。

// ハンドラー関数
function sendMessage(message) {
  console.log("Send:" + message);
}

// ダメな例
// ❌ 関数に引数付きで直接代入 → 呼び出し済みになってしまう
document.getElementById("btn").onclick = sendMessage("From Button");  
shortcut.add("Ctrl+Enter", sendMessage("From Shortcut"), {
  disable_in_input: true
});

// 良い例1
// ✅ ラップして引数付きで実行(アロー関数)
document.getElementById("btn").onclick = () => sendMessage("From Button");
shortcut.add("Ctrl+Enter", () => sendMessage("From Shortcut"), {
  disable_in_input: true
});

// 良い例2
// ✅ ラップして引数付きで実行(無名関数)
document.getElementById("btn").onclick = function() {
  sendMessage("From Button");
};
shortcut.add("Ctrl+Enter", function() {
  sendMessage("From Shortcut");
}, {
  disable_in_input: true
});

あるいは関数をネストする。

// 良い例3
// ✅ 関数をネストする。これもラップ関数の一部。

// 外側の関数がメッセージを受け取り,内側の関数がイベント時に呼ばれる
function createSendHandler(message) {
  return function() {
    sendMessage(message);
  };
}

// イベント登録
document.getElementById("btn").onclick = createSendHandler("From Button");

shortcut.add("Ctrl+Enter", createSendHandler("From Shortcut"), {
  disable_in_input: true
});
例5.3: 引数付きのハンドラー関数をより効率的に実装

基本はボタンとショートカットどちらも同じことをすると思います。
いちいち () => を書きたくないので変数で関数を保持してしまいます。 例5.1 と同じ要領です。

function sendMessage(label) {
  console.log("Send: " + label);
}

let messageText = "Hello, World!";

// ボタンとショートカット両方で同じ値を送信
const boundHandler = () => sendMessage(messageText);

document.getElementById("btn").onclick = boundHandler;

shortcut.add("Ctrl+Enter", boundHandler, {
  disable_in_input: true
});
例5.4: イベント引数の場合

後々分かりやすいように順を追って説明しておきます。
イベント引数の場合は関数を渡す際,引数を省略できます。JavaScriptのイベントリスナー機構が自動でイベントオブジェクト(e)を渡してくれるからです。

function handleClick(e) {
  console.log("event object:", e);
}

// ボタンにもショートカットにも同じ関数
document.getElementById('btn').onclick = handleClick;

shortcut.add("Ctrl+K", handleClick, {
  disable_in_input: true
});
例5.5: カスタム引数 + イベント引数の両方を渡す場合

当たり前ですが,今回は例5.4と違ってイベントオブジェクト(e)は省略できません。イベント引数だけではなくてカスタム引数が複数あっても同じように書けます。

function logAction(message, e) {
  console.log("message:", message);
  console.log("event:", e);
}

document.getElementById("btn").onclick = function(e) {
  logAction("from button: ", e);
};

shortcut.add("Ctrl+K", function(e) {
  logAction("from shortcut: ", e);
}, {
  disable_in_input: true
});

イベントリスナーやショートカットのコールバック関数の内部で DOM 要素自身(this)にアクセスしたい場合は,アロー関数ではなく 無名関数(function式) を使う必要があります。なぜなら,アロー関数では this がレキシカルに束縛されており,イベント発火元の要素を指さないためです。

先ほどの例ではショートカットを使う際には this を使いたくなる場合もあると思いますので,アロー関数( () => )ではなく無名関数( = function(e) {...} )で実装しておきました。Reactやクラス外(レキシカルな this が重要な文脈)ではアローが必要です。

ちなみに this を使う例はこんな感じです。

function showMessage(message, e) {
  console.log("message:", message);
  console.log("event:", e);
  console.log("this:", this); // 無名関数なので `this` は DOM 要素を指す
  this.style.backgroundColor = "lightyellow"; // 発火元のボタンをハイライト
}

// ボタンクリック → this = #btn-info
document.getElementById("btn-info").onclick = function(e) {
  showMessage.call(this, "clicked: info button", e);
};

// キーボードショートカット → this = #btn-info に明示的にバインド
shortcut.add("Ctrl+I", function(e) {
  const btn = document.getElementById("btn-info");
  showMessage.call(btn, "shortcut: info button", e);
}, {
  disable_in_input: true
});
例5.6: ハンドラー関数の引数が複数あり,何回も呼び出す場合

例5.5ではいちいち = function(e) {...} を書きましたが,実際問題,プロジェクトが大きくなってくるとそんなことはやってられません。以下のようにカリー化して短縮することができます。

実装例2のような状況を想定しています。構造的に管理しやすく強靭にするには必須です。

function createHandler(message) {
  return function(e) {
    logAction(message, e);
  };
}

document.getElementById("btn").onclick = createHandler("from button");

shortcut.add("Ctrl+K", createHandler("from shortcut"), {
  disable_in_input: true
});

ショートカットの削除

shortcut.remove(shortcut_combination)

登録したショートカットを削除します。初期化でこれを忘れると

  1. 不要なショートカットの動作が続く
    例えば画面遷移やSPAのページ切り替え後,その画面固有であるはずのショートカットが残り続け,意図しないタイミングでコールバックが実行されてしまう。
  2. 同じショートカット登録のたび多重でイベントが発火
    既存のショートカット(例 Ctrl+B)を新しい状態で何度も add することになり,一度キーを押しただけで複数回コールバックが動いてしまう(ダブル処理・バグの元)。
  3. メモリリークやパフォーマンス劣化の温床
    ショートカットが残る=裏で不要なコールバックも残るので,状態遷移の多いSPAや長時間利用でユーザー体感が重くなる要因。
  4. ショートカットの関連性が崩れる
    画面ごとに異なるショートカットを割り当てている場合,前画面のショートカットが現在のUIと矛盾した動作を招く。

といいことはないのでこれは最初に行いましょう。

使用例

// 初期設定:削除してから
shortcut.remove("Ctrl+B");

// 登録
shortcut.add("Ctrl+B", function() {
  alert("Bold!");
});

よくある間違い

ケース1: 不要なショートカットが動作し続けてしまう
// ❌ ダメな例
// 画面A(初期化時)
function setupScreenA() {
  shortcut.add("Ctrl+B", function() {
    alert("Aの太字コマンド!");
  });
}

// 画面B(初期化時。A側ショートカットのremoveを忘れている)
function setupScreenB() {
  shortcut.add("Ctrl+B", function() {
    alert("Bの太字コマンド!");
  });
}

// ページ切替・SPA的な遷移
setupScreenA();
// ...何らかの画面遷移
setupScreenB();

結果

  • 「Ctrl+B」を押すと,A・B両方のコールバックが実行され,アラートが2回表示される
  • 本来はB側だけ発動してほしいのに,A側(不要なショートカット)が残存
ケース2: 多重登録によるバグ
// ❌ ダメな例
// 画面Aに戻るたびに setupScreenA が呼ばれる
function setupScreenA() {
  shortcut.add("Ctrl+B", function() {
    console.log("Bold in ScreenA");
  });
}

// ↓毎回呼ばれる想定
setupScreenA();
setupScreenA();
setupScreenA();

結果

  • 「Ctrl+B」1回で ログ出力が3回連続発生(コールバック3つ分すべてが発火)
  • 再登録のたびに「イベントリスナーの数 = 画面再生成回数」にどんどん増えていく
  • メモリリークやパフォーマンス劣化の温床になる

良い子はやらないようにしましょう...

実装例

実装例1: 文書の保存を Ctrl + S でもできるようにする

// 安全にショートカットを登録する自作ユーティリティ関数
function registerSafeShortcut(key, handler, options = {}) {
  shortcut.remove(key);
  shortcut.add(key, handler, {
    ...options,
    disable_in_input: true,
  });
}

// 保存処理ハンドラー
function handleSave(context, e) {
  e?.preventDefault();
  console.log(`${context.title} を保存中...`);
  // ここに保存処理を実装(例:API呼び出しやローカル保存など)
}

const context = { title: "文書A" };

// ボタンクリックイベント登録
document.getElementById("save-btn").onclick = (e) => handleSave(context, e);

// ショートカットの安全な登録
registerSafeShortcut("Ctrl+S", (e) => handleSave(context, e));

実装例2: 何のボタンを押したかを表示する

// 安全にショートカットを登録する自作ユーティリティ関数
function registerSafeShortcut(key, handler, options = {}) {
  shortcut.remove(key);
  shortcut.add(key, handler, {
    ...options,
    disable_in_input: true,
  });
}

function logAction(message, e) {
  console.log("message:", message);
  console.log("event:", e);
}

function createHandler(message) {
  return function (e) {
    logAction(message, e);
  };
}

const buttons = [
  { id: "btn-save", message: "save", shortcut: "Ctrl+S" },
  { id: "btn-delete", message: "delete", shortcut: "Ctrl+D" },
  { id: "btn-cancel", message: "cancel", shortcut: "Esc" },
];

buttons.forEach(({ id, message, shortcut }) => {
  const handler = createHandler(`Clicked: ${message}`);
  document.getElementById(id).onclick = handler;
  registerSafeShortcut(shortcut, handler);
});

ショートカット記述ルール

形式:

[Modifier+]+Key

サポートされている修飾キー (Modifier)

  • Ctrl
  • Alt
  • Shift
  • Meta(Mac の Command)

サポートされているキー

  • アルファベットAZ
  • 数字09
  • 記号- = [ ] ; ' , . / \ など
  • ファンクションキーF1F12
  • 特殊キー
キー エイリアス
Enter return
Esc escape
Backspace
Tab
Delete
Insert
Home
End
PageUp page_up, pu
PageDown page_down, pd
Space
上下左右キー up, down, left, right
CapsLock, NumLock, ScrollLock caps_lock, num_lock, scroll_lock

⚠️ キー名・修飾キーともに 大文字小文字の区別はありません

豆知識

Shift+数字 など一部の組み合わせが正しく判定されるための工夫

shortcut.jsの問題というよりJavaScript の keydown の仕様の問題です。以下はshortcut.jsをコードリーディングしたときに分かったこと。興味のある人だけで構いません。

問題の背景

そもそもJavaScript の keydown イベントで使える主なプロパティは

element.addEventListener('keydown', function(e) {
  console.log(e.key);     // 実際に入力される文字(Shift考慮)
  console.log(e.code);    // 物理キーボード上のキー名
  console.log(e.keyCode); // 数値(非推奨)
});

ここでユーザーが Shift+1 を押したときを見てみると

プロパティ 説明
e.key '!' 期待値とは違う → 1ではなく Shift による記号処理になる
e.code 'Digit1' ハードウェアキーの場所
e.keyCode 49 数字の "1" に該当(非推奨)

問題になる典型パターン

例えばこのコードでは "Shift+1" を「Shift と数字の 1 を押した」とは判定されず,1をシフトを押した結果である「!を押した」と判定されます。

const key = String.fromCharCode(e.keyCode).toLowerCase();
  • e.keyCode == 49"1" になる
  • だけど,Shift が押されてると実際には "!" が入力される
    "1" とは一致しないため,ショートカット判定に失敗

したがってshortcut.js の中で以下の対策が行われています。(56行目以降)

shortcut.js
var shift_nums = {
    "1":"!",
    "2":"@",
    "3":"#",
    "4":"$",
    ...
}

if(shift_nums[character] && e.shiftKey) {
  character = shift_nums[character]; 
  if(character == k) kp++;
}

このマッピングにより,「Shift+1」(="!")という事実を元に,本来のキー ("1") と一致させるための 補正ロジック を入れてます。

万が一の対策

より明確に判定したい場合は

document.addEventListener('keydown', (e) => {
  if (e.shiftKey && e.code === 'Digit1') {
    console.log('Shift+1 pressed');
  }
});

このように e.code(=物理キー位置)を使って処理すると,Shiftによる変換によらず,元のキーそのものを検知できます。豆知識でした。

(補足)shortcut.js 内部構造

shortcut = {
  all_shortcuts: {}, // 全ショートカット登録情報

  add: function(combination, func, opt) { ... },   // 登録

  remove: function(combination) { ... }            // 削除
}

(補足)BSDライセンスについて

BSDライセンスは,ソフトウェアの自由な利用・改変・再配布を認める,非常に寛容なオープンソースライセンスです。元々はカリフォルニア大学バークレー校で開発されたUNIX系システムの配布条件として策定され,その後多くのオープンソースソフトウェアに採用されています。

便利なツール

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?