From 593a7e2301084100ba36e163a5c8f8acfd60dd9a Mon Sep 17 00:00:00 2001 From: L-N1988 <2994567011@qq.com> Date: Fri, 27 Mar 2026 19:21:01 +0800 Subject: [PATCH] feat: enable zooom in/out via ctrl +/-. --- .../src-tauri/capabilities/default.json | 1 + .../src/__tests__/lib/app-zoom.test.ts | 183 ++++++++++++++++++ apps/desktop/src/__tests__/mocks/tauri.ts | 43 ++++ .../workspace/editor/image-preview.tsx | 3 + .../workspace/preview/pdf-viewer.tsx | 3 + .../src/hooks/use-keyboard-shortcuts.ts | 30 ++- apps/desktop/src/lib/app-zoom.ts | 104 ++++++++++ apps/desktop/src/main.tsx | 37 ++-- 8 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/__tests__/lib/app-zoom.test.ts create mode 100644 apps/desktop/src/lib/app-zoom.ts diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 3a658c3..15a8f1d 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -15,6 +15,7 @@ "core:window:allow-toggle-maximize", "core:window:allow-close", "core:webview:allow-create-webview-window", + "core:webview:allow-set-webview-zoom", "dialog:default", "dialog:allow-open", "dialog:allow-save", diff --git a/apps/desktop/src/__tests__/lib/app-zoom.test.ts b/apps/desktop/src/__tests__/lib/app-zoom.test.ts new file mode 100644 index 0000000..976da91 --- /dev/null +++ b/apps/desktop/src/__tests__/lib/app-zoom.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { + APP_ZOOM_STORAGE_KEY, + DEFAULT_APP_ZOOM, + LOCAL_ZOOM_SHORTCUTS_ATTR, + MAX_APP_ZOOM, + MIN_APP_ZOOM, + clampAppZoom, + getAppZoomAction, + initializeAppZoom, + persistAppZoom, + readStoredAppZoom, + resetAppZoom, + shouldHandleAppZoomShortcut, + zoomInApp, + zoomOutApp, +} from "@/lib/app-zoom"; + +describe("app zoom", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + it("falls back to the default zoom when storage is empty or invalid", () => { + expect(readStoredAppZoom()).toBe(DEFAULT_APP_ZOOM); + + localStorage.setItem(APP_ZOOM_STORAGE_KEY, "not-a-number"); + expect(readStoredAppZoom()).toBe(DEFAULT_APP_ZOOM); + }); + + it("clamps stored zoom values into the supported range", () => { + localStorage.setItem(APP_ZOOM_STORAGE_KEY, "999"); + expect(readStoredAppZoom()).toBe(MAX_APP_ZOOM); + + localStorage.setItem(APP_ZOOM_STORAGE_KEY, "0.1"); + expect(readStoredAppZoom()).toBe(MIN_APP_ZOOM); + }); + + it("applies persisted zoom through the webview API", async () => { + const webview = getCurrentWebview(); + + await persistAppZoom(1.25); + + expect(webview.setZoom).toHaveBeenCalledWith(1.25); + expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.25"); + }); + + it("restores the saved zoom on startup", async () => { + const webview = getCurrentWebview(); + localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.4"); + + await initializeAppZoom(); + + expect(webview.setZoom).toHaveBeenCalledWith(1.4); + }); + + it("zooms in, zooms out, and resets around the stored value", async () => { + const webview = getCurrentWebview(); + localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.2"); + + await zoomInApp(); + expect(webview.setZoom).toHaveBeenLastCalledWith(1.3); + expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.3"); + + await zoomOutApp(); + expect(webview.setZoom).toHaveBeenLastCalledWith(1.2); + expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.2"); + + await resetAppZoom(); + expect(webview.setZoom).toHaveBeenLastCalledWith(DEFAULT_APP_ZOOM); + expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe( + DEFAULT_APP_ZOOM.toString(), + ); + }); + + it("rounds and clamps zoom values consistently", () => { + expect(clampAppZoom(1.234)).toBe(1.23); + expect(clampAppZoom(10)).toBe(MAX_APP_ZOOM); + expect(clampAppZoom(0.01)).toBe(MIN_APP_ZOOM); + }); +}); + +describe("getAppZoomAction", () => { + it("detects zoom-in shortcuts", () => { + expect( + getAppZoomAction({ + metaKey: true, + ctrlKey: false, + altKey: false, + key: "+", + code: "Equal", + }), + ).toBe("in"); + + expect( + getAppZoomAction({ + metaKey: false, + ctrlKey: true, + altKey: false, + key: "=", + code: "Equal", + }), + ).toBe("in"); + }); + + it("detects zoom-out shortcuts", () => { + expect( + getAppZoomAction({ + metaKey: true, + ctrlKey: false, + altKey: false, + key: "-", + code: "Minus", + }), + ).toBe("out"); + + expect( + getAppZoomAction({ + metaKey: false, + ctrlKey: true, + altKey: false, + key: "_", + code: "Minus", + }), + ).toBe("out"); + }); + + it("detects zoom reset shortcuts", () => { + expect( + getAppZoomAction({ + metaKey: true, + ctrlKey: false, + altKey: false, + key: "0", + code: "Digit0", + }), + ).toBe("reset"); + }); + + it("ignores unrelated shortcuts", () => { + expect( + getAppZoomAction({ + metaKey: false, + ctrlKey: false, + altKey: false, + key: "+", + code: "Equal", + }), + ).toBeNull(); + + expect( + getAppZoomAction({ + metaKey: true, + ctrlKey: false, + altKey: true, + key: "+", + code: "Equal", + }), + ).toBeNull(); + }); +}); + +describe("shouldHandleAppZoomShortcut", () => { + it("handles global zoom when the target is outside a local zoom surface", () => { + const target = document.createElement("div"); + expect(shouldHandleAppZoomShortcut(target)).toBe(true); + }); + + it("skips global zoom inside local zoom surfaces", () => { + const wrapper = document.createElement("div"); + wrapper.setAttribute(LOCAL_ZOOM_SHORTCUTS_ATTR, "true"); + const target = document.createElement("button"); + wrapper.appendChild(target); + + expect(shouldHandleAppZoomShortcut(target)).toBe(false); + }); + + it("defaults to handling zoom for non-element targets", () => { + expect(shouldHandleAppZoomShortcut(null)).toBe(true); + }); +}); diff --git a/apps/desktop/src/__tests__/mocks/tauri.ts b/apps/desktop/src/__tests__/mocks/tauri.ts index 738e874..78c5b3e 100644 --- a/apps/desktop/src/__tests__/mocks/tauri.ts +++ b/apps/desktop/src/__tests__/mocks/tauri.ts @@ -1,11 +1,54 @@ import { vi } from "vitest"; +const storageData = new Map(); +const mockStorage = { + getItem: vi.fn((key: string) => storageData.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + storageData.set(key, value); + }), + removeItem: vi.fn((key: string) => { + storageData.delete(key); + }), + clear: vi.fn(() => { + storageData.clear(); + }), + key: vi.fn((index: number) => Array.from(storageData.keys())[index] ?? null), + get length() { + return storageData.size; + }, +}; + +const mockWebview = { + setZoom: vi.fn(() => Promise.resolve()), + onDragDropEvent: vi.fn(() => Promise.resolve(() => {})), +}; + +Object.defineProperty(window, "localStorage", { + value: mockStorage, + configurable: true, +}); +Object.defineProperty(globalThis, "localStorage", { + value: mockStorage, + configurable: true, +}); + // Mock @tauri-apps/api/core vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`), })); +// Mock @tauri-apps/api/event +vi.mock("@tauri-apps/api/event", () => ({ + emit: vi.fn(() => Promise.resolve()), + listen: vi.fn(() => Promise.resolve(() => {})), +})); + +// Mock @tauri-apps/api/webview +vi.mock("@tauri-apps/api/webview", () => ({ + getCurrentWebview: vi.fn(() => mockWebview), +})); + // Mock @tauri-apps/api/path vi.mock("@tauri-apps/api/path", () => ({ join: vi.fn((...args: string[]) => Promise.resolve(args.join("/"))), diff --git a/apps/desktop/src/components/workspace/editor/image-preview.tsx b/apps/desktop/src/components/workspace/editor/image-preview.tsx index c9b4e5a..4853513 100644 --- a/apps/desktop/src/components/workspace/editor/image-preview.tsx +++ b/apps/desktop/src/components/workspace/editor/image-preview.tsx @@ -3,6 +3,7 @@ import { CheckIcon, XIcon } from "lucide-react"; import { writeFile } from "@tauri-apps/plugin-fs"; import { toast } from "sonner"; import { useDocumentStore, type ProjectFile } from "@/stores/document-store"; +import { LOCAL_ZOOM_SHORTCUTS_ATTR } from "@/lib/app-zoom"; import { getAssetUrl } from "@/lib/tauri/fs"; import { Button } from "@/components/ui/button"; @@ -353,12 +354,14 @@ export function ImagePreview({
containerRef.current?.focus()} onMouseMove={cropMode ? handleCropMouseMove : undefined} onMouseUp={cropMode ? handleCropMouseUp : undefined} onMouseLeave={cropMode ? handleCropMouseUp : undefined} diff --git a/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx b/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx index b093e43..f5738b4 100644 --- a/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx +++ b/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx @@ -6,6 +6,7 @@ import { getCachedDocument, getOrOpenDocument, } from "@/lib/mupdf/pdf-doc-cache"; +import { LOCAL_ZOOM_SHORTCUTS_ATTR } from "@/lib/app-zoom"; import { MupdfPage } from "./mupdf-page"; import { createLogger } from "@/lib/debug/logger"; import { APP_VISIBILITY_RESTORED } from "@/lib/debug/log-store"; @@ -751,8 +752,10 @@ export function PdfViewer({
containerRef.current?.focus()} onMouseDown={handleCaptureMouseDown} onMouseMove={handleCaptureMouseMove} onMouseUp={handleCaptureMouseUp} diff --git a/apps/desktop/src/hooks/use-keyboard-shortcuts.ts b/apps/desktop/src/hooks/use-keyboard-shortcuts.ts index fc084e4..08b3a0d 100644 --- a/apps/desktop/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/desktop/src/hooks/use-keyboard-shortcuts.ts @@ -1,9 +1,33 @@ import { useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { + getAppZoomAction, + resetAppZoom, + shouldHandleAppZoomShortcut, + zoomInApp, + zoomOutApp, +} from "@/lib/app-zoom"; import { useDocumentStore } from "@/stores/document-store"; export function useKeyboardShortcuts() { useEffect(() => { + const handleZoomKeyDown = (e: KeyboardEvent) => { + const zoomAction = getAppZoomAction(e); + if (!zoomAction || !shouldHandleAppZoomShortcut(e.target)) { + return; + } + + e.preventDefault(); + + if (zoomAction === "in") { + zoomInApp().catch(console.error); + } else if (zoomAction === "out") { + zoomOutApp().catch(console.error); + } else { + resetAppZoom().catch(console.error); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); @@ -46,6 +70,10 @@ export function useKeyboardShortcuts() { }; window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + document.addEventListener("keydown", handleZoomKeyDown, true); + return () => { + window.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keydown", handleZoomKeyDown, true); + }; }, []); } diff --git a/apps/desktop/src/lib/app-zoom.ts b/apps/desktop/src/lib/app-zoom.ts new file mode 100644 index 0000000..025c04f --- /dev/null +++ b/apps/desktop/src/lib/app-zoom.ts @@ -0,0 +1,104 @@ +import { getCurrentWebview } from "@tauri-apps/api/webview"; + +export const APP_ZOOM_STORAGE_KEY = "claude-prism-app-zoom"; +export const LOCAL_ZOOM_SHORTCUTS_ATTR = "data-local-zoom-shortcuts"; +export const DEFAULT_APP_ZOOM = 1; +export const MIN_APP_ZOOM = 0.5; +export const MAX_APP_ZOOM = 3; +export const APP_ZOOM_STEP = 0.1; + +export type AppZoomAction = "in" | "out" | "reset"; + +type ZoomShortcutEvent = Pick< + KeyboardEvent, + "altKey" | "code" | "ctrlKey" | "key" | "metaKey" +>; + +function roundZoom(value: number): number { + return Math.round(value * 100) / 100; +} + +export function clampAppZoom(value: number): number { + return roundZoom(Math.min(MAX_APP_ZOOM, Math.max(MIN_APP_ZOOM, value))); +} + +export function readStoredAppZoom(): number { + const raw = window.localStorage.getItem(APP_ZOOM_STORAGE_KEY); + if (raw === null) return DEFAULT_APP_ZOOM; + + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return DEFAULT_APP_ZOOM; + + return clampAppZoom(parsed); +} + +async function applyAppZoom(value: number): Promise { + const zoom = clampAppZoom(value); + await getCurrentWebview().setZoom(zoom); + return zoom; +} + +export async function persistAppZoom(value: number): Promise { + const zoom = await applyAppZoom(value); + window.localStorage.setItem(APP_ZOOM_STORAGE_KEY, zoom.toString()); + return zoom; +} + +export function initializeAppZoom(): Promise { + return applyAppZoom(readStoredAppZoom()); +} + +export function zoomInApp(): Promise { + return persistAppZoom(readStoredAppZoom() + APP_ZOOM_STEP); +} + +export function zoomOutApp(): Promise { + return persistAppZoom(readStoredAppZoom() - APP_ZOOM_STEP); +} + +export function resetAppZoom(): Promise { + return persistAppZoom(DEFAULT_APP_ZOOM); +} + +export function getAppZoomAction( + event: ZoomShortcutEvent, +): AppZoomAction | null { + if (!(event.metaKey || event.ctrlKey) || event.altKey) { + return null; + } + + if ( + event.key === "+" || + event.key === "=" || + event.code === "Equal" || + event.code === "NumpadAdd" + ) { + return "in"; + } + + if ( + event.key === "-" || + event.key === "_" || + event.code === "Minus" || + event.code === "NumpadSubtract" + ) { + return "out"; + } + + if ( + event.key === "0" || + event.code === "Digit0" || + event.code === "Numpad0" + ) { + return "reset"; + } + + return null; +} + +export function shouldHandleAppZoomShortcut( + target: EventTarget | null, +): boolean { + if (!(target instanceof Element)) return true; + return !target.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`); +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 4b278e0..50f36b5 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { listen } from "@tauri-apps/api/event"; +import { initializeAppZoom } from "./lib/app-zoom"; import { createLogger } from "./lib/debug/logger"; import { APP_VISIBILITY_RESTORED } from "./lib/debug/log-store"; import "./styles/globals.css"; @@ -51,6 +52,7 @@ if (navigator.userAgent.includes("Windows")) { const rootEl = document.getElementById("root"); if (!rootEl) throw new Error("Root element #root not found"); +const rootContainer: HTMLElement = rootEl; function hideLoadingScreen() { const loading = document.getElementById("loading-screen"); @@ -61,23 +63,32 @@ function hideLoadingScreen() { getCurrentWindow().show(); } -if (isDebugWindow) { - // Debug window — render standalone debug page - import("./components/debug/debug-page").then(({ DebugPage }) => { - ReactDOM.createRoot(rootEl).render( +async function bootstrap() { + try { + await initializeAppZoom(); + } catch (error) { + log.error("Failed to initialize app zoom", { error: String(error) }); + } + + if (isDebugWindow) { + // Debug window — render standalone debug page + const { DebugPage } = await import("./components/debug/debug-page"); + ReactDOM.createRoot(rootContainer).render( , ); hideLoadingScreen(); - }); -} else { + return; + } + // Main app window - import("./App").then(({ App }) => { - ReactDOM.createRoot(rootEl).render( - - - , - ); - }); + const { App } = await import("./App"); + ReactDOM.createRoot(rootContainer).render( + + + , + ); } + +void bootstrap();