From 4e33182b2a4e2639e8fc982ed2203e076dfb55b9 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:52:37 -0700 Subject: [PATCH 1/4] feat(settings): Ctrl/Cmd + and - step the window zoom --- src/components/appearance-provider.tsx | 34 +++++++++++++++++++++- src/i18n/messages/ar.json | 12 ++++++++ src/i18n/messages/de.json | 12 ++++++++ src/i18n/messages/en.json | 14 ++++++++- src/i18n/messages/es.json | 12 ++++++++ src/i18n/messages/fr.json | 12 ++++++++ src/i18n/messages/ja.json | 12 ++++++++ src/i18n/messages/ko.json | 12 ++++++++ src/i18n/messages/pt.json | 12 ++++++++ src/i18n/messages/zh-CN.json | 12 ++++++++ src/i18n/messages/zh-TW.json | 12 ++++++++ src/lib/keyboard-shortcuts.test.ts | 34 ++++++++++++++++++++++ src/lib/keyboard-shortcuts.ts | 40 ++++++++++++++++++++++++++ src/lib/theme-presets.test.ts | 13 +++++++++ src/lib/theme-presets.ts | 8 ++++++ 15 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 src/lib/theme-presets.test.ts diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 4cdebc977..88f89a92d 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -15,6 +15,7 @@ import { ZOOM_LEVELS, DEFAULT_ZOOM_LEVEL, type ZoomLevel, + stepZoom, } from "@/lib/theme-presets" import { resolveFontStack, @@ -65,7 +66,11 @@ import { type CustomThemeToken, } from "@/lib/custom-style" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" -import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" +import { + isZoomInShortcutEvent, + isZoomOutShortcutEvent, + matchShortcutEvent, +} from "@/lib/keyboard-shortcuts" import { DEFAULT_WORKSPACE_BG_ENABLED, DEFAULT_WORKSPACE_BG_MASK_OPACITY, @@ -472,6 +477,8 @@ export function AppearanceProvider({ syncTrafficLightPosition(zoom) persist(STORAGE_KEY_ZOOM_LEVEL, String(zoom)) }, []) + const zoomLevelRef = useRef(zoomLevel) + zoomLevelRef.current = zoomLevel const setShowWelcomeQuickActions = useCallback((on: boolean) => { setShowWelcomeQuickActionsState(on) @@ -744,6 +751,7 @@ export function AppearanceProvider({ // 或某个组件吞掉了冒泡,这一路依然能把自定义样式整体停用。 const { shortcuts } = useShortcutSettings() const toggleCustomStyleShortcut = shortcuts.toggle_custom_style + const zoomResetShortcut = shortcuts.zoom_reset useEffect(() => { if (!toggleCustomStyleShortcut) return const onKeyDown = (event: KeyboardEvent) => { @@ -756,6 +764,30 @@ export function AppearanceProvider({ return () => window.removeEventListener("keydown", onKeyDown, true) }, [toggleCustomStyleShortcut, customStyleSuspended, setCustomStyleSuspended]) + // Same rungs as Settings → Window zoom. Capture-phase so the webview + // does not eat Ctrl/Cmd +/- as its own page zoom. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.repeat) return + if (isZoomInShortcutEvent(event)) { + event.preventDefault() + setZoomLevel(stepZoom(zoomLevelRef.current, 1)) + return + } + if (isZoomOutShortcutEvent(event)) { + event.preventDefault() + setZoomLevel(stepZoom(zoomLevelRef.current, -1)) + return + } + if (zoomResetShortcut && matchShortcutEvent(event, zoomResetShortcut)) { + event.preventDefault() + setZoomLevel(DEFAULT_ZOOM_LEVEL) + } + } + window.addEventListener("keydown", onKeyDown, true) + return () => window.removeEventListener("keydown", onKeyDown, true) + }, [setZoomLevel, zoomResetShortcut]) + // 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进 useEffect(() => { const FONT_KEYS = new Set([ diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 65b8e461a..d500a79e4 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "إيقاف/استئناف النمط المخصص", "description": "مخرج طوارئ: يوقف كل الألوان المخصصة وCSS، ويعيد تفعيلها" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index e32ee3900..439c9a575 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "Eigenen Stil aussetzen/fortsetzen", "description": "Notausstieg: schaltet alle eigenen Farben und CSS aus und wieder ein" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 12125059a..3535f5b62 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Window zoom", - "sectionDescription": "Scale the entire interface. Applies immediately and persists per device.", + "sectionDescription": "Scale the entire interface. Applies immediately and persists per device. Ctrl + and Ctrl - step the same rungs as this menu (Ctrl 0 resets to 100%).", "placeholder": "Select zoom level", "default": "Default", "current": "Current zoom: {zoom}%" @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "Suspend/resume custom style", "description": "Escape hatch: turns all custom colors and CSS off, and back on" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 217fdc962..9c21a4ce3 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "Suspender/reanudar estilo personalizado", "description": "Vía de escape: desactiva todos los colores y el CSS personalizados, y los vuelve a activar" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 521ed0381..bd84a7ddd 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "Suspendre/réactiver le style personnalisé", "description": "Issue de secours : désactive toutes les couleurs et le CSS personnalisés, puis les réactive" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 29b0d7617..9f3debcb0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "カスタムスタイルの停止/再開", "description": "緊急脱出用: カスタム配色と CSS をすべてオフにし、再度押すと元に戻します" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index f9d54f976..0db7fb00d 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "사용자 지정 스타일 중지/재개", "description": "비상 탈출구: 모든 사용자 지정 색상과 CSS를 끄고, 다시 누르면 되돌립니다" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index abd237609..1e58924c9 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "Suspender/retomar estilo personalizado", "description": "Saída de emergência: desliga todas as cores e o CSS personalizados e volta a ligá-los" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f00b67206..aa667ce2f 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "停用/恢复自定义样式", "description": "逃生舱:一键关闭全部自定义配色与 CSS,再按一次恢复" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 182ffd220..5a55e2d8d 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -415,6 +415,18 @@ "toggle_custom_style": { "title": "停用/恢復自訂樣式", "description": "逃生艙:一鍵關閉全部自訂配色與 CSS,再按一次恢復" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index d32d86949..6dfd0985c 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest" import { DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, + isZoomInShortcutEvent, + isZoomOutShortcutEvent, matchShortcutEvent, shortcutFromKeyboardEvent, } from "./keyboard-shortcuts" @@ -117,3 +119,35 @@ describe("alt combinations use event.code", () => { ).toBe(true) }) }) + +describe("window zoom shortcuts", () => { + it("registers zoom_in / zoom_out / zoom_reset defaults", () => { + const ids = SHORTCUT_DEFINITIONS.map((definition) => definition.id) + expect(ids).toContain("zoom_in") + expect(ids).toContain("zoom_out") + expect(ids).toContain("zoom_reset") + expect(DEFAULT_SHORTCUTS.zoom_in).toBe("mod+=") + expect(DEFAULT_SHORTCUTS.zoom_out).toBe("mod+-") + expect(DEFAULT_SHORTCUTS.zoom_reset).toBe("mod+0") + }) + + it("treats Ctrl+= and Ctrl++ as zoom in", () => { + expect( + isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true })) + ).toBe(true) + expect( + isZoomInShortcutEvent(keyEvent("+", { ctrlKey: true, shiftKey: true })) + ).toBe(true) + expect(isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(true) + expect(isZoomInShortcutEvent(keyEvent("k", { ctrlKey: true }))).toBe(false) + }) + + it("treats Ctrl+- as zoom out", () => { + expect( + isZoomOutShortcutEvent(keyEvent("-", { ctrlKey: true })) + ).toBe(true) + expect(isZoomOutShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe( + false + ) + }) +}) diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index 342995d88..7d8d1ad00 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -15,6 +15,9 @@ export type ShortcutActionId = | "send_message" | "newline_in_message" | "toggle_custom_style" + | "zoom_in" + | "zoom_out" + | "zoom_reset" export interface ShortcutDefinition { id: ShortcutActionId @@ -69,6 +72,15 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ { id: "toggle_custom_style", }, + { + id: "zoom_in", + }, + { + id: "zoom_out", + }, + { + id: "zoom_reset", + }, ] /** Actions that allow shortcuts without modifier keys (e.g. plain Enter). */ @@ -98,6 +110,11 @@ export const DEFAULT_SHORTCUTS: ShortcutSettings = { // 自定义样式的逃生舱:用户把界面改到不可用时,这一路必须仍然按得动,所以选一个 // 三修饰键组合(不会与任何常用操作撞车),并在捕获阶段监听。 toggle_custom_style: "mod+alt+shift+s", + // Same rungs as Settings → Window zoom. `=` is what US keyboards fire for + // Ctrl/+ without Shift; `+` is Shift+= and the numpad. + zoom_in: "mod+=", + zoom_out: "mod+-", + zoom_reset: "mod+0", } export const SHORTCUTS_STORAGE_KEY = "settings:shortcuts:v1" @@ -115,6 +132,9 @@ const SPECIAL_KEY_ALIASES: Record = { down: "arrowdown", left: "arrowleft", right: "arrowright", + add: "+", + subtract: "-", + equal: "=", } const KEY_LABELS: Record = { @@ -306,6 +326,26 @@ export function shortcutFromKeyboardEvent( return parts.join("+") } +/** Ctrl/Cmd + or = (with or without Shift). Matches the Settings zoom-in rung. */ +export function isZoomInShortcutEvent(event: ShortcutEventLike): boolean { + if (matchShortcutEvent(event, "mod+=") || matchShortcutEvent(event, "mod++")) { + return true + } + if (!(event.metaKey || event.ctrlKey) || event.altKey) return false + const key = eventKeyToken(event) + if (key === "+" || key === "=") return true + return event.code === "Equal" || event.code === "NumpadAdd" +} + +/** Ctrl/Cmd - (with or without Shift). Matches the Settings zoom-out rung. */ +export function isZoomOutShortcutEvent(event: ShortcutEventLike): boolean { + if (matchShortcutEvent(event, "mod+-")) return true + if (!(event.metaKey || event.ctrlKey) || event.altKey) return false + const key = eventKeyToken(event) + if (key === "-" || key === "_") return true + return event.code === "Minus" || event.code === "NumpadSubtract" +} + export function matchShortcutEvent( event: ShortcutEventLike, shortcut: string diff --git a/src/lib/theme-presets.test.ts b/src/lib/theme-presets.test.ts new file mode 100644 index 000000000..9ac742a17 --- /dev/null +++ b/src/lib/theme-presets.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest" + +import { DEFAULT_ZOOM_LEVEL, stepZoom } from "./theme-presets" + +describe("stepZoom", () => { + it("walks the Settings rungs and stops at the ends", () => { + expect(stepZoom(100, 1)).toBe(110) + expect(stepZoom(110, -1)).toBe(100) + expect(stepZoom(80, -1)).toBe(80) + expect(stepZoom(150, 1)).toBe(150) + expect(stepZoom(DEFAULT_ZOOM_LEVEL, 1)).toBe(110) + }) +}) diff --git a/src/lib/theme-presets.ts b/src/lib/theme-presets.ts index 2d8a9a2b1..574ecbfb1 100644 --- a/src/lib/theme-presets.ts +++ b/src/lib/theme-presets.ts @@ -97,3 +97,11 @@ export const ZOOM_LEVELS = [80, 90, 100, 110, 125, 150] as const export type ZoomLevel = (typeof ZOOM_LEVELS)[number] export const DEFAULT_ZOOM_LEVEL: ZoomLevel = 100 + +/** Next discrete Settings zoom step. Stops at the first / last rung. */ +export function stepZoom(current: ZoomLevel, direction: 1 | -1): ZoomLevel { + const index = ZOOM_LEVELS.indexOf(current) + const from = index >= 0 ? index : ZOOM_LEVELS.indexOf(DEFAULT_ZOOM_LEVEL) + const next = Math.min(ZOOM_LEVELS.length - 1, Math.max(0, from + direction)) + return ZOOM_LEVELS[next] +} From 0c025bf6f67646434e2859b03ec492f900452feb Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:03:21 -0700 Subject: [PATCH 2/4] chore: format after rebase onto main --- src/lib/keyboard-shortcuts.test.ts | 12 +++--------- src/lib/keyboard-shortcuts.ts | 5 ++++- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index 6dfd0985c..2255894b1 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -132,9 +132,7 @@ describe("window zoom shortcuts", () => { }) it("treats Ctrl+= and Ctrl++ as zoom in", () => { - expect( - isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true })) - ).toBe(true) + expect(isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(true) expect( isZoomInShortcutEvent(keyEvent("+", { ctrlKey: true, shiftKey: true })) ).toBe(true) @@ -143,11 +141,7 @@ describe("window zoom shortcuts", () => { }) it("treats Ctrl+- as zoom out", () => { - expect( - isZoomOutShortcutEvent(keyEvent("-", { ctrlKey: true })) - ).toBe(true) - expect(isZoomOutShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe( - false - ) + expect(isZoomOutShortcutEvent(keyEvent("-", { ctrlKey: true }))).toBe(true) + expect(isZoomOutShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(false) }) }) diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index 7d8d1ad00..e5cf6a4fa 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -328,7 +328,10 @@ export function shortcutFromKeyboardEvent( /** Ctrl/Cmd + or = (with or without Shift). Matches the Settings zoom-in rung. */ export function isZoomInShortcutEvent(event: ShortcutEventLike): boolean { - if (matchShortcutEvent(event, "mod+=") || matchShortcutEvent(event, "mod++")) { + if ( + matchShortcutEvent(event, "mod+=") || + matchShortcutEvent(event, "mod++") + ) { return true } if (!(event.metaKey || event.ctrlKey) || event.altKey) return false From 1876641f5fa7002f73b37a743af1a0b8f5aa04f9 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:04:59 -0700 Subject: [PATCH 3/4] feat(settings): raise window zoom to 300% Same discrete rungs as Settings and Ctrl/Cmd +/-. Adds 175, 200, 250, and 300 so the top step is 300% instead of 150%. --- src/lib/appearance-script.ts | 2 +- src/lib/theme-presets.test.ts | 3 ++- src/lib/theme-presets.ts | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/appearance-script.ts b/src/lib/appearance-script.ts index a0bcf4d21..0ad7d1866 100644 --- a/src/lib/appearance-script.ts +++ b/src/lib/appearance-script.ts @@ -83,7 +83,7 @@ const SCRIPT = ` (function() { try { var VALID_COLORS = ["neutral","zinc","slate","stone","gray","red","rose","orange","green","blue","yellow","violet"]; - var VALID_ZOOMS = [80, 90, 100, 110, 125, 150]; + var VALID_ZOOMS = [80, 90, 100, 110, 125, 150, 175, 200, 250, 300]; var storedColor = localStorage.getItem("${STORAGE_KEY_THEME_COLOR}"); var color = VALID_COLORS.indexOf(storedColor) >= 0 ? storedColor : "neutral"; diff --git a/src/lib/theme-presets.test.ts b/src/lib/theme-presets.test.ts index 9ac742a17..467e1fd87 100644 --- a/src/lib/theme-presets.test.ts +++ b/src/lib/theme-presets.test.ts @@ -7,7 +7,8 @@ describe("stepZoom", () => { expect(stepZoom(100, 1)).toBe(110) expect(stepZoom(110, -1)).toBe(100) expect(stepZoom(80, -1)).toBe(80) - expect(stepZoom(150, 1)).toBe(150) + expect(stepZoom(150, 1)).toBe(175) + expect(stepZoom(300, 1)).toBe(300) expect(stepZoom(DEFAULT_ZOOM_LEVEL, 1)).toBe(110) }) }) diff --git a/src/lib/theme-presets.ts b/src/lib/theme-presets.ts index 574ecbfb1..30f109744 100644 --- a/src/lib/theme-presets.ts +++ b/src/lib/theme-presets.ts @@ -92,7 +92,9 @@ export const THEME_COLOR_PREVIEW: Record = { * 缩放档位(百分比)。100 是默认。 * 选用离散档位而非连续滑块,是为了与现有 ThemeMode 选择器保持视觉一致。 */ -export const ZOOM_LEVELS = [80, 90, 100, 110, 125, 150] as const +export const ZOOM_LEVELS = [ + 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, +] as const export type ZoomLevel = (typeof ZOOM_LEVELS)[number] From 27e8a8b3ed6e7c1b5cc651ac746cc2c8bba16e30 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:08:35 -0700 Subject: [PATCH 4/4] fix(settings): honor remapped zoom shortcuts and record + Zoom in/out now match the Settings bindings instead of a hardcoded Ctrl+= / Ctrl+-. Held keys repeat and still preventDefault. The integrated terminal keeps Ctrl+_, recording a shortcut no longer zooms the window, and + survives normalizeShortcut. --- src/components/appearance-provider.tsx | 46 ++-- src/components/settings/shortcut-settings.tsx | 7 + src/i18n/messages/ar.json | 14 +- src/i18n/messages/de.json | 14 +- src/i18n/messages/en.json | 6 +- src/i18n/messages/es.json | 14 +- src/i18n/messages/fr.json | 14 +- src/i18n/messages/ja.json | 14 +- src/i18n/messages/ko.json | 14 +- src/i18n/messages/pt.json | 14 +- src/i18n/messages/zh-CN.json | 14 +- src/i18n/messages/zh-TW.json | 14 +- src/lib/keyboard-shortcuts.test.ts | 113 ++++++++- src/lib/keyboard-shortcuts.ts | 227 +++++++++++------- 14 files changed, 348 insertions(+), 177 deletions(-) diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 88f89a92d..5b2d85481 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -67,9 +67,9 @@ import { } from "@/lib/custom-style" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { - isZoomInShortcutEvent, - isZoomOutShortcutEvent, + isShortcutRecorderArmed, matchShortcutEvent, + resolveWindowZoomAction, } from "@/lib/keyboard-shortcuts" import { DEFAULT_WORKSPACE_BG_ENABLED, @@ -478,7 +478,9 @@ export function AppearanceProvider({ persist(STORAGE_KEY_ZOOM_LEVEL, String(zoom)) }, []) const zoomLevelRef = useRef(zoomLevel) - zoomLevelRef.current = zoomLevel + useEffect(() => { + zoomLevelRef.current = zoomLevel + }, [zoomLevel]) const setShowWelcomeQuickActions = useCallback((on: boolean) => { setShowWelcomeQuickActionsState(on) @@ -751,6 +753,8 @@ export function AppearanceProvider({ // 或某个组件吞掉了冒泡,这一路依然能把自定义样式整体停用。 const { shortcuts } = useShortcutSettings() const toggleCustomStyleShortcut = shortcuts.toggle_custom_style + const zoomInShortcut = shortcuts.zoom_in + const zoomOutShortcut = shortcuts.zoom_out const zoomResetShortcut = shortcuts.zoom_reset useEffect(() => { if (!toggleCustomStyleShortcut) return @@ -764,29 +768,43 @@ export function AppearanceProvider({ return () => window.removeEventListener("keydown", onKeyDown, true) }, [toggleCustomStyleShortcut, customStyleSuspended, setCustomStyleSuspended]) - // Same rungs as Settings → Window zoom. Capture-phase so the webview + // Same levels as Settings → Window zoom. Capture-phase so the webview // does not eat Ctrl/Cmd +/- as its own page zoom. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.repeat) return - if (isZoomInShortcutEvent(event)) { - event.preventDefault() + if (event.isComposing) return + if (isShortcutRecorderArmed()) return + if ( + event.target instanceof Element && + event.target.closest('[data-terminal-panel-region="true"]') + ) { + return + } + + const action = resolveWindowZoomAction(event, { + zoom_in: zoomInShortcut, + zoom_out: zoomOutShortcut, + zoom_reset: zoomResetShortcut, + }) + if (!action) return + + // Match first, then preventDefault, including on repeats. A held + // key should walk the zoom levels, and in the browser the un- + // prevented repeat would also trigger the page's own zoom. + event.preventDefault() + if (action === "in") { setZoomLevel(stepZoom(zoomLevelRef.current, 1)) return } - if (isZoomOutShortcutEvent(event)) { - event.preventDefault() + if (action === "out") { setZoomLevel(stepZoom(zoomLevelRef.current, -1)) return } - if (zoomResetShortcut && matchShortcutEvent(event, zoomResetShortcut)) { - event.preventDefault() - setZoomLevel(DEFAULT_ZOOM_LEVEL) - } + setZoomLevel(DEFAULT_ZOOM_LEVEL) } window.addEventListener("keydown", onKeyDown, true) return () => window.removeEventListener("keydown", onKeyDown, true) - }, [setZoomLevel, zoomResetShortcut]) + }, [setZoomLevel, zoomInShortcut, zoomOutShortcut, zoomResetShortcut]) // 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进 useEffect(() => { diff --git a/src/components/settings/shortcut-settings.tsx b/src/components/settings/shortcut-settings.tsx index 97b3dfd9f..b928160d3 100644 --- a/src/components/settings/shortcut-settings.tsx +++ b/src/components/settings/shortcut-settings.tsx @@ -12,6 +12,7 @@ import { SHORTCUT_DEFINITIONS, type ShortcutActionId, formatShortcutLabel, + setShortcutRecorderArmed, shortcutFromKeyboardEvent, } from "@/lib/keyboard-shortcuts" import { Button } from "@/components/ui/button" @@ -53,6 +54,11 @@ export function ShortcutSettings() { [shortcuts] ) + useEffect(() => { + setShortcutRecorderArmed(Boolean(recordingAction)) + return () => setShortcutRecorderArmed(false) + }, [recordingAction]) + useEffect(() => { if (!recordingAction) return @@ -60,6 +66,7 @@ export function ShortcutSettings() { if (event.repeat) return event.preventDefault() event.stopPropagation() + event.stopImmediatePropagation() if (event.key === "Escape") { setRecordingAction(null) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d500a79e4..0811b58ba 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "تكبير النافذة", - "sectionDescription": "تكبير أو تصغير الواجهة بالكامل. يتم تطبيقه فوراً ويُحفظ لكل جهاز على حدة.", + "sectionDescription": "تكبير أو تصغير الواجهة بالكامل. يتم تطبيقه فوراً ويُحفظ لكل جهاز على حدة. ⌘/Ctrl + و - ينتقلان بين نفس مستويات التكبير في هذه القائمة (⌘/Ctrl 0 يعيد الضبط إلى 100%).", "placeholder": "اختر مستوى التكبير", "default": "افتراضي", "current": "التكبير الحالي: {zoom}%" @@ -417,16 +417,16 @@ "description": "مخرج طوارئ: يوقف كل الألوان المخصصة وCSS، ويعيد تفعيلها" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "تكبير", + "description": "اجعل النافذة أكبر بدرجة واحدة" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "تصغير", + "description": "اجعل النافذة أصغر بدرجة واحدة" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "إعادة ضبط التكبير", + "description": "أعد تكبير النافذة إلى 100%" } } }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 439c9a575..dd1b3caca 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Fensterzoom", - "sectionDescription": "Skaliert die gesamte Oberfläche. Wird sofort übernommen und pro Gerät gespeichert.", + "sectionDescription": "Skaliert die gesamte Oberfläche. Wird sofort übernommen und pro Gerät gespeichert. ⌘/Ctrl + und - durchlaufen dieselben Zoomstufen wie dieses Menü (⌘/Ctrl 0 setzt auf 100% zurück).", "placeholder": "Zoomstufe wählen", "default": "Standard", "current": "Aktueller Zoom: {zoom}%" @@ -417,16 +417,16 @@ "description": "Notausstieg: schaltet alle eigenen Farben und CSS aus und wieder ein" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "Vergrößern", + "description": "Das Fenster eine Stufe größer machen" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "Verkleinern", + "description": "Das Fenster eine Stufe kleiner machen" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "Zoom zurücksetzen", + "description": "Fensterzoom auf 100% zurücksetzen" } } }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 3535f5b62..2f8867a23 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Window zoom", - "sectionDescription": "Scale the entire interface. Applies immediately and persists per device. Ctrl + and Ctrl - step the same rungs as this menu (Ctrl 0 resets to 100%).", + "sectionDescription": "Scale the entire interface. Applies immediately and persists per device. ⌘/Ctrl + and - move through the same zoom levels as this menu (⌘/Ctrl 0 resets to 100%).", "placeholder": "Select zoom level", "default": "Default", "current": "Current zoom: {zoom}%" @@ -418,11 +418,11 @@ }, "zoom_in": { "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "description": "Make the window one step larger" }, "zoom_out": { "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "description": "Make the window one step smaller" }, "zoom_reset": { "title": "Reset zoom", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 9c21a4ce3..ba08d0f6f 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Zoom de ventana", - "sectionDescription": "Escala toda la interfaz. Se aplica al instante y se guarda por dispositivo.", + "sectionDescription": "Escala toda la interfaz. Se aplica al instante y se guarda por dispositivo. ⌘/Ctrl + y - recorren los mismos niveles de zoom que este menú (⌘/Ctrl 0 vuelve al 100%).", "placeholder": "Selecciona el nivel de zoom", "default": "Predeterminado", "current": "Zoom actual: {zoom}%" @@ -417,16 +417,16 @@ "description": "Vía de escape: desactiva todos los colores y el CSS personalizados, y los vuelve a activar" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "Acercar", + "description": "Amplía la ventana un nivel" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "Alejar", + "description": "Reduce la ventana un nivel" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "Restablecer zoom", + "description": "Restaura el zoom de la ventana al 100%" } } }, diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index bd84a7ddd..7749ed44f 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Zoom de la fenêtre", - "sectionDescription": "Met à l'échelle toute l'interface. S'applique immédiatement et est enregistré par appareil.", + "sectionDescription": "Met à l'échelle toute l'interface. S'applique immédiatement et est enregistré par appareil. ⌘/Ctrl + et - parcourent les mêmes niveaux de zoom que ce menu (⌘/Ctrl 0 rétablit 100%).", "placeholder": "Sélectionnez le niveau de zoom", "default": "Par défaut", "current": "Zoom actuel : {zoom}%" @@ -417,16 +417,16 @@ "description": "Issue de secours : désactive toutes les couleurs et le CSS personnalisés, puis les réactive" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "Zoom avant", + "description": "Agrandit la fenêtre d'un cran" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "Zoom arrière", + "description": "Réduit la fenêtre d'un cran" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "Réinitialiser le zoom", + "description": "Remet le zoom de la fenêtre à 100%" } } }, diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 9f3debcb0..fb5391433 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "ウィンドウズーム", - "sectionDescription": "インターフェイス全体を拡大・縮小します。すぐに反映され、デバイスごとに保存されます。", + "sectionDescription": "インターフェイス全体を拡大・縮小します。すぐに反映され、デバイスごとに保存されます。⌘/Ctrl + と - で、このメニューと同じ段階で拡大縮小します(⌘/Ctrl 0 で 100% に戻します)。", "placeholder": "ズームレベルを選択", "default": "デフォルト", "current": "現在のズーム:{zoom}%" @@ -417,16 +417,16 @@ "description": "緊急脱出用: カスタム配色と CSS をすべてオフにし、再度押すと元に戻します" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "拡大", + "description": "ウィンドウの表示倍率を一段階上げます" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "縮小", + "description": "ウィンドウの表示倍率を一段階下げます" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "ズームをリセット", + "description": "ウィンドウのズームを 100% に戻します" } } }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 0db7fb00d..9e4f144eb 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "창 확대/축소", - "sectionDescription": "전체 인터페이스를 확대하거나 축소합니다. 즉시 적용되며 장치별로 저장됩니다.", + "sectionDescription": "전체 인터페이스를 확대하거나 축소합니다. 즉시 적용되며 장치별로 저장됩니다. ⌘/Ctrl +와 -는 이 메뉴와 같은 단계로 확대/축소합니다(⌘/Ctrl 0은 100%로 되돌립니다).", "placeholder": "확대/축소 단계 선택", "default": "기본값", "current": "현재 확대/축소: {zoom}%" @@ -417,16 +417,16 @@ "description": "비상 탈출구: 모든 사용자 지정 색상과 CSS를 끄고, 다시 누르면 되돌립니다" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "확대", + "description": "창을 한 단계 더 크게 만듭니다" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "축소", + "description": "창을 한 단계 더 작게 만듭니다" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "확대/축소 초기화", + "description": "창 확대/축소를 100%로 되돌립니다" } } }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 1e58924c9..39e3557e0 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Zoom da janela", - "sectionDescription": "Dimensiona toda a interface. Aplica imediatamente e é salvo por dispositivo.", + "sectionDescription": "Dimensiona toda a interface. Aplica imediatamente e é salvo por dispositivo. ⌘/Ctrl + e - percorrem os mesmos níveis de zoom deste menu (⌘/Ctrl 0 volta para 100%).", "placeholder": "Selecione o nível de zoom", "default": "Padrão", "current": "Zoom atual: {zoom}%" @@ -417,16 +417,16 @@ "description": "Saída de emergência: desliga todas as cores e o CSS personalizados e volta a ligá-los" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "Aumentar zoom", + "description": "Aumenta a janela em um nível" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "Diminuir zoom", + "description": "Diminui a janela em um nível" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "Redefinir zoom", + "description": "Restaura o zoom da janela para 100%" } } }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index aa667ce2f..b02d0ecea 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "窗口缩放", - "sectionDescription": "整体放大或缩小界面,立即生效,按设备分别保存。", + "sectionDescription": "整体放大或缩小界面,立即生效,按设备分别保存。⌘/Ctrl + 和 - 按与此菜单相同的档位调节(⌘/Ctrl 0 恢复为 100%)。", "placeholder": "请选择缩放档位", "default": "默认", "current": "当前缩放:{zoom}%" @@ -417,16 +417,16 @@ "description": "逃生舱:一键关闭全部自定义配色与 CSS,再按一次恢复" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "放大", + "description": "把窗口缩放提高一档" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "缩小", + "description": "把窗口缩放降低一档" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "重置缩放", + "description": "把窗口缩放恢复为 100%" } } }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5a55e2d8d..a11400b7c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "視窗縮放", - "sectionDescription": "整體放大或縮小介面,立即生效,依裝置分別儲存。", + "sectionDescription": "整體放大或縮小介面,立即生效,依裝置分別儲存。⌘/Ctrl + 和 - 依與此選單相同的檔位調節(⌘/Ctrl 0 恢復為 100%)。", "placeholder": "請選擇縮放檔位", "default": "預設", "current": "目前縮放:{zoom}%" @@ -417,16 +417,16 @@ "description": "逃生艙:一鍵關閉全部自訂配色與 CSS,再按一次恢復" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "放大", + "description": "把視窗縮放提高一檔" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "縮小", + "description": "把視窗縮放降低一檔" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "重設縮放", + "description": "把視窗縮放恢復為 100%" } } }, diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index 2255894b1..a47ee8a98 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -3,9 +3,10 @@ import { describe, expect, it } from "vitest" import { DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, - isZoomInShortcutEvent, - isZoomOutShortcutEvent, + formatShortcutLabel, matchShortcutEvent, + normalizeShortcut, + resolveWindowZoomAction, shortcutFromKeyboardEvent, } from "./keyboard-shortcuts" @@ -120,6 +121,12 @@ describe("alt combinations use event.code", () => { }) }) +const defaultZoom = { + zoom_in: DEFAULT_SHORTCUTS.zoom_in, + zoom_out: DEFAULT_SHORTCUTS.zoom_out, + zoom_reset: DEFAULT_SHORTCUTS.zoom_reset, +} + describe("window zoom shortcuts", () => { it("registers zoom_in / zoom_out / zoom_reset defaults", () => { const ids = SHORTCUT_DEFINITIONS.map((definition) => definition.id) @@ -131,17 +138,103 @@ describe("window zoom shortcuts", () => { expect(DEFAULT_SHORTCUTS.zoom_reset).toBe("mod+0") }) - it("treats Ctrl+= and Ctrl++ as zoom in", () => { - expect(isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(true) + it("lets + survive normalize so Ctrl/Cmd Shift = can be recorded", () => { + expect(normalizeShortcut("mod++")).toBe("mod++") + expect(normalizeShortcut("mod+shift++")).toBe("mod+shift++") expect( - isZoomInShortcutEvent(keyEvent("+", { ctrlKey: true, shiftKey: true })) + shortcutFromKeyboardEvent( + keyEvent("+", { ctrlKey: true, shiftKey: true }) + ) + ).toBe("mod+shift++") + expect(formatShortcutLabel("mod++", false)).toBe("Ctrl++") + expect(formatShortcutLabel("mod++", true)).toBe("⌘+") + }) + + it("treats = and + as the same physical key on the bound zoom-in shortcut", () => { + expect(matchShortcutEvent(keyEvent("=", { ctrlKey: true }), "mod+=")).toBe( + true + ) + expect( + matchShortcutEvent( + keyEvent("+", { ctrlKey: true, shiftKey: true }), + "mod+=" + ) ).toBe(true) - expect(isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(true) - expect(isZoomInShortcutEvent(keyEvent("k", { ctrlKey: true }))).toBe(false) + expect(matchShortcutEvent(keyEvent("k", { ctrlKey: true }), "mod+=")).toBe( + false + ) + }) + + it("treats - and _ as the same physical key on the bound zoom-out shortcut", () => { + expect(matchShortcutEvent(keyEvent("-", { ctrlKey: true }), "mod+-")).toBe( + true + ) + expect( + matchShortcutEvent( + keyEvent("_", { ctrlKey: true, shiftKey: true }), + "mod+-" + ) + ).toBe(true) + expect(matchShortcutEvent(keyEvent("=", { ctrlKey: true }), "mod+-")).toBe( + false + ) + }) + + it("follows a remapped zoom-in binding and ignores the old default", () => { + const remapped = { + ...defaultZoom, + zoom_in: "mod+shift+z", + } + expect( + resolveWindowZoomAction( + keyEvent("z", { ctrlKey: true, shiftKey: true }), + remapped + ) + ).toBe("in") + expect( + resolveWindowZoomAction(keyEvent("=", { ctrlKey: true }), remapped) + ).toBeNull() + expect( + resolveWindowZoomAction( + keyEvent("+", { ctrlKey: true, shiftKey: true }), + remapped + ) + ).toBeNull() }) - it("treats Ctrl+- as zoom out", () => { - expect(isZoomOutShortcutEvent(keyEvent("-", { ctrlKey: true }))).toBe(true) - expect(isZoomOutShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(false) + it("still matches a repeat of the bound zoom key", () => { + expect( + resolveWindowZoomAction(keyEvent("=", { ctrlKey: true }), defaultZoom) + ).toBe("in") + expect( + resolveWindowZoomAction(keyEvent("-", { ctrlKey: true }), defaultZoom) + ).toBe("out") + expect( + resolveWindowZoomAction(keyEvent("0", { ctrlKey: true }), defaultZoom) + ).toBe("reset") + }) + + it("does not treat AZERTY Ctrl+) as zoom out just because code is Minus", () => { + expect( + matchShortcutEvent( + keyEvent(")", { ctrlKey: true, code: "Minus" }), + "mod+-" + ) + ).toBe(false) + }) + + it("still matches numpad + / - against the default zoom bindings", () => { + expect( + matchShortcutEvent( + keyEvent("Add", { ctrlKey: true, code: "NumpadAdd" }), + "mod+=" + ) + ).toBe(true) + expect( + matchShortcutEvent( + keyEvent("Subtract", { ctrlKey: true, code: "NumpadSubtract" }), + "mod+-" + ) + ).toBe(true) }) }) diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index e5cf6a4fa..0dd2b185e 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -132,9 +132,40 @@ const SPECIAL_KEY_ALIASES: Record = { down: "arrowdown", left: "arrowleft", right: "arrowright", - add: "+", - subtract: "-", - equal: "=", +} + +/** + * `=`/`+` and `-`/`_` are the same physical key (unshifted vs shifted). + * Bindings on one should also fire for the other; extra Shift is ignored + * only for these pairs, because Shift is how you type the sibling. + */ +const PHYSICAL_KEY_SIBLINGS: Record = { + "=": "+", + "+": "=", + "-": "_", + _: "-", +} + +export interface ParsedShortcut { + mod: boolean + alt: boolean + shift: boolean + key: string +} + +/** + * Recording a shortcut in Settings and the global zoom listener are both + * capture handlers on `window`. `stopPropagation()` does not stop a sibling + * listener on the same target, so the recorder arms this flag instead. + */ +let shortcutRecorderArmed = false + +export function setShortcutRecorderArmed(armed: boolean): void { + shortcutRecorderArmed = armed +} + +export function isShortcutRecorderArmed(): boolean { + return shortcutRecorderArmed } const KEY_LABELS: Record = { @@ -194,59 +225,77 @@ function normalizeSettings(input: unknown): ShortcutSettings { return next } -export function normalizeShortcut(rawShortcut: string): string | null { - const parts = rawShortcut - .toLowerCase() - .split("+") - .map((part) => part.trim()) - .filter(Boolean) +/** + * Split a shortcut string without treating a trailing `+` key as a delimiter. + * `mod++` and `mod+shift++` are how Ctrl/Cmd+Shift+= serializes. + */ +export function parseShortcut(rawShortcut: string): ParsedShortcut | null { + const lowered = rawShortcut.toLowerCase().trim() + if (!lowered) return null + + let keyRaw: string + let prefix: string + if (lowered === "+" || lowered.endsWith("++")) { + keyRaw = "+" + prefix = lowered === "+" ? "" : lowered.slice(0, -2) + } else { + const lastPlus = lowered.lastIndexOf("+") + if (lastPlus === -1) { + keyRaw = lowered + prefix = "" + } else { + keyRaw = lowered.slice(lastPlus + 1) + prefix = lowered.slice(0, lastPlus) + } + } - if (parts.length === 0) return null + const keyToken = normalizeKeyToken(keyRaw.trim()) + if (!keyToken || MODIFIER_KEY_SET.has(keyToken)) return null let mod = false let alt = false let shift = false - let keyToken: string | null = null - - for (const part of parts) { - if ( - part === "mod" || - part === "cmd" || - part === "command" || - part === "meta" || - part === "ctrl" || - part === "control" - ) { - mod = true - continue - } - - if (part === "alt" || part === "option") { - alt = true - continue - } - - if (part === "shift") { - shift = true - continue + if (prefix) { + const parts = prefix + .split("+") + .map((part) => part.trim()) + .filter(Boolean) + for (const part of parts) { + if ( + part === "mod" || + part === "cmd" || + part === "command" || + part === "meta" || + part === "ctrl" || + part === "control" + ) { + mod = true + continue + } + if (part === "alt" || part === "option") { + alt = true + continue + } + if (part === "shift") { + shift = true + continue + } + return null } - - if (keyToken) return null - - const normalizedKey = normalizeKeyToken(part) - if (!normalizedKey || MODIFIER_KEY_SET.has(normalizedKey)) return null - - keyToken = normalizedKey } - if (!keyToken) return null + return { mod, alt, shift, key: keyToken } +} - const normalizedParts: string[] = [] - if (mod) normalizedParts.push("mod") - if (alt) normalizedParts.push("alt") - if (shift) normalizedParts.push("shift") - normalizedParts.push(keyToken) +export function normalizeShortcut(rawShortcut: string): string | null { + const parsed = parseShortcut(rawShortcut) + if (!parsed) return null + const normalizedParts: string[] = [] + if (parsed.mod) normalizedParts.push("mod") + if (parsed.alt) normalizedParts.push("alt") + if (parsed.shift) normalizedParts.push("shift") + normalizedParts.push(parsed.key) return normalizedParts.join("+") } @@ -326,54 +375,61 @@ export function shortcutFromKeyboardEvent( return parts.join("+") } -/** Ctrl/Cmd + or = (with or without Shift). Matches the Settings zoom-in rung. */ -export function isZoomInShortcutEvent(event: ShortcutEventLike): boolean { - if ( - matchShortcutEvent(event, "mod+=") || - matchShortcutEvent(event, "mod++") - ) { - return true - } - if (!(event.metaKey || event.ctrlKey) || event.altKey) return false - const key = eventKeyToken(event) - if (key === "+" || key === "=") return true - return event.code === "Equal" || event.code === "NumpadAdd" +function siblingKeys(keyToken: string): Set { + const sibling = PHYSICAL_KEY_SIBLINGS[keyToken] + return sibling ? new Set([keyToken, sibling]) : new Set([keyToken]) } -/** Ctrl/Cmd - (with or without Shift). Matches the Settings zoom-out rung. */ -export function isZoomOutShortcutEvent(event: ShortcutEventLike): boolean { - if (matchShortcutEvent(event, "mod+-")) return true - if (!(event.metaKey || event.ctrlKey) || event.altKey) return false - const key = eventKeyToken(event) - if (key === "-" || key === "_") return true - return event.code === "Minus" || event.code === "NumpadSubtract" +function matchesNumpadCode( + event: ShortcutEventLike, + boundKey: string +): boolean { + if (boundKey === "=" || boundKey === "+") { + return event.code === "NumpadAdd" + } + if (boundKey === "-" || boundKey === "_") { + return event.code === "NumpadSubtract" + } + return false } export function matchShortcutEvent( event: ShortcutEventLike, shortcut: string ): boolean { - const normalized = normalizeShortcut(shortcut) - if (!normalized) return false - - const parts = normalized.split("+") - const keyToken = parts[parts.length - 1] - const needsMod = parts.includes("mod") - const needsAlt = parts.includes("alt") - const needsShift = parts.includes("shift") + const parsed = parseShortcut(shortcut) + if (!parsed) return false + const keys = siblingKeys(parsed.key) const actualKey = eventKeyToken(event) - if (!actualKey) return false - if (actualKey !== keyToken) return false + const matchesKey = actualKey !== null && keys.has(actualKey) + if (!matchesKey && !matchesNumpadCode(event, parsed.key)) return false const hasMod = event.metaKey || event.ctrlKey - if (hasMod !== needsMod) return false - if (event.altKey !== needsAlt) return false - if (event.shiftKey !== needsShift) return false + if (hasMod !== parsed.mod) return false + if (event.altKey !== parsed.alt) return false + + // Extra Shift is how `=` becomes `+` (and `-` becomes `_`). Require Shift + // when the binding asked for it; ignore a surplus Shift only on those pairs. + if (parsed.shift) { + if (!event.shiftKey) return false + } else if (event.shiftKey && keys.size === 1) { + return false + } return true } +export function resolveWindowZoomAction( + event: ShortcutEventLike, + shortcuts: Pick +): "in" | "out" | "reset" | null { + if (matchShortcutEvent(event, shortcuts.zoom_in)) return "in" + if (matchShortcutEvent(event, shortcuts.zoom_out)) return "out" + if (matchShortcutEvent(event, shortcuts.zoom_reset)) return "reset" + return null +} + function toKeyLabel(keyToken: string): string { const common = KEY_LABELS[keyToken] if (common) return common @@ -385,18 +441,15 @@ function toKeyLabel(keyToken: string): string { } export function formatShortcutLabel(shortcut: string, isMac: boolean): string { - const normalized = normalizeShortcut(shortcut) - if (!normalized) return shortcut - - const parts = normalized.split("+") - const keyToken = parts[parts.length - 1] + const parsed = parseShortcut(shortcut) + if (!parsed) return shortcut const modifiers: string[] = [] - if (parts.includes("mod")) modifiers.push(isMac ? "⌘" : "Ctrl") - if (parts.includes("alt")) modifiers.push(isMac ? "⌥" : "Alt") - if (parts.includes("shift")) modifiers.push(isMac ? "⇧" : "Shift") + if (parsed.mod) modifiers.push(isMac ? "⌘" : "Ctrl") + if (parsed.alt) modifiers.push(isMac ? "⌥" : "Alt") + if (parsed.shift) modifiers.push(isMac ? "⇧" : "Shift") - const keyLabel = toKeyLabel(keyToken) + const keyLabel = toKeyLabel(parsed.key) if (isMac) { return `${modifiers.join("")}${keyLabel}`