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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user