2
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?

ズームショートカット、Windowsでは一発で動いた。でもMacの日本語キーボードだけ効かなくて沼った話

2
Last updated at Posted at 2026-06-25

初めに

  • Tauri + React 製のデスクトップ Markdown エディタに「⌘ +/⌘ -/⌘ 0」のズームショートカットを実装した
  • Windows では普通に動くのに、macOS の JISキーボードだけ ⌘ +(拡大)が効かないという報告が来た
  • 原因は1つではなく 3つが重なっていた
    1. JISキーボードは + =物理キー位置がUSと違う
    2. macOS の WebKit は Command 押下中、event.key が「Shift前の文字」を返す
    3. (これが本当の罠)isComposing チェックが、日本語IME入力中にショートカットを丸ごと飲んでいた
  • 解決は event.key 依存をやめて event.code + shiftKey で判定し、isComposing ガードを外すこと
  • 最終的に Mac × JISキーボードでも ⌘ +⌘ -⌘ 0 が全部動くようになった

やりたかったこと

ブラウザやエディタでお馴染みの、画面ズームの3点セットです。

ショートカット 動作
⌘ +Ctrl + 拡大
⌘ -Ctrl - 縮小
⌘ 0Ctrl 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配列とJIS配列の「+」「=」「-」キーと event.code の比較

同じ「+」でも、US は Equal キー、JIS は Semicolon キー。event.code が変わるのがハマりどころです。

JISキーボードで + = - を打つと、物理キー(= event.code)はこうなっています。

文字 JISでの打ち方 物理キー(= event.code)
+ Shift + 「れ」キー Semicolon
= Shift + 「ほ」キー Minus
- 「ほ」キー(Shiftなし) Minus

つまり JIS で ⌘ + を押すと、飛んでくる event.codeEqual ではなく 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.codeEqual でもなく、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.isComposingtrue になることがある
  • すると 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つです。

  1. isComposing ガードを撤去(原因③)。hasPrimaryModifier(⌘/Ctrl の確認)が先に効くので、修飾キーなしの素の入力を拾う心配はありません。
  2. event.code + shiftKey を主軸に(原因①②)。JIS の +Semicolon+Shift)、=Minus+Shift)を物理キーで拾います。
  3. 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 を起点に考えると、だいぶ見通しが良くなりました。

2
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
2
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?