初めに
- Tauri + React 製のデスクトップ Markdown エディタに「⌘ +/⌘ -/⌘ 0」のズームショートカットを実装した
-
Windows では普通に動くのに、macOS の JISキーボードだけ
⌘ +(拡大)が効かないという報告が来た - 原因は1つではなく 3つが重なっていた
- JISキーボードは
+=の物理キー位置がUSと違う - macOS の WebKit は Command 押下中、
event.keyが「Shift前の文字」を返す - (これが本当の罠)
isComposingチェックが、日本語IME入力中にショートカットを丸ごと飲んでいた
- JISキーボードは
- 解決は
event.key依存をやめてevent.code+shiftKeyで判定し、isComposingガードを外すこと - 最終的に Mac × JISキーボードでも
⌘ +/⌘ -/⌘ 0が全部動くようになった
やりたかったこと
ブラウザやエディタでお馴染みの、画面ズームの3点セットです。
| ショートカット | 動作 |
|---|---|
⌘ +(Ctrl +) |
拡大 |
⌘ -(Ctrl -) |
縮小 |
⌘ 0(Ctrl 0) |
リセット(100%) |
実装としては keydown を拾って、ストアの zoom 値を増減するだけ……のはずでした。
case 'view.zoomIn':
store.setZoom(Math.min(300, store.zoom + 10))
break
case 'view.zoomOut':
store.setZoom(Math.max(50, store.zoom - 10))
break
case 'view.zoomReset':
store.setZoom(100)
break
問題は「どのキーが押されたら拡大なのか」を判定するところでした。
最初の素直な実装
最初はこう書いていました。event.key(押した結果の文字)と event.code(物理キー)を両方見ています。
// 最初の実装(USキーボードなら動く)
if (event.code === 'Equal' || event.key === '=' || event.key === '+') {
runZoomIn()
} else if (event.code === 'Minus' || event.key === '-') {
runZoomOut()
} else if (event.key === '0') {
runZoomReset()
}
USキーボードだと + も = も同じ Equal キーなので、event.code === 'Equal' で一発。Windows でも問題なく動きます。
ところが、テスターから
Mac(日本語配列)だと
⌘ + Shift + +(拡大)が効かない。⌘ -と⌘ 0は効くのに。
という報告が来ました。ここから沼にハマります。
原因①:JISキーボードは + = の物理位置がUSと違う
まず大前提。event.code は「US配列での物理キー位置」を表す、レイアウト非依存の値です(W3C仕様)。ここを勘違いしていました。
同じ「+」でも、US は
Equalキー、JIS はSemicolonキー。event.codeが変わるのがハマりどころです。
JISキーボードで + = - を打つと、物理キー(= event.code)はこうなっています。
| 文字 | JISでの打ち方 | 物理キー(= event.code) |
|---|---|---|
+ |
Shift + 「れ」キー |
Semicolon |
= |
Shift + 「ほ」キー |
Minus |
- |
「ほ」キー(Shiftなし) | Minus |
つまり JIS で ⌘ + を押すと、飛んでくる event.code は Equal ではなく Semicolon なんです。最初のコードは Equal しか見ていないので、当然ヒットしません。
一方 ⌘ - は code === 'Minus' で拾えるので「縮小だけ効く」状態になっていた、というわけです。報告内容と完全に一致しました。
原因②:macOSのWebKitは Command 中 event.key が「Shift前」の文字になる
「じゃあ event.key === '+' のフォールバックがあるから拾えるのでは?」と思いますよね。私もそう思いました。
しかし macOS の WebKit(WKWebView)は、Command を押している間 event.key に「Shiftを適用する前のベース文字」を返すという挙動があります。
JIS で ⌘ + Shift + 「れ」(+を打つつもり)を押すと:
event.code = "Semicolon"
event.key = ";" // ← "+" ではない!(Command中はベース文字)
event.shiftKey = true
event.key は ; になってしまうので、event.key === '+' も '=' もヒットしません。event.code が Equal でもなく、event.key も文字として頼れない——これでズームインの判定が全滅していました。
Windows(WebView2)では Shift が event.key にちゃんと反映されて '+' になるので、こちらは動いていた。プラットフォーム差はここでした。
原因③:本当の落とし穴は isComposing だった
実は調査の途中で「event.code で JIS の Semicolon/Minus も拾うようにしたのに、それでも Mac で効かない。しかも今度は ⌘ - まで効かなくなった(デグレ)」という現象に当たりました。
犯人は、ショートカット判定の共通処理に入れていた この1行でした。
if (event.isComposing) return // ← 日本語IMEで全部巻き添えにする
isComposing は「IME変換中のキーイベント」を弾くためのガードで、普通の文字入力には正しいチェックです。が、⌘/Ctrl を伴うショートカットには有害でした。
- 日本語IMEを有効にしていると、
⌘系の組み合わせでもevent.isComposingがtrueになることがある - すると
returnでズームに限らず全ショートカットが握りつぶされる - Windowsユーザーは変換中でないことが多く
isComposing === falseなので、ここでも気づかれずにすり抜けていた
⌘/Ctrl 修飾キー付きの操作は、そもそもテキスト変換に参加しません。修飾キー付きショートカットの判定で isComposing を見てはいけない、というのが本質的な学びでした。
補足:このプロジェクトは日本語・英語・中国語の3言語対応で、CJK IME 周りには特に気を使う必要がありました。「IME中はショートカットを無効化」という一見正しいガードが、IMEユーザーだけを壊していたわけです。
解決:event.code + shiftKey で判定し、isComposing を外す
問題が3つとも見えたので、判定ロジックを純粋関数として切り出し、こう書き直しました。
export type ZoomShortcut = 'in' | 'out' | 'reset' | null
// keydown から主修飾キー(⌘/Ctrl)付きのズームショートカットを判定する。
//
// event.key ではなく event.code + shiftKey で見るのがポイント。
// macOS の WebKit は Command 押下中 event.key が「Shift前のベース文字」になり
// (Shift+= が '=' ではなく ';' になる等)、JIS では '+' が Semicolon キー、
// '=' が Minus キーに乗っているため。event.key は「正しく返すレイアウト」向けの
// フォールバックとしてのみ残す。isComposing は見ない(IME中も ⌘ 系は通す)。
export function matchZoomShortcut(event: ShortcutEvent, mac = isMacPlatform()): ZoomShortcut {
if (event.altKey || !hasPrimaryModifier(event, mac)) return null
const zoomIn =
event.code === 'Equal' || // US の '='/'+' キー
event.code === 'NumpadAdd' || // テンキーの '+'
((event.code === 'Semicolon' || event.code === 'Minus') && event.shiftKey) || // JIS の '+' / '='
event.key === '+' ||
event.key === '='
if (zoomIn) return 'in'
const zoomOut =
event.code === 'NumpadSubtract' ||
(event.code === 'Minus' && !event.shiftKey) || // Shiftなしの '-'
event.key === '-'
if (zoomOut) return 'out'
if (event.code === 'Digit0' || event.code === 'Numpad0' || event.key === '0') return 'reset'
return null
}
ポイントは3つです。
-
isComposingガードを撤去(原因③)。hasPrimaryModifier(⌘/Ctrl の確認)が先に効くので、修飾キーなしの素の入力を拾う心配はありません。 -
event.code+shiftKeyを主軸に(原因①②)。JIS の+(Semicolon+Shift)、=(Minus+Shift)を物理キーで拾います。 -
event.keyはフォールバックとして残す。Windows などevent.keyが正しく'+'/'='を返す環境を二重で担保します。
呼び出し側はシンプルになりました。
const zoomShortcut = matchZoomShortcut(event)
if (zoomShortcut === 'in') {
event.preventDefault()
runAppShortcutCommand('view.zoomIn')
} else if (zoomShortcut === 'out') {
event.preventDefault()
runAppShortcutCommand('view.zoomOut')
} else if (zoomShortcut === 'reset') {
event.preventDefault()
runAppShortcutCommand('view.zoomReset')
}
なぜ縮小と拡大が「同じ Minus キー」でも衝突しないのか
JIS では =(拡大寄り)も -(縮小)も同じ Minus キーです。ここは shiftKey で分岐しています。
-
Minus+ Shiftあり →=(JIS)→ 拡大 として扱う -
Minus+ Shiftなし →-→ 縮小
zoomIn を先に判定し、zoomOut 側の Minus を !event.shiftKey で限定しているので、取り違えは起きません。
テストで固定する
このロジックは「Macがなくても」ユニットテストで JIS の挙動を検証できるのが嬉しいところです(実機の event.code/event.key を再現するだけ)。Node 標準の node:test で固めました。
// JISキーボード × macOS の回帰テスト
test('JIS layout zoom-in works on macOS', () => {
// ⌘ + Shift + ';' => JIS の '+'。code は Semicolon
assert.equal(
matchZoomShortcut(createEvent({ metaKey: true, shiftKey: true, code: 'Semicolon', key: ';' }), true),
'in'
)
// ⌘ + Shift + '-' => JIS の '='。code は Minus
assert.equal(
matchZoomShortcut(createEvent({ metaKey: true, shiftKey: true, code: 'Minus', key: '-' }), true),
'in'
)
// ⌘ + '-'(Shiftなし)は縮小のまま
assert.equal(matchZoomShortcut(createEvent({ metaKey: true, code: 'Minus', key: '-' }), true), 'out')
})
まとめ
キーボードショートカットの「動かない」は、だいたい次のどれかでした。
-
event.keyに頼ると詰む。レイアウト依存だし、macOS は Command 中にベース文字を返す。ショートカットは原則event.code(物理キー)+ 修飾キーで判定する(VS Code も macOS ではe.codeでディスパッチしている)。 -
JISキーボードは記号の物理位置がUSと違う。
+はSemicolon、=はMinus。USしか想定していないと普通に踏み抜く。 -
isComposingを修飾キー付きショートカットに使わない。日本語IMEユーザーだけが静かに壊れる、見つけにくいバグになる。
「Windowsでは動くのにMacだけ動かない」は、たいてい キーレイアウト × OSのキーイベント挙動 × IME のどれかの掛け算です。event.key ではなく event.code を起点に考えると、だいぶ見通しが良くなりました。
