diff --git a/packages/editor/src/extensions/behavior/Clipboard/clipboard.ts b/packages/editor/src/extensions/behavior/Clipboard/clipboard.ts index 359a72d1c..1082d450b 100644 --- a/packages/editor/src/extensions/behavior/Clipboard/clipboard.ts +++ b/packages/editor/src/extensions/behavior/Clipboard/clipboard.ts @@ -10,6 +10,7 @@ import {isNodeSelection, isTextSelection, isWholeSelection} from 'src/utils/sele import {BaseNode, pType} from '../../base/BaseSchema'; import {isInsideCode} from './code'; +import {detectMarkdown} from './markdown-detect'; import {getSelectionContent} from './selection-content'; import {trimTextSelection} from './trim-selection'; import {DataTransferType, extractTextContentFromHtml, isIosSafariShare, trimContent} from './utils'; @@ -98,6 +99,11 @@ export const clipboard = ({ return true; } + if (pasteDetectedMarkdown(view, e.clipboardData, textParser)) { + e.preventDefault(); + return true; + } + if ( !e.clipboardData.types.includes(DataTransferType.Yfm) && (data = e.clipboardData.getData(DataTransferType.Html)) @@ -216,6 +222,28 @@ export const clipboard = ({ }); }; +function pasteDetectedMarkdown( + view: EditorView, + clipboardData: DataTransfer, + textParser: Parser, +): boolean { + if (clipboardData.types.includes(DataTransferType.Yfm) || isInsideCode(view.state)) { + return false; + } + + const doc = detectMarkdown(clipboardData.getData(DataTransferType.Text), textParser); + if (!doc) return false; + + const slice = getSliceFromMarkupFragment(doc.content); + view.dispatch( + trackTransactionMetrics(view.state.tr.replaceSelection(slice), 'paste', { + clipboardDataFormat: DataTransferType.Text, + }), + ); + + return true; +} + function getSliceFromMarkupFragment(fragment: Fragment) { let start = 0; let end = 0; diff --git a/packages/editor/src/extensions/behavior/Clipboard/markdown-detect.test.ts b/packages/editor/src/extensions/behavior/Clipboard/markdown-detect.test.ts new file mode 100644 index 000000000..a60f94ffb --- /dev/null +++ b/packages/editor/src/extensions/behavior/Clipboard/markdown-detect.test.ts @@ -0,0 +1,93 @@ +import {EditorState} from 'prosemirror-state'; +import {builders} from 'prosemirror-test-builder'; +import {EditorView} from 'prosemirror-view'; + +import {dispatchPasteEvent} from '../../../../tests/dispatch-event'; +import {ExtensionsManager} from '../../../core'; +import {Logger2} from '../../../logger'; +import {BaseNode, BaseSchemaSpecs} from '../../base/specs'; +import {TableSpecs} from '../../markdown/Table/TableSpecs'; +import {CellAlign, TableAttrs, TableNode} from '../../markdown/Table/const'; +import {YfmTableSpecs} from '../../yfm/YfmTable/YfmTableSpecs'; + +import {detectMarkdown} from './markdown-detect'; +import {DataTransferType} from './utils'; + +import {Clipboard} from './index'; + +const MARKDOWN_TABLE = [ + '| Name | Role | Office | Tenure |', + '| --- | --- | --- | --- |', + '| Sergey | Frontend | Belgrade | 5 years |', + '| Anna | QA | Moscow | 3 years |', +].join('\n'); + +const HTML_TABLE = ` + + + + + + + + +
NameRoleOfficeTenure
SergeyFrontendBelgrade5 years
AnnaQAMoscow3 years
+`; + +const {schema, textParser, plugins} = new ExtensionsManager({ + logger: new Logger2().nested({env: 'test'}), + extensions: (builder) => + builder.use(BaseSchemaSpecs, {}).use(TableSpecs).use(YfmTableSpecs, {}).use(Clipboard, {}), +}).build(); + +const {doc, table, thead, tbody, tr, th, td} = builders< + 'doc' | 'table' | 'thead' | 'tbody' | 'tr' | 'th' | 'td' +>(schema, { + doc: {nodeType: BaseNode.Doc}, + table: {nodeType: TableNode.Table}, + thead: {nodeType: TableNode.Head}, + tbody: {nodeType: TableNode.Body}, + tr: {nodeType: TableNode.Row}, + th: {nodeType: TableNode.HeaderCell, [TableAttrs.CellAlign]: CellAlign.Left}, + td: {nodeType: TableNode.DataCell, [TableAttrs.CellAlign]: CellAlign.Left}, +}); + +describe('detectMarkdown', () => { + it('detects markdown table using existing parser', () => { + const markdownDoc = detectMarkdown(MARKDOWN_TABLE, textParser); + + expect(markdownDoc).toMatchNode(expectedTableDoc()); + }); + + it('ignores plain text and raw html', () => { + expect(detectMarkdown('plain text', textParser)).toBeNull(); + expect(detectMarkdown(HTML_TABLE, textParser)).toBeNull(); + }); +}); + +describe('Clipboard markdown detection', () => { + it('prefers markdown table from text/plain over text/html', () => { + const view = new EditorView(null, { + state: EditorState.create({schema, plugins}), + }); + + dispatchPasteEvent(view, { + [DataTransferType.Text]: MARKDOWN_TABLE, + [DataTransferType.Html]: HTML_TABLE, + }); + + expect(view.state.doc).toMatchNode(expectedTableDoc()); + }); +}); + +function expectedTableDoc() { + return doc( + table( + thead(tr(th('Name'), th('Role'), th('Office'), th('Tenure'))), + tbody( + tr(td('Sergey'), td('Frontend'), td('Belgrade'), td('5 years')), + tr(td('Anna'), td('QA'), td('Moscow'), td('3 years')), + ), + ), + ); +} diff --git a/packages/editor/src/extensions/behavior/Clipboard/markdown-detect.ts b/packages/editor/src/extensions/behavior/Clipboard/markdown-detect.ts new file mode 100644 index 000000000..0793f8318 --- /dev/null +++ b/packages/editor/src/extensions/behavior/Clipboard/markdown-detect.ts @@ -0,0 +1,25 @@ +import type {Parser} from '#core'; +import type {Node as PmNode} from '#pm/model'; +import {isTableNode} from 'src/table-utils'; + +export function detectMarkdown(text: string, parser: Parser): PmNode | null { + if (!text.trim()) return null; + + try { + const doc = parser.parse(text); + return hasTableNode(doc) ? doc : null; + } catch { + return null; + } +} + +function hasTableNode(node: PmNode): boolean { + let found = false; + + node.descendants((child) => { + if (isTableNode(child)) found = true; + return !found; + }); + + return found; +}