Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/extensions/markdown/CodeBlock/CodeBlock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +25,21 @@ const {doc, p, cb} = builders<'doc' | 'p' | 'cb'>(schema, {

const {same, parse} = createMarkupChecker({parser, serializer});

function createMockDataTransfer(data: Record<string, string>): 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(
Expand Down Expand Up @@ -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 <code> tag', () => {
const data = createMockDataTransfer({
[DataTransferType.Text]: 'x',
[DataTransferType.Html]: '<code>x</code>',
});
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]: '<div>some text</div>',
});
expect(getCodeData(data)).toBeNull();
});
});
151 changes: 123 additions & 28 deletions src/extensions/markdown/CodeBlock/handle-paste.ts
Original file line number Diff line number Diff line change
@@ -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<EditorProps['handlePaste']> = (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<NonNullable<EditorProps['handlePaste']>>[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('<pre') && !html.includes('<code'))) {
return null;
}

const inline = isInlineCodeFromHtml(html, text);

return {
editor: 'code-editor',
value: inline ? text : dedentIfMultiline(text),
inline,
};
}

export function isInlineCode(text: string): boolean {
return !text.includes('\n');
}

export function isInlineCodeFromHtml(html: string, text: string): boolean {
return html.includes('<code') && !html.includes('<pre') && isInlineCode(text);
}

function dedentIfMultiline(value: string): string {
return value.includes('\n') ? dd(value) : value;
}
Loading