Merge pull request #104 from L-N1988/feat/zoom-inout

This commit is contained in:
Hanjin Bae 2026-03-27 22:45:50 +09:00 committed by GitHub
commit 69bcb494f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 390 additions and 14 deletions

View file

@ -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",

View file

@ -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);
});
});

View file

@ -1,11 +1,54 @@
import { vi } from "vitest";
const storageData = new Map<string, string>();
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("/"))),

View file

@ -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({
<div
ref={containerRef}
tabIndex={-1}
{...{ [LOCAL_ZOOM_SHORTCUTS_ATTR]: "true" }}
className="relative h-full overflow-auto bg-muted/50 p-4 outline-none"
style={
cropMode
? { cursor: cropRect && !dragStart ? "default" : "crosshair" }
: undefined
}
onMouseDownCapture={() => containerRef.current?.focus()}
onMouseMove={cropMode ? handleCropMouseMove : undefined}
onMouseUp={cropMode ? handleCropMouseUp : undefined}
onMouseLeave={cropMode ? handleCropMouseUp : undefined}

View file

@ -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({
<div
ref={containerRef}
tabIndex={-1}
{...{ [LOCAL_ZOOM_SHORTCUTS_ATTR]: "true" }}
className="min-h-0 flex-1 overflow-auto outline-none"
style={{ cursor: captureMode ? "crosshair" : undefined }}
onMouseDownCapture={() => containerRef.current?.focus()}
onMouseDown={handleCaptureMouseDown}
onMouseMove={handleCaptureMouseMove}
onMouseUp={handleCaptureMouseUp}

View file

@ -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);
};
}, []);
}

View file

@ -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<number> {
const zoom = clampAppZoom(value);
await getCurrentWebview().setZoom(zoom);
return zoom;
}
export async function persistAppZoom(value: number): Promise<number> {
const zoom = await applyAppZoom(value);
window.localStorage.setItem(APP_ZOOM_STORAGE_KEY, zoom.toString());
return zoom;
}
export function initializeAppZoom(): Promise<number> {
return applyAppZoom(readStoredAppZoom());
}
export function zoomInApp(): Promise<number> {
return persistAppZoom(readStoredAppZoom() + APP_ZOOM_STEP);
}
export function zoomOutApp(): Promise<number> {
return persistAppZoom(readStoredAppZoom() - APP_ZOOM_STEP);
}
export function resetAppZoom(): Promise<number> {
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}]`);
}

View file

@ -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(
<React.StrictMode>
<DebugPage />
</React.StrictMode>,
);
hideLoadingScreen();
});
} else {
return;
}
// Main app window
import("./App").then(({ App }) => {
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<App onReady={hideLoadingScreen} />
</React.StrictMode>,
);
});
const { App } = await import("./App");
ReactDOM.createRoot(rootContainer).render(
<React.StrictMode>
<App onReady={hideLoadingScreen} />
</React.StrictMode>,
);
}
void bootstrap();