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:
69
src/hooks/useCodeMirror.test.ts
Normal file
69
src/hooks/useCodeMirror.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user