From d6ed2d0950c0f9a891e7af183ad3b2672f229fcd Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Thu, 23 Jul 2026 17:12:23 +0200 Subject: [PATCH] fix(Table): prefer plain text for markdown table paste --- .../markdown/Table/TablePaste.test.ts | 103 ++++++++++++++++++ .../src/extensions/markdown/Table/index.ts | 2 + .../Table/plugins/markdownTablePastePlugin.ts | 55 ++++++++++ 3 files changed, 160 insertions(+) create mode 100644 packages/editor/src/extensions/markdown/Table/TablePaste.test.ts create mode 100644 packages/editor/src/extensions/markdown/Table/plugins/markdownTablePastePlugin.ts diff --git a/packages/editor/src/extensions/markdown/Table/TablePaste.test.ts b/packages/editor/src/extensions/markdown/Table/TablePaste.test.ts new file mode 100644 index 000000000..81fcc08aa --- /dev/null +++ b/packages/editor/src/extensions/markdown/Table/TablePaste.test.ts @@ -0,0 +1,103 @@ +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 {Clipboard} from '../../behavior/Clipboard'; +import {DataTransferType} from '../../behavior/Clipboard/utils'; + +import {TableSpecs} from './TableSpecs'; +import {CellAlign, TableAttrs, TableNode} from './const'; +import {isPipedMarkdownTable, markdownTablePastePlugin} from './plugins/markdownTablePastePlugin'; + +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, plugins} = new ExtensionsManager({ + logger: new Logger2().nested({env: 'test'}), + extensions: (builder) => { + builder.use(BaseSchemaSpecs, {}).use(TableSpecs).use(Clipboard, {}); + builder.addPlugin(markdownTablePastePlugin, builder.Priority.High); + }, +}).build(); + +const {doc, p, table, thead, tbody, tr, th, td} = builders< + 'doc' | 'p' | 'table' | 'thead' | 'tbody' | 'tr' | 'th' | 'td' +>(schema, { + doc: {nodeType: BaseNode.Doc}, + p: {nodeType: BaseNode.Paragraph}, + 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('markdownTablePastePlugin', () => { + it('detects pipe-bounded text as a markdown table candidate', () => { + expect(isPipedMarkdownTable(MARKDOWN_TABLE)).toBe(true); + expect(isPipedMarkdownTable(`\n${MARKDOWN_TABLE}\n`)).toBe(true); + expect(isPipedMarkdownTable('plain text')).toBe(false); + expect(isPipedMarkdownTable(HTML_TABLE)).toBe(false); + }); + + it('prefers markdown table from text/plain over text/html', () => { + const view = createView(); + + dispatchPasteEvent(view, { + [DataTransferType.Text]: MARKDOWN_TABLE, + [DataTransferType.Html]: HTML_TABLE, + }); + + expect(view.state.doc).toMatchNode(expectedTableDoc()); + }); + + it('ignores pipe-bounded text that is not parsed as a table', () => { + const view = createView(); + + dispatchPasteEvent(view, { + [DataTransferType.Text]: '| not a table |', + [DataTransferType.Html]: '

html text

', + }); + + expect(view.state.doc).toMatchNode(doc(p('html text'))); + }); +}); + +function createView() { + return new EditorView(null, { + state: EditorState.create({schema, plugins}), + }); +} + +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/markdown/Table/index.ts b/packages/editor/src/extensions/markdown/Table/index.ts index d4f6dcc07..3e2889de6 100644 --- a/packages/editor/src/extensions/markdown/Table/index.ts +++ b/packages/editor/src/extensions/markdown/Table/index.ts @@ -7,6 +7,7 @@ import * as TableActions from './actions/tableActions'; import {moveToNextRowCommand} from './commands'; import * as TableHelpers from './helpers'; import {tableCellContextPlugin} from './plugins/TableCellContextPlugin'; +import {markdownTablePastePlugin} from './plugins/markdownTablePastePlugin'; export {TableHelpers, TableActions}; export {TableNode, TableAttrs, CellAlign as TableCellAlign} from './const'; @@ -24,6 +25,7 @@ export const Table: ExtensionAuto = (builder) => { builder.addAction('createTable', createTableAction); builder.addAction('deleteTable', () => deleteTableAction); builder.addPlugin(tableCellContextPlugin); + builder.addPlugin(markdownTablePastePlugin, builder.Priority.High); }; declare global { diff --git a/packages/editor/src/extensions/markdown/Table/plugins/markdownTablePastePlugin.ts b/packages/editor/src/extensions/markdown/Table/plugins/markdownTablePastePlugin.ts new file mode 100644 index 000000000..dc2fabab9 --- /dev/null +++ b/packages/editor/src/extensions/markdown/Table/plugins/markdownTablePastePlugin.ts @@ -0,0 +1,55 @@ +import {type ExtensionDeps, type Parser, trackTransactionMetrics} from '#core'; +import {Slice} from '#pm/model'; +import {Plugin} from '#pm/state'; +import type {EditorView} from '#pm/view'; + +import {isInsideCode} from '../../../behavior/Clipboard/code'; +import {DataTransferType} from '../../../behavior/Clipboard/utils'; +import {TableNode} from '../const'; + +export const markdownTablePastePlugin = ({textParser}: ExtensionDeps) => + new Plugin({ + props: { + handleDOMEvents: { + paste(view, event) { + const {clipboardData} = event; + if (!clipboardData || !shouldHandlePaste(view, clipboardData)) return false; + + const slice = parsePipedMarkdownTable( + clipboardData.getData(DataTransferType.Text), + textParser, + ); + if (!slice) return false; + + event.preventDefault(); + view.dispatch( + trackTransactionMetrics(view.state.tr.replaceSelection(slice), 'paste', { + clipboardDataFormat: DataTransferType.Text, + }), + ); + + return true; + }, + }, + }, + }); + +export function isPipedMarkdownTable(text: string): boolean { + const trimmed = text.trim(); + return trimmed.startsWith('|') && trimmed.endsWith('|'); +} + +function shouldHandlePaste(view: EditorView, clipboardData: DataTransfer): boolean { + return !clipboardData.types.includes(DataTransferType.Yfm) && !isInsideCode(view.state); +} + +function parsePipedMarkdownTable(text: string, parser: Parser): Slice | null { + if (!isPipedMarkdownTable(text)) return null; + + try { + const content = parser.parse(text).content; + return content.firstChild?.type.name === TableNode.Table ? new Slice(content, 0, 0) : null; + } catch { + return null; + } +}