Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
9.7 kB · 256 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257import { describe, it, expect, afterEach, vi } from 'vitest'import { EditorState, EditorSelection, Transaction } from '@codemirror/state'import { EditorView } from '@codemirror/view'import { markdownPasteHandler, markdownDomPasteHandler } from '../markdown-paste-handler'import { imagePasteHandler } from '../image-paste-handler'import { INLINE_SEMANTIC_CHANGE_ANNOTATION, INLINE_SEMANTIC_TEXT_CHANGE_ANNOTATION,} from '../../inline-semantic-ops'
const createdViews: EditorView[] = []afterEach(() => { for (const v of createdViews) v.destroy(); createdViews.length = 0 })
function makeFakeDataTransfer(textPlain?: string): DataTransfer { const types: string[] = [] if (textPlain !== undefined) types.push('text/plain') return { items: Object.assign([], { length: 0 }) as unknown as DataTransferItemList, types, files: [], getData(t: string) { if (t === 'text/plain') return textPlain ?? '' return '' }, } as unknown as DataTransfer}
function dispatchPaste(view: EditorView, dt: DataTransfer): Event { const event = new Event('paste', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: dt, configurable: true }) view.contentDOM.dispatchEvent(event) return event}
type MountOpts = { doc?: string selection?: EditorSelection readOnly?: boolean extensions?: ReturnType<typeof EditorState.create>['facet'] // just for typing}
function mount(opts: MountOpts = {}): { view: EditorView; transactions: Transaction[] } { const collected: Transaction[] = [] const view = new EditorView({ state: EditorState.create({ doc: opts.doc ?? '', selection: opts.selection ?? EditorSelection.single(0), extensions: [ markdownPasteHandler(), EditorState.readOnly.of(opts.readOnly ?? false), EditorView.updateListener.of((update) => { for (const tr of update.transactions) { collected.push(tr) } }), ], }), }) createdViews.push(view) return { view, transactions: collected }}
function findSemanticAnnotation(transactions: Transaction[]): any { for (const tr of transactions) { const ann = tr.annotation(INLINE_SEMANTIC_CHANGE_ANNOTATION) if (ann != null) return ann } return undefined}
function findTextAnnotation(transactions: Transaction[]): any { for (const tr of transactions) { const ann = tr.annotation(INLINE_SEMANTIC_TEXT_CHANGE_ANNOTATION) if (ann != null) return ann } return undefined}
function findUserEvent(transactions: Transaction[]): string | undefined { for (const tr of transactions) { const ue = tr.annotation(Transaction.userEvent) if (ue) return ue } return undefined}
describe('markdownPasteHandler', () => { it('dispatches annotated transaction for plain text paste', () => { const { view, transactions } = mount({ doc: '', selection: EditorSelection.single(0) }) const event = dispatchPaste(view, makeFakeDataTransfer('hello **world**'))
expect(event.defaultPrevented).toBe(true) expect(view.state.doc.toString()).toBe('hello **world**')
expect(findUserEvent(transactions)).toBe('input.paste')
const semanticChange = findSemanticAnnotation(transactions) expect(semanticChange).toBeDefined() // 'hello **world**' is 15 chars, pasted at position 0 expect(semanticChange.ops).toEqual([{ kind: 'synthesize-paste', range: { start: 0, end: 15 } }])
const textChange = findTextAnnotation(transactions) expect(textChange).toBeDefined() expect(textChange).toEqual({ from: 0, to: 0, insert: 'hello **world**' }) })
it('computes correct pasted range for selection replacement', () => { const { view, transactions } = mount({ doc: 'old text here', selection: EditorSelection.single(4, 8), // selects "text" }) dispatchPaste(view, makeFakeDataTransfer('new'))
expect(view.state.doc.toString()).toBe('old new here')
const semanticChange = findSemanticAnnotation(transactions) expect(semanticChange).toBeDefined() expect(semanticChange.ops).toEqual([{ kind: 'synthesize-paste', range: { start: 4, end: 7 } }])
const textChange = findTextAnnotation(transactions) expect(textChange).toBeDefined() expect(textChange).toEqual({ from: 4, to: 8, insert: 'new' }) })
it('delegates when read-only', () => { const { view, transactions } = mount({ doc: 'hello', readOnly: true, selection: EditorSelection.single(0) }) dispatchPaste(view, makeFakeDataTransfer('**bold**'))
// Doc unchanged — readOnly prevents text insertion. expect(view.state.doc.toString()).toBe('hello') // Our handler did NOT add synthesize-paste annotation. expect(findSemanticAnnotation(transactions)).toBeUndefined() })
it('delegates on multi-cursor selection', () => { // CM6's EditorView collapses multi-cursor selections during construction // in jsdom; test the handler's delegation logic directly with a mock view. const dispatchSpy = vi.fn() const mockView = { state: { readOnly: false, selection: { ranges: { length: 2 }, main: { from: 0, to: 0 } }, doc: { toString: () => 'foo bar' }, }, dispatch: dispatchSpy, } as unknown as EditorView
const dt = makeFakeDataTransfer('**bold**') const event = new Event('paste', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: dt, configurable: true })
const result = markdownDomPasteHandler(event, mockView)
// Multi-cursor (ranges.length === 2) → handler delegates, does not dispatch. expect(result).toBe(false) expect(dispatchSpy).not.toHaveBeenCalled() })
it('delegates when clipboard text is empty', () => { const { view, transactions } = mount({ doc: '', selection: EditorSelection.single(0) }) dispatchPaste(view, makeFakeDataTransfer(''))
expect(view.state.doc.toString()).toBe('') expect(findSemanticAnnotation(transactions)).toBeUndefined() })
it('delegates when clipboard has files', () => { const { view, transactions } = mount({ doc: '', selection: EditorSelection.single(0) }) const file = new File([new Uint8Array([1, 2, 3])], 'test.png', { type: 'image/png' })
const fakeItem = { kind: 'file' as const, type: 'image/png', getAsFile: () => file, } const items = Object.assign([fakeItem], { length: 1 }) as unknown as DataTransferItemList const dt = { items, types: ['Files', 'text/plain'], files: [file], getData(t: string) { return t === 'text/plain' ? 'plain text' : '' }, } as unknown as DataTransfer
dispatchPaste(view, dt) // Our handler must NOT add synthesize-paste annotation (file pastes delegate // to image handler). CM6 may still insert text via its default paste path. expect(findSemanticAnnotation(transactions)).toBeUndefined() })
it('does not act when event.defaultPrevented is already true', () => { const { view, transactions } = mount({ doc: '', selection: EditorSelection.single(0) }) const dt = makeFakeDataTransfer('**bold**') const event = new Event('paste', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: dt, configurable: true }) event.preventDefault() view.contentDOM.dispatchEvent(event)
expect(view.state.doc.toString()).toBe('') expect(findSemanticAnnotation(transactions)).toBeUndefined() })
it('image handler beats markdown handler when both registered', async () => { const fakeApp = { vault: { createBinary: vi.fn(async () => ({} as never)) }, workspace: { getActiveFile: () => null }, } as unknown as Parameters<typeof imagePasteHandler>[0] const view = new EditorView({ state: EditorState.create({ doc: 'docs', selection: EditorSelection.single(0, 4), extensions: [imagePasteHandler(fakeApp), markdownPasteHandler()], }), parent: document.body, }) createdViews.push(view)
const file = new File([new Uint8Array([0x89, 0x50])], 'x.png', { type: 'image/png' }) const fakeImageItem = { kind: 'file' as const, type: 'image/png', getAsFile: () => file, } const items = Object.assign([fakeImageItem], { length: 1 }) as unknown as DataTransferItemList const dt = { items, types: ['Files', 'text/plain'], files: [file], getData(t: string) { return t === 'text/plain' ? '**bold**' : '' }, } as unknown as DataTransfer
const event = new Event('paste', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: dt, configurable: true }) view.contentDOM.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true) expect(view.state.doc.toString()).toBe('docs') await vi.waitFor(() => expect(view.state.doc.toString()).toMatch(/^!\[\[.*\.png\]\]$/)) })})
describe('markdownDomPasteHandler guards', () => { it('returns false when the paste event is already defaultPrevented', () => { const event = new Event('paste', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'defaultPrevented', { value: true }) expect(markdownDomPasteHandler(event, null as unknown as EditorView)).toBe(false) })
it('returns false when clipboardData is missing', () => { const { view } = mount({ doc: 'hello' }) const event = new Event('paste', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: null, configurable: true }) expect(markdownDomPasteHandler(event, view)).toBe(false) })})