diff --git a/src/extensions/markdown/CodeBlock/CodeBlock.test.ts b/src/extensions/markdown/CodeBlock/CodeBlock.test.ts index e45405a3..6cd416be 100644 --- a/src/extensions/markdown/CodeBlock/CodeBlock.test.ts +++ b/src/extensions/markdown/CodeBlock/CodeBlock.test.ts @@ -3,9 +3,11 @@ import {builders} from 'prosemirror-test-builder'; import {parseDOM} from '../../../../tests/parse-dom'; import {createMarkupChecker} from '../../../../tests/sameMarkup'; import {ExtensionsManager} from '../../../core'; +import {DataTransferType} from '../../../utils/clipboard'; import {BaseNode, BaseSchemaSpecs} from '../../base/specs'; import {CodeBlockNodeAttr, CodeBlockSpecs, codeBlockNodeName} from './CodeBlockSpecs'; +import {getCodeData, isInlineCode} from './handle-paste'; const { schema, @@ -23,6 +25,21 @@ const {doc, p, cb} = builders<'doc' | 'p' | 'cb'>(schema, { const {same, parse} = createMarkupChecker({parser, serializer}); +function createMockDataTransfer(data: Record): DataTransfer { + const types = Object.keys(data); + return { + types, + getData: (type: string) => data[type] || '', + setData: jest.fn(), + clearData: jest.fn(), + setDragImage: jest.fn(), + dropEffect: 'none', + effectAllowed: 'all', + files: [] as unknown as FileList, + items: [] as unknown as DataTransferItemList, + } as DataTransfer; +} + describe('CodeBlock extension', () => { it('should parse a code block', () => same( @@ -53,3 +70,60 @@ describe('CodeBlock extension', () => { it('should support different markup', () => same('~~~\n123\n~~~', doc(cb({[CodeBlockNodeAttr.Markup]: '~~~'}, '123')))); }); + +describe('CodeBlock paste handling', () => { + it('should detect inline code for single line text', () => { + expect(isInlineCode('const x = 1')).toBe(true); + expect(isInlineCode('const x = 1\nconst y = 2')).toBe(false); + }); + + it('should detect VSCode paste as inline for single line', () => { + const data = createMockDataTransfer({ + [DataTransferType.Text]: 'const x = 1', + [DataTransferType.VSCodeData]: '{"version":1}', + }); + const result = getCodeData(data); + + expect(result).toEqual({ + editor: 'vscode', + value: 'const x = 1', + inline: true, + }); + }); + + it('should detect VSCode paste as block for multiline', () => { + const data = createMockDataTransfer({ + [DataTransferType.Text]: 'const x = 1\nconst y = 2', + [DataTransferType.VSCodeData]: '{"version":1}', + }); + const result = getCodeData(data); + + expect(result).toEqual({ + editor: 'vscode', + value: 'const x = 1\nconst y = 2', + inline: false, + }); + }); + + it('should detect inline code from HTML tag', () => { + const data = createMockDataTransfer({ + [DataTransferType.Text]: 'x', + [DataTransferType.Html]: 'x', + }); + const result = getCodeData(data); + + expect(result).toEqual({ + editor: 'code-editor', + value: 'x', + inline: true, + }); + }); + + it('should return null when no code-related data', () => { + const data = createMockDataTransfer({ + [DataTransferType.Text]: 'some text', + [DataTransferType.Html]: '
some text
', + }); + expect(getCodeData(data)).toBeNull(); + }); +}); diff --git a/src/extensions/markdown/CodeBlock/handle-paste.ts b/src/extensions/markdown/CodeBlock/handle-paste.ts index 1317926f..b51702d3 100644 --- a/src/extensions/markdown/CodeBlock/handle-paste.ts +++ b/src/extensions/markdown/CodeBlock/handle-paste.ts @@ -1,52 +1,147 @@ +import type {Schema} from 'prosemirror-model'; +import type {Transaction} from 'prosemirror-state'; import dd from 'ts-dedent'; import {getLoggerFromState} from '#core'; -import {Fragment} from '#pm/model'; import type {EditorProps} from '#pm/view'; import {DataTransferType, isVSCode, tryParseVSCodeData} from 'src/utils/clipboard'; -import {CodeBlockNodeAttr} from './CodeBlockSpecs'; import {codeBlockType} from './const'; +export type CodePasteData = { + editor: string; + value: string; + inline: boolean; +}; + +type InsertCodeParams = { + tr: Transaction; + schema: Schema; + code: CodePasteData; + from: number; + to: number; + inCodeBlock: boolean; +}; + export const handlePaste: NonNullable = (view, e) => { - if (!e.clipboardData || view.state.selection.$from.parent.type.spec.code) return false; - const code = getCodeData(e.clipboardData); + const data = e.clipboardData; + if (!data) return false; + + const code = getCodeData(data); if (!code) return false; - getLoggerFromState(view.state).event({ + const {state} = view; + const {tr, schema, selection} = state; + const $from = selection.$from; + const inCodeBlock = Boolean($from.parent.type.spec.code); + + logPasteEvent(state, code, data); + + if (!code.value) { + return false; + } + + insertCode({ + tr, + schema, + code, + from: selection.from, + to: selection.to, + inCodeBlock, + }); + + view.dispatch(tr.scrollIntoView()); + e.preventDefault(); + return true; +}; + +function logPasteEvent( + state: Parameters>[0]['state'], + code: CodePasteData, + data: DataTransfer, +): void { + getLoggerFromState(state).event({ domEvent: 'paste', event: 'paste-from-code-editor', editor: code.editor, - editorMode: code.mode, empty: !code.value, - dataTypes: e.clipboardData.types, + inline: code.inline, + dataTypes: Array.from(data.types), }); +} - const {tr, schema} = view.state; - if (code.value) { - const codeBlockNode = codeBlockType(schema).create( - {[CodeBlockNodeAttr.Lang]: code.mode}, - schema.text(code.value), - ); - tr.replaceSelectionWith(codeBlockNode); +export function insertCode({tr, schema, code, from, to, inCodeBlock}: InsertCodeParams): void { + if (inCodeBlock) { + tr.insertText(code.value, from, to); + } else if (code.inline) { + insertInlineCode(tr, schema, code.value); } else { - tr.replaceWith(tr.selection.from, tr.selection.to, Fragment.empty); + insertCodeBlock(tr, schema, code.value); } - view.dispatch(tr.scrollIntoView()); - return true; -}; +} -function getCodeData(data: DataTransfer): null | {editor: string; mode?: string; value: string} { - if (data.getData(DataTransferType.Text)) { - let editor = 'unknown'; - let mode: string | undefined; +function insertInlineCode(tr: Transaction, schema: Schema, value: string): void { + const codeMarkType = schema.marks.code; + const marks = codeMarkType ? [codeMarkType.create()] : undefined; + const textNode = schema.text(value, marks); + tr.replaceSelectionWith(textNode, false); +} - if (isVSCode(data)) { - editor = 'vscode'; - mode = tryParseVSCodeData(data)?.mode; - } else return null; +function insertCodeBlock(tr: Transaction, schema: Schema, value: string): void { + const nodeType = codeBlockType(schema); + const textNode = schema.text(value); + const codeBlockNode = nodeType.create(null, textNode); + tr.replaceSelectionWith(codeBlockNode); +} + +export function getCodeData(data: DataTransfer): CodePasteData | null { + const text = data.getData(DataTransferType.Text); + if (!text) return null; + + const vscodeData = parseVSCodeData(data, text); + if (vscodeData) return vscodeData; + + const htmlData = parseHtmlCodeData(data, text); + if (htmlData) return htmlData; - return {editor, mode, value: dd(data.getData(DataTransferType.Text))}; - } return null; } + +function parseVSCodeData(data: DataTransfer, text: string): CodePasteData | null { + if (!isVSCode(data)) return null; + + tryParseVSCodeData(data); + return { + editor: 'vscode', + value: dedentIfMultiline(text), + inline: isInlineCode(text), + }; +} + +function parseHtmlCodeData(data: DataTransfer, text: string): CodePasteData | null { + const html = data.getData('text/html') || ''; + + if (!html || (!html.includes('