diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6a1b1c62e7471..d81ad16b8be3f 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -46,6 +46,7 @@ import { openSystemSettings, checkSystemPermission, pasteAsText, + zoomAppIn, probeRemoteEditors, revealWindow, pickFolder, @@ -154,6 +155,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); + yield* ipc.handle(zoomAppIn); yield* ipc.handle(revealWindow); yield* ipc.handle(syncHtmlFullscreen); yield* ipc.handle(probeRemoteEditors); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index aebbc1c24f8c2..498935a226270 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -10,6 +10,7 @@ export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; +export const ZOOM_APP_IN_CHANNEL = "desktop:zoom-app-in"; export const REVEAL_WINDOW_CHANNEL = "desktop:reveal-window"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index e0eea5c74c9ed..d8849aa29fac9 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -30,6 +30,7 @@ import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironm import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; @@ -433,6 +434,25 @@ export const pasteAsText = DesktopIpc.makeIpcMethod({ }), }); +export const zoomAppIn = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.ZOOM_APP_IN_CHANNEL, + payload: Schema.Undefined, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.zoomAppIn")(function* (_input, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(window) || + window.value.isDestroyed() || + window.value.webContents.id !== event.sender.id + ) { + return; + } + yield* (yield* DesktopWindow.DesktopWindow).zoomMain("in"); + }), +}); + export const revealWindow = DesktopIpc.makeIpcMethod({ channel: IpcChannels.REVEAL_WINDOW_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2dbc4eeda3ae5..c71c71ed3dbe4 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -229,6 +229,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), + zoomAppIn: () => ipcRenderer.invoke(IpcChannels.ZOOM_APP_IN_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 3be9175c4f2e4..b6614ff9cd6a2 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -41,6 +41,7 @@ import { import { syncBrowserChromeTheme } from "./hooks/useTheme"; import { startThreadVisitedMigration } from "./hooks/useThreadVisitedMigration"; import { installDesktopPasteAsText } from "./lib/desktopPasteAsText"; +import { installDesktopZoomInFallback } from "./lib/desktopZoomInFallback"; import { syncDocumentElectronPlatformClasses, syncDocumentWindowControlsOverlayClass, @@ -178,6 +179,7 @@ export function startAppServices(): AppRouter { } installDesktopPasteAsText(window.desktopBridge, window); + installDesktopZoomInFallback(window.desktopBridge, navigator.platform); startClientAppearance(); startDocumentTitle(); diff --git a/apps/web/src/lib/desktopZoomInFallback.browser.test.ts b/apps/web/src/lib/desktopZoomInFallback.browser.test.ts new file mode 100644 index 0000000000000..4c621fa4086bb --- /dev/null +++ b/apps/web/src/lib/desktopZoomInFallback.browser.test.ts @@ -0,0 +1,54 @@ +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { registerCommands } from "../shortcuts/dispatcher"; +import { installDesktopZoomInFallback } from "./desktopZoomInFallback"; + +const previewZoomIn: ResolvedKeybindingsConfig = [ + { + command: "preview.zoomIn", + shortcut: { + key: "+", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + }, +]; + +// A German or Swiss layout types "+" on BracketRight without Shift. +function pressCommandPlus(): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key: "+", + code: "BracketRight", + metaKey: true, + cancelable: true, + bubbles: true, + }); + window.dispatchEvent(event); + return event; +} + +describe("installDesktopZoomInFallback", () => { + it("zooms the app only when no keybinding consumed Cmd++", () => { + const zoomAppIn = vi.fn(async () => {}); + const previewZoom = vi.fn(); + using stack = new DisposableStack(); + stack.defer(installDesktopZoomInFallback({ zoomAppIn }, "MacIntel") ?? (() => {})); + + expect(pressCommandPlus().defaultPrevented).toBe(true); + expect(zoomAppIn).toHaveBeenCalledOnce(); + + stack.defer( + registerCommands( + { "preview.zoomIn": previewZoom }, + { keybindings: () => previewZoomIn, context: () => ({ previewFocus: true }) }, + ), + ); + pressCommandPlus(); + expect(previewZoom).toHaveBeenCalledOnce(); + expect(zoomAppIn).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/lib/desktopZoomInFallback.ts b/apps/web/src/lib/desktopZoomInFallback.ts new file mode 100644 index 0000000000000..99c377c6723ae --- /dev/null +++ b/apps/web/src/lib/desktopZoomInFallback.ts @@ -0,0 +1,28 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { isMacPlatform } from "@t3tools/shared/keybindings"; + +import { registerCommands } from "../shortcuts/dispatcher"; + +/** + * macOS matches the Zoom In menu shortcut by US key position, so Cmd++ does nothing on layouts + * with "+" elsewhere. Zoom the app when no keybinding (such as preview zoom) consumed the key. + */ +export function installDesktopZoomInFallback( + bridge: Pick | undefined, + platform: string, +): (() => void) | undefined { + if (!bridge || !isMacPlatform(platform)) return undefined; + return registerCommands( + {}, + { + observe: (event, consumed) => { + if (consumed || !event.metaKey || event.ctrlKey || event.altKey || event.key !== "+") { + return; + } + // Keeps the US-layout menu accelerator from zooming a second time. + event.preventDefault(); + void bridge.zoomAppIn(); + }, + }, + ); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 29be3d6d0219f..f398ba701e630 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1123,6 +1123,7 @@ export interface DesktopBridge { checkSystemPermission: (pane: SystemSettingsPane) => Promise; probeRemoteEditors: () => Promise; pasteAsText: () => Promise; + zoomAppIn: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent: (listener: (event: DesktopSnapShotEvent) => void) => () => void; onQuitShortcut: (listener: (event: QuitShortcutHintEvent) => void) => () => void;