feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)' },
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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(),
|
||||
]
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
156
product-source/hololake-platform/src/extensions/zoomCursorFix.ts
Normal file
156
product-source/hololake-platform/src/extensions/zoomCursorFix.ts
Normal 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
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue