diff --git a/src/extensions/zoomCursorFix.test.ts b/src/extensions/zoomCursorFix.test.ts new file mode 100644 index 00000000..44bc3b4c --- /dev/null +++ b/src/extensions/zoomCursorFix.test.ts @@ -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) + }) +}) diff --git a/src/extensions/zoomCursorFix.ts b/src/extensions/zoomCursorFix.ts new file mode 100644 index 00000000..64f94a1f --- /dev/null +++ b/src/extensions/zoomCursorFix.ts @@ -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 + }, + } + }) +} diff --git a/src/hooks/useCodeMirror.test.ts b/src/hooks/useCodeMirror.test.ts index d8729ee7..64e9397a 100644 --- a/src/hooks/useCodeMirror.test.ts +++ b/src/hooks/useCodeMirror.test.ts @@ -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) + }) }) diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index 2ae48172..6696db4f 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -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())