fix(editor): route zoom gestures and app zoom to the PDF viewer (#26652)

* fix(editor): route zoom gestures and app zoom to the PDF viewer

Ctrl-wheel / trackpad pinch over a PDF scrolled it instead of zooming, and
Cmd/Ctrl +/-/0 changed the (invisible) editor font zoom because the main
process intercepts zoom shortcuts before the PDF's keydown listener runs.

- The pdf.js session owns a non-passive ctrl-wheel listener for its
  container and zooms to an absolute scale anchored at the pointer.
- App zoom commands on an editor tab are first offered to the PDF in the
  command-owning (focused) editor pane; unclaimed commands keep editor font
  zoom.
- Wheel, toolbar and shortcut zoom all record the persisted scale.

Co-authored-by: Infinity_X <Infinity_X@sinomo.net>

* fix(editor): make pinch zoom track the trackpad more closely

Halve the wheel-zoom divisor so pinch zoom in PDFs and images moves twice as fast, and halve the per-event delta cap so a single event still zooms less than 2x.

Co-authored-by: Infinity_X <Infinity_X@sinomo.net>

* test(editor): type the PDF ownership test's document fixture

* fix(editor): keep fast pinch bursts anchored to the pointer

pdf.js refreshes its saved location on the next frame, so a second wheel event in the same frame zoomed around the old location and drifted. Call viewer.update() after the anchor scroll. Also make the ownership test's reset assertion depend on reset running.

---------

Co-authored-by: Infinity_X <Infinity_X@sinomo.net>
This commit is contained in:
Jinwoo Hong
2026-10-08 16:09:56 -04:00
committed by GitHub
co-authored by Infinity_X
parent a062abffd2
commit 47019ae458
13 changed files with 382 additions and 90 deletions
@@ -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.
<DiffNavigationProvider>
<EditorPanelShell
panelRef={setPanelRef}
activeFile={activeFile}
activeViewStateId={activeViewStateId}
model={model}
copiedPathVisible={copiedPathToast?.fileId === activeFile.id}
showMarkdownTableOfContents={isMarkdownTableOfContentsVisible}
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
markdownFrontmatterVisible={isMarkdownFrontmatterVisible}
sideBySide={sideBySide}
openFiles={openFiles}
fileContents={fileContents}
diffContents={diffContents}
editorDrafts={editorDrafts}
pendingEditorReveal={pendingEditorReveal}
renameDialogFile={renameDialogFile}
renameError={renameError}
disableRenameBrowse={disableRenameBrowse}
onCopyPath={() => 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}
/>
</DiffNavigationProvider>
<EditorCommandOwnerContext value={isCmdSaveOwner}>
{/* Why: each split pane needs an isolated bridge between its diff editor and header controls. */}
<DiffNavigationProvider>
<EditorPanelShell
panelRef={setPanelRef}
activeFile={activeFile}
activeViewStateId={activeViewStateId}
model={model}
copiedPathVisible={copiedPathToast?.fileId === activeFile.id}
showMarkdownTableOfContents={isMarkdownTableOfContentsVisible}
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
markdownFrontmatterVisible={isMarkdownFrontmatterVisible}
sideBySide={sideBySide}
openFiles={openFiles}
fileContents={fileContents}
diffContents={diffContents}
editorDrafts={editorDrafts}
pendingEditorReveal={pendingEditorReveal}
renameDialogFile={renameDialogFile}
renameError={renameError}
disableRenameBrowse={disableRenameBrowse}
onCopyPath={() => 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}
/>
</DiffNavigationProvider>
</EditorCommandOwnerContext>
)
}
@@ -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<InstanceType<typeof EventBus> | null>(null)
const findControllerRef = useRef<InstanceType<typeof PDFFindController> | null>(null)
const pdfViewerRef = useRef<InstanceType<typeof PdfJsViewer> | 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)
@@ -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)
@@ -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)
})
@@ -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
@@ -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()
})
})
@@ -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
}
@@ -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<typeof PdfJsViewer>
eventBus: InstanceType<typeof EventBus>
@@ -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({
@@ -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<string, never> }) => 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(
<EditorCommandOwnerContext value={isCommandOwner}>
<PdfViewer content={btoa('%PDF')} filePath="/repo/report.pdf" preferenceKey="report" />
</EditorCommandOwnerContext>
)
}
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)
})
})
@@ -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)
})
})
@@ -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)
}
@@ -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 })
+30 -1
View File
@@ -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 })