mirror of
https://github.com/delibae/claude-prism.git
synced 2026-09-05 08:06:03 +00:00
Merge pull request #104 from L-N1988/feat/zoom-inout
This commit is contained in:
commit
69bcb494f4
8 changed files with 390 additions and 14 deletions
|
|
@ -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",
|
||||
|
|
|
|||
183
apps/desktop/src/__tests__/lib/app-zoom.test.ts
Normal file
183
apps/desktop/src/__tests__/lib/app-zoom.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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("/"))),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
|
|
|||
104
apps/desktop/src/lib/app-zoom.ts
Normal file
104
apps/desktop/src/lib/app-zoom.ts
Normal 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}]`);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue