From bcfd37d48133db1849ac4b652ed500582c832fd1 Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 8 Mar 2026 14:54:50 +0100 Subject: [PATCH] fix: CodeMirror cursor placement at non-100% zoom levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: CSS zoom on document.documentElement caused stale CodeMirror measurements. Two issues: 1. Race condition: zoom was applied in useEffect (parent), but CodeMirror was created in useEffect (child) — child effects run first, so CM measured at zoom=1 before zoom was actually applied. 2. No re-measure on zoom change: CSS zoom changes don't trigger ResizeObserver on descendant elements, so CodeMirror never updated its cached scaleX/scaleY, line heights, or character widths. Fix: - Apply zoom synchronously during useState init (before child effects) - Dispatch 'laputa-zoom-change' event when zoom changes - Listen for this event in useCodeMirror and call view.requestMeasure() Co-Authored-By: Claude Opus 4.6 --- src/hooks/useCodeMirror.test.ts | 69 +++++++++++++++++++++++++++++++++ src/hooks/useCodeMirror.ts | 14 ++++++- src/hooks/useZoom.test.ts | 42 ++++++++++++++++++++ src/hooks/useZoom.ts | 14 ++++--- 4 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 src/hooks/useCodeMirror.test.ts diff --git a/src/hooks/useCodeMirror.test.ts b/src/hooks/useCodeMirror.test.ts new file mode 100644 index 00000000..d8729ee7 --- /dev/null +++ b/src/hooks/useCodeMirror.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror' + +const noop = () => {} +const noopCallbacks: CodeMirrorCallbacks = { + onDocChange: noop, + onCursorActivity: noop, + onSave: noop, + onEscape: () => false, +} + +describe('useCodeMirror', () => { + let container: HTMLDivElement + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + document.body.removeChild(container) + }) + + it('creates an EditorView in the container', () => { + const ref = { current: container } + const { result } = renderHook(() => + useCodeMirror(ref, 'hello world', false, noopCallbacks), + ) + expect(result.current.current).not.toBeNull() + expect(container.querySelector('.cm-editor')).toBeInTheDocument() + }) + + it('calls requestMeasure when laputa-zoom-change event fires', () => { + const ref = { current: container } + const { result } = renderHook(() => + useCodeMirror(ref, 'hello', false, noopCallbacks), + ) + const view = result.current.current! + const spy = vi.spyOn(view, 'requestMeasure') + + act(() => { + window.dispatchEvent(new Event('laputa-zoom-change')) + }) + + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('stops listening for zoom changes after unmount', () => { + const ref = { current: container } + const { result, unmount } = renderHook(() => + useCodeMirror(ref, 'hello', false, noopCallbacks), + ) + const view = result.current.current! + const spy = vi.spyOn(view, 'requestMeasure') + + unmount() + + act(() => { + window.dispatchEvent(new Event('laputa-zoom-change')) + }) + + // After unmount, the listener should be removed — requestMeasure should NOT be called. + // (The view is also destroyed on unmount, so this verifies cleanup.) + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) +}) diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index 30a49792..2ae48172 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -111,7 +111,19 @@ export function useCodeMirror( const view = new EditorView({ state, parent }) viewRef.current = view - return () => { view.destroy(); viewRef.current = null } + + // When CSS zoom changes on the document, CodeMirror's cached measurements + // (scaleX/scaleY, line heights, character widths) become stale because + // ResizeObserver doesn't fire for ancestor zoom changes. Force a re-measure + // so cursor placement stays accurate at any zoom level. + const handleZoomChange = () => { view.requestMeasure() } + window.addEventListener('laputa-zoom-change', handleZoomChange) + + return () => { + window.removeEventListener('laputa-zoom-change', handleZoomChange) + view.destroy() + viewRef.current = null + } // Re-create editor when isDark changes (theme is baked into extensions) // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDark]) diff --git a/src/hooks/useZoom.test.ts b/src/hooks/useZoom.test.ts index 6d5dd195..9408a311 100644 --- a/src/hooks/useZoom.test.ts +++ b/src/hooks/useZoom.test.ts @@ -109,4 +109,46 @@ describe('useZoom', () => { const { result } = renderHook(() => useZoom()) expect(result.current.zoomLevel).toBe(100) }) + + it('dispatches laputa-zoom-change event on zoomIn', () => { + const handler = vi.fn() + window.addEventListener('laputa-zoom-change', handler) + const { result } = renderHook(() => useZoom()) + handler.mockClear() // clear any init-phase dispatches + act(() => result.current.zoomIn()) + expect(handler).toHaveBeenCalled() + window.removeEventListener('laputa-zoom-change', handler) + }) + + it('dispatches laputa-zoom-change event on zoomOut', () => { + const handler = vi.fn() + window.addEventListener('laputa-zoom-change', handler) + const { result } = renderHook(() => useZoom()) + handler.mockClear() + act(() => result.current.zoomOut()) + expect(handler).toHaveBeenCalled() + window.removeEventListener('laputa-zoom-change', handler) + }) + + it('dispatches laputa-zoom-change event on zoomReset', () => { + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn()) + const handler = vi.fn() + window.addEventListener('laputa-zoom-change', handler) + const { result } = renderHook(() => useZoom()) + handler.mockClear() + act(() => result.current.zoomReset()) + expect(handler).toHaveBeenCalled() + window.removeEventListener('laputa-zoom-change', handler) + }) + + it('applies CSS zoom synchronously during initialization', () => { + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn()) + const spy = vi.spyOn(document.documentElement.style, 'setProperty') + renderHook(() => useZoom()) + // Zoom should be applied during state init (setProperty called with zoom value) + expect(spy).toHaveBeenCalledWith('zoom', '120%') + spy.mockRestore() + }) }) diff --git a/src/hooks/useZoom.ts b/src/hooks/useZoom.ts index 129e1450..e820ee18 100644 --- a/src/hooks/useZoom.ts +++ b/src/hooks/useZoom.ts @@ -29,6 +29,7 @@ function loadPersistedZoom(): number { function applyZoomToDocument(level: number): void { document.documentElement.style.setProperty('zoom', `${level}%`) + window.dispatchEvent(new Event('laputa-zoom-change')) } function persistZoom(level: number): void { @@ -36,12 +37,13 @@ function persistZoom(level: number): void { } export function useZoom() { - const [zoomLevel, setZoomLevel] = useState(loadPersistedZoom) - - // Apply persisted zoom on mount - useEffect(() => { - applyZoomToDocument(zoomLevel) - }, []) // eslint-disable-line react-hooks/exhaustive-deps -- only on mount + const [zoomLevel, setZoomLevel] = useState(() => { + const level = loadPersistedZoom() + // Apply zoom synchronously during init so child components (e.g. CodeMirror) + // measure the correct scale factor in their own effects. + document.documentElement.style.setProperty('zoom', `${level}%`) + return level + }) // Re-sync when vault config becomes available useEffect(() => {