diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx
index de466cd7074..118b14a42bb 100644
--- a/src/renderer/src/components/editor/EditorPanel.tsx
+++ b/src/renderer/src/components/editor/EditorPanel.tsx
@@ -9,6 +9,7 @@ import { exportActiveMarkdownToPdf } from './export-active-markdown'
import type { EditorToggleValue } from './EditorViewToggle'
import { EditorPanelShell } from './EditorPanelShell'
import { DiffNavigationProvider } from './diff-navigation-context'
+import { EditorCommandOwnerContext } from './editor-command-owner-context'
import { canUseChangesModeForFile } from './editor-panel-file-mode'
import { getEditorPanelRenderModel } from './editor-panel-render-model'
import { useEditorCmdSaveRequest } from './useEditorCmdSaveRequest'
@@ -317,61 +318,66 @@ function EditorPanelInner({
)
return (
- // Why: each split pane needs an isolated bridge between its diff editor and header controls.
-
- void handleCopyPath()}
- onOpenDiffTargetFile={handleOpenDiffTargetFile}
- onOpenPreviewToSide={handleOpenPreviewToSide}
- onOpenMarkdownPreview={handleOpenMarkdownPreview}
- onToggleSideBySide={() => setSideBySide((prev) => !prev)}
- onEditorToggleChange={handleEditorToggleChange}
- onToggleMarkdownTableOfContents={() =>
- setMarkdownTableOfContentsVisible(
- markdownDocumentStateFileId,
- !isMarkdownTableOfContentsVisible
- )
- }
- onToggleMarkdownFrontmatter={() =>
- setMarkdownFrontmatterVisible(markdownDocumentStateFileId, !isMarkdownFrontmatterVisible)
- }
- onExportMarkdownToPdf={() =>
- void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current })
- }
- createMarkdownArtifactRequest={
- activeMarkdownContent === null ? undefined : createActiveMarkdownArtifactRequest
- }
- onContentChange={handleContentChange}
- onContentChangeForFile={handleContentChangeForFile}
- onDirtyStateHint={handleDirtyStateHint}
- onSave={handleSave}
- onSaveForFile={handleSaveForFile}
- onReloadContent={reloadContent}
- onCloseMarkdownTableOfContents={() =>
- setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false)
- }
- onCloseRenameDialog={closeRenameDialog}
- onRenameConfirm={handleRenameConfirm}
- markdownAnnotationsEnabled={markdownAnnotationsEnabled && markdownReviewNotesEnabled}
- />
-
+
+ {/* Why: each split pane needs an isolated bridge between its diff editor and header controls. */}
+
+ void handleCopyPath()}
+ onOpenDiffTargetFile={handleOpenDiffTargetFile}
+ onOpenPreviewToSide={handleOpenPreviewToSide}
+ onOpenMarkdownPreview={handleOpenMarkdownPreview}
+ onToggleSideBySide={() => setSideBySide((prev) => !prev)}
+ onEditorToggleChange={handleEditorToggleChange}
+ onToggleMarkdownTableOfContents={() =>
+ setMarkdownTableOfContentsVisible(
+ markdownDocumentStateFileId,
+ !isMarkdownTableOfContentsVisible
+ )
+ }
+ onToggleMarkdownFrontmatter={() =>
+ setMarkdownFrontmatterVisible(
+ markdownDocumentStateFileId,
+ !isMarkdownFrontmatterVisible
+ )
+ }
+ onExportMarkdownToPdf={() =>
+ void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current })
+ }
+ createMarkdownArtifactRequest={
+ activeMarkdownContent === null ? undefined : createActiveMarkdownArtifactRequest
+ }
+ onContentChange={handleContentChange}
+ onContentChangeForFile={handleContentChangeForFile}
+ onDirtyStateHint={handleDirtyStateHint}
+ onSave={handleSave}
+ onSaveForFile={handleSaveForFile}
+ onReloadContent={reloadContent}
+ onCloseMarkdownTableOfContents={() =>
+ setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false)
+ }
+ onCloseRenameDialog={closeRenameDialog}
+ onRenameConfirm={handleRenameConfirm}
+ markdownAnnotationsEnabled={markdownAnnotationsEnabled && markdownReviewNotesEnabled}
+ />
+
+
)
}
diff --git a/src/renderer/src/components/editor/PdfViewer.tsx b/src/renderer/src/components/editor/PdfViewer.tsx
index b63ce8ef1ff..1243f2664a2 100644
--- a/src/renderer/src/components/editor/PdfViewer.tsx
+++ b/src/renderer/src/components/editor/PdfViewer.tsx
@@ -1,5 +1,5 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: PDF loading drives pdf.js document/viewer instances and decode errors through an external worker lifecycle. */
-import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { type JSX, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { Image as ImageIcon, RotateCcw, Search, ZoomIn, ZoomOut } from 'lucide-react'
import * as pdfjsLib from 'pdfjs-dist'
import type {
@@ -24,6 +24,8 @@ import {
type PdfScalePreference
} from './pdf-scale-preference'
import { readPdfScalePreference, writePdfScalePreference } from './pdf-scale-preference-storage'
+import { EditorCommandOwnerContext } from './editor-command-owner-context'
+import { listenForPdfZoomRequests } from './pdf-zoom-request'
pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl
@@ -66,6 +68,7 @@ function PdfDocumentViewer({
const [scale, setScale] = useState(1)
const keybindings = useAppStore((state) => state.keybindings)
const findShortcutLabel = useShortcutLabel('editor.find')
+ const isCommandOwner = useContext(EditorCommandOwnerContext)
const eventBusRef = useRef | null>(null)
const findControllerRef = useRef | null>(null)
const pdfViewerRef = useRef | null>(null)
@@ -76,6 +79,18 @@ function PdfDocumentViewer({
const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath])
const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content])
+ // Why: every zoom entry point (toolbar, app zoom command, wheel/pinch) must record the
+ // scale preference so the next content reload restores it (see scalePreferenceRef).
+ const recordScalePreference = useCallback(
+ (preference: PdfScalePreference) => {
+ scalePreferenceRef.current = preference
+ if (preferenceKey) {
+ writePdfScalePreference(preferenceKey, preference)
+ }
+ },
+ [preferenceKey]
+ )
+
useEffect(() => {
const container = containerRef.current
const viewerDiv = viewerDivRef.current
@@ -97,7 +112,8 @@ function PdfDocumentViewer({
scrollCacheKey,
scalePreference: scalePreferenceRef.current,
scaleBounds: SCALE_BOUNDS,
- onScaleChanging: setScale
+ onScaleChanging: setScale,
+ onWheelZoom: recordScalePreference
})
eventBusRef.current = session.eventBus
findControllerRef.current = session.findController
@@ -119,7 +135,7 @@ function PdfDocumentViewer({
loaderRef.current = null
loader.dispose()
}
- }, [filePath, preferenceKey, scrollCacheKey])
+ }, [filePath, preferenceKey, recordScalePreference, scrollCacheKey])
useEffect(() => {
loaderRef.current?.load(cleanedContent)
@@ -134,8 +150,6 @@ function PdfDocumentViewer({
setFindOpen(false)
}, [])
- // Why: every zoom entry point (toolbar + keyboard) must record the scale
- // preference so the next content reload restores it (see scalePreferenceRef).
const stepZoom = useCallback(
(direction: 'in' | 'out') => {
const viewer = pdfViewerRef.current
@@ -144,12 +158,9 @@ function PdfDocumentViewer({
}
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
viewer.currentScale = next.scale
- scalePreferenceRef.current = next.preference
- if (preferenceKey) {
- writePdfScalePreference(preferenceKey, next.preference)
- }
+ recordScalePreference(next.preference)
},
- [preferenceKey]
+ [recordScalePreference]
)
const zoomIn = useCallback(() => stepZoom('in'), [stepZoom])
@@ -160,12 +171,18 @@ function PdfDocumentViewer({
if (!viewer) {
return
}
- scalePreferenceRef.current = 'page-width'
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
- if (preferenceKey) {
- writePdfScalePreference(preferenceKey, 'page-width')
+ recordScalePreference('page-width')
+ }, [recordScalePreference])
+
+ useEffect(() => {
+ if (!isCommandOwner) {
+ return
}
- }, [preferenceKey])
+ return listenForPdfZoomRequests((direction) =>
+ direction === 'reset' ? zoomReset() : stepZoom(direction)
+ )
+ }, [isCommandOwner, stepZoom, zoomReset])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
@@ -174,22 +191,11 @@ function PdfDocumentViewer({
e.preventDefault()
e.stopPropagation()
setFindOpen(true)
- return
- }
- if (keybindingMatchesAction('zoom.in', e, platform, keybindings)) {
- e.preventDefault()
- zoomIn()
- } else if (keybindingMatchesAction('zoom.out', e, platform, keybindings)) {
- e.preventDefault()
- zoomOut()
- } else if (keybindingMatchesAction('zoom.reset', e, platform, keybindings)) {
- e.preventDefault()
- zoomReset()
}
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
- }, [keybindings, zoomIn, zoomOut, zoomReset])
+ }, [keybindings])
const zoomPercent = Math.round(scale * 100)
diff --git a/src/renderer/src/components/editor/editor-command-owner-context.ts b/src/renderer/src/components/editor/editor-command-owner-context.ts
new file mode 100644
index 00000000000..e78988a642c
--- /dev/null
+++ b/src/renderer/src/components/editor/editor-command-owner-context.ts
@@ -0,0 +1,4 @@
+import { createContext } from 'react'
+
+// Why: split panes and hidden worktrees keep editors mounted; only the focused pane answers app commands.
+export const EditorCommandOwnerContext = createContext(false)
diff --git a/src/renderer/src/components/editor/image-viewer-zoom.test.ts b/src/renderer/src/components/editor/image-viewer-zoom.test.ts
index 367c4f365e2..c67aa8899c3 100644
--- a/src/renderer/src/components/editor/image-viewer-zoom.test.ts
+++ b/src/renderer/src/components/editor/image-viewer-zoom.test.ts
@@ -43,8 +43,8 @@ describe('image viewer zoom helpers', () => {
})
it('bounds very large per-event zoom factors', () => {
- expect(getPinchZoomFactor(-10_000, 0)).toBeCloseTo(getPinchZoomFactor(-200, 0))
- expect(getPinchZoomFactor(10_000, 0)).toBeCloseTo(getPinchZoomFactor(200, 0))
+ expect(getPinchZoomFactor(-10_000, 0)).toBeCloseTo(getPinchZoomFactor(-100, 0))
+ expect(getPinchZoomFactor(10_000, 0)).toBeCloseTo(getPinchZoomFactor(100, 0))
expect(getPinchZoomFactor(-10_000, 0)).toBeLessThan(2)
expect(getPinchZoomFactor(10_000, 0)).toBeGreaterThan(0.5)
})
diff --git a/src/renderer/src/components/editor/image-viewer-zoom.ts b/src/renderer/src/components/editor/image-viewer-zoom.ts
index 3f6d2c30e08..f9896bf29da 100644
--- a/src/renderer/src/components/editor/image-viewer-zoom.ts
+++ b/src/renderer/src/components/editor/image-viewer-zoom.ts
@@ -7,8 +7,8 @@ const DOM_DELTA_LINE = 1
const DOM_DELTA_PAGE = 2
const PIXELS_PER_LINE = 16
const PIXELS_PER_PAGE = 800
-const MAX_NORMALIZED_WHEEL_DELTA = 200
-const WHEEL_ZOOM_SENSITIVITY = 300
+const MAX_NORMALIZED_WHEEL_DELTA = 100
+const WHEEL_ZOOM_SENSITIVITY = 150
type ImageZoomWheelEventLike = {
ctrlKey: boolean
diff --git a/src/renderer/src/components/editor/pdf-scale-preference.test.ts b/src/renderer/src/components/editor/pdf-scale-preference.test.ts
index 6d3ac4a7584..51ecc701c2d 100644
--- a/src/renderer/src/components/editor/pdf-scale-preference.test.ts
+++ b/src/renderer/src/components/editor/pdf-scale-preference.test.ts
@@ -1,9 +1,11 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import {
applyPdfScalePreference,
clampPdfScale,
- stepPdfScalePreference
+ stepPdfScalePreference,
+ zoomPdfViewerWithWheel
} from './pdf-scale-preference'
+import { getPinchZoomFactor } from './image-viewer-zoom'
const BOUNDS = { min: 0.25, max: 5, step: 1.25 }
@@ -46,3 +48,59 @@ describe('stepPdfScalePreference', () => {
expect(zoomedOut.scale).toBe(1)
})
})
+
+describe('zoomPdfViewerWithWheel', () => {
+ function wheel(deltaY: number, ctrlKey = true) {
+ return { ctrlKey, deltaY, deltaMode: 0, clientX: 300, clientY: 250, preventDefault: vi.fn() }
+ }
+ function viewerAt(scale: number) {
+ return {
+ currentScale: scale,
+ container: {
+ scrollLeft: 100,
+ scrollTop: 400,
+ getBoundingClientRect: () => ({ left: 200, top: 50 })
+ },
+ update: vi.fn()
+ }
+ }
+
+ it('leaves plain wheel scrolling alone', () => {
+ const viewer = viewerAt(1)
+ const event = wheel(-30, false)
+ expect(zoomPdfViewerWithWheel(viewer, event, BOUNDS)).toBeNull()
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ expect(viewer.currentScale).toBe(1)
+ })
+
+ it('keeps the content under the pointer in place', () => {
+ const viewer = viewerAt(1)
+ const event = wheel(-30)
+ expect(zoomPdfViewerWithWheel(viewer, event, BOUNDS)).toBe(viewer.currentScale)
+ expect(event.preventDefault).toHaveBeenCalled()
+ const growth = viewer.currentScale - 1
+ expect(growth).toBeGreaterThan(0)
+ // Pointer sits 100px right of and 200px below the container's top-left.
+ expect(viewer.container.scrollLeft).toBeCloseTo(100 + 100 * growth)
+ expect(viewer.container.scrollTop).toBeCloseTo(400 + 200 * growth)
+ // The next same-frame event must anchor to this scroll position.
+ expect(viewer.update).toHaveBeenCalledOnce()
+ })
+
+ it('accumulates slow trackpad pinches instead of rounding them away', () => {
+ const viewer = viewerAt(0.5)
+ for (let i = 0; i < 30; i++) {
+ zoomPdfViewerWithWheel(viewer, wheel(-1), BOUNDS)
+ }
+ expect(viewer.currentScale).toBeCloseTo(0.5 * getPinchZoomFactor(-1, 0) ** 30)
+ })
+
+ it('claims the gesture but stops at the scale bounds', () => {
+ const viewer = viewerAt(BOUNDS.max)
+ const event = wheel(-30)
+ expect(zoomPdfViewerWithWheel(viewer, event, BOUNDS)).toBeNull()
+ expect(event.preventDefault).toHaveBeenCalled()
+ expect(viewer.container.scrollLeft).toBe(100)
+ expect(viewer.update).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/editor/pdf-scale-preference.ts b/src/renderer/src/components/editor/pdf-scale-preference.ts
index abdba00c6a9..27f50254a28 100644
--- a/src/renderer/src/components/editor/pdf-scale-preference.ts
+++ b/src/renderer/src/components/editor/pdf-scale-preference.ts
@@ -1,3 +1,5 @@
+import { getPinchZoomFactor, shouldHandleImageZoomWheel } from './image-viewer-zoom'
+
export type PdfScalePreference = 'page-width' | number
export function clampPdfScale(scale: number, min: number, max: number): number {
@@ -29,3 +31,48 @@ export function stepPdfScalePreference(
: clampPdfScale(currentScale / bounds.step, bounds.min, bounds.max)
return { scale: next, preference: next }
}
+
+type PdfWheelZoomEvent = Pick<
+ WheelEvent,
+ 'ctrlKey' | 'deltaY' | 'deltaMode' | 'clientX' | 'clientY' | 'preventDefault'
+>
+
+/** Ctrl-wheel / trackpad-pinch zoom that keeps the content under the pointer in place. */
+export function zoomPdfViewerWithWheel(
+ viewer: {
+ currentScale: number
+ container: {
+ scrollLeft: number
+ scrollTop: number
+ getBoundingClientRect: () => { left: number; top: number }
+ }
+ update: () => void
+ },
+ event: PdfWheelZoomEvent,
+ bounds: { min: number; max: number }
+): number | null {
+ if (!shouldHandleImageZoomWheel(event)) {
+ return null
+ }
+ event.preventDefault()
+ const previous = viewer.currentScale
+ const next = clampPdfScale(
+ previous * getPinchZoomFactor(event.deltaY, event.deltaMode),
+ bounds.min,
+ bounds.max
+ )
+ if (next === previous) {
+ return null
+ }
+ const { container } = viewer
+ const rect = container.getBoundingClientRect()
+ // Why: an absolute scale avoids updateScale's 0.01 rounding, which swallows slow pinches.
+ viewer.currentScale = next
+ // Why: pdf.js keeps the viewport's top-left fixed, so shift by the pointer's grown offset.
+ const growth = next / previous - 1
+ container.scrollLeft += (event.clientX - rect.left) * growth
+ container.scrollTop += (event.clientY - rect.top) * growth
+ // Why: pdf.js re-reads its location on the next frame; a faster pinch event would anchor to the old one.
+ viewer.update()
+ return next
+}
diff --git a/src/renderer/src/components/editor/pdf-viewer-session.ts b/src/renderer/src/components/editor/pdf-viewer-session.ts
index dfa80fff68b..52b14b3eefe 100644
--- a/src/renderer/src/components/editor/pdf-viewer-session.ts
+++ b/src/renderer/src/components/editor/pdf-viewer-session.ts
@@ -5,7 +5,11 @@ import {
PDFLinkService,
PDFViewer as PdfJsViewer
} from 'pdfjs-dist/web/pdf_viewer.mjs'
-import { applyPdfScalePreference, type PdfScalePreference } from './pdf-scale-preference'
+import {
+ applyPdfScalePreference,
+ zoomPdfViewerWithWheel,
+ type PdfScalePreference
+} from './pdf-scale-preference'
import { pdfViewPositionCache, setWithLRU } from '@/lib/scroll-cache'
import {
buildPdfScrollDestination,
@@ -33,7 +37,8 @@ export function createPdfViewerSession({
scrollCacheKey,
scalePreference,
scaleBounds,
- onScaleChanging
+ onScaleChanging,
+ onWheelZoom
}: {
container: HTMLDivElement
viewerDiv: HTMLDivElement
@@ -42,6 +47,7 @@ export function createPdfViewerSession({
scalePreference: PdfScalePreference
scaleBounds: { min: number; max: number }
onScaleChanging: (scale: number) => void
+ onWheelZoom: (scale: number) => void
}): {
viewer: InstanceType
eventBus: InstanceType
@@ -77,6 +83,18 @@ export function createPdfViewerSession({
}
eventBus.on('scalechanging', handleScaleChanging)
+ // Why: Chromium reports trackpad pinch as ctrl-wheel; unclaimed, it only scrolls the PDF.
+ container.addEventListener(
+ 'wheel',
+ (event) => {
+ const scale = zoomPdfViewerWithWheel(viewer, event, scaleBounds)
+ if (scale !== null) {
+ onWheelZoom(scale)
+ }
+ },
+ { passive: false, signal: abortController.signal }
+ )
+
// Why: each displayed document owns its scroll recorder and cache key.
const recorder = scrollCacheKey
? createPdfViewPositionRecorder({
diff --git a/src/renderer/src/components/editor/pdf-viewer-zoom-ownership.test.tsx b/src/renderer/src/components/editor/pdf-viewer-zoom-ownership.test.tsx
new file mode 100644
index 00000000000..ff4c6340046
--- /dev/null
+++ b/src/renderer/src/components/editor/pdf-viewer-zoom-ownership.test.tsx
@@ -0,0 +1,77 @@
+// @vitest-environment happy-dom
+import { act, cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import PdfViewer from './PdfViewer'
+import { EditorCommandOwnerContext } from './editor-command-owner-context'
+import { requestPdfZoom } from './pdf-zoom-request'
+import { readPdfScalePreference } from './pdf-scale-preference-storage'
+
+type PdfDocumentFixture = { name: string }
+
+const viewers = vi.hoisted((): { currentScale: number; currentScaleValue: string }[] => [])
+
+vi.mock('pdfjs-dist', () => ({ GlobalWorkerOptions: {} }))
+vi.mock('pdfjs-dist/web/pdf_viewer.css', () => ({}))
+vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }))
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => '' }))
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: { keybindings: Record }) => unknown) =>
+ selector({ keybindings: {} })
+}))
+vi.mock('./pdf-document-loader', () => ({
+ createPdfDocumentLoader: ({ display }: { display: (doc: PdfDocumentFixture) => () => void }) => ({
+ load: () => display({ name: 'report.pdf' }),
+ dispose: () => {}
+ })
+}))
+vi.mock('./pdf-viewer-session', () => ({
+ createPdfViewerSession: () => {
+ const viewer = { currentScale: 1, currentScaleValue: 'page-width' }
+ viewers.push(viewer)
+ return { viewer, eventBus: null, findController: null, dispose: () => {} }
+ }
+}))
+
+afterEach(() => {
+ cleanup()
+ viewers.length = 0
+ localStorage.clear()
+})
+
+function renderPdf(isCommandOwner: boolean): void {
+ render(
+
+
+
+ )
+}
+
+describe('PdfViewer app zoom ownership', () => {
+ it('zooms the PDF in the command-owning editor pane', () => {
+ renderPdf(true)
+
+ let claimed = false
+ act(() => {
+ claimed = requestPdfZoom('in')
+ })
+
+ expect(claimed).toBe(true)
+ expect(viewers[0].currentScale).toBe(1.25)
+ expect(readPdfScalePreference('report')).toBe(1.25)
+
+ viewers[0].currentScaleValue = '1.25'
+ act(() => {
+ requestPdfZoom('reset')
+ })
+ expect(viewers[0].currentScaleValue).toBe('page-width')
+ expect(readPdfScalePreference('report')).toBe('page-width')
+ })
+
+ it('leaves zoom to the focused pane when this PDF is in an unfocused split or hidden worktree', () => {
+ renderPdf(false)
+
+ expect(requestPdfZoom('in')).toBe(false)
+ expect(viewers[0].currentScale).toBe(1)
+ })
+})
diff --git a/src/renderer/src/components/editor/pdf-zoom-request.test.ts b/src/renderer/src/components/editor/pdf-zoom-request.test.ts
new file mode 100644
index 00000000000..b8b33bf6876
--- /dev/null
+++ b/src/renderer/src/components/editor/pdf-zoom-request.test.ts
@@ -0,0 +1,18 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi } from 'vitest'
+import { listenForPdfZoomRequests, requestPdfZoom } from './pdf-zoom-request'
+
+describe('requestPdfZoom', () => {
+ it('reports whether a PDF took the zoom command', () => {
+ expect(requestPdfZoom('in')).toBe(false)
+
+ const onZoom = vi.fn()
+ const stop = listenForPdfZoomRequests(onZoom)
+ expect(requestPdfZoom('out')).toBe(true)
+ expect(onZoom).toHaveBeenCalledWith('out')
+
+ stop()
+ expect(requestPdfZoom('reset')).toBe(false)
+ expect(onZoom).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/renderer/src/components/editor/pdf-zoom-request.ts b/src/renderer/src/components/editor/pdf-zoom-request.ts
new file mode 100644
index 00000000000..5f46b78176a
--- /dev/null
+++ b/src/renderer/src/components/editor/pdf-zoom-request.ts
@@ -0,0 +1,24 @@
+export type PdfZoomDirection = 'in' | 'out' | 'reset'
+
+const PDF_ZOOM_REQUEST_EVENT = 'orca:pdf-zoom-request'
+
+/** Offers an app zoom command to the focused pane's PDF; true when a PDF took it. */
+export function requestPdfZoom(direction: PdfZoomDirection): boolean {
+ const event = new CustomEvent(PDF_ZOOM_REQUEST_EVENT, { detail: direction, cancelable: true })
+ window.dispatchEvent(event)
+ return event.defaultPrevented
+}
+
+export function listenForPdfZoomRequests(
+ onZoom: (direction: PdfZoomDirection) => void
+): () => void {
+ const listener = (event: Event): void => {
+ const direction: unknown = event instanceof CustomEvent ? event.detail : null
+ if (direction === 'in' || direction === 'out' || direction === 'reset') {
+ event.preventDefault()
+ onZoom(direction)
+ }
+ }
+ window.addEventListener(PDF_ZOOM_REQUEST_EVENT, listener)
+ return () => window.removeEventListener(PDF_ZOOM_REQUEST_EVENT, listener)
+}
diff --git a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts
index 6d233790909..763c553fcfd 100644
--- a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts
+++ b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts
@@ -6,6 +6,7 @@ import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
import { stepUIZoomLevel } from '../../../../shared/ui-zoom-level'
import { useAppStore } from '../../store'
import { resolveZoomTarget } from '../resolve-zoom-target'
+import { requestPdfZoom } from '@/components/editor/pdf-zoom-request'
export function registerZoomIpcBridge(unsubs: (() => void)[]): void {
// Zoom handling for menu accelerators and keyboard fallback paths.
@@ -29,6 +30,10 @@ export function registerZoomIpcBridge(unsubs: (() => void)[]): void {
return
}
if (target === 'editor') {
+ // Why: a PDF owns its page zoom; editor font zoom would change nothing visible.
+ if (requestPdfZoom(direction)) {
+ return
+ }
const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction)
setEditorFontZoomLevel(next)
void window.api.ui.set({ editorFontZoomLevel: next })
diff --git a/src/renderer/src/hooks/zoom-routing.test.ts b/src/renderer/src/hooks/zoom-routing.test.ts
index 370e229fb01..4ee5624e26b 100644
--- a/src/renderer/src/hooks/zoom-routing.test.ts
+++ b/src/renderer/src/hooks/zoom-routing.test.ts
@@ -113,6 +113,7 @@ describe('registerZoomIpcBridge', () => {
uiZoomLevel?: number
nativeChatAppearance?: NativeChatAppearanceSettings
editorFontZoomLevel?: number
+ pdfTakesZoom?: boolean
} = {}
) {
const {
@@ -121,7 +122,8 @@ describe('registerZoomIpcBridge', () => {
activeElement = makeTarget({ editorClosest: true, hasXtermClass: true }),
uiZoomLevel = 0,
editorFontZoomLevel = 0,
- nativeChatAppearance
+ nativeChatAppearance,
+ pdfTakesZoom = false
} = args
const applyUIZoom = vi.fn()
@@ -133,6 +135,8 @@ describe('registerZoomIpcBridge', () => {
Object.assign(settings, updates)
})
+ const requestPdfZoom = vi.fn(() => pdfTakesZoom)
+ vi.doMock('@/components/editor/pdf-zoom-request', () => ({ requestPdfZoom }))
vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom }))
vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged }))
vi.doMock('../store', () => ({
@@ -176,6 +180,7 @@ describe('registerZoomIpcBridge', () => {
}
return {
fire,
+ requestPdfZoom,
applyUIZoom,
dispatchZoomLevelChanged,
setEditorFontZoomLevel,
@@ -264,6 +269,30 @@ describe('registerZoomIpcBridge', () => {
expect(zoom.applyUIZoom).not.toHaveBeenCalled()
})
+ it('hands editor-tab zoom to the PDF it shows instead of editor font zoom', async () => {
+ const zoom = await mountZoomBridge({
+ activeTabType: 'editor',
+ activeElement: makeTarget({}),
+ pdfTakesZoom: true
+ })
+
+ zoom.fire('out')
+
+ expect(zoom.requestPdfZoom).toHaveBeenCalledWith('out')
+ expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled()
+ expect(zoom.dispatchZoomLevelChanged).not.toHaveBeenCalled()
+ expect(zoom.applyUIZoom).not.toHaveBeenCalled()
+ })
+
+ it('never offers app zoom outside editor tabs to a PDF', async () => {
+ const zoom = await mountZoomBridge({ activeTabType: 'browser', pdfTakesZoom: true })
+
+ zoom.fire('in')
+
+ expect(zoom.requestPdfZoom).not.toHaveBeenCalled()
+ expect(zoom.applyUIZoom).toHaveBeenCalledWith(0.5)
+ })
+
it('clamps app zoom at the supported maximum', async () => {
const zoom = await mountZoomBridge({ uiZoomLevel: UI_ZOOM_MAX })