fix: CodeMirror cursor placement at non-100% zoom levels

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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-08 14:54:50 +01:00
parent f27ebe05c4
commit bcfd37d481
4 changed files with 132 additions and 7 deletions

View File

@@ -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()
})
})

View File

@@ -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])

View File

@@ -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()
})
})

View File

@@ -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(() => {