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:
88
src/extensions/zoomCursorFix.test.ts
Normal file
88
src/extensions/zoomCursorFix.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
141
src/extensions/zoomCursorFix.ts
Normal file
141
src/extensions/zoomCursorFix.ts
Normal 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
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user