feat: publish HoloLake model-native living system source

This commit is contained in:
冰朔 2026-08-03 10:04:41 +08:00
commit c395dd3a99
2467 changed files with 615073 additions and 0 deletions

View file

@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from './frontmatterHighlight'
function createView(doc: string) {
const parent = document.createElement('div')
document.body.appendChild(parent)
const state = EditorState.create({
doc,
extensions: [frontmatterHighlightPlugin, frontmatterHighlightTheme(false)],
})
const view = new EditorView({ state, parent })
return { view, parent }
}
describe('frontmatterHighlightPlugin', () => {
it('applies delimiter class to --- lines', () => {
const { view, parent } = createView('---\ntitle: Hello\n---\n\n# Heading')
const delimiters = parent.querySelectorAll('.cm-frontmatter-delimiter')
expect(delimiters.length).toBeGreaterThanOrEqual(2)
view.destroy()
parent.remove()
})
it('applies key class to YAML keys', () => {
const { view, parent } = createView('---\ntitle: Hello\ntags: one\n---\n')
const keys = parent.querySelectorAll('.cm-frontmatter-key')
expect(keys.length).toBeGreaterThanOrEqual(2)
view.destroy()
parent.remove()
})
it('applies value class to YAML values', () => {
const { view, parent } = createView('---\ntitle: Hello\n---\n')
const values = parent.querySelectorAll('.cm-frontmatter-value')
expect(values.length).toBeGreaterThanOrEqual(1)
view.destroy()
parent.remove()
})
it('handles content without frontmatter', () => {
const { view, parent } = createView('# Just a heading\n\nNo frontmatter here.')
const delimiters = parent.querySelectorAll('.cm-frontmatter-delimiter')
expect(delimiters.length).toBe(0)
const keys = parent.querySelectorAll('.cm-frontmatter-key')
expect(keys.length).toBe(0)
view.destroy()
parent.remove()
})
})
describe('frontmatterHighlightTheme', () => {
it('returns an EditorView extension for light mode', () => {
const theme = frontmatterHighlightTheme(false)
expect(theme).toBeDefined()
})
it('returns an EditorView extension for dark mode', () => {
const theme = frontmatterHighlightTheme(true)
expect(theme).toBeDefined()
})
})

View file

@ -0,0 +1,80 @@
import { ViewPlugin, Decoration, type DecorationSet, EditorView } from '@codemirror/view'
import { RangeSetBuilder } from '@codemirror/state'
const frontmatterDelimiter = Decoration.mark({ class: 'cm-frontmatter-delimiter' })
const frontmatterKey = Decoration.mark({ class: 'cm-frontmatter-key' })
const frontmatterValue = Decoration.mark({ class: 'cm-frontmatter-value' })
function findFrontmatterEnd(doc: { lines: number; line(n: number): { text: string } }): number {
if (doc.lines < 1) return -1
const first = doc.line(1).text
if (first !== '---') return -1
for (let i = 2; i <= doc.lines; i++) {
if (doc.line(i).text === '---') return i
}
return -1
}
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
const doc = view.state.doc
const fmEnd = findFrontmatterEnd(doc)
if (fmEnd === -1) return builder.finish()
for (let i = 1; i <= fmEnd; i++) {
const line = doc.line(i)
const text = line.text
decorateFrontmatterLine(builder, line.from, text, i === 1 || i === fmEnd)
}
return builder.finish()
}
function decorateFrontmatterLine(
builder: RangeSetBuilder<Decoration>,
from: number,
text: string,
isDelimiter: boolean,
): void {
if (text.length === 0) return
if (isDelimiter) {
builder.add(from, from + text.length, frontmatterDelimiter)
return
}
const colonIdx = text.indexOf(':')
if (colonIdx > 0) {
builder.add(from, from + colonIdx, frontmatterKey)
const valueStart = colonIdx + 1
const valuePart = text.slice(valueStart).trimStart()
if (valuePart.length > 0) {
const valueOffset = text.indexOf(valuePart, valueStart)
builder.add(from + valueOffset, from + text.length, frontmatterValue)
}
}
}
export const frontmatterHighlightPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = buildDecorations(view)
}
update(update: { docChanged: boolean; viewportChanged: boolean; view: EditorView }) {
if (update.docChanged) {
this.decorations = buildDecorations(update.view)
}
}
},
{ decorations: (v) => v.decorations },
)
export function frontmatterHighlightTheme() {
return EditorView.baseTheme({
'.cm-frontmatter-delimiter': { color: 'var(--syntax-frontmatter-key)', fontWeight: '600' },
'.cm-frontmatter-key': { color: 'var(--syntax-frontmatter-key)' },
'.cm-frontmatter-value': { color: 'var(--syntax-frontmatter-value)' },
})
}

View file

@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { forceParsing, syntaxTree } from '@codemirror/language'
import { markdownLanguage } from './markdownHighlight'
function createView(doc: string) {
const parent = document.createElement('div')
document.body.appendChild(parent)
const state = EditorState.create({
doc,
extensions: [markdownLanguage()],
})
const view = new EditorView({ state, parent })
return { view, parent }
}
function nodeNamesAt(view: EditorView, doc: string, needle: string) {
const pos = doc.indexOf(needle)
expect(pos).toBeGreaterThanOrEqual(0)
forceParsing(view, view.state.doc.length)
const names: string[] = []
let node = syntaxTree(view.state).resolveInner(pos + 1, 1)
while (node) {
names.push(node.name)
node = node.parent
}
return names
}
function findLines(parent: HTMLDivElement) {
return Array.from(parent.querySelectorAll<HTMLDivElement>('.cm-line'))
}
function expectMarkerOnlyHighlight(
line: HTMLDivElement | undefined,
expectedText: string,
expectedMarker: string,
expectedTrailingText: string,
) {
expect(line).toBeDefined()
expect(line!.textContent).toBe(expectedText)
expect(Array.from(line!.querySelectorAll('span'), (span) => span.textContent)).toEqual([expectedMarker])
expect(line!.lastChild).not.toBeNull()
expect(line!.lastChild!.nodeType).toBe(Node.TEXT_NODE)
expect(line!.lastChild!.textContent).toBe(expectedTrailingText)
}
describe('markdownLanguage', () => {
it('returns a valid extension', () => {
const ext = markdownLanguage()
expect(ext).toBeDefined()
expect(Array.isArray(ext)).toBe(true)
})
it('creates an editor without errors', () => {
const { view, parent } = createView('# Heading\n\n**bold** and *italic*\n\n- list item')
expect(view.state.doc.toString()).toContain('# Heading')
view.destroy()
parent.remove()
})
it('parses markdown content with mixed syntax', () => {
const doc = [
'# Title',
'',
'Some **bold** and *italic* text.',
'',
'- item one',
'- item two',
'',
'[a link](http://example.com)',
'',
'> a blockquote',
'',
'`inline code`',
].join('\n')
const { view, parent } = createView(doc)
expect(view.state.doc.lines).toBe(12)
view.destroy()
parent.remove()
})
it('parses valid leading frontmatter as YAML instead of markdown', () => {
const doc = [
'---',
'# comment',
'title: Hello',
'tags:',
' - one',
'"Belongs to": Alpha',
'---',
'',
'# Heading',
].join('\n')
const { view, parent } = createView(doc)
expect(nodeNamesAt(view, doc, '# comment')).toContain('Frontmatter')
expect(nodeNamesAt(view, doc, '# comment')).not.toContain('ATXHeading1')
expect(nodeNamesAt(view, doc, '- one')).toContain('Frontmatter')
expect(nodeNamesAt(view, doc, '- one')).not.toContain('BulletList')
expect(nodeNamesAt(view, doc, '"Belongs to"')).toContain('Frontmatter')
expect(nodeNamesAt(view, doc, '# Heading')).toContain('ATXHeading1')
expect(nodeNamesAt(view, doc, '# Heading')).not.toContain('Frontmatter')
view.destroy()
parent.remove()
})
it('styles only list markers while leaving list item text as plain content', async () => {
const doc = [
'- item one',
' - nested item',
'1. ordered item',
].join('\n')
const { view, parent } = createView(doc)
forceParsing(view, view.state.doc.length)
await new Promise((resolve) => setTimeout(resolve, 0))
const lines = findLines(parent)
expect(lines).toHaveLength(3)
expectMarkerOnlyHighlight(lines[0], '- item one', '-', ' item one')
expectMarkerOnlyHighlight(lines[1], ' - nested item', '-', ' nested item')
expectMarkerOnlyHighlight(lines[2], '1. ordered item', '1.', ' ordered item')
view.destroy()
parent.remove()
})
})

View file

@ -0,0 +1,61 @@
import { markdown } from '@codemirror/lang-markdown'
import { yamlFrontmatter } from '@codemirror/lang-yaml'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
import type { Extension } from '@codemirror/state'
const SYNTAX_COLORS = {
atom: 'var(--syntax-highlight-number)',
comment: 'var(--syntax-highlight-comment)',
foreground: 'var(--text-primary)',
heading: 'var(--syntax-heading)',
keyword: 'var(--syntax-highlight-keyword)',
link: 'var(--syntax-link)',
monospace: 'var(--syntax-monospace)',
monospaceBackground: 'var(--syntax-monospace-bg)',
muted: 'var(--syntax-muted)',
number: 'var(--syntax-highlight-number)',
operator: 'var(--syntax-muted)',
string: 'var(--syntax-highlight-string)',
title: 'var(--syntax-highlight-title)',
type: 'var(--syntax-highlight-type)',
}
const markdownHighlightStyle = HighlightStyle.define([
{ tag: tags.heading1, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.4em' },
{ tag: tags.heading2, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.25em' },
{ tag: tags.heading3, color: SYNTAX_COLORS.heading, fontWeight: '600', fontSize: '1.1em' },
{ tag: tags.heading4, color: SYNTAX_COLORS.heading, fontWeight: '600' },
{ tag: tags.heading5, color: SYNTAX_COLORS.heading, fontWeight: '600' },
{ tag: tags.heading6, color: SYNTAX_COLORS.heading, fontWeight: '600' },
{ tag: tags.strong, fontWeight: '700' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: SYNTAX_COLORS.link, textDecoration: 'underline' },
{ tag: tags.url, color: SYNTAX_COLORS.link },
{ tag: tags.monospace, color: SYNTAX_COLORS.monospace, backgroundColor: SYNTAX_COLORS.monospaceBackground, borderRadius: '3px' },
{ tag: tags.quote, color: SYNTAX_COLORS.muted, fontStyle: 'italic' },
{ tag: tags.separator, color: SYNTAX_COLORS.muted },
{ tag: tags.processingInstruction, color: SYNTAX_COLORS.monospace, fontWeight: '600' },
{ tag: tags.contentSeparator, color: SYNTAX_COLORS.monospace, fontWeight: '600' },
{ tag: tags.comment, color: SYNTAX_COLORS.comment, fontStyle: 'italic' },
{ tag: tags.keyword, color: SYNTAX_COLORS.keyword, fontWeight: '600' },
{ tag: [tags.atom, tags.bool, tags.null], color: SYNTAX_COLORS.atom },
{ tag: tags.number, color: SYNTAX_COLORS.number },
{ tag: [tags.string, tags.special(tags.string)], color: SYNTAX_COLORS.string },
{ tag: [tags.variableName, tags.propertyName], color: SYNTAX_COLORS.foreground },
{ tag: [tags.function(tags.variableName), tags.definition(tags.variableName)], color: SYNTAX_COLORS.title },
{ tag: [tags.typeName, tags.className], color: SYNTAX_COLORS.type },
{ tag: [tags.operator, tags.punctuation], color: SYNTAX_COLORS.operator },
])
export function rawEditorSyntaxHighlighting(): Extension {
return syntaxHighlighting(markdownHighlightStyle)
}
export function markdownLanguage(): Extension {
return [
yamlFrontmatter({ content: markdown() }),
rawEditorSyntaxHighlighting(),
]
}

View file

@ -0,0 +1,41 @@
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
import { python } from '@codemirror/lang-python'
import { sql } from '@codemirror/lang-sql'
import { yaml } from '@codemirror/lang-yaml'
import type { Extension } from '@codemirror/state'
import { rawEditorLanguageIdForPath, type RawEditorLanguageId } from '../utils/rawEditorLanguage'
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from './frontmatterHighlight'
import { markdownLanguage, rawEditorSyntaxHighlighting } from './markdownHighlight'
function javascriptLanguage(id: RawEditorLanguageId): Extension {
if (id === 'typescript') return javascript({ typescript: true })
if (id === 'tsx') return javascript({ jsx: true, typescript: true })
if (id === 'jsx') return javascript({ jsx: true })
return javascript()
}
function highlighted(language: Extension): Extension[] {
return [language, rawEditorSyntaxHighlighting()]
}
const LANGUAGE_EXTENSIONS: Record<RawEditorLanguageId, () => Extension[]> = {
javascript: () => highlighted(javascriptLanguage('javascript')),
json: () => highlighted(json()),
jsx: () => highlighted(javascriptLanguage('jsx')),
markdown: () => [markdownLanguage(), frontmatterHighlightTheme(), frontmatterHighlightPlugin],
plain: () => [],
python: () => highlighted(python()),
sql: () => highlighted(sql()),
tsx: () => highlighted(javascriptLanguage('tsx')),
typescript: () => highlighted(javascriptLanguage('typescript')),
yaml: () => highlighted(yaml()),
}
function rawEditorLanguage(id: RawEditorLanguageId): Extension[] {
return LANGUAGE_EXTENSIONS[id]()
}
export function rawEditorLanguageExtensionsForPath(path?: string | null): Extension[] {
return rawEditorLanguage(rawEditorLanguageIdForPath(path))
}

View file

@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { defineMock } = vi.hoisted(() => ({
defineMock: vi.fn((factory: (view: unknown) => unknown) => factory),
}))
vi.mock('@codemirror/view', () => ({
EditorView: class EditorView {},
ViewPlugin: {
define: defineMock,
},
}))
import { getDocumentZoom, zoomCursorFix } 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 next = Reflect.get(target, prop)
return typeof next === 'function' ? next.bind(target) : next
},
})
}
return style
})
}
function mockInlineZoom(value: string) {
return vi.spyOn(document.documentElement.style, 'getPropertyValue').mockImplementation((name: string) => {
if (name === 'zoom') return value
return ''
})
}
function createView() {
const contentDOM = document.createElement('div')
const textNode = document.createTextNode('hello')
contentDOM.appendChild(textNode)
const origPosAtCoords = vi.fn(() => 11)
const origPosAndSideAtCoords = vi.fn(() => ({ pos: 13, assoc: -1 as const }))
const prototype = {
posAtCoords: origPosAtCoords,
posAndSideAtCoords: origPosAndSideAtCoords,
}
const view = Object.assign(Object.create(prototype), {
contentDOM,
posAtDOM: vi.fn(() => 17),
})
return {
view,
textNode,
origPosAtCoords,
origPosAndSideAtCoords,
}
}
describe('zoomCursorFix behavior', () => {
beforeEach(() => {
document.documentElement.style.removeProperty('zoom')
delete (document as Document & {
caretRangeFromPoint?: (x: number, y: number) => Range | null
}).caretRangeFromPoint
})
afterEach(() => {
vi.restoreAllMocks()
})
it('reads inline percentage zoom values when computed zoom is normal', () => {
const computedSpy = mockComputedZoom('normal')
const inlineSpy = mockInlineZoom('125%')
expect(getDocumentZoom()).toBe(1.25)
inlineSpy.mockRestore()
computedSpy.mockRestore()
})
it('falls back to 1 for invalid inline zoom values', () => {
const computedSpy = mockComputedZoom('normal')
const inlineSpy = mockInlineZoom('banana')
expect(getDocumentZoom()).toBe(1)
inlineSpy.mockRestore()
computedSpy.mockRestore()
})
it('uses caretRangeFromPoint when CSS zoom is active and restores prototype methods on destroy', () => {
const computedSpy = mockComputedZoom('normal')
const inlineSpy = mockInlineZoom('150%')
const { view, textNode } = createView()
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => ({ startContainer: textNode, startOffset: 2 })),
})
const pluginFactory = zoomCursorFix() as unknown as (view: typeof view) => { destroy: () => void }
const plugin = pluginFactory(view)
expect(view.posAtCoords({ x: 30, y: 40 }, true)).toBe(17)
expect(view.posAndSideAtCoords({ x: 30, y: 40 }, false)).toEqual({ pos: 17, assoc: 1 })
expect(view.posAtDOM).toHaveBeenCalledWith(textNode, 2)
expect(Object.hasOwn(view, 'posAtCoords')).toBe(true)
plugin.destroy()
expect(Object.hasOwn(view, 'posAtCoords')).toBe(false)
expect(view.posAtCoords({ x: 5, y: 6 }, false)).toBe(11)
inlineSpy.mockRestore()
computedSpy.mockRestore()
})
it('falls back to adjusted coordinates when the browser API is unavailable or returns an unusable range', () => {
const computedSpy = mockComputedZoom('normal')
const inlineSpy = mockInlineZoom('200%')
const pluginFactory = zoomCursorFix() as unknown as (view: ReturnType<typeof createView>['view']) => { destroy: () => void }
const first = createView()
pluginFactory(first.view)
expect(first.view.posAtCoords({ x: 40, y: 60 }, true)).toBe(11)
expect(first.origPosAtCoords).toHaveBeenCalledWith({ x: 20, y: 30 }, true)
expect(first.view.posAndSideAtCoords({ x: 40, y: 60 }, false)).toEqual({ pos: 13, assoc: -1 })
expect(first.origPosAndSideAtCoords).toHaveBeenCalledWith({ x: 20, y: 30 }, false)
const second = createView()
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => ({ startContainer: document.createTextNode('outside'), startOffset: 0 })),
})
pluginFactory(second.view)
expect(second.view.posAtCoords({ x: 50, y: 70 }, false)).toBe(11)
expect(second.origPosAtCoords).toHaveBeenCalledWith({ x: 25, y: 35 }, false)
const third = createView()
third.view.posAtDOM.mockImplementation(() => {
throw new Error('boom')
})
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => ({ startContainer: third.textNode, startOffset: 1 })),
})
pluginFactory(third.view)
expect(third.view.posAtCoords({ x: 60, y: 80 }, false)).toBe(11)
expect(third.origPosAtCoords).toHaveBeenCalledWith({ x: 30, y: 40 }, false)
inlineSpy.mockRestore()
computedSpy.mockRestore()
})
})

View file

@ -0,0 +1,113 @@
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDocumentZoom, zoomCursorFix } from './zoomCursorFix'
function mockComputedZoom(value: string) {
const realGetComputedStyle = window.getComputedStyle.bind(window)
return vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudo) => {
const style = realGetComputedStyle(element, pseudo)
if (element === document.documentElement) {
return new Proxy(style, {
get(target, prop) {
if (prop === 'zoom') return value
const current = Reflect.get(target, prop)
return typeof current === 'function' ? current.bind(target) : current
},
})
}
return style
})
}
describe('zoomCursorFix extra coverage', () => {
let parent: HTMLDivElement
beforeEach(() => {
parent = document.createElement('div')
document.body.appendChild(parent)
document.documentElement.style.removeProperty('zoom')
})
afterEach(() => {
vi.restoreAllMocks()
document.documentElement.style.removeProperty('zoom')
document.body.innerHTML = ''
delete (document as Document & { caretRangeFromPoint?: unknown }).caretRangeFromPoint
})
function createView() {
return new EditorView({
parent,
state: EditorState.create({
doc: 'hello world',
extensions: [zoomCursorFix()],
}),
})
}
it('falls back to inline zoom values when computed zoom is invalid', () => {
mockComputedZoom('not-a-number')
const inlineZoomSpy = vi.spyOn(document.documentElement.style, 'getPropertyValue')
inlineZoomSpy.mockReturnValueOnce('125%')
expect(getDocumentZoom()).toBe(1.25)
inlineZoomSpy.mockReturnValueOnce('-20%')
expect(getDocumentZoom()).toBe(1)
})
it('uses caretRangeFromPoint for zoomed coordinates and restores prototype methods on destroy', () => {
const protoPosAtCoords = vi.spyOn(EditorView.prototype, 'posAtCoords').mockReturnValue(99)
const protoPosAndSideAtCoords = vi.spyOn(EditorView.prototype, 'posAndSideAtCoords').mockReturnValue({ pos: 99, assoc: -1 })
const view = createView()
mockComputedZoom('2')
const textNode = view.contentDOM.querySelector('.cm-line')?.firstChild
expect(textNode).toBeTruthy()
const range = document.createRange()
range.setStart(textNode as Node, 3)
range.setEnd(textNode as Node, 3)
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => range),
})
expect(view.posAtCoords({ x: 20, y: 30 }, true)).toBe(3)
expect((view as unknown as { posAndSideAtCoords: (coords: { x: number; y: number }, precise?: boolean) => unknown })
.posAndSideAtCoords({ x: 20, y: 30 }, false)).toEqual({ pos: 3, assoc: 1 })
expect(protoPosAtCoords).not.toHaveBeenCalled()
expect(protoPosAndSideAtCoords).not.toHaveBeenCalled()
view.destroy()
expect(Object.hasOwn(view, 'posAtCoords')).toBe(false)
expect(Object.hasOwn(view, 'posAndSideAtCoords')).toBe(false)
})
it('falls back to original coordinate methods when zoom is 1 or caret lookup misses', () => {
const protoPosAtCoords = vi.spyOn(EditorView.prototype, 'posAtCoords').mockImplementation((coords) => (
coords.x === 10 && coords.y === 15 ? 11 : 7
))
const protoPosAndSideAtCoords = vi.spyOn(EditorView.prototype, 'posAndSideAtCoords').mockImplementation((coords) => (
coords.x === 10 && coords.y === 15 ? { pos: 11, assoc: -1 } : { pos: 7, assoc: -1 }
))
const view = createView()
expect(view.posAtCoords({ x: 8, y: 12 }, true)).toBe(7)
expect(protoPosAtCoords).toHaveBeenCalledWith({ x: 8, y: 12 }, true)
mockComputedZoom('2')
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => null),
})
expect(view.posAtCoords({ x: 20, y: 30 }, false)).toBe(11)
expect(protoPosAtCoords).toHaveBeenCalledWith({ x: 10, y: 15 }, false)
expect((view as unknown as { posAndSideAtCoords: (coords: { x: number; y: number }, precise?: boolean) => unknown })
.posAndSideAtCoords({ x: 20, y: 30 }, true)).toEqual({ pos: 11, assoc: -1 })
expect(protoPosAndSideAtCoords).toHaveBeenCalledWith({ x: 10, y: 15 }, true)
})
})

View file

@ -0,0 +1,94 @@
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('normalizes a computed percentage zoom value', () => {
const spy = mockComputedZoom('125%')
expect(getDocumentZoom()).toBe(1.25)
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,156 @@
import { EditorView, ViewPlugin } from '@codemirror/view'
function parseZoomValue(source: string | undefined): number | null {
const value = source?.trim() ?? ''
if (!value || value === 'normal') return null
let parsed = parseFloat(value)
if (value.endsWith('%')) parsed /= 100
return parsed > 0 && Number.isFinite(parsed) ? parsed : null
}
/**
* Read the current CSS zoom factor from document.documentElement.
* Returns 1 when no zoom is applied or the value is unparseable.
*/
export function getDocumentZoom(): number {
const computedZoom = parseZoomValue(getComputedStyle(document.documentElement).zoom)
if (computedZoom !== null) return computedZoom
const inline = document.documentElement.style.getPropertyValue('zoom')
const inlineZoom = parseZoomValue(inline)
if (inlineZoom !== null) return inlineZoom
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 }
type CoordsMethod<Result> = (
this: EditorView,
coords: Coords,
precise?: boolean,
) => Result
type EditorViewCoordsOverrides = {
posAtCoords?: CoordsMethod<number | null>
posAndSideAtCoords?: CoordsMethod<PosAndSide | null>
}
interface ZoomAwareCoordsCall<Result> {
self: EditorView
coords: Coords
precise: boolean | undefined
originalMethod: CoordsMethod<Result>
resultFromCaret: (pos: number) => Result
}
function callZoomAwareCoords<Result>(call: ZoomAwareCoordsCall<Result>): Result {
const { self, coords, precise, originalMethod, resultFromCaret } = call
const zoom = getDocumentZoom()
if (zoom === 1) return originalMethod.call(self, coords, precise)
const pos = caretPosFromPoint(self, coords.x, coords.y)
if (pos !== null) return resultFromCaret(pos)
return originalMethod.call(self, adjustCoordsForZoom(coords, zoom), precise)
}
function makeZoomCoordsOverride<Result>(
originalMethod: CoordsMethod<Result>,
resultFromCaret: (pos: number) => Result,
): CoordsMethod<Result> {
return function zoomAwareCoordsOverride(
this: EditorView,
coords: Coords,
precise?: boolean,
): Result {
return callZoomAwareCoords({
self: this,
coords,
precise,
originalMethod,
resultFromCaret,
})
}
}
/**
* 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 prototype = Object.getPrototypeOf(view) as Required<EditorViewCoordsOverrides>
const origPosAtCoords = prototype.posAtCoords
const origPosAndSideAtCoords = prototype.posAndSideAtCoords
const overrides = view as EditorViewCoordsOverrides
// Override on the instance (shadows prototype methods)
overrides.posAtCoords = makeZoomCoordsOverride(origPosAtCoords, (pos) => pos)
overrides.posAndSideAtCoords = makeZoomCoordsOverride(
origPosAndSideAtCoords,
(pos) => ({ pos, assoc: 1 }),
)
return {
destroy() {
// Remove instance overrides, restoring prototype methods
delete overrides.posAtCoords
delete overrides.posAndSideAtCoords
},
}
})
}