2026-03-08 14:54:50 +01:00
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
|
import { renderHook, act } from '@testing-library/react'
|
2026-04-26 11:31:41 +02:00
|
|
|
|
import { EditorView } from '@codemirror/view'
|
|
|
|
|
|
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
2026-04-25 16:06:17 +02:00
|
|
|
|
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
|
2026-03-08 14:54:50 +01:00
|
|
|
|
|
|
|
|
|
|
const noop = () => {}
|
|
|
|
|
|
const noopCallbacks: CodeMirrorCallbacks = {
|
|
|
|
|
|
onDocChange: noop,
|
|
|
|
|
|
onCursorActivity: noop,
|
|
|
|
|
|
onSave: noop,
|
|
|
|
|
|
onEscape: () => false,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
describe('useCodeMirror', () => {
|
|
|
|
|
|
let container: HTMLDivElement
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
container = document.createElement('div')
|
|
|
|
|
|
document.body.appendChild(container)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
document.body.removeChild(container)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('creates an EditorView in the container', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
const { result } = renderHook(() =>
|
2026-04-23 22:06:01 +02:00
|
|
|
|
useCodeMirror(ref, 'hello world', noopCallbacks),
|
2026-03-08 14:54:50 +01:00
|
|
|
|
)
|
|
|
|
|
|
expect(result.current.current).not.toBeNull()
|
|
|
|
|
|
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-17 19:34:18 +02:00
|
|
|
|
it('does not vertically offset line numbers from editor text', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
renderHook(() =>
|
|
|
|
|
|
useCodeMirror(ref, '---\ntype: Note\n---', noopCallbacks),
|
|
|
|
|
|
)
|
|
|
|
|
|
const gutters = container.querySelector('.cm-gutters')
|
|
|
|
|
|
|
|
|
|
|
|
expect(gutters).toBeInTheDocument()
|
|
|
|
|
|
expect(getComputedStyle(gutters!).paddingTop).toBe('0px')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-26 11:31:41 +02:00
|
|
|
|
it('tags generated CodeMirror style elements with the runtime CSP nonce', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
|
useCodeMirror(ref, 'hello world', noopCallbacks),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.current.current?.state.facet(EditorView.cspNonce)).toBe(RUNTIME_STYLE_NONCE)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-03 10:40:38 +02:00
|
|
|
|
it('enables per-line auto text direction for mixed LTR and RTL content', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
|
useCodeMirror(ref, 'English\nمرحبا بالعالم', noopCallbacks),
|
|
|
|
|
|
)
|
|
|
|
|
|
const view = result.current.current!
|
|
|
|
|
|
|
|
|
|
|
|
expect(view.state.facet(EditorView.perLineTextDirection)).toBe(true)
|
|
|
|
|
|
expect([...container.querySelectorAll('.cm-line')].map(line => line.getAttribute('dir'))).toEqual(['auto', 'auto'])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-08 14:54:50 +01:00
|
|
|
|
it('calls requestMeasure when laputa-zoom-change event fires', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
const { result } = renderHook(() =>
|
2026-04-23 22:06:01 +02:00
|
|
|
|
useCodeMirror(ref, 'hello', noopCallbacks),
|
2026-03-08 14:54:50 +01:00
|
|
|
|
)
|
|
|
|
|
|
const view = result.current.current!
|
|
|
|
|
|
const spy = vi.spyOn(view, 'requestMeasure')
|
|
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
|
window.dispatchEvent(new Event('laputa-zoom-change'))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
expect(spy).toHaveBeenCalled()
|
|
|
|
|
|
spy.mockRestore()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('stops listening for zoom changes after unmount', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
const { result, unmount } = renderHook(() =>
|
2026-04-23 22:06:01 +02:00
|
|
|
|
useCodeMirror(ref, 'hello', noopCallbacks),
|
2026-03-08 14:54:50 +01:00
|
|
|
|
)
|
|
|
|
|
|
const view = result.current.current!
|
|
|
|
|
|
const spy = vi.spyOn(view, 'requestMeasure')
|
|
|
|
|
|
|
|
|
|
|
|
unmount()
|
|
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
|
window.dispatchEvent(new Event('laputa-zoom-change'))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// After unmount, the listener should be removed — requestMeasure should NOT be called.
|
|
|
|
|
|
// (The view is also destroyed on unmount, so this verifies cleanup.)
|
|
|
|
|
|
expect(spy).not.toHaveBeenCalled()
|
|
|
|
|
|
spy.mockRestore()
|
|
|
|
|
|
})
|
2026-03-09 10:43:46 +01:00
|
|
|
|
|
2026-03-18 13:32:07 +01:00
|
|
|
|
it('syncs content prop changes to the editor', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
const onDocChange = vi.fn()
|
|
|
|
|
|
const callbacks = { ...noopCallbacks, onDocChange }
|
|
|
|
|
|
const { result, rerender } = renderHook(
|
2026-04-23 22:06:01 +02:00
|
|
|
|
({ content }) => useCodeMirror(ref, content, callbacks),
|
2026-03-18 13:32:07 +01:00
|
|
|
|
{ initialProps: { content: '---\ntitle: Hello\n---\nBody' } },
|
|
|
|
|
|
)
|
|
|
|
|
|
const view = result.current.current!
|
|
|
|
|
|
expect(view.state.doc.toString()).toBe('---\ntitle: Hello\n---\nBody')
|
|
|
|
|
|
|
|
|
|
|
|
// Simulate external content update (e.g. frontmatter written to disk)
|
|
|
|
|
|
rerender({ content: '---\ntitle: Hello\nTrashed: true\n---\nBody' })
|
|
|
|
|
|
|
|
|
|
|
|
expect(view.state.doc.toString()).toBe('---\ntitle: Hello\nTrashed: true\n---\nBody')
|
|
|
|
|
|
// External sync should NOT trigger onDocChange (would cause infinite loop)
|
|
|
|
|
|
expect(onDocChange).not.toHaveBeenCalled()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-27 10:12:13 +02:00
|
|
|
|
it('lets app Escape handling run before the CodeMirror default keymap', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
const onEscape = vi.fn(() => true)
|
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
|
useCodeMirror(ref, 'hello', { ...noopCallbacks, onEscape }),
|
|
|
|
|
|
)
|
|
|
|
|
|
const view = result.current.current!
|
|
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
|
view.focus()
|
|
|
|
|
|
view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', {
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
cancelable: true,
|
|
|
|
|
|
key: 'Escape',
|
|
|
|
|
|
}))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
expect(onEscape).toHaveBeenCalledOnce()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-18 13:32:07 +01:00
|
|
|
|
it('does not sync when content matches current editor state', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
const { result, rerender } = renderHook(
|
2026-04-23 22:06:01 +02:00
|
|
|
|
({ content }) => useCodeMirror(ref, content, noopCallbacks),
|
2026-03-18 13:32:07 +01:00
|
|
|
|
{ initialProps: { content: 'hello' } },
|
|
|
|
|
|
)
|
|
|
|
|
|
const view = result.current.current!
|
|
|
|
|
|
const spy = vi.spyOn(view, 'dispatch')
|
|
|
|
|
|
|
|
|
|
|
|
// Re-render with same content — no dispatch needed
|
|
|
|
|
|
rerender({ content: 'hello' })
|
|
|
|
|
|
expect(spy).not.toHaveBeenCalled()
|
|
|
|
|
|
spy.mockRestore()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-09 10:43:46 +01:00
|
|
|
|
it('installs zoomCursorFix that overrides posAtCoords on the view instance', () => {
|
|
|
|
|
|
const ref = { current: container }
|
|
|
|
|
|
const { result } = renderHook(() =>
|
2026-04-23 22:06:01 +02:00
|
|
|
|
useCodeMirror(ref, 'hello world', noopCallbacks),
|
2026-03-09 10:43:46 +01:00
|
|
|
|
)
|
|
|
|
|
|
const view = result.current.current!
|
|
|
|
|
|
// The extension overrides posAtCoords on the instance (not the prototype)
|
|
|
|
|
|
expect(Object.prototype.hasOwnProperty.call(view, 'posAtCoords')).toBe(true)
|
|
|
|
|
|
})
|
2026-03-08 14:54:50 +01:00
|
|
|
|
})
|