diff --git a/src/renderer/src/components/editor/ImageViewer.test.tsx b/src/renderer/src/components/editor/ImageViewer.test.tsx index 8845cf33bd5..5e73b4ef4c2 100644 --- a/src/renderer/src/components/editor/ImageViewer.test.tsx +++ b/src/renderer/src/components/editor/ImageViewer.test.tsx @@ -18,6 +18,9 @@ vi.mock('react', async () => { useEffect() { return undefined }, + useContext() { + return false + }, useRef(initial: T) { return { current: initial } }, diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index 73aaa09adc8..b44fc845d10 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -1,8 +1,10 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: image surface size is measured with ResizeObserver and DOM refs, which are external layout systems outside render derivation. */ import { Image as ImageIcon, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react' -import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type JSX, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { cn } from '@/lib/utils' import ImageViewerPopup from './ImageViewerPopup' +import { EditorCommandOwnerContext } from './editor-command-owner-context' +import { listenForViewerZoomRequests } from './viewer-zoom-request' import PdfViewer from './PdfViewer' import { type ApplyImageViewerZoomChange, @@ -54,6 +56,7 @@ export default function ImageViewer({ const [popupSurfaceSize, setPopupSurfaceSize] = useState(null) const [imageDimensions, setImageDimensions] = useState(null) const [failedPreviewSrc, setFailedPreviewSrc] = useState(null) + const isCommandOwner = useContext(EditorCommandOwnerContext) const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath]) const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content]) @@ -217,6 +220,34 @@ export default function ImageViewer({ return () => observer.disconnect() }, [isPopupOpen]) + useEffect(() => { + // Why: PDFs own their zoom; combined diff images yield to editor text until a popup opens. + if (!isCommandOwner || isPdf || (isIntrinsicLayout && !isPopupOpen)) { + return + } + const applyZoomChange = isPopupOpen ? applyPopupZoomChange : applyInlineZoomChange + return listenForViewerZoomRequests( + (direction) => + applyZoomChange((currentZoom) => { + if (direction === 'reset') { + return 1 + } + return direction === 'in' + ? currentZoom * IMAGE_VIEWER_ZOOM_STEP + : currentZoom / IMAGE_VIEWER_ZOOM_STEP + }), + isPopupOpen ? popupSurfaceRef : inlineSurfaceRef, + isPopupOpen ? 'popup' : 'pane' + ) + }, [ + applyInlineZoomChange, + applyPopupZoomChange, + isCommandOwner, + isIntrinsicLayout, + isPdf, + isPopupOpen + ]) + if (isPdf) { return ( - direction === 'reset' ? zoomReset() : stepZoom(direction) + return listenForViewerZoomRequests( + (direction) => (direction === 'reset' ? zoomReset() : stepZoom(direction)), + rootRef ) }, [isCommandOwner, stepZoom, zoomReset]) diff --git a/src/renderer/src/components/editor/image-viewer-zoom-ownership.test.tsx b/src/renderer/src/components/editor/image-viewer-zoom-ownership.test.tsx new file mode 100644 index 00000000000..56e24597f2d --- /dev/null +++ b/src/renderer/src/components/editor/image-viewer-zoom-ownership.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import ImageViewer from './ImageViewer' +import ImageDiffViewer from './ImageDiffViewer' +import { EditorCommandOwnerContext } from './editor-command-owner-context' +import { requestViewerZoom } from './viewer-zoom-request' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('./PdfViewer', () => ({ default: () => null })) + +// 1x1 PNG +const PNG = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' + +afterEach(cleanup) + +function renderImage(isCommandOwner: boolean, layout: 'fill' | 'intrinsic' = 'fill'): void { + render( + + + + ) +} + +function zoom(direction: 'in' | 'out' | 'reset'): boolean { + let claimed = false + act(() => { + claimed = requestViewerZoom(direction) + }) + return claimed +} + +describe('ImageViewer app zoom ownership', () => { + it('zooms the image in the command-owning editor pane', () => { + renderImage(true) + + expect(zoom('in')).toBe(true) + expect(screen.getByText('125%')).toBeTruthy() + + zoom('in') + zoom('out') + expect(screen.getByText('125%')).toBeTruthy() + + zoom('reset') + expect(screen.getByText('100%')).toBeTruthy() + }) + + it('zooms the open popup instead of the hidden inline image', () => { + renderImage(true) + fireEvent.click(screen.getByTitle('Open image in popup')) + + zoom('in') + + expect(within(screen.getByRole('dialog')).getByText('125%')).toBeTruthy() + expect(screen.getAllByText('100%')).toHaveLength(1) + }) + + it('releases zoom ownership when focus moves and removes its listener on unmount', () => { + const image = (ownsCommands: boolean) => ( + + + + ) + const viewer = render(image(true)) + expect(zoom('in')).toBe(true) + + viewer.rerender(image(false)) + expect(zoom('in')).toBe(false) + expect(screen.getByText('125%')).toBeTruthy() + + viewer.rerender(image(true)) + expect(zoom('in')).toBe(true) + expect(screen.getByText('156%')).toBeTruthy() + + viewer.unmount() + expect(zoom('in')).toBe(false) + }) + + it('zooms a diff popup without changing the other image behind it', () => { + render( + + + + ) + fireEvent.click(screen.getAllByTitle('Open image in popup')[1]) + + expect(zoom('in')).toBe(true) + expect(within(screen.getByRole('dialog')).getByText('125%')).toBeTruthy() + expect(screen.getAllByText('100%')).toHaveLength(2) + + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' })) + expect(zoom('in')).toBe(true) + expect(screen.getAllByText('125%')).toHaveLength(2) + }) + + it('routes between main and floating image owners using DOM focus', () => { + const image = (filePath: string) => ( + + + + ) + render( + <> +
+ {image('/repo/main.png')} +
+
+ {image('/repo/floating.png')} +
+ + ) + const main = screen.getByTestId('main') + const floating = screen.getByTestId('floating') + + floating.focus() + expect(zoom('in')).toBe(true) + expect(within(floating).getByText('125%')).toBeTruthy() + expect(within(main).getByText('100%')).toBeTruthy() + + main.focus() + expect(zoom('in')).toBe(true) + expect(within(main).getByText('125%')).toBeTruthy() + expect(within(floating).getByText('125%')).toBeTruthy() + + floating.focus() + fireEvent.click(within(floating).getByTitle('Open image in popup')) + expect(zoom('in')).toBe(true) + expect(within(screen.getByRole('dialog')).getByText('156%')).toBeTruthy() + expect(within(main).getByText('125%')).toBeTruthy() + expect(within(floating).getByText('125%')).toBeTruthy() + }) + + it.each([true, false])( + 'leaves text zoom unclaimed across surfaces (floating image: %s)', + (floating) => { + render( + <> +
+ + + +
+
+