import { 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['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[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) }) })