diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 4cdebc977..5b2d85481 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 { + isShortcutRecorderArmed, + matchShortcutEvent, + resolveWindowZoomAction, +} from "@/lib/keyboard-shortcuts" import { DEFAULT_WORKSPACE_BG_ENABLED, DEFAULT_WORKSPACE_BG_MASK_OPACITY, @@ -472,6 +477,10 @@ export function AppearanceProvider({ syncTrafficLightPosition(zoom) persist(STORAGE_KEY_ZOOM_LEVEL, String(zoom)) }, []) + const zoomLevelRef = useRef(zoomLevel) + useEffect(() => { + zoomLevelRef.current = zoomLevel + }, [zoomLevel]) const setShowWelcomeQuickActions = useCallback((on: boolean) => { setShowWelcomeQuickActionsState(on) @@ -744,6 +753,9 @@ 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 const onKeyDown = (event: KeyboardEvent) => { @@ -756,6 +768,44 @@ export function AppearanceProvider({ return () => window.removeEventListener("keydown", onKeyDown, true) }, [toggleCustomStyleShortcut, customStyleSuspended, setCustomStyleSuspended]) + // 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.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 (action === "out") { + setZoomLevel(stepZoom(zoomLevelRef.current, -1)) + return + } + setZoomLevel(DEFAULT_ZOOM_LEVEL) + } + window.addEventListener("keydown", onKeyDown, true) + return () => window.removeEventListener("keydown", onKeyDown, true) + }, [setZoomLevel, zoomInShortcut, zoomOutShortcut, zoomResetShortcut]) + // 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进 useEffect(() => { const FONT_KEYS = new Set([ 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 f436b8c6d..f9f5fcbc4 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}%" @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "إيقاف/استئناف النمط المخصص", "description": "مخرج طوارئ: يوقف كل الألوان المخصصة وCSS، ويعيد تفعيلها" + }, + "zoom_in": { + "title": "تكبير", + "description": "اجعل النافذة أكبر بدرجة واحدة" + }, + "zoom_out": { + "title": "تصغير", + "description": "اجعل النافذة أصغر بدرجة واحدة" + }, + "zoom_reset": { + "title": "إعادة ضبط التكبير", + "description": "أعد تكبير النافذة إلى 100%" } } }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 6d0a5bec3..e3ea234e0 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}%" @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "Eigenen Stil aussetzen/fortsetzen", "description": "Notausstieg: schaltet alle eigenen Farben und CSS aus und wieder ein" + }, + "zoom_in": { + "title": "Vergrößern", + "description": "Das Fenster eine Stufe größer machen" + }, + "zoom_out": { + "title": "Verkleinern", + "description": "Das Fenster eine Stufe kleiner machen" + }, + "zoom_reset": { + "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 b7076662e..2973ab308 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 - move through the same zoom levels as this menu (⌘/Ctrl 0 resets to 100%).", "placeholder": "Select zoom level", "default": "Default", "current": "Current zoom: {zoom}%" @@ -429,6 +429,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": "Make the window one step larger" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Make the window one step smaller" + }, + "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 21ca17fa8..bff3c5c68 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}%" @@ -429,6 +429,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": "Acercar", + "description": "Amplía la ventana un nivel" + }, + "zoom_out": { + "title": "Alejar", + "description": "Reduce la ventana un nivel" + }, + "zoom_reset": { + "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 692b556a8..a3a0e3e8c 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}%" @@ -429,6 +429,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 avant", + "description": "Agrandit la fenêtre d'un cran" + }, + "zoom_out": { + "title": "Zoom arrière", + "description": "Réduit la fenêtre d'un cran" + }, + "zoom_reset": { + "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 24b77626a..95119b3ef 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}%" @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "カスタムスタイルの停止/再開", "description": "緊急脱出用: カスタム配色と CSS をすべてオフにし、再度押すと元に戻します" + }, + "zoom_in": { + "title": "拡大", + "description": "ウィンドウの表示倍率を一段階上げます" + }, + "zoom_out": { + "title": "縮小", + "description": "ウィンドウの表示倍率を一段階下げます" + }, + "zoom_reset": { + "title": "ズームをリセット", + "description": "ウィンドウのズームを 100% に戻します" } } }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index e76fe0b77..07facb3c1 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}%" @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "사용자 지정 스타일 중지/재개", "description": "비상 탈출구: 모든 사용자 지정 색상과 CSS를 끄고, 다시 누르면 되돌립니다" + }, + "zoom_in": { + "title": "확대", + "description": "창을 한 단계 더 크게 만듭니다" + }, + "zoom_out": { + "title": "축소", + "description": "창을 한 단계 더 작게 만듭니다" + }, + "zoom_reset": { + "title": "확대/축소 초기화", + "description": "창 확대/축소를 100%로 되돌립니다" } } }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 6ece222b0..598b4d8a7 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}%" @@ -429,6 +429,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": "Aumentar zoom", + "description": "Aumenta a janela em um nível" + }, + "zoom_out": { + "title": "Diminuir zoom", + "description": "Diminui a janela em um nível" + }, + "zoom_reset": { + "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 e62f6d74c..eaba3aa31 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}%" @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "停用/恢复自定义样式", "description": "逃生舱:一键关闭全部自定义配色与 CSS,再按一次恢复" + }, + "zoom_in": { + "title": "放大", + "description": "把窗口缩放提高一档" + }, + "zoom_out": { + "title": "缩小", + "description": "把窗口缩放降低一档" + }, + "zoom_reset": { + "title": "重置缩放", + "description": "把窗口缩放恢复为 100%" } } }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index e6283cf66..3f1d2556b 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}%" @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "停用/恢復自訂樣式", "description": "逃生艙:一鍵關閉全部自訂配色與 CSS,再按一次恢復" + }, + "zoom_in": { + "title": "放大", + "description": "把視窗縮放提高一檔" + }, + "zoom_out": { + "title": "縮小", + "description": "把視窗縮放降低一檔" + }, + "zoom_reset": { + "title": "重設縮放", + "description": "把視窗縮放恢復為 100%" } } }, 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/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index d32d86949..a47ee8a98 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest" import { DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, + formatShortcutLabel, matchShortcutEvent, + normalizeShortcut, + resolveWindowZoomAction, shortcutFromKeyboardEvent, } from "./keyboard-shortcuts" @@ -117,3 +120,121 @@ describe("alt combinations use event.code", () => { ).toBe(true) }) }) + +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) + 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("lets + survive normalize so Ctrl/Cmd Shift = can be recorded", () => { + expect(normalizeShortcut("mod++")).toBe("mod++") + expect(normalizeShortcut("mod+shift++")).toBe("mod+shift++") + expect( + 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(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("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 342995d88..0dd2b185e 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" @@ -117,6 +134,40 @@ const SPECIAL_KEY_ALIASES: Record = { right: "arrowright", } +/** + * `=`/`+` 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 = { space: "Space", escape: "Esc", @@ -174,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 (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 (part === "alt" || part === "option") { - alt = true - continue - } - - if (part === "shift") { - shift = true - continue - } - - 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("+") } @@ -306,31 +375,61 @@ export function shortcutFromKeyboardEvent( return parts.join("+") } +function siblingKeys(keyToken: string): Set { + const sibling = PHYSICAL_KEY_SIBLINGS[keyToken] + return sibling ? new Set([keyToken, sibling]) : new Set([keyToken]) +} + +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 @@ -342,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}` diff --git a/src/lib/theme-presets.test.ts b/src/lib/theme-presets.test.ts new file mode 100644 index 000000000..467e1fd87 --- /dev/null +++ b/src/lib/theme-presets.test.ts @@ -0,0 +1,14 @@ +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(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 2d8a9a2b1..30f109744 100644 --- a/src/lib/theme-presets.ts +++ b/src/lib/theme-presets.ts @@ -92,8 +92,18 @@ 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] 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] +}