fix: bypass CodeMirror posAtCoords for accurate cursor at non-100% CSS zoom

CSS zoom on document.documentElement causes a coordinate space mismatch
between mouse event clientX/Y (viewport space) and Range.getClientRects()
(CSS space), breaking CodeMirror's click-to-position mapping. The previous
requestMeasure() fix only recalibrated cached geometry, not this mismatch.

New approach: zoomCursorFix extension patches posAtCoords/posAndSideAtCoords
on the EditorView instance to use document.caretRangeFromPoint() — the
browser's native, zoom-aware API — with coord-adjustment fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-09 10:43:46 +01:00
parent e3d616cd26
commit 0082c02eb4
4 changed files with 241 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { getDocumentZoom, adjustCoordsForZoom } from './zoomCursorFix'
function mockComputedZoom(value: string) {
const real = window.getComputedStyle.bind(window)
return vi.spyOn(window, 'getComputedStyle').mockImplementation((elt, pseudo) => {
const style = real(elt, pseudo)
if (elt === document.documentElement) {
return new Proxy(style, {
get(target, prop) {
if (prop === 'zoom') return value
const val = Reflect.get(target, prop)
return typeof val === 'function' ? val.bind(target) : val
},
})
}
return style
})
}
describe('getDocumentZoom', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns 1 when no zoom is set', () => {
expect(getDocumentZoom()).toBe(1)
})
it('returns the zoom factor when computed style reports a decimal', () => {
const spy = mockComputedZoom('1.5')
expect(getDocumentZoom()).toBe(1.5)
spy.mockRestore()
})
it('returns the zoom factor for sub-100% zoom', () => {
const spy = mockComputedZoom('0.8')
expect(getDocumentZoom()).toBe(0.8)
spy.mockRestore()
})
it('returns 1 for zoom: normal', () => {
const spy = mockComputedZoom('normal')
expect(getDocumentZoom()).toBe(1)
spy.mockRestore()
})
it('returns 1 for empty/missing zoom value', () => {
const spy = mockComputedZoom('')
expect(getDocumentZoom()).toBe(1)
spy.mockRestore()
})
})
describe('adjustCoordsForZoom', () => {
it('returns coords unchanged when zoom is 1', () => {
expect(adjustCoordsForZoom({ x: 200, y: 100 }, 1)).toEqual({ x: 200, y: 100 })
})
it('divides coords by zoom factor for zoom > 1', () => {
const result = adjustCoordsForZoom({ x: 300, y: 150 }, 1.5)
expect(result.x).toBe(200)
expect(result.y).toBe(100)
})
it('divides coords by zoom factor for zoom < 1', () => {
const result = adjustCoordsForZoom({ x: 160, y: 80 }, 0.8)
expect(result.x).toBe(200)
expect(result.y).toBe(100)
})
it('handles common zoom levels correctly', () => {
// 90% zoom
const at90 = adjustCoordsForZoom({ x: 90, y: 90 }, 0.9)
expect(at90.x).toBeCloseTo(100, 10)
expect(at90.y).toBeCloseTo(100, 10)
// 110% zoom
const at110 = adjustCoordsForZoom({ x: 110, y: 110 }, 1.1)
expect(at110.x).toBeCloseTo(100, 10)
expect(at110.y).toBeCloseTo(100, 10)
// 125% zoom
const at125 = adjustCoordsForZoom({ x: 125, y: 125 }, 1.25)
expect(at125.x).toBeCloseTo(100, 10)
expect(at125.y).toBeCloseTo(100, 10)
})
})

View File

@@ -0,0 +1,141 @@
import { EditorView, ViewPlugin } from '@codemirror/view'
/**
* Read the current CSS zoom factor from document.documentElement.
* Returns 1 when no zoom is applied or the value is unparseable.
*
* Checks getComputedStyle first (real browsers return the decimal value),
* then falls back to the inline style property (works in jsdom and test
* environments where getComputedStyle doesn't report zoom).
*/
export function getDocumentZoom(): number {
const computed = getComputedStyle(document.documentElement).zoom
if (computed && computed !== 'normal') {
const parsed = parseFloat(computed)
if (parsed > 0 && isFinite(parsed)) return parsed
}
const inline = document.documentElement.style.getPropertyValue('zoom')
if (inline && inline !== 'normal') {
let value = parseFloat(inline)
if (inline.endsWith('%')) value /= 100
if (value > 0 && isFinite(value)) return value
}
return 1
}
/**
* Convert viewport-space coordinates to CSS-space coordinates by
* dividing by the zoom factor. When CSS zoom is applied to the root
* element, mouse event clientX/clientY are in viewport space, but
* Range.getClientRects() (used by CodeMirror's posAtCoords) may return
* values in CSS space. Dividing by zoom aligns them.
*/
export function adjustCoordsForZoom(
coords: { x: number; y: number },
zoom: number,
): { x: number; y: number } {
if (zoom === 1) return coords
return { x: coords.x / zoom, y: coords.y / zoom }
}
/**
* Use the browser's native caretRangeFromPoint API to find the document
* position at viewport coordinates. This API correctly handles CSS zoom
* because it operates in the browser's own coordinate system.
*
* Returns null if the API is unavailable or the position is outside the
* editor's content area.
*/
function caretPosFromPoint(
view: EditorView,
x: number,
y: number,
): number | null {
if (typeof document.caretRangeFromPoint !== 'function') return null
const range = document.caretRangeFromPoint(x, y)
if (!range) return null
if (!view.contentDOM.contains(range.startContainer)) return null
try {
return view.posAtDOM(range.startContainer, range.startOffset)
} catch {
return null
}
}
type Coords = { x: number; y: number }
type PosAndSide = { pos: number; assoc: -1 | 1 }
/**
* CodeMirror extension that fixes cursor positioning at non-100% CSS zoom.
*
* When CSS `zoom` is applied to document.documentElement, CodeMirror's
* posAtCoords breaks because it compares mouse event coordinates (viewport
* space) against Range.getClientRects() values (which may be in CSS space
* under zoom). This extension overrides posAtCoords and posAndSideAtCoords
* on the EditorView instance with zoom-aware versions that:
*
* 1. Use document.caretRangeFromPoint() — the browser's native, zoom-aware
* coordinate-to-text API — to find the correct position.
* 2. Fall back to the original method with coordinates divided by the zoom
* factor if caretRangeFromPoint is unavailable or returns no result.
*/
export function zoomCursorFix() {
return ViewPlugin.define((view) => {
const proto = Object.getPrototypeOf(view) as EditorView
const origPosAtCoords = proto.posAtCoords
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const origPosAndSideAtCoords = (proto as any).posAndSideAtCoords as (
coords: Coords,
precise?: boolean,
) => PosAndSide | null
// Override posAtCoords on the instance (shadows prototype method)
view.posAtCoords = function (
this: EditorView,
coords: Coords,
precise?: boolean,
): number | null {
const zoom = getDocumentZoom()
if (zoom === 1) return origPosAtCoords.call(this, coords, precise)
const pos = caretPosFromPoint(this, coords.x, coords.y)
if (pos !== null) return pos
const adjusted = adjustCoordsForZoom(coords, zoom)
return origPosAtCoords.call(this, adjusted, precise)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(view as any).posAndSideAtCoords = function (
this: EditorView,
coords: Coords,
precise?: boolean,
): PosAndSide | null {
const zoom = getDocumentZoom()
if (zoom === 1)
return origPosAndSideAtCoords.call(this, coords, precise)
const pos = caretPosFromPoint(this, coords.x, coords.y)
if (pos !== null) return { pos, assoc: 1 }
const adjusted = adjustCoordsForZoom(coords, zoom)
return origPosAndSideAtCoords.call(this, adjusted, precise)
}
return {
destroy() {
// Remove instance overrides, restoring prototype methods
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (view as any).posAtCoords
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (view as any).posAndSideAtCoords
},
}
})
}

View File

@@ -66,4 +66,14 @@ describe('useCodeMirror', () => {
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
it('installs zoomCursorFix that overrides posAtCoords on the view instance', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello world', false, noopCallbacks),
)
const view = result.current.current!
// The extension overrides posAtCoords on the instance (not the prototype)
expect(Object.prototype.hasOwnProperty.call(view, 'posAtCoords')).toBe(true)
})
})

View File

@@ -3,6 +3,7 @@ import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirro
import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight'
import { zoomCursorFix } from '../extensions/zoomCursorFix'
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -98,6 +99,7 @@ export function useCodeMirror(
buildBaseTheme(isDark),
frontmatterHighlightTheme(isDark),
frontmatterHighlightPlugin,
zoomCursorFix(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
callbacksRef.current.onDocChange(update.state.doc.toString())